company_banner

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



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

    Одноклассники

    93,00

    Делимся экспертизой

    Поделиться публикацией
    Комментарии 16
      0
        +4
        Oops. Тут должна была быть надпись «Класс!», подкреплённая unicode-символом с вытянутым вверх большим пальцем. Но что-то пошло не так :)
          +2
          Как теперь с этим знанием жить, вот в чем вопрос?
        0
        Век живи — век учись :)
        Спасибо.
          0
          Вообще, это позор для Гуавы — не прогонять код перед релизом через статические анализаторы.

          Наверняка, FindBugs нашел бы эту ошибку влёт.

          Или я не прав? Не найдет? Или умрет в False Positives?
            +4
            Проверил — не находит. Вот, вызов Thread.start() из статического инициализатора FindBugs считает ошибкой, а просто создание дочернего объекта — нет.

            Все ошибаются, ничего позорного в этом нет. Коллегам из Google, на самом деле, респект, что отнеслись с пониманием и оперативно исправили.
              +1
              да, наверное жестковато я.

              Знаешь, это даже не конкретно к гуглу претензия, а к… к состоянию дел, что ли. Миллионы людей используют гуаву и другие библиотеки — а там такое. Иногда я сижу и не понимаю, как все это вообще не разваливается к чертовой матери…
                +2
                Поймать этот баг в продакшне ещё постараться надо. Опять же, нам «повезло» лишь благодаря высоким нагрузкам: сервер только поднимается, а на него сразу тысяча одновременных запросов прилетает с участием разных классов: RegularImmutableList, SingletonImmutableList и т.д., и все они начинают конкурентно инициализироваться. Много ли где такое встречается?
                  0
                  С одной стороны, да. Больше ворклоады и все такое. С другой стороны, иногда становится реально страшно. Особенно когда думаешь, сколько там еще не пойманных подобных багов.
                    +3
                    Реально страшно за что?
                    Ну да, бывают баги в любом коде, в любых либах. Мы ж на этом не самолеты запускаем с реакторами, а лишь в интернете чатики, да картинки показываем.
                      0
                      ну много финансового софта написано, вдруг что нибудь подобное вылезет во время тока как кто нибудь все свои сбережения переводит и сколько потом банк будет расследовать сбой этой транзакции и возвращать бабки )
                        0
                        В банках используются transactional rdbms(обычно Oracle)
                        И если у вас случился адский deadlock, то и не случится commit для вашей transaction.

                        apangin
                        у меня возник вопрос, т.е. при первом обращении к ImmutableList в теории мы должны получить deadlock, но к примеру у нас в приложении ни разу такой ситуации вроде как не было. Андрей, опиши примерно case, который случился у вас в production, потому что пример в guava issue tracker уж больно нереален на мой взгляд.
                          0
                          Так я писал: сразу после запуска сервера, когда он входит в кластер, на него прилетает несколько тысяч запросов, содержащих разные дочерние классы ImmutableList. Во время десериализации запросов происходит инициализация классов (поскольку до этого они не использовались). Дедлок происходит, когда несколько разных классов из одной иерархии начинают одновременно инициализироваться. Одного обращения к ImmutableList недостаточно.
                  +1
                  Загляни в исходники netbeans profiler и jvisualvm. После этого вообще плакать хочется)
              0
              Таки я пропустил этот пост. Очень круто, спасибо! Стоит добавить диагностику в FindBugs на вызов parallel из clinit? :-)
                +1
                Да, возможно. Только там куча вариантов, все запаришься перечислять: Stream.parallel(), Collection.parallelStream(), Arrays.parallelSort(), Arrays.parallelSetAll() и т.д.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое