Interfaccia Runnable di Java
Definisci unità di lavoro per i thread in Java con l'interfaccia funzionale Runnable — la forma preferita per thread, executor e virtual thread.
Runnable è un'interfaccia con un solo metodo — probabilmente la più importante in java.lang. Tutto ciò che "viene eseguito su un thread" in Java è, in ultima analisi, un Runnable da qualche parte: il costruttore di Thread ne accetta uno, ExecutorService.execute ne accetta uno, gli shutdown hook della JVM ne accettano uno. Il motivo per cui il capitolo precedente consigliava di "passare un Runnable al costruttore di Thread" anziché "estendere Thread" è che Runnable separa cosa viene eseguito da cosa lo esegue. Questa separazione è ciò che permette alla stessa attività di funzionare su un platform thread, un thread pool o un virtual thread senza modificare il codice.
La struttura
L'intera definizione sta in tre righe:
@FunctionalInterface
public interface Runnable {
void run();
}Tutto qui. Da quelle tre righe derivano due conseguenze:
- È un'interfaccia funzionale. Qualsiasi lambda o riferimento a metodo con firma senza argomenti e void la implementa:
() -> System.out.println("hi"),this::flush,Foo::staticMethod. - Restituisce void e non lancia eccezioni checked. Questo è il limite di ciò che puoi esprimere. Se hai bisogno di un risultato, o di lanciare qualcosa di checked, hai bisogno di
Callable(un capitolo o due più avanti).
Tre modi per scriverne uno
// 1. Lambda — the modern default
Runnable r1 = () -> System.out.println("hello");
// 2. Method reference — when an existing method has the right signature
Runnable r2 = System.out::flush;
// 3. Anonymous class — pre-Java-8 form, occasionally useful when the body needs fields
Runnable r3 = new Runnable() {
@Override public void run() {
System.out.println("hello");
}
};Tutti e tre producono un oggetto di tipo Runnable. La forma lambda è preferita da Java 8; la forma con classe anonima è utile solo quando hai bisogno di campi propri (il che di solito non accade — cattura invece le variabili locali).
Come viene utilizzato Runnable
Tre delle principali API che accettano Runnable:
new Thread(runnable).start(); // platform thread, dedicated
executor.execute(runnable); // thread pool or virtual thread
Runtime.getRuntime().addShutdownHook(new Thread(runnable)); // JVM shutdownLa stessa istanza Runnable funziona in tutti e tre i contesti. Questo è il punto di progettazione: il cosa (il lavoro) e il dove (il thread) sono ortogonali. Puoi scrivere codice che svolge il lavoro e qualcun altro può decidere su cosa eseguirlo.
Il contrasto con la forma della sottoclasse di Thread rende questo concreto:
// Coupled: this work can only run on its own dedicated platform thread.
class ImageResizer extends Thread {
@Override public void run() { resize(); }
}
new ImageResizer().start();
// Decoupled: the same body runs anywhere.
Runnable resize = this::resize;
new Thread(resize).start(); // dedicated thread
executor.execute(resize); // pool
virtualExecutor.execute(resize); // virtual threadLa forma disaccoppiata è il motivo per cui il Java di produzione è pieno di Runnable (e Callable) e quasi mai ha una classe che estende Thread.
Le variabili catturate devono essere effettivamente finali
Una lambda che diventa un Runnable può leggere le variabili locali del metodo che la racchiude, ma solo quelle che il compilatore può dimostrare essere effettivamente finali — assegnate esattamente una volta e mai riassegnate:
String name = "alice";
int n = 3;
Runnable r = () -> {
for (int i = 0; i < n; i++) {
System.out.println(name + " " + i);
}
};
// n = 4; // would break the lambda above — compile errorSe hai bisogno di stato mutabile condiviso, non puoi usare una variabile locale catturata — hai bisogno di un campo, un AtomicInteger, uno slot di array o un altro oggetto i cui interni siano mutabili. La restrizione è intenzionale: le lambda catturano valori, non alias, e vietare la riassegnazione è la regola più semplice che rende questo comportamento coerente.
La soluzione più comune è l'array a un elemento:
int[] counter = {0};
Runnable r = () -> counter[0]++; // works; the array reference is final, the int inside isn'tMa per i contatori condivisi thread-safe, un AtomicInteger è la scelta giusta — vedremo perché tra qualche capitolo.
Gestione delle eccezioni: niente da catturare, niente da recuperare
run() non lancia eccezioni checked. Se il tuo worker può fallire con un'eccezione checked, devi catturarla all'interno di run():
Runnable parseFile = () -> {
try {
Files.readAllLines(path);
} catch (IOException e) {
log.error("parse failed", e); // you HAVE to handle it here
}
};Per le eccezioni unchecked, la situazione è peggiore: niente nel codice chiamante le cattura. Se il tuo Runnable lancia NullPointerException su un thread separato, l'eccezione va al gestore delle eccezioni non catturate di quel thread e il thread muore. Il thread principale non lo sa.
Due modi per affrontare questo problema:
- Cattura tutto dentro
run()e registralo tu stesso. Grezzo ma affidabile. - Usa
CallableeFuture.get(). IlFuturerilancia l'eccezione sul thread che ha chiamatoget(). Questo è ciò che ti offre il framework executor.
Per il lavoro occasionale, l'opzione 1 va bene; per tutto ciò che produce un risultato di cui il chiamante ha bisogno, l'opzione 2 è la risposta giusta.
Runnable vs. Callable
Un confronto fianco a fianco delle due interfacce per i task — incontrerai Callable correttamente più avanti, ma il contrasto è utile ora:
Runnable | Callable<V> | |
|---|---|---|
| Metodo | void run() | V call() throws Exception |
| Valore di ritorno | Nessuno | Risultato tipizzato V |
| Eccezioni checked | Non può lanciare | Può lanciare qualsiasi Exception |
| Accettato da | new Thread, Executor.execute, shutdown hook | ExecutorService.submit |
| Gestore del risultato | Nessuno (fire and forget) | Future<V> |
Ogni volta che hai bisogno sia di un valore di ritorno sia della possibilità di lanciare eccezioni checked, passa a Callable. Per il lavoro puramente con effetti collaterali — flush, logging, scheduling — Runnable è lo strumento più leggero.
Un esempio pratico: stesso Runnable, tre runner
Il programma seguente definisce un Runnable che svolge una piccola operazione, poi esegue la stessa istanza su (a) un nuovo platform thread, (b) un ExecutorService, e (c) il thread chiamante tramite .run() diretto. Lo stesso corpo viene eseguito in tutti e tre i contesti; l'unica cosa che cambia è il runner.
Cosa ricavare dall'esecuzione:
- I primi tre blocchi hanno eseguito la stessa istanza
greetin tre runner diversi — chiamata diretta, thread dedicato, thread pool. Il nome del thread stampato dagreetcambiava ogni volta:main,dedicated-worker,pool-1-thread-1. Questo è il motivo principale per preferireRunnablerispetto alla sottoclasse diThread: il lavoro è riutilizzabile, il runner è intercambiabile. - L'eccezione
RuntimeExceptiondel threadcrashynon ha uccisomain. È morta sul suo thread e il gestore delle eccezioni non catturate l'ha segnalata. Senza un gestore, la JVM stampa uno stack trace su stderr e il resto del programma continua a girare — il che è spesso peggio, perché il lavoro che il thread avrebbe dovuto svolgere silenziosamente non è avvenuto. - La lambda
shoutha catturatonameendalle variabili locali dimain. Sono effettivamente finali — assegnate una volta, mai riassegnate. Aggiungin = 4;in qualsiasi punto dopo la definizione della lambda e il file smette di compilarsi. Questa restrizione è ciò che rende la cattura nelle lambda sicura tra i thread. - L'esempio
bumpha usatoAtomicIntegerperché due thread incrementavano lo stesso contatore. Con un campointsemplice, il valore finale sarebbe stato tra1000e2000— aggiornamenti persi a causa dii++non atomico.incrementAndGet()è la correzione più semplice e torneremo su di essa nel capitolo sugli atomics. - La singola istanza condivisa
Runnableè stata passata anew Thread(bump, "a")enew Thread(bump, "b")— la stessa lambda era in esecuzione su due thread simultaneamente. La lambda non ha campi propri; tutto ciò che tocca vive al di fuori di essa. Questa è la forma di ogniRunnableparallelo sicuro: il meno stato interno possibile, lo stato viene portato in un oggetto thread-safe condiviso dai thread.
Cosa c'è dopo
Il prossimo capitolo, Ciclo di vita del thread Java, illustra i sei valori di Thread.State — NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED — e mostra come leggere un thread dump che li espone.