Java Runnable Interface
Arbeitseinheiten für Threads in Java mit dem funktionalen Interface Runnable definieren — die bevorzugte Form für Thread-, Executor- und Virtual-Thread-Arbeit.
Runnable ist ein Interface mit einer einzigen Methode — wahrscheinlich das wichtigste in java.lang. Alles, was in Java „auf einem Thread läuft", ist letztlich irgendwo ein Runnable: der Thread-Konstruktor akzeptiert eines, ExecutorService.execute akzeptiert eines, die Shutdown-Hooks der JVM akzeptieren eines. Der Grund, warum das vorherige Kapitel empfohlen hat, „ein Runnable an den Thread-Konstruktor zu übergeben" statt „Thread zu erweitern", liegt darin, dass Runnable was ausgeführt wird von was es ausführt trennt. Diese Trennung ermöglicht es, dieselbe Aufgabe auf einem Platform-Thread, einem Thread-Pool oder einem Virtual Thread auszuführen, ohne den Code zu ändern.
Die Struktur
Die gesamte Definition passt in drei Zeilen:
@FunctionalInterface
public interface Runnable {
void run();
}Das ist alles. Aus diesen drei Zeilen ergeben sich zwei Konsequenzen:
- Es ist ein funktionales Interface. Jeder Lambda-Ausdruck oder jede Methodenreferenz mit einer argumentlosen void-Signatur implementiert es:
() -> System.out.println("hi"),this::flush,Foo::staticMethod. - Es gibt void zurück und wirft keine geprüften Ausnahmen. Das ist die Grenze dessen, was ausgedrückt werden kann. Wenn ein Ergebnis oder das Werfen einer geprüften Ausnahme benötigt wird, ist
Callabledie richtige Wahl (ein oder zwei Kapitel weiter).
Drei Arten, eines zu schreiben
// 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");
}
};Alle drei erzeugen ein Objekt vom Typ Runnable. Die Lambda-Form wird seit Java 8 bevorzugt; die anonyme Klasse ist nur dann nützlich, wenn eigene Felder benötigt werden (was normalerweise nicht der Fall ist — stattdessen lokale Variablen erfassen).
Wie Runnable verwendet wird
Drei der wichtigsten APIs, die Runnable akzeptieren:
new Thread(runnable).start(); // platform thread, dedicated
executor.execute(runnable); // thread pool or virtual thread
Runtime.getRuntime().addShutdownHook(new Thread(runnable)); // JVM shutdownDieselbe Runnable-Instanz funktioniert in allen drei Kontexten. Das ist der Designgedanke: das Was (die Arbeit) und das Wo (der Thread) sind orthogonal. Code kann geschrieben werden, der die Arbeit erledigt, und jemand anderes kann entscheiden, worauf er ausgeführt wird.
Der Vergleich mit der Thread-Unterklassen-Form macht das konkret:
// 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 threadDie entkoppelte Form erklärt, warum produktiver Java-Code voller Runnable- (und Callable-) Instanzen ist und fast nie eine Klasse hat, die Thread erweitert.
Erfasste Variablen müssen effektiv final sein
Ein Lambda, das zu einem Runnable wird, kann lokale Variablen aus der umschließenden Methode lesen, aber nur solche, die der Compiler als effektiv final einschätzen kann — genau einmal zugewiesen und nie erneut:
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 errorWenn veränderlicher gemeinsamer Zustand benötigt wird, kann keine erfasste lokale Variable verwendet werden — stattdessen wird ein Feld, ein AtomicInteger, ein Array-Element oder ein anderes Objekt mit veränderbarem Innenleben benötigt. Die Einschränkung ist beabsichtigt: Lambdas erfassen Werte, keine Aliase, und das Verbot der Neuzuweisung ist die einfachste Regel, die das konsistent macht.
Die häufigste Umgehung ist das einelementige Array:
int[] counter = {0};
Runnable r = () -> counter[0]++; // works; the array reference is final, the int inside isn'tFür thread-sichere gemeinsame Zähler ist jedoch ein AtomicInteger die richtige Wahl — ein paar Kapitel weiter wird erläutert, warum.
Ausnahmebehandlung: nichts zu fangen, nichts zu erholen
run() wirft keine geprüften Ausnahmen. Wenn der Worker mit einer geprüften Ausnahme fehlschlagen kann, muss sie innerhalb von run() gefangen werden:
Runnable parseFile = () -> {
try {
Files.readAllLines(path);
} catch (IOException e) {
log.error("parse failed", e); // you HAVE to handle it here
}
};Bei ungeprüften Ausnahmen ist die Situation schlimmer: nichts im aufrufenden Code fängt sie. Wenn das Runnable eine NullPointerException auf einem separaten Thread wirft, geht die Ausnahme an den Uncaught-Exception-Handler dieses Threads, und der Thread stirbt. Der Haupt-Thread erfährt davon nichts.
Zwei Wege, damit umzugehen:
- Alles innerhalb von
run()fangen und selbst protokollieren. Grob, aber zuverlässig. CallableundFuture.get()verwenden. DasFuturewirft die Ausnahme erneut auf dem Thread, derget()aufgerufen hat. Das ist es, was das Executor-Framework bietet.
Für einmalige Arbeit ist Option 1 in Ordnung; für alles, was ein Ergebnis erzeugt, das der Aufrufer benötigt, ist Option 2 die richtige Antwort.
Runnable vs. Callable
Ein direkter Vergleich der beiden Task-Interfaces — Callable wird später richtig vorgestellt, aber der Kontrast ist jetzt nützlich:
Runnable | Callable<V> | |
|---|---|---|
| Methode | void run() | V call() throws Exception |
| Rückgabewert | Keiner | Typisiertes Ergebnis V |
| Geprüfte Ausnahmen | Können nicht geworfen werden | Können jede Exception werfen |
| Akzeptiert von | new Thread, Executor.execute, Shutdown-Hooks | ExecutorService.submit |
| Ergebnis-Handle | Keiner (fire and forget) | Future<V> |
Wann immer ein Rückgabewert oder die Möglichkeit zum Werfen geprüfter Ausnahmen benötigt wird, sollte zu Callable gewechselt werden. Für reine Seiteneffekt-Arbeit — Flushing, Logging, Scheduling — ist Runnable das leichtgewichtigere Werkzeug.
Ein ausgearbeitetes Beispiel: dasselbe Runnable, drei Ausführende
Das folgende Programm definiert ein Runnable, das eine kleine Aufgabe erledigt, und führt dann dieselbe Instanz auf (a) einem brandneuen Platform-Thread, (b) einem ExecutorService und (c) dem aufrufenden Thread via direktem .run() aus. Derselbe Code wird in allen drei Kontexten ausgeführt; das Einzige, was sich ändert, ist der Ausführende.
Was aus dem Lauf mitgenommen werden sollte:
- Die ersten drei Blöcke haben dieselbe
greet-Instanz in drei verschiedenen Ausführenden ausgeführt — direkter Aufruf, dedizierter Thread, Thread-Pool. Der vongreetausgegebene Thread-Name änderte sich jedes Mal:main,dedicated-worker,pool-1-thread-1. Das ist der eigentliche Grund,Runnablegegenüber derThread-Unterklasse zu bevorzugen: die Arbeit ist wiederverwendbar, der Ausführende ist austauschbar. - Die
RuntimeExceptiondescrashy-Threads hat nichtmainbeendet. Er starb auf seinem eigenen Thread, und der Uncaught-Exception-Handler meldete es. Ohne einen Handler gibt die JVM einen Stack-Trace auf stderr aus, und der Rest des Programms läuft weiter — was oft schlimmer ist, weil die Arbeit, die der Thread erledigen sollte, still und leise nicht stattfand. - Das
shout-Lambda erfasstenameundnaus den lokalen Variablen vonmain. Sie sind effektiv final — einmal zugewiesen, nie erneut zugewiesen.n = 4;irgendwo nach der Definition des Lambdas hinzuzufügen lässt die Datei nicht mehr kompilieren. Diese Einschränkung macht Lambda-Capturing thread-sicher. - Das
bump-Beispiel verwendeteAtomicInteger, weil zwei Threads denselben Zähler inkrementieren. Mit einem einfachenint-Feld wäre der Endwert irgendwo zwischen1000und2000gewesen — verlorene Updates durch nicht-atomaresi++.incrementAndGet()ist die einfachste Lösung, und das wird im Kapitel über Atomics noch vertieft. - Die einzelne gemeinsame
Runnable-Instanz wurde annew Thread(bump, "a")undnew Thread(bump, "b")übergeben — dasselbe Lambda lief gleichzeitig auf zwei Threads. Das Lambda hat keine eigenen Felder; alles, was es berührt, lebt außerhalb davon. Das ist die Form jedes sicheren parallelenRunnable: so wenig internen Zustand wie möglich, den Zustand in ein thread-sicheres Objekt auslagern, das die Threads gemeinsam nutzen.
Was kommt als Nächstes
Das nächste Kapitel, Java Thread Lifecycle, behandelt die sechs Thread.State-Werte — NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED — und zeigt, wie ein Thread-Dump gelesen wird, der sie offenbart.