Lezione 16 · Canale 2 · lunedì 2 novembre 2026
Ciclo di vita e sincronizzazione dei thread
Progettazione del Software
Lezione 16 · Canale 2 · lunedì 2 novembre 2026
Progettazione del Software
Un diagramma di stati descrive il ciclo di vita di un thread: i rettangoli sono gli stati, le frecce le transizioni. Un thread runnable è attivo ed esegue run(); in waiting, timed waiting e blocked (not running) non usa il processore e ne esce solo per una causa precisa.
| Stato | Ci si arriva | Se ne esce |
|---|---|---|
| new | new Thread(r) | start() |
| runnable | start(), o l'uscita da not running | run() termina, o il thread si ferma |
| waiting | wait() | notify(), notifyAll() |
| timed waiting | sleep(ms) | scade l'intervallo |
| blocked | monitor occupato, o I/O | monitor libero, o I/O finito |
| terminated | run() termina | stato finale |
yield() cede volontariamente il controllo: il processore può passare a un thread runnable di priorità maggiore o uguale. isAlive() dà false se il thread non è mai partito o è terminated, altrimenti true: non distingue gli stati.
Un gestore non preemptive non interrompe i thread: cedono da soli il controllo (I/O, sleep(), yield()). Uno preemptive può metterli in pausa, a suddivisione di tempo o per priorità. Java non specifica come mappa i suoi thread su quelli del sistema operativo: l'alternanza dipende dalla piattaforma, senza garanzie di tempo reale. Di norma vince la priorità più alta e a parità si alterna a turno (round robin), ma il programma non deve contarci.
MAX_PRIORITY, NORM_PRIORITY e MIN_PRIORITY valgono 10, 5 e 1; getPriority() e setPriority(p) leggono e cambiano la priorità, da 1 a 10. Ordina i thread senza fissare i tempi e il sistema operativo può ignorarla. Un ciclo infinito di soli calcoli (thread CPU bound) potrebbe tenere il processore per sempre: dentro ci vogliono I/O, una sleep o un coordinamento con altri thread.
Un thread può dover aspettare la fine di altri: il thread 1 avvia i thread 2 e 3, esegue il suo task e li aspetta prima di proseguire. t2.join() sospende il chiamante finché t2 termina. join(ms) e join(ms, ns) aspettano al più quel tempo. join() lancia InterruptedException: va in un try o si dichiara con throws, e nel catch sta il codice per un risveglio anomalo.
CountDown stampa un conto alla rovescia con 100 ms di pausa a ogni passo.
public class CountDown implements Runnable {
private String id;
private int countDown;
public CountDown(String nome, int countDownPartenza) {
id = nome;
countDown = countDownPartenza;
}
private String status() {
return id + ": " + (countDown > 0 ? countDown : "Go!") + "\n";
}
public void run() {
while (countDown >= 0) {
System.out.print(status());
countDown--;
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
}
}
}
Se main chiama run() direttamente sui due oggetti, i conti vanno uno dopo l'altro nel suo thread. Con due thread e senza join, «Si parte!!!» compare mentre i conti sono in corso. Con join compare per ultima.
CountDown h = new CountDown("Houston", 10);
CountDown c = new CountDown("CapeCanaveral", 10);
Thread t1 = new Thread(h);
Thread t2 = new Thread(c);
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
// codice per un risveglio anomalo
}
System.out.println("Si parte!!!");
t2.interrupt() è una richiesta: imposta un flag in t2, che non la serve subito. Se t2 è running, controlla il flag con Thread.interrupted(); se non lo fa, prosegue senza accorgersene. Se è in attesa, o ci arriva, per esempio in sleep() o wait(), lancia InterruptedException, riporta il flag a false e prosegue nel catch. Serve quindi a gestire errori, non a sincronizzare: per aspettare un thread si usa join().
public class SleepInterrupt implements Runnable {
public void run() {
try {
System.out.println("in run()");
Thread.sleep(20000);
System.out.println("in run() - woke up");
} catch (InterruptedException x) {
System.out.println("in run() - interrupted while sleeping");
return;
}
System.out.println("fine");
}
}
SleepInterrupt si = new SleepInterrupt();
Thread t = new Thread(si);
t.start();
try {
Thread.sleep(2000);
} catch (InterruptedException x) {
}
System.out.println("in main() - interrupting other thread");
t.interrupt();
System.out.println("in main() - leaving");
L'ordine fra le stampe dei due thread non è garantito.
Thread concorrenti possono competere per la stessa risorsa: devono alternarsi. Due thread chiamano dieci volte incrementa() di uno stesso Contatore, con 10 ms di pausa.
public class Contatore {
private int valore;
public synchronized void incrementa() {
int tmp = valore;
try {
Thread.sleep(10);
} catch (InterruptedException ex) {
}
valore = tmp + 1;
System.out.println(Thread.currentThread().getName() + ": il contatore segna " + valore);
}
public synchronized int getValore() {
return valore;
}
}
Senza synchronized i due thread leggono lo stesso valore prima che l'altro lo aggiorni e di norma stampano i numeri a coppie. Il campo è uno solo: l'errore è nell'accesso non protetto. Serve un'esecuzione atomica, un thread alla volta. Con synchronized su incrementa() e su getValore(), che legge ciò che l'altro scrive, ogni valore da 1 a 20 compare una volta, ma non è fissato quale thread lo stampi.
I valori stampati dal contatore
synchronized installa un monitor, che controlla l'accesso a un metodo o a una porzione di codice e realizza la mutua esclusione: un solo thread alla volta esegue il codice protetto, gli altri aspettano in blocked. Quel codice non va in parallelo, quindi si sincronizza il minimo.
synchronized usa il monitor dell'oggetto.static synchronized usa il monitor della classe.synchronized (ogg) { … } usa il monitor di ogg, che è un oggetto, non un tipo semplice. Protegge solo una parte del codice, o un oggetto come un array. Con oggetti diversi i blocchi non si escludono.I metodi non sincronizzati ignorano il monitor. Il monitor è rientrante: chi lo detiene può chiamare altri metodi sincronizzati dello stesso oggetto senza bloccarsi. Così il codice protetto si divide in metodi piccoli.
Un deadlock si ha quando due thread tengono ciascuno un monitor e aspettano quello dell'altro. In Risorsa i metodi getValore(), getNome() e maxVal(ra) sono sincronizzati, e maxVal chiama ra.getValore().
public class Risorsa {
private String nome;
private int valore;
public Risorsa(String id, int v) {
nome = id;
valore = v;
}
public synchronized int getValore() {
return valore;
}
public synchronized String getNome() {
return nome;
}
public synchronized Risorsa maxVal(Risorsa ra) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
int va = ra.getValore();
if (valore >= va)
return this;
else
return ra;
}
}
Un Confronto chiama r1.maxVal(r2) e stampa il nome della risorsa maggiore. Con due thread che si confrontano a rovescio il programma non finisce.
Risorsa r1 = new Risorsa("alfa", 5);
Risorsa r2 = new Risorsa("beta", 20);
Confronto c1 = new Confronto(r1, r2);
Confronto c2 = new Confronto(r2, r1);
Thread t1 = new Thread(c1);
Thread t2 = new Thread(c2);
t1.start();
t2.start();
t1 tiene il monitor di r1 e t2 quello di r2. t1 chiama r2.getValore() e aspetta t2, t2 chiama r1.getValore() e aspetta t1. main termina, ma i due thread restano bloccati.
Un thread con setDaemon(true), prima di start(), è un demone: il programma termina quando restano solo demoni, anche se a metà lavoro. È rischioso. Per avere il controllo si usano thread normali, senza deadlock, aspettati con join().
Nel paradigma produttore-consumatore un thread scrive in una risorsa e un altro legge. Il consumatore aspetta se non ci sono dati, il produttore se non c'è posto, perché la capacità è limitata. Servono tre metodi di Object, chiamati in codice sincronizzato su quell'oggetto, perché agiscono sul suo monitor:
wait() porta il thread in waiting e rilascia il monitor, finché arriva una notifica o un interrupt();notifyAll() risveglia tutti i thread in attesa su quell'oggetto, che si rimettono in coda per il monitor;notify() ne risveglia uno solo, a caso.Una notifica non garantisce che la condizione sia vera, quindi wait() sta in un ciclo che la ricontrolla: while (!condizione) wait();. Il ciclo qui va bene perché contiene wait().
Esempio: una catena di montaggio del legno. Il produttore fa 80 pezzi al minuto e li mette in un magazzino da 20 posti. Due consumatori li prendono: uno fa 6 tavoli al minuto da 9 tavole l'uno, l'altro 6 sedie al minuto da 6 tavole l'una. Se chi legge non tiene il passo, chi scrive si ferma quando la struttura è piena.
BoundedQueue<T> è la coda limitata, con T tipo qualsiasi (i generici si vedranno più avanti). remove() aspetta se la coda è vuota, add() se è piena, cioè se la dimensione ha raggiunto la capacità. Ogni operazione avvisa tutti con notifyAll(): con notify() potrebbe svegliarsi un produttore con la coda piena, che si rimetterebbe in attesa, e nessuno sveglierebbe un consumatore.
import java.util.ArrayList;
import java.util.List;
public class BoundedQueue<T> {
private List<T> elements;
private int maxSize;
public BoundedQueue(int capacity) {
elements = new ArrayList<T>();
maxSize = capacity;
}
public synchronized T remove() throws InterruptedException {
while (elements.size() == 0)
wait();
T r = elements.remove(0);
notifyAll();
return r;
}
public synchronized void add(T newElem) throws InterruptedException {
while (elements.size() == maxSize)
wait();
elements.add(newElem);
notifyAll();
}
}
Il Producer aggiunge un elemento per volta, con una pausa casuale; il Consumer ne preleva e li stampa.
public class Producer implements Runnable {
private String greeting;
private BoundedQueue<String> queue;
private int greetingCount;
private static final int DELAY = 10;
public Producer(String aGreeting, BoundedQueue<String> aQueue, int count) {
greeting = aGreeting;
queue = aQueue;
greetingCount = count;
}
public void run() {
try {
int i = 1;
while (i <= greetingCount) {
queue.add(i + ": " + greeting);
i++;
Thread.sleep((int) (Math.random() * DELAY));
}
} catch (InterruptedException exception) {
}
}
}
public class Consumer implements Runnable {
private BoundedQueue<String> queue;
private int greetingCount;
private static final int DELAY = 10;
public Consumer(BoundedQueue<String> aQueue, int count) {
queue = aQueue;
greetingCount = count;
}
public void run() {
try {
int i = 1;
while (i <= greetingCount) {
Object greeting = queue.remove();
System.out.println(greeting);
i++;
Thread.sleep((int) (Math.random() * DELAY));
}
} catch (InterruptedException exception) {
}
}
}
Il programma principale crea una coda da 10 posti, la passa a due produttori e due consumatori, da 1000 operazioni ciascuno, e avvia i quattro thread.
BoundedQueue<String> queue = new BoundedQueue<String>(10);
final int GREETING_COUNT = 1000;
Runnable run1 = new Producer("Hello, World!", queue, GREETING_COUNT);
Runnable run2 = new Producer("Goodbye, World!", queue, GREETING_COUNT);
Runnable run3 = new Consumer(queue, GREETING_COUNT);
Runnable run4 = new Consumer(queue, GREETING_COUNT);
Thread thread1 = new Thread(run1);
Thread thread2 = new Thread(run2);
Thread thread3 = new Thread(run3);
Thread thread4 = new Thread(run4);
thread1.start();
thread2.start();
thread3.start();
thread4.start();
Una coda condivisa
Il pacchetto java.util.concurrent ha già strutture per i dati condivisi: BlockingQueue e ConcurrentMap.
Stati
new → runnable: start()sleep(ms): timed waitingwait(): waitingrun() termina: terminatedjoin e interrupt
t.join(): attende la fine
di tt.interrupt(): richiesta,
non forza la finesleep o wait: lancia
InterruptedExceptionMonitor
synchronized: un thread
alla voltathisstatic: monitor della classesynchronized (ogg)Deadlock
wait e notify
while (!condizione) wait();notify(): unonotifyAll(): tuttiPriorità