Threads in Java -- Grundlagen

Gründe für den Einsatz von Threads

  • Nichtblockierende Eingabe/Ausgabe (nonblocking I/O)
  • Zeitgesteuerte Aufgaben (timer, alarms)
  • Unabhängige Aufgaben (independent tasks)
  • Parallelisierbare Algorithmen (paralelizable algorithms)

Threads in Java

  • Threads sind in Java (wie in ADA) ein Teil der Sprache und nicht, wie in C oder C++, Bibliotheksfunktionen
  • Die Sprachspezifikation macht keine Aussage zur Implementierung (native / preemtive vs. green / kooperativ)
    • Alle gängigen Java-Implementierungen (Oracle, Apple, IBM, SAP, HP) bieten native Threads mit preemtivem Scheduling
  • Basis aller Thread-Programmierung in Java sind die Klasse Thread und das Interface Runnable
  • Seit Java 1.5 gibt es mit java.util.concurrent neue Möglichkeiten der parallelen Programmierung – Thread bleibt aber die Grundlage
    • java.util.concurrent ist einer direkten Verwendung von Thread vorzuziehen

Als man die Sprache Java entworfen hat, hat man entschieden, dass viele komplizierte Techniken von C/C++ nicht Einzug in Java halten sollten. Deshalb wurden die Eingabe-/Ausgabe-Operationen vollständig synchron entworfen. Dies hat aber zur Folge, dass man sofort ein Konzept zur Nebenläufigkeit mit Threads benötigt, da man andernfalls keine ernsthaften Programme schreiben könnte. Aus diesem Grund wurden Threads Teil der Sprachspezifikation und sind daher auf jeder Java-Implementierung und Plattform verfügbar.

Es gibt zwei Möglichkeiten Threads zu implementieren

  1. preemptives Scheduling (auch native threads genannt): Die Verwaltung der Threads wird vom Betriebssystem übernommen. Die Zeitscheiben (wie lange ein Thread laufen darf) werden hart durchgesetzt. Wenn ein Thread seine Zeitscheibe aufgebraucht hat, wird er (auch gegen seinen Willen) unterbrochen und ein anderer Thread darf weiterarbeiten.
  2. kooperatives Scheduling (auch green threading genannt): Die Threads werden von der Virtuellen Maschine selbst verwaltet. Die Threads geben sich gegenseitig Rechenzeit ab. Ein Thread läuft solange bis er selbst der VM mitteilt, dass ein anderer Thread laufen soll.

Da das kooperative Modell deutlich fehleranfälliger ist, verwenden die allermeisten Virtuellen Maschinen native Threads.

Das zentrale Element der Thread-Programmierung in Java sind die Klasse Thread und das Interface Runnable. Der Umgang mit diesen beiden wird uns in den nächsten Abschnitte beschäftigen.

Beispiel: Thread (preemtiv)

class Runner implements Runnable {
    public void run() {
        while (true) {
            System.out.println("Paralleler Thread");
        }
    }
}

Das Beispiel zeigt eine Klasse Runner, die das Runnable-Interface implementiert. Der Code, der in der run-Methode enthalten ist, wird später parallel zum Hauptprogramm ausgeführt werden.

public class ThreadDemo {
    public static void main(String[] args) throws Exception {

        Thread t = new Thread(new Runner());
        t.start();

        while (true) {
            System.out.println("Hauptprogramm");
        }
    }
}
Paralleler Thread
Hauptprogramm
Hauptprogramm
Paralleler Thread
Paralleler Thread

Von der Klasse Runner muss eine Instanz erzeugt werden, damit die nicht-statische run-Methode ausgeführt werden kann. Dies ist nötig, da man in Java keine Methoden übergeben kann, sondern nur Objekte. Das Objekt von Runner ist also nichts anderes als ein Hilfsmittel, um die run-Methode übergeben zu können.

Diese Instanz von Runnable kann dann einem Objekt der Klasse Thread im Konstruktor übergeben werden. Mit der Erzeugung des Thread-Objektes beginnt aber der neue Thread noch nicht zu laufen; dies passiert erst nachdem die start()-Methode gerufen wurde.

An der Ausgabe erkennt man deutlich, dass beide while-Schleifen parallel ausgeführt werden und beide Endlosschleifen Ausgaben erzeugen.

