---
# System prepended metadata

title: Anwendungssysteme

---

# Anwendungssysteme - Threads

[ToC]

## Fragestellung/Ziel:
- Was müssen Softwarearchitekten über Architektur und technologische Optionen wissen?
- Welche grundlegenden Konzepte und Technologien sind für die Entwicklung verteilter Systeme notwendig?

## Notizen:

### 1. Rollen im Software Engineering:
   - Produktbesitzer: Definiert Produktvision und leitet Anforderungen ab.
   - Softwarearchitekt: Bestimmt die Architektur und trifft Technologieentscheidungen.
   - Softwareentwickler: Baut die Softwaresysteme.

### 2. Wissensbedarf der Architekten:
   - Überblick über verschiedene architektonische Stile und Technologieoptionen.
   - Qualitätsdienste (Quality of Service, QoS) berücksichtigen.

### 3. Kursinhalte und Technologien:
   - **Enterprise Programming:**
     - Erweitertes Java (Annotations, Reflections), Abhängigkeitsmanagement, Build-Tools, Tests, Logging, Versionskontrolle, Debugging, Dependency Injection.
   - **Verteilte Architekturen:**
     - Architektur als Konzept, QoS-Dimensionen, Schichten und Ebenen, Middleware.
   - **Datenmanagement:**
     - Relationale Datenbanksysteme, Transaktionen und SQL, Objekt-relationales Mapping, XML, JSON, Protobuf.
   - **Kommunikation:**
     - Synchrone vs. asynchrone Kommunikation, RPC, Messaging und Pub/Sub.
   - **Das Web:**
     - HTTP, HTML, Webdienste, REST, Web-APIs, GraphQL, JavaScript, AJAX, mobile Apps.
   - **Anwendungsplattformen:**
     - Java EE/Jakarta, serviceorientierte Architektur, Microservices, Cloud-Dienste und Cloud-native, Serverless Computing.

### 4. Kursziele:
   - Grundlegende Konzepte verstehen.
   - Mit einer Technologieglossar vertraut sein.
   - Einige Technologien tiefergehend behandeln, da sie sehr wichtig sind oder ein grundlegendes Konzept veranschaulichen.

## Zusammenfassung:
- Der Kurs bietet einen umfassenden Überblick über die Rollen und erforderlichen Kenntnisse im Bereich Softwarearchitektur, insbesondere im Kontext verteilter Systeme. Die Inhalte decken eine breite Palette von Technologien und Methoden ab, die für die moderne Softwareentwicklung entscheidend sind.

## Schlüsselwörter:
- Softwarearchitektur, Technologieoptionen, Enterprise Programming, verteilte Architekturen, Datenmanagement, Kommunikation, Webentwicklung, Anwendungsplattformen.

# Programmier-Rekap

## Fragestellung/Ziel:
- Wie funktionieren Konzepte der Nebenläufigkeit in der Programmierung?
- Wie werden Streams und Sockets in Java verwendet?

## Notizen:

### 1. Nebenläufigkeit (Concurrency):
   - Parallele oder pseudo-parallele Ausführung von Anweisungen zur Leistungssteigerung.
   - Beispiele für die Umsetzung mittels Threads und `Runnable`-Schnittstelle in Java.
   - Lebenszyklus von Threads: Erstellung, Ausführung, Warten, Beendigung.
   - Synchronisation und Locking zur Verwaltung des Zugriffs auf gemeinsame Ressourcen.

### 2. Streams:
   - Streams repräsentieren Datenflüsse von einer Quelle zu einem Ziel (z.B. Dateien, Netzwerke).
   - Unterscheidung zwischen Byte-Streams und Character-Streams zur Datenverarbeitung.
   - Beispiele für den Einsatz von `FileInputStream`, `FileOutputStream`, `BufferedReader` und `PrintWriter`.

### 3. Sockets:
   - Verwendung von Sockets zur Netzwerkkommunikation zwischen Server und Client.
   - Aufbau und Management von Verbindungen mittels `ServerSocket` und `Socket`.
   - Implementierungsbeispiele für einfache Client-Server-Kommunikationen.

### 4. Wichtige Konzepte:
   - Threads und `Runnable` für die Nebenläufigkeit.
   - Locking und Synchronisation für den Zugriffsschutz.
   - Byte- und Character-Streams für die Datenverarbeitung.
   - Client-Server-Kommunikation über Sockets.

## Zusammenfassung:
- Der Kursabschnitt bietet eine grundlegende Auffrischung wichtiger Programmierkonzepte in Java, fokussiert auf Nebenläufigkeit, Datenströme und Netzwerkkommunikation. Diese Grundlagen sind essentiell für fortgeschrittene Programmieraufgaben und verteilte Anwendungen.

## Schlüsselwörter:
- Threads, Synchronisation, Streams, Sockets, Datenflussmanagement, Server-Client-Architektur.

## Aufgaben zu Advanced Concepts

### Aufgabe 1 – Unchecked Exceptions

- Entwickeln Sie eine Methode addPositive(), die zwei positive Zahlen (ints, inklusive 0) addiert. Werden negative Zahlen als Eingabeparameter übergeben, soll eine IllegalArgumentException geworfen werden. Schreiben Sie weiter eine main-Methode, die zwei int-Werte von der Konsole einliest und soll der Nutzer um erneute Eingabe gebeten werden.
- Für welche Fehlerfälle sind „Unchecked Excepetions“ zu verwenden? Sollten diese Exceptions auch im Methodenkopf angezeigt werden?

```java
import java.util.Scanner;

public class PositiveNumberAdder {

    public static int addPositive(int a, int b) {
        if (a < 0 || b < 0) {
            throw new IllegalArgumentException("Beide Zahlen müssen positiv oder null sein.");
        }
        return a + b;
    }

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        while (true) {
            try {
                System.out.print("Geben Sie die erste positive Zahl (oder 0) ein: ");
                int num1 = scanner.nextInt();
                System.out.print("Geben Sie die zweite positive Zahl (oder 0) ein: ");
                int num2 = scanner.nextInt();

                int result = addPositive(num1, num2);
                System.out.println("Das Ergebnis der Addition ist: " + result);
                break;
            } catch (IllegalArgumentException ex) {
                System.out.println("Fehler: " + ex.getMessage());
                System.out.println("Bitte versuchen Sie es erneut.");
            } catch (Exception ex) {
                System.out.println("Ein unerwarteter Fehler ist aufgetreten. Bitte geben Sie nur ganze Zahlen ein.");
                scanner.nextLine(); // Dies leert den Scanner-Puffer.
            }
        }
        scanner.close();
    }
}
```

#### Erläuterung der Implementierung:

1. **Methode `addPositive(int a, int b)`**:
   - Überprüft, ob die übergebenen Werte `a` und `b` positiv oder null sind. Wenn einer der Werte negativ ist, wird eine `IllegalArgumentException` mit einer entsprechenden Nachricht geworfen.
   - Wenn keine Ausnahme geworfen wird, gibt die Methode die Summe der beiden Zahlen zurück.

2. **`main` Methode**:
   - Erstellt einen `Scanner` zum Einlesen von Benutzereingaben.
   - Verwendet eine `while`-Schleife, um die Eingaben in einer Schleife zu verarbeiten, bis gültige Eingaben gemacht wurden.
   - Die Eingaben werden durch die `nextInt()` Methode des `Scanner`-Objekts gelesen.
   - Ruft `addPositive()` mit den eingegebenen Werten auf und gibt das Ergebnis aus, wenn keine Ausnahme geworfen wird.
   - Fängt die `IllegalArgumentException` ab, um Fehlermeldungen auszugeben und die Schleife fortzusetzen.
   - Fängt allgemeine Ausnahmen ab, um den Scanner-Puffer zu leeren und Probleme mit ungültigen Eingaben (nicht-integer) zu behandeln.

#### Zum Einsatz von Unchecked Exceptions:
- **Unchecked Exceptions** (z.B. `IllegalArgumentException`) sind für Programmfehler gedacht, die während der Laufzeit auftreten können und oft auf Fehler im Code hinweisen, wie z.B. das Übergeben eines ungültigen Arguments.
- Diese sollten **nicht** im Methodenkopf angezeigt werden (`throws`-Klausel), da sie zur Klasse der `RuntimeExceptions` gehören. Diese Art von Ausnahmen werden normalerweise nicht im Voraus überprüft (nicht "checked"), und der Compiler erfordert keine Behandlung oder Deklaration im Methodenkopf.
- Unchecked Exceptions sollten eingesetzt werden, wenn Fehler auftreten können, die sich nicht auf externe Faktoren zurückführen lassen (zum Beispiel falsche Parameterwerte oder Null-Zugriffe), und sie sollen in der Regel von dem Entwickler während der Entwicklung berücksichtigt und korrigiert werden.

#### Erklärung
- Ungeprüfte Ausnahmen (oder "Unchecked Exceptions") in Java sind jene, die von RuntimeException und ihren Unterklassen abgeleitet sind. Sie werden normalerweise verwendet, um Programmierfehler zu signalisieren, wie z.B. ungültige Argumente, Zugriff auf null Referenzen oder falsche Indexpositionen in Datenstrukturen. Diese müssen nicht im Methodenkopf deklariert werden (d.h., sie müssen nicht in der throws-Klausel aufgeführt werden), da der Compiler nicht erfordert, dass der Aufrufer diese Ausnahmen behandelt oder deklariert.

