Thread erweitern oder Runnable implementieren? Aufgaben und Ausführung sauber trennen
extends Thread und implements Runnable können beide Code in einem neuen Thread ausführen. Sie modellieren aber nicht dasselbe: Thread steht für den Ausführungsmechanismus, Runnable für eine Aufgabe ohne Rückgabewert.
Das Wichtigste vorab
Kurzfassung: ca. 1 Minute · vollständiger Artikel: ca. 8 Minuten
- In den meisten Anwendungen ist
Runnabledie bessere Ausgangsbasis, weil Aufgabe und Thread getrennt bleiben. - Eine Klasse, die
Threaderweitert, kann keine andere Klasse mehr erweitern und koppelt ihre Fachlogik eng an einen bestimmten Ausführungsmechanismus. - Thread-Namen, Daemon-Status und Fehlerbehandlung lassen sich auch bei einem
Runnableüber Konstruktoren,Thread.Builderoder eineThreadFactorykonfigurieren. Runnablehat keinen Rückgabewert und darf keine checked Exception deklarieren. Für Ergebnisse istCallable<T>vorgesehen.- Executor Services sowie Plattform- und Virtual Threads können dieselbe Aufgabe ausführen. Das macht
Runnablewiederverwendbar und testbar. - Auch bei sauberer Trennung bleibt gemeinsam veränderlicher Zustand gefährlich. Eine mehrfach ausgeführte Aufgabe muss threadsicher entworfen sein.

