Ricerca…


Osservazioni

Il modello di memoria Java è la sezione del JLS che specifica le condizioni in base alle quali un thread è garantito per vedere gli effetti delle scritture di memoria effettuate da un altro thread. La sezione pertinente nelle edizioni recenti è "JLS 17.4 Memory Model" (in Java 8 , Java 7 , Java 6 )

C'è stata un'importante revisione del Java Memory Model in Java 5 che (tra le altre cose) ha cambiato il modo in cui ha funzionato la volatile . Da allora, il modello di memoria è rimasto sostanzialmente invariato.

Motivazione per il modello di memoria

Considera il seguente esempio:

public class Example {
    public int a, b, c, d;
    
    public void doIt() {
       a = b + 1;
       c = d + 1;
    }
}

Se questa classe viene utilizzata è un'applicazione a thread singolo, il comportamento osservabile sarà esattamente come ci si aspetterebbe. Per esempio:

public class SingleThreaded {
    public static void main(String[] args) {
        Example eg = new Example();
        System.out.println(eg.a + ", " + eg.c);
        eg.doIt();
        System.out.println(eg.a + ", " + eg.c);
    }
}

produrrà:

0, 0
1, 1

Per quanto riguarda il thread "principale" , le istruzioni nel metodo main() e nel metodo doIt() verranno eseguite nell'ordine in cui sono scritte nel codice sorgente. Questo è un chiaro requisito della Java Language Specification (JLS).

Consideriamo ora la stessa classe utilizzata in un'applicazione multi-thread.

public class MultiThreaded {
    public static void main(String[] args) {
        final Example eg = new Example();
        new Thread(new Runnable() {
            public void run() {
                while (true) {
                    eg.doIt();
                }
            }
        }).start();
        while (true) {
            System.out.println(eg.a + ", " + eg.c);
        }
    }
}

Cosa stamperà?

Infatti, secondo JLS non è possibile prevedere che questo verrà stampato:

  • Probabilmente vedrai alcune righe di 0, 0 per iniziare.
  • Quindi probabilmente vedrai linee come N, N o N, N + 1 .
  • Potresti vedere linee come N + 1, N
  • In teoria, potresti persino vedere che le righe 0, 0 continuano per sempre 1 .

1 - In pratica, la presenza delle istruzioni println può provocare una sincronizzazione fortuita e uno svuotamento della cache della memoria. È probabile che si nascondano alcuni degli effetti che causerebbero il comportamento di cui sopra.

Quindi, come possiamo spiegarli?

Riordino dei compiti

Una possibile spiegazione per risultati imprevisti è che il compilatore JIT ha cambiato l'ordine dei compiti nel metodo doIt() . Il JLS richiede che le istruzioni vengano eseguite in ordine dalla prospettiva del thread corrente . In questo caso, nulla nel codice del metodo doIt() può osservare l'effetto di un (ipotetico) riordino di queste due affermazioni. Ciò significa che il compilatore JIT sarebbe autorizzato a farlo.

Perché dovrebbe farlo?

In un tipico hardware moderno, le istruzioni della macchina vengono eseguite utilizzando una pipeline di istruzioni che consente a una sequenza di istruzioni di essere in diverse fasi. Alcune fasi di esecuzione delle istruzioni richiedono più tempo di altre e le operazioni di memoria richiedono tempi più lunghi. Un compilatore intelligente può ottimizzare il throughput delle istruzioni della pipeline ordinando le istruzioni per massimizzare la quantità di sovrapposizione. Ciò potrebbe comportare l'esecuzione di parti di istruzioni fuori servizio. Il JLS lo consente a condizione che non influenzi il risultato del calcolo dal punto di vista del thread corrente .

Effetti delle cache di memoria

Una seconda spiegazione possibile è l'effetto della memorizzazione nella memoria cache. In un'architettura di computer classica, ogni processore ha un piccolo set di registri e una maggiore quantità di memoria. L'accesso ai registri è molto più rapido dell'accesso alla memoria principale. Nelle architetture moderne, ci sono cache di memoria che sono più lente dei registri, ma più veloci della memoria principale.

Un compilatore sfrutterà questo cercando di mantenere copie di variabili nei registri o nelle cache di memoria. Se una variabile non ha bisogno di essere scaricata nella memoria principale, o non ha bisogno di essere letta dalla memoria, ci sono significativi vantaggi in termini di prestazioni nel non farlo. Nei casi in cui il JLS non richiede che le operazioni di memoria siano visibili a un altro thread, è probabile che il compilatore JIT Java non aggiunga le istruzioni "read barrier" e "write barrier" che imporranno le letture e le scritture della memoria principale. Ancora una volta, i vantaggi in termini di prestazioni di questo sono significativi.

