Thread e Sincronizzazione in Java
- In Java, la sincronizzazione è essenziale per garantire che le risorse condivise siano accessibili da un solo thread alla volta.
- La sincronizzazione può essere implementata utilizzando metodi
synchronized
o blocchisynchronized
. - I monitor sono utilizzati per gestire l'accesso concorrente alle risorse condivise. Essi sono implementati implicitamente in ogni oggetto Java.
- Attraverso la sincronizzazione, si evita la corruzione dello stato interno delle risorse condivise, prevenendo le cosiddette race condition.
Sincronizzazione in Java
Quando due o più thread necessitano l'accesso a una risorsa condivisa, hanno bisogno di qualche modo per assicurare che la risorsa sarà utilizzata da un solo thread alla volta.
La motivazione principale per questo è che, se due thread accedono alla stessa risorsa condivisa contemporaneamente, possono verificarsi situazioni in cui lo stato interno della risorsa condivisa viene corrotto. Ad esempio, se due thread accedono a una variabile condivisa e uno di essi la modifica mentre l'altro la legge, il thread che legge potrebbe ottenere un valore errato. Questo può portare a comportamenti imprevedibili e bug difficili da diagnosticare.
Il processo attraverso il quale questo viene raggiunto è chiamato sincronizzazione. Come vedremo, Java fornisce un supporto unico a livello di linguaggio per essa.
La chiave della sincronizzazione è il concetto di monitor. Un monitor è un oggetto che viene utilizzato come un lock mutuamente esclusivo. Solo un thread può possedere un monitor in un determinato momento. Quando un thread acquisisce un lock, si dice che esso è entrato nel monitor. Tutti gli altri thread che tentano di entrare nel monitor bloccato saranno sospesi fino a quando il primo thread non esce dal monitor. Questi altri thread si dice che stanno aspettando il monitor. Un thread che possiede un monitor può rientrare nello stesso monitor se lo desidera.
Possiamo sincronizzare il nostro codice in uno dei due modi. Entrambi coinvolgono l'uso della parola chiave synchronized
, ed entrambi sono esaminati qui.
Metodi synchronized
La sincronizzazione è facile in Java, perché tutti gli oggetti hanno il proprio monitor implicito associato ad essi.
Per entrare nel monitor di un oggetto, basta chiamare un metodo che è stato modificato con la parola chiave synchronized
. Mentre un thread è all'interno di un metodo synchronized
, tutti gli altri thread che cercano di chiamarlo (o qualsiasi altro metodo synchronized
) sulla stessa istanza devono aspettare. Per uscire dal monitor e cedere il controllo dell'oggetto al prossimo thread in attesa, il proprietario del monitor semplicemente ritorna dal metodo synchronized
.
Per comprendere la necessità della sincronizzazione, iniziamo con un semplice esempio che non la utilizza ma dovrebbe. Il seguente programma ha tre classi semplici. La prima, Chiamami
, ha un singolo metodo chiamato chiama()
. Il metodo chiama()
prende un parametro String
chiamato messaggio
. Questo metodo cerca di stampare la stringa messaggio
all'interno di parentesi quadre. La cosa interessante da notare è che dopo che chiama()
stampa la parentesi aperta e la stringa messaggio
, chiama Thread.sleep(1000), che mette in pausa il thread corrente per un secondo.
Il costruttore della classe successiva, Chiamante
, prende un riferimento a un'istanza della classe Chiamami
e una String
, che sono memorizzate rispettivamente in obiettivo
e messaggio
. Il costruttore crea anche un nuovo thread che chiamerà il metodo run()
di questo oggetto. Il metodo run()
di Chiamante
chiama il metodo chiama()
sull'istanza obiettivo
di Chiamami
, passando la stringa messaggio
. Infine, la classe Sinc
inizia creando una singola istanza di Chiamami
, e tre istanze di Chiamante
, ognuna con una stringa di messaggio unica. La stessa istanza di Chiamami
viene passata a ogni Chiamante
.
// Questo programma non è sincronizzato.
// La classe Chiamami ha un metodo chiama() che non è sincronizzato.
class Chiamami {
// Il metodo chiama() stampa il messaggio passato come parametro.
void chiama(String messaggio) {
System.out.print("[" + messaggio);
// Prima di stampare la parentesi chiusa,
// il thread corrente dorme per 1000 millisecondi.
try {
Thread.sleep(1000);
} catch(InterruptedException e) {
System.out.println("Interrotto");
}
// Stampa la parentesi chiusa.
System.out.println("]");
}
}
// La classe Chiamante implementa Runnable
// e chiama il metodo chiama() di Chiamami.
class Chiamante implements Runnable {
String messaggio;
Chiamami obiettivo;
Thread t;
// Il costruttore di Chiamante prende un oggetto Chiamami e una stringa.
public Chiamante(Chiamami obiet, String s) {
obiettivo = obiet;
messaggio = s;
t = new Thread(this);
}
// Il metodo run() chiama il metodo chiama() di Chiamami.
public void run() {
obiettivo.chiama(messaggio);
}
}
// La classe Sinc crea tre thread che chiamano il metodo chiama() di Chiamami.
class Sinc {
public static void main(String[] args) {
Chiamami obiettivo = new Chiamami();
Chiamante og1 = new Chiamante(obiettivo, "Ciao");
Chiamante og2 = new Chiamante(obiettivo, "Sincronizzato");
Chiamante og3 = new Chiamante(obiettivo, "Mondo");
// Avvia i thread.
og1.t.start();
og2.t.start();
og3.t.start();
// Aspetta che i thread finiscano.
try {
og1.t.join();
og2.t.join();
og3.t.join();
} catch(InterruptedException e) {
System.out.println("Interrotto");
}
}
}
Ecco l'output prodotto da questo programma:
[Ciao[Sincronizzato[Mondo]
]
]
Come possiamo vedere, chiamando sleep()
, il metodo chiama()
permette all'esecuzione di passare a un altro thread. Questo risulta nell'output confuso delle tre stringhe di messaggio.
In questo programma, non esiste nulla che impedisca a tutti e tre i thread di chiamare lo stesso metodo, sullo stesso oggetto, allo stesso tempo. Questa è conosciuta come una race condition, perché i tre thread stanno correndo l'uno contro l'altro per completare il metodo.
Questo esempio ha usato sleep()
per rendere gli effetti ripetibili e ovvi. Nella maggior parte delle situazioni, una race condition è più sottile e meno prevedibile, perché non possiamo essere sicuri di quando il cambio di contesto avverrà. Questo può causare l'esecuzione corretta di un programma una volta e scorretta la volta successiva.
Per correggere il programma precedente, dobbiamo serializzare l'accesso a chiama()
. Cioè, dobbiamo restringere il suo accesso a un solo thread alla volta. Per fare questo, dobbiamo semplicemente precedere la definizione di chiama()
con la parola chiave synchronized
, come mostrato qui:
class Chiamami {
// Il metodo chiama() è ora sincronizzato.
// Basta aggiungere synchronized qui.
synchronized void chiama(String messaggio) {
/* ... */
Questo impedisce ad altri thread di entrare in chiama()
mentre un altro thread lo sta utilizzando. Dopo che synchronized
è stato aggiunto a chiama()
, l'output del programma è come segue:
[Ciao]
[Sincronizzato]
[Mondo]
Ogni volta che abbiamo un metodo, o gruppo di metodi, che manipola lo stato interno di un oggetto in una situazione multithreaded, dovremmo usare la parola chiave synchronized
per proteggere lo stato dalle race condition. Ricordiamo, una volta che un thread entra in qualsiasi metodo synchronized
su un'istanza, nessun altro thread può entrare in qualsiasi altro metodo synchronized
sulla stessa istanza. Tuttavia, i metodi non sincronizzati su quell'istanza continueranno a essere chiamabili.
Istruzione synchronized
Sebbene la creazione di metodi synchronized
all'interno delle classi che creiamo sia un mezzo facile ed efficace per ottenere la sincronizzazione, non funzionerà in tutti i casi.
Per capire perché, consideriamo quanto segue. Immaginiamo di voler sincronizzare l'accesso agli oggetti di una classe che non è stata progettata per l'accesso multithreaded. Cioè, la classe non utilizza metodi synchronized
. Inoltre, questa classe non è stata creata da noi, ma da una terza parte, e non abbiamo accesso al codice sorgente. Pertanto, non possiamo aggiungere synchronized
ai metodi appropriati all'interno della classe.
Come può essere sincronizzato l'accesso a un oggetto di questa classe?
Fortunatamente, la soluzione a questo problema è abbastanza semplice: basta mettere le chiamate ai metodi definiti da questa classe all'interno di un blocco synchronized
.
Questa è la forma generale dell'istruzione
synchronized
:
synchronized(rifOggetto) {
// istruzioni da sincronizzare
// ...
}
Qui, rifOggetto
è un riferimento all'oggetto che viene sincronizzato. Un blocco synchronized assicura che una chiamata a un metodo synchronized che è un membro della classe di rifOggetto
avvenga solo dopo che il thread corrente è entrato con successo nel monitor di rifOggetto
.
Ecco una versione alternativa dell'esempio precedente, utilizzando un blocco synchronized all'interno del metodo run():
// Questo programma utilizza un blocco synchronized.
class Chiamami {
// Il metodo chiama() stampa il messaggio passato come parametro.
// Non è sincronizzato.
void chiama(String messaggio) {
System.out.print("[" + messaggio);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println("Interrotto");
}
System.out.println("]");
}
}
// La classe Chiamante implementa Runnable
// e chiama il metodo chiama() di Chiamami
// all'interno di un blocco synchronized.
class Chiamante implements Runnable {
String messaggio;
Chiamami obiettivo;
Thread t;
// Il costruttore di Chiamante prende un oggetto Chiamami e una stringa.
public Chiamante(Chiamami dest, String s) {
obiettivo = dest;
messaggio = s;
t = new Thread(this);
}
// sincronizza le chiamate a chiama()
public void run() {
// Usa un blocco synchronized per sincronizzare l'accesso a obiettivo.
// Questo assicura che solo un thread alla volta possa chiamare chiama().
synchronized(obiettivo) {
obiettivo.chiama(messaggio);
}
}
}
// La classe Sinc1 crea tre thread che chiamano il metodo chiama() di Chiamami.
class Sinc1 {
public static void main(String[] args) {
Chiamami obiettivo = new Chiamami();
Chiamante ob1 = new Chiamante(obiettivo, "Ciao");
Chiamante ob2 = new Chiamante(obiettivo, "Sincronizzato");
Chiamante ob3 = new Chiamante(obiettivo, "Mondo");
// Avvia i thread.
ob1.t.start();
ob2.t.start();
ob3.t.start();
// aspetta che i thread finiscano
try {
ob1.t.join();
ob2.t.join();
ob3.t.join();
} catch(InterruptedException e) {
System.out.println("Interrotto");
}
}
}
Qui, il metodo chiama()
non è modificato da synchronized
. Invece, l'istruzione synchronized
è utilizzata all'interno del metodo run()
di Chiamante
. Questo causa lo stesso output corretto dell'esempio precedente, perché ogni thread aspetta che quello precedente finisca prima di procedere.