Когда lock становится бутылочным горлышком: инженерная история AbstractQueuedSynchronizer (AQS)

История начинается не с академической теории, а с типичной production-проблемы.

Представьте сервис:

  • 48 CPU
  • 300+ потоков
  • нагрузка 200k операций в секунду
  • много shared state

Команда использует обычные Java locks:

ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    updateState();
} finally {
    lock.unlock();
}

На тестах всё отлично.

Но под production нагрузкой внезапно появляются симптомы:

  • CPU utilisation падает
  • latency p99 растёт
  • потоки зависают в BLOCKED
  • thread dump показывает десятки потоков на lock

И тут появляется главный вопрос:

Почему простая операция lock/unlock может разрушить масштабируемость системы?

Чтобы ответить на этот вопрос, нужно понять архитектуру, которая стоит за большинством синхронизационных примитивов Java.

Эта архитектура называется AbstractQueuedSynchronizer.


Почему вообще появился AQS

До Java 5 большинство синхронизации выглядело так:

synchronized(lock) {
    criticalSection();
}

Это работало, но имело несколько серьёзных ограничений:

  • невозможно реализовать кастомные синхронизаторы
  • нет гибкого контроля над очередями потоков
  • невозможно создать semaphore, latch, read/write lock

Каждый новый синхронизатор приходилось писать практически с нуля.

Именно тогда Doug Lea предложил универсальный механизм:

Сделать один низкоуровневый каркас, который управляет очередью потоков и состоянием синхронизации.

Так появился AQS.


Главная идея AQS

AQS — это фреймворк для построения синхронизаторов.

Он решает две задачи:

  • управление состоянием синхронизации
  • управление очередью ожидающих потоков

Внутри него есть всего несколько ключевых элементов:

volatile int state
CLH queue of threads
CAS operations
park / unpark threads

И из этих кирпичей строятся:

  • ReentrantLock
  • Semaphore
  • CountDownLatch
  • ReentrantReadWriteLock

Как выглядит архитектура AQS


Thread1
Thread2
Thread3
   ↓
[AQS Queue]
   ↓
 state

Каждый поток, который не смог получить lock, помещается в очередь.

Эта очередь называется CLH queue.


AbstractQueuedSynchronizer vs BlockingQueue

Критерий AbstractQueuedSynchronizer (AQS) BlockingQueue
📌 Что это Базовый фреймворк синхронизаторов Потокобезопасная очередь с блокировкой
🎯 Назначение Строить синхронизаторы (Lock, Semaphore, Latch) Передача данных между потоками (producer-consumer)
🧱 Уровень Низкоуровневый (framework) Высокоуровневый concurrent utility
⚙️ Основная идея Очередь ожидания + state + CAS Очередь + блокировка при пусто/полно
🔗 Используется в ReentrantLock, Semaphore, CountDownLatch, ReadWriteLock ThreadPoolExecutor, producer-consumer pipelines
🚦 Механика блокировки FIFO queue + park/unpark через LockSupport ReentrantLock + Condition (или внутренние механизмы)
📊 State Одно число (int state) Элементы + capacity + condition queues
🧠 Фокус Синхронизация доступа Передача данных + backpressure
📉 Backpressure Нет напрямую Да (bounded queue блокирует producer)
🧩 Абстракция Фундамент для других примитивов Готовый инструмент для задач
🔬 Внутренности CLH queue, Node, CAS, park/unpark Lock + Condition + внутренняя очередь

Почему именно очередь

Представим naive реализацию lock:

while(!tryLock()) {
    // busy wait
}

Это называется spin lock.

Проблема — CPU burning.

Если 200 потоков делают spin:

CPU → 100%
throughput → падает

Поэтому AQS делает иначе.

Потоки, которые не получили lock, не крутятся в цикле. Они паркуются (sleep) в очереди.

Внутренний Node очереди

Каждый поток в очереди представлен Node.

class Node {
    volatile Node prev;
    volatile Node next;
    volatile Thread thread;
    volatile int waitStatus;
}

Это двусвязная очередь.


HEAD ← Node ← Node ← Node ← TAIL

End-to-End сценарий lock()

Разберём полный жизненный цикл.

lock.lock()

Шаг 1 — попытка CAS

