Статьи

5 вещей, которые вы не знали о синхронизации в Java и Scala

Практически все серверные приложения требуют какой-то синхронизации между несколькими потоками. Большая часть работы по синхронизации выполняется для нас на уровне инфраструктуры, например, нашим веб-сервером, клиентом БД или системой обмена сообщениями. Java и Scala предоставляют множество компонентов для написания надежных многопоточных приложений. К ним относятся пулы объектов, одновременные коллекции, расширенные блокировки, контексты выполнения и т. Д. blog_trampoline

Чтобы лучше понять их, давайте рассмотрим наиболее идиому синхронизации — блокировку объекта . Этот механизм поддерживает синхронизированное ключевое слово, что делает его одним из, если не самым популярным, многопоточным языком в Java. Он также лежит в основе многих более сложных шаблонов, которые мы используем, таких как пулы потоков и соединений, одновременные коллекции и многое другое.

Синхронизированное ключевое слово используется в двух основных контекстах:

  1. как модификатор метода, чтобы отметить метод, что он может быть выполнен только одним потоком за раз.
  2. объявив блок кода критическим разделом, который доступен только одному потоку в любой момент времени.

Инструкция по блокировке

Факт № 1 . Синхронизированные блоки кода реализованы с использованием двух специальных инструкций байт-кода, которые являются частью официальной спецификации — MonitorEnter и MonitorExit . Это отличается от других механизмов блокировки, таких как механизмы, найденные в пакете java.util.concurrent , которые реализованы (в случае HotSpot) с использованием комбинации кода Java и собственных вызовов, выполняемых через sun.misc.Unsafe .

Эти инструкции работают с объектом, который явно указан разработчиком в контексте синхронизированного блока. Для синхронизированных методов блокировка автоматически выбирается как переменная this . Для статических методов блокировка будет размещена на объекте Class.

Синхронизированные методы могут иногда вызывать плохое поведение . Одним из примеров является создание неявных зависимостей между различными синхронизированными методами одного и того же объекта, поскольку они совместно используют одну и ту же блокировку. Худший сценарий — объявление синхронизированных методов в базовом классе (который может быть даже сторонним классом), а затем добавление новых синхронизированных методов в производный класс. Это создает неявные зависимости синхронизации по всей иерархии и может создать проблемы пропускной способности или даже тупики. Чтобы избежать этого, рекомендуется использовать находящийся в частном владении объект в качестве замка, чтобы предотвратить случайное совместное использование или утечку замков.

Компилятор и синхронизация

Есть две инструкции байт-кода, отвечающие за синхронизацию. Это необычно, так как большинство инструкций байт-кода не зависят друг от друга, обычно «общаются» друг с другом, помещая значения в стек операндов потока. Блокируемый объект также загружается из стека операндов, предварительно помещенного туда путем разыменования переменной, поля или вызова метода, возвращающего объект.

Факт № 2. Так что же происходит, если одна из двух инструкций вызывается без соответствующего вызова другой? Компилятор Java не будет генерировать код, который вызывает MonitorExit без вызова MonitorEnter. Тем не менее, с точки зрения JVM, такой код полностью действителен. Результатом такого случая будет то, что инструкция MonitorExit с броском исключения IllegalMonitorStateException.

Более опасный случай — это то, что произойдет, если блокировка получена через MonitorEnter, но не снята при соответствующем вызове MonitorExit. В этом случае поток, владеющий блокировкой, может привести к тому, что другие потоки, пытающиеся получить блокировку, будут блокироваться бесконечно. Стоит отметить, что, поскольку блокировка реентерабельна, поток, владеющий блокировкой, может продолжать успешно выполняться, даже если он достигнет и повторно войдет в ту же блокировку снова.

А вот и подвох. Чтобы этого не происходило, компилятор Java генерирует совпадающие инструкции входа и выхода таким образом, что после выполнения выполнения в синхронизированном блоке или методе он должен пройти через соответствующую инструкцию MonitorExit для того же объекта. Одна вещь, которая может бросить ключ в этом, — это если исключение выдается в критической секции.

1
2
3
4
5
public void hello() {
  synchronized (this) {
    System.out.println("Hi!, I'm alone here");
  }
}

Давайте проанализируем байт-код —

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
aload_0 //load this into the operand stack
dup //load it again
astore_1 //backup this into an implicit variable stored at register 1
monitorenter //pop the value of this from the stack to enter the monitor
  
//the actual critical section
getstatic java/lang/System/out Ljava/io/PrintStream;
ldc "Hi!, I'm alone here"
invokevirtual java/io/PrintStream/println(Ljava/lang/String;)V
  
aload_1 //load the backup of this
monitorexit //pop up the var and exit the monitor
goto 14 // completed - jump to the end
  
// the added catch clause - we got here if an exception was thrown -
aload_1 // load the backup var.
monitorexit //exit the monitor
athrow // rethrow the exception object, loaded into the operand stack
return

Механизм, используемый компилятором для предотвращения разматывания стека без прохождения инструкции MonitorExit, довольно прост — компилятор добавляет неявное предложение try… catch для снятия блокировки и повторного выброса исключения.

