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

Чому StringBuffer повільніший за StringBuilder?

StringBuffer повільніший тому, що кожен його метод позначений ключовим словом synchronized. Це означає, що перед виконанням будь-якої операції (навіть простого append) Java пови...

Мовні версії: English Russian Ukrainian

🟢 Junior Level

StringBuffer повільніший тому, що кожен його метод позначений ключовим словом synchronized. Це означає, що перед виконанням будь-якої операції (навіть простого append) Java повинна захопити “монітор” (блокування) об’єкта.

Проста аналогія: Уявіть двері в туалет:

  • StringBuilder — двері без замка. Зайшов і зробив справу.
  • StringBuffer — двері із замком. Потрібно спочатку відкрити замок, зайти, зробити справу, закрити замок. Навіть якщо ви один у будівлі.

Приклад:

StringBuilder sb = new StringBuilder(); // Без замка — швидко
StringBuffer sb2 = new StringBuffer();  // Із замком — повільніше

for (int i = 0; i < 1_000_000; i++) {
    sb.append("x");   // ~15ms
    sb2.append("x");  // ~25ms (на 60% повільніше)
}

// Цифри приблизні (JMH, single-thread, JDK 17, x86_64). // На вашому залізі можуть відрізнятися, але співвідношення збережеться.

Коли це важливо: Лише коли ви викликаєте мільйони операцій. Для звичайних завдань різниця непомітна.


🟡 Middle Level

Чому synchronized сповільнює

Кожен synchronized-метод StringBuffer вимагає:

  1. Захоплення монітора — перевірка, чи не зайнятий об’єкт іншим потоком
  2. Memory Barriers — процесор синхронізує кеш з основною пам’яттю
  3. Звільнення монітора — після завершення метода

Навіть якщо потік всього один, JVM все одно виконує ці кроки.

Де використовується на практиці

StringBuffer — legacy з Java 1.0. У сучасному коді його практично не використовують. Єдиний сценарій: один StringBuilder-об’єкт реально використовується з кількох потоків (що саме по собі — рідкісний і підозрілий патерн).

Типові помилки

  1. Помилка: Думати, що StringBuffer потрібен для “безпеки” Рішення: Якщо кожен потік використовує свій буфер — StringBuilder безпечний

  2. Помилка: Використовувати StringBuffer для конкатенації в циклі “на всякий випадок” Рішення: Конкатенація в циклі майже завжди однопотокова — StringBuilder

Порівняння продуктивності (однопотоковий режим)

| Операція | StringBuilder | StringBuffer | Різниця | | ———- | ————– | ————– | ——— | | 1M append | ~15ms | ~25ms | +66% | | 1M insert | ~20ms | ~35ms | +75% | | 1M delete | ~10ms | ~18ms | +80% |


🔴 Senior Level

Internal Implementation

// StringBuffer — КОЖЕН публічний метод синхронізований
public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

// StringBuilder — ЖОДНОГО synchronized
public StringBuilder append(String str) {
    super.append(str);
    return this;
}

Обидва делегують в AbstractStringBuilder, але обгортка StringBuffer додає synchronized на кожен виклик.

Що стоїть за synchronized

  1. Monitor Enter/Exit: JVM-інструкції monitorenter і monitorexit
  2. Memory Barriers: LoadLoad, StoreStore, LoadStore, StoreLoad — процесор скидає і інвалідує кеш-лінії
  3. Object Header: У mark word об’єкта записується інформація про власника блокування

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

Lock Elision (усунення блокувань):

void foo() {
    StringBuffer sb = new StringBuffer(); // Escape analysis: об'єкт не "втікає"
    sb.append("a");                       // JIT: synchronized можна прибрати!
    sb.append("b");
}

HotSpot через Escape Analysis може довести, що об’єкт не видимий іншим потокам, і прибрати synchronized.

Lock Coarsening (укрупнення блокувань):

StringBuffer sb = ...;
sb.append("a");  // захоплення монітора
sb.append("b");  // захоплення монітора
sb.append("c");  // захоплення монітора