- Ungeprüfte Ausnahmen (oder "Unchecked Exceptions") in Java sind jene, die von RuntimeException und ihren Unterklassen abgeleitet sind. Sie werden normalerweise verwendet, um Programmierfehler zu signalisieren, wie z.B. ungültige Argumente, Zugriff auf null Referenzen oder falsche Indexpositionen in Datenstrukturen. Diese müssen nicht im Methodenkopf deklariert werden (d.h., sie müssen nicht in der throws-Klausel aufgeführt werden), da der Compiler nicht erfordert, dass der Aufrufer diese Ausnahmen behandelt oder deklariert.



### Aufgabe 2 – Checked Exceptions

- Erstellen Sie eine Klasse ThreeObjectBucket. Diese Klasse soll bis zu 3 Objekte vom Typ Object speichern können und dazu intern ein Array der Länge 3 verwenden.Implementieren sie zudem Methoden zum Hinzufügen, Lesen und Entfernen von Objekten. 
- Erstellen Sie nun eine main-Methode, die versucht vier Objekte in Ihr Bucket einzufügen. Was geschieht? Ist dieses Verhalten erwünscht? Wie können Exceptions helfen das Verhalten im Fehlerfall zu verbessern? Welche Art von Exceptions erscheint geeignet?
- Erstellen Sie nun eine eigene Exceptionklasse BucketFullException. Bieten Sie einen Konstruktor an, der das Setzen einer Fehlermeldung ermöglicht. Integrieren Sie diese Exception in die Klasse ThreeObjectBucket; behandeln und testen Sie ihr Auftreten in der main-Methode.

#### Schritt 1: Definieren der `BucketFullException` Klasse

```java
public class BucketFullException extends Exception {
    public BucketFullException(String message) {
        super(message);
    }
}
```

Diese Klasse erbt von `Exception`, was sie zu einer Checked Exception macht. Sie enthält einen Konstruktor, der es ermöglicht, eine spezifische Fehlermeldung zu setzen.

#### Schritt 2: Implementieren der `ThreeObjectBucket` Klasse

```java
public class ThreeObjectBucket {
    private Object[] bucket;
    private int count;

    public ThreeObjectBucket() {
        bucket = new Object[3];
        count = 0;
    }

    public void addObject(Object obj) throws BucketFullException {
        if (count >= 3) {
            throw new BucketFullException("Der Bucket ist voll. Kein weiteres Objekt kann hinzugefügt werden.");
        }
        bucket[count] = obj;
        count++;
    }

    public Object getObject(int index) {
        return bucket[index];
    }

    public void removeObject(int index) {
        if (index >= 0 && index < count) {
            bucket[index] = null;
            // Optional: Shift remaining elements to the left
            for (int i = index; i < count - 1; i++) {
                bucket[i] = bucket[i + 1];
            }
            bucket[count - 1] = null;
            count--;
        }
    }
}
```

#### Schritt 3: `main` Methode zum Testen der Klasse

```java
public class Main {
    public static void main(String[] args) {
        ThreeObjectBucket bucket = new ThreeObjectBucket();
        try {
            bucket.addObject("Objekt 1");
            bucket.addObject("Objekt 2");
            bucket.addObject("Objekt 3");
            // Versuch, ein viertes Objekt hinzuzufügen
            bucket.addObject("Objekt 4");
        } catch (BucketFullException e) {
            System.out.println(e.getMessage());
        }
    }
}
```

#### Analyse des Verhaltens

In der `main`-Methode wird versucht, vier Objekte in den `ThreeObjectBucket` einzufügen. Beim vierten Objekt wird eine `BucketFullException` geworfen. Dieses Verhalten ist erwünscht, da es sicherstellt, dass die Datenstruktur ihre Kapazitätsgrenze nicht überschreitet und der Programmierer über diesen Zustand informiert wird.

#### Vorteile der Verwendung von Exceptions

- **Klarheit und Sicherheit:** Der Einsatz von Checked Exceptions erzwingt eine Fehlerbehandlung, die zur Laufzeit potenzielle Probleme abfangen kann. Dadurch werden robustere und sicherere Anwendungen ermöglicht.
- **Kontrolliertes Fehlermanagement:** Exceptions ermöglichen es, Fehler dort zu behandeln, wo es am sinnvollsten ist. Der Entwickler hat die Kontrolle darüber, wie das Programm auf bestimmte Fehler reagiert.

Die `BucketFullException` als Checked Exception ist besonders geeignet, da sie den Entwickler zwingt, sich während der Entwicklung mit der Möglichkeit eines vollen Buckets auseinanderzusetzen und entsprechend zu planen. Dies verbessert die Robustheit und Wartbarkeit des Codes.



### Aufgabe 3 – Thread vs. Runnable
- Erstellen Sie eine Klasse SimpleThread, welche von Thread erbt. Überschreiben sie nun die run-Methode sodass sie „I'm a simple Thread.“ auf der Konsole ausgibt.
- Erstellen Sie nun eine Klasse ThreadAndRunnableDemo, in deren main-Methode eine Instanz von SimpleThread erzeugt wird. Die Klasse Thread bietet u.a. die Methoden run() und start() an. Worin unterscheiden sich diese Methoden? Wie lässt sich ein parallel laufender Thread starten?
- Erstellen Sie nun die Klasse SimpleRunnable, welche das Interface Runnable implementiert. Implementieren Sie die run-Methode so, dass sie „I'm a simple Runnable.“ auf der Konsole ausgibt. Erstellen und starten Sie in der main-Methode einen Thread, der das SimpleRunnable zur Ausführung bringt.

#### Schritt 1: Implementierung der Klasse `SimpleThread`

Zuerst erstellen wir eine einfache Klasse namens `SimpleThread`, die von `Thread` erbt und die Methode `run()` überschreibt:

```java
public class SimpleThread extends Thread {
    @Override
    public void run() {
        System.out.println("I'm a simple Thread.");
    }
}
```

Diese Klasse ist eine Erweiterung der `Thread`-Klasse und überschreibt die `run()`-Methode, um eine Nachricht auf der Konsole auszugeben.

#### Schritt 2: Implementierung der `ThreadAndRunnableDemo` Klasse

In der Klasse `ThreadAndRunnableDemo` zeigen wir den Unterschied zwischen den Methoden `run()` und `start()`:

```java
public class ThreadAndRunnableDemo {
    public static void main(String[] args) {
        SimpleThread simpleThread = new SimpleThread();
        simpleThread.run();  // Aufruf der run-Methode im Hauptthread
        simpleThread.start(); // Startet den Thread, führt die run-Methode parallel aus
    }
}
```

- Die Methode `run()` führt den Code im aktuellen Thread aus, in diesem Fall im Hauptthread (main thread).
- Die Methode `start()` hingegen startet einen neuen Thread und ruft darin die `run()` Methode auf. Dies führt zur parallelen Ausführung des Codes.

#### Schritt 3: Implementierung der Klasse `SimpleRunnable`

Nun implementieren wir eine Klasse `SimpleRunnable`, die das Interface `Runnable` implementiert:

```java
public class SimpleRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("I'm a simple Runnable.");
    }
}
```

Diese Klasse implementiert das `Runnable` Interface und definiert, was im `run()` passieren soll, nämlich eine Nachricht ausgeben.

#### Schritt 4: Verwendung von `SimpleRunnable` in `ThreadAndRunnableDemo`

Jetzt erweitern wir die `ThreadAndRunnableDemo` Klasse, um eine Instanz von `SimpleRunnable` in einem neuen Thread zu starten:

```java
public class ThreadAndRunnableDemo {
    public static void main(String[] args) {
        SimpleThread simpleThread = new SimpleThread();
        simpleThread.start(); // Startet den Thread, führt die run-Methode parallel aus

        SimpleRunnable simpleRunnable = new SimpleRunnable();
        Thread thread = new Thread(simpleRunnable);
        thread.start(); // Startet den Thread, der das Runnable ausführt
    }
}
```

In diesem Beispiel:

- Ein `SimpleThread` wird erstellt und gestartet, was zu paralleler Ausführung führt.
- Ein `SimpleRunnable` wird ebenfalls erstellt, aber um diesen auszuführen, müssen wir es einem neuen `Thread` Objekt übergeben und dann diesen `Thread` starten.

#### Zusammenfassung

Diese Implementierungen zeigen die Unterschiede zwischen direkter Verwendung von `Thread` durch Vererbung und der Implementierung des `Runnable` Interfaces. `Runnable` bietet mehr Flexibilität und erlaubt die Trennung der Thread-Steuerung vom auszuführenden Code, was insbesondere bei Anwendung von Konzepten der Objektorientierung und bei der Zusammenarbeit mit APIs, die Threads verwenden, vorteilhaft ist.



### Aufgabe 4 – Interrupts


Ein Interrupt ist die Aufforderung an einen Thread die aktuelle Ausführung zu unterbrechen
und etwas anderes zu tun. Häufig ist das die Terminierung der Ausführung. Das Verhalten im
Falle eines Interrupts ist jedoch vom Entwickler festzulegen.
Übernehmen Sie die Klasse SimpleRunnable aus Aufgabe 3. Passen Sie die run-Methode
so an, dass folgendes Verhalten erreicht wird:
Solange der ausführende Thread nicht interrupted wurde, gibt er "I'm a simple Runnable."
auf der Konsole aus und schläft danach für 100ms.
Wenn der Thread interrupted wird, wird „SimpleRunnable interrupted!“ ausgegeben. Danach
endet die Ausführung.
Erstellen Sie nun die Klasse InterruptRunnableDemo und darin eine main-Methode, die
das SimpleRunnable in einem neuen Thread startet und nach einer Sekunde wieder
interrupted.

