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

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

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

В комментариях на qaru.site/info/16721153/... я попытался выяснить причины, по которым код ведет себя по-разному при старте, и участники этого обсуждения дали мне совет создать отдельную тему.

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

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

или же

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

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

Ответ 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) {
    }
}

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

Called from main
Called from Thread-2
Initialization complete

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Обновить

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

Ответ 2

Тело вашей лямбды:

System.out.println("map: " + Thread.currentThread().getName() + " " + i);
return i;

реализован как статический метод в классе Test.

При первом IntFunction.apply для лямбда-объекта ему необходимо разрешить класс, чтобы разрешить вызов статического метода. Этот процесс будет включать синхронизацию с объектом класса, чтобы обеспечить его инициализацию.

Ваша статическая инициализация требует, чтобы обработка потока была завершена до завершения инициализации класса, поэтому основной поток уже имеет блокировку для объекта класса.

Если первый вызов статического лямбда-тела происходит в основном потоке, он будет работать нормально, поскольку блокировка реентерабельна.

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

Вы можете вызвать такую же проблему без лямбд, как это:

public class Deadlock
{
    private static Integer printIt(int i)
    {
        System.out.println("map: " + Thread.currentThread().getName() + " " + i);
        return i;
    }
    static {
        System.out.println("static initializer: " + Thread.currentThread().getName());

        final long SUM = IntStream.range(0, 5)
                .parallel()
                .mapToObj(new IntFunction<Integer>()
                {
                    @Override
                    public Integer apply(int value)
                    {
                        return printIt(value);
                    }
                } )
                .count();
    }

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

Если вы переместите тело статического метода во внутренний класс, то проблема исчезнет.