if (compareAndSetState(0, 1)) {
    owner = currentThread;
}

Если CAS успешен — поток получил lock.

Это fast path.


Шаг 2 — поток не смог получить lock

Теперь поток должен встать в очередь.

addWaiter(Node.EXCLUSIVE)

Создаётся Node и добавляется в tail.


HEAD ← NodeA ← NodeB ← NodeC

Шаг 3 — парковка потока

Если поток не первый в очереди:

LockSupport.park()

Теперь поток спит.

CPU не тратится.


Шаг 4 — unlock

lock.unlock()

Происходит:

state = 0
unpark(nextThread)

Следующий поток просыпается.


Почему используется CAS

CAS — атомарная операция CPU.

cmpxchg

Она позволяет изменить состояние без lock.

Это уменьшает contention.


Влияние на JVM Memory Model

state объявлен как volatile:

volatile int state

Это создаёт happens-before:

unlock → happens-before → next lock

Таким образом гарантируется visibility.


Влияние на CPU cache

CAS вызывает cache line invalidation.

Если много потоков делают CAS на state:

cache ping-pong

Поэтому AQS старается минимизировать количество CAS.


Влияние на OS threads

Когда поток паркуется:

LockSupport.park()

JVM переводит поток в состояние:

PARKED / WAITING

Это означает:

  • поток снимается с CPU
  • scheduler может запускать другие

GC последствия

Каждый ожидающий поток создаёт Node.

Node object

При больших очередях:

many Node allocations

Но они короткоживущие и быстро очищаются в young generation.


Production кейс

В одном trading сервисе наблюдали:

ReentrantLock contention

метрики:

metric value
threads 120
lock wait time 40ms
CPU 25%

После переработки алгоритма и уменьшения contention:

metric before after
latency p99 120ms 9ms
CPU 25% 70%
throughput 30k ops 180k ops

Trade-offs AQS

механизм плюс минус
CAS быстро cache contention
queue fairness allocation
park/unpark нет spin OS overhead

Инженерная интуиция

AQS не делает lock быстрым сам по себе.

Он делает ожидание lock предсказуемым и масштабируемым.

Ключевая идея:

Если поток не может прогрессировать — лучше поставить его в очередь и убрать с CPU.

Именно поэтому AQS лежит в основе почти всей concurrency библиотеки Java.

Это один из лучших примеров того, как JVM соединяет low-level механизмы CPU, OS и memory model в универсальную архитектуру синхронизации.

Всего лайков:0

Оставить комментарий

Мой канал в социальных сетях
Отправляя email, вы принимаете условия политики конфиденциальности

Полезные статьи:

Context, propagation и cancellation patterns в Go vs Java | Паттерны, идиомы и лучшие практики Go
← Связанные статьи: Часть 1 — Error handling и defer в Go (Параллельность и синхронизация) | Паттерны, идиомы и лучшие практики Go 1. Context и его роль В Go context.Context используется для пере...
Современный подход к параллелизму в Java - Fork/Join Framework, CompletableFuture и виртуальные потоки (Project Loom)
Предисловие Мир программного обеспечения уже давно перестал быть спокойным океаном: сегодня это бурная экосистема, где каждая миллисекунда отклика приложения может стоить компании клиентов, репутации ...
Побитовые операторы в Java
Побитовые операторы в Java В языке программирования Java определено несколько побитовых операторов. Эти операторы применяются к целочисленным типам данных, таким как byte, short, int, long и char. Спи...

Новые статьи:

История начинается не с академической теории, а с типичной production-проблемы. Представьте сервис: 48 CPU 300+ потоков нагрузка 200k операций в секунду много shared state Команда использует обы...
Когда HashMap начинает убивать продакшн: инженерная история ConcurrentHashMap
Представьте обычный продакшн-сервис. 32 CPU сотни потоков кэш конфигурации / сессий / rate limits десятки тысяч операций в секунду И где-то внутри — обычный Map. Сначала всё выглядит безобидно. Map&...
Zero Allocation в Java: что это и почему это важно
Zero Allocation — это подход к написанию кода, при котором во время выполнения (runtime) не создаются лишние объекты в heap памяти. Главная идея: меньше объектов → меньше GC → выше стабильность и про...
Fullscreen image