W3docs

Синхронизированные методы и блоки в Java

Используйте synchronized-методы и блоки в Java для защиты критических секций — и выбирайте правильный объект блокировки.

Предыдущая глава объяснила, что делает synchronized. Эта глава посвящена синтаксису — трём формам, которые может принимать ключевое слово, тому, какой замок использует каждая форма, и как выбрать правильную. Выбор формы влияет на производительность и корректность; «просто добавить synchronized к методу» работает в простых случаях и даёт сбой, когда класс разрастается.

Три формы, три объекта блокировки

ФормаОбъект блокировкиКогда использовать
synchronized void method()thisНебольшие классы. Публичная блокировка приемлема.
synchronized static void method()ClassName.classИзменение состояния на уровне класса из любого экземпляра.
synchronized (obj) { ... }objПочти всё остальное. Используйте приватную блокировку для безопасности.

Третья форма наиболее гибкая. Первые две являются синтаксическим сахаром для неё.

synchronized на методе экземпляра

public synchronized void deposit(int x) {
  balance += x;
}

Компилируется в блок, блокирующий this. Только один поток одновременно может выполнять любой синхронизированный метод экземпляра на данном конкретном объекте. (Разные экземпляры Account имеют разные ссылки this и, следовательно, разные мониторы.) Статические методы и несинхронизированные методы не затрагиваются.

Ловушка. this является частью публичной ссылки. Любой код, имеющий дескриптор объекта, может выполнить synchronized (account) { ... } и удерживать тот же замок, что и account.deposit(). Это включает тестовые фреймворки, отладчики, код фреймворков и любые другие точки вызова, которые вы не контролируете. Недобросовестный вызывающий может удерживать ваш замок сколь угодно долго, и вы окажетесь в состоянии голодания.

В небольших классах вы будете единственным вызывающим — всё в порядке. В библиотеках, в коде, который будут использовать другие люди, или в классах, которые вы можете позже рефакторить, предпочтите приватный объект блокировки.

synchronized на статическом методе

public class Counters {
  private static int total;

  public static synchronized void bump() {
    total++;
  }
}

Компилируется в блок, блокирующий Counters.class. Монитор является глобальным для класса — каждый поток, каждый экземпляр конкурируют за один и тот же замок при вызове bump(). Та же оговорка, что и для this, применима здесь: любой другой код также может выполнить synchronized (Counters.class) { ... } и удержать замок.

Для состояния на уровне класса эта форма подходит в небольших утилитных классах. Для более крупных предпочтите приватную статическую блокировку:

public class Counters {
  private static final Object LOCK = new Object();
  private static int total;

  public static void bump() {
    synchronized (LOCK) { total++; }
  }
}

synchronized на явном объекте — производственная форма

public class Cache {
  private final Object lock = new Object();
  private final Map<String, String> data = new HashMap<>();

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

  public void put(String k, String v) {
    synchronized (lock) {
      data.put(k, v);
    }
  }
}

Эта форма даёт вам два свойства:

  • Приватная блокировка. Ни один вызывающий не может её получить; никто не сможет вас заблокировать.
  • Точный охват. Только внутри блока удерживается замок. Всё снаружи — валидация аргументов, форматирование возвращаемого значения, логирование — выполняется без конкуренции.

По той же причине, по которой вы делаете поля private final приватными, вы держите блокировку приватной. Объект блокировки — часть вашей реализации, а не интерфейса.

Правило: держите критическую секцию минимальной

Чем больше кода выполняется при удерживаемой блокировке, тем больше конкуренции вы создаёте. Правильный шаблон — делать минимально необходимое внутри блока:

// Bad: I/O inside the lock — everybody waits while one thread talks to disk
public synchronized void load(String k) {
  String v = Files.readString(Path.of("/tmp/" + k));         // bad
  cache.put(k, v);
}

// Good: read outside the lock, lock only the mutation
public void load(String k) throws IOException {
  String v = Files.readString(Path.of("/tmp/" + k));
  synchronized (lock) {
    cache.put(k, v);
  }
}

Общий принцип: блокируйте только во время изменения общего состояния, никогда — во время выполнения произвольной работы, которая может заблокироваться.

Составные действия и двойная блокировка

synchronized защищает один блок. Если два действия вместе должны быть атомарными, оба должны быть в одном блоке:

// Wrong: the if and the put are individually synchronised by HashMap... no they're not,
// but even if they were, the gap between them is not.
if (!map.containsKey(k)) {                            // someone else could insert here
  map.put(k, v);
}

// Right: one block protects both ops
synchronized (lock) {
  if (!map.containsKey(k)) {
    map.put(k, v);
  }
}

// Even better: a single atomic operation
map.putIfAbsent(k, v);                                // for ConcurrentHashMap, fully atomic

