StringBuilder vs StringBuffer: что выбрать и почему это важно?
Если вы когда-нибудь писали на Java код, который активно работает со строками (например, собирает SQL-запрос, формирует JSON или просто склеивает десятки кусочков текста), то наверняка сталкивались с дилеммой: StringBuilder или StringBuffer? На Stack Overflow этот вопрос собрал сотни тысяч просмотров, и не зря. На первый взгляд классы похожи как близнецы — оба расширяют AbstractStringBuilder, оба используют изменяемый массив символов, оба умеют append(), insert(), delete(). Но дьявол, как всегда, кроется в деталях.
В этой статье мы не просто перечислим отличия. Мы разберём их на реальных примерах, заглянем под капот JVM и ответим на вопросы, которые вам обязательно зададут на собеседовании. Поехали!
1. Главное отличие: синхронизация
Это самый важный пункт, ради которого, собственно, и заварили всю кашу. StringBuffer — потокобезопасный (thread-safe). Почти все его публичные методы синхронизированы с помощью ключевого слова synchronized. StringBuilder — не синхронизирован. Всё.
Давайте посмотрим на код (упрощённая версия из реального JDK):
// StringBuffer (Java 17)
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
// StringBuilder
public StringBuilder append(String str) {
super.append(str);
return this;
}
Одна строчка synchronized — и вся разница. Но эта строчка меняет всё: производительность, область применения, даже архитектуру приложения.
Когда использовать StringBuffer?
- Вы пишете многопоточное приложение.
- Один и тот же экземпляр
StringBufferиспользуется из нескольких потоков без внешней синхронизации. - Пример: логгер, который собирает сообщения из разных потоков в один буфер.
Когда использовать StringBuilder?
- Однопоточный код (90% случаев).
- Локальные переменные внутри метода.
- Циклы с конкатенацией строк (компилятор сам предложит StringBuilder).
2. Производительность: кто быстрее?
Здесь всё предсказуемо: StringBuilder быстрее StringBuffer. Насколько? Зависит от нагрузки, но в среднем в 2-5 раз. Давайте проверим на простом бенчмарке.
public class PerformanceTest {
private static final int ITERATIONS = 10_000_000;
public static void main(String[] args) {
// StringBuilder
long start = System.nanoTime();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < ITERATIONS; i++) {
sb.append("a");
}
long sbTime = System.nanoTime() - start;
// StringBuffer
start = System.nanoTime();
StringBuffer sbf = new StringBuffer();
for (int i = 0; i < ITERATIONS; i++) {
sbf.append("a");
}
long sbfTime = System.nanoTime() - start;
System.out.println("StringBuilder: " + sbTime / 1_000_000 + " ms");
System.out.println("StringBuffer: " + sbfTime / 1_000_000 + " ms");
System.out.println("Buffer медленнее в " + (sbfTime / (double) sbTime) + " раз");
}
}
На моём ноутбуке (Intel i7, 16GB RAM, Java 17) результат стабильный: StringBuilder ~180 ms, StringBuffer ~620 ms. Разница почти в 3.5 раза. А если добавить реальную работу (парсинг, форматирование), разрыв может увеличиться. Почему? Потому что каждый вызов synchronized — это проверка монитора, захват блокировки и её освобождение. В однопоточном коде это чистая потеря времени.
3. toStringCache: скрытая оптимизация StringBuffer
Мало кто знает, но у StringBuffer есть ещё одно отличие — поле toStringCache:
// StringBuffer (Java 17)
private transient char[] toStringCache;
@Override
public synchronized String toString() {
if (toStringCache == null) {
toStringCache = Arrays.copyOfRange(value, 0, count);
}
return new String(toStringCache, true);
}
Что это даёт? При первом вызове toString() создаётся копия массива символов и кешируется. При последующих вызовах (пока буфер не изменится) мы просто возвращаем уже созданную строку. Это ускоряет повторные вызовы, но только в многопоточной среде (где буфер редко меняется после инициализации). В StringBuilder такого нет — каждый вызов toString() создаёт новую копию массива.
На практике это редко имеет значение, но на собеседовании может быть плюсом, если вы упомянете этот нюанс.
4. История и наследование
StringBuffer появился в Java 1.0 (1996 год). Он был единственным изменямым строковым классом. StringBuilder добавили только в Java 5 (2004 год) как лёгкую замену для однопоточных сценариев. Оба класса наследуются от AbstractStringBuilder, но не являются частью одной иерархии с String (String — неизменяемый, final класс).
Интересный факт: в Java 5-8 компилятор использовал StringBuffer для конкатенации строк в циклах. Начиная с Java 9, он переключился на StringBuilder (через invokedynamic). Так что если вы пишете str1 + str2 внутри цикла, компилятор сам создаст StringBuilder. Но только если не используете многопоточность.
5. Частые вопросы с собеседований (и Stack Overflow)
Вопрос 1: Можно ли использовать StringBuilder в многопоточном коде?
Можно, но с осторожностью. Если вы синхронизируете доступ к нему внешним блоком synchronized или используете ReentrantLock, то да. Но зачем? Проще взять StringBuffer или вообще StringWriter. Если забудете синхронизацию — получите race condition: один поток пишет, другой читает, третий удаляет — итог: ArrayIndexOutOfBoundsException или мусор в буфере.
Вопрос 2: Какой класс быстрее для больших строк?
StringBuilder. Всегда. Исключение — если вы пишете в 10 потоков в один буфер. Тогда StringBuffer будет быстрее, чем StringBuilder с внешней синхронизацией, потому что его синхронизация тоньше (блокировка на уровне метода).
Вопрос 3: Почему StringBuffer не устарел?
Он не deprecated (не помечен как устаревший). Его продолжают использовать в легаси-коде, в фреймворках (например, старые версии Log4j) и в учебных целях