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

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

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

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

class Counter {
    private int value = 0;

    void increment() {
        value++; // читать-изменить-записать — не атомарно
    }

    int get() {
        return value;
    }
}

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

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

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

Каждый объект в Java имеет встроенный монитор (он же внутренний замок, intrinsic lock, mutex). Монитор — это невидимый замок: поток, который «захватил» монитор, входит в критическую секцию; все остальные потоки ждут у закрытой двери.

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

Монитор всегда принадлежит какому-то объекту. Какому именно — зависит от формы synchronized.

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: 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().

class Queue {
    private final List<String> items = new ArrayList<>();
    private final Object lock = new Object();

    void produce(String item) {
        synchronized (lock) {
            items.add(item);
            lock.notify(); // разбудить одного ожидающего
        }
    }

    String consume() throws InterruptedException {
        synchronized (lock) {
            while (items.isEmpty()) {
                lock.wait(); // отпустить монитор и ждать
            }
            return items.remove(0);
        }
    }
}

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

Проверку условия (items.isEmpty()) делают в while, а не в if: пробуждение может быть ложным (spurious wakeup), поэтому после выхода из wait() условие нужно проверить снова.

Для сложных сценариев производитель–потребитель сегодня удобнее использовать java.util.concurrent.BlockingQueue, но понимание wait/notify — основа механизма.

Гранулярность и contention

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

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

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

Коротко

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

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

  • Состояния гонки и как их находить — подробно о том, как проявляются гонки и методы их обнаружения.
  • ReentrantLock и явные замки — расширенные возможности: tryLock, таймаут, условия, честность.
  • Ошибки в многопоточном коде — дедлоки, livelocks и другие типичные проблемы.