I am currently working on an English translation. If you like to help to proofread please contact me: ullenboom ät g m a i l dot c o m.

Java Videotraining Werbung

1. Nebenläufige Programmierung mit Threads

Ein Betriebssystem bietet Threads als Programmierhilfe an, wovon Programme fleißig Gebrauch machen. Wer unter Windows arbeitet, und einmal in den Task-Manager schaut, wird feststellen, dass schnell einmal 3000 Threads laufen. In diesem Kapitel wollen wir die Anzahl noch einmal hochschrauben, um komfortabel mehrere Handlungsstränge nebenläufig oder sogar parallel ausführen zu lassen. Dabei soll es bei den Aufgaben nicht einfach nur darum gehen, Threads zu erzeugen, sondern wenn etwas nebenläufig ist, muss auch auf den korrekten Zugriff auf gemeinsame Ressourcen geachtet werden — die Threads müssen sich koordinieren.

Voraussetzungen

  • main-Thread nutzen können

  • eigene Threads erzeugen können, Unterschied zwischen Thread und Runnable kennen

  • Threads schlafen legen können

  • Threads unterbrechen können

  • Aufgaben in den Thread-Pool zur Bearbeitung abgeben können

  • nebenläufige Operationen mit Rückgaben einsetzen können

  • Unterschied zwischen Runnable und Callable verstehen

  • Threads mit Lock-Objekten synchronisieren können

  • Threads untereinander mit Condition-Objekten benachrichtigen können

  • Synchronisationshelfer wie Semaphore kennen

Verwendete Datentypen in diesem Kapitel:

1.1. Anlegen von Threads

Startet die JVM, erzeugt sie einen Thread mit den Namen main. Dieser Thread führt die main(…​)-Methode aus und er hat in allen vorherigen Aufgaben unsere Programme ausgeführt. Das wollen wir in den nächsten Aufgaben ändern. Wir wollen weitere Threads erzeugen und diese Programmcode abarbeiten lassen.

Thread UML
Abbildung 1. UML-Diagramm der Klasse Thread

1.1.1. Threads erzeugen für Winken und Fähnchen schwenken ⭐

Zu Ehren vom Captain CiaoCiao gibt es eine Parade. Er steht an der Rampe seines Schiffs, winkt mit der einen Hand und schwenkt ein Fähnchen mit der anderen.

Aufgabe:

  • Threads führen in Java immer Dinge vom Typ Runnable aus. Runnable ist eine funk-tionale Schnittstelle, und es gibt in Java zwei Möglichkeiten, funktionale Schnittstellen zu implementieren: Klassen und Lambda-Ausdrücke. Schreibe zwei Runnable-Implementierungen; einmal durch eine Klasse, einmal mit einem Lambda-Ausdruck.

  • Setze in beide Runnable-Implementierungen eine Schleife mit fünfzig Wiederholungen. Das eine Runnable soll auf dem Bildschirm »winken« ausgeben, die Ausgabe des anderen soll »Fähnchen schwenken« sein

  • Erzeuge ein Thread-Objekt, und übergib das Runnable. Starte dann die Threads. Starte nicht fünfzig Threads, sondern nur zwei!

Erweiterung: Die run()-Methode jedes Threads soll die Zeile System.out.println(Thread.currentThread()); enthalten. Was wird dann angezeigt?

Nehmen wir an, Captain CiaoCiao hat noch ein paar mehr Arme zum Winken. Wie viele Threads lassen sich erzeugen, bis das System »steht«? Beobachte den Speicherverbrauch im Windows Task-Manager (Ctrl+Alt+Del). Lässt sich abschätzen, was ein Thread »kostet«?

1.1.2. Nix mehr mit Winken und Fähnchenschwenken: Threads beenden ⭐

Threads lassen sich mit stop() beenden — die Methode ist seit Jahrzehnten deprecated, aber wird wohl nie gelöscht — oder nett mit interrupt() bitten, sich selbst zu beenden. Dafür muss der Thread aber mitspielen und mit isInterrupted() prüfen, ob ein solcher Löschantrag für ihn vorliegt.

Captain CiaoCiao steht immer noch auf dem Schiff, winkt und schwenkt Fähnchen. Wenn es ernst wird, muss er diese Volksbelustigung beenden.

