W3docs

Java Multithreading Einführung

Was Threads sind, warum man sie in Java verwendet und welche Kompromisse die nebenläufige Programmierung mit sich bringt.

Jedes Java-Programm, das Sie bisher geschrieben haben, hatte einen einzigen Ausführungs-Thread — einen Cursor, der schrittweise durch den Bytecode geht, eine Variable auf dem Stack, einen Methodenaufruf nach dem anderen. Das ist der „main"-Thread, den die JVM für Sie startet. Multithreading bedeutet, dass die JVM mehrere solcher Cursor gleichzeitig ausführt und dabei denselben Heap teilt. Zwei Threads können im selben Moment in zwei verschiedenen Methoden auf zwei verschiedenen Objekten sein — und das ist sowohl die Stärke als auch die Gefahr.

Moderne CPUs haben viele Kerne. Ein Single-Thread-Programm lässt alle bis auf einen davon ungenutzt. Ein Webserver, der nur eine Anfrage nach der anderen bearbeitet, kann eine 16-Kern-Maschine nicht besser nutzen als eine 1-Kern-Maschine. Der einzige Grund, warum Multithreading existiert, ist, diese Kerne zum Einsatz zu bringen und das Programm reaktionsfähig zu halten, wenn ein Teil davon wartet (auf die Festplatte, das Netzwerk, den Benutzer).

Was ein Thread eigentlich ist

Ein Java-Thread besteht aus zwei miteinander verbundenen Teilen:

  • Einem OS-Level-Thread, den das Betriebssystem auf einen CPU-Kern plant. Er besitzt einen Programmzähler, einen Registersatz und einen nativen Stack. Das Betriebssystem teilt ihn per Time-Slicing mit allen anderen lauffähigen Threads auf der Maschine auf.
  • Einem Java-Objekt vom Typ java.lang.Thread. Es trägt einen Namen, eine Priorität, ein Daemon-Flag und — am wichtigsten — einen Verweis auf das Runnable, dessen run()-Methode es ausführen wird.

Wenn Sie thread.start() aufrufen, fordert die JVM das Betriebssystem auf, einen neuen nativen Thread zu erstellen, der beim ersten Schedule Ihre run()-Methode aufruft. Der ursprüngliche Thread läuft sofort weiter; die beiden laufen nun nebenläufig.

public static void main(String[] args) {
  System.out.println("main: hello from " + Thread.currentThread().getName());

  Thread t = new Thread(() -> {
    System.out.println("worker: hello from " + Thread.currentThread().getName());
  }, "worker-1");

  t.start();                                         // worker runs concurrently with main
  System.out.println("main: continuing");
}

Die Verschachtelung der Ausgabe ist nicht deterministisch — das Betriebssystem entscheidet, welcher Thread zuerst läuft, und diese Entscheidung ändert sich zwischen den Ausführungen. Dieser Nichtdeterminismus ist die zentrale Tatsache der nebenläufigen Programmierung.

Warum man Threads verwenden würde

Zwei verschiedene Motivationen, die oft vermischt werden:

  • Durchsatz. Sie haben CPU-gebundene Arbeit — Bildgröße ändern, Parsen, Komprimierung. Ein Thread nutzt einen Kern; acht Threads nutzen acht Kerne und sind etwa achtmal schneller fertig. Das ist Parallelismus.
  • Reaktionsfähigkeit. Sie haben einen Thread, der sonst blockieren würde — beim Warten auf eine Netzwerkantwort, eine Datenbankrückmeldung, einen Benutzerklick. Die Ausführung dieser Arbeit in einem separaten Thread ermöglicht es dem Rest des Programms, nützliche Dinge zu tun, während es wartet. Das ist Nebenläufigkeit.

Die meisten echten Programme benötigen beides. Ein Webserver verwendet viele Threads, damit eine langsame Anfrage die anderen nicht blockiert (Nebenläufigkeit) und damit viele schnelle Anfragen parallel über mehrere Kerne hinweg verarbeitet werden können (Parallelismus).

