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

Коли використовувати StringBuilder vs 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 |


🔴 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 вимагає постійних операцій запису, що заважає оптимізаціям розміщення в реєстрах.

Вплив видалення Biased Locking (JEP 374 у Java 15)

Історично HotSpot JVM використовувала Biased Locking (зміщені блокування). Якщо монітор захоплювався лише одним потоком, заголовок об’єкта (Mark Word) «зміщувався» у бік цього потоку ідентифікатором ThreadID. Наступні захоплення монітора тим самим потоком обходилися взагалі без атомарних процесорних інструкцій.

Проте в Java 15 (JEP 374) Biased Locking було вимкнено за замовчуванням, а потім повністю видалено з JVM:

  • Причина: підтримка зміщених блокувань вимагала складних пауз Safepoint для відкликання блокування (Revocation) при зміні потоку, що деградувало latency сучасних збирачів сміття (ZGC, Shenandoah).
  • Наслідок для StringBuffer: У сучасних версіях Java (17, 21+) накладні витрати на виклики synchronized методів у незаблокованому стані (Thin Lock із CAS) зросли порівняно з епохою Biased Locking. Це робить використання StringBuffer в однопотокових сценаріях ще більш нераціональним.

4 Tricky Questions

1. Що станеться, якщо кілька потоків одночасно викличуть sb.append() в одного екземпляра StringBuilder? Чи буде гарантовано викинуто виняток?

Відповідь: Гарантії викидання винятку немає. Поведінка є недетермінованою і залежить від таймінгів планувальника ОС:

  • Сценарій 1 (Виняток): Буде викинуто ArrayIndexOutOfBoundsException або NegativeArraySizeException. Це відбувається, коли потоки одночасно проходять перевірку місткості ensureCapacityInternal(), вирішують, що розширення не потрібне, але при наступному записі один із потоків зсуває count за межі фізичної довжини масиву.
  • Сценарій 2 (Тихе пошкодження даних — Silent Data Corruption): Виняток не викидається, але дані безповоротно псуються. Оскільки операція збільшення лічильника count складається з кількох процесорних інструкцій (read-modify-write), потоки перезаписують комірки пам’яті один одного. У результаті частина символів губиться (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 документів (будуть десятки зайвих переалокацій та копіювань масивів).

Посилання на пов’язані теми