Подтвердить что ты не робот

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

Я столкнулся с странной ситуацией, когда использование параллельного потока с лямбдой в статическом инициализаторе, кажется, навсегда, без использования ЦП. Здесь код:

class Deadlock {
    static {
        IntStream.range(0, 10000).parallel().map(i -> i).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}

Это, по-видимому, минимальный тестовый пример воспроизведения для этого поведения. Если I:

  • помещает блок в основной метод вместо статического инициализатора,
  • удалить распараллеливание или
  • удалите лямбда,

код моментально завершается. Может ли кто-нибудь объяснить это поведение? Это ошибка или это предназначено?

Я использую OpenJDK версии 1.8.0_66-internal.

4b9b3361

Ответ 1

Я нашел отчет об ошибке в очень похожем случае (JDK-8143380), который был закрыт Stuart Marks как "Не проблема":

Это тупик инициализации класса. Основной поток тестовой программы выполняет статический инициализатор класса, который устанавливает флаг выполнения инициализации для класса; этот флаг остается установленным до завершения статического инициализатора. Статический инициализатор выполняет параллельный поток, который заставляет лямбда-выражения оцениваться в других потоках. Эти потоки блокируют ожидание завершения класса для инициализации. Тем не менее, основной поток заблокирован, ожидая завершения параллельных задач, что приводит к тупиковой ситуации.

Тестовая программа должна быть изменена для перемещения логики параллельного потока вне статического инициализатора класса. Закрытие как не проблема.


Мне удалось найти еще один отчет об ошибке (JDK-8136753), также закрытый как "Не проблема" Стюарта Маркса:

Это тупиковая ситуация, которая возникает из-за того, что статический инициализатор Fruit enum плохо взаимодействует с инициализацией класса.

Подробную информацию о инициализации класса см. в разделе 12.1.2 Language Language Specification, раздел 12.4.2.

http://docs.oracle.com/javase/specs/jls/se8/html/jls-12.html#jls-12.4.2

Вкратце, что происходит следующим образом.

  • Основной поток ссылается на класс Fruit и запускает процесс инициализации. Это устанавливает флаг инициализации в процессе выполнения и запускает статический инициализатор в основном потоке.
  • Статический инициализатор запускает некоторый код в другом потоке и ждет его завершения. В этом примере используются параллельные потоки, но это не имеет ничего общего с потоками. Выполнение кода в другом потоке любым способом и ожидание завершения этого кода будет иметь тот же эффект.
  • Код в другом потоке ссылается на класс Fruit, который проверяет флаг инициализации в процессе выполнения. Это заставляет другой поток блокироваться до тех пор, пока флаг не будет очищен. (См. Шаг 2 JLS 12.4.2.)
  • Основной поток блокируется, ожидая завершения другого потока, поэтому статический инициализатор никогда не завершается. Поскольку флаг инициализации в процессе не очищается до тех пор, пока не завершится статический инициализатор, потоки заблокированы.

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

Закрытие как не проблема.


Обратите внимание, что FindBugs имеет открытую проблему для добавления предупреждения для этой ситуации.

Ответ 2

Для тех, кто задается вопросом, где другие потоки, ссылающиеся на класс Deadlock, Java lambdas ведут себя так, как вы написали это:

public class Deadlock {
    public static int lambda1(int i) {
        return i;
    }
    static {
        IntStream.range(0, 10000).parallel().map(new IntUnaryOperator() {
            @Override
            public int applyAsInt(int operand) {
                return lambda1(operand);
            }
        }).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}

С обычными анонимными классами нет тупика:

public class Deadlock {
    static {
        IntStream.range(0, 10000).parallel().map(new IntUnaryOperator() {
            @Override
            public int applyAsInt(int operand) {
                return operand;
            }
        }).count();
        System.out.println("done");
    }
    public static void main(final String[] args) {}
}

Ответ 3

Существует отличное объяснение этой проблемы Andrei Pangin, датированное 07 апреля 2015 года. Доступно здесь, но написано на русском языке (я предлагаю просмотреть образцы кода в любом случае - они являются международными). Общая проблема - это блокировка во время инициализации класса.

Вот некоторые цитаты из статьи:


Согласно JLS, каждый класс имеет уникальную блокировку инициализации, которая фиксируется во время инициализации. Когда другой поток пытается получить доступ к этому классу во время инициализации, он будет заблокирован в блокировке до завершения инициализации. Когда классы инициализируются одновременно, можно получить тупик.

Я написал простую программу, которая вычисляет сумму целых чисел, что она должна печатать?

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() или замените lambda на вызов Integer::sum - что изменится?

Здесь мы снова видим тупик [были некоторые примеры тупиков в инициализаторах класса, ранее описанных в статье]. Из-за операций потока parallel() выполняется в отдельном пуле потоков. Эти потоки пытаются выполнить тело лямбды, которое записывается в байт-код как метод private static внутри класса StreamSum. Но этот метод не может быть выполнен до завершения статического инициализатора класса, который ждет результатов завершения потока.

Чем больше mindblowing: этот код работает по-разному в разных средах. Он будет корректно работать на одном процессоре и, скорее всего, будет работать на многопроцессорной машине. Это различие связано с реализацией пула Fork-Join. Вы можете сами убедиться, что вы изменяете параметр -Djava.util.concurrent.ForkJoinPool.common.parallelism=N