Threads in Java

Einführung in Threads

  • Definition und Grundlagen von Threads:
    • Ein Thread ist die kleinste Ausführungseinheit eines Prozesses. Threads teilen sich den gleichen Adressraum des Prozesses, was bedeutet, dass sie gemeinsam auf dessen Daten und Ressourcen zugreifen können.
  • Unterschied zwischen Prozessen und Threads:
    • Ein Prozess ist ein eigenständiges Programm, das in einer eigenen Speicherumgebung läuft. Threads sind "leichte" Prozesse, die innerhalb eines Prozesses laufen. Threads teilen den gleichen Speicher und andere Ressourcen des Prozesses, wodurch eine schnellere Kommunikation und ein geringerer Overhead erreicht werden.
  • Vorteile und Herausforderungen:
    • Vorteile: Erhöhung der Anwendungsleistung durch parallele Ausführung, bessere Ressourcenausnutzung und schnellere Reaktionsfähigkeit.
    • Herausforderungen: Synchronisationsprobleme wie Race Conditions und Deadlocks, komplexere Fehlerbehandlung und erhöhte Anfälligkeit für nicht-deterministische Fehler.
  • Thread-Zustände: (Details später)
    • Unstarted: Der Thread wurde erstellt, aber noch nicht gestartet.
    • Running: Der Thread wird ausgeführt.
    • Waiting/Blocked: Der Thread wartet auf eine Ressource oder eine andere Bedingung, um fortzufahren.
    • Sleeping: Der Thread wurde absichtlich pausiert (z.B. durch Thread.sleep()).
    • Stopped/Terminated: Der Thread hat seine Ausführung beendet.
  • Context und Context Switch:
    • Der Kontext eines Threads umfasst seinen aktuellen Zustand, Registerwerte, den Program Counter, den Stack, etc. Der Kontext muss gespeichert und wiederhergestellt werden, wenn ein anderer Thread ausgeführt wird.
    • Der Wechsel der Ausführung von einem Thread zu einem anderen. Der Scheduler des Betriebssystems speichert den Kontext des aktuellen Threads und lädt den Kontext des nächsten Threads. Context Switches sind teuer in Bezug auf die Leistung.

Threads in Java

Erweiterung der Thread-Klasse

  1. Erweitern der Thread-Klasse:
public class MyThread extends Thread {
    public void run() {
        // Code für den Thread
    }
}
  1. Erstellen und Starten eines Threads:
MyThread myThread = new MyThread();
myThread.start();

Implementierung des Runnable-Interfaces

  1. Implementieren des Runnable-Interfaces:
public class MyRunnable implements Runnable {
    public void run() {
        // Code für den Thread
    }
}
  1. Erstellen und Starten eines Threads:
MyRunnable myRunnable = new MyRunnable();
Thread myThread = new Thread(myRunnable);
myThread.start();

Anonyme Klassen und Lambda-Ausdrücke

  1. Verwendung anonymer Klassen:
Thread myThread = new Thread(new Runnable() {
    public void run() {
        // Code für den Thread
    }
});
myThread.start();
  1. Verwendung von Lambda-Ausdrücken:
Thread myThread = new Thread(() -> {
    // Code für den Thread
});
myThread.start();
  • Thread-Lebenszyklus:
    • Erstellung: Ein Thread wird erstellt, aber noch nicht gestartet.
    • Start: Der Thread wird gestartet und beginnt seine Ausführung.
    • Ausführung: Der Thread führt seinen Code aus.
    • Warten: Der Thread wartet auf eine Ressource oder eine andere Bedingung.
    • Beendigung: Der Thread beendet seine Ausführung.
    • Abbruch: Der Thread wird abgebrochen
    • Join: Ein Thread wartet auf die Beendigung eines anderen Threads.
    • Sleep: Der Thread wird für eine bestimmte Zeit angehalten.
    • Yield: Der Thread gibt die CPU frei, um anderen Threads die Ausführung zu ermöglichen.
    • Interrupt: Ein Thread wird unterbrochen.
    • Suspend/Resume: Ein Thread wird angehalten und fortgesetzt.

Synchronisation von Threads

  • Race Conditions und Deadlocks:
    • Race Conditions: Tritt auf, wenn mehrere Threads gleichzeitig auf gemeinsame Ressourcen zugreifen und das Endergebnis von der Reihenfolge der Zugriffe abhängt.
    • Deadlocks: Eine Situation, in der zwei oder mehr Threads für immer auf Ressourcen warten, die von den anderen gehalten werden, wodurch alle betroffenen Threads blockiert werden.
  • Demonstration Race Condition:
    • Beispiel: Zwei Threads erhöhen eine gemeinsame Zählervariable.
    • Problem: Wenn die Zählervariable nicht atomar erhöht wird, kann es zu inkonsistenten Ergebnissen kommen.
    • Lösung: Verwendung von Synchronisationsmechanismen wie Locks oder Monitoren, um kritische Abschnitte zu schützen.