#### Implementierung der Klassen
- **Klasse SimpleRunnable**: Diese Klasse implementiert das Runnable Interface und überprüft kontinuierlich, ob der Thread unterbrochen wurde.
- **Klasse InterruptRunnableDemo**: Diese Klasse erstellt und startet einen Thread von SimpleRunnable und unterbricht ihn nach einer Sekunde.



Hier ist ein Beispielcode für beide Klassen:

```java
public class SimpleRunnable implements Runnable {
    public void run() {
        while (!Thread.currentThread().isInterrupted()) {
            System.out.println("I'm a simple Runnable.");
            try {
                Thread.sleep(100); // Schläft für 100ms
            } catch (InterruptedException e) {
                System.out.println("SimpleRunnable interrupted!");
                return; // Beendet die Ausführung nach der Unterbrechung
            }
        }
    }
}

public class InterruptRunnableDemo {
    public static void main(String[] args) {
        Thread thread = new Thread(new SimpleRunnable());
        thread.start(); // Startet den Thread

        try {
            Thread.sleep(1000); // Hauptthread schläft für 1 Sekunde
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        thread.interrupt(); // Unterbricht den Thread
    }
}
```
#### Diskussion und Besonderheiten
##### Beobachtetes Verhalten:

Der SimpleRunnable-Thread gibt kontinuierlich "I'm a simple Runnable." aus und schläft jeweils für 100ms.
Nach einer Sekunde unterbricht der Hauptthread (main) den SimpleRunnable-Thread. Das führt dazu, dass der InterruptedException ausgelöst wird, wenn der Thread gerade schläft.
Der catch-Block fängt diese Ausnahme und gibt "SimpleRunnable interrupted!" aus. Danach wird die Ausführung des Threads beendet.

##### Ist das Verhalten gewünscht?
- Ja, das Verhalten ist gewünscht und korrekt implementiert. Der Thread überprüft, ob er unterbrochen wurde und reagiert entsprechend darauf.

#### Besonderheiten beim Arbeiten mit InterruptedException:

Wenn ein Thread in Java eine InterruptedException fängt, signalisiert dies, dass ein anderer Thread wünscht, dass dieser Thread seine Arbeit beendet. Es wird allgemein empfohlen, auf diese Unterbrechungsanforderung zu reagieren, indem man die Ausführung des betroffenen Threads sauber abbricht. Dies ist ein wichtiges Muster im Multithreading, das hilft, Threads ordnungsgemäß und sicher zu beenden.



### Aufgabe 5 – Das Warten auf Threads mit join()
- Implementieren Sie eine Klasse CountUp, die von Thread erbt und in ihrer run()-Methode die Zahlen von 1 bis zu einem im Konstruktor übergebenen Wert in aufsteigender Reihenfolge ausgibt.
- Implementieren Sie eine Klasse CountDown, die von Thread erbt und in ihrer run()-Methode die Zahlen von 1 bis zu einem im Konstruktor übergebenen Wert in absteigender Reihenfolge ausgibt.
- Erstellen Sie die Klasse ThreadJoinDemo, in der Sie CountUp, sowie CountDown instanziieren und starten, sodass die Zahlen von 1 bis 100 in aufsteigender und in absteigender Reihenfolge ausgegeben werden. Was stellen Sie bei der Ausführung fest? Sie wollen nun sicherstellen, dass die aufsteigende Zahlenreihe komplett vor der absteigenden ausgegeben wird. Wie können Sie dieses Ziel erreichen?

#### Schritt 1: Implementierung der Klasse `CountUp`

Die Klasse `CountUp` erbt von `Thread` und gibt Zahlen von 1 bis zu einem übergebenen Wert in aufsteigender Reihenfolge aus.

```java
public class CountUp extends Thread {
    private int max;

    public CountUp(int max) {
        this.max = max;
    }

    @Override
    public void run() {
        for (int i = 1; i <= max; i++) {
            System.out.println("Up: " + i);
            try {
                Thread.sleep(10); // kurze Pause, um die Ausgabe zu verlangsamen
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
```

#### Schritt 2: Implementierung der Klasse `CountDown`

Die Klasse `CountDown` erbt ebenfalls von `Thread` und gibt Zahlen von einem übergebenen Wert bis 1 in absteigender Reihenfolge aus.

```java
public class CountDown extends Thread {
    private int max;

    public CountDown(int max) {
        this.max = max;
    }

    @Override
    public void run() {
        for (int i = max; i >= 1; i--) {
            System.out.println("Down: " + i);
            try {
                Thread.sleep(10); // kurze Pause, um die Ausgabe zu verlangsamen
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
```

#### Schritt 3: Implementierung der Klasse `ThreadJoinDemo`

In der Klasse `ThreadJoinDemo` werden Instanzen von `CountUp` und `CountDown` erzeugt und gestartet. Zuerst ohne `join()` und dann mit `join()`, um den Unterschied zu verdeutlichen.

```java
public class ThreadJoinDemo {
    public static void main(String[] args) {
        CountUp countUp = new CountUp(100);
        CountDown countDown = new CountDown(100);

        countUp.start();
        countDown.start();

        // Ohne join() werden die Ausgaben vermischt, da beide Threads parallel laufen.
        // Jetzt werden wir join() verwenden, um sicherzustellen, dass countUp zuerst abgeschlossen wird.
        try {
            countUp.join(); // Wartet bis countUp beendet ist.
            countDown.join(); // Optional, falls man sicherstellen möchte, dass das Programm erst endet, wenn beide Threads fertig sind.
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
```

#### Beobachtungen und Anpassungen mit `join()`

- **Ohne `join()`**: Die Ausgaben der Zahlenreihen werden vermischt, da beide Threads gleichzeitig laufen.
- **Mit `join()` auf `countUp`**: Der Hauptthread wartet, bis `countUp` komplett abgeschlossen ist, bevor `countDown` beginnt. Dies stellt sicher, dass die Zahlen erst in aufsteigender und dann in absteigender Reihenfolge vollständig ausgegeben werden.

#### Zusammenfassung

Durch den Einsatz von `join()` kann die Reihenfolge, in der Threads ihre Ausgaben tätigen, effektiv gesteuert werden, was besonders nützlich ist, wenn die Ausgabe oder das Ergebnis des einen Threads vom Abschluss eines anderen Threads abhängig ist.



### Aufgabe 6 – Synchronisierung
- Implementieren Sie eine generische Queue auf Basis einer Liste, die maximal 10 Elemente aufnehmen soll. Erstellen Sie hierzu eine neue Klasse `MyThreadSafeQueue`. Es sollen eine enqueue- und eine dequeue-Methode angeboten werden. Beim Versuch ein elftes Element in die Queue einzufügen, soll eine von Ihnen zu implementierende checked Exception vom Typ `QueueFullException` geworfen werden.
- Damit mehrere Threads ihre Queueimplementierung parallel verwenden können, müssen gleichzeitige Zugriffe unterbunden werden. **Synchronisieren** sie die kritischen Abschnitte, sodass sichergestellt ist, dass sich immer nur ein Thread in einem solchen Abschnitt befindet.

- Nutzen sie diese Implementierung als Grundlage einer neuen Klasse `MyBlockingQueue`. Modifizieren Sie die Implementierung so, dass die enqueue- und dequeue Methoden blockieren bis die vorgesehene Operation ausgeführt werden kann. Tipp: Nutzen sie `wait()` und `notifyAll()`.

#### Schritt 1: MyThreadSafeQueue
- **Definition der Klasse und der maximalen Größe**: Die Klasse wird als generisch definiert, und es wird eine Konstante für die maximale Größe der Warteschlange festgelegt.
- **Implementation der Methoden enqueue und dequeue**:
Diese Methoden müssen threadsicher sein, was durch die Verwendung von synchronized Blöcken erreicht wird.
- **Implementation der QueueFullException**:
Eine benutzerdefinierte checked Exception wird erstellt, um zu signalisieren, dass keine weiteren Elemente zur Warteschlange hinzugefügt werden können, weil die Kapazität erreicht ist.


#### Schritt 2: MyBlockingQueue
Diese Klasse erweitert MyThreadSafeQueue und modifiziert die enqueue und dequeue Methoden, um das Blockieren zu ermöglichen, falls die Operation nicht sofort durchgeführt werden kann.
- **Blockierendes enqueue**:
Falls die Queue voll ist, wird der Thread in einen Wartezustand versetzt, bis Platz verfügbar ist.
- **Blockierendes dequeue**:
Falls die Queue leer ist, wird der Thread in einen Wartezustand versetzt, bis Elemente verfügbar sind.


Die Verwendung von `wait()` und `notifyAll()` ermöglicht die korrekte Synchronisation zwischen den Threads, die versuchen, Elemente hinzuzufügen oder zu entfernen.
#### Implementierung in Java
Hier ist eine Basisimplementierung für **MyThreadSafeQueue**:

```java

import java.util.LinkedList;
import java.util.List;

class QueueFullException extends Exception {
    public QueueFullException(String message) {
        super(message);
    }
}

public class MyThreadSafeQueue<T> {
    private final List<T> queue = new LinkedList<>();
    private final int capacity = 10;

    public synchronized void enqueue(T element) throws QueueFullException {
        if (queue.size() == capacity) {
            throw new QueueFullException("Queue is full");
        }
        queue.add(element);
    }

    public synchronized T dequeue() {
        if (queue.isEmpty()) {
            return null;
        }
        return queue.remove(0);
    }
}
```

