🔤 Раздел 12 · Вопрос #5

Когда использовать 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 ...

Каждое расширение буфера влечет за собой:

  1. Аллокацию нового массива в куче (new byte[newCapacity]).
  2. Копирование данных через нативный вызов System.arraycopy().
  3. Превращение старого массива в мусор для 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 |

Антипаттерны и типичные ошибки

  1. Игнорирование начальной емкости (initialCapacity):
    // ПЛОХО: для строки в 10 000 символов массив переаллоцируется и скопируется ~10 раз
    StringBuilder sb = new StringBuilder(); 
       
    // ХОРОШО: одна аллокация, ноль копирований
    StringBuilder sb = new StringBuilder(estimatedSize);
    
  2. Конкатенация строк через + внутри append():
    // ПЛОХО: компилятор создаст промежуточный StringBuilder для (a + " : " + b)
    sb.append(a + " : " + b); 
       
    // ХОРОШО: цепочечные вызовы append без промежуточных объектов
    sb.append(a).append(" : ").append(b);
    
  3. Создание нового 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:

  1. Синхронизация монитором: Вызов synchronized генерирует байткод-инструкции monitorenter и monitorexit. Даже при отсутствии конкуренции (contention) поток выполняет атомарную инструкцию (например, LOCK CMPXCHG на x86) для установки заголовка Mark Word в заголовке объекта.
  2. Кэширование toStringCache: StringBuffer имеет поле private transient String toStringCache. При вызове toString() создается строка, которая сохраняется в этом поле. Если буфер не модифицировался, повторный toString() возвращает кэшированную строку без создания копии массива! Но любая мутация (append, insert, delete) сбрасывает toStringCache = null.
  3. Метод toString() в StringBuilder: Всегда создает новый экземпляр String, выполняя копирование диапазона массива байт через Arrays.copyOfRange(value, 0, count). Прямая передача массива value в конструктор String невозможна из соображений безопасности (иначе последующая модификация StringBuilder изменила бы содержимое «иммутабельного» String).

Оптимизации JIT-компилятора: Escape Analysis и Lock Elision

HotSpot JVM (C2 компилятор) умеет анализировать область видимости объектов с помощью Escape Analysis (анализ побега):

  1. NoEscape: Объект не покидает пределы метода и не сохраняется в поля других объектов.
  2. ArgEscape: Объект передается в качестве аргумента методов, но не утекает в глобальное состояние.
  3. 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 случайно разделяется между несколькими потоками без внешней синхронизации, происходят следующие критические сбои:

  1. Потерянные обновления (Lost Updates): Операция count += len не атомарна (read-modify-write). Два потока параллельно читают один и тот же count, записывают данные в одну и ту же позицию массива value, и один перезаписывает результат другого.
  2. ArrayIndexOutOfBoundsException: Поток А проверяет, что емкости хватает (ensureCapacityInternal), после чего планировщик переключает контекст на поток Б. Поток Б записывает данные и увеличивает count. Поток А возобновляется и пытается записать свои байты по устаревшему смещению count, выходя за пределы физического массива value.
  3. Тихое повреждение данных (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-интервью

  1. Compact Strings (Java 9+): Оба класса используют byte[] value + byte coder (0 = Latin-1, 1 = UTF-16) вместо старого char[].
  2. Формула расширения: $(\text{oldCapacity} \ll 1) + 2$. Если дефолтный размер 16, рост идет: 16 $\to$ 34 $\to$ 70 $\to$ 142.
  3. Lock Elision & Escape Analysis: C2 JIT может устранить синхронизацию у StringBuffer, если объект NoEscape, но это не повод использовать его в однопоточном коде.
  4. Удаление Biased Locking в Java 15: Сделало неконкурентные вызовы synchronized более дорогими, увеличив отставание StringBuffer от StringBuilder.
  5. Метод toString(): StringBuilder всегда копирует данные через Arrays.copyOfRange(), защищая иммутабельность String. StringBuffer имеет оптимизацию toStringCache.

Красные флаги (Чего говорить категорически нельзя)

  • ❌ Утверждать, что StringBuffer «рекомендуется использовать во всех многопоточных приложениях» (разделять строковый буфер между потоками — плохой архитектурный паттерн).
  • ❌ Заявлять, что StringBuffer защищает от любых состояний гонки (гонки возникают на уровне составных операций check-then-act).
  • ❌ Говорить, что StringBuilder внутри хранит char[] в современных версиях Java (начиная с Java 9 используется byte[] с компактными строками).
  • ❌ Считать, что new StringBuilder() без параметров достаточен для сборки мегабайтных XML/JSON документов (будут десятки лишних переаллокаций и копирований массивов).

Связанные вопросы