Чому StringBuffer повільніший за StringBuilder
Клас StringBuffer повільніший за StringBuilder, тому що практично кожен його публічний метод оголошений із ключовим словом synchronized. Це зобов'язує віртуальну машину Java (JV...
🟢 Junior Level
Клас StringBuffer повільніший за StringBuilder, тому що практично кожен його публічний метод оголошений із ключовим словом synchronized. Це зобов’язує віртуальну машину Java (JVM) виконувати операції захоплення та звільнення монітора об’єкта при кожному виклику методу (append, insert, delete, length тощо), навіть якщо в програмі працює лише один потік.
Суть у 30 секундах
StringBuilder(Java 5): Працює без синхронізації. Доступ до внутрішнього масиву байтів та лічильника символів здійснюється напряму. Процесор виконує операції послідовно на максимальній швидкості регістрів та кешу CPU.StringBuffer(Java 1.0): Потокобезпечний завдяки блокуванням (synchronized). Перед кожною дією потік зобов’язаний перевірити й захопити блокування поточного об’єкта, а після — звільнити його. В однопотоковому сценарії це створює паразитарні накладні витрати на атомарні процесорні інструкції та бар’єри пам’яті, роблячи операції в 1.5–2.5 раза повільнішими. При реальній багатопотоковій конкуренції (contention) швидкість падає на порядки (у 10–20 разів) через очікування та перемикання контексту потоків ОС.
// Порівняння часу виконання (1 мільйон додань символів в 1 потоці):
long start = System.nanoTime();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1_000_000; i++) {
sb.append("a"); // ~8-15 мс (прямий запис у буфер)
}
long timeSb = System.nanoTime() - start;
start = System.nanoTime();
StringBuffer sbf = new StringBuffer();
for (int i = 0; i < 1_000_000; i++) {
sbf.append("a"); // ~20-35 мс (мільйон захоплень монітора)
}
long timeSbf = System.nanoTime() - start;
🟡 Middle Level
Анатомія накладних витрат синхронізації
Чому виклик synchronized-методу не є «безкоштовним» навіть за відсутності інших потоків?
- Байткод-інструкції
monitorenterтаmonitorexit: У байткоді кожен виклик методуStringBufferогортається в пару інструкцій входу та виходу з критичної секції. Це запобігає довільному перевпорядкуванню інструкцій компілятором C2 та процесором. - Атомарні інструкції процесора (CAS):
Щоб переконатися, що блокування захоплене поточним потоком, процесор виконує інструкцію
LOCK CMPXCHG(на архітектурі x86/x64). Ця інструкція блокує кеш-лінію L1/L2 процесора, забороняючи одночасний доступ іншим процесорним ядрам. - Бар’єри пам’яті (Memory Barriers / Fences): Згідно з Java Memory Model (JMM), звільнення монітора утворює відношення happens-before з наступним захопленням цього ж монітора. Для цього JVM змушена скидати буфери запису процесора (Store Buffer) та інвалідувати кеш, запобігаючи апаратним перевпорядкуванням операцій читання/запису.
- Скидання кешу
toStringCache: УStringBufferбудь-яка мутуюча операція зобов’язана інвалідувати полеtoStringCache = null, що створює додаткову операцію запису в пам’ять.
Порівняльна таблиця продуктивності
| Метрика / Сценарій | StringBuilder | StringBuffer (без конкуренції) | StringBuffer (4 конкуруючі потоки) |
| :— | :— | :— | :— |
| Синхронізація | Відсутня | synchronized на кожному методі | synchronized на кожному методі |
| Інструкції процесора | Звичайний MOV | Атомарний LOCK CMPXCHG (CAS) | CAS + паркування потоків (futex/OS mutex) |
| Час 1M операцій append | ~8–12 мс | ~20–30 мс (+150%) | ~150–300 мс (+2000%) |
| Перемикання контексту ОС | 0 | 0 | Тисячі Context Switches |
| Рекомендація | За замовчуванням завжди | Не рекомендується | Не рекомендується (краще локальні буфери) |
Спроби JVM згладити різницю: Lock Elision та Lock Coarsening
Компілятор HotSpot C2 намагається компенсувати повільність StringBuffer двома JIT-оптимізаціями:
- Lock Elision (усунення блокувань): Якщо завдяки механізму Escape Analysis компілятор доведе, що об’єкт
StringBufferлокальний для методу й нікуди не «втікає» (NoEscape), він повністю видалить інструкції блокувань у машинному коді. - Lock Coarsening (укрупнення блокувань): Якщо поспіль викликається кілька синхронізованих методів (
sb.append("a").append("b")), компілятор може спробувати об’єднати їх в один спільний критичний блок замість кількох входів та виходів.
Чому це не вирішує проблему повністю?
Escape Analysis спрацьовує далеко не завжди: варто передати StringBuffer у метод, який C2 не зміг заінлайнити, або зберегти посилання в поле класу, й оптимізація негайно вимикається.
🔴 Senior Level
Еволюція моніторів HotSpot та вплив JEP 374 (Java 15)
Щоб зрозуміти різницю у швидкості на рівні машинного коду, необхідно простежити життєвий цикл монітора об’єкта в HotSpot JVM.
Заголовок будь-якого Java-об’єкта (Mark Word, 64 біти на x86-64) зберігає біти стану блокування:
- Biased Locking (зміщене блокування — видалено в JDK 15):
- До Java 15: JVM записувала
ThreadIDпоточного потоку в заголовок об’єкта. Поки лише цей потік викликав методиStringBuffer, повторний вхід коштував лише однієї бітової маски та перевірки рівності, майже без втрат. - Чому видалили (JEP 374): Підтримка Biased Locking вимагала важких глобальних пауз JVM (Safepoint Stop-the-World) для відкликання зміщення («Revocation»), якщо об’єкт захоплювався іншим потоком. Це зводило нанівець низькі затримки сучасних GC (ZGC, Shenandoah).
- До Java 15: JVM записувала
- Lightweight Locking (тонке блокування):
- Починаючи з Java 15+, при кожному виклику методу
StringBufferпотік виконує атомарний CAS (LOCK CMPXCHG), намагаючись записати покажчик на свій стек (BasicObjectLock) у заголовок Mark Word об’єкта. - Виконання інструкції
LOCKапаратно блокує шину/кеш-лінію в протоколі когерентності кешів (MESI/MOESI), скидає суперскалярний конвеєр інструкцій CPU та очищає черги позачергового виконання (Out-of-Order Execution).
- Починаючи з Java 15+, при кожному виклику методу
- Heavyweight Locking (товсте / роздуте блокування — Fat Lock):
- Якщо за
StringBufferодночасно борються кілька потоків, CAS завершується невдачею. Після короткого спін-локу (Spinning) монітор «роздувається» (Inflates): JVM алокує нативну структуру C++ObjectMonitor. - Потоки переводяться в стан
BLOCKEDчерез системні виклики ядра ОС (pthread_mutex_lock/futexу Linux). - Ціна перемикання контексту ядра ОС (Context Switch) становить від 1 до 5 мікросекунд на потік. Продуктивність застосунку падає катастрофічно.
- Якщо за
[Unlocked Object]
│
▼ (JEP 374: одразу Lightweight)
[Lightweight Lock] ──► Атомарний CAS (LOCK CMPXCHG) у заголовку Mark Word
│
▼ (При конкуренції потоків: Contention)
[Inflated / Fat Lock] ──► Нативний ObjectMonitor, паркування потоків в OS Futex
Вплив на кеш-лінії процесора та False Sharing
При інтенсивній роботі кількох потоків із спільним StringBuffer навіть без блокування потоків в ОС ядра процесорів безперервно конкурують за одну й ту саму кеш-лінію CPU (зазвичай 64 байти), в якій міститься заголовок об’єкта StringBuffer та поле count:
- Кожна зміна заголовка через CAS переводить кеш-лінію в стан
Modified(M) за протоколом MESI. - Усі інші ядра процесора отримують сигнал інвалідації (
Invalidate) і змушені повторно запитувати дані по між’ядерній шині (Interconnect / QPI / UPI). - Виникає Cache Thrashing (буксування кеш-пам’яті), що паралізує пропускну здатність шини пам’яті сервера.
4 Tricky Questions
1. Чому JIT-компілятор C2 не може просто винести блокування за межі циклу з 10 000 ітераціями sb.append() (Lock Coarsening across loops)?
Відповідь:
Компілятор не має права розширювати синхронізований блок за межі циклу, що містить Safepoint Polls (опитування точок безпеки).
У Java всередині циклів (за винятком деяких лічильних циклів з int, які компілятор зміг перетворити на counted loop без сафпоінтів) генеруються інструкції опитування точок безпечної зупинки (Safepoint Poll). У цих точках потік повинен мати можливість зупинитися для виконання Stop-The-World фаз GC.
Якби C2 укрупнив блокування й обернув увесь цикл в один неперервний monitorenter/monitorexit, потік утримував би монітор упродовж усіх 10 000 ітерацій. Якщо всередині циклу потік засне на Safepoint, інші потоки виявляться заблокованими на невизначений термін, що спричинить деградацію відгуку всієї JVM та ризик взаємного блокування (Deadlock). Тому Lock Coarsening працює суворо локально в межах невеликих лінійних ділянок коду без сафпоінтів.
2. Чому Escape Analysis часто зазнає невдачі й не може усунути блокування у локального StringBuffer (Lock Elision failure)?
Відповідь:
Escape Analysis (EA) у HotSpot C2 є консервативним алгоритмом. Він визнає об’єкт таким, що втік (GlobalEscape або ArgEscape), якщо:
- Буфер передається в метод, який компілятор C2 не зміг заінлайнити (наприклад, глибина інлайнінгу перевищила
-XX:MaxInlineLevel=9, метод перевищив ліміт байткоду-XX:MaxInlineSize=35, або метод віртуальний із більш ніж 2 реалізаціями в рантаймі — megamorphic call-site). Оскільки тіло методу невідоме, компілятор зобов’язаний припустити, що чужий метод може зберегти посилання на буфер у статичне поле або передати іншому потоку. - Буфер зберігається в поле будь-якого іншого об’єкта, час життя якого не доведено як суворо локальний.
В обох випадках Lock Elision негайно вимикається, і кожен виклик
StringBufferпродовжує виконувати повноцінні атомарні інструкції блокування.
3. Що відбувається на апаратному рівні процесора при виконанні LOCK CMPXCHG у незаблокованому StringBuffer?
Відповідь:
- Ядро процесора переводить відповідну кеш-лінію (що містить Mark Word об’єкта) в ексклюзивне володіння, надсилаючи широкомовне повідомлення інвалідації (RFO — Request For Ownership) іншим ядрам за протоколом когерентності MESI/MOESI.
- Процесор примусово серіалізує конвеєр: усі попередні інструкції запису скидаються зі Store Buffer до кешу L1D.
- Процесор забороняє спекулятивне виконання (Speculative Execution) інструкцій за точкою синхронізації доти, доки операція CAS не буде зафіксована.
У результаті конвеєр інструкцій простоює (Pipeline Stall), а на операцію витрачається від 15 до 40 тактів CPU замість 1 такту для звичайної інструкції
MOVуStringBuilder.
4. У чому різниця в споживанні пам’яті між StringBuilder та StringBuffer у HotSpot 64-bit?
Відповідь:
Сам по собі об’єкт StringBuilder займає в купі:
- Заголовок об’єкта: Mark Word (8 байтів) + Klass Word (4 байти при Compressed OOPs) = 12 байтів.
- Поле
byte[] value: 4 байти (посилання при Compressed OOPs). - Поле
byte coder: 1 байт. - Вирівнювання (Padding) до 8 байтів: 3 байти.
- Поле
int count: 4 байти. - Поле
int capacity: не зберігається окремо (береться з довжини масиву). - Разом: 24 байти для інстанса
StringBuilder+ розмір масивуbyte[].
У StringBuffer додається поле:
private transient String toStringCache: посилання (4 байти при Compressed OOPs).- Разом розмір інстанса
StringBufferзбільшується до 32 байтів (з урахуванням 8-байтового вирівнювання) — тобто сам інстанс на 33% масивніший. - При роздуванні блокування (Fat Lock) додатково алокується нативний C++ об’єкт
ObjectMonitorу пам’яті C-Heap (поза Java Heap), що займає від 128 до 200+ байтів нативної пам’яті на кожен заблокований екземпляр.
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь
«
StringBufferповільніший заStringBuilder, тому що всі його публічні методи синхронізовані ключовим словомsynchronized. Навіть в одному потоці JVM змушена виконувати інструкціїmonitorenter/monitorexit, атомарні процесорні інструкції CAS (LOCK CMPXCHG) та дотримуватися бар’єрів пам’яті. Після видалення Biased Locking у Java 15 цей оверхед став ще помітнішим. При багатопотоковій конкуренції оверхед зростає експоненціально через ескалацію монітора у Fat Lock та системні виклики паркування потоків ОС.StringBuilderне синхронізований і працює з масивом пам’яті напряму, без затримок».
Ключові тези для Senior-інтерв’ю
- Причина різниці: Наявність
synchronizedна методахStringBuffer. - Вартість в 1 потоці: Атомарні операції CAS на рівні процесора, серіалізація шини кеш-пам’яті за протоколом MESI, скидання конвеєра CPU.
- Вартість в N потоках: Ескалація блокування в
ObjectMonitor, паркування потоків черезfutex/системні виклики ядра, перемикання контексту (1–5 мкс на перемикання). - JEP 374 (Java 15): Видалення зміщених блокувань (Biased Locking) збільшило розрив між
StringBuilderтаStringBufferв однопотокових сценаріях. - Escape Analysis: Lock Elision може прибрати блокування лише в тривіальних локальних випадках при успішному інлайнінгу.
Червоні прапорці (чого в жодному разі не можна говорити)
- ❌ Говорити: «
StringBufferповільний, тому що створює більше проміжних об’єктів у купі» (обидва класи використовують однаковий базовийAbstractStringBuilderіз тим самим масивом байтів). - ❌ Стверджувати, що «JIT завжди вирізає блокування через Escape Analysis» (Escape Analysis часто здається через ліміти глибини інлайнінгу).
- ❌ Забувати про видалення Biased Locking у Java 15 і посилатися на старі статті 2010 року про «безкоштовні» зміщені блокування.