Wichtig ist, dass man folgenden Unterschied klar sieht:

  1. Das Runnable-Objekt und dessen run()-Methode repräsentiert den Code, der parallel ausgeführt werden soll.
  2. Die Klasse Thread führt dann den Code der run()-Methode aus.

Man kann sich Thread als einen „virtuellen“ Prozessor vorstellen, der dazu dient die parallelen Aufgaben auszuführen.

public class ThreadDemo {

    public static void main(String[] args) throws Exception {

        Thread t = new Thread(
          () -> {
                while (true) {
                    System.out.println("Paraller Thread");
                }
        });
        t.start();

        while (true) {
            System.out.println("Hauptprogramm");
        }
    }
}

Dieses Beispiel ist äquivalent zum vorhergehenden, nur dass hier ein Lambda für die Erzeugung des Runnable-Objekts verwendet wird.

Paralleler Thread
Hauptprogramm
Hauptprogramm
Hauptprogramm
Paralleler Thread
Paralleler Thread

Beispiel: Thread (kooperativ)

class Runner implements Runnable {
    public void run() {
        while (true) {
            System.out.println("Paralleler Thread");
            Thread.yield();
        }
    }
}

Der Unterschied zur preemptiven Variante ist, dass hier der Thread freiwillig Rechenzeit durch die Methode Thread.yield() abgibt. Durch den Aufruf von yield() teilt der Thread mit, dass er Rechenzeit abgeben möchte. Er bleibt dann einfach in der Methode stehen und einem anderen Thread wird vom Scheduler mit Rechenzeit versorgt. Es kann vorkommen, dass kein anderer Thread Rechenzeit angefordert hat; in diesem Fall kehrt der Aufruf von yield() sofort zurück und der Thread läuft einfach weiter.

yield() aufzurufen garantiert also nicht, dass wirklich ein anderer Thread an die Reihe kommt. Die letztendliche Entscheidung liegt beim Scheduler. Der yield()-Aufruf ist somit nur ein Hinweis an den Scheduler, bietet aber keine Garantien.

public class ThreadDemo {
    public static void main(String[] args) throws Exception {
        Thread t = new Thread(new Runner());
        t.start();

        while (true) {
            System.out.println("Hauptprogramm");
            Thread.yield();
        }
    }
}
Paralleler Thread
Hauptprogramm
Paralleler Thread
Hauptprogramm
Paralleler Thread

Dass die Ausgaben hier genau abwechselnd erfolgen ist Zufall. Wegen des oben bereits beschriebenen Verhalten von yield() kann es auch vorkommen, dass viele Male hintereinander eine der beiden Threads Ausgaben machen kann.

Beispiel: Thread mit Namen

class Runner implements Runnable {
    public void run() {
        while (true) {
            System.out.println(Thread.currentThread().getName());
        }
    }
}

Man kann einem Thread beim Erzeugen einen Namen mitgeben. Diesen kann dann die ausgeführte Methode über Thread.currentThread().getName() wieder auslesen.

public class ThreadDemo {
    public static void main(String[] args) throws Exception {

        Runnable runner = new Runner();

        Thread t1 = new Thread(runner, "Thread-1");
        Thread t2 = new Thread(runner, "Thread-2");
        t1.start();  t2.start();

        while (true) {
            System.out.println("Hauptprogramm");
        }
    }
}

Man kann einem Thread bei der Erzeugung über den Konstruktor einen Namen mitgeben. Dieser kann aus dem Thread über getName() wieder ausgelesen werden. Die run()-Methode kann herausfinden, welcher Thread sie gerade ausführt, indem sie die statische Methode Thread.currentThread() verwendet.

In diesem Beispiel ist die Besonderheit, dass es nur ein Objekt von der Klasse Runner (und damit nur eine run()-Methode) gibt, diese aber von zwei Threads ausgeführt wird.

Dies macht noch einmal deutlich, dass man streng zwischen dem Thread als dem Ausführenden und der Runnable-Instanz als dem Ausgeführten trennen muss.

Thread-1
Thread-2
Thread-1
Hauptprogramm
Thread-2
Hauptprogramm
Thread-2

Beispiel: Stack versus Heap 1

class Runner4 implements Runnable {

    private int zaehler = 0;

    public void run() {
        int local = new Random().nextInt();

        while (true) {
            zaehler = local++;
        }
    }
}
public class SimpleHeapAndStack {