Warum Threads schwierig sind

Threads teilen Speicher. Dasselbe HashMap, ArrayList oder int counter++ kann von zwei Threads im selben Moment berührt werden — und die JVM, die CPU-Caches und der Compiler dürfen Operationen auf eine Weise neu anordnen, die Sie überrascht. Die drei Probleme, auf die Multithreading-Code immer wieder trifft:

  • Race Conditions. Zwei Threads lesen, modifizieren und schreiben dieselbe Variable; eines der Updates geht verloren. counter++ ist nicht atomar — es besteht aus counter lesen, eins addieren, counter schreiben. Zwei Threads können denselben Wert lesen und beide value + 1 zurückschreiben, und ein Zählschritt geht verloren. Die Lösung ist Synchronisierung — das Erzwingen, dass Lesen-Modifizieren-Schreiben als ein unteilbarer Schritt stattfindet.
  • Sichtbarkeit. Ein Thread schreibt ein Feld; ein anderer Thread liest es und sieht den alten Wert, weil jeder Thread seinen eigenen CPU-Cache hat und es keine Regel gibt, die das Weitergeben des Schreibvorgangs ohne eine Memory-Barrier erzwingt. Dafür gibt es volatile, synchronized und java.util.concurrent.
  • Deadlock. Thread A hält Lock X und wartet auf Lock Y; Thread B hält Lock Y und wartet auf Lock X. Keiner kommt jemals weiter. Das Programm hängt ohne Exception und ohne Log-Eintrag. Das Deadlock-Kapitel zeigt, wie man ihn erkennt und vermeidet.

Der Rest dieses Buchteils handelt größtenteils davon, diese drei Fehler zu verhindern und gleichzeitig den Durchsatzgewinn beizubehalten.

Das Vokabular, dem Sie begegnen werden

Einige Begriffe, die überall auftauchen und die die restlichen Kapitel voraussetzen:

BegriffBedeutung
ConcurrencyMehrere Aufgaben machen im selben Zeitraum Fortschritte. Sie müssen nicht unbedingt im selben Moment ausgeführt werden.
ParallelismMehrere Aufgaben werden buchstäblich im selben Moment auf verschiedenen Kernen ausgeführt. Eine Teilmenge von Concurrency.
Mutual ExclusionNur ein Thread darf sich zu einem Zeitpunkt in einem kritischen Abschnitt befinden. Locks und synchronized stellen das sicher.
Memory ModelDie Regeln, die besagen, wann ein Thread garantiert den Schreibvorgang eines anderen Threads sieht. Durch den JLS definiert, durch JSR-133 verfeinert.
AtomicEine Operation, die nicht halb ausgeführt beobachtet werden kann. Entweder ist sie passiert oder nicht — kein Zwischenzustand für andere Threads sichtbar.
Thread-safeEine Klasse, deren öffentliche API von mehreren Threads ohne externe Synchronisierung aufgerufen werden kann und dabei korrekt funktioniert.

Daemon-Threads und die JVM-Exit-Regel

Eine Sache, die Anfänger überrascht: Die JVM beendet sich, wenn der letzte Non-Daemon-Thread fertig ist. Der main-Thread ist kein Daemon. Threads, die Sie mit new Thread(...) erstellen, sind standardmäßig keine Daemons — das Erzeugen eines Worker-Threads hält die JVM am Leben, bis dieser Worker zurückkehrt.

Sie können einen Thread mit t.setDaemon(true) vor start() als Daemon markieren. Daemon-Threads halten die JVM nicht am Leben; wenn alle Non-Daemon-Threads fertig sind, beendet die JVM sie abrupt. Verwenden Sie Daemons für Hintergrundarbeit, die mit dem Programm sterben soll (ein Timer, der abfragt, ein Metrics-Flusher) — niemals für Arbeit, deren Abschluss Sie tatsächlich benötigen (Dateischreibvorgänge, Transaktions-Commits).

Threads vs. virtuelle Threads

