Класс дедлоков про дедлок классов

de8f18aa45a341b9b654a44aee7e4840.pngЗнаете ли вы, как избежать дедлоков в своей программе? Да, этому учат, про это спрашивают на собеседованиях… И тем не менее, взаимные блокировки встречаются даже в популярных проектах серьёзных компаний вроде Google. А в Java есть особый класс дедлоков, связанный с инициализацией классов, простите за каламбур. Такие ошибки легко допустить, но трудно поймать, тем более, что сама виртуальная машина вводит программиста в заблуждение.

Сегодня пойдёт речь про взаимные блокировки при инициализации классов. Я расскажу, что это такое, проиллюстрирую примерами из реальных проектов, попутно найду багу в JVM, и покажу, как не допустить такие блокировки в своём коде.

Дедлок без локовЕсли я попрошу вас привести пример взаимной блокировки на Java, скорее всего, увижу код с парой synchronized или ReentrantLock. А как насчёт дедлока вообще без synchronized и java.util.concurrent? Поверьте, это возможно, причём очень лаконичным и незамысловатым способом: static class A { static final B b = new B (); }

static class B { static final A a = new A (); }

public static void main (String[] args) { new Thread (A: new).start (); new B (); } Дело в том, что согласно §5.5 спецификации JVM у каждого класса есть уникальный initialization lock, который захватывается на время инициализации. Когда другой поток попытается обратиться к инициализируемому классу, он будет заблокирован на этом локе до завершения инициализации первым потоком. При конкурентной инициализации нескольких ссылающихся друг на друга классов нетрудно наткнуться на взаимную блокировку.Именно это и случилось, к примеру, в проекте QueryDSL:

