Почему StringBuffer медленнее StringBuilder
Класс StringBuffer медленнее, чем StringBuilder, потому что практически каждый его публичный метод объявлен с ключевым словом synchronized. Это обязывает виртуальную машину Java...
🟢 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 года о «бесплатных» смещенных блокировках.