StampedLock: читаем без блокировок, если повезёт
В прошлый раз мы разобрали ReadWriteLock — отличный инструмент, когда читателей много, а писателей мало. Но у него есть пара врождённых недостатков:
- Голодание писателей — если читатели приходят непрерывно, писатель может ждать вечность (даже с честной очередью, но оно требует дополнительных накладных расходов).
- Оверхед — каждый захват readLock() и writeLock() — это полноценный фрейм синхронизации, который влечёт переключения контекста.
- Нельзя "поднять" читателя до писателя — попытка взять write-лок, уже держа read-лок, гарантированно приведёт к дедлоку.
В Java 8 появился StampedLock — альтернатива, которая пытается решить эти проблемы, добавляя оптимистическое чтение. Давайте посмотрим, как это работает.
В StampedLock есть три вида операций:
- Write-лок — эксклюзивный, как и раньше.
- Read-лок — разделяемый, как у ReadWriteLock.
- Optimistic read — это не блокировка в привычном смысле. Вы просто запоминаете "штамп" (число) и потом проверяете, не изменились ли данные за время чтения. Если не изменились — вы считали данные без единого захвата блокировки. Если изменились — вы можете повторить операцию уже с read-локом.
Это даёт огромный выигрыш в сценариях, где чтение очень частое, а запись — редкая, причём конфликты между чтением и записью маловероятны.
Как это выглядит в коде.
private final StampedLock lock = new StampedLock();
private int balance;
public int getBalance() {
// Оптимистическая попытка
long stamp = lock.tryOptimisticRead();
int currentBalance = this.balance; // читаем данные
// Проверяем, не было ли записи после получения штампа
if (!lock.validate(stamp)) {
// Данные изменились — переходим к пессимистичному чтению
stamp = lock.readLock();
try {
currentBalance = this.balance;
} finally {
lock.unlockRead(stamp);
}
}
return currentBalance;
}
public void deposit(int amount) {
long stamp = lock.writeLock();
try {
this.balance += amount;
} finally {
lock.unlockWrite(stamp);
}
}
Обратите внимание: tryOptimisticRead() не блокирует никакие другие операции. Даже если в этот момент писатель меняет баланс, мы всё равно прочитаем значение (может быть, неконсистентное), но потом при вызове validate() узнаем, что штамп устарел, и перечитаем уже под защитой read-лока.
Идеальный сценарий — объект почти не меняется, а читается постоянно. Например, кэш курсов валют, обновляемый раз в минуту. В этом случае tryOptimisticRead() почти всегда проходит успешно, и мы избегаем любых блокировок — ни synchronized, ни даже атомарных операций. Простое чтение поля. Производительность практически как у обычного доступа без синхронизации.
Опасный сценарий — частые записи. Тогда оптимистические чтения будут часто проваливаться, и мы будем постоянно скатываться к read-локу, что даже медленнее, чем обычный ReadWriteLock, потому что добавляет лишние проверки.
Важные нюансы, которые надо знать
StampedLock не реентерабельный. Попытка повторно захватить write-лок из того же потока приведёт к дедлоку. Нет счётчика удержаний, как у ReentrantReadWriteLock. Будьте аккуратны с вложенными вызовами.
Нет поддержки условий (Condition). Если вам нужны await/signal, используйте ReentrantReadWriteLock.
Преобразование режимов — есть методы tryConvertToWriteLock() и tryConvertToReadLock(). Они позволяют атомарно перейти от одного режима к другому, если это возможно. Например, вы взяли read-лок, прочитали данные и поняли, что хотите их изменить — можно попытаться "повысить" до write-лока без отпускания read-лока. Если в этот момент нет других читателей, преобразование пройдёт успешно. Это безопаснее, чем отпустить read и взять write (потому что между ними может вклиниться другой писатель).
Штамп обязательно проверять — никогда не игнорируйте validate(). И не пытайтесь использовать штамп от одного режима в другом — это приведёт к ошибке.