Il Java Memory Model – Lezione 44 di Java Avanzato

Il Java Memory Model è il meccanismo che rende indipendente l’esecuzione di un programma Java su qualsiasi macchina, ovvero rende indipendenti le regole di gestione dei programmi dall’architettura fisica sottostante. Introdotto a partire da Java 5, il JMM è stato sviluppato per sostituire il Memory Model, un vecchio insieme di regole che consentivano di eseguire i programmi sfruttando le regole della macchina su cui venivano eseguiti. Il Java Memory Model consente quindi di astrarsi dall’hardware della macchina su cui viene eseguito il codice, pertanto queste regole consentono di scrivere codice a basso livello senza dover utilizzare ogni volta le operazioni di hit, miss, ecc.

Un programma composto da vari thread è ben sincronizzato in modo che non vi siano interferenze non ci saranno problemi con il Java Memory Model. Al contrario, potremmo avere dei risultati davvero particolari. Alcuni compilatori per velocizzare l’esecuzione dei programmi apportando delle modifiche al loro bytecode. Queste modifiche non forniscono alcuna garanzia sulla gestione dei thread se non ben gestiti, potremmo avere comportamenti inaspettati. Ad esempio alcuni elementi del programma potrebbero restare nascosti per sempre e non essere mai visti!

Facciamo un esempio, dato un ciclo while con due variabili:

  a = ...
  while(){
    i=i+a;
  }

Un buon compilatore per ottimizzare l’esecuzione del programma, invece di leggere il valore della variabile a ad ogni esecuzione del ciclo while, legge il valore di a prima del ciclo e lo inserisce in un registro in modo da avervi un accesso diretto. In un ambiente multithread se non ben sincronizzato, un’eventuale modifica al valore di a non verrà riportata nel risultato finale della variabile i.

Le regole del Java Memory Model: atomicità e visibilità

Il Java Memory Model viene suddiviso in varie categorie, tra questo troviamo l’atomicità delle operazioni e la loro visibilità. Le operazioni atomiche che si possono trovare in Java senza l’uso di meccanismi di sincronizzazione, sono di due tipi.
Nota: ricordiamo che le operazioni atomiche sono quelle operazioni inscindibili da parte del processore, ovvero che se vengono eseguito non potranno mai essere interrotto fino al loro completamente.

Le operazioni atomiche che troviamo in Java sono:

  1. Lettura o scrittura di un valore primitivo, tranne long e double, o di un riferimento è un’operazione atomica;
  2. Lettura o scrittura di un campo volatile è un’operazione atomica (approfondiremo il discorso dopo).

I tipi long e double vengono esclusi perchè hanno più di 32 bit. Essendo regole scritte oltre 15 anni fa, la dimensione delle parola-macchina era di 32 bit, quindi per salvare variabili di tipo long e double sarebbe servito più di un registro.

Vediamo qualche esempio e chiediamoci se si tratta di operazioni atomiche:

  int
  n=7; // Ok, lettura o scrittura
  n=m; // NO, lettura e scrittura
  long e=10000000L; // NO, potrebbe essere interrotta
  volatile long e=10000000L; // SI, è un modificare di campi di classe

Per quanto riguarda la visibilità delle variabili, in Java senza sincronizzazione non esiste nessuna garanzia di quando le operazioni di un thread vengono effettuati, alcuni elementi potrebbero restare nascosti per sempre.

Vediamo un altro esempio: date le variabili A e B condivise, con le variabili t e u locali ai thread e inizialmente A=B=0:

  Thread   1
  t=A
  B=1
  Thread   2
  u=K
  A=2

Senza sincronizzazione, quali sono i possibili valori di t e u?

t u Commento
0 1 Prima T1 e poi T2
1 0 Prima T2 e poi T1
0 0 Se T1 viene interrotto dopo t=1
2 1 Se t=A e B=1 vengono invertite dalla JVM o dal compilatore

Come si può vedere, senza multithreading e nessuna garanzia sull’ordine di come appaiono all’altro. Il compilatore in base alle proprie regole di ottimizzazione potrebbe sia alterare l’ordine d’esecuzione dei thread che interromperli in qualsiasi momento.

Le regole di visibilità in Java possono essere così riassunte:

  1. Acquisire un mutex (con syncronized) rende visibili le operazioni dell’ultimo thread che ha rilasciato il mutex;
  2. Leggo una variabile volatile rende visibile le operazioni dell’ultimo thread che ha modificato la variabile (vediamo tutto, anche quello non volatile);
  3. Invocare t.start() rende visibile al nuovo thread le operazioni svolte del chiamante fino a quel punto;
  4. Ritornare da t.join() rende visibili le operazioni svolte da t fino alla sua operazione.

Se il thread terminasse, potrei non vedere mai le operazioni effettuate.

Il meccanismo di volatile è simile a quello di synchronized, ma ci sono diverse differenze:

  1. Una variabile di tipo primitivo può essere dichiarata volatile, mai synchronized;
  2. L’accesso ad una variabile volatile non è mai bloccante, la variabile verrà solo letta o scritta (non ci sono lock);
  3. Non è possibile dichiarare volatile variabili immutabili (final)
  4. Non è necessario usare variabili volatile in contesti non multi-threading;
  5. Volatile non va usata in operazioni in cui è necessario un accesso esclusivo alle variabili, in questi casi bisogna utilizzare synchronized.
Indice Lezione PrecedenteLezione Successiva

Pubblicato in Guide, Java, Programmazione Taggato con: , , , , ,

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *

*