Aufgabe:

  • Schreibe ein Programm mit zwei Runnable-Implementierungen, die prinzipiell unendlich lange winken und Fähnchen schwenken, es sei denn, es gibt eine Unterbrechung. Die run()-Methode soll daher über Thread.currentThread().isInterrupted() testen, ob es eine Unterbrechung gab, und dann die Schleife verlassen.

  • Baue in die Schleife eine Verzögerung ein. Kopiere dafür folgenden Code:

    try { Thread.sleep( 2000 ); } catch ( InterruptedException e ) { Thread.currentThread().interrupt(); }
  • Das Hauptprogramm soll mit JOptionPane.showInputDialog(String) auf eine Eingabe reagieren, sodass man durch die Kommandos endw das Winken und endf das Fähnchenschwenken beenden.

1.1.3. Runnable parametrisieren ⭐⭐

Der Blick auf den folgenden Code zeigt, dass die beiden Runnable-Implementierungen sehr ähnlich sind und sich nur in der Bildschirmausgabe unterscheiden. Die Unterschiede in der run()-Methode sind fett gesetzt:

// Runnable 1
class Wink implements Runnable {
  @Override public void run() {
    for ( int i = 0; i < 50; i++ )
      System.out.printf( "Wink; %s%n", Thread.currentThread() );
  }
}
Runnable winker = new Wink();

// Runnable 2
Runnable flagWaver = () -> {
  for ( int i = 0; i < 50; i++ )
    System.out.printf( "Wave flag; %s%n", Thread.currentThread() );
};

Codeduplizierung ist aber selten gut, das sollte geändert werden.

Aufgabe:

  • Überlege, wie man prinzipiell einem Runnable etwas mitgeben kann.

  • Implementiere ein parametrisiertes Runnable, sodass man in der oben genannten Schleife

    • die Bildschirmausgaben und

    • die Anzahl Wiederholungen frei bestimmen kann.

  • Schreibe das Winken-und-Fähnchenschwenken-Programm so um, dass das parametrisierte Runnable einem Thread zur Ausführung übergeben wird.

1.2. Ausgeführt und stillgestanden

Ein Thread kann sich in mehreren Zuständen befinden, dazu zählen unter anderem laufend, wartend, schlafend, blockiert oder beendet. In den vorherigen Aufgaben haben wir den Thread gestartet, sodass er im Zustand laufend ist, und die run()-Methode haben wir durch das Ende der Schleife beendet, was auch den Thread beendet. In diesem Abschnitt geht es in den Aufgaben um den Zustand schlafend, und in den Abschnitten »Kritische Abschnitte schützen«, und »Thread-Kooperation und Synchronisationshelfer«, gibt es Aufgaben um die Zustände wartend/blockiert.

1.2.1. Abarbeitung durch schlafende Threads verzögen ⭐⭐

