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

synchronized — встроенный механизм Java для взаимного исключения: простой и надёжный, но иногда его возможностей не хватает. На такие случаи в пакете java.util.concurrent.locks есть инструмент погибче — Lock.

три потока, один замок, время идёт слева направо поток A поток B поток C критическая секцияlock() секция пройденаunlock() lock() — ждёт в очереди критическая секция tryLock() → false делает другое, не ждёт

Поток 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 эксклюзивный и дожидается, пока уйдут все читатели.

шаг 1 пятеро читателей внутри шаг 2 писатель у входа шаг 3 читатели вышли шаг 4 писатель внутри один шаг 5 читатели входят снова

Читателей внутри может быть сколько угодно, а писатель ждёт, пока уйдёт последний: на шаге 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. Его главная особенность — оптимистичное чтение: поток читает данные без захвата замка и только потом проверяет, не изменились ли они за это время.

читаем поля validate без замка считаем ответ штамп цел берём readLock штамп протух

Оптимистичное чтение идёт без замка, и всё решает проверка штампа: цел - ответ готов, протух - только тогда берут настоящий замок.

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 в стеке.

Что почитать дальше