Parallel Infinite Java Streams esauriscono la memoria


16

Sto cercando di capire perché il seguente programma Java dà un OutOfMemoryError, mentre il programma corrispondente senza .parallel().

System.out.println(Stream
    .iterate(1, i -> i+1)
    .parallel()
    .flatMap(n -> Stream.iterate(n, i -> i+n))
    .mapToInt(Integer::intValue)
    .limit(100_000_000)
    .sum()
);

Ho due domande:

  1. Qual è l'output previsto di questo programma?

    Senza .parallel()sembra che questo semplicemente sum(1+2+3+...)emetta, il che significa che si "blocca" semplicemente al primo flusso nella flatMap, il che ha senso.

    Con parallelamente non so se ci sia un comportamento previsto, ma la mia ipotesi sarebbe che in qualche modo intercalasse i primi nflussi, dove si ntrova il numero di operatori paralleli. Potrebbe anche essere leggermente diverso in base al comportamento di chunking / buffering.

  2. Cosa causa l'esaurimento della memoria? Sto specificamente cercando di capire come questi flussi sono implementati sotto il cofano.

    Immagino che qualcosa blocchi lo stream, quindi non finisce mai ed è in grado di sbarazzarsi dei valori generati, ma non so bene in quale ordine vengono valutate le cose e dove si verifica il buffering.

Modifica: nel caso sia rilevante, sto usando Java 11.

Editt 2: Apparentemente la stessa cosa accade anche per il semplice programma IntStream.iterate(1,i->i+1).limit(1000_000_000).parallel().sum(), quindi potrebbe avere a che fare con la pigrizia limitpiuttosto che flatMap.


parallel () utilizza internamente ForkJoinPool. Credo che ForkJoin quadro è in Java da Java 7
Aravind

Risposte:


9

Dici " ma non so bene in quale ordine vengano valutate le cose e dove avvenga il buffering ", che è esattamente ciò che riguarda i flussi paralleli. L'ordine di valutazione non è specificato.

Un aspetto critico del tuo esempio è il .limit(100_000_000). Ciò implica che l'implementazione non può semplicemente riassumere valori arbitrari, ma deve riassumere i primi 100.000.000 di numeri. Si noti che nell'implementazione di riferimento, .unordered().limit(100_000_000)non cambia il risultato, il che indica che non esiste un'implementazione speciale per il caso non ordinato, ma si tratta di un dettaglio di implementazione.

Ora, quando i thread di lavoro elaborano gli elementi, non possono semplicemente riassumerli, poiché devono sapere quali elementi sono autorizzati a consumare, il che dipende da quanti elementi precedono il loro carico di lavoro specifico. Poiché questo stream non conosce le dimensioni, questo può essere conosciuto solo quando gli elementi del prefisso sono stati elaborati, cosa che non accade mai per flussi infiniti. Pertanto, i thread di lavoro continuano a eseguire il buffering per il momento, queste informazioni diventano disponibili.

In linea di principio, quando un thread di lavoro sa che elabora il pezzo di lavoro più a sinistra¹, potrebbe riassumere immediatamente gli elementi, contarli e segnalare la fine quando raggiunge il limite. Quindi lo Stream potrebbe terminare, ma questo dipende da molti fattori.

Nel tuo caso, uno scenario plausibile è che gli altri thread di lavoro sono più veloci nell'allocare i buffer rispetto al conteggio del lavoro più a sinistra. In questo scenario, lievi modifiche ai tempi potrebbero far tornare occasionalmente il flusso con un valore.

Quando rallentiamo tutti i thread di lavoro tranne quello che elabora il blocco più a sinistra, possiamo far terminare il flusso (almeno nella maggior parte delle esecuzioni):

System.out.println(IntStream
    .iterate(1, i -> i+1)
    .parallel()
    .peek(i -> { if(i != 1) LockSupport.parkNanos(1_000_000_000); })
    .flatMap(n -> IntStream.iterate(n, i -> i+n))
    .limit(100_000_000)
    .sum()
);

¹ Sto seguendo un suggerimento di Stuart Marks di usare l'ordine da sinistra a destra quando parlo dell'ordine dell'incontro piuttosto che dell'ordine di elaborazione.


Risposta molto bella! Mi chiedo se c'è anche il rischio che tutti i thread inizino a eseguire le operazioni flatMap e nessuno venga allocato per svuotare effettivamente i buffer (somma)? Nel mio caso d'uso gli stream infiniti sono invece file troppo grandi per essere conservati in memoria. Mi chiedo come posso riscrivere lo stream per limitare l'utilizzo della memoria?
Thomas Ahle,

