Питання 5 · Розділ 12

Коли використовувати StringBuilder vs 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. При відомих розмірах завжди задавайте 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. Що відбувається при конкатенації рядків через оператор +]]