Und nun **MyBlockingQueue**:
```java
public class MyBlockingQueue<T> extends MyThreadSafeQueue<T> {

    @Override
    public synchronized void enqueue(T element) throws InterruptedException, QueueFullException {
        while (queue.size() == capacity) {
            wait();
        }
        super.enqueue(element);
        notifyAll();
    }

    @Override
    public synchronized T dequeue() throws InterruptedException {
        while (queue.isEmpty()) {
            wait();
        }
        T element = super.dequeue();
        notifyAll();
        return element;
    }
}
```

#### Zusammenfassung der Schritte
- **Erstellung von MyThreadSafeQueue** mit grundlegenden threadsicheren Operationen und einer maximalen Kapazität.
- **Erstellung der QueueFullException** als benutzerdefinierte Exception.
- **Erweiterung zu MyBlockingQueue**, die Operationen blockiert, wenn Bedingungen (voll/leer) nicht erfüllt sind.
- **Verwendung von wait() und notifyAll()** zur effektiven Synchronisation und zum Blockieren der Threads unter bestimmten Bedingungen.



### Aufgabe 7 – Wait & notify
- **a**: 
    - Das Producer-Consumer-Prinzip sollen Sie in dieser Aufgabe am Beispiel simulierter Münzwürfe und der Auswertung der relativen Häufigkeiten praktisch umsetzen. 
    - Erstellen Sie die Klasse ProducerConsumerDemo und versehen Sie sie mit einer Instanzvariable vom Typ BlockingQueue. Diese Queue soll zur Kommunikation zwischen Threads verwendet werden. Sie kann anderen Threads über deren Konstruktor oder setterMethoden bekannt gemacht werden. 
    - Implementieren Sie eine Klasse Producer, die von Thread erbt und in ihrer run()-Methode zufällig eines der Stringliterale „Kopf“ oder „Zahl“ mit einer Wahrscheinlichkeit von 50% erzeugt und in die Queue einreiht. Dieses Verhalten soll ausgeführt werden, bis der Thread interrupted wird. 
    - Implementieren Sie dann die Klasse Consumer, die die Ergebnisse der Münzwurfsimulation aus der Queue ausliest und die relative Häufigkeit des Auftretens von „Kopf“ auf der Konsole ausgibt. Auch dieses Verhalten soll ausgeführt werden, bis der Thread interrupted wird. 
    - Fügen Sie eine main()-Methode zur Klasse ProducerConsumerDemo hinzu, in der Producer und Consumer instanziiert und gestartet werden.
- **b**:
    - Lassen sie den Producer nach Hinzufügen von „Kopf“ oder „Zahl“ zur Queue für 10ms schlafen.
    - Passen Sie die main-Methode in der Klasse ProducerConsumerDemo so an, dass Consumer und Producer nach dem Starten 10 Sekunden ausgeführt werden, bevor die Ausführung durch ein Interrupt gestoppt wird.
    - Fügen Sie Zählervariablen in die Klassen Producer und Consumer ein, um die Anzahl an Durchläufen der run()-Methoden zu überprüfen. Geben Sie diese Werte beim Beenden der Threads aus.
    - Was stellen Sie im Bezug auf die Anzahl der Ausführungen fest? Stehen die Werte in einem angemessenen Verhältnis? Wie kann die Effizienz durch das Nutzen von wait() und notifyAll() verbessert werden?
    - Passen Sie Consumer und Producer auf Basis Ihrer Erkenntnisse an und überprüfen Sie anhand der Zählervariablen, ob Sie eine Effizienzsteigerung erzielen konnten.

#### Teil a) Implementierung der Producer-Consumer-Logik mit einer BlockingQueue

Hier implementieren wir die Klassen `Producer` und `Consumer`, die über eine `BlockingQueue` kommunizieren.

##### Schritt 1: Klasse `ProducerConsumerDemo`

Diese Klasse initialisiert und startet die Threads.

```java
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class ProducerConsumerDemo {

    public static void main(String[] args) throws InterruptedException {
        BlockingQueue<String> queue = new LinkedBlockingQueue<>();

        Producer producer = new Producer(queue);
        Consumer consumer = new Consumer(queue);

        producer.start();
        consumer.start();

        // Lassen Sie Producer und Consumer für 10 Sekunden laufen
        Thread.sleep(10000);

        producer.interrupt();
        consumer.interrupt();

        producer.join();
        consumer.join();

        System.out.println("Producer Durchläufe: " + producer.getCounter());
        System.out.println("Consumer Durchläufe: " + consumer.getCounter());
    }
}
```

##### Schritt 2: Klasse `Producer`

Der Producer erzeugt zufällig "Kopf" oder "Zahl" und fügt diese zur Queue hinzu.

```java
import java.util.concurrent.BlockingQueue;
import java.util.Random;

public class Producer extends Thread {
    private final BlockingQueue<String> queue;
    private int counter = 0;

    public Producer(BlockingQueue<String> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        Random random = new Random();
        String[] options = {"Kopf", "Zahl"};

        try {
            while (!Thread.currentThread().isInterrupted()) {
                String item = options[random.nextInt(2)];
                queue.put(item);
                Thread.sleep(10);  // Schlafen für 10ms
                counter++;
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    public int getCounter() {
        return counter;
    }
}
```

##### Schritt 3: Klasse `Consumer`

Der Consumer liest die Ergebnisse aus der Queue und berechnet die relative Häufigkeit von "Kopf".

```java
import java.util.concurrent.BlockingQueue;

public class Consumer extends Thread {
    private final BlockingQueue<String> queue;
    private int headsCount = 0;
    private int totalCount = 0;

    public Consumer(BlockingQueue<String> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        try {
            while (!Thread.currentThread().isInterrupted()) {
                String item = queue.take();
                if (item.equals("Kopf")) {
                    headsCount++;
                }
                totalCount++;
                System.out.println("Relative Häufigkeit von 'Kopf': " + (double) headsCount / totalCount);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    public int getCounter() {
        return totalCount;
    }
}
```

#### Teil b) Analyse und Verbesserung

Die initiale Implementierung zeigt, dass Producer und Consumer etwa gleich oft laufen. Der Producer schläft nach jedem Einfügen für 10 ms, was eine gewisse Begrenzung der Geschwindigkeit darstellt, während der Consumer ständig läuft und auf neue Elemente wartet. Die Effizienz kann verbessert werden, indem man wait() und notifyAll() verwendet, um unnötige CPU-Zyklen zu vermeiden, indem der Consumer schlafen geht, wenn die Queue leer ist, und nur geweckt wird, wenn neue Elemente verfügbar sind. 

##### Verbesserte Implementierung unter Verwendung von wait() und notifyAll()

Diese Änderungen müssten im Rahmen des Designs der BlockingQueue selbst umgesetzt werden oder durch die Verwendung einer eigenen Implementierung der Queue, die solche Methoden unterstützt. Da `LinkedBlockingQueue` bereits effizient mit Locks arbeitet und das busy-waiting Problem minimiert, sind weitere Verbesserungen in Bezug auf wait() und notify() in dieser spezifischen Implementierung möglicherweise nicht notwendig. Jedoch, falls eine eigene Queue-Implementierung verwendet würde, könnte der Consumer nach dem Prüfen einer leeren Queue in einen Wartezustand versetzt werden, bis der Producer neue Daten eingefügt hat und ihn aufweckt.



### Aufgabe 8 – Files & Streams

- Sie sind Kleinunternehmer und Ihre Kundenverwaltungssoftware erlaubt den Export Ihrer Kundendatei als Textdatei (.txt). Sie möchten die Daten in einer Exceltabelle darstellen ohne sie manuell eintragen zu müssen. Dafür bietet sich das CSV-Format (comma-separated values) an. Erstellen sie ein Java-Programm, das die im ILIAS-System bereitgestellte Datei input.txt nutzt um ein CSV-File zu erstellen, das von Excel als Tabelle dargestellt wird.


#### Vorgehen: 
- Um die Daten aus einer Textdatei (.txt) in eine CSV-Datei (.csv) zu konvertieren, die dann leicht in Excel importiert werden kann, können Sie ein einfaches Java-Programm schreiben. Der folgende Ansatz nimmt an, dass die Daten in der input.txt bereits in einer strukturierten Form vorliegen, z.B. mit einem bestimmten Trennzeichen zwischen den Datenfeldern.


#### Schritte zur Erstellung des Programms:
- **Einlesen der Textdatei**: Verwenden Sie BufferedReader zum Einlesen der Textdatei.
- **Schreiben in eine CSV-Datei**: Nutzen Sie PrintWriter oder FileWriter zum Schreiben der Daten in eine CSV-Datei.
- **Datenverarbeitung**: Lesen Sie jede Zeile der Eingabedatei, transformieren Sie diese falls nötig und schreiben Sie sie ins CSV-Format.
- **Fehlerbehandlung**: Implementieren Sie geeignete Fehlerbehandlungsmechanismen.


