- Почему вообще появился AQS
- Главная идея AQS
- Как выглядит архитектура AQS
- AbstractQueuedSynchronizer vs BlockingQueue
- Почему именно очередь
- Внутренний Node очереди
- End-to-End сценарий lock()
- Шаг 1 — попытка CAS
- Шаг 2 — поток не смог получить lock
- Шаг 3 — парковка потока
- Шаг 4 — unlock
- Почему используется CAS
- Влияние на JVM Memory Model
- Влияние на CPU cache
- Влияние на OS threads
- GC последствия
- Production кейс
- Trade-offs AQS
- Инженерная интуиция
Когда 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 в универсальную архитектуру синхронизации.
Оставить комментарий
Полезные статьи:
Новые статьи: