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 (оптимістичне читання без блокування).
- Немає реентрантності: не можна увійти в блокування повторно з того самого потоку.
- Висока продуктивність за великої кількості читань і рідкісних записів.
- Потребує явного керування штампами (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
Відсутність реентрантності
На відміну від 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 |
|---|---|---|
| Реентрантність (повторний вхід) | Так | Ні |
| Оптимістичне читання | Ні | Так |
| Продуктивність за високої конкуренції | Середня | Висока (коли записів мало) |
| Явне керування блокуванням | Ні (автоматично) | Так (штампи) |
| Реакція на переривання | Так | Не завжди |
| Справедливість (fairness) | Так (можна ввімкнути) | Ні |
Режими справедливості та вплив на starvation
- У ReentrantReadWriteLock можна ввімкнути «справедливий» режим (fair), щоб потоки обслуговувалися в порядку черги. Це запобігає starvation (голодуванню).
- У StampedLock справедливості немає: потоки можуть чекати довше, якщо інші весь час «перехоплюють» блокування. Можливе рідкісне голодування.
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 не гарантує порядок обслуговування потоків. У поодиноких випадках можливе голодування.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