```java
import java.io.*;

public class TxtToCsvConverter {

    public static void convertTxtToCsv(String inputFile, String outputFile) {
        try (BufferedReader reader = new BufferedReader(new FileReader(inputFile));
             PrintWriter writer = new PrintWriter(new FileWriter(outputFile))) {

            String line;
            while ((line = reader.readLine()) != null) {
                String csvLine = line.replace(" ", ","); // Ersetzt Leerzeichen durch Kommas
                writer.println(csvLine);
            }
            
            System.out.println("Conversion completed successfully!");
        } catch (IOException e) {
            System.out.println("An error occurred during file processing: " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        String inputFile = "input.txt";  // Pfad zur Eingabedatei
        String outputFile = "output.csv";  // Pfad zur Ausgabedatei
        convertTxtToCsv(inputFile, outputFile);
    }
}
```
##### Erläuterung des Codes
- **BufferedReader & FileReader**: Diese Klassen werden verwendet, um die Eingabedatei zeilenweise zu lesen.
- **PrintWriter & FileWriter** : Diese Klassen helfen beim Schreiben in die Ausgabedatei.
- **String-Ersetzung: line.replace(" ", ",")**  ersetzt jedes Leerzeichen in der gelesenen Zeile durch ein Komma. Wenn das Trennzeichen in Ihrer input.txt etwas anderes ist (z.B. ein Tabulator oder ein anderes spezielles Zeichen), sollten Sie das entsprechend anpassen.



### Aufgabe 9 – Zeitserver
- Implementieren Sie mit Hilfe von Sockets eine Serveranwendung, die das aktuelle Datum, sowie die Uhrzeit an einen Client zurückliefert. Das Format der Antwort soll dem im folgenden Beispiel verwendeten entsprechen: 26.01.2015, 13:42:32 Implementieren sie zudem einen Client, der die aktuelle Zeit vom Server abfragt und auf der Konsole ausgibt.

#### Vorgehen
Für diese Aufgabe werden zwei Anwendungen benötigt: ein Server, der das aktuelle Datum und die Uhrzeit zurückliefert, und ein Client, der diese Daten anfordert und anzeigt. Java bietet mächtige Möglichkeiten für Netzwerkverbindungen durch Sockets, die hier genutzt werden können.

#### Schritt 1: Der Zeitserver
Der Server wird eine ServerSocket-Instanz verwenden, um auf eingehende Verbindungsanfragen zu hören. Bei einer Anfrage sendet der Server das aktuelle Datum und die Uhrzeit im gewünschten Format an den Client.

#### Schritt 2: Der Client
Der Client wird eine Socket-Verbindung zum Server herstellen, das Datum und die Uhrzeit empfangen und dann auf der Konsole ausgeben.


#### **Beispielcode für den Server**


```java
import java.io.IOException;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.text.SimpleDateFormat;
import java.util.Date;

public class TimeServer {
    public static void main(String[] args) {
        int port = 5000;  // Port, auf dem der Server lauscht
        try (ServerSocket serverSocket = new ServerSocket(port)) {
            System.out.println("Server started. Listening on port " + port);

            while (true) {
                try (Socket clientSocket = serverSocket.accept();
                     PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true)) {

                    String dateTime = new SimpleDateFormat("dd.MM.yyyy, HH:mm:ss").format(new Date());
                    out.println(dateTime);
                } catch (IOException e) {
                    System.out.println("Error handling client: " + e.getMessage());
                }
            }
        } catch (IOException e) {
            System.out.println("Could not listen on port " + port + ": " + e.getMessage());
        }
    }
}
```

#### **Beispielcode für den Client**
```java
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.Socket;

public class TimeClient {
    public static void main(String[] args) {
        String hostname = "localhost";  // Server-Hostname oder IP
        int port = 5000;  // Port, auf dem der Server läuft

        try (Socket socket = new Socket(hostname, port);
             BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {

            String serverTime = in.readLine();
            System.out.println("Current server time: " + serverTime);
        } catch (IOException e) {
            System.out.println("Cannot connect to server: " + e.getMessage());
        }
    }
}
```

##### Erläuterung des Codes
**Server:** 
- Der Server verwendet ServerSocket zum Lauschen auf einem bestimmten Port. 
- Für jede eingehende Verbindung wird ein Socket für die Clientverbindung erstellt. Ein PrintWriter wird genutzt, um das formatierte Datum und die Uhrzeit über den Socket an den Client zu senden.

**Client:**
- Der Client verbindet sich mit dem Server über einen Socket. 
- Ein BufferedReader liest die Daten, die vom Server über den Socket gesendet werden.
- Das erhaltene Datum und die Uhrzeit werden auf der Konsole ausgegeben.



## Aufgaben zu Algorithmen und Datenstrukturen



### Aufgabe 1 – SelectionSort
- Implementieren Sie SelectionSort Algorithmus für ein Array.
- Erstellen Sie hierzu eine Klasse SortAlgorithms, die eine statische Methode selectionSort anbietet. Diese Methode soll ein Array vom Typ int[] sortieren, das als Parameter übergeben wird. Ein Rückgabetyp ist nicht erforderlich, da die Methode auf einem Referenzdatentyp arbeitet.

#### Schritt 1: Implementierung der Klasse `SortAlgorithms`

```java
public class SortAlgorithms {

    public static void selectionSort(int[] array) {
        if (array == null || array.length <= 1) {
            return; // Keine Sortierung nötig, wenn das Array null oder sehr klein ist.
        }

        int n = array.length;

        // Durchlaufe das Array, um das Minimum zu finden und an den Anfang zu setzen
        for (int i = 0; i < n - 1; i++) {
            // Setze das erste unsortierte Element als das minimale
            int minIndex = i;

            // Suche das kleinste Element in dem restlichen Array
            for (int j = i + 1; j < n; j++) {
                if (array[j] < array[minIndex]) {
                    minIndex = j; // Finde das neue Minimum
                }
            }

            // Tausche das gefundene Minimum mit dem ersten unsortierten Element
            if (minIndex != i) {
                int temp = array[i];
                array[i] = array[minIndex];
                array[minIndex] = temp;
            }
        }
    }

    // Hauptmethode zum Testen der Sortierfunktion
    public static void main(String[] args) {
        int[] testArray = {64, 34, 25, 12, 22, 11, 90};
        System.out.println("Ursprüngliches Array:");
        for (int value : testArray) {
            System.out.print(value + " ");
        }

        selectionSort(testArray);

        System.out.println("\nSortiertes Array:");
        for (int value : testArray) {
            System.out.print(value + " ");
        }
    }
}
```

#### Funktionsweise des SelectionSort-Algorithmus

1. **Initialisierung und Validierung:** Zuerst wird überprüft, ob das Array leer oder bereits sortiert ist (d.h., es enthält ein oder kein Element).
   
2. **Finden des Minimums:** Für jede Position im Array (außer für die letzte) findet der Algorithmus das kleinste Element im unsortierten Teil des Arrays.

3. **Tauschen:** Das gefundene kleinste Element wird mit dem Element an der aktuellen Position getauscht.

4. **Wiederholung:** Dieser Vorgang wird wiederholt, bis das gesamte Array sortiert ist.

#### Test des Algorithmus

Die `main` Methode in der `SortAlgorithms` Klasse demonstriert, wie der Algorithmus ein Beispielarray sortiert. Sie gibt das Array vor und nach der Sortierung aus. Dies hilft, die korrekte Funktionalität von `selectionSort` zu verifizieren.

#### Effizienz

SelectionSort ist ein einfacher, aber ineffizienter Sortieralgorithmus mit einer Zeitkomplexität von O(n^2), was ihn für große Datenmengen ungeeignet macht. Er ist jedoch einfach zu implementieren und zu verstehen und funktioniert gut für kleinere Datenmengen.



### Aufgabe 2 – InsertionSort
- Implementieren Sie Insertion Sort Algorithmus in der Klasse SortAlgorithms. 
- Erstellen Sie hierzu eine statische Methode insertionSort. Diese Methode soll ein Array vom Typ int[] sortieren, das als Parameter übergeben wird. Ein Rückgabetyp ist nicht erforderlich, da die Methode auf einem Referenzdatentyp arbeitet. 
- Hinweis: Es genügt wenn positive Zahlen sortiert werden können.

#### Schritt 2: Ergänzung der Klasse `SortAlgorithms` um die Methode `insertionSort`

```java
public class SortAlgorithms {

    public static void selectionSort(int[] array) {
        // Implementation aus der vorherigen Antwort
    }

    public static void insertionSort(int[] array) {
        if (array == null || array.length <= 1) {
            return; // Keine Sortierung nötig, wenn das Array leer oder sehr klein ist.
        }

        int n = array.length;
        for (int i = 1; i < n; i++) {
            int key = array[i];
            int j = i - 1;

            // Verschiebe Elemente von array[0..i-1], die größer als der key sind, eine Position nach rechts
            while (j >= 0 && array[j] > key) {
                array[j + 1] = array[j];
                j = j - 1;
            }
            array[j + 1] = key;
        }
    }

    // Hauptmethode zum Testen der Sortierfunktionen
    public static void main(String[] args) {
        int[] selectionSortArray = {64, 34, 25, 12, 22, 11, 90};
        System.out.println("Ursprüngliches Array für SelectionSort:");
        for (int value : selectionSortArray) {
            System.out.print(value + " ");
        }

        selectionSort(selectionSortArray);

        System.out.println("\nSortiertes Array nach SelectionSort:");
        for (int value : selectionSortArray) {
            System.out.print(value + " ");
        }

        System.out.println("\n\nUrsprüngliches Array für InsertionSort:");
        int[] insertionSortArray = {22, 11, 99, 88, 9, 7, 42};
        for (int value : insertionSortArray) {
            System.out.print(value + " ");
        }

        insertionSort(insertionSortArray);

        System.out.println("\nSortiertes Array nach InsertionSort:");
        for (int value : insertionSortArray) {
            System.out.print(value + " ");
        }
    }
}
```

