JavaRush /Курсы /JAVA 25 SELF /StampedLock и низкоспорные счётчики

StampedLock и низкоспорные счётчики

JAVA 25 SELF
58 уровень , 3 лекция
Открыта

1. Почему не всегда хватает ReadWriteLock

Проблема высокой конкуренции на запись

ReadWriteLock (чаще всего — ReentrantReadWriteLock) хорошо работает, когда большинство потоков только читают данные, а писателей мало. В этом случае несколько потоков могут читать одновременно, а запись блокирует доступ лишь на короткий момент.

Проблема возникает, когда потоков, которые пишут, становится больше, чем ожидалось, или они часто переключаются между чтением и записью. Если операции записи занимают много времени, потоки начинают дольше ждать освобождения блокировок. В результате конкуренция за доступ к данным растёт, чтение и запись замедляются, и эффективность ReadWriteLock падает.

Дорогое переключение блокировок

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

Если такие переходы происходят часто, это создаёт задержки. Потоки начинают стоять в очереди, и производительность падает — особенно когда одновременно много операций чтения и записи.

ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();

// Чтение
readLock.lock();
try {
    // читаем данные
} finally {
    readLock.unlock();
}

// Запись
writeLock.lock();
try {
    // меняем данные
} finally {
    writeLock.unlock();
}

Каждый раз, когда поток переходит между readLock и writeLock, система выполняет все проверки, чтобы избежать конфликтов. Если таких переходов много — это дорого.

2. StampedLock: современный подход к синхронизации

StampedLock — это современный механизм синхронизации, появившийся в Java 8. Он сочетает в себе идеи ReadWriteLock, но добавляет новый режим — оптимистичное чтение, а также работает не с блокировками, а с «штампами» (stamps) — специальными токенами, которые нужно явно освобождать.

Ключевые особенности:

  • Три режима: write lock (эксклюзивная запись), read lock (разделяемое чтение), optimistic read (оптимистичное чтение без блокировки).
  • Нет reentrancy: нельзя войти в блокировку повторно из того же потока.
  • Высокая производительность при большом количестве чтений и редких записях.
  • Требует явного управления штампами (stamp).

Оптимистичное чтение: tryOptimisticRead + validate

Оптимистичное чтение — это режим, когда поток читает данные без блокировки вообще, надеясь, что в этот момент никто не пишет. После чтения поток должен проверить, не было ли записи во время чтения, с помощью метода validate(stamp).

import java.util.concurrent.locks.StampedLock;

public class Point {
    private double x, y;
    private final StampedLock lock = new StampedLock();

    public double distanceFromOrigin() {
        long stamp = lock.tryOptimisticRead();
        double currentX = x;
        double currentY = y;
        // Проверяем, не было ли записи во время чтения
        if (!lock.validate(stamp)) {
            // Если была запись — берём обычный read lock
            stamp = lock.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return Math.hypot(currentX, currentY);
    }
}

Как это работает:

  • tryOptimisticRead() возвращает штамп (long), который «действителен», пока никто не пишет.
  • Читаем значения x и y.
  • validate(stamp) проверяет, не было ли записи между началом и концом чтения.
  • Если всё ок — используем прочитанные значения; если была запись — берём обычный readLock и читаем заново.

Это выгодно, когда записей очень мало, а чтений — много. В большинстве случаев validate вернёт true, и чтение будет почти бесплатным.

Сценарии «много чтения — мало записи»

