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 над одним числом.
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.
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 "страйпов", и конкурируют только потоки, попавшие в один и тот же сегмент. Чем больше страйпов, тем меньше конкуренция, но больше памяти и риск случайных коллизий по хэшу.