Когда использовать StringBuilder, а когда StringBuffer
Классы StringBuilder и StringBuffer предназначены для создания и модификации изменяемых (mutable) последовательностей символов. В отличие от неизменяемого класса String, где люб...
🟢 Junior Level
Классы StringBuilder и StringBuffer предназначены для создания и модификации изменяемых (mutable) последовательностей символов. В отличие от неизменяемого класса String, где любая операция конкатенации или модификации создает новый объект в куче (Heap), StringBuilder и StringBuffer изменяют символы непосредственно внутри собственного внутреннего буфера, минимизируя аллокации памяти и нагрузку на сборщик мусора (Garbage Collector).
Суть в 30 секундах
Главное и принципиальное различие между ними — потокобезопасность (thread safety):
StringBuilder(появился в Java 5): Непотокобезопасен (not thread-safe). Ни один из его методов не синхронизирован. Работает максимально быстро. Это выбор по умолчанию в 99.9% задач, когда модификация строки происходит внутри одного потока (в локальных переменных методов).StringBuffer(существует с Java 1.0): Потокобезопасен (thread-safe). Почти все публичные методы объявлены со спецификаторомsynchronized. Обладает накладными расходами на захват/освобождение мониторов и барьеры памяти. Используется исключительно в редких устаревших архитектурах, где один общий экземпляр буфера одновременно модифицируется несколькими параллельными потоками.
// 1. Использование StringBuilder (в 99.9% случаев в прикладном коде)
public String buildReport(List<String> items) {
// Выделяем начальную емкость, если примерный размер известен
StringBuilder sb = new StringBuilder(items.size() * 32);
sb.append("HEADER\n");
for (String item : items) {
sb.append("- ").append(item).append("\n");
}
return sb.toString();
}
// 2. Использование StringBuffer (устаревший сценарий разделяемого буфера)
public class ThreadSafeLogger {
// Разделяемый между потоками экземпляр
private final StringBuffer sharedBuffer = new StringBuffer();
public void log(String threadName, String message) {
// Вызовы append потокобезопасны на уровне каждого метода
sharedBuffer.append("[").append(threadName).append("] ").append(message).append("\n");
}
}
Простое правило выбора
- Модифицируете строку внутри метода, цикла или в рамках одного потока? 👉 Всегда
StringBuilder. - Нужен разделяемый между потоками буфер? 👉 Даже в этом случае в современном многопоточном коде обычно предпочитают
StringBuilderвнутриThreadLocal, либо сборку сообщений в локальныхStringBuilderс последующей отправкой в потокобезопасную очередь (BlockingQueue). ПоэтомуStringBufferсегодня считается legacy-классом.
🟡 Middle Level
Внутреннее устройство: иерархия классов и буфер
И StringBuilder, и StringBuffer являются прямыми наследниками абстрактного класса java.lang.AbstractStringBuilder и реализуют интерфейсы CharSequence, Appendable и Serializable (а также Comparable начиная с Java 11).
Внутри AbstractStringBuilder состояние задается двумя ключевыми полями:
abstract sealed class AbstractStringBuilder implements Appendable, CharSequence {
// В Java 9+ (Compact Strings) массив байтов вместо char[]
byte[] value;
// Кодировка: LATIN1 (0) или UTF16 (1)
byte coder;
// Количество фактически записанных символов (не путать с ёмкостью массива value!)
int count;
}
До Java 9 массив был char[] value. Начиная с Java 9 (JEP 254: Compact Strings) используется массив byte[] value и поле coder. Если все добавленные символы укладываются в Latin-1 (ISO-8859-1, 1 байт на символ), coder = 0, и массив байтов равен размеру емкости. Если встречается хотя бы один символ вне Latin-1 (например, кириллица или эмодзи), буфер «раздувается» (inflate) до кодировки UTF-16 (coder = 1), где каждый символ занимает ровно 2 байта.
Механика расширения емкости (Capacity Expansion)
При создании буфера конструктором по умолчанию new StringBuilder() резервируется емкость (capacity) 16 символов:
public StringBuilder() {
super(16);
}
Если текущего размера массива value недостаточно для добавления новой подстроки, вызывается внутренняя процедура расширения ensureCapacityInternal().
Алгоритм расчета новой емкости:
\(\text{newCapacity} = (\text{oldCapacity} \ll 1) + 2 = \text{oldCapacity} \times 2 + 2\)
Если полученной новой емкости все еще недостаточно для вмещения новой строки, то в качестве емкости берется минимально необходимый размер (minimumCapacity = count + str.length()).
Шаги расширения при дефолтной емкости 16:
16 --> (16 * 2) + 2 = 34 --> (34 * 2) + 2 = 70 --> (70 * 2) + 2 = 142 ...
Каждое расширение буфера влечет за собой:
- Аллокацию нового массива в куче (
new byte[newCapacity]). - Копирование данных через нативный вызов
System.arraycopy(). - Превращение старого массива в мусор для GC.
Сравнительная таблица
| Параметр | StringBuilder | StringBuffer |
| :— | :— | :— |
| Появился в | Java 5 (J2SE 5.0) | Java 1.0 |
| Потокобезопасность | ❌ Нет | ✅ Да (через synchronized) |
| Накладные расходы | Минимальные, прямой доступ к полям | Захват/освобождение мониторов объекта, барьеры памяти |
| Производительность (1 поток) | В 1.5–2.5 раза быстрее StringBuffer | Медленнее из-за мониторов (даже при JIT Lock Elision) |
| Основная область применения | Любая однопоточная модификация строк | Устаревший многопоточный код, интеграция со старыми API |
| Синхронизация методов | Методы не синхронизированы | Все публичные мутирующие методы помечены synchronized |
| Кэш последнего toString() | ❌ Отсутствует | ✅ Поле transient String toStringCache |
Антипаттерны и типичные ошибки
- Игнорирование начальной емкости (
initialCapacity):// ПЛОХО: для строки в 10 000 символов массив переаллоцируется и скопируется ~10 раз StringBuilder sb = new StringBuilder(); // ХОРОШО: одна аллокация, ноль копирований StringBuilder sb = new StringBuilder(estimatedSize); - Конкатенация строк через
+внутриappend():// ПЛОХО: компилятор создаст промежуточный StringBuilder для (a + " : " + b) sb.append(a + " : " + b); // ХОРОШО: цепочечные вызовы append без промежуточных объектов sb.append(a).append(" : ").append(b); - Создание нового
StringBuilderна каждой итерации цикла:// ПЛОХО: O(N) аллокаций в цикле for (Item item : items) { result += item.getName(); // генерирует скрытые StringBuilder на каждой итерации }
🔴 Senior Level
Глубокие различия в реализации методов
Рассмотрим разницу в реализации метода append в HotSpot:
// StringBuilder: прямой вызов реализации базового класса без барьеров
@Override
@IntrinsicCandidate
public StringBuilder append(String str) {
super.append(str);
return this;
}
// StringBuffer: захват монитора текущего экземпляра (this) + сброс кэша
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
В StringBuffer:
- Синхронизация монитором: Вызов
synchronizedгенерирует байткод-инструкцииmonitorenterиmonitorexit. Даже при отсутствии конкуренции (contention) поток выполняет атомарную инструкцию (например,LOCK CMPXCHGна x86) для установки заголовка Mark Word в заголовке объекта. - Кэширование
toStringCache:StringBufferимеет полеprivate transient String toStringCache. При вызовеtoString()создается строка, которая сохраняется в этом поле. Если буфер не модифицировался, повторныйtoString()возвращает кэшированную строку без создания копии массива! Но любая мутация (append,insert,delete) сбрасываетtoStringCache = null. - Метод
toString()вStringBuilder: Всегда создает новый экземплярString, выполняя копирование диапазона массива байт черезArrays.copyOfRange(value, 0, count). Прямая передача массиваvalueв конструкторStringневозможна из соображений безопасности (иначе последующая модификацияStringBuilderизменила бы содержимое «иммутабельного»String).
Оптимизации JIT-компилятора: Escape Analysis и Lock Elision
HotSpot JVM (C2 компилятор) умеет анализировать область видимости объектов с помощью Escape Analysis (анализ побега):
- NoEscape: Объект не покидает пределы метода и не сохраняется в поля других объектов.
- ArgEscape: Объект передается в качестве аргумента методов, но не утекает в глобальное состояние.
- GlobalEscape: Объект сохраняется в статическое поле, поле кучи или возвращается из метода.
Если C2 доказывает, что экземпляр StringBuffer имеет статус NoEscape (например, создан и используется локально внутри одного метода):
public String generateLocal() {
StringBuffer sb = new StringBuffer(); // NoEscape
sb.append("A").append("B");
return sb.toString();
}
Включается оптимизация Lock Elision (устранение блокировок): JIT-компилятор полностью вырезает инструкции monitorenter/monitorexit из скомпилированного машинного кода.
Почему тогда StringBuffer все равно медленнее StringBuilder?
- Escape Analysis требует времени компиляции и бюджета компилятора C2. На этапе интерпретации и компиляции C1 блокировки сохраняются.
- Сложный поток управления, глубокий инлайнинг или передача буфера в сторонний метод превращают объект в
ArgEscape/GlobalEscape, при которых Lock Elision отключается. - Наличие поля
toStringCacheвStringBufferтребует постоянных операций записи (volatile/safepoint-чувствительных инвалидаций), что мешает регистрационным оптимизациям.
Влияние удаления Biased Locking (JEP 374 в Java 15)
Исторически HotSpot JVM использовала Biased Locking (смещенные блокировки). Если монитор захватывался только одним потоком, заголовок объекта (Mark Word) «смещался» в сторону этого потока идентификатором ThreadID. Последующие захваты монитора тем же потоком обходились вообще без атомарных процессорных инструкций (простая проверка равенства ThreadID).
Однако в Java 15 (JEP 374) Biased Locking был отключен по умолчанию, а затем полностью удален из JVM:
- Причина: поддержка смещенных блокировок требовала сложнейших пауз Safepoint для отзыва блокировки (Revocation) при смене потока, что сильно деградировало latency современных сборщиков мусора (ZGC, Shenandoah).
- Следствие для
StringBuffer: В современных версиях Java (17, 21+) накладные расходы на вызовыsynchronizedметодов в незаблокированном состоянии (Thin Lockс CAS) возросли по сравнению с эпохой Biased Locking. Это делает использованиеStringBufferв однопоточных сценариях еще более нерациональным.
Катастрофические последствия использования StringBuilder в многопоточной среде
Если StringBuilder случайно разделяется между несколькими потоками без внешней синхронизации, происходят следующие критические сбои:
- Потерянные обновления (Lost Updates): Операция
count += lenне атомарна (read-modify-write). Два потока параллельно читают один и тот жеcount, записывают данные в одну и ту же позицию массиваvalue, и один перезаписывает результат другого. ArrayIndexOutOfBoundsException: Поток А проверяет, что емкости хватает (ensureCapacityInternal), после чего планировщик переключает контекст на поток Б. Поток Б записывает данные и увеличиваетcount. Поток А возобновляется и пытается записать свои байты по устаревшему смещениюcount, выходя за пределы физического массиваvalue.- Тихое повреждение данных (Silent Data Corruption) и «дыры» из нулей: В массив байтов символы могут записаться со смещением, оставив между ними неинициализированные нулевые байты
\0. Итоговая строка превращается в испорченный мусор без каких-либо исключений в рантайме.
4 Tricky Questions
1. Что произойдет, если несколько потоков одновременно вызовут sb.append() у одного экземпляра StringBuilder? Будет ли гарантированно выброшено исключение?
Ответ: Гарантии выброса исключения нет. Поведение недетерминировано и зависит от таймингов планировщика ОС:
- Сценарий 1 (Исключение): Будет выброшено
ArrayIndexOutOfBoundsExceptionилиNegativeArraySizeException. Это происходит, когда потоки одновременно проходят проверку емкостиensureCapacityInternal(), решают, что расширение не требуется, но при последующей записи один из потоков сдвигаетcountза пределы физической длины массива. - Сценарий 2 (Тихое повреждение — Silent Corruption): Исключение не выбрасывается, но данные безвозвратно портятся. Так как операция увеличения счетчика
countсостоит из нескольких процессорных инструкций, потоки перезаписывают ячейки памяти друг друга. В результате часть символов теряется (lost updates), а длина итоговой строкиsb.length()оказывается существенно меньше суммы длин добавленных строк. Более того, в строке могут образоваться неинициализированные байты\0.
2. Гарантирует ли StringBuffer потокобезопасность составных операций (Compound Operations, check-then-act) без внешней синхронизации?
Ответ:
Нет, не гарантирует. StringBuffer гарантирует атомарность и потокобезопасность только отдельных вызовов собственных методов (например, одиночного append() или одиночного delete()).
Если над StringBuffer выполняется составная логика вида «проверить условие — затем выполнить действие» (check-then-act):
// ПОТОКОНЕБЕЗОПАСНО, несмотря на использование StringBuffer!
if (sharedBuffer.length() >= 5) {
// Между length() и delete() другой поток может очистить sharedBuffer!
// Это приведет к StringIndexOutOfBoundsException
sharedBuffer.delete(0, 5);
}
Для обеспечения потокобезопасности составных операций вызывающий код обязан явно синхронизироваться на мониторе самого экземпляра StringBuffer:
synchronized (sharedBuffer) {
if (sharedBuffer.length() >= 5) {
sharedBuffer.delete(0, 5);
}
}
3. Почему метод toString() в StringBuilder всегда копирует массив, а не возвращает внутренний byte[] value напрямую через защищенный конструктор String?
Ответ:
Из-за фундаментального требования иммутабельности класса String.
Если бы StringBuilder.toString() передал ссылку на свой внутренний массив byte[] value напрямую в создаваемый объект String, то любой последующий вызов sb.append() или sb.setCharAt() изменил бы содержимое уже возвращенного объекта String. Это разрушило бы гарантии неизменяемости строк в Java, привело бы к уязвимостям безопасности (подмена URL, прав доступа, ключей в HashMap) и нарушило бы модель памяти Java Memory Model (JMM).
Поэтому StringBuilder.toString() вызывает Arrays.copyOfRange(value, 0, count), полностью изолируя внутренний буфер от созданной строки.
4. В чем разница между вызовами sb.setLength(0) и созданием нового new StringBuilder() при повторном использовании буфера?
Ответ:
sb.setLength(0): Не аллоцирует новый объектStringBuilderи не освобождает уже выделенный массивbyte[] value. Он просто сбрасывает счетчикcount = 0(и обнуляет неиспользуемые ячейки при необходимости). Это позволяет переиспользовать уже выделенную память в высоконагруженных циклах без давления на GC.- Подводный камень: Если в буфер один раз была записана огромная строка (например, 10 МБ), этот массив останется в памяти навсегда, удерживая память кучи (Memory Leak / Retention).
new StringBuilder(): Создает абсолютно новый объект со стандартной начальной емкостью (16 символов). Старый массив и объект отдаются сборщику мусора. Это защищает от утечек памяти при случайных аномально больших строках, но генерирует дополнительную нагрузку на Minor GC.
🎯 Шпаргалка для интервью
30-секундный ответ
«
StringBuilderиStringBufferслужат для создания изменяемых строк на базе динамически расширяемого массива байт. Главное отличие: методыStringBufferсинхронизированы ключевым словомsynchronized, что делает его потокобезопасным, но медленным.StringBuilderне синхронизирован, работает значительно быстрее и является выбором по умолчанию в 99.9% задач.StringBuffer— устаревший класс из Java 1.0, в современном коде для разделения работы со строками между потоками используютThreadLocalили потокобезопасные очереди».
Ключевые тезисы для Senior-интервью
- Compact Strings (Java 9+): Оба класса используют
byte[] value+byte coder(0 = Latin-1, 1 = UTF-16) вместо старогоchar[]. - Формула расширения: $(\text{oldCapacity} \ll 1) + 2$. Если дефолтный размер 16, рост идет: 16 $\to$ 34 $\to$ 70 $\to$ 142.
- Lock Elision & Escape Analysis: C2 JIT может устранить синхронизацию у
StringBuffer, если объектNoEscape, но это не повод использовать его в однопоточном коде. - Удаление Biased Locking в Java 15: Сделало неконкурентные вызовы
synchronizedболее дорогими, увеличив отставаниеStringBufferотStringBuilder. - Метод
toString():StringBuilderвсегда копирует данные черезArrays.copyOfRange(), защищая иммутабельностьString.StringBufferимеет оптимизациюtoStringCache.
Красные флаги (Чего говорить категорически нельзя)
- ❌ Утверждать, что
StringBuffer«рекомендуется использовать во всех многопоточных приложениях» (разделять строковый буфер между потоками — плохой архитектурный паттерн). - ❌ Заявлять, что
StringBufferзащищает от любых состояний гонки (гонки возникают на уровне составных операций check-then-act). - ❌ Говорить, что
StringBuilderвнутри хранитchar[]в современных версиях Java (начиная с Java 9 используетсяbyte[]с компактными строками). - ❌ Считать, что
new StringBuilder()без параметров достаточен для сборки мегабайтных XML/JSON документов (будут десятки лишних переаллокаций и копирований массивов).