Синхронизация в Java
Координация доступа к общему состоянию между потоками в Java с помощью ключевого слова synchronized и встроенных блокировок.
Введение в многопоточность предупреждало о трёх режимах сбоев — гонках, ошибках видимости и взаимных блокировках. synchronized — это первый ответ Java на первые два из них. Он даёт блоку кода сразу две гарантии: взаимное исключение (только один поток может находиться внутри в любой момент) и видимость памяти (записи, выполненные одним потоком внутри блока, будут видны следующему потоку, который войдёт в него). Этих двух гарантий вместе достаточно, чтобы сделать огромное количество многопоточного кода корректным.
Эта глава посвящена концепциям — что делает synchronized, что такое встроенный монитор, какие гонки он устраняет, а какие нет. Следующая глава, synchronized-блоки, показывает синтаксические формы и правила выбора между ними.
Гонка, для устранения которой и создан этот оператор
class Counter {
int n;
void increment() { n++; }
}
Counter c = new Counter();
// Thread A and Thread B both call c.increment() a million times.
// After both finish, what is c.n?n++ — это одна строка исходного кода и три операции байткода: загрузить n, прибавить 1, сохранить n. Если поток A загрузил n=42, а затем поток B загружает n=42 до того, как A сохранил результат, оба прибавят 1 и оба сохранят 43. Одно приращение потеряно. Запустите программу по миллиону раз в каждом потоке, и c.n будет стабильно меньше 2_000_000.
synchronized — это решение:
class Counter {
int n;
synchronized void increment() { n++; }
}Теперь только один поток одновременно выполняет increment на данном Counter. Остальные ждут у входа. Результат: c.n == 2_000_000 при каждом запуске.
Что такое монитор
У каждого Java-объекта внутри JVM есть связанная блокировка, называемая встроенным монитором (или монитором блокировки). Это просто структура данных с двумя состояниями: поток-владелец (или null) и очередь ожидания. Поток, входящий в блок synchronized:
- Пытается захватить монитор объекта, на котором стоит
synchronized. - Если монитор свободен, захватывает его (теперь
owner == self) и продолжает работу. - Если монитор принадлежит другому потоку, этот поток переходит в состояние
BLOCKEDи встаёт в очередь ожидания. - Когда владелец покидает блок, JVM освобождает монитор и один из ожидающих потоков получает его.
Монитор принадлежит конкретному объекту. Два экземпляра Counter имеют два отдельных монитора; потоки, работающие с разными Counter, не блокируют друг друга. Это важно — синхронизация привязана к объекту, а не «к методу».
synchronized (someObject) {
// critical section: only one thread at a time
// holds someObject's monitor inside this block
}synchronized на экземплярном методе — это синтаксический сахар для synchronized (this). На статическом методе — сахар для synchronized (Counter.class), то есть монитор объекта Class.
Видимость, а не только исключение
Взаимное исключение — очевидная часть. Менее очевидная — и более важная — это отношение happens-before, которое JVM предоставляет бесплатно:
Всё, что один поток делает перед освобождением монитора, гарантированно будет видно любому потоку, который впоследствии захватит тот же монитор.
Именно это делает synchronized корректным, а не просто «первый пришёл — первый обслужен». Без этого два потока могут использовать блок synchronized, договориться о взаимном исключении и всё равно видеть записи друг друга в неправильном порядке — потому что иначе кэши процессора и JIT вправе изменять порядок операций. Пара release/acquire устанавливает барьер памяти, который заставляет процессор и JIT сбросить и перезагрузить данные.
Следствие: любое поле, которое многопоточная программа читает или записывает за пределами блока synchronized (и не через volatile, атомарный тип или другой примитив java.util.concurrent), не имеет гарантии видимости. Один поток может записать done = true, а другой может навсегда видеть done = false. Мы вернёмся к этому, когда будем изучать volatile и модель памяти Java.
Что synchronized не исправляет
Четыре вещи, которых новички часто ожидают от synchronized, но которые он не обеспечивает:
- Он не блокирует данные.
synchronized (list)не запрещает другому коду трогатьlist; он запрещает другому потоку удерживать тот же монитор. Если какой-то другой путь кода обращается кlistбез захвата того же монитора, защита исчезает. - Он не компонуется между объектами.
synchronized (a); synchronized (b);— это два отдельных захвата; если другой поток захватывает их в обратном порядке, возникает взаимная блокировка. - Он не ускоряет ничего. Блокировки — это чистые накладные расходы. Используйте их только там, где этого требует корректность.
- Он не устраняет все гонки. Составные действия типа «проверить — действовать» всё равно гонят, даже если каждая отдельная операция синхронизирована.
if (map.containsKey(k)) map.put(k, v)некорректен, даже еслиcontainsKeyиputпо отдельности потокобезопасны — промежуток между двумя вызовами не защищён. ИспользуйтеputIfAbsentили единый синхронизированный блок, охватывающий оба вызова.
Реентрабельность
Встроенный монитор является реентрабельным: поток, уже удерживающий монитор, может войти в другой блок synchronized на том же объекте, не блокируясь на самом себе. Именно поэтому это работает:
class Account {
synchronized void deposit(int x) { balance += x; }
synchronized void transferTo(Account other, int x) {
deposit(-x); // re-enters same monitor — fine
other.deposit(x); // acquires other's monitor too
}
}Если бы мониторы не были реентрабельными, внутренний вызов deposit заблокировался бы на мониторе, который уже удерживает внешний вызов — мгновенная самоблокировка. Реентрабельность позволяет безопасно вызывать другой синхронизированный метод на том же объекте.
Обратная сторона: каждый захват должен сопровождаться соответствующим освобождением. JVM ведёт счётчик; монитор освобождается, когда счётчик падает до нуля.
На чём синхронизироваться
Несколько правил, которые предотвращают большинство ошибок неправильного использования блокировок:
- Синхронизируйтесь на приватном объекте блокировки, а не на
this. Внешний код также может выполнитьsynchronized (yourInstance); это позволяет вызывающему удерживать вашу блокировку сколь угодно долго. Приватныйfinal Object lock = new Object();принадлежит только вам, и никто другой не может его захватить. - Не синхронизируйтесь на строковых литералах или упакованных примитивах. Они интернированы/закэшированы; два блока
synchronized ("foo")в разных частях кода разделяют монитор с кем угодно, кто тоже написал"foo". - Не синхронизируйтесь на ссылке, которая может измениться.
synchronized (myField), гдеmyFieldможет быть переприсвоено, означает два разных монитора в разное время. Компилятор этого не замечает; ошибка молчалива. - Держите критическую секцию маленькой. Чем больше вы делаете внутри блока
synchronized, тем дольше ждут все остальные. Удерживайте блокировку во время изменения общего состояния, а не во время окружающего ввода-вывода.
Разобранный пример: с блокировкой и без
Программа ниже запускает одну и ту же нагрузку на общий счётчик тремя способами: без синхронизации, с методом synchronized и с блоком synchronized на выделенном объекте блокировки. Числа показывают, что первая форма теряет обновления, а две другие — нет.
Что следует вынести из запуска:
- Строка
unsafeстабильно теряла обновления — итоговое значение оказывалось ниже ожидаемого1_000_000. Два потока, выполняющихn++, гонятся на операции чтение-изменение-запись; некоторые приращения исчезают. Даже если тест однажды пройдёт удачно, JIT, планировщик ОС или другой процессор в итоге это обнажат. Несинхронизированное изменение общего поля — это ошибка. - Оба безопасных варианта давали точно ожидаемое значение при каждом запуске. Взаимное исключение — очевидная часть того, что делает
synchronized; менее заметная часть в том, что значение, которое читаетvalue(), является последним записаннымincrement— это и есть гарантия видимости. Без пары монитора чтение вполне законно могло бы увидеть устаревшую копию из кэша. - Показания времени для
sync methodиsync blockбыли заметно выше, чем дляunsafe. Блокировки не бесплатны — каждый вход/выход устанавливает барьер памяти и (при конкуренции) переключение контекста потока. Синхронизируйте там, где требует корректность; не разбрасывайте её «для безопасности». - Вариант
sync block on private lock— это то, что используется в производственном коде. Формаsync methodблокируется наthis, которую может захватить любой внешний вызывающий — он может вас вытеснить, удерживая вашу же блокировку. Приватный объект блокировки, который вы никогда не раскрываете, принадлежит только вам. - Блок с реентрабельностью выполнился без взаимной блокировки.
outer()уже удерживал мониторthis;inner()вошёл в него повторно, не заблокировавшись. Именно поэтому синхронизированный метод может свободно вызывать другой синхронизированный метод на том же объекте — без реентрабельности половина стандартной библиотеки зашла бы в тупик.
Что дальше
Следующая глава, Java Synchronized Blocks, подробно рассматривает синтаксические формы — метод, блок, статический — и правила выбора правильного объекта блокировки. Для координации между потоками на более высоком уровне смотрите межпоточное взаимодействие (wait/notify).