Факт № 3 . Другой вопрос — где хранится ссылка на заблокированный объект между соответствующими вызовами входа и выхода. Имейте в виду, что несколько потоков могут выполнять один и тот же синхронизированный блок одновременно, используя разные объекты блокировки. Если заблокированный объект является результатом вызова метода, маловероятно, что JVM выполнит его снова, так как он может изменить состояние объекта или даже не вернуть тот же объект. То же самое может быть верно для переменной или поля, которые могли измениться с момента ввода монитора.

Монитор переменный . Чтобы противостоять этому, компилятор добавляет в метод неявную локальную переменную для хранения значения заблокированного объекта. Это умное решение, так как оно накладывает довольно минимальные издержки на поддержание ссылки на заблокированный объект, в отличие от использования параллельной структуры кучи для отображения заблокированных объектов на потоки (структура, которая сама по себе может нуждаться в синхронизации). Я впервые увидел эту новую переменную при построении алгоритма анализа стека Takipi и увидел, что в коде появились неожиданные переменные.

Обратите внимание, что вся эта работа выполняется на уровне компилятора Java. JVM очень рада войти в критическую секцию с помощью инструкции MonitorEnter, не выходя из нее (или наоборот), или использовать другие объекты для того, что должно соответствовать методам входа и выхода.

Блокировка на уровне JVM

Давайте более подробно рассмотрим, как на самом деле реализованы блокировки на уровне JVM. Для этого мы рассмотрим реализацию HotSpot SE 7, поскольку это зависит от виртуальной машины. Поскольку блокировка может иметь некоторые довольно неблагоприятные последствия для пропускной способности кода, JVM внедрила несколько очень сильных оптимизаций для повышения эффективности получения и освобождения блокировок.

Факт № 4. Одним из самых сильных механизмов, созданных JVM, является смещение блокировки резьбы . Блокировка — это внутренняя возможность, которую имеет каждый Java-объект, очень похожий на наличие системного хэш-кода или ссылки на его определяющий класс. Это верно независимо от типа объекта (вы можете даже использовать примитивный массив в качестве блокировки, если хотите).

Эти типы данных хранятся в заголовке каждого объекта (также известного как метка объекта). Некоторые из этих данных, которые помещаются в заголовок объекта, зарезервированы для описания состояния блокировки объекта. Это включает битовые флаги, описывающие состояние блокировки объекта (то есть заблокировано / разблокировано) и ссылку на поток, которому в данный момент принадлежит блокировка — поток к объекту смещен.

Чтобы сэкономить место в заголовке объекта, объекты потока Java размещаются в нижнем сегменте кучи виртуальной машины, чтобы уменьшить размер адреса и сэкономить на битах в заголовке каждого объекта (54 или 23 бита для 64- и 32-битной JVM. соответственно).

Для 64 бит —

blog_normal-объект

Алгоритм блокировки

Когда JVM пытается получить блокировку на объекте, она проходит серию шагов от оптимистического до пессимистического.

Факт № 5. Блокировка получается потоком, если ему удается утвердить себя в качестве владельца блокировки объекта. Это определяется тем, может ли поток установить ссылку на себя (указатель на внутренний объект JavaThread) в заголовке объекта.

Приобретая замок. Первая попытка сделать это делается с помощью простой операции сравнения и обмена (CAS). Это очень эффективно, поскольку обычно может переводиться в прямую инструкцию процессора (например, cmpxchg). Операции CAS вместе со специальными процедурами парковки потоков в ОС служат строительными блоками для идиомы синхронизации объектов.

Если блокировка либо свободна, либо ранее была смещена к этому потоку, блокировка объекта получается для потока, и выполнение может быть немедленно продолжено. В случае сбоя CAS JVM выполнит один раунд блокировки вращения, где поток паркуется, чтобы эффективно перевести его в спящий режим между попытками CAS. Если эти первоначальные попытки потерпят неудачу (сигнализируя о достаточно высоком уровне конкуренции за блокировку), поток переместится в заблокированное состояние и включится в список потоков, претендующих на блокировку, и начнет серию спин-блокировок.

После каждого раунда вращения поток будет проверять изменения глобального состояния JVM, такие как начало GC «остановить мир», и в этом случае поток должен будет приостановить себя до завершения GC, чтобы предотвратить случай. где блокировка получена и выполнение продолжается, пока выполняется STW GC.

Снятие блокировки. При выходе из критической секции через инструкцию MonitorExit поток владельца попытается выяснить, сможет ли он разбудить какой-либо из припаркованных потоков, которые могут ожидать снятия блокировки. Этот процесс известен как выбор «наследника». Это предназначено для повышения жизнеспособности и предотвращения сценария, в котором потоки остаются припаркованными, в то время как блокировка уже снята (также известная как многопоточность).

Проблемы с многопоточностью на сервере отладки сложны, так как они, как правило, зависят от очень определенного времени и эвристики ОС. Это была одна из причин, по которой мы начали работать над Такипи .