← назад к разделу

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, голодание и гонки данных: как они возникают и как их избежать.