Коли використовувати 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 ...
Кожне розширення буфера спричиняє:
- Алокацію нового масиву в купі (
new byte[newCapacity]). - Копіювання даних через нативний виклик
System.arraycopy(). - Перетворення старого масиву на сміття для 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:
- Синхронізація монітором: Виклик
synchronizedгенерує байткод-інструкціїmonitorenterтаmonitorexit. Навіть за відсутності конкуренції (contention) потік виконує атомарну інструкцію (наприклад,LOCK CMPXCHGна x86) для встановлення Mark Word у заголовку об’єкта. - Кешування
toStringCache:StringBufferмає полеprivate transient String toStringCache. При викликуtoString()створюється рядок, який зберігається в цьому полі. Якщо буфер не модифікувався, повторнийtoString()повертає кешований рядок без створення копії масиву! Але будь-яка мутація (append,insert,delete) скидаєtoStringCache = null. - Метод
toString()уStringBuilder: Завжди створює новий екземплярString, виконуючи копіювання діапазону масиву байтів черезArrays.copyOfRange(value, 0, count). Пряма передача масивуvalueв конструкторStringнеможлива з міркувань безпеки (інакше подальша модифікаціяStringBuilderзмінила б вміст «імутабельного»String).
Оптимізації JIT-компілятора: Escape Analysis та Lock Elision
HotSpot JVM (C2-компілятор) уміє аналізувати область видимості об’єктів за допомогою Escape Analysis (аналіз втечі):
- NoEscape: Об’єкт не залишає меж методу й не зберігається в поля інших об’єктів.
- ArgEscape: Об’єкт передається як аргумент методів, але не витікає в глобальний стан.
- 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-інтерв’ю
- Compact Strings (Java 9+): Обидва класи використовують
byte[] value+byte coder(0 = Latin-1, 1 = UTF-16) замість старогоchar[]. - Формула розширення: $(\text{oldCapacity} \ll 1) + 2$. При дефолтному розмірі 16 зростання йде: 16 $\to$ 34 $\to$ 70 $\to$ 142.
- Lock Elision & Escape Analysis: C2 JIT може усунути синхронізацію в
StringBuffer, якщо об’єктNoEscape, але це не привід використовувати його в однопотоковому коді. - Видалення Biased Locking у Java 15: Зробило неконкурентні виклики
synchronizedдорожчими, збільшивши відставанняStringBufferвідStringBuilder. - Метод
toString():StringBuilderзавжди копіює дані черезArrays.copyOfRange(), захищаючи імутабельністьString.StringBufferмає оптимізаціюtoStringCache.
Червоні прапорці (чого в жодному разі не можна говорити)
- ❌ Стверджувати, що
StringBuffer«рекомендується використовувати в усіх багатопотокових застосунках» (розділяти рядковий буфер між потоками — поганий архітектурний патерн). - ❌ Заявляти, що
StringBufferзахищає від будь-яких станів гонки (гонки виникають на рівні складених операцій check-then-act). - ❌ Говорити, що
StringBuilderусередині зберігаєchar[]у сучасних версіях Java (починаючи з Java 9 використовуєтьсяbyte[]із компактними рядками). - ❌ Вважати, що
new StringBuilder()без параметрів достатній для складання мегабайтних XML/JSON документів (будуть десятки зайвих переалокацій та копіювань масивів).