#### Funktionsweise des InsertionSort-Algorithmus

1. **Iteration:** Beginnend beim zweiten Element des Arrays (`i = 1`), wird jedes Element als `key` betrachtet.

2. **Einfügen des Keys:** Der Algorithmus vergleicht den `key` mit den vorherigen Elementen. Elemente, die größer als der `key` sind, werden nach rechts verschoben, um Platz für den `key` zu machen.

3. **Einsetzen des Keys:** Sobald ein Element gefunden wird, das kleiner oder gleich dem `key` ist, wird der `key` an dieser Position eingefügt.

#### Effizienz

InsertionSort ist effektiver als SelectionSort, besonders wenn das Array teilweise sortiert ist. Seine durchschnittliche und schlechteste Zeitkomplexität ist jedoch ebenfalls O(n^2), was ihn für sehr große Datenmengen ineffizient macht. Trotzdem ist InsertionSort aufgrund seiner adaptiven, stabilen und in-place Eigenschaften für kleinere Datenmengen oder fast sortierte Arrays eine gute Wahl.



### Aufgabe 3 – Datenstruktur DoublyLinked List
- Programmieren Sie eine doppelt verkettete Liste, die Objekte des Typs String aufnimmt.
- Orientieren Sie sich dazu am Aufbau und den Operationen der einfach verketteten Liste, die in der Vorlesung vorgestellt wurde. 
- Welche Vor- und Nachteile bringt die doppelte Verkettung mit sich?

#### Implementierung einer doppelt verketteten Liste

Hier ist eine einfache Implementierung einer doppelt verketteten Liste (`DoublyLinkedList`), die `String`-Objekte aufnimmt. Diese Implementierung umfasst Grundoperationen wie Einfügen am Anfang, am Ende und das Löschen von Elementen.

##### Schritt 1: Definition der Knotenklasse `Node`

```java
class Node {
    String data;
    Node prev;
    Node next;

    Node(String data) {
        this.data = data;
        this.prev = null;
        this.next = null;
    }
}
```

##### Schritt 2: Implementierung der `DoublyLinkedList` Klasse

```java
public class DoublyLinkedList {
    private Node head;
    private Node tail;

    public DoublyLinkedList() {
        this.head = null;
        this.tail = null;
    }

    // Methode zum Einfügen am Anfang
    public void insertAtFront(String data) {
        Node newNode = new Node(data);
        if (head == null) {
            tail = newNode;
        } else {
            head.prev = newNode;
            newNode.next = head;
        }
        head = newNode;
    }

    // Methode zum Einfügen am Ende
    public void insertAtEnd(String data) {
        Node newNode = new Node(data);
        if (tail == null) {
            head = newNode;
        } else {
            tail.next = newNode;
            newNode.prev = tail;
        }
        tail = newNode;
    }

    // Methode zum Löschen eines Knotens
    public void deleteNode(String key) {
        Node temp = head;
        while (temp != null && !temp.data.equals(key)) {
            temp = temp.next;
        }
        if (temp == null) return;

        if (temp.prev != null) {
            temp.prev.next = temp.next;
        } else {
            head = temp.next;
        }

        if (temp.next != null) {
            temp.next.prev = temp.prev;
        } else {
            tail = temp.prev;
        }
    }

    // Methode zur Ausgabe der Liste von vorne nach hinten
    public void printList() {
        Node current = head;
        while (current != null) {
            System.out.print(current.data + " <-> ");
            current = current.next;
        }
        System.out.println("null");
    }
}
```

#### Testen der `DoublyLinkedList`

```java
public class Main {
    public static void main(String[] args) {
        DoublyLinkedList dll = new DoublyLinkedList();
        dll.insertAtFront("Hello");
        dll.insertAtEnd("World");
        dll.insertAtFront("Start");
        dll.insertAtEnd("End");

        dll.printList();

        dll.deleteNode("World");
        dll.printList();
    }
}
```

#### Vorteile einer doppelt verketteten Liste

1. **Bidirektionale Navigation:** Durch die Verknüpfung in beide Richtungen (vorwärts und rückwärts) ist die Navigation durch die Liste effizienter, insbesondere wenn Elemente von beiden Enden der Liste häufig zugegriffen werden müssen.
   
2. **Einfacheres Einfügen und Löschen:** Im Vergleich zu einfach verketteten Listen kann das Einfügen und Löschen von Knoten an beliebiger Stelle in der Liste leichter durchgeführt werden, da man nicht notwendigerweise den vorherigen Knoten durchlaufen muss, um Verbindungen neu zu knüpfen.

#### Nachteile einer doppelt verketteten Liste

1. **Höherer Speicherverbrauch:** Jeder Knoten benötigt zusätzlichen Speicherplatz für den vorherigen Zeiger, was insbesondere bei großen Datenmengen zu einem höheren Speicherbedarf führt.

2. **Komplexität:** Die Implementierung von Operationen in einer doppelt verketteten Liste ist komplizierter, insbesondere beim korrekten Umgang mit den `prev` und `next` Zeigern, was zu Fehlern führen kann.



### Aufgabe 4 – Stack mit Array
- Ein Stack, oder auch Stapelspeicher, ist eine Datenstruktur, die folgende Operationen vorsieht:
    • push: Legt ein Element auf den Stapel
    • pop: Entfernt das oberste Element vom Stapel und liefert es zurück.
    • top: Gibt das oberste Element des Stapels zurück
- Implementieren Sie einen Stack für den Datentyp Object. Verwenden Sie ein Array als interne Datenstruktur. Der Stack muss nicht mehr als 10 Elemente aufnehmen können.

#### Schritt 1: Implementierung der Klasse `Stack`

```java
public class Stack {
    private Object[] elements;
    private int top;
    private static final int MAX_SIZE = 10;

    public Stack() {
        elements = new Object[MAX_SIZE];
        top = -1;
    }

    // Fügt ein Element auf den Stapel hinzu
    public void push(Object element) {
        if (top >= MAX_SIZE - 1) {
            throw new IllegalStateException("Stack is full");
        }
        elements[++top] = element;
    }

    // Entfernt das oberste Element vom Stapel und gibt es zurück
    public Object pop() {
        if (top == -1) {
            throw new IllegalStateException("Stack is empty");
        }
        return elements[top--];
    }

    // Gibt das oberste Element des Stapels zurück, ohne es zu entfernen
    public Object top() {
        if (top == -1) {
            throw new IllegalStateException("Stack is empty");
        }
        return elements[top];
    }
}
```

#### Vorteile dieser Implementierung

1. **Einfachheit:** Die Implementierung ist einfach zu verstehen und zu verwenden.
2. **Effizienz:** Operationen wie `push`, `pop` und `top` sind in konstanter Zeit \(O(1)\) möglich.

#### Nachteile

1. **Feste Größe:** Der Stack ist auf 10 Elemente beschränkt, was seine Verwendung in Anwendungen mit möglicherweise mehr Elementen einschränkt.
2. **Mangel an Typsicherheit:** Da `Object` verwendet wird, gibt es keine Typsicherheit bei der Verwendung des Stacks. Es könnte nützlich sein, eine generische Version zu implementieren.



### Aufgabe 5 – Stack mit DoublyLinkedList
- Implementieren Sie den Stack aus Aufgabe 4 auf Basis der doppelt verketteten Liste aus Aufgabe 3. 
- Wäre auch eine SinglyLinkedList für die Implementierung geeignet?

#### Schritt 1: Anpassung der Node und DoublyLinkedList Klasse

Hier nutzen wir die `Node`- und `DoublyLinkedList`-Klassen aus der früheren Implementierung und erweitern sie um Stack-Operationen.

```java
class Node {
    String data;
    Node prev;
    Node next;

    Node(String data) {
        this.data = data;
        this.prev = null;
        this.next = null;
    }
}

class DoublyLinkedList {
    private Node head;
    private Node tail;

    public void push(String data) {
        Node newNode = new Node(data);
        if (head == null) {
            head = tail = newNode;
        } else {
            newNode.next = head;
            head.prev = newNode;
            head = newNode;
        }
    }

    public String pop() {
        if (head == null) throw new IllegalStateException("Stack is empty");
        String data = head.data;
        head = head.next;
        if (head != null) {
            head.prev = null;
        } else {
            tail = null;
        }
        return data;
    }

    public String top() {
        if (head == null) throw new IllegalStateException("Stack is empty");
        return head.data;
    }
}
```

#### Schritt 2: Implementierung der Stack-Klasse

Die `Stack` Klasse verwendet nun eine Instanz von `DoublyLinkedList` für die Stack-Operationen.

```java
public class Stack {
    private DoublyLinkedList list;

    public Stack() {
        list = new DoublyLinkedList();
    }

    public void push(String data) {
        list.push(data);
    }

    public String pop() {
        return list.pop();
    }

    public String top() {
        return list.top();
    }
}
```

#### Wäre auch eine SinglyLinkedList für die Implementierung geeignet?

Ja, eine SinglyLinkedList könnte ebenfalls für die Implementierung eines Stacks genutzt werden. Bei einem Stack werden Elemente nur am `head` hinzugefügt und entfernt, was mit den Operationen einer SinglyLinkedList vereinbar ist. Eine doppelt verkettete Liste bietet allerdings den Vorteil, dass beim Entfernen des obersten Elements nicht das ganze List durchlaufen werden muss, um den `prev`-Zeiger des neuen `head` zu aktualisieren. Dies ist bei einer SinglyLinkedList nicht nötig, was die Implementierung vereinfachen kann.