  • Данные редко меняются, но часто читаются (например, кэш, координаты, метаданные).
  • Важно минимизировать задержки на чтение.
  • Можно «перечитать» данные, если вдруг была запись.

Падение обратно на read lock

Если оптимистичное чтение не удалось (validate вернул false), поток «откатывается» к обычному readLock. Это гарантирует корректность данных, но происходит редко.

3. Подводные камни StampedLock

Отсутствие reentrancy

В отличие от ReentrantReadWriteLock, StampedLock не поддерживает повторный вход. Если поток уже держит блокировку и попытается взять её снова — будет deadlock.

long stamp1 = lock.writeLock();
long stamp2 = lock.writeLock(); // DEADLOCK! Поток ждёт сам себя

Внимательность к прерываниям

StampedLock не реагирует на прерывания потоков так же, как классические блокировки. Если поток был прерван во время ожидания блокировки, он не всегда «просыпается» сразу. Для задач, где важна быстрая реакция на прерывание, используйте другие механизмы.

Корректное освобождение штампов

Каждый вызов readLock(), writeLock() или tryOptimisticRead() возвращает уникальный штамп (long). Его обязательно нужно передать в соответствующий метод unlock:

  • unlockRead(stamp)
  • unlockWrite(stamp)

Ошибка: Если перепутать штампы или забыть вызвать unlock — получите утечку блокировок и зависание программы.

4. Сравнение с ReentrantReadWriteLock

Характеристика ReentrantReadWriteLock StampedLock
Reentrancy (повторный вход) Да Нет
Оптимистичное чтение Нет Да
Производительность при высокой конкуренции Средняя Высокая (при мало записей)
Явное управление блокировкой Нет (автоматически) Да (штампы)
Реакция на прерывания Да Не всегда
Справедливость (fairness) Да (можно включить) Нет

Режимы справедливости и влияние на starvation

  • В ReentrantReadWriteLock можно включить «справедливый» режим (fair), чтобы потоки обслуживались в порядке очереди. Это предотвращает starvation (голодание).
  • В StampedLock справедливости нет: потоки могут ждать дольше, если другие всё время «перехватывают» блокировку. Возможен редкий starvation.

5. Счётчики: LongAdder/LongAccumulator vs AtomicLong

Проблема с AtomicLong при высокой конкуренции

AtomicLong — атомарная переменная, обеспечивающая потокобезопасный инкремент. Но при большом количестве потоков, одновременно вызывающих incrementAndGet(), все они «дерутся» за одну переменную, что приводит к падению производительности.

LongAdder: полосные (striped) счётчики

LongAdder решает проблему иначе: он разбивает счётчик на несколько «полос» (stripes), каждая из которых обслуживает свою группу потоков. Поток увеличивает одну из полос, а итоговое значение — сумма всех полос.

Преимущество:

  • При высокой конкуренции потоки почти не мешают друг другу.
  • Производительность в разы выше, чем у AtomicLong.
import java.util.concurrent.atomic.LongAdder;

LongAdder adder = new LongAdder();

Runnable task = () -> {
    for (int i = 0; i < 100_000; i++) {
        adder.increment();
    }
};

Thread[] threads = new Thread[8];
for (int i = 0; i < threads.length; i++) {
    threads[i] = new Thread(task);
    threads[i].start();
}
for (Thread t : threads) t.join();

System.out.println("Итоговое значение: " + adder.sum());

LongAccumulator

LongAccumulator — обобщённая версия LongAdder, где можно задать произвольную функцию накопления (например, максимум, минимум и т.д.).

import java.util.concurrent.atomic.LongAccumulator;

LongAccumulator max = new LongAccumulator(Long::max, Long.MIN_VALUE);

max.accumulate(10);
max.accumulate(42);
max.accumulate(7);

System.out.println("Максимум: " + max.get()); // 42

Полосные (striped) блокировки и снижение конкуренции

Техника striped locks разбивает общий ресурс на несколько независимых частей (stripes), каждая защищена своей блокировкой или переменной. Потоки равномерно распределяются по полосам, что снижает конкуренцию и увеличивает производительность. Именно этот подход используют LongAdder и LongAccumulator.

6. Практика: кэш с преобладающим чтением

Задача: у нас есть карта (Map) с данными и метаданные (например, счётчик обращений). Чтения происходят часто, записи — редко.

Реализация с StampedLock:

import java.util.*;
import java.util.concurrent.locks.StampedLock;
import java.util.concurrent.atomic.LongAdder;

public class MetadataCache<K, V> {
    private final Map<K, V> map = new HashMap<>();
    private final StampedLock lock = new StampedLock();
    private final LongAdder hits = new LongAdder();

