iToverDose/Software· 18 MAI 2026 · 12:08

Java-Threads richtig nutzen: Nebenläufigkeit ohne Fallstricke

Threads in Java ermöglichen parallele Abläufe, bergen aber Risiken wie Race Conditions. Dieser Leitfaden erklärt die Unterschiede zwischen Prozessen und Threads, zeigt den richtigen Umgang mit Threads und stellt ExecutorService als bessere Alternative vor.

DEV Community5 min0 Kommentare

Java-Entwickler stehen oft vor der Herausforderung, nebenläufige Programme zu schreiben, ohne in typische Fallstricke zu tappen. Die meisten Einführungen demonstrieren nur einfache Beispiele wie "Hello World" – doch in der Praxis sieht es ganz anders aus. Dieser Artikel basiert auf eigenen Experimenten und beleuchtet die Stolpersteine, die selbst erfahrene Entwickler kennen sollten.

Prozesse und Threads: Zwei Ebenen der Nebenläufigkeit

Ein Prozess in Java lässt sich mit einer Fabrik vergleichen: Er besitzt eigene Ressourcen und eine abgeschottete Umgebung. Startet man ein Java-Programm, wird automatisch ein Prozess erzeugt, der die Java Virtual Machine (JVM) ausführt.

Ein Thread hingegen ist ein einzelner Arbeiter innerhalb dieser Fabrik. Die JVM erstellt beim Programmstart den main-Thread. Alle weiteren Threads entstehen als Ableger dieses Haupt-Threads. Der entscheidende Unterschied: Threads teilen sich denselben Arbeitsspeicher, insbesondere den Java-Heap. Diese gemeinsame Speichernutzung ermöglicht zwar schnelle Kommunikation zwischen Threads, birgt aber auch Gefahren.

Stellen Sie sich vor, ein Thread liest einen Wert aus dem Speicher, während ein anderer gerade dabei ist, denselben Wert zu aktualisieren. Die Folge? Datenkorruption – selbst wenn die Threads nicht exakt gleichzeitig laufen. Ein Thread muss nur zwischen dem Lesen und Schreiben eines anderen Threads eingreifen.

Gemeinsame und isolierte Ressourcen

Was sich alle Threads teilen:

  • Heap-Speicher – alle mit new erzeugten Objekte
  • Statische Variablen – ein einziger Speicherort für alle Threads
  • Klassenbytecode – die kompilierten .class-Dateien

Was jedem Thread exklusiv gehört:

  • Der eigene Stack mit lokalen Variablen und Rücksprungadressen
  • Der Programmzähler – zeigt an, welche Anweisung der Thread gerade ausführt

Threads erstellen: Zwei bewährte Methoden

In Java gibt es zwei Standardwege, um Hintergrundarbeiten zu starten. Beide haben Vor- und Nachteile, die in der Praxis entscheidend sein können.

Methode 1: Von der Thread-Klasse erben

Die einfachste, aber weniger flexible Variante besteht darin, die Thread-Klasse zu erweitern und die run()-Methode zu überschreiben:

class MeinArbeiter extends Thread {
    @Override
    public void run() {
        System.out.println("Läuft in: " + Thread.currentThread().getName());
    }
}

new MeinArbeiter().start();

Diese Methode ist zwar schnell umgesetzt, schränkt die Klasse aber ein, da Java keine Mehrfachvererbung zulässt. In den meisten Fällen ist es daher besser, die zweite Variante zu wählen.

Methode 2: Das Runnable-Interface implementieren

Das Runnable-Interface bietet eine saubere Lösung, indem es die Arbeitslogik von der Thread-Verwaltung trennt:

Runnable aufgabe = () -> {
    System.out.println("Läuft in: " + Thread.currentThread().getName());
};

new Thread(aufgabe).start();

Der Vorteil: Die Klasse bleibt frei, andere Klassen zu erweitern. In Produktionscode sollte Runnable daher stets die bevorzugte Wahl sein.

Ein gefährlicher Fehler: run() statt start() aufrufen

Ein klassischer Anfängerfehler sieht so aus:

new Thread(aufgabe).run();  // Falsch – kein neuer Thread!

Ein Vergleich zeigt den Unterschied:

Runnable aufgabe = () -> {
    System.out.println("Ausführung in: " + Thread.currentThread().getName());
};

System.out.println("-- Aufruf von run() --");
new Thread(aufgabe).run();

System.out.println("-- Aufruf von start() --");
new Thread(aufgabe).start();

Ausgabe:

-- Aufruf von run() --
Ausführung in: main
-- Aufruf von start() --
Ausführung in: Thread-0

Der Aufruf von run() führt lediglich eine normale Methodenausführung aus – kein neuer Thread entsteht. Die Ausgabe zeigt main, was bedeutet, dass der Haupt-Thread die Arbeit übernimmt. Erst start() löst die Erstellung eines neuen Threads aus, erkennbar am Namen Thread-0.

Dieser Fehler ist besonders tückisch, weil der Code zwar läuft, aber ohne echte Nebenläufigkeit. Keine Exception wird geworfen, die Anwendung bleibt jedoch single-threaded – ein klassisches Beispiel für einen stillen Fehler.

Statische Variablen und die join()-Methode

