Главная › Блог › ReadWriteLock и striped locking: когда synchronized уже не хватает

ReadWriteLock и striped locking: когда synchronized уже не хватает

Проблема одной блокировки на всё

synchronized даёт взаимное исключение, но платит за это полным сериализованием доступа: даже если 99% операций — это чтение, которое друг другу не мешает, synchronized всё равно пустит потоки по одному. На собесе это часто всплывает в формате: «у вас есть структура данных, читают её в тысячу раз чаще, чем пишут, как ускорить доступ без потери консистентности».

Ответ — развести блокировки на чтение и запись, либо вообще раздробить одну блокировку на много маленьких (striping). Это стандартный инструмент, который лежит в основе ConcurrentHashMap и многих кэшей.

ReentrantReadWriteLock

Идея простая: есть два лока — read и write, которые делят общий счётчик состояния. Правила:

  • Несколько читателей могут держать read-lock одновременно.
  • Писатель получает write-lock только тогда, когда нет ни одного активного читателя и ни одного писателя.
  • Если кто-то держит write-lock, все остальные (и читатели, и писатели) ждут.
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private final Map<String, Data> cache = new HashMap<>();

public Data get(String key) {
    lock.readLock().lock();
    try {
        return cache.get(key);
    } finally {
        lock.readLock().unlock();
    }
}

public void put(String key, Data value) {
    lock.writeLock().lock();
    try {
        cache.put(key, value);
    } finally {
        lock.writeLock().unlock();
    }
}

Внутри ReentrantReadWriteLock использует один AQS (AbstractQueuedSynchronizer) на 32-битном state: старшие 16 бит — счётчик читателей, младшие 16 — счётчик записи (с учётом реентрантности). Поэтому операции read/write-lock — это, по сути, CAS над одним числом.

Классическая ловушка: read-write lock выгоден только при реально длинных и частых read-операциях и редких записях. Если критическая секция короткая (пара инструкций), оверхед на управление состоянием блокировки может оказаться дороже, чем просто synchronized. Меряйте, не гадайте.

Downgrade с write на read

ReentrantReadWriteLock поддерживает понижение: поток держит write-lock, захватывает read-lock, затем отпускает write-lock — и остаётся с read-lock. Это безопасно и часто используется, чтобы после модификации данных сразу их перечитать без промежутка, когда другой писатель может вклиниться.

Обратный путь — апгрейд с read на write — не поддерживается напрямую и приводит к deadlock, если делать это наивно (два читателя одновременно попытаются апгрейднуться до write и будут ждать друг друга вечно). Нужно явно отпустить read-lock перед взятием write-lock, с повторной проверкой условия после.

StampedLock — следующий уровень

StampedLock (Java 8+) добавляет третий режим — optimistic read, который вообще не блокирует. Идея: поток запоминает "штамп" (версию состояния), читает данные без лока, а потом проверяет — не изменилась ли версия. Если изменилась — переходит на полноценный read-lock.

private final StampedLock lock = new StampedLock();
private double x, y;

double distanceFromOrigin() {
    long stamp = lock.tryOptimisticRead();
    double curX = x, curY = y;
    if (!lock.validate(stamp)) {
        stamp = lock.readLock();
        try {
            curX = x;
            curY = y;
        } finally {
            lock.unlockRead(stamp);
        }
    }
    return Math.sqrt(curX * curX + curY * curY);
}

StampedLock быстрее ReentrantReadWriteLock в сценариях с высокой конкуренцией читателей, но не реентрантен и не поддерживает Condition — это более низкоуровневый инструмент, с которым легко выстрелить себе в ногу, если забыть validate.

ReadWriteLock — это как читальный зал библиотеки: сколько угодно людей читают одновременно, но если пришёл библиотекарь менять книги на полке, всех читателей просят выйти. StampedLock — это когда читатель вообще не спрашивает разрешения, а просто подглядывает и потом сверяет, не переставили ли книги, пока он смотрел.

Striped locking: дробим одну блокировку на много

Второй подход к масштабированию — не разделять read/write, а разбить защищаемую структуру на независимые сегменты, каждый со своей блокировкой. Так устроен ConcurrentHashMap до Java 8 (Segment-based) и так работает Guava Striped.

Striped<Lock> stripes = Striped.lock(16);

void update(String key) {
    Lock lock = stripes.get(key);
    lock.lock();
    try {
        // модификация данных по key
    } finally {
        lock.unlock();
    }
}

Ключ хэшируется в один из N "страйпов", и конкурируют только потоки, попавшие в один и тот же сегмент. Чем больше страйпов, тем меньше конкуренция, но больше памяти и риск случайных коллизий по хэшу.

Чем StampedLock отличается от ReentrantReadWriteLock и когда он опаснее?
StampedLock добавляет optimistic read без блокировки вообще, но не реентрантен, не поддерживает Condition и не детектит deadlock через interrupt так же явно. Подходит для коротких read-доминантных секций с высокой конкуренцией, но требует аккуратного validate() — забытая проверка молча читает неконсистентные данные.
Почему нельзя просто апгрейднуть read-lock до write-lock в ReentrantReadWriteLock?
Потому что если два потока одновременно держат read-lock и оба пытаются получить write-lock, они ждут, пока другой отпустит read — а никто не отпускает. Правильный паттерн: освободить read-lock, взять write-lock, заново проверить условие (double-checked).

Ещё разборы