Синхронизированные методы и блоки в 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 или спроектируйте мутацию так, чтобы она не могла выполниться наполовину.
Проработанный пример: четыре формы рядом
Программа ниже использует каждую форму применительно к одному и тому же общему состоянию и заканчивается сравнительным анализом.
Что можно вынести из запуска:
- Все три формы счётчика дали ожидаемое значение
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 — способами, которыми потоки сигнализируют друг другу внутри критической секции.