Sincronizzazione corretta

Finora, abbiamo visto che il JLS consente al compilatore JIT di generare codice che rende più veloce il codice a thread singolo riordinando o evitando le operazioni di memoria. Ma cosa succede quando altri thread possono osservare lo stato delle variabili (condivise) nella memoria principale?

La risposta è che gli altri thread sono soggetti ad osservare stati variabili che sembrano impossibili ... basati sull'ordine di codice delle istruzioni Java. La soluzione a questo è utilizzare la sincronizzazione appropriata. I tre approcci principali sono:

  • Uso dei mutex primitivi e dei costrutti synchronized .
  • Utilizzo di variabili volatile .
  • Utilizzo del supporto di concorrenza di livello superiore; ad es. classi nei pacchetti java.util.concurrent .

Ma anche con questo, è importante capire dove è necessaria la sincronizzazione e quali effetti su cui puoi fare affidamento. È qui che entra in gioco il modello di memoria Java.

Il modello di memoria

Il modello di memoria Java è la sezione del JLS che specifica le condizioni in base alle quali un thread è garantito per vedere gli effetti delle scritture di memoria effettuate da un altro thread. Il modello di memoria è specificato con un discreto grado di rigore formale e (come risultato) richiede una lettura dettagliata e attenta da comprendere. Ma il principio di base è che certi costrutti creano una relazione "succede prima" tra la scrittura di una variabile di un thread e una successiva lettura della stessa variabile di un altro thread. Se esiste la relazione "accade prima", il compilatore JIT è obbligato a generare codice che assicurerà che l'operazione di lettura veda il valore scritto dalla scrittura.

Armato di questo, è possibile ragionare sulla coerenza della memoria in un programma Java e decidere se questo sarà prevedibile e coerente per tutte le piattaforme di esecuzione.

Le relazioni avvenute prima

(Quanto segue è una versione semplificata di ciò che dice la specifica del linguaggio Java. Per una comprensione più approfondita, è necessario leggere la specifica stessa.)

Le relazioni Happens-before sono la parte del modello di memoria che ci consente di comprendere e ragionare sulla visibilità della memoria. Come dice JLS ( JLS 17.4.5 ):

"Due azioni possono essere ordinate da una relazione di successo prima : se si verifica un'azione , prima di un'altra, la prima è visibile e ordinata prima della seconda".

Cosa significa questo?

Azioni

Le azioni a cui si riferisce la citazione sopra sono specificate in JLS 17.4.2 . Esistono 5 tipi di azioni elencate dalle specifiche:

  • Leggi: lettura di una variabile non volatile.

  • Scrivi: scrittura di una variabile non volatile.

  • Azioni di sincronizzazione:

    • Lettura volatile: lettura di una variabile volatile.

    • Scrittura volatile: scrittura di una variabile volatile.

    • Serratura. Blocco di un monitor

    • Sbloccare. Sbloccare un monitor.

    • La (sintetica) prima e ultima azione di una discussione.

    • Azioni che avviano un thread o rilevano che un thread è terminato.

  • Azioni esterne. Un'azione che ha un risultato che dipende dall'ambiente in cui il programma.

  • Discussione delle azioni di divergenza. Questi modellano il comportamento di certi tipi di loop infinito.

Ordine di programmazione e ordine di sincronizzazione

Questi due ordini ( JLS 17.4.3 e JLS 17.4.4 ) regolano l'esecuzione delle istruzioni in un Java

L'ordine del programma descrive l'ordine di esecuzione dell'istruzione all'interno di un singolo thread.

L'ordine di sincronizzazione descrive l'ordine dell'esecuzione dell'istruzione per due istruzioni collegate da una sincronizzazione:

  • Un'azione di sblocco sul monitor si sincronizza con tutte le azioni di blocco successive su quel monitor.

  • Una scrittura su una variabile volatile si sincronizza con tutte le letture successive della stessa variabile da qualsiasi thread.

  • Un'azione che avvia un thread (cioè la chiamata a Thread.start() ) si sincronizza con la prima azione nel thread che inizia (ovvero la chiamata al metodo run() del thread).

  • L'inizializzazione predefinita dei campi si sincronizza con la prima azione in ogni thread. (Vedi la JLS per una spiegazione di questo.)

  • L'azione finale in un thread si sincronizza con qualsiasi azione in un altro thread che rileva la terminazione; ad esempio il ritorno di una chiamata join() o isTerminated() che restituisce true .

  • Se un thread interrompe un altro thread, la chiamata di interrupt nel primo thread si sincronizza, con il punto in cui un altro thread rileva che il thread è stato interrotto.

