🔤 Раздел 12 · Вопрос #6

Почему 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-метода не является «бесплатным» даже при отсутствии других потоков?

  1. Байткод-инструкции monitorenter и monitorexit: В байткоде каждый вызов метода StringBuffer оборачивается в пару инструкций входа и выхода из критической секции. Это предотвращает свободное перемещение инструкций компилятором C2 и процессором.
  2. Атомарные инструкции процессора (CAS): Чтобы убедиться, что блокировка захвачена текущим потоком, процессор выполняет инструкцию LOCK CMPXCHG (на архитектуре x86/x64). Эта инструкция блокирует кэш-линию L1/L2 процессора, запрещая одновременный доступ другим процессорным ядрам.
  3. Барьеры памяти (Memory Barriers / Fences): Согласно Java Memory Model (JMM), освобождение монитора образует отношение happens-before с последующим захватом этого же монитора. Для этого JVM вынуждена сбрасывать буферы записи процессора (Store Buffer) и инвалидировать кэш, предотвращая аппаратные переупорядочивания операций чтения/записи.
  4. Сброс кэша 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) хранит биты состояния блокировки:

  1. Biased Locking (смещенная блокировка — удалена в JDK 15):
    • До Java 15: JVM записывала ThreadID текущего потока в заголовок объекта. Пока только этот поток вызывал методы StringBuffer, повторный вход стоил всего одной битовой маски и проверки равенства, почти без потерь.
    • Почему удалили (JEP 374): Поддержка Biased Locking требовала тяжелейших глобальных пауз JVM (Safepoint Stop-the-World) для отзыва смещения («Revocation»), если объект захватывался другим потоком. Это сводило на нет низкие задержки современных GC (ZGC, Shenandoah).
  2. Lightweight Locking (тонкая блокировка):
    • Начиная с Java 15+, при каждом вызове метода StringBuffer поток выполняет атомарный CAS (LOCK CMPXCHG), пытаясь записать указатель на свой стек (BasicObjectLock) в заголовок Mark Word объекта.
    • Выполнение инструкции LOCK аппаратно блокирует шину/кэш-линию в протоколе когерентности кэшей (MESI/MOESI), сбрасывает суперскалярный конвейер инструкций CPU и очищает очереди внеочередного исполнения (Out-of-Order Execution).
  3. 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), если:

  1. Буфер передается в метод, который компилятор C2 не смог заинлайнить (например, глубина инлайнинга превысила -XX:MaxInlineLevel=9, метод превысил лимит байткода -XX:MaxInlineSize=35, или метод виртуальный с более чем 2 реализациями в рантайме — megamorphic call-site). Раз тело метода неизвестно, компилятор обязан предположить, что чужой метод может сохранить ссылку на буфер в статическое поле или передать другому потоку.
  2. Буфер сохраняется в поле любого другого объекта, время жизни которого не доказано как строго локальное. В обоих случаях Lock Elision немедленно отключается, и каждый вызов StringBuffer продолжает выполнять полноценные атомарные инструкции блокировки.

3. Что происходит на аппаратном уровне процессора при выполнении LOCK CMPXCHG в незаблокированном StringBuffer?

Ответ:

  1. Ядро процессора переводит соответствующую кэш-линию (содержащую Mark Word объекта) в эксклюзивное владение, посылая широковещательное сообщение инвалидации (RFO — Request For Ownership) другим ядрам по протоколу когерентности MESI/MOESI.
  2. Процессор принудительно сериализует конвейер: все предшествующие инструкции записи сбрасываются из Store Buffer в кэш L1D.
  3. Процессор запрещает спекулятивное выполнение (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-интервью

  1. Причина разницы: Наличие synchronized на методах StringBuffer.
  2. Стоимость в 1 потоке: Атомарные операции CAS на уровне процессора, сериализация шины кэш-памяти по протоколу MESI, сброс конвейера CPU.
  3. Стоимость в N потоках: Эскалация блокировки в ObjectMonitor, парковка потоков через futex/системные вызовы ядра, переключение контекста (1–5 мкс на переключение).
  4. JEP 374 (Java 15): Удаление смещенных блокировок (Biased Locking) увеличило разрыв между StringBuilder и StringBuffer в однопоточных сценариях.
  5. Escape Analysis: Lock Elision может убрать блокировки только в тривиальных локальных случаях при успешном инлайнинге.

Красные флаги (Чего говорить категорически нельзя)

  • ❌ Говорить: «StringBuffer медленный, потому что создает больше промежуточных объектов в куче» (оба класса используют одинаковый базовый AbstractStringBuilder с тем же массивом байт).
  • ❌ Утверждать, что «JIT всегда вырезает блокировки через Escape Analysis» (Escape Analysis часто сдается из-за лимитов глубины инлайнинга).
  • ❌ Забывать про удаление Biased Locking в Java 15 и ссылаться на старые статьи 2010 года о «бесплатных» смещенных блокировках.

Связанные вопросы