Prozess, Thread, Nebenläufigkeit
Warum zwei Threads und ein count++ falsch rechnen, wie aus zwei harmlosen Überweisungen ein Deadlock wird und wann virtuelle Threads wirklich helfen – mit Java-Code.
Ein Prozess ist ein laufendes Programm mit eigenem, abgeschottetem Speicher. Ein Thread ist ein Ausführungsstrang innerhalb eines Prozesses. Alle Threads eines Prozesses teilen sich denselben Speicher – das macht sie billig und schnell, aber gefährlich.
Greifen zwei Threads ohne Absprache auf dieselben Daten zu, entstehen Race Conditions: Ergebnisse, die mal stimmen und mal nicht. Locks verhindern das, können aber Deadlocks erzeugen. Und seit Java 21 gibt es mit virtuellen Threads eine Möglichkeit, Hunderttausende Aufgaben gleichzeitig warten zu lassen.
Prozess oder Thread?
Ein Bild dafür: Ein Prozess ist eine Wohnung mit eigener Tür. Threads sind die Mitbewohner. Sie teilen sich Küche und Kühlschrank – praktisch, weil niemand etwas hin- und hertragen muss. Aber wenn zwei gleichzeitig das letzte Ei nehmen wollen, gibt es Streit. Wer etwas mit der Nachbarwohnung austauschen will, muss klingeln.
Prozess
- eigener Adressraum, vom Betriebssystem geschützt
- Starten teuer (Millisekunden)
- Austausch nur über Umwege: Pipes, Sockets, Dateien
- Absturz betrifft nur ihn selbst
Thread
- teilt Heap, offene Dateien, alles
- Starten billiger (Mikrosekunden)
- Austausch direkt über gemeinsame Objekte
- eine unbehandelte Exception beendet nur den Thread – aber ein kaputter Zustand trifft alle
// Ein neuer Prozess: ein anderes Programm starten
Process p = new ProcessBuilder("git", "status").inheritIO().start();
int exitCode = p.waitFor();
// Ein neuer Thread: im selben Prozess, mit Zugriff auf dieselben Objekte
Thread t = Thread.ofPlatform().start(() -> System.out.println("Hallo aus " + Thread.currentThread()));
t.join();Nebenläufig ist nicht parallel
Nebenläufig (concurrent) heißt: Mehrere Aufgaben sind gleichzeitig in Arbeit und überlappen sich zeitlich. Parallel heißt: Sie werden im selben Moment ausgeführt – dafür braucht man mehrere Kerne. Ein Koch, der zwischen drei Töpfen hin- und herspringt, arbeitet nebenläufig. Drei Köche an drei Töpfen arbeiten parallel. Die Probleme auf dieser Seite treten bei beidem auf – schon ein einziger Kern, der zwischen Threads wechselt, reicht für eine Race Condition.
Die Lebensphasen eines Threads
So heißen die Zustände in Thread.State. BLOCKED bedeutet: wartet auf einen Lock. WAITING: wartet auf ein Signal, etwa bei join(). TIMED_WAITING: wartet mit Zeitlimit, etwa bei sleep(). In einem Thread-Dump mit jstack sieht man genau diese Zustände.
Race Condition: count++ ist nicht ein Schritt
In Java sieht count++ aus wie ein Befehl. Für die CPU sind es drei: Wert aus dem Speicher lesen, eins addieren, Ergebnis zurückschreiben. Zwischen diesen Schritten kann der Scheduler jederzeit zu einem anderen Thread wechseln. Probier, ob du einen Zähler kaputt bekommst:
Das Tückische: Meistens geht es gut. Der Fehler zeigt sich nur, wenn der Wechsel genau zwischen Lesen und Schreiben fällt – im Test selten, unter Last in Produktion regelmäßig. Dieser Fehler heißt Lost Update, und er ist der Klassiker unter den Nebenläufigkeitsfehlern.
class Zaehler {
private int count = 0;
void inc() { count++; } // lesen, +1, schreiben – drei Schritte
int get() { return count; }
}
var z = new Zaehler();
Runnable job = () -> { for (int i = 0; i < 100_000; i++) z.inc(); };
Thread a = Thread.ofPlatform().start(job);
Thread b = Thread.ofPlatform().start(job);
a.join(); b.join();
System.out.println(z.get()); // selten 200000Drei Reparaturen
// 1. Lock: Nur ein Thread zur Zeit darf in die Methode
synchronized void inc() { count++; }
// 2. Atomare Operation: die CPU erledigt lesen-ändern-schreiben untrennbar (Compare-and-Swap)
private final AtomicInteger count = new AtomicInteger();
void inc() { count.incrementAndGet(); }
// 3. Bei sehr vielen schreibenden Threads: verteilte Teilzähler, erst beim Lesen summiert
private final LongAdder count = new LongAdder();
void inc() { count.increment(); }Der Bereich, den nur ein Thread zur Zeit betreten darf, heißt kritischer Abschnitt. Er sollte so klein wie möglich sein: Wer im synchronized-Block eine Datenbank abfragt, lässt alle anderen Threads warten.
Sichtbarkeit: wenn ein Thread nichts mitbekommt
Es gibt einen zweiten, noch heimtückischeren Fehler. Jeder Kern hat eigene Caches, und der JIT-Compiler darf Code umstellen, solange ein einzelner Thread keinen Unterschied merkt. Ohne Absprache ist deshalb nicht garantiert, dass ein Thread die Änderung eines anderen überhaupt jemals sieht.
class Arbeiter {
private boolean stop = false; // ohne volatile!
void run() { while (!stop) { /* arbeiten */ } }
void halt() { stop = true; } // aus einem anderen Thread
}
// Die Schleife kann ewig laufen: Der JIT darf stop einmal lesen und dann annehmen,
// dass es sich nie ändert – im selben Thread wird es ja nie geschrieben.Die Lösung ist volatile boolean stop. Das Java Memory Model formuliert die Regel dahinter als happens-before: Was ein Thread vor dem Schreiben einer volatile-Variable, vor dem Verlassen eines synchronized-Blocks oder vor dem Ende eines Threads getan hat, sieht garantiert jeder Thread, der danach diese Variable liest, denselben Lock nimmt oder join() aufruft. Ohne so eine Kante gibt es keine Garantie.
volatile sorgt für Sichtbarkeit, nicht für Atomarität. Ein volatile int count mit count++ verliert genauso Erhöhungen wie oben.Deadlock: alle warten aufeinander
Locks lösen Race Conditions – und schaffen ein neues Problem. Zwei Überweisungen laufen gleichzeitig: Thread 1 überweist von Konto A nach B, Thread 2 von B nach A. Jeder sperrt zuerst das Quellkonto, dann das Zielkonto. Spiel es durch:
Ein Deadlock braucht vier Bedingungen gleichzeitig – die Coffman-Bedingungen: Ressourcen sind exklusiv; wer etwas hält, wartet auf mehr; niemand kann einem anderen etwas wegnehmen; und das Warten bildet einen Kreis. Bricht man eine davon, ist der Deadlock unmöglich. Am einfachsten bricht man den Kreis: Alle nehmen Locks in derselben Reihenfolge.
void ueberweise(Konto von, Konto an, long betrag) {
// Kreis brechen: immer zuerst das Konto mit der kleineren ID sperren
Konto erstes = von.id() < an.id() ? von : an;
Konto zweites = erstes == von ? an : von;
synchronized (erstes) {
synchronized (zweites) {
von.abbuchen(betrag);
an.gutschreiben(betrag);
}
}
}Das berühmte Lehrbuchbild dafür sind die speisenden Philosophen von Edsger Dijkstra: Fünf Philosophen sitzen am runden Tisch, zwischen je zweien liegt eine Gabel, und jeder braucht zwei zum Essen. Nimmt jeder zuerst die linke Gabel, verhungern alle. Ein JVM-Deadlock lässt sich übrigens finden: jstack meldet ihn mit „Found one Java-level deadlock“. Auflösen kann die JVM ihn nicht – der Prozess muss neu gestartet werden.
Thread-Pools und virtuelle Threads
Ein normaler Java-Thread – seit Java 21 Plattform-Thread genannt – ist ein echter Thread des Betriebssystems. Er ist vergleichsweise teuer: Er reserviert einen eigenen Stack (unter 64-Bit-Linux standardmäßig 1 MB Adressraum), und der Kernel muss ihn planen. Deshalb benutzt man Thread-Pools: eine feste Zahl Threads, die Aufgaben aus einer Warteschlange abarbeiten.
Das passt schlecht zu Servern, deren Aufgaben meistens warten – auf die Datenbank, auf einen anderen Dienst. Ein Thread, der wartet, belegt seinen Platz im Pool und tut nichts. Virtuelle Threads lösen das: Die JVM verwaltet sie selbst und verteilt sie auf wenige Plattform-Threads, die sogenannten Carrier. Wartet ein virtueller Thread, wird er abgehängt, und der Carrier übernimmt einen anderen.
// Klassisch: fester Pool – 10 000 wartende Aufgaben brauchen 50 Runden à 1 s
try (var ex = Executors.newFixedThreadPool(200)) {
for (int i = 0; i < 10_000; i++) ex.submit(() -> { Thread.sleep(1000); return null; });
}
// Seit Java 21: ein virtueller Thread je Aufgabe – fertig nach gut 1 s
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10_000; i++) ex.submit(() -> { Thread.sleep(1000); return null; });
}Virtuelle Threads poolt man nicht – man erzeugt einfach für jede Aufgabe einen neuen. Will man begrenzen, wie viele gleichzeitig eine Datenbank treffen, nimmt man eine Semaphore. Ein früherer Haken ist inzwischen weg: Bis Java 23 blieb ein virtueller Thread in einem synchronized-Block an seinen Carrier gefesselt („Pinning“). Seit Java 24 ist das behoben.
Der Werkzeugkasten
| Problem | Werkzeug in Java |
|---|---|
| Ein Zähler, eine Referenz atomar ändern | AtomicInteger, AtomicReference, LongAdder |
| Mehrere Felder konsistent ändern | synchronized oder ReentrantLock |
| Viele lesen, wenige schreiben | ReadWriteLock, StampedLock, oder unveränderliche Objekte (record) |
| Gemeinsame Map, Liste, Queue | ConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue |
| Aufgaben verteilen | ExecutorService, bei viel Warten virtuelle Threads |
| Ergebnisse verketten | CompletableFuture |
| Große Datenmengen auf alle Kerne verteilen | parallelStream(), ForkJoinPool |
| Zugriffe begrenzen | Semaphore |
| Auf mehrere Threads warten | CountDownLatch, join(), Structured Concurrency (seit Java 21 als Preview in Arbeit) |
Die beste Nebenläufigkeit ist die, die man nicht braucht: Unveränderliche Objekte können beliebig viele Threads gleichzeitig lesen, ganz ohne Locks.
Häufige Irrtümer
AtomicInteger oder einen Lock.if (!map.containsKey(k)) map.put(k, v) ist eine Race Condition. Richtig: putIfAbsent, computeIfAbsent oder merge.