Почему использование параллельных потоков в статическом инициализаторе приводит к нестабильной взаимоблокировке

ВНИМАНИЕ: это не дубликат, пожалуйста, прочитайте тему внимательно https://stackoverflow.com/users/3448419/apangin цитата:

Настоящий вопрос заключается в том, почему код иногда работает, когда не должен. Проблема воспроизводится даже без лямбд. Это заставляет меня думать, что может быть ошибка JVM.

В комментариях https://stackoverflow.com/a/53709217/2674303 я попытался выяснить причины поведения кода по разному от начала к другому и участники той дискуссии дали мне совет создать отдельную тему.

Рассмотрим следующий исходный код:

public class Test {
    static {
        System.out.println("static initializer: " + Thread.currentThread().getName());

        final long SUM = IntStream.range(0, 5)
                .parallel()
                .mapToObj(i -> {
                    System.out.println("map: " + Thread.currentThread().getName() + " " + i);
                    return i;
                })
                .sum();
    }

    public static void main(String[] args) {
        System.out.println("Finished");
    }
}

Иногда (почти всегда) это приводит к тупику.

Пример вывода:

static initializer: main
map: main 2
map: ForkJoinPool.commonPool-worker-3 4
map: ForkJoinPool.commonPool-worker-3 3
map: ForkJoinPool.commonPool-worker-2 0

Но иногда завершается успешно (очень редко):

static initializer: main
map: main 2
map: main 3
map: ForkJoinPool.commonPool-worker-2 4
map: ForkJoinPool.commonPool-worker-1 1
map: ForkJoinPool.commonPool-worker-3 0
Finished

or

static initializer: main
map: main 2
map: ForkJoinPool.commonPool-worker-2 0
map: ForkJoinPool.commonPool-worker-1 1
map: ForkJoinPool.commonPool-worker-3 4
map: main 3

Не могли бы вы объяснить такое поведение?


person gstackoverflow    schedule 11.12.2018    source источник
comment
@Amongalen это не дубликат. Я читал ту тему.   -  person gstackoverflow    schedule 11.12.2018
comment
Что именно не объясняется в повторяющемся вопросе? Ответ сводится к следующему: написание такого кода может привести к взаимоблокировкам. так что не пишите такой код. Как вы думаете, что еще можно добавить к этому?   -  person GhostCat    schedule 11.12.2018
comment
@GhostCat, почему поведение нестабильно!!!   -  person gstackoverflow    schedule 11.12.2018
comment
@GhostCat, мой вопрос более подробный!   -  person gstackoverflow    schedule 11.12.2018
comment
GhostCat, мне любопытно узнать о внутренностях, и у нас есть ребята, которые это знают. Хотя бы @apangin   -  person gstackoverflow    schedule 11.12.2018
comment
Природа многопоточности чувствительна к времени. Если бы это не срабатывало все время или никогда, то более чем один поток не был бы сложным.   -  person GhostCat    schedule 11.12.2018
comment
@GhostCat, для меня это ясно, но мы можем попробовать все возможные комбинации порядка и видимости и предсказать результат   -  person gstackoverflow    schedule 11.12.2018
comment
все возможные комбинации... могут подойти для вашего небольшого примера здесь, но, учитывая любую реальную, реалистичную проблему, вы быстро получаете экспоненциально растущее количество комбинаций. и единственное, что вы могли возможно предсказать, это вероятность тупиковой ситуации?   -  person GhostCat    schedule 11.12.2018
comment
@GhostCat, вы правы - я не могу предсказать взаимоблокировку для реального сложного кода и воспользуюсь вашим советом, чтобы избежать параллельных потоков в статическом инициализаторе. Но мне любопытно узнать, как это работает на простейшем примере, просто для моего личного обогащения, просто потому, что это позволит мне спать спокойно)   -  person gstackoverflow    schedule 11.12.2018
comment
Это не дубликат. Настоящий вопрос заключается в том, почему код иногда работает, когда не должен. Проблема воспроизводится даже без лямбд. Это заставляет меня думать, что может быть ошибка JVM. Чуть позже проверю.   -  person apangin    schedule 11.12.2018
comment
Соглашаясь с @apangin Когда мы перемещаем System.out.println("Finished"); в конец блока static {}, мы можем ясно показать, что рабочим потокам удалось выполнить тело лямбды, пока инициализация класса еще не завершена, т.е. что это не проблема потока Оп возвращается слишком рано. Обратите внимание, что пример немного неудачен для более новых версий Java, поскольку, начиная с Java 9, count() пропустит всю обработку и вернет предсказуемый размер. Так что .map(i -> { System.out.println("map: "+Thread.currentThread().getName()+" "+i); return 1; }).sum(); может быть лучше.   -  person Holger    schedule 11.12.2018
comment
Используя версию Хольгера, для меня это всегда блокируется в oracle jdk1.8.0_121, но никогда в openJdk jdk-11.0.1. Здесь определенно что-то изменилось.   -  person Hulk    schedule 12.12.2018
comment
@Hulk, ты имеешь в виду, что пытался использовать sum()?   -  person gstackoverflow    schedule 12.12.2018
comment
для меня: версия java 1.8.0_111 - оракул jdk   -  person gstackoverflow    schedule 12.12.2018
comment
@gstackoverflow да, с sum(), потому что count() пропускает обработку (начиная с java 9, как упоминал Хольгер).   -  person Hulk    schedule 12.12.2018
comment
@Hulk Вы знаете, где я могу прочитать об этой функции?   -  person gstackoverflow    schedule 12.12.2018
comment
@gstackoverflow JavaDocs, например: реализация может отказаться от выполнения потокового конвейера (последовательно или параллельно), если она способна вычислять счетчик непосредственно из источника потока.   -  person Hulk    schedule 12.12.2018
comment
@apangin, есть новости?   -  person gstackoverflow    schedule 14.12.2018
comment
Да, теперь я убежден, что это ошибка JVM, по-видимому, связанная с постоянным разрешением пула. Я также обнаружил очень старую похожую ошибку JDK-4493560 относительно доступа к статическому полю. Похоже, ошибка была исправлена ​​для байт-кодов getstatic/putstatic, но не для invokestatic.   -  person apangin    schedule 16.12.2018
comment
Пока не было времени найти первопричину. Я опубликую ответ после более глубокого исследования, которое, вероятно, приведет к отправке отчета об ошибке.   -  person apangin    schedule 16.12.2018
comment
Опишите в своем вопросе (не в комментариях), почему это не дубликат.   -  person Raedwald    schedule 17.12.2018
comment
@Raedwald, добавлено в тему.   -  person gstackoverflow    schedule 17.12.2018
comment
@apangin, очень интересное исследование   -  person gstackoverflow    schedule 17.12.2018