class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}
Counter counter = new Counter();
// Thread 1 und Thread 2 erhöhen den Zähler
Thread t1 = new Thread(() -> {
    for(int i = 0; i < 10000; i++) {
        counter.increment();
    }
});
Thread t2 = new Thread(() -> {
    for(int i = 0; i < 10000; i++) {
        counter.increment();
    }
});

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

t1.join();
t2.join();

System.out.println("Erwartetes Ergebnis: 20000");
System.out.println("Tatsächliches Ergebnis: " + counter.getCount());
$ java deadlocks.simple.Counter
Erwartetes Ergebnis: 20000
Tatsächliches Ergebnis: 14966
$
  • Synchronisation in Java mit synchronized (Monitoren):

    • synchronized-Schlüsselwort: Schützt einen kritischen Abschnitt vor gleichzeitigen Zugriffen durch mehrere Threads.
    • synchronized-Methoden: Methoden können als synchronized deklariert werden, um den gesamten Methodenblock zu schützen.
    • synchronized-Blöcke: Einzelne Codeblöcke können mit synchronized geschützt werden.
    • wait() und notify(): Methoden zum Warten und Benachrichtigen von Threads in Java.
class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}
  • Lösung mit Atomic Variablen:
    • AtomicInteger: Eine atomare Variable, die Operationen wie increment() und decrement() atomar ausführt.
import java.util.concurrent.atomic.AtomicInteger;
class Counter {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();
    }

    public int getCount() {
        return count.get();
    }
}

Deadlocks

  • Voraussetzungen für Deadlocks:
    • Gegenseitiger Ausschluss: Eine Ressource kann immer nur von einem Thread gleichzeitig genutzt werden.
    • Halten und Warten: Ein Thread, der eine Ressource hält, wartet auf eine zusätzliche Ressource.
    • Keine Präemption: Eine Ressource kann nicht zwangsweise von einem Thread entzogen werden.
    • Zirkuläres Warten: Ein zyklischer Wartegraph, bei dem jeder Thread auf eine Ressource wartet, die von einem anderen gehalten wird.
  • Vermeidung von Deadlocks:
    • Ressourcenreihenfolge: Konsistente Reihenfolge beim Anfordern von Ressourcen verwenden.
    • Ressourcenausbeutung: Sicherstellen, dass ein Thread alle benötigten Ressourcen auf einmal erhält.
    • Timeout-Mechanismen: Zeitbegrenzungen für das Warten auf Ressourcen.
  • Beispiel für Deadlock:
    • Zwei Threads, die auf gegenseitige Ressourcen warten:
public class DeadlockDemo {
    private static Object Resource1 = new Object();
    private static Object Resource2 = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (Resource1) {
                System.out.println("Thread 1: Ressource 1 gesperrt");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (Resource2) {
                    System.out.println("Thread 1: Ressource 2 gesperrt");
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (Resource2) {
                System.out.println("Thread 2: Ressource 2 gesperrt");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (Resource1) {
                    System.out.println("Thread 2: Ressource 1 gesperrt");
                }
            }
        });

        t1.start();
        t2.start();
    }
}
  • Lösung für obiges Beispiel: (Reihenfolge beachten)
public class DeadlockDemo {
    private static Object Resource1 = new Object();
    private static Object Resource2 = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (Resource1) {
                System.out.println("Thread 1: Ressource 1 gesperrt");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (Resource2) {
                    System.out.println("Thread 1: Ressource 2 gesperrt");
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (Resource1) {
                System.out.println("Thread 2: Ressource 1 gesperrt");
                try { Thread.sleep(100); } catch (InterruptedException e) {}
                synchronized (Resource2) {
                    System.out.println("Thread 2: Ressource 2 gesperrt");
                }
            }
        });

        t1.start();
        t2.start();
    }
}
  • Ressource Allocation Graph:
    • Ein Diagramm, das Threads und Ressourcen darstellt und zeigt, welche Ressourcen von welchen Threads gehalten oder angefordert werden. Wird verwendet, um mögliche Deadlocks zu identifizieren. (siehe oben)
  • Deadlocks zur Laufzeit erkennen und auflösen:
    • Erkennung: Analyse-Tools und Algorithmen zur Deadlock-Erkennung können genutzt werden, um Deadlocks zur Laufzeit zu identifizieren.
    • Auflösung: Threads, die Teil eines Deadlocks sind, können abgebrochen oder neu gestartet werden. Eine andere Möglichkeit ist die Freigabe von Ressourcen, um den Deadlock zu beheben.

Philosophenproblem

  • Szenario:
    • Fünf Philosophen sitzen an einem runden Tisch.
    • Jeder Philosoph benötigt zwei Gabeln, um zu essen.
    • Die Gabeln sind zwischen den Philosophen platziert.

-> Live Demo mit Java-Code