Happens-before Order

Questo ordinamento ( JLS 17.4.5 ) è ciò che determina se una scrittura di memoria è garantita per essere visibile a una lettura di memoria successiva.

Più specificatamente, una lettura di una variabile v è garantita per osservare una scrittura su v se e solo se avviene la write(v) - prima di read(v) E non vi è alcun intervento scritto su v . Se ci sono scritture intervenienti, allora la read(v) può vedere i risultati di esse piuttosto che la prima.

Le regole che definiscono l' accaduto, prima di ordinare, sono le seguenti:

  • Regola Happens-Before # 1 - Se xey sono azioni dello stesso thread e x viene prima di y in ordine di programma , allora x accade-prima di y.

  • Happens-Before Rule # 2 - C'è un fronte di happen-before dalla fine di un costruttore di un oggetto all'inizio di un finalizzatore per quell'oggetto.

  • Happens-Before Rule # 3 - Se un'azione x si sincronizza con un'azione successiva y, allora x accade prima di y.

  • Happens-Before Rule # 4 - Se x accade-prima che y e y succedano-prima di z allora x accade-prima di z.

Inoltre, varie classi nelle librerie standard Java vengono specificate come definizione delle relazioni precedenti . Puoi interpretare ciò nel senso che accade in qualche modo , senza bisogno di sapere esattamente come verrà soddisfatta la garanzia.

Succede prima che il ragionamento si applicasse ad alcuni esempi

Presenteremo alcuni esempi per mostrare come applicare prima - il ragionamento per verificare che le scritture siano visibili alle letture successive.

Codice a thread singolo

Come ci si aspetterebbe, le scritture sono sempre visibili alle letture successive in un programma a thread singolo.

public class SingleThreadExample {
    public int a, b;
    
    public int add() {
       a = 1;         // write(a)
       b = 2;         // write(b)
       return a + b;  // read(a) followed by read(b)
    }
}

Prima regola: numero 1:

  1. L'azione write(a) avviene prima dell'azione write(b) .
  2. L'azione write(b) avviene prima dell'azione read(a) .
  3. L'azione di read(a) verifica prima dell'azione di read(a) .

Dalla regola 4 di Happens-Before:

  1. write(a) accade-prima di write(b) E write(b) accade-prima di read(a) IMPLICA write(a) accade-prima di read(a) .
  2. write(b) succede-prima read(a) E read(a) succede-prima read(b) IMPLICA write(b) succede-prima read(b) .

Riassumendo:

  1. La relazione write(a) happens-before read(a) significa che l'istruzione a + b è garantita per vedere il valore corretto di a .
  2. La relazione write(b) happens-before read(b) significa che l'istruzione a + b è garantita per vedere il valore corretto di b .

Comportamento di 'volatile' in un esempio con 2 thread

Useremo il seguente codice di esempio per esplorare alcune implicazioni del modello di memoria per "volatile".

public class VolatileExample {
    private volatile int a;
    private int b;         // NOT volatile
    
    public void update(int first, int second) {
       b = first;         // write(b)
       a = second;         // write-volatile(a)
    }

    public int observe() {
       return a + b;       // read-volatile(a) followed by read(b)
    }
}

Innanzitutto, considera la seguente sequenza di istruzioni che coinvolgono 2 thread:

  1. Viene creata una singola istanza di VolatileExample ; chiamalo ve ,
  2. ve.update(1, 2) è chiamato in un thread, e
  3. ve.observe() è chiamato in un altro thread.

Prima regola: numero 1:

  1. L'azione write(a) avviene prima dell'azione volatile-write(a) .
  2. L'azione volatile-read(a) avviene prima dell'azione read(b) .

Dalla regola Happens-Before # 2:

  1. L'azione volatile-write(a) nel primo thread avviene prima dell'azione volatile-read(a) nel secondo thread.

Dalla regola 4 di Happens-Before:

  1. L'azione write(b) nel primo thread avviene prima dell'azione read(b) nel secondo thread.