// JIT може об'єднати в одне блокування:
// synchronized(sb) { append("a"); append("b"); append("c"); }

// JIT застосовує coarsening лише якщо бачить послідовні виклики // у межах одного compilable метода. Якщо виклики рознесені по різних // методах — коарсенінг неможливий.

Але: Ці оптимізації не гарантовані. Вони залежать від:

  • -XX:+EliminateLocks (увімкнено за замовчуванням)
  • Компіляційного бюджету C2
  • Розміру методу (inlining budget)

Edge Cases

  1. Biased Locking (видалено у Java 15): Раніше JVM “прив’язувала” монітор до першого потоку, роблячи наступні захоплення безкоштовними. Видалено через overhead у контейнерах.

  2. Contended доступ: Якщо 2+ потоки реально конкурують за StringBuffer:
    • Thread A: BLOCKED → context switch → OS scheduling → resume
    • Context switch: ~1-10μs (набагато дорожче monitor enter без contention)
  3. False Sharing: Монітор об’єкта StringBuffer може викликати false sharing із сусідніми об’єктами у кеш-лінії.

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

| Сценарій | StringBuilder | StringBuffer | Delta | | ———————— | ————– | ————– | ———- | | 1 thread, no escape | 15ms | 18ms (elision) | +20% | | 1 thread, escapes | 15ms | 25ms | +66% | | 4 threads, no contention | 15ms x4 | 25ms x4 | +66% | | 4 threads, contention | 15ms x4 | 200ms | +1233% |

Production Experience

Сценарій: Логування у веб-сервісі (50K RPS):

  • StringBuffer для форматування кожного логу: p99 latency = 8ms
  • StringBuilder: p99 latency = 5ms
  • Різниця у 3ms × 50K = 150 CPU-секунд/сек → 150 ядер даремно

Best Practices для Highload

  • StringBuilder — дефолтний вибір
  • StringBufferлише якщо один буфер реально шариться між потоками
  • Якщо потрібен thread-safe буфер, краще використовувати StringBuilder + зовнішню синхронізацію (контроль над granularity)
  • Для максимальної продуктивності: StringBuilder з initialCapacity + avoid reallocations

🎯 Шпаргалка для співбесіди

Обов’язково знати:

  • StringBuffer повільніший через synchronized на кожному методі
  • synchronized вимагає: monitorenter/monitorexit, memory barriers, звільнення монітора
  • Навіть в однопотоковому режимі JVM виконує усі кроки синхронізації
  • JVM може оптимізувати через Lock Elision (Escape Analysis), але це не гарантовано
  • Biased Locking видалено у Java 15 — overhead StringBuffer в однопотоковому режимі ще вищий
  • Contention (2+ потоки): context switch ~1-10μs, набагато дорожче monitor enter

Часті уточнюючі запитання:

  • Наскільки StringBuffer повільніший? — ~66% повільніше в однопотоковому режимі, ~1200%+ при contention 4 потоків.
  • Що таке Lock Elision? — JIT через Escape Analysis доводить, що об’єкт не видимий іншим потокам, і прибирає synchronized. Не гарантовано.
  • Що таке Lock Coarsening? — JIT об’єднує послідовні synchronized-виклики в одне блокування. Працює лише в межах одного компільованого методу.
  • Чому Biased Locking видалили? — overhead у контейнерах. У Java 15+ overhead StringBuffer ще вищий.

Червоні прапорці (НЕ говорити):

  • ❌ “StringBuffer швидший в однопотоковому режимі” — повільніший через synchronized
  • ❌ “JVM завжди оптимізує synchronized” — Lock Elision не гарантований
  • ❌ “StringBuffer потрібен для кожної багатопотокової програми” — лише якщо ОДИН буфер шариться
  • ❌ “Різниця непомітна на практиці” — при 50K RPS: 3ms × 50K = 150 CPU-секунд/сек даремно

Пов’язані теми:

  • [[5. Коли використовувати StringBuilder vs StringBuffer]]
  • [[4. Чому String є незмінним]]