    public static void main(String[] args) {
        Runner4 runner = new Runner4();
        Thread t1 = new Thread(runner, "Thread-1");
        Thread t2 = new Thread(runner, "Thread-2");

        t1.start();
        t2.start();
    }
}

Dieses Beispiel zeigt die Beziehung zwischen den verschiedenen Objekten. Auf dem Heap gibt es zwei Thread-Objekte, die beide eine run()-Methode ausführen. Da es drei Threads gibt (main-Thread und zwei explizit gestartete Threads) existieren drei Stacks.

Beispiel: Stack versus Heap 2

class Runner3 implements Runnable {
    private HeapAndStackDemo hasd;
    public Runner3(HeapAndStackDemo hasd) {
        this.hasd = hasd;
    }
    private void doIt() {
        int a = new Random().nextInt(10);
        int b = new Random().nextInt(10);
        hasd.f = hasd.calc(a, b);
    }
    public void run() {
        while (true) {
            doIt();
            System.out.println(Thread.currentThread().getName()
                    + ": " + hasd.f);
        }
    }
}
public class HeapAndStackDemo {

    int f;

    public int calc(int i, int j) {
        int temp = i + j;
        return temp;
    }

    public void go() {
        Runnable runner = new Runner3(this);
        new Thread(runner, "Thread-1").start();
        new Thread(runner, "Thread-2").start();
    }
}

Die Klasse Thread

Methoden von Thread

  • static void yield() – Rechenzeit abgeben
  • static void sleep(long millis) – Rechenzeit abgeben und für millis Millisekunden schlafen
  • static Thread currentThread() – Liefert den aktuellen Thread zurück
  • Thread(Runnable target) – Einen neuen Thread anlegen
  • Thread(Runnable target, String name) – Einen neuen Thread mit Namen name anlegen
  • void setName(String name), String getName() – Namen setzen und lesen
  • void start() – Thread starten
  • void run() – Thread implementiert selbst Runnable, man kann also von Thread ableiten und dann run() überschreiben (nicht empfohlen)

Zustände eines Threads

Ein Thread kann verschiedene Zustände einnehmen. Diese Abbildung zeigt drei (der insgesamt fünf) Zustände. Die noch fehlenden zwei Zustände werden später nachgereicht.

Wenn ein Thread gestartet wird, läuft er nicht sofort los, sondern befindet sich im Zustand lauffähig (runnable). Dieser Zustand bedeutet, dass der Thread prinzipiell alle Voraussetzungen erfüllt, um ausgeführt zu werden. Da aber nur der Scheduler die CPU an Threads zuteilen darf, muss der Thread solange im Zustand lauffähig warten, bis der Scheduler ihn zur Ausführung bringt.

Wenn der Thread ausgeführt wird, ist er im Zustand läuft (running). Hier bleibt er solange, bis eine der folgenden Dinge passiert:

  • Die Zeitscheibe des Threads ist zu Ende und der Scheduler entzieht ihm die CPU. Dann wandert er in den Zustand lauffähig zurück.
  • Der Thread gibt per Thread.yield() den Rest seiner Zeitscheibe ab und wird vom Scheduler in den Zustand lauffähig versetzt.
  • Der Thread führt eine Aktion durch, bei der er nicht weiterlaufen kann, z. B. weil er auf Daten aus einer Datei wartet oder sich selbst mit Thread.sleep() für eine gewisse Zeit schlafen gelegt hat. In diesem Fall wandert er in den Zustand blockiert (blocked). In diesem verbleibt er solange, bis die blockierende Situation verschwunden ist, woraufhin er wieder lauffähig wird.

Beispiel: Thread noch einmal starten

class Runner2 implements Runnable {
    public void run() { /* ... */ }
}

public class BrokenRestart {
    public static void main(String[] args) throws Exception {
        Runnable runnable = new Runner2();
        Thread t1 = new Thread(runnable, "Thread-1");
        t1.start();
        Thread.sleep(100);
        t1.start(); // FEHLER!!
    }
}
Exception in thread "main" java.lang.IllegalThreadStateException
    at java.lang.Thread.start(Thread.java:613)
    at de.smits_net.pr2.runnable.BrokenRestart.main(BrokenRestart.java:18)

Es ist nicht möglich einen bereits gestarteten Thread noch einmal zu starten. Wurde die start()-Methode schon aufgerufen, führt jeder weitere Aufruf von start() zu einer IllegalThreadStateException.