In altre parole, per questa particolare sequenza, è garantito che il secondo thread vedrà l'aggiornamento della variabile non volatile b creata dal primo thread. Tuttavia, dovrebbe anche essere chiaro che se le assegnazioni nel metodo di update erano il contrario, o il metodo observe() leggeva la variabile b prima di a , allora la catena degli eventi precedenti sarebbe stata interrotta. La catena sarebbe anche rotta se volatile-read(a) nel secondo thread non era successivo alla volatile-write(a) nel primo thread.

Quando la catena è rotta, non vi è alcuna garanzia che observe() vedrà il valore corretto di b .

Volatile con tre fili

Supponiamo di aggiungere un terzo thread nell'esempio precedente:

  1. Viene creata una singola istanza di VolatileExample ; chiamalo ve ,
  2. update chiamate a due thread:
    • ve.update(1, 2) è chiamato in un thread,
    • ve.update(3, 4) è chiamato nel secondo thread,
  3. ve.observe() viene successivamente chiamato in una terza discussione.

Per analizzare completamente questo, dobbiamo considerare tutti i possibili intrecci delle affermazioni nel primo thread e nel secondo. Invece, considereremo solo due di loro.

Scenario n. 1 - Supponiamo che l' update(1, 2) preceda l' update(3,4) otteniamo questa sequenza:

write(b, 1), write-volatile(a, 2)     // first thread
write(b, 3), write-volatile(a, 4)     // second thread
read-volatile(a), read(b)             // third thread

In questo caso, è facile vedere che c'è un ininterrotto successo - prima della catena da write(b, 3) a read(b) . Inoltre non vi è alcun intervento scritto a b . Quindi, per questo scenario, il terzo thread è garantito per vedere b come valore 3 .

Scenario 2 - Supponiamo che l' update(1, 2) e l' update(3,4) sovrappongano e le azioni siano intercalate come segue:

write(b, 3)                           // second thread
write(b, 1)                           // first thread
write-volatile(a, 2)                  // first thread
write-volatile(a, 4)                  // second thread
read-volatile(a), read(b)             // third thread

Ora, mentre c'è una catena di successi prima di write(b, 3) per read(b) , c'è un'azione di write(b, 1) interposta eseguita dall'altro thread. Ciò significa che non possiamo essere certi quale valore read(b) vedrà.

(A parte questo: ciò dimostra che non possiamo fare affidamento sulla volatile per garantire la visibilità delle variabili non volatili, tranne in situazioni molto limitate).

Come evitare di aver bisogno di capire il modello di memoria

Il modello di memoria è difficile da capire e difficile da applicare. È utile se hai bisogno di ragionare sulla correttezza del codice multi-thread, ma non vuoi dover fare questo ragionamento per ogni applicazione multi-thread che scrivi.

Se si adottano i principi seguenti quando si scrive codice concorrente in Java, è possibile evitare in gran parte la necessità di ricorrere al ragionamento degli eventi precedenti .

  • Utilizzare strutture di dati immutabili ove possibile. Una classe immutabile correttamente implementata sarà thread-safe e non introdurrà problemi di sicurezza del thread quando lo si utilizza con altre classi.

  • Comprendere ed evitare "pubblicazione non sicura".

  • Usa i mutex primitivi o Lock objects per sincronizzare l'accesso allo stato in oggetti mutabili che devono essere thread-safe 1 .

  • Utilizzare Executor / ExecutorService o il framework fork join piuttosto che tentare di creare direttamente i thread di gestione.

  • Utilizza le classi `java.util.concurrent che forniscono blocchi avanzati, semafori, latch e barriere, invece di usare wait / notify / notifyAll direttamente.

  • Utilizzare le versioni java.util.concurrent di mappe, insiemi, elenchi, code e deques piuttosto che la sinconizzazione esterna di raccolte non concorrenti.

Il principio generale è di provare ad utilizzare le librerie di concomitanza integrate di Java piuttosto che la "rotazione della propria" concorrenza. Puoi fare affidamento su di loro lavorando, se li usi correttamente.


1 - Non tutti gli oggetti devono essere thread-safe. Ad esempio, se un oggetto o un oggetto è limitato da un thread (ovvero è accessibile solo a un thread), la sua sicurezza del thread non è rilevante.



Modified text is an extract of the original Stack Overflow Documentation
Autorizzato sotto CC BY-SA 3.0
Non affiliato con Stack Overflow