Grundlagen

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.

Lesestand

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

NEW→RUNNABLE⇄BLOCKEDWAITINGTIMED_WAITING→TERMINATED

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:

Zwei Threads, ein Zähler

Thread A

    count im Heap
    0

    Thread B

      Beide Threads führen count++ einmal aus. Richtig wäre am Ende 2. Du bist der Scheduler: Wähle, wer als Nächstes einen Schritt machen darf.

      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 200000

      Drei 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:

      Zwei Überweisungen
      Thread 1Thread 2Konto AKonto B

      Thread 1: A → B

        Thread 2: B → A

          Durchgezogener Pfeil: Thread hält das Konto. Gestrichelter Pfeil: Thread wartet darauf.

          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.

          Pool oder virtuelle Threads?
          Anzahl Aufgaben
          Threads im Pool

          Idealisierte Rechnung auf einem Rechner mit 8 Kernen, ohne Overhead. Speicher: Stack-Reservierung bei Plattform-Threads, Größenordnung bei virtuellen Threads.

          // 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

          ProblemWerkzeug in Java
          Ein Zähler, eine Referenz atomar ändernAtomicInteger, AtomicReference, LongAdder
          Mehrere Felder konsistent ändernsynchronized oder ReentrantLock
          Viele lesen, wenige schreibenReadWriteLock, StampedLock, oder unveränderliche Objekte (record)
          Gemeinsame Map, Liste, QueueConcurrentHashMap, CopyOnWriteArrayList, BlockingQueue
          Aufgaben verteilenExecutorService, bei viel Warten virtuelle Threads
          Ergebnisse verkettenCompletableFuture
          Große Datenmengen auf alle Kerne verteilenparallelStream(), ForkJoinPool
          Zugriffe begrenzenSemaphore
          Auf mehrere Threads wartenCountDownLatch, 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

          „Mehr Threads, mehr Tempo.“ – Für Rechenarbeit bringt alles über die Zahl der Kerne nichts, nur zusätzliche Wechsel. Mehr Threads helfen nur, wenn sie überwiegend warten.
          „volatile macht count++ sicher.“ – Es macht Änderungen sichtbar, aber nicht atomar. Dafür braucht es AtomicInteger oder einen Lock.
          „Ich benutze ConcurrentHashMap, also ist mein Code thread-sicher.“ – Jede einzelne Operation ist sicher, ihre Kombination nicht. if (!map.containsKey(k)) map.put(k, v) ist eine Race Condition. Richtig: putIfAbsent, computeIfAbsent oder merge.
          „Im Test lief es tausendmal – also ist es korrekt.“ – Nebenläufigkeitsfehler hängen vom Timing ab. Sie zeigen sich oft erst unter Last, auf anderer Hardware oder nach einem JVM-Update.
          „Virtuelle Threads machen alles schneller.“ – Sie machen Warten billig. Rechenintensiver Code wird nicht schneller, denn die Zahl der Kerne bleibt dieselbe.

          Verwandte Begriffe