Das Runnable-Interface hat eine grundlegende Einschränkung: Die run()-Methode gibt keinen Rückgabewert zurück. In der Praxis führt dies oft zu einem fragilen Workaround – der Nutzung statischer Variablen für Ergebnisse.

static int ergebnis = 0;

public static void main(String[] args) {
    Runnable aufgabe = () -> {
        ergebnis = 10 + 10;
    };

    new Thread(aufgabe).start();
    System.out.println(ergebnis);  // Was wird hier ausgegeben?
}

Die Ausgabe ist fast immer 0, da der main-Thread schneller ist als der Hintergrund-Thread. Er startet zwar die Aufgabe, führt aber sofort die nächste Anweisung aus und liest ergebnis, bevor der Hintergrund-Thread die Berechnung abgeschlossen hat.

Um dies zu vermeiden, muss der main-Thread warten, bis der Hintergrund-Thread fertig ist. Dazu dient die join()-Methode:

Thread t1 = new Thread(aufgabe);
t1.start();
t1.join();  // Warte, bis t1 beendet ist
System.out.println(ergebnis);  // Jetzt wird 20 ausgegeben

Race Conditions: Wenn mehrere Threads gleichzeitig schreiben

Der statische Variablen-Ansatz funktioniert zwar für einen Thread, scheitert aber in der Praxis an Race Conditions. Wenn zwei Threads gleichzeitig auf dieselbe Variable schreiben, überschreiben sich die Werte unvorhersehbar. Die Ausgabe variiert von Lauf zu Lauf, was Fehler schwer nachvollziehbar macht.

Callable, FutureTask und die Grenzen von Threads

Angesichts der Unzuverlässigkeit statischer Variablen führte Java das Callable-Interface ein. Es ähnelt Runnable, kann jedoch Werte zurückgeben und Exceptions werfen:

Callable<String> aufgabe = () -> {
    return "Ergebnis von: " + Thread.currentThread().getName();
};

Allerdings akzeptiert die Thread-Klasse kein Callable direkt. Der Compiler würde einen Fehler melden. Die Lösung bietet FutureTask – eine Wrapper-Klasse, die sowohl Runnable als auch Callable unterstützt:

FutureTask<String> zukunftsAufgabe = new FutureTask<>(aufgabe);
new Thread(zukunftsAufgabe).start();
System.out.println(zukunftsAufgabe.get());  // Blockiert, bis das Ergebnis verfügbar ist

Thread-Pools und ExecutorService: Die bessere Lösung

Die manuelle Erstellung von Threads mit new Thread() ist in Produktionsumgebungen problematisch. Bei vielen Aufgaben kann schnell ein OutOfMemoryError drohen, weil jeder neue Thread Speicher belegt.

Als Lösung dient der ExecutorService – ein Thread-Pool-Manager, der die Erstellung und Verwaltung von Threads übernimmt. Statt jeden Thread einzeln zu erstellen, übergibt man die Aufgaben an einen Pool:

// 1. Thread-Pool mit 10 Arbeitern initialisieren
ExecutorService executor = Executors.newFixedThreadPool(10);

// 2. Aufgabe einreichen
Future<String> zukunft = executor.submit(aufgabe);

// 3. Ergebnis abrufen, wenn benötigt
System.out.println(zukunft.get());

// 4. Pool ordnungsgemäß herunterfahren
executor.shutdown();

Der Future-Rückgabewert ist wie ein Versprechen: Die Aufgabe wird bearbeitet, und das Ergebnis ist verfügbar, sobald es fertig ist. Der Haupt-Thread blockiert nicht, sondern ruft zukunft.get() erst auf, wenn das Ergebnis tatsächlich benötigt wird.

Fazit: Nachhaltige Nebenläufigkeit mit Java

Threads und statische Variablen sind nur die Grundbausteine der Java-Nebenläufigkeit. Für zuverlässige und wartbare Programme empfiehlt es sich, auf moderne APIs wie ExecutorService und Future zu setzen. Diese vermeiden nicht nur Speicherprobleme, sondern auch die typischen Race Conditions und Blockaden.

Die Wahl des richtigen Ansatzes hängt von den Anforderungen ab: Einfache Hintergrundaufgaben lassen sich mit Runnable umsetzen, während komplexe Systeme von Thread-Pools profitieren. Ein tiefes Verständnis der Speicherteilung und Synchronisation bleibt jedoch unerlässlich, um stabile Anwendungen zu entwickeln.

Die nächste Stufe der Java-Nebenläufigkeit führt über synchronized-Blöcke, volatile-Variablen und komplexere Synchronisationsmechanismen. Wer diese Prinzipien beherrscht, kann skalierbare und fehlerfreie Multithreading-Anwendungen schreiben – ohne im Chaos der Race Conditions zu versinken.

KI-Zusammenfassung

Learn how Java threads share memory, the difference between run() and start(), and why race conditions occur. Discover production-ready concurrency patterns with Callable and ExecutorService.

Kommentare

00
KOMMENTAR SCHREIBEN
ID #Z1RKMI

0 / 1200 ZEICHEN

Menschen-Check

9 + 5 = ?

Erscheint nach redaktioneller Prüfung

Moderation · Spam-Schutz aktiv

Noch keine Kommentare. Sei der erste.