Thread in JAVA: Ciclo di Vita, Sincronizzazione e Concorrenza
Da un main single-thread a un'app che scarica, calcola e aggiorna l'UI contemporaneamente: come funziona davvero un Thread in Java, come nasce, dorme, aspetta e muore dentro la JVM.
Quando lanciate java Main pensate di far partire un programma. In realtà state accendendo una piccola città: la JVM parte, crea il main thread, il garbage collector, il Finalizer, il JIT compiler. Io vi dico sempre una cosa in lab: se il vostro programma fa tutto nel main, state usando lo 0.1% della JVM. Oggi impariamo a usarla tutta.
1. Programmi, processi e thread: perché Java non vive di solo main
Processo = istanza di JVM con suo heap. Thread = flusso leggero dentro la JVM che condivide heap ma ha suo stack e program counter. In Java ogni thread è un oggetto java.lang.Thread.
Il programma App.class è statico. Il processo è la JVM che lo carica: ha heap, Metaspace, file aperti. Il thread è l'unità che davvero gira sul core. Io ve lo spiego così da 10 anni e funziona sempre.
Io ho pensato di fare così per farvelo ricordare all'interrogazione: aprite IntelliJ, lanciate un programma vuoto e guardate in Debug > Threads: vedrete almeno 5 thread anche se non ne avete creato uno. Io lo farei così per spiegarlo alla Maturità: "Il programma è la fabbrica, il processo è la fabbrica accesa con corrente e operai (JVM), i thread sono gli operai che lavorano in parallelo sullo stesso magazzino (heap)". Se dite che thread e processo sono uguali, vi fermo subito.
Avete una app Swing/JavaFX che scarica un file nel main thread? L'interfaccia si freeza. Perché? Un solo thread fa tutto: UI + rete. Soluzione: un thread worker scarica, il main resta RUNNABLE per ridisegnare. Questo è il motivo #1 per cui in Java si usano i thread.
2. Multithreading, multiprocessing e la JVM
Una JVM single-core fa time-sharing tra thread Java, esattamente come il SO tra processi. Con più core, fa vero parallelismo. La JVM mappa i Java Thread su thread nativi del SO (1:1 su HotSpot).
| Termine | Significato in Java | Obiettivo / Quando |
|---|---|---|
| Multithreading | Più Thread nella stessa JVM, condividono heap, stack separati | Reattività + sfruttare multi-core senza duplicare memoria |
| Multiprocessing (multi-JVM) | Più processi JVM separati, heap isolati | Isolamento totale, ma costo RAM alto |
| Concorrenza vs Parallelismo | Concorrenza = gestire più task (interleaving), Parallelismo = eseguirli davvero insieme su core diversi | Java fa entrambi: Executor decide |
Io ho pensato di fare così quando correggo: se scrivete "i thread non condividono nulla" vi tolgo mezzo punto. Io lo farei così per ricordarlo: "In Java i thread condividono l'heap (oggetti), non lo stack (variabili locali)". Guardate VisualVM > Threads: vedete RUNNABLE che saltano da un core all'altro.
Due thread che fanno conto++ sullo stesso oggetto condividono la stessa variabile in heap. Senza sincronizzazione perdete incrementi. Non è un bug raro, è la normalità se non usate synchronized o AtomicInteger.
3. Il ciclo di vita: gli stati di un Thread in Java
Un Thread Java non ha 3 stati, ne ha 6. Li trovate in Thread.State. Li dovete sapere a memoria per l'esame, con le frecce.
| Stato Java | Cosa significa davvero |
|---|---|
| NEW | Oggetto Thread creato con new Thread(), ma start() non ancora chiamato. Non in scheduler. |
| RUNNABLE | Dopo start(). In realtà è Ready + Running messi insieme. La JVM decide quando dargli CPU. |
| BLOCKED | Bloccato su monitor: vuole entrare in un blocco synchronized già occupato. |
| WAITING | Ha chiamato wait(), join() senza timeout, LockSupport.park(). Aspetta notify() di un altro thread, infinito. |
| TIMED_WAITING | Come WAITING ma con timer: Thread.sleep(ms), wait(ms), join(ms), LockSupport.parkNanos(). |
| TERMINATED | Metodo run() finito o eccezione non catturata. Non torna più RUNNABLE. |
Fate sleep(1000) invece di while(System.currentTimeMillis()...). Nel primo caso il thread va in TIMED_WAITING e non consuma CPU. Nel secondo resta RUNNABLE e brucia un core al 100%. Io lo vedo ogni anno in verifica: busy-wait = bocciatura diretta dell'esercizio.
4. Cosa salva la JVM per un thread: stack, PC e TCB
Il Thread Control Block in JVM è leggero: Program Counter Java, stack dei frame (metodi chiamati), riferimento a heap, stato, priorità, thread-local. Context switch tra thread Java = salva solo stack + registri, non heap: ecco perché è 10-100x più veloce di un cambio processo.
| Campo del thread | Contenuto pratico | Perché serve nel vostro lab Java |
|---|---|---|
| TID / Nome | ID + name (es. "Thread-3 - Downloader") | Per debug, log, Thread.currentThread().getName() |
| Stato | NEW/RUNNABLE/BLOCKED... | Thread.getState() in VisualVM |
| Program Counter JVM | Bytecode successivo da eseguire | Al ritorno da Waiting riparte esatto |
| Java Stack | Frame di chiamate (metodi, variabili locali) | StackOverflowError se ricorsione infinita per thread |
| Priorità (1-10) | MIN_PRIORITY 1, NORM 5, MAX 10 | Hint per scheduler, non garanzia |
| Heap condiviso | Oggetti, static | Qui nascono le race condition |
Io ho pensato di fare così a lezione di Tecnologia: apro jconsole e vi faccio vedere i thread. Clicco su "Downloader-1" e vi mostro stack trace, stato BLOCKED su synchronized. Io lo farei così all'interrogazione: "Prof, il TCB del thread Java è come il segnalibro di un libro: salva pagina (PC) e appunti sul tavolo (stack), ma la libreria (heap) resta condivisa". Se dite questa frase, vi do il mezzo punto in più.
Ogni thread Java mangia ~1MB di stack di default. 1000 thread = 1GB solo di stack. Ecco perché oggi non creiamo più thread a mano, usiamo pool. Altrimenti OutOfMemoryError: unable to create native thread.
5. Come creare un thread in Java (i 2 metodi + quello che uso io)
Ci sono 2 modi ufficiali, ma uno è quello che vi chiedo io in verifica. Non esiste start() senza run().
extends Thread
Semplice ma spreca ereditarietà. Non potete estendere altro. Lo vedo nei vecchi libri.
implements Runnable / lambda
Separa task da thread. Riutilizzabile con Executor. È quello che uso io al 90% in lab.
METODO 1: extends Thread - sconsigliato ma da sapere
class MioThread extends Thread {
@Override
public void run() {
System.out.println("Sono " + Thread.currentThread().getName() + " - ID: " + Thread.currentThread().getId());
}
}
METODO 2: implements Runnable - quello che voglio io
class MioTask implements Runnable {
private String nome;
public MioTask(String nome){ this.nome = nome; }
@Override
public void run() {
for(int i=1;i<=3;i++){
System.out.println(nome + " -> " + i + " su " + Thread.currentThread().getName());
try { Thread.sleep(500); } catch (InterruptedException e){ Thread.currentThread().interrupt(); }
}
}
}
public class MainThread {
public static void main(String[] args) {
// ERRORE CHE VEDO SEMPRE: chiamare run() invece di start()
// MioThread t = new MioThread(); t.run(); // SBAGLIATO: resta nel main, non è un thread!
// GIUSTO:
Thread t1 = new MioThread();
t1.setName("Downloader-1");
t1.start(); // NEW -> RUNNABLE
Thread t2 = new Thread(new MioTask("Calcolo-A"));
t2.start();
// METODO CHE USO IO: lambda + Runnable - pulito e moderno
Thread t3 = new Thread(() -> {
System.out.println("Task lambda su " + Thread.currentThread().getName());
}, "Lambda-Worker");
t3.start();
System.out.println("Main continua, non aspetta. Stato t1: " + t1.getState());
}
}
Io ho pensato di fare così in lab: vi faccio scrivere prima run() e poi start() e vi faccio stampare Thread.currentThread().getName(). Se vedete due volte "main", avete chiamato run() e non start(). Io lo farei così per farlo sembrare vostro: dite "Prof, start() crea il thread nativo e mette NEW->RUNNABLE, run() è solo un metodo normale". Nelle app moderne non faccio mai new Thread() in loop, uso Executor, ve lo mostro dopo.
Caso d'uso: 3 download paralleli senza bloccare main
import java.util.concurrent.atomic.AtomicInteger;
public class DownloadManager {
static AtomicInteger completati = new AtomicInteger(0);
static Runnable creaDownload(String url){
return () -> {
String threadName = Thread.currentThread().getName();
System.out.println("["+threadName+"] Inizio download: " + url);
try {
// Simulo rete lenta -> TIMED_WAITING
Thread.sleep((long)(Math.random()*2000 + 500));
} catch (InterruptedException e){ return; }
int tot = completati.incrementAndGet();
System.out.println("["+threadName+"] Finito " + url + " - Totale: " + tot + "/3");
};
}
public static void main(String[] args) throws InterruptedException {
Thread d1 = new Thread(creaDownload("foto1.jpg"), "DL-1");
Thread d2 = new Thread(creaDownload("video.mp4"), "DL-2");
Thread d3 = new Thread(creaDownload("doc.pdf"), "DL-3");
long start = System.currentTimeMillis();
d1.start(); d2.start(); d3.start();
// Main fa altro nel frattempo
System.out.println("[MAIN] Interfaccia libera, posso muovere il mouse...");
// Aspetto che finiscano -> WAITING
d1.join(); d2.join(); d3.join();
System.out.println("[MAIN] Tutti finiti in " + (System.currentTimeMillis()-start) + "ms vs 4500ms sequenziale");
}
}
6. Sincronizzazione: race condition, mutex e deadlock
Questo è il cuore dell'esame. Se due thread condividono un oggetto e almeno uno scrive, avete una corsa. Senza controllo, il risultato è casuale.
| Problema | Come si vede | Soluzione Java che voglio in verifica |
|---|---|---|
| Race Condition | counter++ fa 1000+1000 = 1847, non 2000 | synchronized, AtomicInteger, Lock |
| Visibility | Thread non vede modifica di un altro per cache CPU | volatile, synchronized (happens-before) |
| Deadlock | Thread A ha lock X aspetta Y, B ha Y aspetta X. Entrambi WAITING per sempre | Ordine dei lock, tryLock con timeout |
| Starvation | Thread bassa priorità non entra mai in synchronized | ReentrantLock fair=true |
class ContoCorrente {
private int saldo = 0;
// VERSIONE SBAGLIATA - 10 studenti su 10 lo sbagliano al primo colpo
// public void deposita(int v){ saldo += v; } // read-modify-write NON atomico!
// VERSIONE GIUSTA 1: synchronized - monitor dell'oggetto
public synchronized void depositaSync(int v){
saldo += v; // ora atomico rispetto ad altri synchronized sullo stesso oggetto
}
// VERSIONE GIUSTA 2: blocco esplicito - quello che uso io quando devo spiegare
private final Object lock = new Object();
public void depositaLock(int v){
synchronized(lock){
saldo += v;
}
}
// VERSIONE GIUSTA 3: moderna, senza lock
// private AtomicInteger saldoAtom = new AtomicInteger(0);
// saldoAtom.addAndGet(v);
public int getSaldo(){ return saldo; }
}
public class TestRace {
public static void main(String[] args) throws InterruptedException {
ContoCorrente conto = new ContoCorrente();
Runnable task = () -> {
for(int i=0;i<10000;i++) conto.depositaSync(1);
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start(); t2.start();
t1.join(); t2.join();
// Se usi metodo non synchronized vedrai 18743, 19231... casuale!
System.out.println("Saldo atteso 20000, reale: " + conto.getSaldo());
}
}
public class DeadlockDemo {
static final Object forchetta1 = new Object();
static final Object forchetta2 = new Object();
public static void main(String[] args) {
Thread filosofo1 = new Thread(() -> {
synchronized(forchetta1){
System.out.println("Filosofo1 ha forchetta1, vuole forchetta2...");
try{ Thread.sleep(100); } catch(Exception e){}
synchronized(forchetta2){ // aspetta per sempre se l'altro ha forchetta2
System.out.println("Filosofo1 mangia");
}
}
}, "Filosofo-1");
Thread filosofo2 = new Thread(() -> {
synchronized(forchetta2){ // ORDINE INVERSO -> DEADLOCK!
System.out.println("Filosofo2 ha forchetta2, vuole forchetta1...");
try{ Thread.sleep(100); } catch(Exception e){}
synchronized(forchetta1){
System.out.println("Filosofo2 mangia");
}
}
}, "Filosofo-2");
filosofo1.start(); filosofo2.start();
// Come lo trovo? jps + jstack PID -> vi dice "Found 1 deadlock"
// Fix: sempre stesso ordine di lock!
}
}
Io ho pensato di fare così quando mi chiedete del deadlock: vi disegno alla lavagna due cerchi che si abbracciano. Io lo farei così per farlo sembrare vostro: "Prof, il deadlock ha 4 condizioni di Coffman: mutua esclusione, hold-and-wait, no preemption, attesa circolare. Se rompo una, niente deadlock. In Java rompo l'attesa circolare prendendo i lock sempre nello stesso ordine". Se dite Coffman, vi metto subito 8.
Mai fare if(!condizione) wait(). Sempre while(!condizione) wait(). Perché? Spurious wakeup e notify() di un altro thread. Se usate if, vi risvegliate con condizione falsa e andate in eccezione. È l'errore che trovo nel 70% dei vostri compiti.
7. Multithreading moderno: Executor, Pool e Virtual Thread
Dal 2014 in poi nessuno crea più new Thread() a mano in produzione. Si usano pool. Dal 2023 con Java 21, i Virtual Thread cambiano tutto: costano pochi KB, non 1MB.
| Strumento Java | Cosa fa | Quando lo uso io |
|---|---|---|
| ExecutorService | Gestisce pool di thread, riuso, coda | Sempre, al posto di new Thread(). Es. newFixedThreadPool(4) |
| Callable + Future | Come Runnable ma ritorna valore e lancia eccezioni | Quando il thread deve restituire risultato |
| Virtual Thread (Java 21+) | Thread leggerissimo gestito da JVM, 1M thread = ok | Server, 10k connessioni, I/O bound |
| CompletableFuture | Pipeline asincrona, thenApply, thenCombine | Microservizi, chiamate REST parallele |
Server vecchio: 1 thread per client = 10k thread = 10GB RAM = crash. Server con Executor fixed 100 = coda, lento. Server con Virtual Thread: Thread.ofVirtual().start(() -> gestisciClient) → ogni client ha suo thread da pochi KB, quando fa I/O va in WAITING e la JVM lo parcheggia. Così il server sembra avere 10k thread ma ne usa 8 reali. È il motivo per cui oggi Java ha battuto Node su concorrenza.
import java.util.concurrent.*;
import java.util.*;
public class ModernoConcorrente {
// METODO CLASSICO CHE USO IN 4A: ExecutorService
public static void demoExecutor() throws Exception {
// Io ho pensato di fare così: 4 thread = numero core della vostra VM in lab
ExecutorService pool = Executors.newFixedThreadPool(4);
List<Future<Integer>> futures = new ArrayList<>();
for(int i=0;i<8;i++){
int taskId = i;
Future<Integer> f = pool.submit(() -> {
System.out.println("Task " + taskId + " su " + Thread.currentThread().getName());
Thread.sleep(500);
return taskId * taskId;
});
futures.add(f);
}
for(Future<Integer> f : futures){
System.out.println("Risultato: " + f.get()); // WAITING finché pronto
}
pool.shutdown(); // IMPORTANTE: altrimenti JVM non termina!
}
// METODO 2024+ CHE VI CHIEDERANNO AL COLLOQUIO: Virtual Thread
public static void demoVirtualThread() throws Exception {
// Con Java 21+
try (ExecutorService vPool = Executors.newVirtualThreadPerTaskExecutor()) {
for(int i=0;i<1000;i++){
int id = i;
vPool.submit(() -> {
try{
Thread.sleep(100); // I/O simulato: qui il virtual thread si stacca dal carrier
if(id % 200 == 0) System.out.println("Virtual task " + id + " su " + Thread.currentThread());
} catch(InterruptedException e){ Thread.currentThread().interrupt(); }
});
}
} // chiude automatico
System.out.println("1000 virtual thread finiti senza 1GB di RAM");
}
public static void main(String[] args) throws Exception {
System.out.println("--- Executor Classico ---");
demoExecutor();
System.out.println("\n--- Virtual Thread (Java 21+) ---");
// demoVirtualThread(); // scommentate se avete JDK 21
}
}
Io ho pensato di fare così in lab: vi faccio lanciare 1000 new Thread() e vi faccio vedere OutOfMemoryError. Poi vi faccio lanciare 1000 virtual thread e vedete che gira. Io lo farei così per farlo sembrare vostro: dite "Prof, con i platform thread riuso i thread per non pagare il costo di creazione, con i virtual thread non serve riuso perché costano poco". Se dite questa frase in sede d'esame, vi metto il bonus.
8. Verifica e laboratorio mentale
🔬 Attività – carta e penna (da saper fare senza PC)
Esercizi che escono in verifica scritta, 15 minuti:
- Disegna il diagramma NEW → RUNNABLE → (BLOCKED / WAITING / TIMED_WAITING) → TERMINATED con le 6 frecce etichettate (start, synchronized, wait, sleep, notify, fine run).
- Dati 2 thread che fanno saldo++ 10000 volte su stesso ContoCorrente senza synchronized: quale valore finale ti aspetti? Perché può essere <20000? Mostra il read-modify-write.
- Codice: Thread t = new Thread(() -> {...}); t.run(); t.run(); Cosa succede? In che stato è t dopo? Perché non è multithreading?
- Spiega perché in Java wait() deve stare dentro synchronized e dentro while(condizione). Cosa succede con if?
- Caso d'uso: server chat con 5000 client. Perché con Thread classici crasha e con Virtual Thread (Java 21) regge? Calcola memoria: 5000 x 1MB vs 5000 x 5KB.
1) Guardate svg sopra. 2) Race: T1 legge 100, T2 legge 100, entrambi scrivono 101 invece di 102. Incremento perso, non atomico. Valore atteso <20000, non deterministico. 3) run() è chiamata diretta nel main, non crea thread nativo. Stato resta NEW (start mai chiamato). Non c'è parallelismo, solo due chiamate sequenziali. 4) wait() rilascia monitor, quindi deve possederlo (synchronized). While serve per spurious wakeup + condizione cambiata da altri. Con if rischi di procedere con condizione falsa. 5) Platform: 5000MB stack = OOM. Virtual: 5000*5KB=25MB. Inoltre quando client fa I/O, virtual thread si smonta dal carrier → 8 carrier servono 5000 client.