Ciao! Continuiamo a studiare il multithreading e oggi faremo conoscenza con una nuova parola chiave: volatile e il metodo yield(). Scopriamo di cosa si tratta :)
Parola chiave volatile
Quando creiamo applicazioni multi-thread, possiamo affrontare due seri problemi. In primo luogo, durante il funzionamento di un'applicazione multi-thread, diversi thread possono memorizzare nella cache i valori delle variabili (ne parleremo più approfonditamente nella lezione "Utilizzo di volatile" ). È possibile che un thread abbia modificato il valore di una variabile, ma il secondo non abbia notato questo cambiamento perché stava lavorando con la propria copia della variabile memorizzata nella cache. Naturalmente le conseguenze possono essere gravi. Immagina che questa non sia solo una sorta di "variabile", ma, ad esempio, il saldo della tua carta di credito, che improvvisamente ha iniziato a saltare avanti e indietro in modo casuale :) Non molto piacevole, vero? In secondo luogo, in Java, le operazioni di lettura e scrittura su campi di tutti i tipi trannelong
e double
sono atomiche. Cos'è l'atomicità? Bene, ad esempio, se modifichi il valore di una variabile in un thread int
e in un altro thread leggi il valore di questa variabile, otterrai il suo vecchio valore o uno nuovo, quello che si è rivelato dopo la modifica in discussione 1. Nessuna "opzione intermedia" apparirà lì Forse. Tuttavia, questo non funziona con long
e . double
Perché? Perché è multipiattaforma. Ricordi come dicevamo ai primi livelli che il principio Java è “scritto una volta, funziona ovunque”? Questo è multipiattaforma. Cioè, un'applicazione Java viene eseguita su piattaforme completamente diverse. Ad esempio, sui sistemi operativi Windows, su diverse versioni di Linux o MacOS e ovunque questa applicazione funzionerà stabilmente. long
e double
- le primitive più “pesanti” in Java: pesano 64 bit. E alcune piattaforme a 32 bit semplicemente non implementano l'atomicità della lettura e della scrittura di variabili a 64 bit. Tali variabili vengono lette e scritte in due operazioni. Nella variabile vengono prima scritti i primi 32 bit, poi altri 32. Di conseguenza, in questi casi può sorgere un problema. Un thread scrive un valore a 64 bit su una variabileХ
, e lo fa “in due passi”. Allo stesso tempo, il secondo thread tenta di leggere il valore di questa variabile, e lo fa proprio a metà, quando i primi 32 bit sono già stati scritti, ma i secondi non sono ancora stati scritti. Di conseguenza, legge un valore intermedio errato e si verifica un errore. Ad esempio, se su una piattaforma del genere proviamo a scrivere un numero in una variabile - 9223372036854775809 - occuperà 64 bit. In formato binario sarà simile a questo: 1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001 Il primo thread inizierà a scrivere questo numero in una variabile e scriverà prima i primi 32 bit: 10000000000000000000000 00000 00000 e poi il secondo 32: 00000000000000000000000000000001 E un secondo thread può incunearsi in questo divario e leggere il valore intermedio della variabile - 1000000000000000000000000000000000, i primi 32 bit che sono già stati scritti. Nel sistema decimale, questo numero è uguale a 2147483648. Cioè, volevamo solo scrivere il numero 9223372036854775809 in una variabile, ma poiché questa operazione su alcune piattaforme non è atomica, abbiamo ottenuto il numero "sinistro" 2147483648 , di cui non abbiamo bisogno, dal nulla e non si sa come influenzerà il funzionamento del programma. Il secondo thread legge semplicemente il valore della variabile prima che venga scritta definitivamente, ovvero vede i primi 32 bit, ma non i secondi 32 bit. Questi problemi, ovviamente, non si sono verificati ieri e in Java vengono risolti utilizzando una sola parola chiave: volatile . Se dichiariamo qualche variabile nel nostro programma con la parola volatile...
public class Main {
public volatile long x = 2222222222222222222L;
public static void main(String[] args) {
}
}
…significa che:
- Sarà sempre letto e scritto atomicamente. Anche se è a 64 bit
double
olong
. - La macchina Java non lo memorizzerà nella cache. Quindi è esclusa la situazione in cui 10 thread lavorano con le loro copie locali.
metodo yield()
Abbiamo già esaminato molti metodi della classeThread
, ma ce n'è uno importante che sarà nuovo per te. Questo è il metodo yield() . Tradotto dall'inglese come "arrendersi". Ed è esattamente ciò che fa il metodo! Quando chiamiamo il metodo yield su un thread, in realtà dice agli altri thread: "Okay, ragazzi, non ho particolare fretta, quindi se è importante per qualcuno di voi avere tempo di CPU, prendetelo, sono non urgente." Ecco un semplice esempio di come funziona:
public class ThreadExample extends Thread {
public ThreadExample() {
this.start();
}
public void run() {
System.out.println(Thread.currentThread().getName() + "give way to others");
Thread.yield();
System.out.println(Thread.currentThread().getName() + " has finished executing.");
}
public static void main(String[] args) {
new ThreadExample();
new ThreadExample();
new ThreadExample();
}
}
Creiamo e lanciamo in sequenza tre thread: Thread-0
, Thread-1
e Thread-2
. Thread-0
inizia per primo e lascia subito il posto agli altri. Dopo di ciò inizia Thread-1
e cede anche il passo. Dopodiché inizia Thread-2
, che è anche inferiore. Non abbiamo più thread e dopo che Thread-2
l'ultimo ha ceduto il suo posto, lo scheduler dei thread appare: “Allora non ci sono più nuovi thread, chi abbiamo in coda? Chi è stato l'ultimo a cedere il posto prima Thread-2
? Penso che fosse Thread-1
? Ok, allora lasciamo che sia fatto." Thread-1
fa il suo lavoro fino alla fine, dopodiché lo scheduler dei thread continua a coordinarsi: “Okay, Thread-1 è stato completato. Abbiamo qualcun altro in fila?" C'è il Thread-0 in coda: ha ceduto il suo posto immediatamente prima del Thread-1. Ora la questione è arrivata a lui e lo stanno portando avanti fino alla fine. Dopodiché lo scheduler termina di coordinare i thread: “Va bene, Thread-2, hai lasciato il posto ad altri thread, hanno già funzionato tutti. Sei stato l’ultimo a cedere, quindi ora tocca a te”. Successivamente, Thread-2 viene eseguito fino al completamento. L'output della console sarà simile al seguente: Thread-0 cede il passo agli altri Thread-1 cede il posto ad altri Thread-2 cede il posto ad altri Thread-1 ha terminato l'esecuzione. L'esecuzione del thread-0 è terminata. L'esecuzione del thread-2 è terminata. Lo scheduler dei thread, ovviamente, può eseguire i thread in un ordine diverso (ad esempio, 2-1-0 invece di 0-1-2), ma il principio è lo stesso.
Regole "succede prima".
L'ultima cosa di cui parleremo oggi sono i principi del " succede prima ". Come già saprai, in Java, la maggior parte del lavoro di allocazione di tempo e risorse ai thread per completare le proprie attività viene svolto dallo scheduler dei thread. Inoltre, hai visto più di una volta come i thread vengono eseguiti in un ordine arbitrario e molto spesso è impossibile prevederlo. E in generale, dopo la programmazione “sequenziale” che abbiamo fatto prima, il multithreading sembra una cosa casuale. Come hai già visto, l'avanzamento di un programma multithread può essere controllato utilizzando tutta una serie di metodi. Ma oltre a questo, nel multithreading Java c'è un'altra "isola di stabilità" - 4 regole chiamate " accade prima ". Letteralmente dall'inglese questo è tradotto come “accade prima”, o “accade prima”. Il significato di queste regole è abbastanza semplice da capire. Immagina di avere due thread:A
e B
. Ciascuno di questi thread può eseguire operazioni 1
e 2
. E quando in ciascuna delle regole diciamo " A accade prima di B ", ciò significa che tutte le modifiche apportate dal thread A
prima dell'operazione 1
e le modifiche che questa operazione ha comportato sono visibili al thread B
nel momento in cui viene eseguita l'operazione 2
e dopo che l'operazione è stata eseguita. Ognuna di queste regole garantisce che durante la scrittura di un programma multi-thread, alcuni eventi accadranno prima di altri il 100% delle volte e che il thread B
al momento dell'operazione 2
sarà sempre a conoscenza delle modifiche А
apportate dal thread durante l'operazione 1
. Diamo un'occhiata a loro.
Regola 1.
Il rilascio di un mutex avviene prima che avvenga prima che un altro thread acquisisca lo stesso monitor. Bene, qui sembra tutto chiaro. Se il mutex di un oggetto o di una classe viene acquisito da un thread, ad esempio, un threadА
, un altro thread (thread B
) non può acquisirlo contemporaneamente. È necessario attendere il rilascio del mutex.
Regola 2.
Il metodoThread.start()
accade prima Thread.run()
. Niente di complicato neanche. Lo sai già: affinché il codice all'interno del metodo inizi l'esecuzione run()
, devi chiamare il metodo sul thread start()
. È suo, e non il metodo in sé run()
! Questa regola garantisce che Thread.start()
i valori di tutte le variabili impostate prima dell'esecuzione saranno visibili all'interno del metodo che ha avviato l'esecuzione run()
.
Regola 3.
Il completamento del metodorun()
avviene prima dell'uscita dal metodo join()
. Torniamo ai nostri due flussi - А
e B
. Chiamiamo il metodo join()
in modo tale che il thread B
debba attendere fino al completamento A
prima di eseguire il proprio lavoro. Ciò significa che il metodo run()
dell'oggetto A funzionerà sicuramente fino alla fine. E tutte le modifiche ai dati che si verificano nel metodo run()
thread A
saranno completamente visibili nel thread B
quando attenderà il completamento A
e inizierà a funzionare da solo.
Regola 4.
La scrittura su una variabile volatile avviene prima della lettura dalla stessa variabile. Utilizzando la parola chiave volatile, infatti, otterremo sempre il valore corrente. Anche nel caso dilong
e double
, i cui problemi sono stati discussi in precedenza. Come già capirai, le modifiche apportate in alcuni thread non sono sempre visibili ad altri thread. Ma, ovviamente, molto spesso ci sono situazioni in cui tale comportamento del programma non è adatto a noi. Diciamo che abbiamo assegnato un valore a una variabile in un thread A
:
int z;
….
z= 555;
Se il nostro thread B
dovesse stampare il valore di una variabile z
sulla console, potrebbe facilmente stampare 0 perché non conosce il valore ad essa assegnato. Quindi, la Regola 4 ci garantisce: se dichiari una variabile z
come volatile, le modifiche ai suoi valori in un thread saranno sempre visibili in un altro thread. Se aggiungiamo la parola volatile al codice precedente...
volatile int z;
….
z= 555;
...è esclusa la situazione in cui lo stream B
restituirà 0 alla console. La scrittura su variabili volatili avviene prima della lettura da esse.
GO TO FULL VERSION