synchronized — встроенный механизм Java для взаимного исключения. Он простой и надёжный, но иногда его возможностей не хватает. Для таких случаев в пакете java.util.concurrent.locks есть более гибкий инструмент — Lock.
Что не так со synchronized
synchronized работает по принципу «взял — подожди»: если один поток захватил монитор, все остальные блокируются и терпеливо ждут. Выйти раньше нельзя, прервать ожидание нельзя, попробовать взять блокировку «на посмотреть» тоже нельзя.
Представьте: поток ждёт захвата замка уже 10 секунд. Пользователь нажал «Отмена». Уведомить об этом ждущий поток через synchronized невозможно — он просто продолжит ждать.
Или другой пример: в системе много читателей и один редкий писатель. synchronized не различает чтение и запись — читатели будут блокировать друг друга, хотя одновременное чтение совершенно безопасно.
Именно для этих ситуаций и существуют явные блокировки.
ReentrantLock: основы
ReentrantLock — самая часто используемая реализация интерфейса Lock. «Reentrant» означает, что один и тот же поток может захватить замок несколько раз подряд (не застрянет сам на себе), и должен ровно столько же раз его отпустить.
Минимальная схема использования:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private final Lock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock(); // захватить замок
try {
count++; // критическая секция
} finally {
lock.unlock(); // ОБЯЗАТЕЛЬНО в finally
}
}
}
Правило железное: unlock() всегда в блоке finally. Если исключение вылетит до unlock(), замок останется захваченным навсегда — все остальные потоки зависнут. Блок finally гарантирует освобождение при любом исходе.
tryLock: попробовать, не зависая
tryLock() возвращает true, если замок удалось захватить прямо сейчас, и false, если он занят. Поток не блокируется — он сам решает, что делать дальше.
public boolean tryIncrement() {
if (lock.tryLock()) { // взяли замок?
try {
count++;
return true;
} finally {
lock.unlock();
}
}
return false; // замок занят, ничего страшного
}
Есть вариант с таймаутом — подождать заданное время и уйти, если не дождались:
if (lock.tryLock(200, TimeUnit.MILLISECONDS)) {
try {
// работаем
} finally {
lock.unlock();
}
} else {
// замок занят дольше 200 мс — делаем что-то другое
}
Это особенно полезно при работе с несколькими замками: взять первый, попробовать взять второй — если не удалось, отпустить первый и повторить позже. Такая тактика помогает избежать взаимной блокировки (deadlock).
lockInterruptibly: прерываемое ожидание
lockInterruptibly() ждёт захвата замка точно так же, как lock(), но с одним важным отличием: если поток получит прерывание (Thread.interrupt()), он немедленно выйдет из ожидания с исключением InterruptedException.
public void interruptibleWork() throws InterruptedException {
lock.lockInterruptibly(); // может выбросить InterruptedException
try {
// выполняем работу
} finally {
lock.unlock();
}
}
Обычный lock.lock() игнорирует прерывание — поток продолжает ждать даже после interrupt(). lockInterruptibly() нужен, когда важно уметь отменить операцию извне — например, при завершении приложения или по запросу пользователя.
Fairness: справедливая очередь
По умолчанию ReentrantLock не даёт гарантий, какой поток получит замок следующим. На практике часто выигрывает тот, кто попал «в нужный момент», — это называют нечестной (unfair) блокировкой. Она быстрее, потому что не нужно поддерживать очередь.
Если поток может бесконечно долго ждать, пока другие постоянно перехватывают замок — это голодание (starvation). Для таких случаев ReentrantLock поддерживает режим справедливости (fairness):
// true — включить справедливую очередь (FIFO по времени ожидания)
Lock fairLock = new ReentrantLock(true);
В справедливом режиме замок отдаётся потоку, который ждёт дольше всех. Это исключает голодание, но снижает общую скорость работы из-за накладных расходов на управление очередью.
Короткая формула: unfair — быстрее; fair — честнее. Используйте fair только тогда, когда голодание реально является проблемой.
ReadWriteLock: читатели и писатели
Когда данные читают часто, а изменяют редко, ReadWriteLock даёт значительный прирост производительности. У него два замка:
- read lock — можно захватить одновременно несколькими потоками (пока никто не пишет);
- write lock — эксклюзивный, дожидается, пока все читатели уйдут.
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class Cache {
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
private String value;
public String get() {
readLock.lock(); // несколько потоков могут читать одновременно
try {
return value;
} finally {
readLock.unlock();
}
}
public void set(String newValue) {
writeLock.lock(); // только один писатель, читателей нет
try {
value = newValue;
} finally {
writeLock.unlock();
}
}
}
Если операций чтения в разы больше, чем записи, ReadWriteLock существенно снижает конкуренцию между потоками.
StampedLock: оптимистичное чтение
StampedLock (Java 8+) — более продвинутая альтернатива ReadWriteLock. Его главная особенность — оптимистичное чтение: поток читает данные без захвата замка и только потом проверяет, не изменились ли они за это время.
import java.util.concurrent.locks.StampedLock;
public class Point {
private final StampedLock sl = new StampedLock();
private double x, y;
public double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); // читаем без блокировки
double cx = x, cy = y;
if (!sl.validate(stamp)) { // данные изменились во время чтения?
stamp = sl.readLock(); // тогда берём обычный read lock
try {
cx = x; cy = y;
} finally {
sl.unlockRead(stamp);
}
}
return Math.sqrt(cx * cx + cy * cy);
}
}
StampedLock даёт максимальную скорость при высокой частоте чтений, но сложнее в использовании: он не поддерживает реентерабельность и работает со «штампами» (long) вместо привычного интерфейса Lock.
Когда хватает synchronized
Явные блокировки нужны не всегда. synchronized вполне достаточно, когда:
- критическая секция простая и короткая;
- не нужно пробовать захватить замок без ожидания (
tryLock); - не нужно прерывать ожидание;
- нет разделения на читателей и писателей;
- код проще — меньше шансов забыть
unlock().
Короткая формула выбора:
| Нужна возможность | Выбор |
|---|---|
| Просто защитить секцию | synchronized |
tryLock или таймаут | ReentrantLock |
| Прерывание ожидания | ReentrantLock |
| Много читателей, редкий писатель | ReadWriteLock |
| Максимальная скорость при чтении | StampedLock |
Коротко
Lock— явный замок изjava.util.concurrent.locks;ReentrantLock— основная реализация.unlock()всегда в блокеfinally— без исключений.tryLock()возвращает управление немедленно, если замок занят; с таймаутом — ждёт заданное время.lockInterruptibly()позволяет прервать ожидание черезThread.interrupt().- Справедливый режим (
new ReentrantLock(true)) устраняет голодание, но работает медленнее. ReadWriteLockускоряет сценарии «много читателей, редкий писатель».StampedLockдаёт оптимистичное чтение без захвата замка — быстрее, но сложнее.synchronizedпо-прежнему хорош для простых случаев — не усложняйте без причины.
Что почитать дальше
- Synchronized и монитор объекта — как работает встроенная блокировка Java и что происходит внутри монитора.
- Атомарные операции и CAS — когда блокировки вообще не нужны:
AtomicInteger,AtomicReferenceи оптимистичные обновления. - Типичные ошибки многопоточности — deadlock, livelock, голодание и гонки данных: как они возникают и как их избежать.