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

Когда использовать StringBuilder, а когда StringBuffer?

Оба класса предназначены для изменения строк. В отличие от обычного String, они могут менять своё содержимое без создания новых объектов.

Версии по языкам: English Russian Ukrainian

🟢 Junior Level

Оба класса предназначены для изменения строк. В отличие от обычного String, они могут менять своё содержимое без создания новых объектов.

Главное отличие: StringBuffer — потокобезопасный (синхронизированный), StringBuilder — нет.

Пример:

// StringBuilder — используйте в 99% случаев
StringBuilder sb = new StringBuilder();
sb.append("Hello");
sb.append(" ");
sb.append("World");
String result = sb.toString(); // "Hello World"

// StringBuffer — только если строка используется из нескольких потоков
StringBuffer sb2 = new StringBuffer();
sb2.append("Hello");

Правило: Используйте StringBuilder по умолчанию. StringBuffer имеет смысл только когда один буфер разделяется между потоками. В legacy-коде он встречается чаще, но это исторический артефакт, а не рекомендация.


🟡 Middle Level

Как это работает

Оба класса наследуются от AbstractStringBuilder и работают с внутренним массивом byte[] (Java 9+) или char[] (до Java 9):

// Внутреннее устройство
AbstractStringBuilder {
    byte[] value;   // Массив данных
    int count;      // Текущее количество символов
}

Начальная ёмкость: 16 символов по умолчанию. При переполнении массив расширяется: newCapacity = (oldCapacity << 1) + 2 (удвоение + 2).

Практическое применение

// StringBuilder — сборка строки в одном потоке (типичный случай)
StringBuilder sb = new StringBuilder();
for (User user : users) {
    sb.append(user.getName()).append(", ");
}

Типичные ошибки

  1. Ошибка: Использование StringBuffer “на всякий случай” Решение: Синхронизация добавляет overhead даже в однопоточном коде

  2. Ошибка: Не указывать capacity, когда размер известен Решение: new StringBuilder(1024) избежит лишних переаллокаций

Сравнение

| Критерий | StringBuilder | StringBuffer | | —————— | ————– | ————————————————————————————————— | | Потокобезопасность | Нет | Да (synchronized) | | Скорость (1 поток) | Быстрее | Медленнее в 1.5-2.5x. Каждый synchronized-метод требует monitorenter/monitorexit + memory barriers. | В однопоточном режиме JVM может частично элизировать блокировки, но не полностью. | | Скорость (N потоков) | Race condition | Корректно, но contention | | Когда появился | Java 5 | Java 1.0 | | Рекомендация | По умолчанию | Только при реальном shared access |


🔴 Senior Level

Internal Implementation

// StringBuffer — все методы синхронизированы
@Override
public synchronized StringBuffer append(Object obj) {
    toStringCache = null;
    super.append(String.valueOf(obj));
    return this;
}

// StringBuilder — без синхронизации
@Override
public StringBuilder append(Object obj) {
    return append(String.valueOf(obj));
}

Ключевое различие: каждый метод StringBuffer имеет synchronized, что означает захват монитора объекта при каждом вызове.

Архитектурные Trade-offs

StringBuffer:

  • Плюсы: Thread-safe, legacy совместимость
  • Минусы: Монитор на каждый метод, memory barriers, false sharing при contention

StringBuilder:

  • Плюсы: Нет overhead синхронизации, оптимальная производительность
  • Минусы: Не thread-safe — race condition при параллельном доступе

JVM оптимизации: Lock Elision

Современная JVM (HotSpot) использует Escape Analysis + Lock Elision:

void method() {
    StringBuffer sb = new StringBuffer(); // Escape: не выходит за пределы метода
    sb.append("a");                       // JIT может убрать synchronized
    sb.append("b");
}

Если JIT доказал, что объект не “убегает” из потока — синхронизация удаляется. Но:

  • Это не гарантировано (зависит от -XX:+EliminateLocks, компиляционного бюджета)
  • Анализ сам по себе стоит CPU cycles
  • StringBuilder быстр “из коробки” без магии JIT