Beenden eines Threads

  • Ein Thread endet, wenn die run()-Methode zurückkehrt
  • Man kann den Wunsch, dass der Thread sich beenden soll durch das Setzen einer Variable von außen signalisieren
  • Auf keinen Fall sollte man die folgenden Methoden verwenden, um einen Thread zu beenden
    • void stop(), void stop(Throwable obj)
    • void destroy()
    • void suspend(), void resume()
  • Eine weitere Möglichkeit besteht darin, dem Thread mit interrupt() zu beenden
    • Hat den Vorteil, dass hier der Thread auch beendet werden kann, wenn er in bestimmten Methoden blockiert ist

Ein Thread ist beendet, wenn die run()-Methode zurückkehrt, z. B. mit return. Da nur der Thread seinen internen Zustand kennt, ist die einzig adäquate Methode ihm zu signalisieren, dass er sich bei der nächsten Möglichkeit beenden soll. Hierzu kann man entweder ein boolean Flag setzen oder die interrupt()-Methode von Thread verwenden (Details folgen).

Es gibt eine ganze Reihe von Methoden in der Klasse Thread, die dem Beenden und Anhalten von Threads dienen. Diese Methoden sind als überholt (deprecated) gekennzeichnet und sollten auf gar keinen Fall benutzt werden. Da sie den Thread einfach an einer beliebigen Stelle stoppen, können unvorhergesehene Probleme auftreten. Insbesondere Ressourcen, die der Thread benutzt (Dateien, Speicher, Netzwerk- oder Datenbankverbindungen) werden nicht mehr korrekt freigegeben, wenn der Thread einfach von außen beendet wird.

Beispiel: Thread beenden (Flag)

public class Stoppable implements Runnable {
    private volatile boolean cont = true;

    public void requestTermination() {
        cont = false;
    }

    public void run() {
        while (cont) {
            System.out.println("Thread laeuft");
            Thread.yield();
        }
        System.out.println("** Stoppable gestoppt **");
    }
}
Stoppable st = new Stoppable();
Thread thread = new Thread(st);
thread.start();
Thread.sleep(100);
st.requestTermination();

In diesem Beispiel wird dem Thread durch die Variable cont signalisiert, wenn er sich beenden soll. Das volatile bei der Deklaration kann an dieser Stelle noch nicht begründet werden, wird aber später noch erklärt. Es ist nötig, damit das Beenden korrekt funktioniert.

Wenn der Verwender möchte, dass sich der Thread beendet, kann er die requestTermination()-Methode auf der Runnable-Instanz aufrufen. Die run()-Methode kann dann beim nächsten Durchgang die Variable cont abfragen und sich sauber beenden.

Beispiel: Thread beenden (interrupt())

public class Interruptible implements Runnable {

    public void run() {

        while (true) {
            System.out.println("Thread laeuft");

            if (Thread.currentThread().isInterrupted()) {
                break;
            }
            Thread.yield();
        }
        System.out.println("** Thread gestoppt **");
    }
}
Interruptible interuptible = new Interruptible();

Thread thread = new Thread(interuptible);

thread.start();

Thread.sleep(100);

thread.interrupt();

Eine andere Möglichkeit einem Thread zu signalisieren, dass er sich beenden soll, besteht in der Methode interrupt() der Klasse Thread. Der Name ist insofern irreführend, als dass er den Eindruck erweckt, der Thread würde durch den Aufruf unterbrochen. Tatsächlich wird aber nur eine Variable im Thread gesetzt, die die run()-Methode über isInterrupted() abfragen kann. Der Thread behält also hier die volle Kontrolle über die Beendigung und kann das interrupt() auch ignorieren.

Der große Vorteil dieser Variante gegenüber dem Flag besteht darin, dass es eine Reihe von Methoden gibt, in denen der Thread blockieren kann (z. B. sleep()). Würde man nur ein Flag setzen, der Thread könnte es nicht abfragen, da er im Zustand „blockiert“ ist. interrupt() hat den Vorteil, dass es den Thread aus dem blockierten Zustand wecken kann. Dies geschieht, indem die blockierten Methoden durch eine InterruptedException vorzeitig zurückkehren. Tritt diese Ausnahme auf, weiß der Thread, dass er im blockierten Zustand ein interrupt() empfangen hat. Deshalb ist das normale Verhalten, aus dem catch-Block der InterruptedException die run()-Methode mit return zu verlassen bzw. die Endlosschleife des Threads über ein break zu beenden.

