Знаете ли вы, как избежать дедлоков в своей программе? Да, этому учат, про это спрашивают на собеседованиях… И тем не менее, взаимные блокировки встречаются даже в популярных проектах серьёзных компаний вроде 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<Boolean> EQ = new OperatorImpl<Boolean>(NS, "EQ");
public static final Operator<Boolean> NE = new OperatorImpl<Boolean>(NS, "NE");
...
public final class OperatorImpl<T> implements Operator<T> {
static {
try {
// initialize all fields of Ops
List<Field> fields = new ArrayList<Field>();
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<E> ... {
private static final ImmutableList<Object> EMPTY =
new RegularImmutableList<Object>(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 — что-нибудь изменится?
Из этих потоков теперь вызывается тело лямбды, записанное в байткоде в виде специального private static метода внутри того же класса StreamSum. Но этот метод не может быть вызван, пока не завершится статический инициализатор класса, который в свою очередь ожидает вычисления свёртки.
Проверьте сами, запуская пример с разным значением
-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=0x000000001a098800 nid=0x1cf8 in Object.wait() [0x000000001a95e000]
java.lang.Thread.State: RUNNABLE
at Example1$A.<clinit>(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=0x000000000098e800 nid=0x23b4 in Object.wait() [0x000000000228e000]
java.lang.Thread.State: RUNNABLE
at Example1$B.<clinit>(Example1.java:8)
at Example1.main(Example1.java:13)
Вот наши потоки. Оба зависли внутри статического инициализатора <clinit>, но при этом оба 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.
Комментарии (14)
23derevo
07.04.2015 13:23Вообще, это позор для Гуавы — не прогонять код перед релизом через статические анализаторы.
Наверняка, FindBugs нашел бы эту ошибку влёт.
Или я не прав? Не найдет? Или умрет в False Positives?apangin Автор
07.04.2015 14:38+4Проверил — не находит. Вот, вызов Thread.start() из статического инициализатора FindBugs считает ошибкой, а просто создание дочернего объекта — нет.
Все ошибаются, ничего позорного в этом нет. Коллегам из Google, на самом деле, респект, что отнеслись с пониманием и оперативно исправили.23derevo
07.04.2015 14:45+1да, наверное жестковато я.
Знаешь, это даже не конкретно к гуглу претензия, а к… к состоянию дел, что ли. Миллионы людей используют гуаву и другие библиотеки — а там такое. Иногда я сижу и не понимаю, как все это вообще не разваливается к чертовой матери…apangin Автор
07.04.2015 14:58+2Поймать этот баг в продакшне ещё постараться надо. Опять же, нам «повезло» лишь благодаря высоким нагрузкам: сервер только поднимается, а на него сразу тысяча одновременных запросов прилетает с участием разных классов: RegularImmutableList, SingletonImmutableList и т.д., и все они начинают конкурентно инициализироваться. Много ли где такое встречается?
23derevo
07.04.2015 15:05С одной стороны, да. Больше ворклоады и все такое. С другой стороны, иногда становится реально страшно. Особенно когда думаешь, сколько там еще не пойманных подобных багов.
edolganov
07.04.2015 23:34+3Реально страшно за что?
Ну да, бывают баги в любом коде, в любых либах. Мы ж на этом не самолеты запускаем с реакторами, а лишь в интернете чатики, да картинки показываем.yetanothercoder
14.04.2015 23:11ну много финансового софта написано, вдруг что нибудь подобное вылезет во время тока как кто нибудь все свои сбережения переводит и сколько потом банк будет расследовать сбой этой транзакции и возвращать бабки )
igor_suhorukov
07.04.2015 23:31+1Загляни в исходники netbeans profiler и jvisualvm. После этого вообще плакать хочется)
lany
21.04.2015 15:45Таки я пропустил этот пост. Очень круто, спасибо! Стоит добавить диагностику в FindBugs на вызов parallel из clinit? :-)
apangin Автор
21.04.2015 17:43+1Да, возможно. Только там куча вариантов, все запаришься перечислять: Stream.parallel(), Collection.parallelStream(), Arrays.parallelSort(), Arrays.parallelSetAll() и т.д.
gvsmirnov
gvsmirnov
Oops. Тут должна была быть надпись «Класс!», подкреплённая unicode-символом с вытянутым вверх большим пальцем. Но что-то пошло не так :)
srez
Как теперь с этим знанием жить, вот в чем вопрос?