    public V get(K key) {
        long stamp = lock.tryOptimisticRead();
        V value = map.get(key);
        if (!lock.validate(stamp)) {
            stamp = lock.readLock();
            try {
                value = map.get(key);
            } finally {
                lock.unlockRead(stamp);
            }
        }
        if (value != null) hits.increment();
        return value;
    }

    public void put(K key, V value) {
        long stamp = lock.writeLock();
        try {
            map.put(key, value);
        } finally {
            lock.unlockWrite(stamp);
        }
    }

    public long getHits() {
        return hits.sum();
    }
}
  • Для чтения используется оптимистичный режим: если никто не пишет — чтение почти бесплатное.
  • Для записи — write lock.
  • Для подсчёта обращений — LongAdder: даже при высокой конкуренции инкременты не мешают друг другу.

7. Профилирование LongAdder vs AtomicLong под нагрузкой

Тест: 8 потоков по 1 млн инкрементов

import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;

public class CounterBenchmark {
    public static void main(String[] args) throws InterruptedException {
        int threads = 8;
        int increments = 1_000_000;

        // AtomicLong
        AtomicLong atomic = new AtomicLong();
        long start = System.nanoTime();
        Thread[] t1 = new Thread[threads];
        for (int i = 0; i < threads; i++) {
            t1[i] = new Thread(() -> {
                for (int j = 0; j < increments; j++) atomic.incrementAndGet();
            });
            t1[i].start();
        }
        for (Thread t : t1) t.join();
        long timeAtomic = System.nanoTime() - start;

        // LongAdder
        LongAdder adder = new LongAdder();
        start = System.nanoTime();
        Thread[] t2 = new Thread[threads];
        for (int i = 0; i < threads; i++) {
            t2[i] = new Thread(() -> {
                for (int j = 0; j < increments; j++) adder.increment();
            });
            t2[i].start();
        }
        for (Thread t : t2) t.join();
        long timeAdder = System.nanoTime() - start;

        System.out.printf("AtomicLong: %d ms, LongAdder: %d ms%n", timeAtomic / 1_000_000, timeAdder / 1_000_000);
    }
}

Типичный результат:

AtomicLong: 2500 ms, LongAdder: 200 ms

Вывод: При высокой конкуренции LongAdder в разы быстрее AtomicLong.

8. Типичные ошибки при использовании StampedLock и LongAdder

Ошибка №1: забыли вызвать unlockRead/unlockWrite. Если не освободить штамп, другие потоки будут ждать вечно. Всегда используйте try/finally!

Ошибка №2: попытка reentrancy. StampedLock не поддерживает повторный вход. Не берите блокировку дважды из одного потока.

Ошибка №3: неправильное использование validate. Если не проверить validate после tryOptimisticRead, можно получить неконсистентные данные.

Ошибка №4: использование AtomicLong при высокой конкуренции. AtomicLong хорош для 1–2 потоков, но при 8+ потоках становится «узким горлышком». Используйте LongAdder.

Ошибка №5: забыли про полосные блокировки. Если вы реализуете свой striped-lock, убедитесь, что потоки равномерно распределяются по полосам, иначе часть полос будет перегружена, а другие — простаивать.

Ошибка №6: ожидание справедливости от StampedLock. StampedLock не гарантирует порядок обслуживания потоков. В редких случаях возможен starvation.

1
Задача
JAVA 25 SELF, 58 уровень, 3 лекция
Недоступна
Центр Управления Полётами: Система Кэширования Высоты 🛰️
Центр Управления Полётами: Система Кэширования Высоты 🛰️
1
Задача
JAVA 25 SELF, 58 уровень, 3 лекция
Недоступна
Навигатор Дрона: Оптимистичный Расчёт Позиции 🚁
Навигатор Дрона: Оптимистичный Расчёт Позиции 🚁
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