Java Thread-Klasse
Threads in Java erstellen und steuern: Thread-Klasse erweitern oder Runnable übergeben – mit den jeweiligen Vor- und Nachteilen.
java.lang.Thread ist das Objekt, das du in der Hand hältst, wenn du einen Ausführungs-Thread starten, benennen, joinen, unterbrechen oder abfragen möchtest. Das vorherige Kapitel hat Threads auf konzeptioneller Ebene eingeführt; dieses hier ist die API-Tour. Alles in java.util.concurrent — Executors, Futures, virtuelle Threads — baut auf Thread auf, daher lohnt es sich, die rohe Klasse zu kennen, auch wenn du im Produktionscode meistens zu den übergeordneten Abstraktionen greifst.
Zwei Wege, einen Thread zu erstellen
Ein Thread ist ein Runnable, das in ein Steuerungsobjekt eingewickelt ist. Es gibt zwei Möglichkeiten, ihm das Runnable zu übergeben:
// 1. Pass a Runnable to the constructor (the modern, preferred form)
Thread a = new Thread(() -> System.out.println("hello from " + Thread.currentThread().getName()));
// 2. Extend Thread and override run()
class HelloThread extends Thread {
@Override public void run() {
System.out.println("hello from " + getName());
}
}
Thread b = new HelloThread();Beide Varianten funktionieren; beide führen deinen Code auf einem neuen Thread aus. Die erste Form ist es, die praktisch der gesamte moderne Code verwendet — aus drei Gründen:
- Eine Klasse kann nur eine andere Klasse erweitern. Wenn du
Threaderweiterst, kannst du nichts anderes mehr erweitern — und der Teil deines Codes, der die Arbeit ist, hat selten einen guten Grund, im OO-Sinne ein Thread zu sein. EinRunnablezu übergeben, hält deine Business-Klasse frei. - Lambdas machen die
Runnable-Form zu einem Einzeiler. Das Unterklassieren vonThreaderfordert eine benannte Klasse für denselben Code. - Das
Runnable, das du übergibst, kann später auch an einenExecutorServiceweitergegeben werden. DieThread-Unterklasse ist darauf festgelegt, auf ihrem eigenen dedizierten Thread zu laufen.
Erweitere Thread nur, wenn du wirklich Zustand oder Methoden zum Thread selbst hinzufügen möchtest (selten). Für alles andere übergibst du ein Runnable.
Starten und Warten
Die zwei Methoden, die du bei fast jedem Thread verwenden wirst:
Thread t = new Thread(() -> doWork(), "worker");
t.start(); // schedule it; return immediately
t.join(); // block the caller until the thread finishesEinige typische Anfängerfehler hierbei:
start()ist das, was den OS-Thread erstellt.run()direkt aufzurufen führt den Rumpf auf dem aktuellen Thread aus, synchron — kein neuer Thread wird gestartet. Das ist der mit Abstand häufigste Multithreading-Fehler für Einsteiger. Wenn du keinstart()siehst, hat keine Parallelverarbeitung stattgefunden.start()kann nur einmal aufgerufen werden. EinThreadist einmalig verwendbar.start()ein zweites Mal aufzurufen wirftIllegalThreadStateException. Um dieselbe Aufgabe erneut auszuführen, erstelle einen neuenThreadoder verwende einenExecutorService.join()kannInterruptedExceptionwerfen. Es ist ein blockierender Aufruf. Wenn jemandinterrupt()auf dem Thread aufruft, der injoin()wartet, endet das Warten mit der Exception. Du musst sie behandeln oder weitergeben.
join(millis) wartet höchstens so viele Millisekunden, bevor es zurückkehrt, unabhängig davon, ob der Thread fertig ist oder nicht. Verwende es, wenn du einem Worker eine begrenzte Chance geben möchtest, graceful zu beenden, bevor du eskalierst.
Die wichtigen Konstruktoren
Thread hat viele Konstruktoren; in der Praxis sind vier relevant:
| Konstruktor | Wann zu verwenden |
|---|---|
new Thread(Runnable) | Der Basisfall. Anonymer Worker. |
new Thread(Runnable, String name) | Fast immer vorzuziehen — Namen erscheinen in Logs, Profilern, Thread-Dumps. |
new Thread(ThreadGroup, Runnable, String) | Wenn du eine explizite Gruppe benötigst (selten; Gruppen sind weitgehend veraltet). |
new Thread(ThreadGroup, Runnable, String, long stackSize) | Wenn der Standard-Stack (etwa 1 MB) falsch ist — z. B. bei tiefer Rekursion oder Speicherdruck. |
Der leere new Thread()-Konstruktor existiert und führt ein leeres run() aus, das nichts tut. Es gibt keinen Grund, ihn zu verwenden.
Benenne deine Threads immer. "worker-1", "http-3", "flush-loop" — was auch immer die Rolle ist. Ein Thread-Dump voller Thread-7, Thread-12, Thread-19 ist ein Thread-Dump, den du nicht lesen kannst.
Eigenschaften einer Thread-Instanz
Die Felder und Getter, die du tatsächlich anfassen wirst:
t.setName("scanner-2"); // any time before or after start()
String name = t.getName();
t.setDaemon(true); // BEFORE start(); else IllegalThreadStateException
boolean d = t.isDaemon();
t.setPriority(Thread.NORM_PRIORITY); // 1..10; mostly advisory, see chapter 6
int p = t.getPriority();
Thread.State s = t.getState(); // NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED
boolean alive = t.isAlive(); // true between start() and run() returning
long id = t.threadId(); // Java 19+; old name: getId()Zwei davon sind am wichtigsten:
setDaemon(true)entscheidet, ob der Thread die JVM am Leben hält. Siehe das vorherige Kapitel — Daemon-Threads sterben mit dem Programm; Nicht-Daemon-Threads halten es am Laufen, bis sie zurückkehren.getState()ist das, was du in einem Thread-Dump anschaust, um zu diagnostizieren, warum ein Thread feststeckt.BLOCKEDbedeutet, er wartet auf ein intrinsisches Lock;WAITING/TIMED_WAITINGbedeutet, er ist inwait(),join(),sleep(),LockSupport.park()usw. geparkt.
Statische Hilfsmethoden auf Thread
Einige statische Methoden, die du aus dem Worker heraus aufrufen wirst:
Thread.currentThread(); // the thread that's executing this code
Thread.sleep(2000); // pause this thread for ~2000 ms
Thread.yield(); // hint to the scheduler "go ahead and run someone else"
Thread.interrupted(); // returns and CLEARS the interrupt flag of currentThreadThread.sleep ist die häufigste; sie wirft InterruptedException, sodass Aufrufer sie behandeln oder weitergeben müssen. Thread.yield ist fast nie das richtige Werkzeug — es ist ein vager Hinweis, den die JVM und das Betriebssystem ignorieren können. Wenn du koordinieren möchtest, verwende ein echtes Synchronisationsprimitiv.
Thread.interrupted() gibt true zurück, wenn der aktuelle Thread unterbrochen wurde, und löscht das Flag. t.isInterrupted() (Instanzmethode, auf einem anderen Thread) gibt das Flag zurück, ohne es zu löschen. Sie zu verwechseln ist eine häufige Quelle steckengebliebener Interrupts.
Unterbrechung: Wie man einen Thread zum Stoppen auffordert
Es gibt kein sicheres t.stop() (die Methode existiert, ist aber seit 1.1 veraltet, weil sie Locks gehalten und den Zustand korrumpiert hinterlässt). Das kooperative Shutdown-Protokoll lautet:
Thread worker = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
doOneUnitOfWork();
}
}, "worker");
worker.start();
// ... later, from somewhere else:
worker.interrupt();
worker.join();interrupt() setzt das Interrupt-Flag des Workers. Der Worker soll das Flag an sicheren Punkten prüfen und beenden. Wenn der Worker in sleep, wait, join oder vielen java.nio-Aufrufen blockiert ist, wirft der blockierende Aufruf sofort InterruptedException, damit der Thread reagieren kann.
Wenn du InterruptedException abfängst und sie nicht weitergeben möchtest, ist die Konvention, das Flag neu zu setzen, damit Aufrufer weiter oben im Stack den Interrupt noch sehen:
try { Thread.sleep(1000); }
catch (InterruptedException e) {
Thread.currentThread().interrupt(); // re-arm the flag
return; // and give up cooperatively
}Einen Interrupt zu schlucken, ohne das Flag neu zu setzen, ist ein Bug. Das Flag ist der Weg, wie der Rest des Programms weiß, dass du aufgefordert wurdest zu stoppen.
Ein ausgearbeitetes Beispiel: Der vollständige Lebenszyklus in einem Programm
Das folgende Programm erstellt zwei Worker auf verschiedene Arten (Runnable, Unterklasse), beobachtet ihre Zustandsübergänge, joined sie und demonstriert das Interrupt-Protokoll an einem dritten Worker.
Was man aus dem Lauf mitnehmen kann:
- Die Zustandsübergänge entsprechen dem Vertrag. Vor
start()waren beide ThreadsNEW. Nachstart()waren sieRUNNABLE(oderTERMINATED, wenn die Arbeit winzig war und vor dem Print fertig wurde). Nachjoin()waren beideTERMINATED. Das ist der Lebenszyklus, denThread.Statebeschreibt. - Die Zeile "t3 ran on thread: main" ist der Bug, den man sich für immer merken sollte.
t3.run()hat den Rumpf ausgeführt — auf dem aufrufenden Thread, synchron. Es wurde kein neuer Thread erstellt.t3.isAlive()war danachfalse, weilstart()nie aufgerufen wurde. Wenn du debuggst, warum "nichts parallel zu laufen scheint", prüfe, ob dustart()oderrun()geschrieben hast. - Die Interrupt-Schleife verwendete
Thread.sleepnicht als Hauptwartemechanismus — sie prüfte nur fleißig das Flag, mit einem gelegentlichen kurzen Sleep, damit der Interrupt den Sleep früh beenden konnte. Der Vertrag ist auf beide Arten gleich:isInterrupted()ist das, was der Worker abfragt;interrupt()ist das, was der Anforderer aufruft. - Das Neu-Setzen des Flags im
catch-Block (dieThread.currentThread().interrupt()-Zeile) hat das Signal für jeden Code weiter oben im Aufruf-Stack erhalten. Ohne diese Zeile würde ein abgefangener und ignorierter Interrupt verschwinden — was eine der einfachsten Methoden ist, einen Thread zu schreiben, der nicht sauber herunterfährt. - Der Daemon am Ende war dabei, 60 Sekunden zu schlafen; stattdessen beendete die JVM, sobald
mainzurückkehrte, und tötete ihn mitten im Sleep. Daemon-Threads können beliebige Ressourcen halten — aber sie können auch jederzeit unterbrochen werden, weshalb du keine commit-pflichtige Arbeit auf ihnen ablegen solltest.
Was kommt als Nächstes
Das nächste Kapitel, Java Runnable Interface, befasst sich eingehend mit Runnable selbst — was es wirklich ist, warum Callable und Future darüber hinzugefügt wurden, und wie Lambdas die Ergonomie des Übergebens von Arbeit an einen Thread verändert haben.