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

Когда несколько потоков обращаются к одним и тем же данным одновременно, что-то может пойти не так. synchronized — первый и самый прямой инструмент Java, чтобы этого не допустить.

без монитора поток 1 поток 2 value++читает 42, пишет 43 43 оба записали 43 — второе приращение потерялось synchronized (lock) — по одному поток 1 монитор lock поток 2 захватил монитор занято — ждёт у входа value 42 → 43 отпустил монитортеперь монитор у потока 2 value 43 → 44

Сверху: без монитора оба потока читают 42 и оба пишут 43 — одно приращение теряется. Снизу: synchronized пускает в секцию по одному, второй ждёт у входа и, войдя, видит результат первого. Итог — 44.

Что ломается без защиты

Представьте счётчик посещений. Два потока читают значение 42, оба прибавляют 1, оба пишут обратно 43. Одно из двух приращений потерялось — итог 43 вместо 44. Это состояние гонки (race condition).

Операция value++ на самом деле три отдельных шага: прочитать, прибавить, записать. Между любыми двумя из них другой поток может вмешаться. Вот та же арифметика на двух потоках: сначала без защиты, потом под монитором.

живой пример

public class LostUpdates {
    static int plain = 0;
    static int guarded = 0;
    static final Object lock = new Object();

    static void inTwoThreads(Runnable job) throws InterruptedException {
        Thread one = new Thread(job);
        Thread two = new Thread(job);
        one.start();
        two.start();
        one.join();
        two.join();
    }