Java 21 führte virtuelle Threads ein, die auf API-Ebene identisch aussehen, aber von der JVM auf einem kleinen Pool von OS-Threads geplant werden. Das mentale Modell in diesem Kapitel — ein Java-Thread entspricht einem OS-Thread — beschreibt „Platform Threads", die Sie mit dem einfachen new Thread(...)-Konstruktor erhalten. Platform Threads sind teuer: Jeder benötigt etwa 1 MB nativen Stack, und das Betriebssystem begrenzt, wie viele ein Prozess haben kann, weshalb man sie mit Bedacht erstellt. Virtuelle Threads sind günstig — Millionen sind in Ordnung — und sie machen blockierendes I/O wieder unproblematisch. Wir behandeln sie in Java Virtual Threads; bis dahin bedeutet „Thread" „Platform Thread."

Ein ausgearbeitetes Beispiel: seriell vs. parallel

Das folgende Programm summiert einen Teil CPU-Arbeit auf zwei Arten — einmal sequenziell im main-Thread, einmal aufgeteilt auf vier Threads — und gibt die Wanduhrzeit für jede Variante aus. Die Zahlen variieren je nach Maschine, aber das Muster ist überall gleich: mehr Threads, weniger Wanduhrzeit, bis die Kerne ausgehen.

java— editable, runs on the server

Was man aus dem Lauf mitnehmen kann:

  • Der serielle Lauf verwendete einen Kern; der parallele Lauf verwendete vier. Der Speedup ist sublinear (näher an 3x als 4x), weil das Betriebssystem, der GC und andere JVM-Threads ebenfalls CPU-Zeit beanspruchen. Amdahls Gesetz in Aktion — ein kleiner serieller Anteil (die abschließende Summierungsschleife für Partials, der Schleifenstart) begrenzt den Speedup.
  • Jeder Worker schrieb in seinen eigenen Slot in partials[]. Keine zwei Threads berührten jemals denselben Index, daher war keine Synchronisierung erforderlich. Das ist die einfachste Form des Parallelismus — Daten partitionieren und jedem Thread seine Partition überlassen.
  • t.join() ist die Art und Weise, wie main auf die Fertigstellung von worker-3 wartet. Ohne die Joins würde die Schleife partials lesen, bevor die Worker geschrieben haben, und parallelSum wäre falsch. join ist das einzige Stück Thread-Koordination, das dieses Programm verwendet; die nächsten Kapitel werden viele weitere vorstellen.
  • Der Daemon-Thread am Ende hielt die JVM nicht am Leben. Er wollte 60 Sekunden schlafen, aber main kehrte zurück und die JVM beendete sich, was den Daemon mitten im Schlaf abriss, ohne seine print-Anweisung auszuführen. Das ist der Daemon-Vertrag.
  • Thread.currentThread().getName() und der explizite Name, der dem Thread-Konstruktor übergeben wird, sind die Art und Weise, wie man Threads in Logs, in Profilern und in Thread-Dumps unterscheidet. Benennen Sie Ihre Threads immer — Thread-3 ist nutzlos, wenn Sie herausfinden wollen, welcher hängt.

Was als nächstes kommt

Das nächste Kapitel, Java Thread Class, geht näher auf das Thread-Objekt ein — seine Konstruktoren, den Unterschied zwischen dem Erweitern von Thread und dem Übergeben eines Runnable sowie die API für Benennung, Priorität, Daemon-Status und Interruption.

Übungen

Übung
Zwei Threads führen jeweils `counter++` 100.000 Mal auf einem gemeinsamen `int counter` aus, der bei 0 beginnt. Was ist nach beiden `join()`-Aufrufen der wahrscheinlichste Wert von `counter`?
Zwei Threads führen jeweils `counter++` 100.000 Mal auf einem gemeinsamen `int counter` aus, der bei 0 beginnt. Was ist nach beiden `join()`-Aufrufen der wahrscheinlichste Wert von `counter`?
Was this page helpful?