1
Stai usando Files.lines(…)? È stato migliorato significativamente in Java 9.
Holger

1
Questo è ciò che fa in Java 8. Nei JRE più recenti, BufferedReader.lines()in determinate circostanze tornerà ancora (non il filesystem predefinito, un set di caratteri speciale o dimensioni maggiori di Integer.MAX_FILES). Se uno di questi si applica, una soluzione personalizzata potrebbe aiutare. Questo varrebbe la pena di un nuovo Q & A ...
Holger

1
Integer.MAX_VALUE, ovviamente ...
Holger,

1
Qual è il flusso esterno, un flusso di file? Ha una dimensione prevedibile?
Holger

5

La mia ipotesi migliore è che l'aggiunta parallel()modifica il comportamento interno di flatMap()cui già avevano problemi a essere valutati pigramente prima .

L' OutOfMemoryErrorerrore che viene visualizzato è stato segnalato in [JDK-8202307] Ottenere un java.lang.OutOfMemoryError: spazio heap Java quando si chiama Stream.iterator (). Next () su uno stream che utilizza uno stream infinito / molto grande in flatMap . Se guardi il biglietto è più o meno la stessa traccia dello stack che stai ricevendo. Il biglietto è stato chiuso come non risolto con il seguente motivo:

I metodi iterator()e spliterator()sono "tratteggi di escape" da utilizzare quando non è possibile utilizzare altre operazioni. Hanno alcune limitazioni perché trasformano quello che è un modello push dell'implementazione del flusso in un modello pull. Tale transizione richiede il buffering in alcuni casi, ad esempio quando un elemento è (piatto) mappato su due o più elementi . Ciò complicherebbe in modo significativo l'implementazione del flusso, probabilmente a spese di casi comuni, per supportare l'idea di contropressione per comunicare quanti elementi attraversare strati nidificati di produzione di elementi.


Questo è molto interessante! È logico che la transizione push / pull richieda il buffering che potrebbe esaurire la memoria. Tuttavia nel mio caso sembra che usare solo push dovrebbe funzionare bene e semplicemente scartare gli elementi rimanenti come appaiono? O forse stai dicendo che flapmap provoca la creazione di un iteratore?
Thomas Ahle,

3

OOME è causata non dalla corrente essendo infinito, ma per il fatto che essa non è .

Vale a dire, se commentate il .limit(...), non rimarrà mai a corto di memoria - ma ovviamente, non finirà mai neanche.

Una volta diviso, il flusso può tenere traccia del numero di elementi solo se sono accumulati all'interno di ciascun thread (sembra che sia l'accumulatore reale Spliterators$ArraySpliterator#array).

Sembra che tu possa riprodurlo senza flatMap, basta eseguire quanto segue con -Xmx128m:

    System.out.println(Stream
            .iterate(1, i -> i + 1)
            .parallel()
      //    .flatMap(n -> Stream.iterate(n, i -> i+n))
            .mapToInt(Integer::intValue)
            .limit(100_000_000)
            .sum()
    );

Tuttavia, dopo aver commentato il limit(), dovrebbe funzionare bene fino a quando non decidi di risparmiare il tuo laptop.

Oltre ai dettagli di implementazione, ecco cosa penso stia accadendo:

Con limit, il sumriduttore vuole riassumere i primi X elementi, quindi nessun thread può emettere somme parziali. Ogni "fetta" (thread) dovrà accumulare elementi e passarli attraverso. Senza limiti, non esiste un tale vincolo, quindi ogni "fetta" calcolerà semplicemente la somma parziale degli elementi che ottiene (per sempre), supponendo che alla fine emetterà il risultato.


Cosa intendi con "una volta diviso"? Il limite lo divide in qualche modo?
Thomas Ahle,

@ThomasAhle parallel()utilizzerà ForkJoinPoolinternamente per raggiungere il parallelismo. Il Spliteratorverranno utilizzati per il lavoro assegnare a ciascun ForkJoincompito, credo che possiamo chiamare l'unità di lavoro qui come "split".
Karol Dowbecki,

Ma perché succede solo con un limite?
Thomas Ahle,

@ThomasAhle Ho modificato la risposta con i miei due centesimi.
Costi Ciudatu,

1
@ThomasAhle imposta un punto di interruzione Integer.sum(), utilizzato dal IntStream.sumriduttore. Vedrai che la versione senza limiti chiama sempre quella funzione, mentre la versione limitata non la chiama mai prima di OOM.
Costi Ciudatu,
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.