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

86

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

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

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

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

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

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

Восстановить Монику
источник
4
При диапазоне (0, 1) программа завершается нормально. С (0, 2) и выше зависает.
Laszlo Hirdi
5
аналогичный вопрос: stackoverflow.com/questions/34222669/…
Alex - GlassEditor.com
2
На самом деле это точно такой же вопрос / проблема, только с другим API.
Didier L
3
Вы пытаетесь использовать класс в фоновом потоке, когда вы не завершили инициализацию класса, поэтому его нельзя использовать в фоновом потоке.
Питер Лоури
4
@ Solomonoff'sSecret, поскольку i -> iне является ссылкой на метод, он static methodреализован в классе Deadlock. Если заменить i -> iс Function.identity()этим кодом должно быть хорошо.
Питер Лоури

Ответы:

71

Я нашел отчет об ошибке очень похожего дела ( JDK-8143380 ), который Стюарт Маркс закрыл как «Не проблема»:

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

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


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

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

См. Спецификацию языка Java, раздел 12.4.2, для получения подробной информации об инициализации класса.

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

Вкратце происходит следующее.

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

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

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


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

Тунаки
источник
20
«Это был рассмотрен , когда мы разработали функцию» и «Мы знаем , что вызывает эту ошибку , но не так, как это исправить» делать не средний «это не ошибка» . Это абсолютно ошибка.
BlueRaja - Дэнни Пфлугхёфт
13
@ bayou.io Основная проблема заключается в использовании потоков в статических инициализаторах, а не лямбдах.
Стюарт Маркс
5
Кстати, Тунаки, спасибо, что откопал мои отчеты об ошибках. :-)
Стюарт Маркс
13
@ bayou.io: на уровне класса это то же самое, что и в конструкторе, позволяя thisуйти во время создания объекта. Основное правило: не используйте в инициализаторах многопоточные операции. Не думаю, что это сложно понять. Ваш пример регистрации реализованной лямбда-функции в реестре - это другое дело, он не создает взаимоблокировок, если вы не собираетесь ждать одного из этих заблокированных фоновых потоков. Тем не менее, я настоятельно не рекомендую выполнять такие операции в инициализаторе класса. Это не то, для чего они предназначены.
Holger
9
Я предполагаю, что урок стиля программирования таков: сохраняйте статические инициализаторы простыми.
Raedwald
16

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

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) {}
}
Тамас Хегедус
источник
5
@ Solomonoff'sSecret Это выбор реализации. Код в лямбде должен куда-то идти. Javac компилирует его в статический метод в содержащем классе (аналогично lambda1этому примеру). Помещение каждой лямбды в отдельный класс было бы значительно дороже.
Стюарт Маркс,
1
@StuartMarks Учитывая, что лямбда создает класс, реализующий функциональный интерфейс, разве не было бы так же эффективно включить реализацию лямбда в реализацию лямбда функционального интерфейса, как во втором примере этого поста? Это, безусловно, очевидный способ делать что-то, но я уверен, что есть причина, по которой они поступают именно так.
Восстановить Монику
6
@ Solomonoff'sSecret Лямбда может создать класс во время выполнения (через java.lang.invoke.LambdaMetafactory ), но тело лямбда должно быть где-то размещено во время компиляции. Таким образом, лямбда-классы могут использовать некоторую магию виртуальной машины, чтобы быть менее затратными, чем обычные классы, загружаемые из файлов .class.
Джеффри Босбум
1
@ Solomonoff'sSecret Да, ответ Джеффри Босбума верен. Если в будущей JVM станет возможным добавить метод к существующему классу, метафабрика может сделать это вместо создания нового класса. (Чистое предположение.)
Стюарт Маркс,
3
@ Секрет Соломонова: не судите, глядя на такие тривиальные лямбда-выражения, как ваш i -> i; они не будут нормой. Лямбда-выражения могут использовать все члены окружающего их класса, включая privateих, и это делает сам определяющий класс их естественным местом. Допустить, чтобы все эти варианты использования страдали от реализации, оптимизированной для особого случая инициализаторов классов с многопоточным использованием тривиальных лямбда-выражений, без использования членов их определяющего класса, не является жизнеспособным вариантом.
Holger
14

Есть отличное объяснение этой проблемы от Андрея Пангина от 7 апреля 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()или замените лямбду на Integer::sumcall - что изменится?

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

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

АдамСкайуокер
источник