Perché il flusso parallelo con lambda nell'inizializzatore statico causa un deadlock?


86

Mi sono imbattuto in una strana situazione in cui l'utilizzo di un flusso parallelo con un lambda in un inizializzatore statico richiede apparentemente per sempre senza l'utilizzo della CPU. Ecco il codice:

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

Sembra essere un test case di riproduzione minimo per questo comportamento. Se io:

  • mettere il blocco nel metodo principale invece di un inizializzatore statico,
  • rimuovere la parallelizzazione, o
  • rimuovere la lambda,

il codice viene completato immediatamente. Qualcuno può spiegare questo comportamento? È un bug o è inteso?

Sto usando OpenJDK versione 1.8.0_66-internal.


4
Con intervallo (0, 1) il programma termina normalmente. Con (0, 2) o superiore si blocca.
Laszlo Hirdi


2
In realtà è esattamente la stessa domanda / problema, solo con un'API diversa.
Didier L

3
Stai tentando di utilizzare una classe, in un thread in background, quando non hai finito di inizializzare la classe in modo che non possa essere utilizzata in un thread in background.
Peter Lawrey

4
@ Solomonoff'sSecret poiché i -> inon è un riferimento al metodo, è static methodimplementato nella classe Deadlock. Se sostituire i -> icon Function.identity()questo codice dovrebbe andare bene.
Peter Lawrey

Risposte:


71

Ho trovato un bug report di un caso molto simile ( JDK-8143380 ) che è stato chiuso come "Not an Issue" da Stuart Marks:

Questo è un deadlock di inizializzazione della classe. Il thread principale del programma di test esegue l'inizializzatore statico della classe, che imposta il flag di inizializzazione in corso per la classe; questo flag rimane impostato fino al completamento dell'inizializzatore statico. L'inizializzatore statico esegue un flusso parallelo, che causa la valutazione delle espressioni lambda in altri thread. Quei thread si bloccano in attesa che la classe completi l'inizializzazione. Tuttavia, il thread principale viene bloccato in attesa del completamento delle attività parallele, con conseguente deadlock.

Il programma di test deve essere modificato per spostare la logica del flusso parallelo all'esterno dell'inizializzatore statico della classe. Chiusura come non un problema.


Sono stato in grado di trovare un'altra segnalazione di bug di questo ( JDK-8136753 ), anch'essa chiusa come "Not an Issue" da Stuart Marks:

Si tratta di un deadlock che si verifica perché l'inizializzatore statico dell'enumerazione Fruit sta interagendo male con l'inizializzazione della classe.

Vedere la specifica del linguaggio Java, sezione 12.4.2 per i dettagli sull'inizializzazione della classe.

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

In breve, quello che sta succedendo è il seguente.

  1. Il thread principale fa riferimento alla classe Fruit e avvia il processo di inizializzazione. Questo imposta il flag di inizializzazione in corso ed esegue l'inizializzatore statico sul thread principale.
  2. L'inizializzatore statico esegue del codice in un altro thread e attende che finisca. Questo esempio utilizza flussi paralleli, ma questo non ha nulla a che fare con i flussi di per sé. L'esecuzione del codice in un altro thread con qualsiasi mezzo e l'attesa del completamento del codice avrà lo stesso effetto.
  3. Il codice nell'altro thread fa riferimento alla classe Fruit, che controlla il flag di inizializzazione in corso. Ciò causa il blocco dell'altro thread finché il flag non viene cancellato. (Vedere il passaggio 2 di JLS 12.4.2.)
  4. Il thread principale è bloccato in attesa che l'altro thread termini, quindi l'inizializzatore statico non viene mai completato. Poiché il flag di inizializzazione in corso non viene cancellato fino al completamento dell'inizializzatore statico, i thread vengono bloccati.

Per evitare questo problema, assicurarsi che l'inizializzazione statica di una classe venga completata rapidamente, senza che altri thread eseguano il codice che richiede che questa classe abbia completato l'inizializzazione.

Chiusura come non un problema.


Nota che FindBugs ha un problema aperto per l'aggiunta di un avviso per questa situazione.


20
"Questo è stato considerato quando abbiamo progettato la funzione" e "Noi sappiamo cosa provoca questo bug, ma non come risolvere il problema" facciamo non media "questo non è un bug" . Questo è assolutamente un bug.
BlueRaja - Danny Pflughoeft

13
@ bayou.io Il problema principale è l'utilizzo di thread all'interno di inizializzatori statici, non lambda.
Stuart Marks

5
BTW Tunaki grazie per aver scavato le mie segnalazioni di bug. :-)
Stuart Marks

