Как стать автором
Поиск
Написать публикацию
Обновить
70.84
ОК
Делаем продукт, который объединяет миллионы людей

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

Время на прочтение5 мин
Количество просмотров39K


Знаете ли вы, как избежать дедлоков в своей программе? Да, этому учат, про это спрашивают на собеседованиях… И тем не менее, взаимные блокировки встречаются даже в популярных проектах серьёзных компаний вроде 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 — что-нибудь изменится?

Так в чём же дело?
Здесь опять имеет место дедлок. Благодаря директиве 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=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.
Теги:
Хабы:
Всего голосов 45: ↑45 и ↓0+45
Комментарии16

Публикации

Информация

Сайт
oktech.ru
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Юля Шаймухаметова