Die InterruptedException ist insofern außergewöhnlich, als dass sie keinen Fehler signalisiert, sondern dazu dient, das frühzeitige Zurückkehren einer blockierenden Methode anzuzeigen. Wenn man z. B. sleep(10000) aufruft, um für 10 Sekunden zu schlafen, dann zeigt die InterruptedException an, dass der Thread kürzer als 10 Sekunden geschlafen hat, weil die Methode wegen eines interrupt()-Aufrufs früher zurückgekehrt ist.

public class Interruptible2 implements Runnable {
    public void run() {

        while (true) {
            System.out.println("Thread laeuft");

            if (Thread.currentThread().isInterrupted()) {
                break;
            }
            try {
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                break;
            }
        }
        System.out.println("** Thread gestoppt **");
    }
}

Der Code der main()-Methode ist hier unverändert, d. h. genauso wie auf der letzten Folie dargestellt:

    public static void main(String[] args) throws Exception {
        Interruptible2 interuptible = new Interruptible2();
        Thread thread = new Thread(interuptible);
        thread.start();
        Thread.sleep(100);
        thread.interrupt();
    }
public class Interruptible3 implements Runnable {

    public void run() {

        while (!Thread.currentThread().isInterrupted()) {
            System.out.println("Thread laeuft");

            try {
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        System.out.println("** Thread gestoppt **");
    }
}

Auch hier ist die main-Methode unverändert zum vorhergehenden Beispiel.

Threads vereinigen

  • Häufig möchte man, dass ein Thread erst weiterläuft, wenn andere Threads zu Ende gelaufen sind
  • Mit der Methode join() kann man darauf warten, dass ein Thread fertig ist
    • void join(long millis) – Warte maximal millis Millisekunden (0 = ewig)
    • void join(long millis, int nanos) – Wartet maximal millis Milli- und nanos Nanosekunden
    • void join() – Wartet ewig
  • Alle join()-Methoden sind unterbrechbar (interruptible), d. h. sie kehren mit einer InterruptedException zurück, wenn der Thread der sie aufgerufen hat (nicht der auf den gewartet wird) interrupted wird

Beispiel: Threads vereinigen

class RunnerPrinter implements Runnable {
    String text;
    RunnerPrinter(String text) {
        this.text = text;
    }
    public void run() {
       for (int i = 0; i < 5; i++) {
           System.out.println(text);
           Thread.yield();
       }
       System.out.println(text + " ist fertig.");
    }
}
Thread t1 = new Thread(new RunnerPrinter("Runner 1"));
Thread t2 = new Thread(new RunnerPrinter("Runner 2"));
Thread t3 = new Thread(new RunnerPrinter("Runner 3"));

t1.start(); t2.start(); t3.start();
t1.join(); t2.join(); t3.join();

System.out.println("Alle fertig");
Runner 1
Runner 3
Runner 1
Runner 3
Runner 2
Runner 1 ist fertig.
Runner 3 ist fertig.
Runner 2
Runner 2
Runner 2 ist fertig.
Alle fertig

Prioritäten und Daemon-Threads

  • Man kann Threads Prioritäten zwischen 1 und 10 zuweisen
    • void setPriority(int newPriority)
    • int getPriority()
  • Thread-Prioritäten sind nicht portabel und nicht zuverlässig – die VM sieht sie maximal als Hinweis
  • Mann kann festlegen, was beim Beenden des Main-Threads passieren soll
    • void setDaemon(boolean on)
    • boolean isDaemon()
  • Die VM beendet sich dann, wenn alle Nicht-Daemon-Threads beendet wurden

Beispiel: Daemon-Threads

class DaemonRunner implements Runnable {
    public void run() {
        while (true);
    }
}

public class DaemonDemo {

    public static void main(String[] args) {
        Thread t = new Thread(new DaemonRunner());
        t.setDaemon(true);
        t.start();
    }
}
ublic class DaemonDemo {

    public static void main(String[] args) {
        Thread t = new Thread(() ->  { while (true); });

        t.setDaemon(true);
        t.start();
    }
}

Copyright © 2025 Thomas Smits