Lösung für das Philosophenproblem

  • Lösung:
    • Jeder Philosoph nimmt zuerst die linke Gabel und dann die rechte Gabel.
    • Wenn die rechte Gabel nicht verfügbar ist, legt der Philosoph die linke Gabel zurück und wartet, bevor er es erneut versucht.
    • Dadurch wird der Zyklus unterbrochen und Deadlocks vermieden.
  • Simple Lösung:
    • Ein Philosoph nimmt die Gabel in anderer Reihenfolge:
    • Beispiel: Philosoph 0 nimmt zuerst Gabel 4 und dann Gabel 0.
    • Dadurch wird der Zyklus unterbrochen und Deadlocks vermieden.

Thread-Prioritäten

  • Thread-Prioritäten in Java:
    • Jeder Thread hat eine Priorität, die bestimmt, wie oft der Thread vom Scheduler des Betriebssystems ausgeführt wird.
    • Prioritäten reichen von 1 (niedrigste Priorität) bis 10 (höchste Priorität).
    • Standardpriorität: Thread.NORM_PRIORITY (5).
    • Prioritäten können mit setPriority() festgelegt werden.
    • Höhere Prioritäten bedeuten nicht unbedingt, dass der Thread schneller ausgeführt wird.

wait() und notify() in Java

  • wait(): Lässt den aktuellen Thread warten, bis er benachrichtigt (notify()) oder unterbrochen wird.
  • notify(): Weckt einen wartenden Thread auf, sodass er fortgesetzt werden kann.
  • notifyAll(): Weckt alle wartenden Threads auf.

Beide Methoden gehören zur Klasse Object und müssen innerhalb eines synchronized-Blocks aufgerufen werden. Andernfalls gibt es eine IllegalMonitorStateException.

wait() - warten auf eine Bedingung

synchronized (monitor) {
    while (!condition) {
        try {
            monitor.wait();
        } catch (InterruptedException e) {
            // Behandlung der Unterbrechung
        }
    }
    // Aktionscode, wenn Bedingung erfüllt
}
  • Der Thread gibt die Sperrung des Monitors frei und wartet auf eine Benachrichtigung.
  • Der Thread wird durch notify() oder notifyAll() oder durch eine Unterbrechung geweckt.

notify() - Benachrichtigung eines wartenden Threads

synchronized (monitor) {
    condition = true;
    monitor.notify();
}
  • Der Thread setzt die Bedingung auf true und benachrichtigt einen wartenden Thread.

notifyAll() - Benachrichtigung aller wartenden Threads

synchronized (monitor) {
    condition = true;
    monitor.notifyAll();
}
  • Der Thread setzt die Bedingung auf true und benachrichtigt alle wartenden Threads.
  • Alle Threads werden geweckt und können ihre Aktionen fortsetzen.
  • Daher sollte beim Warten auf die Bedingung eine Schleife verwendet werden.

Weitere Synchronisationsmöglichkeiten in Java

  • ReentrantLock: Feinere Kontrolle über Sperren
  • ReadWriteLock: Effizient für viele Lesezugriffe
  • Semaphore: Begrenzung gleichzeitiger Zugriffe
  • CountDownLatch: Wartet auf eine Gruppe von Threads
  • CyclicBarrier: Threads synchronisieren sich regelmäßig
  • Exchanger: Direkter Datenaustausch zwischen zwei Threads

Collections und Threads

  • Nicht-synchronisierte Collections:
    • ArrayList, HashMap, HashSet, etc.
    • Nicht thread-sicher, daher nicht für parallele Zugriffe geeignet (Race Conditions).
    • Verwendung in Single-Thread-Anwendungen oder mit externer Synchronisation.
    • Beispiel: Collections.synchronizedList(new ArrayList<>()).
  • Synchronisierte Collections:
    • Vector, Hashtable, ConcurrentHashMap, CopyOnWriteArrayList, etc.
    • Thread-sicher, für parallele Zugriffe geeignet.
    • Verwendung in Multi-Thread-Anwendungen ohne externe Synchronisation.

Zusammenfassung

  • Threads sind die kleinste Ausführungseinheit eines Prozesses.
  • Thread-Zustände: Unstarted, Running, Waiting/Blocked, Sleeping, Stopped/Terminated.
  • Synchronisation ist erforderlich (Vorsicht! Mögliche Deadlocks).

Ausblick auf fortgeschrittene Themen

  • Thread-Pools: Verwalten und wiederverwenden von Threads.
  • Executor-Framework: Vereinfachte Verwaltung von Threads.
  • Fork-Join-Framework: Parallele Verarbeitung von Aufgaben.
  • Reactive Programming: Asynchrone und ereignisgesteuerte Programmierung.
  • Parallel Streams: Parallele Verarbeitung von Streams.
  • CompletableFuture: Kombination von asynchronen Operationen.
  • Lock-Free Programming: Vermeidung von Synchronisationsmechanismen.
  • Actor Model: Nachrichtenbasierte Kommunikation zwischen Threads.

Einführung in Threads

By Harald Haberstroh

Einführung in Threads

  • 111