13
@ bayou.io: è la stessa cosa a livello di classe che sarebbe in un costruttore, lasciando thisscappare durante la costruzione dell'oggetto. La regola di base è non utilizzare operazioni multi-thread negli inizializzatori. Non credo che sia difficile da capire. Il tuo esempio di registrazione di una funzione implementata da lambda in un registro è una cosa diversa, non crea deadlock a meno che tu non stia aspettando uno di questi thread in background bloccati. Tuttavia, sconsiglio vivamente di eseguire tali operazioni in un inizializzatore di classe. Non è quello per cui sono destinati.
Holger

9
Immagino che la lezione sullo stile di programmazione sia: mantenere semplici gli inizializzatori statici.
Raedwald

16

Per coloro che si stanno chiedendo dove siano gli altri thread che fanno riferimento alla Deadlockclasse stessa, i lambda Java si comportano come hai scritto tu:

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) {}
}

Con classi anonime regolari non c'è deadlock:

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 È una scelta di implementazione. Il codice nel lambda deve andare da qualche parte. Javac lo compila in un metodo statico nella classe contenitore (analogo a in lambda1questo esempio). Mettere ogni lambda nella propria classe sarebbe stato notevolmente più costoso.
Stuart Marks

1
@StuartMarks Dato che lambda crea una classe che implementa l'interfaccia funzionale, non sarebbe altrettanto efficiente mettere l'implementazione del lambda nell'implementazione del lambda dell'interfaccia funzionale come nel secondo esempio di questo post? Questo è certamente il modo più ovvio di fare le cose, ma sono sicuro che ci sia un motivo per cui sono fatte come sono.
Ripristina Monica il

6
@ Solomonoff'sSecret Il lambda potrebbe creare una classe in fase di esecuzione (tramite java.lang.invoke.LambdaMetafactory ), ma il corpo lambda deve essere posizionato da qualche parte in fase di compilazione. Le classi lambda possono quindi sfruttare alcune magie della VM per essere meno costose delle normali classi caricate dai file .class.
Jeffrey Bosboom

1
@ Solomonoff'sSecret Sì, la risposta di Jeffrey Bosboom è corretta. Se in una futura JVM diventa possibile aggiungere un metodo a una classe esistente, la metafactory potrebbe farlo invece di far girare una nuova classe. (Pura speculazione.)
Stuart Marks,

3
@ Solomonoff's Secret: non giudicare guardando espressioni lambda così banali come la tua i -> i; non saranno la norma. Le espressioni lambda possono utilizzare tutti i membri della loro classe circostante, compresi privatequelli, e questo rende la classe di definizione stessa il loro posto naturale. Lasciare che tutti questi casi d'uso soffrano di un'implementazione ottimizzata per il caso speciale degli inizializzatori di classe con l'uso multi-thread di banali espressioni lambda, senza usare i membri della loro classe che li definisce, non è un'opzione praticabile.
Holger

14

C'è un'eccellente spiegazione di questo problema di Andrei Pangin , datata 7 aprile 2015. È disponibile qui , ma è scritto in russo (suggerisco comunque di rivedere i campioni di codice - sono internazionali). Il problema generale è un blocco durante l'inizializzazione della classe.

Ecco alcune citazioni dall'articolo:


Secondo JLS , ogni classe ha un blocco di inizializzazione univoco che viene acquisito durante l'inizializzazione. Quando un altro thread tenta di accedere a questa classe durante l'inizializzazione, verrà bloccato sul blocco fino al completamento dell'inizializzazione. Quando le classi vengono inizializzate contemporaneamente, è possibile ottenere un deadlock.

Ho scritto un semplice programma che calcola la somma dei numeri interi, cosa dovrebbe stampare?

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);
    }
} 

Ora rimuovi parallel()o sostituisci lambda con Integer::sumcall: cosa cambierà?

Qui vediamo di nuovo il deadlock [c'erano alcuni esempi di deadlock negli inizializzatori di classe precedentemente nell'articolo]. A causa del parallel()flusso, le operazioni vengono eseguite in un pool di thread separato. Questi thread tentano di eseguire lambda body, che è scritto in bytecode come private staticmetodo all'interno della StreamSumclasse. Ma questo metodo non può essere eseguito prima del completamento dell'inizializzatore statico della classe, che attende i risultati del completamento del flusso.

Cosa c'è di più strabiliante: questo codice funziona in modo diverso in ambienti diversi. Funzionerà correttamente su una macchina con una singola CPU e molto probabilmente si bloccherà su una macchina con più CPU. Questa differenza deriva dall'implementazione del pool Fork-Join. Puoi verificarlo tu stesso modificando il parametro-Djava.util.concurrent.ForkJoinPool.common.parallelism=N

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.