Гонка между containsKey и put — известная как гонка проверка-затем-действие — является источником большего числа ошибок конкурентности, чем сама блокировка. Каждый раз, когда вы пишете if (...) doThing(), спрашивайте себя: между if и doThing, может ли другой поток изменить ответ? Если да — атомизируйте.

Блокировки не компонуются — остерегайтесь порядка блокировок

Два блока synchronized, захваченных разными потоками в разном порядке, могут привести к взаимной блокировке:

// Thread A
synchronized (account1) {
  synchronized (account2) { transfer(account1, account2, 100); }
}

// Thread B simultaneously
synchronized (account2) {
  synchronized (account1) { transfer(account2, account1, 100); }
}

Каждый поток удерживает один замок и ждёт другого. Оба потока навсегда находятся в состоянии BLOCKED. Исправление — согласованный порядок — всегда захватывать замки в одном и том же глобальном порядке:

void transfer(Account a, Account b, int x) {
  Account first  = a.id() < b.id() ? a : b;          // ordering by stable key
  Account second = a.id() < b.id() ? b : a;
  synchronized (first) {
    synchronized (second) {
      a.debit(x);
      b.credit(x);
    }
  }
}

Упорядочивание по хэшу, упорядочивание на основе System.identityHashCode или замок-тай-брейкер — три обычных подхода. Глава о взаимных блокировках рассматривает их подробнее.

Что насчёт synchronized на примитиве?

Нельзя. synchronized требует объект — long или int не имеет монитора. Упакуйте его (Long/Integer), и синтаксически вы сможете заблокироваться на нём, но никогда так не делайте: упакованные примитивы в кэше автоупаковки являются общими. Два куска кода, блокирующих Integer.valueOf(1), блокируют один и тот же объект — даже если они никак не связаны.

synchronized (Integer.valueOf(1)) {                   // never do this
  ...
}

Для объектов блокировки всегда выделяйте приватный Object. Вся суть монитора — в идентичности, а не значении.

synchronized и исключения

Если тело синхронизированного блока бросает исключение, монитор освобождается по мере раскрутки стека. Вам не нужен finally для разблокировки — JVM обрабатывает это. Это одна из главных причин, по которым synchronized сложно использовать неправильно: здесь нет «утечки замка», как в случае с явным API Lock, где lock()/unlock() (разблокировка — это отдельный вызов метода, который вы должны помнить поместить в finally).

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

Проработанный пример: четыре формы рядом

Программа ниже использует каждую форму применительно к одному и тому же общему состоянию и заканчивается сравнительным анализом.

java— editable, runs on the server

Что можно вынести из запуска:

  • Все три формы счётчика дали ожидаемое значение 800 000. Каждая форма выбрала разный объект блокировки (this, приватный Object, Class), но каждая защищала операцию чтение-изменение-запись одинаково. synchronized не заботится о том, чем является объект блокировки — важно только то, что все конкурирующие потоки используют один и тот же.
  • Форма со статическим методом (V3) использовала монитор V3.class в качестве замка. Каждый поток, каждый тест, каждый другой кусок кода, синхронизирующийся на V3.class, будет конкурировать за один и тот же замок. Это уместно для состояния на уровне класса; использование этого для состояния на уровне экземпляра является ошибкой конкуренции — вы будете блокировать несвязанную работу против самой себя.
  • Формы со статическим и экземплярным методом удобны, но блокируют публично доступный объект (this или Class). Любой может выполнить synchronized (someObject) и удержать тот же монитор. Форма с приватным объектом блокировки (V2) — это то, что используется в производственном коде именно потому, что никто за пределами класса не может добраться до замка.
  • Класс V4 (определён, но не протестирован выше) показывает неверную структуру: I/O-подобная работа внутри критической секции. Следующая корректная версия выносит форматирование и (смоделированный) блокирующий вызов за пределы блока synchronized, чтобы конкуренция была только за фактический put. Та же корректность — при гораздо большей пропускной способности под нагрузкой.
  • Блок двойной блокировки в конце захватил два несвязанных замка в порядке, определённом System.identityHashCode. Это правило упорядочивания, применяемое повсеместно в программе, является простейшей стратегией предотвращения взаимоблокировок, когда необходимо одновременно держать два замка. Мы снова встретимся с ним в главе о взаимных блокировках.

Что дальше

Следующая глава, Взаимодействие потоков в Java, знакомит с другой половиной API встроенного монитора — wait, notify и notifyAll — способами, которыми потоки сигнализируют друг другу внутри критической секции.

Практика

Практика
Вы пишете `public synchronized void deposit(int x)` на методе `class Account`. Какой монитор приобретает метод?
Вы пишете `public synchronized void deposit(int x)` на методе `class Account`. Какой монитор приобретает метод?
Was this page helpful?