synchronized — встроенный механизм Java для взаимного исключения: простой и надёжный, но иногда его возможностей не хватает. На такие случаи в пакете java.util.concurrent.locks есть инструмент погибче — Lock.
Поток A взял замок и работает. Поток B вызвал lock() и встал в очередь: он проснётся только после unlock() и ничего другого за это время не сделает. Поток C вызвал tryLock(), получил false и сразу ушёл заниматься другой работой — в этом и разница между явной блокировкой и synchronized, где вариант «не ждать» недоступен.
Что не так со 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
}
}
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable job = () -> { for (int i = 0; i < 100_000; i++) counter.increment(); };
Thread a = new Thread(job);
Thread b = new Thread(job);
a.start();
b.start();
a.join();
b.join();
System.out.println("после двух потоков: " + counter.count);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Печатает ровно 200000. Уберите lock() и unlock() — каждый запуск даст своё число, и меньшее.
Правило железное: unlock() всегда в блоке finally. Вылетит исключение до unlock() — замок останется захваченным навсегда, и все остальные потоки зависнут.
Ещё два правила из той же семьи, и оба про то, что замок теперь ваша ответственность, а не JVM. Первое: отпускать замок должен тот же поток, который его взял. unlock() из чужого потока бросает IllegalMonitorStateException — замок помнит владельца.
Второе следует из первого: lock() в одном методе и unlock() в другом почти всегда ошибка проектирования. Формально язык это позволяет (в отличие от synchronized, где скобки блока не дают разъехаться), но проверить такой код глазами невозможно, а любой ранний return или исключение на пути между двумя методами оставляет замок захваченным навсегда. Захват и освобождение держат в одном методе, в паре try/finally.
И одна гарантия, о которой после статьи про модель памяти спрашивают отдельно: Lock даёт тот же happens-before, что и монитор. Освобождение замка happens-before его следующего захвата другим потоком, поэтому всё, что вы записали под замком, увидит тот, кто возьмёт замок после вас. Никаких дополнительных volatile для защищённых полей не нужно — ни с synchronized, ни с ReentrantLock.
tryLock: попробовать, не зависая
tryLock() возвращает true, если замок удалось захватить прямо сейчас, и false, если он занят: поток не блокируется и сам решает, что делать дальше. Есть и вариант с таймаутом — подождать заданное время и уйти ни с чем. В примере ниже замок держит главный поток, а сосед пробует оба варианта.
живой пример
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class TryLockDemo {
private static final Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
lock.lock(); // замок занял главный поток
Thread worker = new Thread(() -> {
System.out.println("tryLock() сразу: " + lock.tryLock());
try {
boolean got = lock.tryLock(300, TimeUnit.MILLISECONDS);
System.out.println("tryLock() с ожиданием: " + got);
if (got) {
lock.unlock();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
worker.start();
Thread.sleep(100);
lock.unlock(); // отпускаем, пока сосед ещё ждёт
worker.join();
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Вывод: false в первой строке, true во второй. Сигнатуры разные: tryLock() без аргументов InterruptedException не бросает, а вариант с таймаутом — бросает, он ведь ждёт.
Так берут несколько замков: взять первый, попробовать второй, не вышло — отпустить первый и повторить позже. Это спасает от взаимной блокировки (deadlock).
lockInterruptibly: прерываемое ожидание
lockInterruptibly() ждёт захвата замка точно так же, как lock(), но с одним важным отличием: если поток получит прерывание (Thread.interrupt()), он немедленно выйдет из ожидания с исключением InterruptedException.
живой пример
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class InterruptibleDemo {
private static final Lock lock = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
lock.lock(); // замок занят и не освободится
Thread waiting = new Thread(() -> {
try {
lock.lockInterruptibly(); // ждём, но откликаемся на прерывание
try {
System.out.println("вошли в секцию");
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
System.out.println("ожидание прервано, замок так и не взяли");
}
});
waiting.start();
Thread.sleep(100);
waiting.interrupt(); // обычный lock() это прерывание не заметил бы
waiting.join();
lock.unlock();
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Замените lockInterruptibly() на lock() — программа не завершится: поток продолжит ждать и после interrupt(). Прерываемое ожидание нужно там, где операцию надо уметь отменить извне: остановка приложения, отказ пользователя.
Condition: очередь ожидания у явного замка
У монитора есть wait/notify — очередь потоков, ждущих условия. У явного замка есть то же самое, но лучше: Condition, и таких очередей у одного замка может быть несколько.
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class BoundedBuffer<T> {
private final Object[] items;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // здесь ждут производители
private final Condition notEmpty = lock.newCondition(); // здесь ждут потребители
private int count, head, tail;
public BoundedBuffer(int capacity) { items = new Object[capacity]; }
public void put(T item) throws InterruptedException {
lock.lock();
try {
while (count == items.length) notFull.await(); // отпускает замок и ждёт
items[tail] = item;
tail = (tail + 1) % items.length;
count++;
notEmpty.signal(); // будим одного потребителя
} finally {
lock.unlock();
}
}
@SuppressWarnings("unchecked")
public T take() throws InterruptedException {
lock.lock();
try {
while (count == 0) notEmpty.await();
T item = (T) items[head];
head = (head + 1) % items.length;
count--;
notFull.signal(); // будим одного производителя
return item;
} finally {
lock.unlock();
}
}
}
Соответствие простое: await() это wait(), signal() это notify(), signalAll() это notifyAll(); условие так же проверяют в while, а не в if, и по тем же двум причинам. Выигрыш — в раздельных очередях. У монитора очередь одна на всех, поэтому notify() может разбудить не того, и приходится звать notifyAll(), поднимая всех без разбора. Здесь производители ждут на notFull, потребители на notEmpty, и signal() будит ровно того, кому адресовано.
Два бонуса, которых у wait нет вовсе: await(2, TimeUnit.SECONDS) ждёт с таймаутом и возвращает false, если срок истёк, а awaitUninterruptibly() ждёт, игнорируя прерывание. И правило то же, что у монитора: await и signal вызывают, только держа замок, иначе IllegalMonitorStateException.
Писать такой буфер руками не нужно — это в точности ArrayBlockingQueue из статьи про потокобезопасные коллекции, и внутри у неё ровно этот код. Но когда условие сложнее, чем «пусто или полно», Condition — единственный способ его выразить.
Fairness: справедливая очередь
По умолчанию ReentrantLock не обещает, какой поток получит замок следующим: обычно выигрывает тот, кто попал «в нужный момент». Это нечестная (unfair) блокировка, и она быстрее — очередь поддерживать не надо.
Если поток ждёт бесконечно, пока другие перехватывают замок, — это голодание (starvation), как оно выглядит в живом сервисе, разобрано в статье про ошибки многопоточности. На такой случай у ReentrantLock есть режим справедливости (fairness):
// true — включить справедливую очередь (FIFO по времени ожидания)
Lock fairLock = new ReentrantLock(true);
В справедливом режиме замок достаётся тому, кто ждёт дольше всех: голодания нет, но платят за это дорого. Пропускная способность падает в разы, и вот почему: вместо того чтобы отдать освободившийся замок потоку, который прямо сейчас крутится на процессоре, замок будят из очереди — а это переключение контекста на каждом захвате. Поэтому справедливый режим включают там, где голодание реально мешает, а не «на всякий случай».
И ещё одна деталь, о которой узнают поздно: tryLock() без аргументов очередь не соблюдает вовсе. Даже у замка, созданного как new ReentrantLock(true), он заберёт свободный замок немедленно, вперёд всех, кто честно стоит в очереди. Вариант с таймаутом, tryLock(300, TimeUnit.MILLISECONDS), справедливость уважает. Так что «включили fairness и успокоились» не работает: один tryLock() в коде обнуляет договорённость.
ReadWriteLock: читатели и писатели
Когда данные читают часто, а изменяют редко, выручает ReadWriteLock — у него два замка вместо одного. Read lock захватывают одновременно сколько угодно потоков, пока никто не пишет; write lock эксклюзивный и дожидается, пока уйдут все читатели.
Читателей внутри может быть сколько угодно, а писатель ждёт, пока уйдёт последний: на шаге 4 внутри он один.
import java.util.concurrent.locks.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();
}
}
}
Если чтений в разы больше, чем записей, конкуренция между потоками падает заметно. Но граблей тут две.
Первая: «повысить» read lock до write внутри одного потока нельзя — писатель ждёт ухода читателей, а читатель — он сам, и поток встаёт намертво. Обратный порядок, понижение write до read, разрешён и нужен чаще, чем кажется: он даёт обновить данные и тут же продолжить читать их, ни на мгновение не отпустив защиту. Между unlock() записи и lock() чтения кто-то успел бы вклиниться и поменять значение; при понижении такой щели нет.
writeLock.lock();
try {
value = recalculate();
readLock.lock(); // взяли чтение, ещё держа запись
} finally {
writeLock.unlock(); // отпустили запись — остались с чтением
}
try {
return render(value); // читаем то, что сами записали
} finally {
readLock.unlock();
}
Вторая серьёзнее и вылезает как раз под нагрузкой. В нечестном режиме, а он здесь по умолчанию, непрерывный поток читателей может держать писателя в ожидании сколь угодно долго: пока внутри есть хоть один читатель, писателя не пускают, а читатели всё прибывают. Данные при этом не обновляются часами, и выглядит это не как ошибка, а как «почему-то отдаём старое значение». Лечится справедливым режимом — new ReentrantReadWriteLock(true), — и тогда возвращается разговор про цену справедливости.
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 быстрее всех, когда читают часто, а пишут редко. Как только доля записей становится заметной, оптимистичное чтение раз за разом не проходит validate() и вырождается в повторные попытки — выигрыша не остаётся.
Плата за скорость — сложность: замок не реентерабельный и работает со «штампами» (long) вместо привычного интерфейса Lock. И есть правило безопасности, нарушение которого даёт самые загадочные падения: всё, что прочитано оптимистично, до validate() — просто набор чисел, которому нельзя верить.
Эти значения нельзя разыменовывать и нельзя использовать для ветвления: между собой они могут быть несогласованы (x из старого состояния, y из нового), а прочитанная так ссылка может оказаться уже недействительной. Порядок всегда один: скопировали в локальные переменные, вызвали validate(), и только после этого что-то с ними делаем — ровно как в примере.
Когда хватает synchronized
Явные блокировки нужны не всегда. synchronized хватает, когда секция короткая, ждать всё равно придётся до конца, а читателей от писателей никто не отделяет — и код проще.
Главный довод в пользу монитора именно такой: JVM отпускает его при любом выходе из блока — по return, по исключению, как угодно. У явного замка эта обязанность на вас, и забытый unlock() — не «чуть хуже», а намертво зависшее приложение: все, кто придёт за этим замком, встанут и не уйдут уже никогда. Поэтому ReentrantLock берут, когда нужна конкретная его возможность из таблицы ниже, а не «потому что он современнее».
Короткая формула выбора:
| Нужна возможность | Выбор |
|---|---|
| Просто защитить секцию | synchronized |
tryLock или таймаут | ReentrantLock |
| Прерывание ожидания | ReentrantLock |
| Много читателей, редкий писатель | ReadWriteLock |
| Максимальная скорость при чтении | StampedLock |
Диагностика: что видно снаружи
У явного замка есть то, чего нет у монитора: он может рассказать о себе. ReentrantLock умеет isLocked() (занят ли), isHeldByCurrentThread() (мной ли), getHoldCount() (сколько раз я в него вошёл) и getQueueLength() (сколько потоков ждёт). Последнее особенно полезно как метрика: растущая очередь у замка — прямое объяснение растущей задержки сервиса, и это число стоит отдавать в мониторинг.
Обратная сторона проявляется при разборе зависания. Поток, стоящий на входе в synchronized, виден в дампе как BLOCKED с понятной строкой «waiting to lock <0x...>». Поток, ждущий ReentrantLock, выглядит иначе: состояние WAITING (parking), а в стеке sun.misc.Unsafe.park и AbstractQueuedSynchronizer$ConditionObject или acquireQueued. Слова «lock» там может не быть вовсе, и новичок делает вывод «поток просто спит». Признак ровно один — AbstractQueuedSynchronizer в стеке: это и есть ожидание явного замка. А вот findDeadlockedThreads() и автоматический поиск дедлока в jstack понимают и мониторы, и явные замки — так что взаимную блокировку JVM найдёт в обоих случаях.
Глубже: примитивы координации: латч, барьер, семафоррасширенное
Замки защищают данные. Другой класс задач про порядок: дождаться, пока N потоков закончат, впустить не больше K одновременно, собрать всех на контрольной точке. Для них в java.util.concurrent свои примитивы.
CountDownLatch это счётчик в одну сторону: потоки-работники вызывают countDown(), ожидающий стоит в await(), пока счётчик не дойдёт до нуля. Использовать повторно нельзя. Классика: главный поток запускает пять загрузчиков и ждёт всех.
CountDownLatch done = new CountDownLatch(5);
for (Source s : sources) pool.submit(() -> { load(s); done.countDown(); });
done.await(30, TimeUnit.SECONDS); // с таймаутом: без него зависший загрузчик повесит и нас
CyclicBarrier собирает N потоков в одной точке и отпускает всех разом, после чего готов к следующему кругу: так синхронизируют шаги параллельной симуляции. Semaphore держит K разрешений: acquire() берёт, release() возвращает, и это самый простой ограничитель на «не больше десяти одновременных запросов к внешнему API». Phaser это барьер, у которого число участников меняется на ходу; Exchanger даёт двум потокам обменяться объектами в точке встречи, редкость.
Правило про все сразу: каждое ожидание с таймаутом. await() без срока превращает потерянный countDown() (упавший поток до него не дошёл) в вечное ожидание. И второе: латч с таймаутом закрывает половину случаев, где раньше писали Thread.sleep(1000) в тесте «чтобы успело»: ждать надо событие, а не время.
Коротко
Lock— явный замок изjava.util.concurrent.locks,ReentrantLock— основная реализация;unlock()всегда в блокеfinally.tryLock()не блокирует поток: занят — вернётfalse; вариант с таймаутом ждёт заданное время и бросаетInterruptedException.lockInterruptibly()даёт прервать ожидание черезThread.interrupt(), обычныйlock()прерывание игнорирует.- Справедливый режим (
new ReentrantLock(true)) устраняет голодание ценой скорости. ReadWriteLockускоряет «много читателей, редкий писатель»; повысить read lock до write нельзя.StampedLockчитает без захвата замка: быстрее, но сложнее; для простых секций хватаетsynchronized.- Координация без замков:
CountDownLatchдождаться N,CyclicBarrierсобрать всех на точке,Semaphoreвпустить не больше K; всякое ожидание с таймаутом. Conditionзаменяетwait/notifyу явного замка: несколько очередей на один замок (notFull,notEmpty),awaitс таймаутом, точечныйsignalвместоnotifyAll.unlockиз чужого потока бросаетIllegalMonitorStateException, аlockиunlockв разных методах — ошибка проектирования; замок даёт тот же happens-before, что и монитор.- Понижение write до read без щели даёт дочитать то, что сами записали; снаружи замок видно через
getQueueLength(), а в дампе ожидание выглядит какWAITING (parking)сAbstractQueuedSynchronizerв стеке.
Что почитать дальше
- Synchronized и монитор объекта — как работает встроенная блокировка Java и что происходит внутри монитора.
- Атомарные операции и CAS — когда блокировки вообще не нужны:
AtomicInteger,AtomicReferenceи оптимистичные обновления. - Потокобезопасные коллекции — структуры, где блокировки уже расставлены за вас.
- Типичные ошибки многопоточности — deadlock, livelock, голодание и гонки данных: как они возникают и как их избежать.