Ответы (1)


TL;DR Это ошибка HotSpot JDK-8215634

Проблему можно воспроизвести с помощью простого теста, в котором вообще нет гонок:

public class StaticInit {

    static void staticTarget() {
        System.out.println("Called from " + Thread.currentThread().getName());
    }

    static {
        Runnable r = new Runnable() {
            public void run() {
                staticTarget();
            }
        };

        r.run();

        Thread thread2 = new Thread(r, "Thread-2");
        thread2.start();
        try { thread2.join(); } catch (Exception ignore) {}

        System.out.println("Initialization complete");
    }

    public static void main(String[] args) {
    }
}

Это похоже на классический тупик инициализации, но HotSpot JVM не зависает. Вместо этого он печатает:

Called from main
Called from Thread-2
Initialization complete

Почему это ошибка

JVMS §6.5 требует, чтобы при выполнении invokestatic байт-кода

класс или интерфейс, объявивший разрешенный метод, инициализируется, если этот класс или интерфейс еще не были инициализированы

Когда Thread-2 вызывает staticTarget, основной класс StaticInit явно не инициализирован (поскольку его статический инициализатор все еще работает). Это означает, что Thread-2 должен запустить процедуру инициализации класса, описанную в JVMS §5.5. Согласно этой процедуре,

  1. Если объект класса для C указывает, что инициализация выполняется для C каким-либо другим потоком, то отпустите LC и заблокируйте текущий поток до тех пор, пока не будет сообщено, что текущая инициализация завершена.

Однако Thread-2 не блокируется, несмотря на то, что класс находится в процессе инициализации потоком main.

А как насчет других JVM

Я тестировал OpenJ9 и JET, и они оба ожидаемо зависают в приведенном выше тесте.
Интересно, что HotSpot также зависает в режиме -Xcomp, но не в -Xint или смешанных режимах.

Как это происходит

Когда интерпретатор впервые встречает байт-код invokestatic, он вызывает среду выполнения JVM для разрешения ссылки на метод. В рамках этого процесса JVM при необходимости инициализирует класс. После успешного разрешения разрешенный метод сохраняется в записи кэша пула констант. Кэш пула констант — это специфичная для HotSpot структура, в которой хранятся разрешенные значения пула констант.

В приведенном выше тесте байт-код invokestatic, который вызывает staticTarget, сначала разрешается потоком main. Среда выполнения интерпретатора пропускает инициализацию класса, поскольку класс уже инициализируется тем же потоком. Разрешенный метод сохраняется в кэше пула констант. В следующий раз, когда Thread-2 выполнит тот же invokestatic, интерпретатор увидит, что байт-код уже разрешен, и использует константную запись кэша пула без обращения к среде выполнения и, таким образом, пропускает инициализацию класса.

Аналогичная ошибка для getstatic/putstatic давно исправлена ​​— JDK-4493560, но исправление не коснулось invokestatic. Я отправил новую ошибку JDK-8215634 для решения этой проблемы.

Что касается исходного примера,

зависает он или нет, зависит от того, какой поток первым разрешает статический вызов. Если это поток main, программа завершается без взаимоблокировки. Если статический вызов разрешается одним из ForkJoinPool потоков, программа зависает.

Обновлять

Ошибка подтверждено. Это исправлено в следующих выпусках: JDK 8u201, JDK 11.0.2 и JDK 12.

person apangin    schedule 19.12.2018