Die klassische Lehrbuchfrage „Thread erweitern oder Runnable implementieren?“ greift heute etwas zu kurz. Moderne Java-Anwendungen reichen Aufgaben häufig an Executor Services oder Virtual Threads weiter. Trotzdem ist der Vergleich wichtig, weil er eine grundlegende Designentscheidung sichtbar macht: Ist deine Klasse selbst ein Thread – oder beschreibt sie Arbeit, die von unterschiedlichen Ausführungsmechanismen erledigt werden kann?
Aufgabe und Ausführung unterscheiden
Runnable ist ein funktionales Interface mit genau einer abstrakten Methode: void run(). Es beschreibt eine Operation, liefert aber kein Ergebnis zurück. Das Interface sagt nicht, ob die Operation in einem neuen Thread, in einem Thread-Pool oder sogar synchron im aktuellen Thread ausgeführt wird.
Thread ist dagegen ein konkretes Objekt mit Identität, Zustand und Lebenszyklus. Es kann gestartet, unterbrochen und mit join() abgewartet werden. Ein Thread besitzt außerdem einen Namen und – bei Plattform-Threads – Eigenschaften wie Daemon-Status und Priorität.
Runnable oder Callable meist passender. Eine Unterklasse von Thread behauptet dagegen eine echte Ist-ein-Beziehung: Die Klasse ist selbst ein Thread.Variante 1: Thread erweitern
Eine Unterklasse überschreibt run() und kann anschließend direkt gestartet werden. Für ein kleines Lernbeispiel ist der Mechanismus leicht zu erkennen.
final class ReportThread extends Thread {
private final int reportId;
ReportThread(int reportId) {
super("report-" + reportId);
this.reportId = reportId;
}
@Override
public void run() {
System.out.printf("Report %d läuft in %s%n",
reportId, Thread.currentThread().getName());
}
}
public class ThreadSubclassDemo {
public static void main(String[] args) throws InterruptedException {
Thread thread = new ReportThread(17);
thread.start();
thread.join();
}
}
Die Variante funktioniert. Sie verbindet aber die fachliche Aufgabe „Report erzeugen“ unmittelbar mit einem Plattform-Thread. Die Klasse kann wegen der Java-Einzelvererbung keine andere Oberklasse besitzen. Außerdem lässt sich dieselbe Aufgabe nicht ohne zusätzliche Anpassung an einen Executor übergeben.
Variante 2: Runnable implementieren
Beim zweiten Ansatz beschreibt eine eigenständige Klasse nur die Aufgabe. Der ausführende Thread wird außerhalb erzeugt. Da Runnable ein Interface ist, bleibt eine andere Oberklasse möglich und die Aufgabe kann weitere Interfaces implementieren.
final class GenerateReport implements Runnable {
private final int reportId;
GenerateReport(int reportId) {
this.reportId = reportId;
}
@Override
public void run() {
System.out.printf("Report %d läuft in %s%n",
reportId, Thread.currentThread().getName());
}
}
public class RunnableDemo {
public static void main(String[] args) throws InterruptedException {
Runnable task = new GenerateReport(17);
Thread thread = Thread.ofPlatform()
.name("report-worker")
.start(task);
thread.join();
}
}
Die Aufgabe weiß nicht, welcher Thread sie ausführt. In einem einfachen Unit-Test kannst du task.run() kontrolliert im Test-Thread aufrufen. In der Anwendung kann ein Plattform-Thread, ein Virtual Thread oder ein Executor dieselbe Instanz beziehungsweise eine neue Instanz ausführen.
Thread und Runnable im Vergleich
| Frage | extends Thread |
implements Runnable |
|---|---|---|
| Was modelliert die Klasse? | Einen konkreten Thread samt Aufgabe | Eine ausführbare Aufgabe |
| Andere Oberklasse möglich? | Nein | Ja |
| Executor-kompatibel? | Nicht als saubere Aufgabenabstraktion gedacht | Direkt mit execute() oder submit() |
| Virtual-Thread-kompatibel? | Eine eigene Thread-Unterklasse ist ein Plattform-Thread | Dieselbe Aufgabe kann in einem Virtual Thread laufen |
| Separat testbar? | Aufgabe und Thread-Lebenszyklus sind gekoppelt | Die Fachlogik kann unabhängig vom Ausführer getestet werden |
| Typischer Einsatz | Seltene Thread-spezifische Anpassung | Reguläre Anwendungsaufgaben |
Thread konfigurieren – ohne Thread zu erweitern
Ein verbreitetes Missverständnis lautet, eine Unterklasse sei nötig, um Namen, Daemon-Status oder einen Uncaught-Exception-Handler zu setzen. Diese Eigenschaften gehören zum ausführenden Thread und lassen sich über Konstruktoren, Thread.Builder oder eine ThreadFactory konfigurieren.
import java.util.concurrent.ThreadFactory;
ThreadFactory factory = Thread.ofPlatform()
.name("import-worker-", 0)
.daemon(false)
.uncaughtExceptionHandler((thread, error) ->
System.err.println(thread.getName() + ": " + error))
.factory();
Thread first = factory.newThread(() -> importFile("customers.csv"));
Thread second = factory.newThread(() -> importFile("orders.csv"));
first.start();
second.start();
Der Builder erzeugt hier fortlaufende Namen wie import-worker-0 und import-worker-1. Die Aufgaben bleiben normale Lambdas. Genau diese Trennung verbessert Diagnose, Wiederverwendung und Tests.
Wann Callable<T> besser passt
Runnable.run() liefert void und kann keine checked Exception deklarieren. Wenn eine Aufgabe ein Ergebnis erzeugt oder einen erwartbaren Fehlschlag an den Aufrufer melden soll, passt Callable<T> besser.
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.Callable;
Callable<Long> fileSize = () ->
Files.size(Path.of("report.pdf"));
| Aspekt | Runnable |
Callable<T> |
|---|---|---|
| Methode | void run() |
T call() throws Exception |
| Rückgabewert | Nein | Ja |
| Checked Exception | Nicht in der Signatur | Möglich |
| Typische Einreichung | execute() oder submit() |
submit() |
Executors und Virtual Threads
Die Trennung durch Runnable zahlt sich aus, sobald die Ausführungsstrategie wechselt. Ein Executor kann eine begrenzte Zahl von Plattform-Threads wiederverwenden oder für jede Aufgabe einen neuen Virtual Thread starten.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
Runnable task = new GenerateReport(17);
try (ExecutorService executor =
Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(task).get();
}
Virtual Threads sind besonders für sehr viele blockierende I/O-Aufgaben geeignet. Für lange CPU-intensive Berechnungen erhöhen sie den verfügbaren Parallelismus nicht. Auch hier bleibt die Aufgabe unabhängig von dieser Entscheidung.
Vorsicht bei gemeinsamem Zustand
Die Verwendung von Runnable macht Code nicht automatisch threadsicher. Wird dieselbe veränderliche Aufgabeninstanz mehreren Threads übergeben, greifen alle auf dieselben Felder zu.
final class ValidateOrder implements Runnable {
private final Order order;
ValidateOrder(Order order) {
this.order = order;
}
@Override
public void run() {
order.validate();
}
}
Die Aufgabe hält nur die Referenz auf genau einen Auftrag. Ob das sicher ist, hängt weiterhin von Order ab. Besonders einfach bleibt Nebenläufigkeit, wenn Aufgaben unveränderliche Eingaben erhalten, Ergebnisse zurückgeben und keinen globalen veränderlichen Zustand benutzen.
Eine Runnable-Instanz nicht gedankenlos teilen
Felder wie Zähler, Zwischenergebnisse oder wiederverwendete Builder können Race Conditions verursachen. Erzeuge pro Auftrag eine eigene Aufgabe oder schütze den tatsächlich gemeinsam benötigten Zustand mit einer passenden Nebenläufigkeitsstrategie.
Wann kann eine Thread-Unterklasse sinnvoll sein?
Die Java-API erlaubt ausdrücklich, Thread für Anpassungen und andere fortgeschrittene Gründe zu erweitern. In normalem Anwendungscode besteht dafür jedoch nur selten Bedarf. Thread-Namen, Daemon-Status, Factorys und Uncaught-Exception-Handler erfordern keine Vererbung.
Eine Unterklasse kann vertretbar sein, wenn du tatsächlich einen spezialisierten Plattform-Thread als Infrastrukturtyp modellierst und dessen Lebenszyklus bewusst kontrollierst. Sie sollte nicht lediglich als Behälter für Fachlogik dienen. Virtual Threads können nicht über eine eigene Unterklasse definiert werden; sie werden über Builder oder Factorys erzeugt.
„Ich brauche Methoden von Thread“ ist kein Grund für Vererbung. Auch ein Runnable kann innerhalb von
run()überThread.currentThread()auf den aktuell ausführenden Thread zugreifen.
Java und Kotlin im Vergleich
Kotlin kann Java-Interfaces wie Runnable direkt verwenden. Für einen einzelnen Plattform-Thread bietet die Standardbibliothek außerdem die Hilfsfunktion thread. In modernen Kotlin-Anwendungen werden Aufgaben häufig als Coroutines modelliert.
import kotlin.concurrent.thread
val worker = thread(name = "report-worker") {
println("Läuft in ${Thread.currentThread().name}")
}
worker.join()
Auch in Kotlin bleibt die Designregel sinnvoll: Beschreibe zuerst die Aufgabe und wähle den Ausführungsmechanismus danach. Eine Coroutine ist allerdings kein Runnable mit kürzerer Syntax, sondern Teil eines eigenen, kooperativen Ausführungsmodells.
Entscheidungshilfe
| Anforderung | Passender Ausgangspunkt |
|---|---|
| Eine Aufgabe ohne Ergebnis beschreiben | Runnable |
| Ein Ergebnis oder eine checked Exception liefern | Callable<T> |
| Viele Aufgaben kontrolliert ausführen | ExecutorService |
| Sehr viele blockierende I/O-Aufgaben ausführen | Virtual-Thread-Executor |
| Einen einzelnen benannten Plattform-Thread erzeugen | Thread.ofPlatform().name(...).start(task) |
| Einen spezialisierten Thread-Infrastrukturtyp bauen | Nur bei tatsächlichem Bedarf Thread erweitern |
Best Practices und typische Anti-Patterns
Gute Leitlinien
- Aufgabe und Ausführung getrennt modellieren.
- Für Ergebnisse
Callableverwenden. - Threads über Builder oder Factorys benennen.
- Executors kontrolliert schließen.
- Aufgaben möglichst zustandslos oder unveränderlich halten.
- Abbruch und Fehler sichtbar behandeln.
Warnsignale
- Jede Aufgabe besitzt ihre eigene Thread-Unterklasse.
run()wird aufgerufen, obwohl Nebenläufigkeit erwartet wird.- Thread-Prioritäten sollen die fachliche Reihenfolge garantieren.
- Dieselbe veränderliche Runnable-Instanz läuft gleichzeitig mehrfach.
- Ein Executor wird nie heruntergefahren.
- Virtual Threads werden wie eine knappe Pool-Ressource behandelt.
Glossar
- Runnable
- Funktionales Interface für eine Operation ohne Rückgabewert.
- Callable
- Aufgabe mit Rückgabewert, die eine Exception deklarieren darf.
- Thread
- Konkreter Ausführungspfad mit Identität und Lebenszyklus.
- ThreadFactory
- Factory für einheitlich konfigurierte Threads, etwa mit Namensschema.
- ExecutorService
- Dienst zum Einreichen, Ausführen und kontrollierten Beenden von Aufgaben.
- Future
- Handle auf den Zustand und das spätere Ergebnis einer eingereichten Aufgabe.
Fazit: Runnable ist meist die bessere Grenze
Die beiden Ansätze sind technisch möglich, aber nicht gleich flexibel. extends Thread verbindet Fachlogik mit einem konkreten Plattform-Thread. Runnable beschreibt dagegen nur die Aufgabe und lässt offen, wie sie ausgeführt wird.
Die praktische Leitlinie: Verwende für reguläre Aufgaben Runnable beziehungsweise Callable<T>. Übergib sie an einen Thread-Builder oder Executor. Erweitere Thread nur dann, wenn du wirklich einen spezialisierten Thread-Typ und nicht bloß ausführbare Fachlogik modellierst.



tiny-tool.de
tiny-tool.de