Когда 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, вы принимаете условия политики конфиденциальности

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

Channel direction и select patterns в Go vs Java | Паттерны, идиомы и лучшие практики Go
← Связанные статьи: Context, propagation и cancellation patterns в Go vs Java | Паттерны, идиомы и лучшие практики Go 1. Channel direction — направление каналов В Go каналы могут быть односторонн...
Низкоуровневые механизмы - часть 2 | Go ↔ Java
В этой статье мы собрали ключевые low-level механизмы Go, которые чаще всего вызывают вопросы у разработчиков, приходящих из Java. Мы рассмотрим: unsafe.Pointer, выравнивание структур, арифметику указ...
Циклы в Java: for, while, do while, Операторы continue и break
Привет! С вами Виталий Лесных. Сегодня мы продолжим курс «Основы Java для начинающих» и разберём одну из важнейших тем программирования — циклы. Цикл — это повторение выполнения кода до тех пор, пок...

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

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