### Aufgabe 6 – Prüfungsverwaltung mit Sets
- Sie sind für die Prüfungsorganisation in einem Fach mit verpflichtender Vorleistung zuständig. Um den manuellen Aufwand zu reduzieren, wollen sie ein kleines Java Programm entwickeln, das die Prüfungsteilnehmer verwaltet.
- Folgende Informationen liegen Ihnen vor:
    - eine Liste der Anmeldungen im QISPOS-System
    - eine Liste der Anmeldungen mit einem physischen Prüfungsschein
    - eine Liste der Teilnehmer, die die verpflichtende Vorleistung bestanden haben.
- Mit welchen Operationen können nun die folgenden Informationen bestimmt werden?
    - Liste der angemeldeten Studenten
    - Liste der für die Prüfung zugelassenen Studenten
- Warum ist der Datentyp Set, der eine Menge implementiert, dafür besonders geeignet?
- Erstellen sie eine Klasse Pruefungsverwaltung, die in der main-Methode zuerst die gegebenen Informationen einliest und dann die gesuchten Informationen daraus berechnet.Teilnehmer sollen durch ihren Namen identifiziert werden. Mit welcher Methode der Klasse Set kann die Frage ob ein bestimmter Teilnehmer zur Prüfung zugelassen ist beantwortet werden?
- Wahrscheinlich zeigt Eclipse Ihnen Warnungen im Kontext der Verwendung des Datentyps Set an. Wie können Sie sich diese Warnungen erklären?

#### Warum der Datentyp Set für die Prüfungsverwaltung geeignet ist

Der `Set` Datentyp in Java ist ideal für die Verwaltung von Prüfungsteilnehmern, da er eine Menge von einzigartigen Elementen darstellt. Das bedeutet:

1. **Keine Duplikate:** Jeder Student wird nur einmal aufgeführt, auch wenn er/sie mehrfach in den Eingabelisten erscheinen könnte.
2. **Effiziente Operationen:** Operationen wie die Vereinigung, Schnittmenge und Differenz (um zugelassene Studenten zu identifizieren) sind effizient zu handhaben.

#### Vorteilhafte Set-Operationen

- **Vereinigung (`addAll`)**: Kann genutzt werden, um eine Gesamtliste aller angemeldeten Studenten zu erstellen.
- **Schnittmenge (`retainAll`)**: Kann genutzt werden, um die Liste der für die Prüfung zugelassenen Studenten zu berechnen, basierend auf den Studenten, die sowohl angemeldet sind als auch die Vorleistung bestanden haben.
- **Test auf Mitgliedschaft (`contains`)**: Ermöglicht die Überprüfung, ob ein bestimmter Teilnehmer zur Prüfung zugelassen ist.

#### Implementierung der Klasse `Pruefungsverwaltung`

```java
import java.util.HashSet;
import java.util.Set;

public class Pruefungsverwaltung {
    public static void main(String[] args) {
        // Simulierte Eingabelisten
        Set<String> qisposAnmeldungen = new HashSet<>();
        Set<String> physischeAnmeldungen = new HashSet<>();
        Set<String> bestandeneVorleistungen = new HashSet<>();

        // Hinzufügen von simulierten Daten
        qisposAnmeldungen.add("Alice");
        qisposAnmeldungen.add("Bob");
        qisposAnmeldungen.add("Charlie");

        physischeAnmeldungen.add("Alice");
        physischeAnmeldungen.add("Charlie");

        bestandeneVorleistungen.add("Alice");
        bestandeneVorleistungen.add("Bob");

        // Berechnen der angemeldeten Studenten (Vereinigung)
        Set<String> angemeldeteStudenten = new HashSet<>(qisposAnmeldungen);
        angemeldeteStudenten.addAll(physischeAnmeldungen);

        // Berechnen der für die Prüfung zugelassenen Studenten (Schnittmenge)
        Set<String> zugelasseneStudenten = new HashSet<>(physischeAnmeldungen);
        zugelasseneStudenten.retainAll(bestandeneVorleistungen);

        System.out.println("Angemeldete Studenten: " + angemeldeteStudenten);
        System.out.println("Zugelassene Studenten: " + zugelasseneStudenten);

        // Überprüfen, ob ein bestimmter Teilnehmer zur Prüfung zugelassen ist
        String testStudent = "Alice";
        System.out.println("Ist " + testStudent + " zugelassen? " + zugelasseneStudenten.contains(testStudent));
    }
}
```

#### Umgang mit Warnungen in Eclipse

Wenn Eclipse Warnungen im Zusammenhang mit der Verwendung des Datentyps `Set` anzeigt, handelt es sich wahrscheinlich um Warnungen zur generischen Typsicherheit. Diese Warnungen treten auf, weil der generische Typ (`Set<String>`) zur Kompilierzeit Typsicherheit bietet, die zur Laufzeit aufgrund von Typ-Löschung (Type Erasure) nicht garantiert werden kann. Diese Warnungen können behoben werden, indem Sie den Code sauber halten und sicherstellen, dass Sie konsistent denselben generischen Typ verwenden. Nutzen Sie auch das `@SuppressWarnings("unchecked")` Annotation sorgsam, wenn Sie sicher sind, dass Ihr Code typsicher ist.



### Aufgabe 7 – Generische Datentypen: ArrayList
- Eine Implementierung einer generischen Liste, d.h. des List-Interfaces, stellt die Klasse ArrayList dar.
- Erstellen Sie eine Klasse ArrayListDemo mit einer main-Methode. Deklarieren und instanziieren Sie in dieser eine ArrayList, die Objekte vom Typ String aufnehmen kann.
- Fügen Sie die Namen der Tutorienteilnehmer ein. Geben sie die Teilnehmer auf der Konsole aus. Die Namen der Teilnehmer sollen durch Semikolons getrennt werden. Nutzen sie hierfür die vereinfachte for-Schleife.

#### Implementierung der Klasse `ArrayListDemo`

```java
import java.util.ArrayList;

public class ArrayListDemo {
    public static void main(String[] args) {
        // Deklaration und Instanziierung einer ArrayList, die String Objekte aufnehmen kann
        ArrayList<String> teilnehmerListe = new ArrayList<>();

        // Hinzufügen von Namen der Tutorienteilnehmer
        teilnehmerListe.add("Alice");
        teilnehmerListe.add("Bob");
        teilnehmerListe.add("Charlie");
        teilnehmerListe.add("Diana");

        // Ausgabe der Teilnehmer auf der Konsole, getrennt durch Semikolons
        boolean isFirst = true;
        for (String name : teilnehmerListe) {
            if (!isFirst) {
                System.out.print("; ");
            }
            System.out.print(name);
            isFirst = false;
        }
    }
}
```

#### Erklärung des Codes

1. **Deklaration und Instanzierung**: Eine `ArrayList` von `String` wird erstellt. `ArrayList<String>` bedeutet, dass diese Liste speziell für die Speicherung von `String`-Objekten ausgelegt ist. Dies sorgt für Typsicherheit, da Sie nur Strings hinzufügen oder aus der Liste erhalten können, ohne dass Casts nötig sind.

2. **Hinzufügen von Elementen**: Namen werden zur Liste hinzugefügt mit der Methode `add()`. Diese Methode fügt die Elemente am Ende der Liste hinzu.

3. **Ausgabe der Liste**: In der `main` Methode wird eine vereinfachte for-Schleife verwendet, um über die Liste zu iterieren. Innerhalb der Schleife prüfe ich mit der Boolean-Variable `isFirst`, ob es sich um das erste Element handelt, um vor den folgenden Namen ein Semikolon zu setzen, außer vor dem ersten Namen. 

#### Vorteile der Verwendung von `ArrayList` in Java

- **Flexibilität**: `ArrayLists` sind dynamisch, was bedeutet, dass sie ihre Größe automatisch anpassen, wenn Elemente hinzugefügt oder entfernt werden.
- **Zugriff**: Der Zugriff auf ein Element an einer bestimmten Position ist sehr effizient (Zugriff in konstanter Zeit).
- **Generische Typen**: Generische Typen bieten Typsicherheit und vermeiden das Casting, das bei Verwendung von nicht generischen Kollektionen wie `ArrayList` notwendig wäre.



### Aufgabe 8 – Typisierte Prüfungsverwaltung
- Typisieren Sie die von Ihnen in Aufgabe 6 entwickelte Prüfungsverwaltung mit Hilfe generischer Sets. 
- Erstellen Sie hierzu die Klasse PruefungsverwaltungTypisiert. Die Einträge sollen vom Typ String sein.

#### Implementierung der Klasse `PruefungsverwaltungTypisiert`