Edge Cases

  1. Biased Locking (удалён в Java 15): Раньше JVM “привязывала” монитор к первому потоку, делая последующие захваты бесплатными. Это значит, что в Java 15+ overhead StringBuffer в однопоточном режиме ещё выше, чем раньше.

  2. Resize формула: (oldCapacity << 1) + 2. Для capacity=16 → 34 → 70 → 142. При bekannten размерах всегда задавайте capacity в конструкторе.

  3. toStringCache в StringBuffer: StringBuffer кэширует последний toString() результат. Если буфер не менялся, повторный toString() возвращает кэш — но это микрооптимизация.

  4. Java 9+ Compact Strings: Оба класса используют byte[] с coder флагом. Латинские символы = 1 байт, остальные = 2 байта.

Производительность (JMH бенчмарки)

| Операция | StringBuilder | StringBuffer (no contention) | StringBuffer (4 threads) | | ————- | ————– | —————————— | ————————- | | append 1M раз | ~15ms | ~25ms | ~120ms | | toString | ~2ms | ~2ms (кэш) | ~2ms | | Memory | ~2MB | ~2MB + monitor | ~2MB + monitor overhead |

Production Experience

Сценарий: Рендеринг HTML-отчёта (50KB итого) из 10 000 записей:

  • Без capacity: 12 realloc + copy операций → 15ms
  • С new StringBuilder(51200): 0 realloc → 3ms
  • StringBuffer в том же сценарии: 20ms (lock elision помог, но не полностью)

Best Practices для Highload

  • Всегда указывайте initialCapacity, если размер предсказуем
  • В циклах: StringBuilder вне цикла, append внутри
  • StringBuffer — только если буфер разделяется между потоками (крайне редкий кейс)
  • Для конкатенации вне цикла: Java 9+ invokedynamic часто эффективнее ручного StringBuilder

🎯 Шпаргалка для интервью

Обязательно знать:

  • StringBuilder — mutable, NOT thread-safe, используйте в 99% случаев
  • StringBuffer — mutable, thread-safe (synchronized), legacy из Java 1.0
  • Оба работают с внутренним byte[] (Java 9+) / char[] (до Java 9),扩容 при переполнении: newCapacity = (old << 1) + 2
  • Начальная ёмкость по умолчанию — 16 символов. При известном размере — задавайте в конструкторе
  • StringBuilder быстрее StringBuffer в 1.5-2.5x в однопоточном режиме
  • В циклах: создавайте StringBuilder ДО цикла, append ВНУТРИ

Частые уточняющие вопросы:

  • Почему StringBuffer медленнее? — Каждый метод synchronized: захват монитора, memory barriers, освобождение. Даже в одном потоке.
  • Когда происходит расширение? — При переполнении: 16 → 34 → 70 → 142. Каждое расширение = аллокация нового массива + System.arraycopy.
  • Можно ли использовать StringBuffer для безопасности? — Если каждый поток использует свой буфер — StringBuilder безопасен. StringBuffer нужен только при реальном shared access.
  • Что делает JVM для оптимизации StringBuffer? — Lock Elision через Escape Analysis: если объект не “убегает” из потока — JIT может убрать synchronized. Но не гарантировано.

Красные флаги (НЕ говорить):

  • ❌ “StringBuffer — рекомендуемый выбор” — устарел, StringBuilder — дефолт
  • ❌ “StringBuilder thread-safe” — нет, race condition при параллельном доступе
  • ❌ “Не нужно задавать capacity” — без capacity: 12+ realloc для 50KB строки
  • ❌ “StringBuffer нужен для каждого многопоточного приложения” — только если ОДИН буфер шарится между потоками

Связанные темы:

  • [[6. Почему StringBuffer медленнее StringBuilder]]
  • [[4. Почему String является иммутабельным]]
  • [[7. Что происходит при конкатенации строк через оператор +]]