public final class Ops { public static final Operator EQ = new OperatorImpl(NS, «EQ»); public static final Operator NE = new OperatorImpl(NS, «NE»); … public final class OperatorImpl implements Operator {

static { try { // initialize all fields of Ops List fields = new ArrayList(); fields.addAll (Arrays.asList (Ops.class.getFields ())); for (Class cl: Ops.class.getClasses ()) { fields.addAll (Arrays.asList (cl.getFields ())); } … В ходе обсуждения на StackOverflow причина была найдена, о проблеме сообщено разработчику, и к настоящему моменту ошибка уже исправлена.Проблема курицы и яйца В точности такой же дедлок может возникнуть всякий раз, когда в статическом инициализаторе класса создаётся экземпляр потомка. По сути это частный случай описанной выше проблемы, поскольку инициализация потомка автоматически приводит к инициализации родителя (см. JVMS §5.5). К сожалению, такой шаблон можно встретить довольно часто, особенно когда класс родителя абстрактный: public abstract class ImmutableList … {

private static final ImmutableList EMPTY = new RegularImmutableList(ObjectArrays.EMPTY_ARRAY); Это реальный фрагмент кода из библиотеки Google Guava. Благодаря нему часть наших серверов после очередного апдейта намертво подвисла при запуске. Как выяснилось, виной тому послужило обновление Guava с версии 14.0.1 до 15.0, где и появился злополучный шаблон неправильной статической инициализации.Конечно же, мы сообщили об ошибке, и спустя некоторое время её исправили в репозитории, однако будьте внимательны: последний на момент написания статьи публичный релиз Guava 18.0 всё ещё содержит ошибку!

В одну строчку Java 8 подарила нам Стримы и Лямбды, а вместе с ними и новую головную боль. Да, теперь можно красиво одной строчкой в функциональном стиле оформить целый алгоритм. Но при этом можно и так же, одной строчкой, выстрелить себе в ногу.Хотите упражнение для самопроверки? Я составил программку, вычисляющую сумму ряда; что она напечатает?

public class StreamSum { static final int SUM = IntStream.range (0, 100).parallel ().reduce ((n, m) → n + m).getAsInt ();

public static void main (String[] args) { System.out.println (SUM); } } А теперь уберите .parallel () или, как вариант, замените лямбду на Integer: sum — что-нибудь изменится? Так в чём же дело? Здесь опять имеет место дедлок. Благодаря директиве parallel () свёртка стрима выполняется в отдельном пуле потоков.Из этих потоков теперь вызывается тело лямбды, записанное в байткоде в виде специального private static метода внутри того же класса StreamSum. Но этот метод не может быть вызван, пока не завершится статический инициализатор класса, который в свою очередь ожидает вычисления свёртки.

Больше ада Совсем взрывает мозг то, что приведённый фрагмент работает по-разному в разных средах. На однопроцессорной машине он отработает корректно, а на многопроцессорной, скорее всего, зависнет. Причина кроется в механике параллелизма стандартного Fork-Join пула.Проверьте сами, запуская пример с разным значением

-Djava.util.concurrent.ForkJoinPool.common.parallelism=N Лукавый Хотспот Обычно дедлоки легко обнаружить из Thread Dump: проблемные потоки будут висеть в состоянии BLOCKED или WAITING, и JVM в стектрейсах покажет, какие мониторы тот или иной поток держит, а какие пытается захватить. Так ли обстоит дело с нашими примерами? Возьмём самый первый, с классами A и B. Дождёмся зависания и снимем thread dump (с помощью утилиты jstack либо клавишами Ctrl+\ в Linux или Ctrl+Break в Windows): «Thread-0» #12 prio=5 os_prio=0 tid=0×000000001a098800 nid=0×1cf8 in Object.wait () [0×000000001a95e000] java.lang.Thread.State: RUNNABLE at Example1$A.(Example1.java:4) at Example1$$Lambda$1/1418481495.run (Unknown Source) at java.lang.Thread.run (Thread.java:745)

«main» #1 prio=5 os_prio=0 tid=0×000000000098e800 nid=0×23b4 in Object.wait () [0×000000000228e000] java.lang.Thread.State: RUNNABLE at Example1$B.(Example1.java:8) at Example1.main (Example1.java:13) Вот наши потоки. Оба зависли внутри статического инициализатора , но при этом оба RUNNABLE! Как-то не стыкуется со здравым смыслом, не обманывает ли нас JVM? Особенность initialization lock заключается в том, что из Java программы его не видно, а захват и освобождение происходит внутри виртуальной машины. Строго говоря, по спецификации Thread.State здесь не может быть ни BLOCKED (потому как нет synchronized блока), ни WAITING (поскольку методы Object.wait, Thread.join и LockSupport.park здесь не вызываются). Более того, initialization lock вообще не обязан быть Java объектом. Таким образом, единственным формально допустимым состоянием остаётся RUNNABLE.

На эту тему есть давний баг JDK-6501158, закрытый как «Not an issue», и сам Дэвид Холмс мне в переписке признался, что у него нет ни времени, ни желания возвращаться к этому вопросу.

Если неочевидное состояние потока ещё можно считать «фичей», то другую особенность initialization lock иначе как «багом» не назовёшь. Разбираясь с проблемой, я наткнулся в исходниках HotSpot на странность в отправке JVMTI оповещений: событие MonitorWait посылается из функции JVM_MonitorWait, соответствующей Java-методу Object.wait, в то время как симметричное ему событие MonitorWaited посылается из низкоуровневой функции ObjectMonitor: wait.

Как мы уже выяснили, для ожидания initialization lock метод Object.wait не вызывается, таким образом, событий MonitorWait для них мы не увидим, зато MonitorWaited будут приходить, как и для обычных Java-мониторов, что, согласитесь, не логично.

Нашёл ошибку — сообщи разработчику. Такого правила придерживаемся и мы: JDK-8075259.

Заключение Для обеспечения потокобезопасной инициализации классов JVM использует синхронизацию на невидимом программисту initialization lock, имеющемся у каждого класса.Неаккуратное написание инициализаторов может привести к дедлокам. Чтобы этого избежать

следите за тем, чтобы статические инициализаторы не обращались к другим неинициализированным классам; не создавайте в статических инициализаторах экземпляры дочерних классов; не создавайте потоки и избегайте конкурентного исполнения кода в статических инициализаторах; никому не доверяйте; изучайте исходный код и сообщайте об ошибках, найденных в сторонних проектах. По результатам анализа дедлоков инициализации были обнаружены ошибки в Querydsl, Guava и HotSpot JVM.

© Habrahabr.ru