    public static void main(String[] args) throws InterruptedException {
        inTwoThreads(() -> {
            for (int i = 0; i < 1_000_000; i++) {
                plain++;
            }
        });
        inTwoThreads(() -> {
            for (int i = 0; i < 1_000_000; i++) {
                synchronized (lock) {
                    guarded++;
                }
            }
        });
        System.out.println("ожидали:          2000000");
        System.out.println("value++ как есть: " + plain);
        System.out.println("под synchronized: " + guarded);
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Второе число — всегда ровно два миллиона. Первое каждый запуск разное и меньше: часть приращений затёрлась.

Критическая секция — участок кода, который в каждый момент времени должен выполнять только один поток. synchronized — способ обозначить эту секцию.

Что такое монитор

Каждый объект в Java имеет встроенный монитор (он же внутренний замок, intrinsic lock). У монитора две части. Первая — сам замок: поток, который «захватил» монитор, входит в критическую секцию, все остальные ждут у закрытой двери. Вторая — очередь ожидания: место, где потоки спят до чужого сигнала. Про неё понадобится вспомнить в разделе про wait() и notify() — они живут на том же объекте именно потому, что очередь принадлежит монитору.

Короткая формула: захватил монитор → вошёл в секцию → отпустил монитор → следующий может войти.

Монитор всегда принадлежит какому-то объекту. Какому именно — зависит от формы synchronized. И он повторно входим (reentrant): поток, уже державший замок, входит в другой synchronized-блок на том же объекте и сам себя не блокирует.

Звучит как мелочь, но без этого свойства не работал бы обычный код. Синхронизированный метод, вызывающий соседний синхронизированный метод того же объекта, встал бы намертво сам на себе:

class Account {
    private long balance;

    synchronized void deposit(long amount) {
        balance += amount;
    }

    synchronized void transferFrom(Account other, long amount) {
        other.withdraw(amount);
        deposit(amount);          // второй раз берём тот же монитор — с реентерабельностью это можно
    }
}

JVM считает не «занят или нет», а сколько раз текущий поток вошёл: на входе счётчик растёт, на выходе уменьшается, и монитор отпускается, когда счётчик дошёл до нуля. Оговорка одна: реентерабельность действует на один и тот же монитор. В transferFrom выше other.withdraw(...) берёт чужой монитор, монитор другого счёта, и ждёт его на общих основаниях — отсюда, кстати, и классический дедлок двух переводов навстречу друг другу, разобранный в статье про ошибки многопоточности.

synchronized-метод и synchronized-блок

Метод экземпляра: монитор — this

class Counter {
    private int value = 0;

    synchronized void increment() {
        value++; // только один поток одновременно
    }

    synchronized int get() {
        return value;
    }
}

Ключевое слово на методе означает: захватить монитор объекта this на всё время выполнения метода. Два потока с одним экземпляром Counter не могут одновременно выполнять increment() или get().

Метод класса: монитор — Class-объект

class Registry {
    private static int count = 0;

    static synchronized void add() {
        count++;
    }
}

Для static-метода монитором служит объект Registry.class — один на весь класс, не на экземпляр.

Блок: монитор явно указан

class Cache {
    private final Map<String, String> data = new HashMap<>();
    private final Object lock = new Object(); // выделенный объект-замок

    void put(String key, String value) {
        synchronized (lock) {
            data.put(key, value);
        }
    }

    String get(String key) {
        synchronized (lock) {
            return data.get(key);
        }
    }
}

Блок даёт больший контроль: можно синхронизировать только нужный фрагмент, а не весь метод, и явно выбрать объект-замок.

На каком объекте синхронизироваться

Здесь главное правило: все потоки, которые обращаются к одним данным, должны синхронизироваться на одном и том же объекте. Если один поток захватывает this, а другой — lock, они не будут знать друг о друге — гонка сохранится.

Три варианта на практике:

ВариантКогдаПлюсы / минусы
synchronized (this)небольшой класс, нет внешнего доступа к thisпросто; но внешний код может случайно захватить тот же монитор
synchronized (MyClass.class)статические поляодин замок на класс; грубая гранулярность, легко создать узкое место
synchronized (lock) где lock = new Object()явный контрольрекомендуется; lock никто снаружи не знает, невозможен случайный захват

Выделенный объект-замок — наиболее предсказуемый вариант: он виден только внутри класса и захватить его извне невозможно.

Обратную сторону стоит увидеть явно. synchronized-метод публичного класса делает его монитор публичным: монитор это сам объект, а объект доступен всем, кто получил на него ссылку. Значит, любой чужой код может написать synchronized (yourObject) { ... } и держать ваш замок сколько захочет:

Counter counter = new Counter();      // все методы synchronized

synchronized (counter) {              // чужой код взял ваш монитор
    Thread.sleep(60_000);             // и на минуту заблокировал весь класс
}

Ошибки не будет, компилятор промолчит, а counter.increment() во всех потоках встанет на минуту. Это не теория: так случайно блокируют друг друга библиотека и приложение, синхронизируясь на одном публичном объекте. Выделенный private final Object lock = new Object() снаружи не виден, и повторить такое с ним нельзя.

А теперь запрет, который важнее выбора из таблицы: замком не может быть то, что можно подменить или чем владеет кто-то ещё.

  • Боксированные Integer и Long. Строчка synchronized (counters.get(productId)) выглядит как замок «на конкретный товар», но маленькие числа JVM берёт из общего кэша: Long.valueOf(7) в двух разных местах программы вернёт один и тот же объект. Два разных товара получат один замок и будут ждать друг друга без причины, а как только счётчик выйдет за границы кэша — замки так же внезапно разъедутся, и защиты не станет вовсе.
  • Строковые литералы. synchronized ("orders") захватывает объект из пула строк, общий на всю JVM. Тот же литерал может держать любая библиотека в приложении, и вы об этом не узнаете.
  • Поле, которое переприсваивают. Если lock не final и кто-то положил в него новый объект, потоки, вошедшие до и после подмены, синхронизируются на разных замках и друг друга не видят. Поэтому объект-замок всегда объявляют final.

Видимость через synchronized: happens-before

synchronized решает не только порядок доступа, но и видимость изменений между потоками.

В Java изменения, сделанные одним потоком, не гарантированно видны другим немедленно: процессор и JVM вправе кешировать значения, переупорядочивать инструкции. Спецификация языка определяет это через отношение happens-before: если операция A happens-before операции B — B видит результат A.

synchronized даёт happens-before: освобождение монитора happens-before захвата того же монитора другим потоком.

class Shared {
    private int x = 0;
    private final Object lock = new Object();

    void write() {
        synchronized (lock) {
            x = 42; // записать
        }
        // освободили lock
    }

    int read() {
        synchronized (lock) {
            // захватили тот же lock — видим x = 42
            return x;
        }
    }
}

Без synchronized чтение x могло бы вернуть 0 — даже если write() уже завершился.

wait и notify — ожидание условия

Иногда поток должен ждать, пока другой что-то сделает. Для этого внутри synchronized-блока используют wait() и notify(): у монитора, кроме замка, есть очередь ожидающих.

у входа в секции в ожидании замок свободен wait(): замок отпущен notify()

Три места монитора: очередь у входа, секция под замком и очередь ожидания; wait() уводит из секции в ожидание, а notify() возвращает не в секцию, а в очередь за замком.

живой пример

import java.util.ArrayDeque;
import java.util.Queue;

public class WaitNotify {
    static final Object lock = new Object();
    static final Queue<String> items = new ArrayDeque<>();

    public static void main(String[] args) throws InterruptedException {
        Thread consumer = new Thread(() -> {
            for (int i = 0; i < 3; i++) {
                synchronized (lock) {
                    while (items.isEmpty()) {  // while, а не if
                        try {
                            lock.wait();       // отпустить монитор и ждать
                        } catch (InterruptedException e) {
                            return;
                        }
                    }
                    System.out.println("взяли:    " + items.poll());
                }
            }
        });
        consumer.start();
        for (String order : new String[] {"ord-1", "ord-2", "ord-3"}) {
            Thread.sleep(50);
            synchronized (lock) {
                items.add(order);
                System.out.println("положили: " + order);
                lock.notify();                 // разбудить одного ожидающего
            }
        }
        consumer.join();
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

wait() делает три вещи сразу: освобождает монитор, засыпает, и при пробуждении снова захватывает монитор. Именно поэтому оба метода — wait() и notify() — можно вызывать только внутри блока, синхронизированного на том же объекте.

Проверку условия (items.isEmpty()) делают в while, а не в if, и главная причина не та, которую называют чаще. Между notify() и моментом, когда разбуженный поток действительно заберёт монитор обратно, проходит время — за это время кто-то третий успевает забрать элемент, и очередь снова пуста. Вторая причина — ложное пробуждение (spurious wakeup): поток может проснуться сам, без всякого notify(). Спецификация это разрешает, но на практике оно редкость, а вот перехваченный элемент — обычное дело.

В этом примере ждущий один, и notify() безопасен. Как только ожидающих становится несколько и ждут они разного, notify() превращается в мину: JVM будит одного, какого именно — не сказано, и разбудить может того, чьё условие всё ещё не выполнено. Тот проверит while, уснёт обратно, а поток, для которого данные как раз появились, так и останется спать. Рабочее правило простое: по умолчанию notifyAll(), а notify() — только когда все ожидающие взаимозаменяемы и ждут ровно одного и того же.

Понимание wait/notify — основа механизма, но в прикладном коде его почти не пишут: весь пример выше это одна строка на BlockingQueue.

BlockingQueue<String> items = new ArrayBlockingQueue<>(10);

// потребитель
String order = items.take();     // сам ждёт, если пусто
// производитель
items.put(order);                // сам ждёт, если полно

Ни synchronized, ни while-проверки, ни notify — очередь делает всё это внутри, на том же мониторе с условиями. Поэтому правило такое: wait/notify надо уметь читать (они внутри очередей, пулов и старого кода) и знать их правила, а писать руками — только когда условие сложнее, чем «очередь пуста или полна». Что умеют готовые очереди, разбирает статья про потокобезопасные коллекции.

wait против sleep

Эти два метода путают чаще всего, а разница принципиальная. Thread.sleep(ms) усыпляет текущий поток на заданное время и не отпускает захваченные мониторы: если поток заснул внутри synchronized-блока, все остальные стоят у входа, пока он не проснётся. Поэтому sleep в критической секции почти всегда ошибка: дверь заперта, а за ней никто не работает.

wait() — метод Object, вызывается только внутри synchronized по этому объекту и делает обратное: отпускает монитор и ждёт, пока другой поток не позовёт notify() или notifyAll(); после пробуждения поток заново конкурирует за монитор. Одной строкой: sleep ждёт время и держит дверь, wait ждёт события и дверь отпускает.

wait() в секции отпустил монитор ждёт notify sleep() в секции держит монитор ждёт время

Одно и то же ожидание с одной разницей: wait() перед сном отпускает монитор, sleep() держит его, и остальные всё это время стоят у входа.

Чего synchronized не умеет

Два ограничения монитора видны именно на фоне wait/notify, и оба лечатся только сменой инструмента.

Ожидание нельзя прервать. Поток, стоящий у входа в synchronized, не реагирует на interrupt(): флаг ставится, но поток продолжает ждать монитор. Остановить приложение, у которого поток завис на входе в занятую секцию, штатными средствами нельзя — он не выйдет, пока замок не освободится.

Нельзя ни попробовать, ни подождать с таймаутом. У synchronized один режим: ждать столько, сколько нужно. Написать «попробуй взять замок, не вышло — займись другим делом» или «жди не больше двухсот миллисекунд» этим механизмом невозможно, а именно так берут несколько замков сразу, не рискуя дедлоком.

Оба сценария закрывает явный замок ReentrantLock с методами tryLock() и lockInterruptibly(), и это главная причина, по которой его вообще берут вместо монитора; отдельная статья разбирает его целиком.

Чем короче секция, тем меньше ждут

Contention (конкуренция за замок) — ситуация, когда поток вынужден ждать, пока другой держит монитор. Чем дольше поток держит замок и чем больше потоков его хотят, тем сильнее конкуренция — и тем больше времени потоки проводят в ожидании.

Важно, что дорога именно конкуренция, а не само слово synchronized. Незанятый монитор в HotSpot стоит считанные наносекунды: JVM не ходит в ядро операционной системы, пока за замок никто не борется, а при короткой конкуренции сначала даёт потоку немного покрутиться на месте (адаптивный спин) в расчёте, что замок вот-вот освободится. Настоящая цена появляется дальше: если ждать долго, поток паркуют, и пробуждение это уже системный вызов и переключение контекста, микросекунды. Отсюда практический вывод, обратный распространённому страху: убирать synchronized из редко вызываемого кода ради скорости бессмысленно, а укорачивать секцию, которую дерут десять потоков, — осмысленно.

Несколько правил, которые помогают её снизить:

  • Синхронизируйте только то, что надо. Если защищать нужно только обновление одного поля — не захватывайте монитор на весь метод с дорогими вычислениями.
  • Не вызывайте чужой код под замком. Вызов внешнего метода под synchronized — риск дедлока или неожиданно долгого удержания.
  • Держите блок коротким. Всё, что можно вычислить до или после synchronized, выносите за него.
void process(String input) {
    String result = compute(input); // долгая работа — без замка
    synchronized (lock) {
        data.add(result); // только быстрая запись под замком
    }
}

Коротко

  • Критическая секция — участок кода, который одновременно выполняет только один поток.
  • Каждый объект Java имеет монитор (intrinsic lock): метод экземпляра захватывает монитор this, статический — монитор Class, блок — явно указанный объект; для того же потока замок повторно входим.
  • Все потоки, работающие с одними данными, должны синхронизироваться на одном объекте.
  • synchronized обеспечивает happens-before: поток, захвативший монитор, видит всё, что сделал поток, отпустивший его ранее.
  • wait() отпускает монитор и ждёт notify(), sleep() держит его; условие проверяют в while.
  • Держите критическую секцию короткой — длинный захват монитора создаёт contention и тормозит все ожидающие потоки.
  • Монитор реентерабелен по счётчику входов, но только для того же монитора: чужой замок ждут на общих основаниях, отсюда дедлок встречных переводов.
  • synchronized-метод публичного класса делает монитор публичным: чужой код может взять ваш замок и держать его; выделенный private final Object lock этого не позволяет.
  • Монитор нельзя прервать и нельзя взять с таймаутом — за этим идут к ReentrantLock; незанятый synchronized почти бесплатен, дорога только конкуренция.
  • Производитель и потребитель в прикладном коде пишутся не на wait/notify, а на BlockingQueue (put и take).

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