Introduzione al modello di memoria Java

Il Java Memory Model (JMM) descrive il comportamento dei thread nell'ambiente di runtime Java. Il modello di memoria fa parte della semantica del linguaggio Java e descrive ciò che un programmatore può e non deve aspettarsi quando sviluppa software non per una macchina Java specifica, ma per Java nel suo insieme.

Il modello di memoria Java originale (che, in particolare, fa riferimento alla "memoria percolocale"), sviluppato nel 1995, è considerato un fallimento: molte ottimizzazioni non possono essere effettuate senza perdere la garanzia di sicurezza del codice. In particolare, ci sono diverse opzioni per scrivere "single" multi-thread:

  • o ogni atto di accesso a un singleton (anche quando l'oggetto è stato creato molto tempo fa e nulla può cambiare) causerà un blocco inter-thread;
  • o in un certo insieme di circostanze, il sistema emetterà un solitario incompiuto;
  • o in un certo insieme di circostanze, il sistema creerà due solitari;
  • oppure il design dipenderà dal comportamento di una particolare macchina.

Pertanto, il meccanismo di memoria è stato riprogettato. Nel 2005, con il rilascio di Java 5, è stato presentato un nuovo approccio, ulteriormente migliorato con il rilascio di Java 14.

Il nuovo modello si basa su tre regole:

Regola n. 1 : i programmi a thread singolo vengono eseguiti in modo pseudo-sequenziale. Ciò significa: in realtà, il processore può eseguire diverse operazioni per clock, modificandone contemporaneamente l'ordine, tuttavia rimangono tutte le dipendenze dei dati, quindi il comportamento non differisce da quello sequenziale.

Regola numero 2 : non esistono valori dal nulla. La lettura di qualsiasi variabile (eccetto long e double non volatili, per le quali questa regola potrebbe non valere) restituirà il valore predefinito (zero) o qualcosa scritto lì da un altro comando.

E regola numero 3 : il resto degli eventi viene eseguito in ordine, se sono collegati da una stretta relazione di ordine parziale "esegue prima" ( accade prima ).

Succede prima

Leslie Lamport ha ideato il concetto di Happens prima di . Questa è una stretta relazione di ordine parziale introdotta tra i comandi atomici (++ e -- non sono atomici) e non significa "fisicamente prima".

Dice che la seconda squadra sarà "al corrente" delle modifiche apportate dalla prima.

Succede prima

Ad esempio, uno viene eseguito prima dell'altro per tali operazioni:

Sincronizzazione e monitor:

  • Catturare il monitor ( metodo di blocco , avvio sincronizzato) e qualsiasi cosa accada sullo stesso thread dopo di esso.
  • Il ritorno del monitor (metodo di sblocco , fine della sincronizzazione) e tutto ciò che accade sullo stesso thread prima di esso.
  • Restituzione del monitor e quindi acquisizione da parte di un altro thread.

Scrivere e leggere:

  • Scrivere su qualsiasi variabile e quindi leggerla nello stesso flusso.
  • Tutto nello stesso thread prima della scrittura nella variabile volatile e la scrittura stessa. volatile read e tutto sullo stesso thread dopo di esso.
  • Scrivere su una variabile volatile e poi leggerla di nuovo. Una scrittura volatile interagisce con la memoria allo stesso modo di un ritorno del monitor, mentre una lettura è come un'acquisizione. Si scopre che se un thread ha scritto su una variabile volatile e il secondo l'ha trovata, tutto ciò che precede la scrittura viene eseguito prima di tutto ciò che viene dopo la lettura; Guarda l'immagine.

Manutenzione dell'oggetto:

  • Inizializzazione statica e qualsiasi azione con qualsiasi istanza di oggetti.
  • Scrivere nei campi finali nel costruttore e tutto dopo il costruttore. In via eccezionale, la relazione accade prima non si connette in modo transitivo ad altre regole e può quindi causare una corsa tra thread.
  • Qualsiasi lavoro con l'oggetto e finalize() .

Servizio streaming:

  • Avvio di un thread e qualsiasi codice nel thread.
  • Azzeramento delle variabili relative al thread e qualsiasi codice nel thread.
  • Codice in thread e join() ; codice nel thread e isAlive() == false .
  • interrupt() il thread e rileva che si è interrotto.

Succede prima delle sfumature del lavoro

Il rilascio di un monitor accade prima si verifica prima dell'acquisizione dello stesso monitor. Vale la pena notare che è il rilascio e non l'uscita, ovvero non devi preoccuparti della sicurezza quando usi wait.

Vediamo come questa conoscenza ci aiuterà a correggere il nostro esempio. In questo caso, tutto è molto semplice: basta rimuovere il controllo esterno e lasciare la sincronizzazione così com'è. Ora è garantito che il secondo thread veda tutte le modifiche, perché otterrà il monitor solo dopo che l'altro thread lo ha rilasciato. E poiché non lo rilascerà fino a quando tutto non sarà inizializzato, vedremo tutte le modifiche contemporaneamente e non separatamente:

public class Keeper {
    private Data data = null;

    public Data getData() {
        synchronized(this) {
            if(data == null) {
                data = new Data();
            }
        }

        return data;
    }
}

La scrittura su una variabile volatile avviene prima della lettura dalla stessa variabile. La modifica che abbiamo apportato, ovviamente, corregge il bug, ma riporta chiunque abbia scritto il codice originale da dove proveniva, bloccandolo ogni volta. La parola chiave volatile può salvare. In effetti, l'affermazione in questione significa che leggendo tutto ciò che è dichiarato volatile, otterremo sempre il valore effettivo.

Inoltre, come ho detto prima, per i campi volatili la scrittura è sempre (anche long e double) un'operazione atomica. Un altro punto importante: se hai un'entità volatile che ha riferimenti ad altre entità (ad esempio, un array, List o qualche altra classe), solo un riferimento all'entità stessa sarà sempre "fresco", ma non a tutto ciò che è in è in arrivo.

Quindi, torniamo ai nostri arieti a doppia chiusura. Usando volatile, puoi risolvere la situazione in questo modo:

public class Keeper {
    private volatile Data data = null;

    public Data getData() {
        if(data == null) {
            synchronized(this) {
                if(data == null) {
                    data = new Data();
                }
            }
        }
        return data;
    }
}

Qui abbiamo ancora un blocco, ma solo se data == null. Filtriamo i casi rimanenti utilizzando la lettura volatile. La correttezza è assicurata dal fatto che l'archiviazione volatile avviene prima della lettura volatile e tutte le operazioni che si verificano nel costruttore sono visibili a chiunque legga il valore del campo.