```java
import java.util.HashSet;
import java.util.Set;

public class PruefungsverwaltungTypisiert {
    public static void main(String[] args) {
        // Erstellen von Sets mit Typisierung für String
        Set<String> qisposAnmeldungen = new HashSet<>();
        Set<String> physischeAnmeldungen = new HashSet<>();
        Set<String> bestandeneVorleistungen = new HashSet<>();

        // Simulierte Daten hinzufügen
        qisposAnmeldungen.add("Alice");
        qisposAnmeldungen.add("Bob");
        qisposAnmeldungen.add("Charlie");

        physischeAnmeldungen.add("Alice");
        physischeAnmeldungen.add("Charlie");

        bestandeneVorleistungen.add("Alice");
        bestandeneVorleistungen.add("Bob");

        // Berechnen der angemeldeten Studenten (Vereinigung der Sets)
        Set<String> angemeldeteStudenten = new HashSet<>(qisposAnmeldungen);
        angemeldeteStudenten.addAll(physischeAnmeldungen);

        // Berechnen der für die Prüfung zugelassenen Studenten (Schnittmenge)
        Set<String> zugelasseneStudenten = new HashSet<>(physischeAnmeldungen);
        zugelasseneStudenten.retainAll(bestandeneVorleistungen);

        System.out.println("Angemeldete Studenten: " + angemeldeteStudenten);
        System.out.println("Zugelassene Studenten: " + zugelasseneStudenten);

        // Überprüfen, ob ein bestimmter Teilnehmer zur Prüfung zugelassen ist
        String testStudent = "Alice";
        System.out.println("Ist " + testStudent + " zugelassen? " + zugelasseneStudenten.contains(testStudent));
    }
}
```

#### Erklärung

- **Generische Sets**: Die Sets `qisposAnmeldungen`, `physischeAnmeldungen`, und `bestandeneVorleistungen` sind als `Set<String>` typisiert. Dies stellt sicher, dass nur `String` Objekte hinzugefügt werden können, was Typfehler zur Kompilierzeit verhindert.

- **Operationen**:
  - **Vereinigung (`addAll`)**: Die Methode `addAll()` wird verwendet, um alle Anmeldungen (sowohl QISPOS als auch physisch) in ein Set zusammenzuführen.
  - **Schnittmenge (`retainAll`)**: Die Methode `retainAll()` wird verwendet, um nur diejenigen Studenten in `zugelasseneStudenten` zu behalten, die sowohl angemeldet sind als auch die Vorleistung bestanden haben.
  - **Mitgliedschaftsprüfung (`contains`)**: Die Methode `contains()` wird genutzt, um zu überprüfen, ob ein bestimmter Student zur Prüfung zugelassen ist.

#### Vorteile der Verwendung von generischen Typen

- **Typsicherheit**: Das Programm ist sicherer, da es zur Kompilierzeit überprüft, ob nur Strings in die Sets eingefügt werden.
- **Vermeidung von Laufzeitfehlern**: Fehler durch falsche Datentypen werden zur Kompilierzeit erkannt, wodurch Laufzeitfehler vermieden werden.
- **Wartbarkeit und Lesbarkeit**: Der Code ist einfacher zu verstehen und zu warten, weil klar ist, welche Art von Daten die Sets enthalten.



### Aufgabe 9 – Iterator
- Iterators bieten eine weitere Möglichkeit Collections einfach zu traversieren. Jede Instanz einer Klasse, die das Iterable Interface implementiert kann ein Iterator-Objekt zurückliefern. Alle Kindklassen von Collection implementieren Iterable. Realisieren Sie die Ausgabe aus Aufgabe 6 mit Hilfe eines Iterators in der Klasse ListIteratorDemo.


```java
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class ListIteratorDemo {
    public static void main(String[] args) {
        // Create a list of integers
        List<Integer> numbers = new ArrayList<>();
        numbers.add(1);
        numbers.add(2);
        numbers.add(3);
        numbers.add(4);
        numbers.add(5);

        // Obtain the iterator from the list
        Iterator<Integer> iterator = numbers.iterator();

        // Use the iterator to traverse the list
        while (iterator.hasNext()) {
            Integer number = iterator.next();
            System.out.println(number);
        }
    }
}
```

#### Explanation:
- **Import Statements**: The code imports ArrayList, Iterator, and List classes from the Java Util package.
- **List Initialization**: An ArrayList of integers is created and initialized with some values.
- **Obtaining an Iterator**: The iterator is obtained from the list using the `iterator()` method. This method is available because ArrayList implements the Iterable interface.
- **Using the Iterator**: The `hasNext()` method of the iterator checks if there are more elements in the list. If `hasNext()` returns true, `next()` is called to retrieve the next element and print it.



### Aufgabe 10

- Verändern Sie ihre Implementierung der `DoublyLinked List` aus Aufgabe 3 so, dass sie generische Datentypen verwalten kann. Erstellen sie hierzu die Klasse GenericDoublyLinkedList und GenericDoublyLinkedListEntry. Nehme Sie an es existiere die Klasse Tier. 
- Wie wäre eine GenericDoublyLinkList zu deklarieren, die nur Tiere und ihre Kindklassen aufnimmt? Tipp: Wildcards.



#### **Step 1**: Creating the Generic Node Class**
```java
Copy code
class GenericDoublyLinkedListEntry<T> {
    T data;
    GenericDoublyLinkedListEntry<T> next;
    GenericDoublyLinkedListEntry<T> prev;

    public GenericDoublyLinkedListEntry(T data) {
        this.data = data;
        this.next = null;
        this.prev = null;
    }
}

```

#### **Step 2** : Creating the Generic Doubly Linked List Class
```java
java
Copy code
class GenericDoublyLinkedList<T> {
    private GenericDoublyLinkedListEntry<T> head;
    private GenericDoublyLinkedListEntry<T> tail;

    public GenericDoublyLinkedList() {
        this.head = null;
        this.tail = null;
    }

    public void add(T data) {
        GenericDoublyLinkedListEntry<T> newEntry = new GenericDoublyLinkedListEntry<>(data);
        if (head == null) {
            head = newEntry;
            tail = newEntry;
        } else {
            tail.next = newEntry;
            newEntry.prev = tail;
            tail = newEntry;
        }
    }

    public void printList() {
        GenericDoublyLinkedListEntry<T> current = head;
        while (current != null) {
            System.out.println(current.data);
            current = current.next;
        }
    }
}
```



#### **Step 3**: Specializing the Generic Doubly Linked List for Tier and Its Subclasses
To declare a GenericDoublyLinkedList that can accept instances of Tier and its subclasses, you can use Java's wildcard generics with an upper bound. This is how you would declare such a list:

``java
GenericDoublyLinkedList<? extends Tier> animalList = new GenericDoublyLinkedList<>();
``


##### **Example Usage**
Here's how you would use these classes in a Java program. This example assumes the existence of a class Tier and a subclass Katze (cat):

```java
Copy code
class Tier {}

class Katze extends Tier {}

public class Main {
    public static void main(String[] args) {
        GenericDoublyLinkedList<Tier> myList = new GenericDoublyLinkedList<>();
        myList.add(new Tier()); // Adding an instance of Tier
        myList.add(new Katze()); // Adding an instance of Katze, a subclass of Tier

        myList.printList();
    }
}
```



#### Explanation
- **Generic Node Class**: GenericDoublyLinkedListEntry<T> can hold data of any type specified by T.
- **Generic Doubly Linked List Class**: GenericDoublyLinkedList<T> provides basic functionalities like add() for adding new elements and printList() for printing all elements.
- **Usage of Wildcards** : The declaration GenericDoublyLinkedList<? extends Tier> allows the list to accept objects of Tier and any of its subclasses, utilizing Java's wildcard generics with an upper bound to enforce type safety.


## Programmieren I Recap: Tutorium

### Quiz:
1. Wie viele und welche Zustände hat der Thread Lifecycle?
a) (`New`, `Runnable`, `Timed Waiting`, `Waiting`, `Blocked`, `Terminated`)
b) (`New`, `Ready`, `Running`, `Timed Waiting`, `Waiting`, `Blocked`, `Terminated`)
c) (`New`, `Runnable`, `Ready`, `Running`, `Timed Waiting`, `Waiting`, `Blocked`, `Terminated`)
d) (`New`, `Runnable`, `Terminated`)
2. Mit welcher Methode kann der aktuelle Thread auf einen bestimmten Thread warten?
a) `wait()`
b) `join()`
c) `interrupt()`
d) `notity()`
3. Was sind Streams?
a) eine Sequenz von Daten
b) die Möglichkeit in Java eine Netzwerkverbindung herzustellen die Möglichkeit in Java Programme parallel laufen zu lassen
c) Streams sind die Möglichkeit eine Client-Server Architektur in Java zu implementieren
4. Was ist der Unterschied zwischen einem `FileReader` und einem `BufferedReader`?
a) `FileReader` liest einzelne Werte ein und `BufferedReader` ganze Zeilen
b) `BufferedReader` liest einzelne Werte ein und `FileReader` ganze Zeilen
c) `FileReader` ist ein OutputStream und `BufferedReader` ein InputStream
d) `FileReader` ist ein InputStream und `BufferedReader` ein OutputStream
5. Wie funktioniert der Verbindungsaufbau einer Client Server Architektur in Java?
a) 1. Socket (Client) verbindet sich mit ServerSocket. 2. Server erstellt Socket Instanz um zu antworten. 3. Socket Instanz des Servers verbindet sich mit dem Client
b) 1. Socket verbindet sich mit ServerSocket. 2. Socket Instanz des Servers verbindet sich mit Client.
c) 1. Client verbindet sich durch Streams mit dem Server. 2. Server antwortet mit Outpustream und stellt die Verbindung her

Lösungen: 1. a), 2. b), 3. a), 4. a), 5. a)



![image](https://hackmd.io/_uploads/B1ib7wLW0.png)
 Scheduling: 
- Organisation der Prozesse auf unterschiedliche Cores
- findet auf der CPU statt

    