Das unter Unix bekannte Programm sleep (http://man7.org/linux/man-pages/man1/sleep.1.html) lässt sich von der Kommandozeile aufrufen und schläft dann eine Zeit lang, verzögert also in Skripten die nachfolgenden Programme.

Aufgabe:

  • Implementiere das sleep-Programm in Java neu, sodass man vergleichbar wie beim Vorbild auf der Kommandozeile schreiben kann:

    $ java Sleep 22

    Dann soll das Java-Programm 22 Sekunden schlafen und wenn es zum Beispiel in einem Skript nachfolgende Programmaufrufe gibt, werden diese verzögert.

  • Dem Java-Programm soll man die Schlafzeit auf verschiedene Arten auf der Kommandozeile mitgeben können. Wird nur eine Ganzzahl übergeben, dann steht die Wartezeit für Sekunden. Es sollen Suffixe hinter der Ganzzahl für unterschiedliche Dauern erlaubt sein:

    • s für Sekunden (Standard)

    • m für Minuten

    • h für Stunden

    • d für Tage

    Werden mehrere Werte übergeben, so werden sie summiert und ergeben die Gesamtwartezeit.

  • Bei dem Aufruf können verschiedene Dinge schiefgehen, etwa wenn keine Zahl übergeben wird oder die Zahl zu groß ist. Prüfe, ob die Werte, Bereiche und Suffixe korrekt sind. Optional: Beende das Programm im Fehlerfall mit einem individuellen Exit-Code über System.exit(int).

Beispiel:

  • Gültige Aufrufbeispiele:

    $ java Sleep 1m
    $ java Sleep 1m 2s
    $ java Sleep 1h 3h 999999s
  • Ungültige Aufrufe, die zum Abbruch führen:

    $ java Sleep
    $ java Sleep three
    $ java Sleep 1y
    $ java Sleep 9999999999999999999999

Tipp: Strukturiere das Programm so, dass die drei wesentlichen Teile erkennbar sind:

  1. das Ablaufen der Kommandozeile und Analysieren der Übergaben

  2. das Konvertieren der Einheiten in Sekunden

  3. das eigentliche Schlafen von den akkumulierten Sekunden lang

1.2.2. Dateiänderungen beobachten durch Threads ⭐

Nach einer erfolgreichen Plünderung werden alle neuen Schätze systematisch in eine Inventarliste aufgenommen. Diese wird in einer einfachen Datei gespeichert. Bonny Brain möchte Bescheid bekommen, wenn sich die Datei ändert.

Aufgabe:

  1. Schreibe eine Klasse FileChangeWatcher mit einem Konstruktor FileChangeWatcher(String filename).

  2. Implementiere Runnable.

  3. Gib im Abstand von einer halben Sekunde den Dateinamen, die Dateigröße und Files.getLastModifiedTime(path) aus.

  4. Überprüfe mit getLastModifiedTime(…​), ob sich die Datei geändert hat. Gib eine Meldung aus, wenn sich die Datei ändert. Das alles soll endlos geschehen, jede neue Änderung soll gemeldet werden.

  5. Erweiterung: Wir möchten nun flexibler auf Änderungen reagieren. Dazu soll im Konstruktor ein java.util.function.Consumer-Objekt übergeben werden können. Den Konsumenten soll sich FileChangeWatcher merken und immer dann die accept(Path)-Methode aufrufen, wenn sich etwas ändert. So können wir ein Objekt anmelden, das bei einer Dateiänderung informiert wird.

1.2.3. Exceptions auffangen ⭐

Die Unterscheidung zwischen geprüften Ausnahmen und ungeprüften Ausnahmen hat eine wichtige Konsequenz, denn ungeprüfte Ausnahmen können, falls nicht gefangen, so weit eskalieren, dass sie beim ausführenden Thread landen, der dann von der virtuellen Maschine beendet wird. Dies macht die Laufzeitumgebung automatisch, und wir bekommen freundlicherweise noch eine Meldung auf den Standardfehlerkanal, aber wiederbeleben können wir den Thread nicht mehr.

Auf einem lokalen Thread oder global für alle Threads lässt sich ein UncaughtExceptionHandler installieren, der informiert wird, wenn der Thread durch eine Ausnahme beendet wird. Er lässt sich in vier Szenarien einsetzen:

  1. Ein UncaughtExceptionHandler kann auf einem individuellen Thread gesetzt werden. Immer dann, wenn dieser Thread eine unbehandelte Ausnahme bekommt, wird der Thread abgebrochen und der gesetzte UncaughtExceptionHandler informiert.

  2. Ein UncaughtExceptionHandler kann auf einer Thread-Gruppe gesetzt werden.

  3. Ein UncaughtExceptionHandler lässt sich global für alle Threads setzen.

  4. Der main-Thread ist insofern etwas Besonderes, als die JVM ihn automatisch anlegt und das Hauptprogramm ausführt. Natürlich kann es auch im main-Thread ungeprüfte Ausnahmen geben, den ein UncaughtExceptionHandler melden kann. Allerdings kommt eine interessante Besonderheit dazu: An der main(…​)-Methode kann throws stehen, und geprüfte Ausnahmen können so an die JVM zurückgehen. Im Fall einer geprüften Ausnahme wird ebenfalls ein gesetzter UncaughtExceptionHandler benachrichtigt.

Die Abarbeitung findet in einer Kaskade statt: Gibt es eine ungeprüfte Ausnahme, schaut die JVM zunächst, ob ein UncaughtExceptionHandler beim individuellen Thread gesetzt ist. Falls nicht, wird nach einem UncaughtExceptionHandler bei der Thread-Gruppe geschaut und anschließend nach einem globalen Handler, der informiert wird.

Aufgabe:

  • Lasse einen Thread laufen, der durch die Division durch 0 beendet wird. Dokumentiere diese Ausnahme durch einen eigenen globalen UncaughtExceptionHandler.

  • Starte einen zweiten Thread, der einen lokalen UncaughtExceptionHandler hat, der die Ausnahme ignoriert, sodass auch keine Meldung erscheint.

  • Wenn an der main(…​)-Methode throws Exception und im Rumpf new URL("captain") steht, wird auch dann der globale UncaughtExceptionHandler aufgerufen?

1.3. Thread-Pools und Ergebnisse

Es ist nicht immer der beste Weg für Java-Entwickler, selbst Threads anzulegen und diese mit Programmcode zu verbinden: häufig ist es vernünftiger, den Programmcode von der physikalischen Ausführung zu trennen. Dies übernimmt in Java ein Executor. Damit lässt sich der Programmcode vom eigentlichen Thread trennen und auch der gleiche Thread mehrmals für unterschiedlichen Programmcode einsetzen.

In der Java-Bibliothek gibt es drei zentrale Executor-Implementierungen: ThreadPoolExecutor, ScheduledThreadPoolExecutor und ForkJoinPool. Die Typen ThreadPoolExecutor und ForkJoinPool realisieren Thread-Pools die eine Sammlung von existierenden Threads verwalten, sodass Aufgaben an existierende, freie Threads übergeben werden können.

Jede Codeasführung im Hintergrund wird über einen Thread in Java realisiert, der entweder selbst erzeugt und gestartet, oder indirekt von einem Executor oder internem Thread-Pool eingesetzt wird. Es gibt zwei wichtige Schnittstellen, um nebenläufigen Code zu kapseln: Runnable und Callable. Runnable wird direkt dem Thread-Konstruktor übergeben — ein Callable kann man nicht bei Thread übergeben; für Callable benötigt man einen Executor. Ein Callable liefert zudem ein Ergebnis zurück, so wie es auch ein Supplier tut, er hat aber keinen Parameter für eine Übergabe. Bei einem Runnable lässt sich nichts zurückliefern und auch nicht übergeben. Die run()-Methode löst keine Ausnahme aus, call() trägt in der Methodensignatur throws Exception, kann also beliebige Ausnahmen weiterleiten.

Runnable Callable UML
Abbildung 2. UML-Diagramm der Schnittstellen Runnable und Callable

Bisher haben wir Threads immer selbst aufgebaut und nur Runnable verwendet. In den folgenden Aufgaben wird es um Thread-Pools und auch um Callable gehen.

1.3.1. Thread-Pool nutzen ⭐⭐

Ostern steht an, und Bonny Brain geht zum Verteilen von Geschenken mit ihren Crewmitgliedern als Wookiee verkleidet in ein Waisenhaus.

Aufgabe:

  • Erzeuge mit Executors.newCachedThreadPool(); einen ExecutorService, das ist der Thread-Pool.

  • Lege ein String-Array mit Geschenken an.

  • Bonny Brain läuft im main-Thread jedes Geschenk ab und übermittelt es im Abstand von 1 bis 2 Sekunden an ein Crewmitglied, also einen Thread im Thread-Pool.

  • Die Crewmitglieder sind Threads aus dem Thread-Pool. Sie führen die Befehle von Bonny Brain aus, ein Geschenk zu verteilen. Dafür brauchen sie zwischen 1 und 4 Sekunden.

  • Der Ablauf ist dann wie folgt: Die Geschenkverteilung ist durch ein Runnable implementiert, die eigentliche Aktion. Ein freier Thread aus dem Thread-Pool (das Crewmitglied) wird ausgewählt und führt das Runnable aus. Das Runnable benötigt eine Möglichkeit, das Geschenk von Bonny Brain entgegenzunehmen.

1.3.2. Letzte Modifikation von Webseiten ermitteln ⭐⭐

Folgende Klasse implementiert eine Methode, die einen Zeitstempel zurückgibt, in der eine Webseite zum letzten Mal verändert wurde (die Daten sind unter Umständen nicht verfügbar, dann steht die Zeit auf dem 1.1.1970). Der Server sollte in Zonenzeit UTC ± 0 senden.

import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;

public class WebChecker {

    public static void main(String[] args) throws IOException {
     ZonedDateTime urlLastModified = getLastModified(new URL("http://www.tutego.de/index.html"));
     System.out.println(urlLastModified);
     ZonedDateTime urlLastModified2 = getLastModified(new URL("https://en.wikipedia.org/wiki/Main_Page"));
     System.out.println(urlLastModified2);
    }

    private static ZonedDateTime getLastModified(URL url) {
     try {
        HttpURLConnection con = (HttpURLConnection) url.openConnection();
        long dateTime = con.getLastModified();
        con.disconnect();
        return ZonedDateTime.ofInstant( Instant.ofEpochMilli( dateTime ), ZoneId.of( "UTC" ) );
     } catch ( IOException e ) {
         throw new IllegalStateException(e);
     }
  }
}

Aufgabe:

  • Lege eine neue Klasse WebResourceLastModifiedCallable an.

  • Gib WebResourceLastModifiedCallable einen Konstruktor, sodass wir eine URL übergeben können.

  • Lass WebResourceLastModifiedCallable die Schnittstelle Callable<ZonedDateTime> implementieren. Setze die Implementierung von getLastModified(URL) aus dem Beispiel in die call()-Methode. Muss call() die geprüfte Ausnahme selbst auffangen?

  • Baue WebResourceLastModifiedCallable Objekte auf, und lasse sie vom Thread-Pool ausführen.

    • Lass das Callable einmal ohne Zeitbeschränkung ausführen.

    • Gib dem Callable nur eine Mikrosekunde zum Ausführen; was ist das Ergebnis?

  • Optional: Rechne die Zeit um, seit wie viel Minuten relativ zur aktuellen Zeit sich die Webseite verändert hat.

1.4. Kritische Abschnitte schützen

Laufen mehrere Programmteile nebenläufig, so kann es passieren, dass sie auf gemeinsame Ressourcen oder Speicherbereiche zugreifen. Solche Zugriffe müssen synchronisiert werden, sodass ein Thread die Arbeit beenden kann, bis ein anderer Thread auf diese Ressource geht. Werden die Zugriffe auf gemeinsame Ressourcen nicht koordiniert, kommt es zu fehlerhaften Zuständen.

Programme müssen kritische Abschnitte schützen, sodass sich in einem Abschnitt nur ein anderer Thread befinden darf. Java bietet zwei Mechanismen:

  1. das Schlüsselwort synchronized

  2. Lock-Objekte

synchronized ist ein praktisches Schlüsselwort, allerdings in der Leistungsfähigkeit beschränkt. Die Java Concurrency Utilities bieten leistungsfähigere Datentypen. Zum »Absperren« von exklusiv ausgeführten Programmteilen existieren die Schnittstelle java.util.concurrent.locks.Lock und diverse Implementierungen, wie ReentrantLock, ReentrantReadWriteLock.ReadLock, ReentrantReadWriteLock.WriteLock.

Lock ReentrantLock UML
Abbildung 3. UML-Diagramm der Typen Lock und ReentrantLock

1.4.1. Erinnerungen ins Poesiealbum schreiben ⭐

Nachdem ein Frachtschiff mit wertvollem Nepenthe erfolgreich den »Eigentümer« gewechselt hat, schreiben die Freibeuter ihre Erinnerungen in ein Poesiealbum, das Captain CiaoCiao später mit Stickern verziert.

Gegeben ist folgender Programmcode in der main(…​)-Methode einer Klasse:

class FriendshipBook {
  private final StringBuilder text = new StringBuilder();

  public void appendChar( char character ) {
    text.append( character );
  }

  public void appendDivider() {
    text.append(
        "\n_,.-'~'-.,__,.-'~'-.,__,.-'~'-.,__,.-'~'-.,__,.-'~'-.,_\n" );
  }

  @Override public String toString() {
    return text.toString();
  }
}

class Autor implements Runnable {
  private final String text;
  private final FriendshipBook book;

  public Autor( String text, FriendshipBook book ) {
    this.text = text;
    this.book = book;
  }

  @Override public void run() {
    for ( int i = 0; i < text.length(); i++ ) {
      book.appendChar( text.charAt( i ) );
      try { Thread.sleep( 1 ); }
      catch ( InterruptedException e ) { /* Ignore */ }
    }
    book.appendDivider();
  }
}

FriendshipBook book = new FriendshipBook();

String q1 = "Die Blumen brauchen Sonnenschein " +
    "und ich brauch Capatain CiaoCiao zum Fröhlichsein";
new Thread( new Autor( q1, book ) ).start();

String q2 = "Wenn du lachst, lachen sie alle. " +
    "Wenn du weinst, weinst du alleine";
new Thread( new Autor( q2, book ) ).start();

TimeUnit.SECONDS.sleep( 1 );

System.out.println( book );

Aufgabe:

  • Überlege vor der Ausführung des Programms, welches Ergebnis zu erwarten ist.

  • Setze den Code in eine eigene Klasse und main(…​)-Methode, und prüfe die Annahme nach.

  • Die Schwachstelle im Code ist der ungezügelte Zugriff auf das FriendshipBook. Verbessere mit einem Lock-Objekt das Programm, damit das FriendshipBook nur von einem Piraten zu einer Zeit beschrieben werden kann.

1.5. Thread-Kooperation und Synchronisationshelfer

Es ist wichtig, Programmcode zu synchronisieren, damit nicht zwei Threads sich gegenseitig ihre Daten überschreiben. Wir haben gesehen, dass das mit Lock-Objekten funktioniert. Lock-Objekte sperren aber lediglich einen kritischen Bereich, und die Laufzeitumgebung wird automatisch einen Thread warten lassen, wenn ein kritischer Bereich gesperrt ist. Eine Erweiterung davon ist, dass ein Thread — oder mehrere Threads — nicht nur auf den Einlass in einen kritischen Abschnitt wartet, sondern über Signale informiert wird, dass er etwas zu tun hat. Java bietet unterschiedliche Synchronisationshelfer, die einen internen Zustand haben, und andere Threads veranlassen, zu warten oder bei gewissen Bedingungen loszulaufen.

  • Semaphore: Während sich bei einem Lock nur ein einziger Thread in einem kritischen Abschnitt befindet, dürften sich bei einer Semaphore eine benutzerdefinierte Anzahl von Threads in einem Block aufhalten. Auch die Methodennamen sind ein wenig anders: Lock deklariert die Methode lock() und Semaphore die Methode acquire(). Ist bei acquire() die maximale Anzahl erreicht, muss ein Thread wie bei einem Lock auf Zugriff warten. Eine Semaphore mit einer maximalen Anzahl von 1 ist wie ein Lock.

  • Condition: Mit einer Condition kann ein Thread sich warten legen und von einem anderen Thread wieder aufgeweckt werden. Mithilfe von Condition-Objekten lassen sich Konsumenten-Produzenten-Verhältnisse programmieren, allerdings gibt es in der Praxis kaum Notwendigkeit für diesen Datentyp, denn es gibt darauf aufbauende Java-Typen, die oftmals einfacher und flexibler sind. Condition ist eine Schnittstelle, und das Lock-Objekt bietet Fabrikmethoden, die Condition-Instanzen liefern.

  • CountDownLatch: Objekte vom Typ CountDownLatch werden mit einer Ganzzahl initialisiert, und verschiedene Threads zählen diesen CountDownLatch herunter, was sie in einen Wartezustand bringt. Wenn zum Schluss der CountDownLatch bei 0 angekommen ist, werden alle Threads wieder freigelassen. Ein CountDownLatch ist somit eine Möglichkeit, verschiedene Threads an einem gemeinsamen Punkt zusammenzubringen. Wenn ein CountDownLatch einmal verbraucht ist, lässt er sich nicht wieder zurücksetzen.

  • CyclicBarrier: Die Klasse ist eine Implementierung einer sogenannten Barriere. Mit einer Barriere können sich mehrere Threads an einem Punkt treffen. Werden z. B. Arbeitsaufträge parallelisiert und müssen sie später wieder zusammengefügt werden, so lässt sich das über eine Barriere realisieren. Nachdem alle Threads an dieser Barriere zusammenkommen sind, laufen sie weiter. Dem Konstruktor von CyclicBarrier kann ein Runnable übergeben werden, das zum Zeitpunkt des Zusammentreffens aufgerufen wird. Im Gegensatz zu einem CountDownLatch lässt sich ein CyclicBarrier zurücksetzen und wiederverwenden.

  • Exchanger: Produzenten-Konsumenten-Verhältnisse kommen in Programmen häufig vor, und der Produzent übermittelt dem Konsument Daten. Genau für diesen Fall gibt es die Klasse Exchanger. Damit können zwei Threads sich an einem Punkt treffen und Daten austauschen.

1.5.1. Am Bankett mit den Captains teilnehmen / Semaphore ⭐⭐

Bonny Brain und Captain CiaoCiao planen ein Bankett mit vielen Weggefährten. Sie sitzen beide an einem Tisch mit 6 Plätzen und empfangen unterschiedliche Gäste. Die Gäste kommen, bleiben ein wenig, erzählen und essen und verlassen wieder den Tisch.

Aufgabe:

  • Lege eine Semaphore mit genauso vielen Plätzen an, wie es gleichzeitig Gäste an dem Tisch mit den Captains geben kann.

  • Modelliere einen Gast als Klasse Guest, die Runnable implementiert. Alle Gäste haben einen Namen.

  • Die Gäste warten auf einen Platz. Es muss nicht »fair« sein, dass also der Gast, der schon am längsten wartet, zwangsläufig als nächster an den Tisch kommt.

  • Das Programm soll eine Bildschirmausgabe tätigen für einen Gast, der gerne an den Tisch kommen möchte, für einen Gast, der einen Platz bekommen hat, und für einen Gast, der den Tisch verlässt.

1.5.2. Fluchen und beleidigen / Condition ⭐⭐

Piraten duellieren sich heutzutage nicht mehr mit Entersäbeln, sondern mit Flüchen.

Aufgabe:

  • Starte zwei Threads, die jeweils zwei Piraten repräsentieren; gib den Threads Namen.

  • Ein zufälliger Pirat startet mit dem Fluchen und bringt einen endlosen Beleidigungswettbewerb in Gang.

  • Die Flüche sollen zufällig aus einer vorgegebenen Sammlung von Flüchen stammen.

  • Vor dem eigentlichen Fluch kann ein Pirat eine »Denkpause« von bis zu einer Sekunde benötigen.

1.5.3. Stifte aus dem Malkasten nehmen / Condition ⭐⭐

Im Kindergarten kommen regelmäßig die kleinen Piraten zusammen und malen Bilder. Leider gibt es nur einen Kasten mit 12 Stiften. Wenn ein Kind Stifte aus dem Kasten genommen hat, muss ein anderes Kind immer dann warten, wenn es mehr Stifte möchte, als im Kasten verfügbar sind.

Das Szenario lässt sich gut mit Threads realisieren.

Aufgabe:

  • Schreibe eine Klasse Paintbox für einen Malkasten. Paintbox soll einen Konstruktor bekommen und die maximale Anzahl der freien Stifte annehmen.

  • In der Klasse Paintbox soll es eine Methode acquirePens(int numberOfPens) geben, mit der die Kinder eine Anzahl Stifte erfragen können. Die gewünschte Anzahl Stifte kann über der verfügbaren Anzahl liegen, dann soll die Methode solange blockieren, bis die Anzahl gewünschter Stifte wieder verfügbar ist.

  • Die Klasse Paintbox fügt zusätzlich über die Methode releasePens(int numberOfPens) zum Zurücklegen der Stifte. Diese Methode signalisiert, dass wieder Stifte verfügbar sind.

  • Lege eine Klasse Child an.

  • Gib der Klasse Child ein Konstruktor, sodass jedes Kind einen Namen hat, und einen Verweis auf einen Malkasten bekommen kann.

  • Die Klasse Child soll die Schnittstelle Runnable implementieren. Die Methode soll eine Zufallszahl zwischen 1 und 10 bestimmen, dass die Anzahl der gewünschten Stifte repräsentiert. Dann erfragt das Kind diese Anzahl Stifte von Malkasten. Das Kind nutzt die Stifte zwischen 1 und 3 Sekunden und legt anschließend alle Stifte wieder in den Malkasten zurück — nicht mehr und auch nicht weniger. Danach wartet das Kind zwischen 1 und 5 Sekunden und beginnt erneut damit eine zufällige Anzahl Stifte zu fordern.

Das Malen kann mit folgenden Kindern gestartet werden:

public static void main( String[] args ) {
  Paintbox paintbox = new Paintbox( 12 );
  ExecutorService executor = Executors.newCachedThreadPool();
  executor.submit( new Child( "Mirjam", paintbox ) );
  executor.submit( new Child( "Susanne", paintbox ) );
  executor.submit( new Child( "Serena", paintbox ) );
  executor.submit( new Child( "Elm", paintbox ) );
}

1.5.4. Schere, Papier, Stein spielen / CyclicBarrier ⭐⭐⭐

Schere, Stein, Papier (auch Schnick, Schnack, Schnuck) ist ein altes Spiel, das schon im 17. Jahrhundert gespielt wurde. Nach einem Startsignal bilden die beiden Spieler mit einer Hand eine Form für Schere, Stein oder Papier. Welcher Spieler gewinnt, bestimmen folgende Regeln:

  • Schere schneidet das Papier (Schere gewinnt)

  • Papier wickelt den Stein ein (Papier gewinnt)

  • Stein macht die Schere stumpf (Stein gewinnt)

Jedes Handzeichen kann also gewinnen oder verlieren.

Wir wollen eine Simulation für das Spiel schreiben, und nehmen folgende Aufzählung für Handzeichen als Basis:

enum HandSign {
  SCISSORS, ROCK, PAPER;

  static HandSign random() {
    return values()[ ThreadLocalRandom.current().nextInt( 3 ) ];
  }

  int beats( HandSign other ) {
    return (this == other) ? 0 :
           (this == HandSign.ROCK && other == HandSign.SCISSORS
            || this == HandSign.PAPER && other == HandSign.ROCK
            || this == HandSign.SCISSORS && other == HandSign.PAPER) ? +1 : -1;
 }
}

Die enum HandSign deklarierte drei Aufzählungselemente für Schere, Stein, Papier. Die statische Methode random() liefert ein zufälliges Handzeichen. Die Methode beats(HandSign) ist ähnlich wie eine Comparator-Methode: Sie vergleicht das aktuelle Handzeichen mit dem übergebenen Handzeichen und liefert 0, wenn beide Handzeichen gleichwertig sind, +1 wenn das eigene Handzeichen höherwertig ist als das übergebene Anzeichen und -1 sonst.

Aufgabe:

  • Lasse einen Starter-Thread laufen, der jede Sekunde ein Schnick-Schnack-Schnuck-Spiel anstößt. Für die wiederholte Ausführung lässt sich auf einen ScheduledExecutorService zurückgreifen.

  • Ein Spieler ist durch ein Runnable repräsentiert, das ein zufälliges Handzeichen wählt, und die Wahl mit add(…​) in eine Datenstruktur vom Typ ArrayBlockingQueue setzt.

  • Nachdem ein Spieler ein Handzeichen gewählt hat, soll auf einer vorher aufgebauten CyclicBarrier die await()-Methode aufgerufen werden.

  • Der Konstruktor von CyclicBarrier soll ein Runnable bekommen, was am Ende des Spiels den Gewinner ermittelt. Das Runnable nimmt aus der ArrayBlockingQueue die beiden Handzeichen mit poll() heraus, vergleicht sie, und wertet den Gewinner und Verlierer aus. An der ersten Stelle der Datenstruktur steht der Spieler 1, an der zweiten Position der Spieler 2.

1.5.5. Den schnellsten Läufer finden / CountDownLatch ⭐⭐

Für den nächsten Überfall benötigt Bonny Brain schnelle Läufer. Dafür richtet sie einen Wettbewerb aus und lässt die besten Läufer antreten. Mit einer Startpistole steht Bonny Brain an der Bahn, und alle warten auf den Startschuss.

Aufgabe:

  • Lege 10 Threads an, die auf das Signal von Bonny Brain warten. Dann starten die Threads und benötigen zwischen einer frei gewählten Anzahl von 10 und 20 Sekunden für den Lauf. Am Ende sollen die Threads ihre Zeit in einer gemeinsame Datenstruktur schreiben, sodass der Name des Threads (Läufername) mit der Laufzeit vermerkt wird.

  • Bonny Brain startet die Läufer im main-Thread, und gibt zum Schluss alle Laufzeiten aufsteigend sortiert mit den Läufernamen aus.

Sollen mehrere Threads an einer Stelle zusammenkommen, lässt sich dafür gut ein CountDownLatch einsetzen. Der CountDownLatch wird mit einer Ganzzahl (einem Zähler) initialisiert und bietet zwei zentrale Methoden:

  • countDown() vermindert den Zähler,

  • await() blockiert solange, bis der Zähler 0 wird.