Чому паралельний потік з лямбда-сигналом у статичному ініціалізаторі спричиняє глухий кут?


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) або вище зависає.
Ласло Хірді,

5

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 - Danny Pflughoeft

13
@ bayou.io Основна проблема полягає у використанні потоків всередині статичних ініціалізаторів, а не лямбда-файлів.
Стюарт Маркс

5
До речі, Tunaki дякую за розкопки моїх звітів про помилки. :-)
Стюарт Маркс

13
@ bayou.io: це те саме, що на рівні класу, як і в конструкторі, дозволяючи thisвтекти під час побудови об'єкта. Основне правило - не використовувати багатопотокові операції в ініціалізаторах. Я не думаю, що це важко зрозуміти. Ваш приклад реєстрації лямбда-реалізованої функції в реєстрі - це інше, він не створює тупикових ситуацій, якщо ви не будете чекати одного із цих заблокованих фонових потоків. Тим не менше, я настійно не рекомендую робити такі операції в ініціалізаторі класу. Це не те, для чого вони призначені.
Holger

9
Я думаю, що урок стилю програмування такий: тримайте прості статичні ініціалізатори.
Редвальд

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 компілює його в статичний метод у містять класі (аналогічно lambda1i у цьому прикладі). Поміщення кожної лямбди у свій клас було б значно дорожчим.
Стюарт Маркс

1
@StuartMarks Враховуючи, що лямбда створює клас, що реалізує функціональний інтерфейс, чи не було б так само ефективно застосувати реалізацію лямбда у реалізації лямбда-функціонального інтерфейсу, як у другому прикладі цього допису? Це, безумовно, очевидний спосіб робити щось, але я впевнений, що є причина, чому вони роблять так, як вони.
Поновити Моніку

6
@ Solomonoff'sSecret Лямбда може створити клас під час виконання (через java.lang.invoke.LambdaMetafactory ), але лямбда-тіло має бути розміщене десь під час компіляції. Таким чином, лямбда-класи можуть скористатися деякими магіями ВМ, щоб бути дешевшими, ніж звичайні класи, завантажені з файлів .class.
Джеффрі Босбум,

1
@ Solomonoff'sSecret Так, відповідь Джеффрі Босбума є правильною. Якщо в майбутньому JVM стане можливим додавати метод до існуючого класу, метафабрика може це зробити, замість того, щоб крутити новий клас. (Чиста спекуляція.)
Стюарт Маркс

3
@ Секрет Соломонова: не судіть, дивлячись на такі тривіальні лямбда-вирази, як ваш i -> i; вони не будуть нормою. Лямбда-вирази можуть використовувати всіх членів оточуючого їх класу, включаючи privateтих, що робить сам визначальний клас їх природним місцем. Дозволити всім цим випадкам використання страждати від реалізації, оптимізованої для особливого випадку ініціалізаторів класів із багатопотоковим використанням тривіальних лямбда-виразів, не використовуючи членів їх визначального класу, не є життєздатною опцією.
Holger

14

Є чудове пояснення цієї проблеми від Андрія Пангіна від 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()або замініть лямбду на Integer::sumдзвінок - що зміниться?

Тут ми знову бачимо глухий кут [раніше в статті було кілька прикладів тупикових ситуацій в ініціалізаторах класів]. Через parallel()потокові операції виконуються в окремому пулі потоків. Ці потоки намагаються виконати лямбда-тіло, яке записано в байт-коді як private staticметод всередині StreamSumкласу. Але цей метод не може бути виконаний до завершення статичного ініціалізатора класу, який чекає результатів завершення потоку.

Що ще більше вражає: цей код працює по-різному в різних середовищах. Він буде правильно працювати на одній машині з процесором і, швидше за все, буде зависати на машині з декількома процесорами. Ця відмінність походить від реалізації пулу Fork-Join. Ви можете перевірити це самостійно, змінивши параметр-Djava.util.concurrent.ForkJoinPool.common.parallelism=N

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.