Applets und Threads

Threads (manchmal auch als Lightweight-Prozess bezeichnet) sind (meist kleinere) Programmteile, die parallel ablaufen. Ein einzelner Thread wird innerhalb eines Programms durch eine sequentielle Anweisungsfolge charakterisiert. Im Gegensatz zu Programmen können aber Threads nicht selbstständig ablaufen. Während ein Thread läuft, können daher andere Programmteile gleichzeitig andere Aufgaben wahrnehmen. Dies wird auch als Multitasking bezeichnet. Task ist das englische Wort für Aufgabe; durch den Begriff Multitasking wird daher angegeben, dass mehrere Aufgaben simultan bearbeitet werden können.

Wird ein Thread verwendet, um Aufgaben abzuarbeiten, die kontinuierlich ablaufen und die eventuell eine große Menge an Prozessorkapazität benötigen, so wird der außerhalb des Threads vorhandene Code bei der Abarbeitung nicht blockiert, da eine parallele Verarbeitung stattfindet.

Die grundsätzlichen Zustände, in denen sich ein Thread befinden kann, sind in Abb. 4-5 dargestellt. Ein Thread wechselt in den Zustand nicht laufend, wenn

  • die sleep-Methode aufgerufen wird,
  • der Thread die wait-Methode aufruft, um auf die Erfüllung einer bestimmten Bedingung zu warten, oder
  • der Thread wegen einer I/O-Operation blockiert.

Für jeden Wechsel eines Threads in den Zustand nicht laufend existiert genau eine Routine, die den Thread wiederum in den Zustand ablaufend versetzt. Der Aufruf der jeweiligen Methode hängt von dem Ereignis ab, das den Übergang in den Zustand nicht laufend bewirkt hat. Wenn ein Thread bspw. mittels sleep eine Zeit warten soll, so muss diese Zeit (in Millisekunden) verstreichen, bevor der Thread wieder „aufgeweckt" wird. Die folgende Liste beinhaltet die Routinen, die den Thread wieder in den Zustand laufend versetzen:

kap45 

Abb. 4.5: Thread-Zustandsdiagramm

  • Wenn innerhalb des Threads sleep aufgerufen wurde, muss die angegebene Zeit in Millisekunden verstreichen.
  • Wenn ein Thread auf die Erfüllung einer Bedingung wartet, muss das Objekt, das für die Erfüllung der Bedingung zuständig ist, den Thread auf den Wechsel des Bedingungsstatus hinweisen, indem notify oder notifyAll aufgerufen werden.
  • Ein Thread, der aufgrund einer I/O-Operation wartet, kann erst dann fortfahren, wenn diese Operation abgeschlossen ist.

Werden Abläufe eines Applets in Threads isoliert, so sind die folgenden Schritte durchzuführen:

  1. Erweiterung der Klassendefinition um die Schlüsselworte implements Runnable.
    Das Schlüsselwort
    implements wird ähnlich wie extends verwendet, da die Klassendeklaration modifiziert wird. Das folgende Beispiel verwendet sowohl implements als auch extends:

code 

import java.applet.Applet;
import java.awt.*;
public class Beispiel extends Applet implements Runnable{

    // abzuarbeitender Code

    }

}

    Runnable ist ein Interface. Die Methoden, die Runnable zur Verfügung stellt, können von jeglicher Klasse implementiert werden, die diese Funktionalität benötigt. Runnable spezifiziert bspw. die Definition der start-Methode, die zum Starten eines Threads aufgerufen wird. Die hierzu notwendige Klasse Thread ist Teil des Packages java.lang, das allen Anwendungen ohne vorherige Importierung zur Verfügung steht.

  • Initialisierung eines Thread-Objekts.
    Zur Programmierung mit Threads muss ein Thread zunächst definiert und anschließend gestartet werden. Das folgende Beispiel verdeutlicht dieses Vorgehen:
  • code 

    import java.applet.Applet;
    import java.awt.*;
    public class Beispiel extends Applet implements Runnable{

      Thread t;
      public void start() {

        if (t == null){

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

        }

      }

    }

      In diesem Beispiel muss das Schlüsselwort this im Thread-Konstruktoraufruf verwendet werden, um das Objekt zu referenzieren, in dem die Methode läuft. In diesem Fall ist dies das Applet selbst. Um einen Thread zu starten, wird anschließend die start-Methode aufgerufen. Diese belegt die notwendigen Systemressourcen, plant einen Thread ein (Scheduling) und ruft anschließend die run-Methode auf.

    • Definition der run-Methode.
      Der Aufruf der
      start-Methode verursacht den Aufruf der run-Methode, in der die eigentliche Funktionalität eines Threads definiert ist. Hierzu muss eine run-Methode angegeben werden. Im Beispiel implementiert das Applet das runnable-Interface. Die Verbindung zwischen Applet und Thread wurde durch das this-Schlüsselwort hergestellt.
    • code 

      public void run (){

        // abzuarbeitender Code des Threads

      }

      1. Der vierte Schritt betrifft das Anhalten eines Threads mittels der stop-Methode. Meist geht man hierbei so vor, dass man das Thread-Objekt auf den Wert null setzt. Dies hält den Thread allerdings nur dann an, wenn innerhalb der run-Methode überprüft wird, auf welchem Wert das Thread-Objekt steht. Das folgende Beispiel zeigt das Zusammenspiel zwischen start, run und stop. Hierzu wird in der run-Methode zunächst eine temporäre Variable vom Typ Thread erzeugt, mit der überprüft wird, ob der in run verwendete Thread derselbe wie vor einer Unterbrechung ist. Hierzu wird die Methode currentThread verwendet, die Teil der Klasse Thread ist. Weiterhin wird in der run-Methode überprüft, ob eine Ausnahmebehandlung notwendig ist. Wurde eine Ausnahme festgestellt, so wird zunächst die run-Methode verlassen und in der stop-Methode der Wert der Variablen t verändert. Anschließend wird die run-Methode fortgesetzt, jedoch festgestellt, dass sich der Wert der Thread-Variablen geändert hat. Dies verursacht einen Abbruch der while-Schleife, womit der Thread beendet ist.

      code 

      import java.applet.Applet;
      import java.awt.*;
      public class Beispiel extends Applet implements Runnable{

        Thread t;
        public void start() {

          if (t == null){

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

          }

        }
        public void run (){

          Thread runThread = Thread.currentThread();
          while (runThread == thisThread) {

            // abzuarbeitender Code des Threads
            try {

              Thread.sleep(1000);

            }catch (InterruptedException ex) {}

          }

        }
        public void stop() {

          if (t != null)

            t = null;

        }

      }

      Prioritäten von Threads

      Eine der wichtigen Eigenschaften von Threads ist, dass sie parallel ablaufen können. Da die meisten heute verwendeten Rechner aber nur eine CPU haben, ist dies in der Praxis nur teilweise richtig, da zwischen den verschiedenen Prozessen eines Rechners so schnell hin und hergeschaltet wird, dass die Illusion der Parallelität entsteht. Die Einplanung der Ausführung einer Anzahl von Prozessen bezeichnet man in diesem Zusammenhang auch als Scheduling. Die Laufzeitumgebung von Java unterstützt einen sehr einfachen deterministischen Einplanungsmechanismus, das Scheduling nach festen Prioritäten. Hierbei werden Threads derart eingeplant, dass ihre Priorität relativ zu der anderer ablaufender Threads beachtet wird.

      Wenn in Java ein Thread erzeugt wird, erbt er die Priorität von dem Thread, der ihn erzeugt hat. Diese Priorität kann jederzeit zur Laufzeit verändert werden, indem die setPriority-Methode aufgerufen wird. Thread-Prioritäten sind Ganzzahlen im Bereich zwischen MIN_PRIORITY und MAX_PRIORITY, die als Konstanten in der Klasse Thread definiert sind. Je höher der Zahlenwert, desto höher auch die Priorität eines Threads. Zu jeder Zeit, in der das System den nächsten abzuarbeitenden Thread auswählt, wird der Thread mit der höchsten Priorität aktiv. Threads mit niedrigerer Priorität werden nur dann gestartet, wenn dieser Thread aus irgendeinem Grund anhält. Wenn Threads derselben Priorität auf die CPU warten, wählt der Scheduler den nächsten abzuarbeitenden Thread nach dem Round-Robin-Verfahren (alle Threads in einem Kreis angeordnet) aus. Der ausgewählte Thread läuft dann solange ab, bis die folgenden Bedingungen erfüllt sind:

      • ein Thread höherer Priorität wechselt in den Zustand laufend,
      • der Zeitschlitz ist abgelaufen (nur auf Systemen, die dieses Verfahren verwenden) oder
      • er ist abgearbeitet.

      Die Laufzeitumgebung von Java arbeitet weiterhin präemptiv. Wird ein Thread mit einer höheren Priorität als der der momentan ausgeführten lauffähig, so wird dieser unmittelbar gestartet und alle anderen Threads angehalten. Grundsätzlich läuft also immer derjenige Thread ab, der die höchste Priorität hat. Um allerdings ein „Verhungern" anderer Threads zu verhindern, kann der Scheduler auch einen Thread mit einer niedrigeren Priorität auswählen. Prioritäten sollten daher nur eingesetzt werden, um die Effizienz von Programmen zu steigern.

      Das folgende Beispiel verwendet drei Threads, die eine unterschiedliche Priorität haben. Während der Thread mit der höchsten Priorität nur zur Unterbrechung der Abarbeitung eines anderen Threads verwendet wird, ist klar ersichtlich, dass die beiden anderen Threads nicht gleich behandelt werden. Jedes Mal, wenn einer der zwei niedrig-priorisierten Threads ausgewählt werden soll, wird der Prozess mit der höheren Priorität gestartet. Dies ist vor allem deswegen unfair, weil beide Prozesse dieselbe Aufgabe wahrnehmen und daher in gleicher Geschwindigkeit abgearbeitet werden sollten.

      code 

      public class Unfair extends Applet implements Runnable {

        public Zaehler zaehler1, zaehler2;
        public Thread dummyThread = null;
        public void init() {

          zaehler1= new Zaehler();
          zaehler2= new Zaehler();
          zaehler1.setPriority(3);
          zaehler2.setPriority(2);
          if (dummyThread == null) {

            dummyThread = new Thread(this, "zaehler");
            dummyThread.setPriority(4);

          }

        }
        public void run() {

          Thread tmpThread = Thread.currentThread();
          while (dummyThread == tmpThread) {

            try {

              Thread.sleep(10);

            } catch (InterruptedException e) { }

          }

        }

        public class Zaehler extends Thread {

          int schritt = 1;
          public void run() {

            while (schritt < 1000)

              schritt++;

          }

        }

      }

      Die in diesem Beispiel verwendete Zaehler-Klasse ist in einer Art und Weise implementiert, die eine Ungleichbehandlung anderer Threads nach sich ziehen kann. Problematisch ist, dass die while-Schleife in jedem Fall abgearbeitet wird, wenn der Scheduler einen Thread mit einer derartigen Definition auswählt. Dieser Thread wird also niemals freiwillig die Kontrolle über die CPU-Zeit abgeben, es sei denn, er ist abgearbeitet oder ein Thread mit höherer Priorität wird gestartet. Derartige Threads arbeiten unfair. In einigen Fällen wird die Verwendung unfairer Threads keine Konsequenzen haben, da ein Thread mit einer höheren Priorität einen unfairen abbrechen kann. Wie im Beispiel dargestellt, werden die Zähler-Threads durch den Prozess mit der höchsten Priorität unterbrochen. In anderen Situationen können allerdings unfaire Threads die CPU vollständig übernehmen und daher dafür sorgen, dass alle weiteren Threads solange warten müssen, bis der unfaire Thread beendet ist. Einige Systeme verhindern jedoch bereits standardmäßig ein solches Verhalten, bspw. Windows-Systeme der Firma Microsoft. Unfaire Threads werden hier mit sog. Zeitschlitzen verhindert. Die Zeitschlitz-Strategie wird immer dann angewendet, wenn mehrere ablauffähige Threads existieren, die eine gleiche Priorität haben und die die am höchsten priorisierten Threads sind, die sich um die Verwendung der CPU bewerben. Die im Beispiel erläuterten Threads gehören zu dieser Kategorie.

      Im Folgenden wird die run-Methode so modifiziert, dass sie eine Schleife enthält, die eine Zählervariable erhöht, und die alle 100 Schritte eine Ausgabe vornimmt, die die Nummer des Threads und die Schrittzahl ausgibt. Es ist nun leicht erkennbar, ob ein Zeitschlitz-System verwendet wird, da auf derartigen Systemen die Ausgabe jeweils von beiden Prozessen gemischt erzeugt wird.

      code 

      public class Zaehler extends Thread {

        int schritt = 1;
        private nummer;
        public void start(int nummer) {

          this.nummer = nummer;

        }
        public void run() {

          while (schritt < 1000){

            schritt++;

            if ((schritt%100) == 0)

              System.out.println("Thread " + nummer + ", schritt = "

          + schritt);

          }

        }

      }

      Die Ausgabe, die sich auf Systemen ergibt, die Zeitschlitze verwenden, sieht dann wie folgt aus:

      ausgabe 

      Thread 1, schritt = 100
      Thread 1, schritt = 200
      Thread 0, schritt = 100
      Thread 1, schritt = 300
      Thread 0, schritt = 200

      Diese unregelmäßige Ausgabe kann damit erklärt werden, dass auf einem Zeitschlitz-System die CPU in Zeitschlitze unterteilt wird und jedem der Threads mit gleich hoher Priorität ein Zeitschlitz zugewiesen wird, in dem er ablaufen muss. Das Zeitschlitz-System durchläuft dann die Liste aller Threads mit höchster (aber gleicher) Priorität und weist jedem eine gewisse Menge der Prozessorzeit zu. Dies wird solange fortgesetzt, bis einer dieser Threads endet oder bis ein Prozess mit höherer Priorität gestartet wird. Es wird hierbei aber keine Gewähr dafür übernommen, wie oft oder in welcher Reihenfolge Threads eingeplant werden. Die Ausgabe erfolgt daher im obigen Beispiel nicht abwechselnd zwischen den Threads.

      Wird ein derartiges Programm auf einem System eingesetzt, das die Zeitschlitz-Technik nicht verwendet, so werden zunächst die Ausgaben des höher priorisierten Threads erzeugt und anschließend die des niedriger priorisierten. Dies liegt daran, dass ein System, das die Zeitschlitz-Technik nicht einsetzt, einen der Threads zur Abarbeitung auswählt, die die höchste Priorität haben. Dieser Thread darf dann solange laufen, bis er die CPU wieder freigibt oder bis er von einem Thread höherer Priorität unterbrochen wird. Java selbst implementiert keinen Zeitschlitz-Mechanismus. Ob das System des Lesers diese Technik einsetzt, kann leicht geprüft werden, indem das obige Beispiel übersetzt und ausgeführt wird.

      Es wird ersichtlich, dass die Verwendung CPU-intensiver Threads negative Auswirkungen auf andere Threads haben kann, die Teil desselben Programms sind. Allgemein gilt daher, dass stets die Verwendung solcher Threads zu empfehlen ist, die periodisch die CPU freigeben und so anderen Threads die Möglichkeit einräumen, ausgeführt zu werden. Insbesondere sollte von der Implementierung von Java-Code abgesehen werden, der nur in Kombination mit der Zeitschlitz-Technik funktioniert. Ein solcher Ansatz würde fast sicher zu unterschiedlichen Resultaten auf verschiedenen Plattformen führen.

      Ein Thread kann in Java so implementiert werden, dass er regelmäßig die CPU freigibt. Hierzu muss die yield-Methode verwendet werden, die Threads derselben Priorität die Möglichkeit gibt, gestartet zu werden. yield wird immer dann ignoriert, wenn keine Threads derselben Priorität ausgeführt werden müssen. Die yield-Methode, deren Verwendung im folgenden Beispiel dargestellt ist, wird grundsätzlich aus der run-Methode eines Threads aufgerufen.

      code 

      public class Zaehler extends Thread {

        int schritt = 1;
        private nummer;
        public void start(int nummer) {

          this.nummer = nummer;

        }
        public void run() {

          while (schritt < 1000){

            schritt++;

            if ((schritt%100) == 0)

              System.out.println("Thread " + nummer + ", schritt = " + schritt);

            yield();

          }

        }

      }

      Synchronisation von Threads

      Bisher wurden unabhängige, asynchrone Threads erläutert, die dadurch charakterisiert sind, dass jeder Thread alle Daten und Methoden enthält, die für seine Ausführung erforderlich sind. Ressourcen von außerhalb wurden hierbei nicht hinzugebunden. Zusätzlich wurde bisher kein Wissen über andere Systemzustände oder über Aktivitäten anderer parallel ablaufender Threads verwendet. In vielen Fällen ist es jedoch außerordentlich sinnvoll, dass parallel ablaufende Threads auf dieselben Daten zugreifen und daher Kenntnis über den Zustand und die Aktivitäten anderer Threads haben. Ein Beispiel, das in diesem Zusammenhang in der Informatik häufig gebraucht wird, ist das Produzenten-Konsumenten-Problem, in dem ein Produzent einen Strom von Daten erzeugt, die von einem Konsumenten verbraucht werden. Ein Beispiel für eine derartige Anwendung ist eine Netzwerkanwendung, die empfangene Daten in einen Puffer schreibt. Im Falle einer Videoanwendung wäre der Konsument ein Player, der die Daten aus dem Puffer ausliest und diese anzeigt. Die Threads, die diese Aufgaben erfüllen, laufen parallel ab und greifen auf dieselben Daten zu. Um diese Aufgabe zu erfüllen, ist eine Synchronisation der beteiligten Threads notwendig.

      Das folgende Beispiel zeigt das Zusammenspiel zweier Threads auf. Hierbei erzeugt ein Produzent jeweils eine Zahl und speichert diese in einer Variablen. Anschließend pausiert er mittels der sleep-Methode für eine zufällige Zeit (diese wird von der Methode random berechnet), bevor die nächste Zahl erzeugt wird. Der Konsument verbraucht die abgelegten Zahlen so schnell wie möglich. Der Code für Produzent und Konsument sehen dann wie folgt aus:

      code 

      public class Produzent extends Thread {

        private Speicher speicher;
        private int nummer;
        public Produzent(Speicher s, int nummer) {

          speicher = s;
          this.nummer = nummer;

        }
        public void run() {

          char zahl;
          for (int i = 0; i < 100; i++) {

            speicher.ablage(zahl);
            System.out.println("Produzent " + this.nummer + " lege ab: i");
            try {

              sleep((int)(Math.random() * 1000));

            } catch (InterruptedException e) { }

          }

        }

      }

      Der Konsument liest die Zahl folgendermaßen wieder aus:

      code 

      public class Konsument extends Thread {

        private Speicher speicher;
        private int nummer;
        public Konsument(Speicher s, int number) {

          speicher = s;
          this.nummer = nummer;

        }
        public void run() {

          int wert = 0;
          for (int i = 0; i < 500; i++) {

            wert = speicher.hole();
            System.out.println("Konsument " + this.nummer + " hole: "+ wert);

          }

        }

      }

      Mittels des Objekts Speicher verwenden Produzent und Konsument dieselben Daten. Hierbei stellt der Produzent allerdings nicht sicher, dass der Konsument jede Zahl genau einmal bekommt. Die Synchronisation zwischen den beiden Threads, die mittels der Methoden hole und ablage realisiert wird, erfolgt auf einer niedrigen Ebene. Es soll jedoch zunächst angenommen werden, dass zwischen beiden Threads keinerlei Absprachen bezüglich einer Synchronisation bestehen. Diese Situation ist allerdings deshalb problematisch, da der Produzent schneller Daten erzeugen kann, als der Konsument in der Lage ist, diese zu verbrauchen. Es kann dann geschehen, dass der Konsument eine der erzeugten Zahlen nicht erhält. Umgekehrt führt auch der Fall, in dem der Konsument die Zahlen schneller verbraucht als der Produzent sie erzeugt, zu Problemen, da dann eine Zahl möglicherweise doppelt verwendet wird. Die einzige korrekte Lösung ist jedoch, dass der Konsument jede Zahl genau einmal erhält. Derartige Probleme werden im Kontext von Betriebssystemen oft auch als Wettkampfbedingungen bezeichnet. Sie resultieren immer aus der Verwendung von mehreren asynchron ablaufenden Threads, die auf ein einzelnes Objekt gleichzeitig zugreifen wollen.

      Wettkampfbedingungen können vermieden werden, wenn der Zugriff auf gemeinsam verwendete Objekte synchronisiert wird. Hierdurch wird jede der generierten Zahlen vom Konsumenten genau einmal verbraucht. Die Synchronisation muss hierbei zwei Abläufe enthalten: Die Threads dürfen erstens auf das gemeinsam verwendete Objekt nicht simultan zugreifen und zweitens müssen beide Threads eine einfache Koordination vornehmen. Ein Java-Thread kann ein Objekt sperren und somit verhindern, dass andere Threads dieses Objekt verwenden, während er damit arbeitet. Versucht ein zweiter Thread, diese Daten zu verwenden, so wird er solange blockiert, bis das Objekt wieder freigegeben wird. Der Produzent muss weiterhin koordiniert mit dem Konsumenten zusammenarbeiten, er muss diesem also mitteilen, dass ein Wert zur Abholung bereitsteht. Die Klasse Thread bietet hierzu die Methoden wait, notify und notifyAll an, mit denen Threads, die auf den Eintritt einer Bedingung warten, von anderen Threads benachrichtigt werden können, dass die Bedingung eingetreten ist. notifyAll und wait werden im Folgenden noch detailliert erklärt.

      Die Programmteile, die aus separaten, parallel ablaufenden Threads auf dasselbe Objekt zugreifen, werden im Kontext von Betriebssystemen oft auch als kritische Abschnitte bezeichnet. In Java kann ein kritischer Abschnitt, der mit dem Schlüsselwort synchronized bezeichnet wird, ein Anweisungsblock oder eine Methode sein. Ein Sperrmechanismus wird dann automatisch von der Java-Plattform für ein Objekt angelegt, das ein synchronisiertes Code-Objekt verwendet.

      Im Beispiel sind die Methoden hole und ablage die kritischen Abschnitte, da hier auf das gemeinsam verwendete Objekt zugegriffen wird. Beide Methoden müssen daher folgendermaßen mit dem Schlüsselwort synchronized ausgezeichnet werden:

      code 

      //Speicherobjekt
      public class Speicher {

        private int inhalt;
        private boolean fertig = false;
        public synchronized int hole() {

          //...

        }
        public synchronized void ablage (int wert) {

          //...

        }

      }

      Mit Hilfe der derart ausgezeichneten Methoden erzeugt das System für jede Instanz des Speicherobjekts einen eindeutigen Sperrmechanismus. Jedes Mal wenn eine derartige Methode ausgeführt wird, sperrt der Thread das Objekt, dessen Methode aufgerufen wurde. Andere Threads können dann eine synchronisierte Methode desselben Objekts solange nicht aufrufen, bis das Objekt wieder freigegeben wird. Im Beispiel sperrt daher der Produzent die ablage-Methode des Speicher-Objekts und verhindert dadurch, dass der Konsument auf die get-Methode desselben Objekts zugreift.

      code 

      //Speicherobjekt
      public class Speicher {

        private int inhalt;
        private boolean fertig = false;
        public synchronized int hole() {

          // Speicher gesperrt
          //...
          // Speicher freigegeben

        }
        public synchronized void ablage (int wert) {

          // Speicher gesperrt
          //...
          // Speicher freigegeben

        }

      }

      Die Ausführung des Sperrens und Freigebens wird von der Java-Laufzeitumgebung in einer atomaren Operation automatisch durchgeführt. Hierdurch wird sichergestellt, dass Wettkampfbedingungen in der Implementierung der Threads nicht auftreten können. Dies garantiert die Integrität der Daten. Neben der Synchronisation müssen zwei Threads aber weiterhin in der Lage sein, den jeweiligen Partner zu benachrichtigen, wenn eine Aufgabe erfüllt ist.

      Das Speicher-Objekt legt Daten in einer als private gekennzeichneten Variable namens inhalt ab. Speicher verwendet eine weitere boole'sche private-Variable namens fertig. fertig steht dann auf true, wenn ein Wert abgelegt, aber noch nicht abgerufen wurde und respektive auf false, wenn der Wert abgerufen, aber noch kein neuer erzeugt wurde. Eine nahe liegende (aber fehlerhafte!) Implementierung könnte folgendermaßen aussehen:

      code 

      public class Speicher {

        private int inhalt;
        private boolean fertig = false;
        public synchronized int hole() {

          if (fertig == false)

            fertig = true;
            wert=1;

        }
        public synchronized void ablage (int wert) {

          // Fehlerhaft!!!
          if (fertig == true)

            fertig = false;

          return inhalt;

          }

        }

      }

      Derart implementierte Methoden können nicht korrekt funktionieren. Am Beispiel der get-Methode ist ersichtlich, dass keine Abarbeitung erfolgt, wenn der Produzent nichts im Speicher-Objekt abgelegt hat und wenn fertig ungleich true ist. In ähnlicher Weise erfolgt in put keine Abarbeitung, wenn der Produzent put aufruft, bevor der Konsument den Wert erhalten hat.

      Der Konsument sollte daher warten, bis der Produzent einen Wert im Speicher abgelegt hat. Hierzu muss er aber vom Produzenten benachrichtigt werden. In ähnlicher Art und Weise muss der Produzent warten, bis der Konsument den Wert abgerufen hat, bevor er einen neuen Wert erzeugt. Auch der Produzent muss benachrichtigt werden, wenn dies erfolgt ist. Zur koordinierten Abarbeitung von Threads können die Methoden wait und notifyAll eines Objekts eingesetzt werden. Das folgende Beispiel zeigt die Verwendung beider Methoden auf.

      code 

      //Speicherobjekt
      public class Speicher {

        private int inhalt;
        private boolean fertig = false;
        public synchronized int hole() {

          while (fertig == false) {

            try {

              wait();

            } catch (InterruptedException e) { }

          }
          fertig = false;
          notifyAll();
          return inhalt;

        }
        public synchronized void ablage (int wert) {

          while (fertig == true) {

            try {

              wait();

            } catch (InterruptedException e) { }

          }
          inhalt = wert;
          fertig = true;
          notifyAll();

        }

      }

      Der Code in der get-Methode durchläuft die Schleife solange, bis der Produzent einen neuen Wert erzeugt hat. In jedem Schleifendurchlauf wird die wait-Methode aufgerufen, die weiterhin bewirkt, dass der Block freigegeben wird. Hierdurch wird der Produzent in die Lage versetzt, auf das Speicher-Objekt zuzugreifen. Anschließend wartet der Konsument auf eine Benachrichtigung durch den Produzenten. Nachdem der Produzent Daten im Speicher-Objekt abgelegt hat, benachrichtigt er den Konsumenten mittels der Methode notifyAll. Hierdurch wird der Konsument wieder aufgeweckt und kann das Speicher-Objekt verwenden, da die Variable fertig nun den Wert true hat. Auch put funktioniert auf diese Art und Weise, indem der Konsumenten-Thread darauf wartet, einen Wert zu verbrauchen, bis der Produzent einen neuen erzeugt hat.

      Es ist Aufgabe der notifyAll-Methode, alle Threads aufzuwecken, die auf ein gesperrtes Objekt warten. Anschließend treten diese Threads in Wettstreit um das Objekt, wenn mehrere Threads simultan auf dieses zugreifen wollen. Die Threads, die das Objekt nicht erhalten, kehren in den Wartezustand zurück. Weiterer Bestandteil der Klasse Object ist die Methode notify, die in einer Zufallsauswahl einen der Threads aufweckt, die auf ein Objekt warten.

      Die Klasse Object enthält neben der Version der Methode wait, die in diesem Beispiel verwendet wurde und die unendlich auf eine Benachrichtigung wartet, zwei weitere Versionen der wait-Methode:

      • wait(long timeout)
        Erwartet die Benachrichtigung in einer Zeit, die kleiner als der Wert
        timeout (in Millisekunden) ist.
      • wait(long timeout, int nanos)
        Erwartet die Benachrichtigung in einer Zeit, die kleiner als der Wert
        timeout (in Millisekunden) zuzüglich des Werts nanos (in Nanosekunden) ist.

      Nachdem nun alle Kenntnisse vorhanden sind, die zur Implementierung synchronisierter Threads nötig sind, kann das Programm vervollständigt werden, indem das Hauptprogramm angegeben wird. Die folgende Java-Application erzeugt das Speicher-Objekt, den Produzenten und den Konsumenten und startet die letzteren beiden.

      code 

      //Hauptprogramm
      public class ProducerConsumerTest {

        public static void main(String[] args) {

          Speicher s= new Speicher();
          Produzent p = new Produzent(s, 1);
          Konsument k= new Konsument(s, 1);
          p.start();
          k.start();

        }

      }

      Gruppierung von Threads

      Jeder Java-Thread ist Mitglied einer Thread-Gruppe, die Mechanismen bereitstellt, um mehrere Threads in einem einzigen Objekt zu gruppieren und alle Threads in einem Zug zu manipulieren, anstatt auf jeden einzelnen getrennt zuzugreifen. Hierdurch können bspw. alle Threads einer Gruppe auf einmal aufgeweckt werden, indem ein einziges Kommando verwendet wird. Die Gruppierung von Threads wird in Java durch die Klasse ThreadGroup implementiert, die Teil des Packages java.lang ist.

      Die Einordnung eines Threads in eine Gruppe wird vom Laufzeitsystem während der Einrichtung eines Threads vorgenommen. Ein neuer Thread kann entweder in eine Standardgruppe eingefügt werden oder explizit in einer vom Benutzer angegebenen. Ein Thread ist anschließend permanentes Mitglied der Gruppe, der er bei der Einrichtung zugeordnet wurde. Ein Gruppenwechsel zur Laufzeit ist daher unmöglich.

      Wenn ein neuer Thread generiert wird, ohne dass seine Gruppe im Konstruktor angegeben wird, so wird er vom Laufzeitsystem automatisch in dieselbe Gruppe eingefügt, in der der Thread abläuft, der ersteren generierte. Dies wird oft auch als aktuelle Thread-Gruppe bzw. als aktueller Thread bezeichnet. Wenn eine Java-Application das erste Mal gestartet wird, wird eine Thread-Gruppe namens main angelegt, in der alle neuen Threads eingefügt werden, wenn keine andere Angabe im Konstruktor enthalten ist.

      Wenn ein Thread in einem Applet erzeugt wird, kann die Gruppe eines Threads allerdings auch eine andere als main sein, abhängig vom Browser oder Viewer, in dem das Applet läuft. Dies liegt daran, dass ein Applet in mehreren Threads lauffähig ist. Grafikroutinen wie paint oder update werden immer aus dem Thread aufgerufen, der die AWT-Grafikroutinen und die notwendige Ereignisbehandlung abarbeitet. Die Threads, die Methoden wie init, start, stop und destroy verarbeiten, hängen von den Anwendungen ab, die das Applet ausführen. Derartige Routinen werden allerdings von AWT-Threads niemals aufgerufen.

      Viele Browser legen einen eigenen Thread für jedes Applet einer Webseite an. Der jeweilige Thread wird dann dazu verwendet, die wichtigsten Methoden eines Applets aufzurufen. Manche Browser legen aber für jedes Applet auch eine Thread-Gruppe an, so dass alle Threads, die zu einem bestimmten Applet gehören, auf einmal beendet werden können. In jedem dieser Fälle ist aber garantiert, dass jeder Thread, der die wichtigsten Methoden eines Applets aufruft, zu ein und derselben Gruppe gehört.

      Der Grund, dass ein Applet seine eigenen Threads erzeugt und benutzt, liegt darin, dass verhindert werden soll, dass ein Thread eines Applets andere Applets einer Webseite behindert. Ein Applet, das bspw. in der init-Methode eine zeitaufwendige Initialisierung durchläuft, ist dadurch gekennzeichnet, dass der Thread, der init aufruft, blockiert bis init beendet ist. Ist ein Applet bspw. an oberster Position einer Webseite, so würde keine der darzustellenden Komponenten angezeigt werden bis init beendet ist. Selbst in Browsern, die einen separaten Thread für jedes Applet anlegen, ist es durchaus sinnvoll, zeitaufwendige Aufgaben in Threads zu verlagern, die von einem Applet erzeugt werden. Dies stellt sicher, dass ein Applet andere Aufgaben wahrnehmen kann, während die zeitaufwendigen vervollständigt werden. Hierbei kann als Daumenregel gelten, dass ein Applet immer dann einen eigenen Thread anlegen und verwenden sollte, wenn zeitaufwendige Aufgaben zu bearbeiten sind. Applets verarbeiten typischerweise zwei Arten von zeitaufwendigen Aufgaben: Solche, die genau einmal ausgeführt werden und solche, die periodisch auftreten.

      Viele Java-Programmierer ignorieren Thread-Gruppen vollständig und überlassen es dem Laufzeitsystem, Details hinsichtlich Thread-Gruppen zu verarbeiten. Wenn ein Programm allerdings eine große Anzahl von Threads erzeugt, die besser als Gruppe manipuliert werden können, oder wenn bspw. ein eigener Security-Manager implementiert wird, so ist eine Kontrolle der Thread-Gruppen wünschenswert.

      Es wurde bereits erläutert, dass ein Thread ein permanentes Mitglied der jeweiligen Thread-Gruppe ist, der er bei der Generierung beitritt. Wenn also ein Thread in eine andere als in die Standardgruppe eingeordnet werden soll, muss dies explizit angegeben werden. Hierzu stehen in der Klasse Thread drei Konstruktoren zur Verfügung, die das Setzen einer Thread-Gruppe ermöglichen:

      • public Thread(ThreadGroup gruppe, Runnable ziel)
      • public Thread(ThreadGroup gruppe, String name)
      • public Thread(ThreadGroup gruppe, Runnable ziel, String name)

      Jeder dieser Konstruktoren erzeugt einen neuen Thread, der basierend auf den Runnable- und String-Parametern initialisiert wird. Im folgenden Beispiel wird eine Thread-Gruppe eigeneThreadGroup angelegt und ein Thread eigenerThread in dieser Gruppe erzeugt:

      code 

      ThreadGroup eigeneThreadGroup = new ThreadGroup("Eigene Thread-Gruppe");

      Thread eigenerThread = new Thread(eigeneThreadGroup, "Thread fuer eigene Gruppe");

      Die Thread-Gruppe wird an einen Thread-Konstruktor übergeben und muss dabei nicht notwendigerweise selbst erzeugt werden. Auch Gruppen, die vom Laufzeitsystem erzeugt wurden, oder solche, die von der Anwendung generiert wurden, in der das Applet läuft, sind hier zulässig. Um herauszufinden, in welcher Gruppe sich ein Thread befindet, kann die getThreadGroup-Methode verwendet werden. Das folgende Beispiel legt dies dar.

      code 

      theGroup = myThread.getThreadGroup();

      Die Klasse ThreadGroup kann eine beliebige Anzahl von Threads enthalten. Üblicherweise weisen die Threads einer Gruppe eine Beziehung zueinander auf, bspw. eine ähnliche Funktion. Neben Threads kann eine ThreadGroup auch andere Thread-Gruppen enthalten und so eine Hierarchie bilden. Die Wurzel dieser Hierarchie ist in einer Java-Application die Gruppe main. Threads und Thread-Gruppen können prinzipiell in main oder in Untergruppen von main angelegt werden.

      Die Methoden der Klasse ThreadGroup können wie folgt kategorisiert werden:

      • Methoden zur Verwaltung von Collections
        Methoden, die eine Menge von Threads und Subgruppen der Thread-Gruppe verwalten.
      • Methoden, die auf einer Gruppe operieren.
        Diese Methoden setzen oder erfragen Attribute des
        ThreadGroup-Objekts.
      • Methoden, die auf allen Threads einer Gruppe operieren.
      • Methoden, die Zugriffsrechte regeln.
        Sowohl
        ThreadGroup als auch Thread erlauben dem Security-Manager, den Zugriff auf Threads auf der Basis der Gruppenmitgliedschaft einzuschränken.

      Im Folgenden werden diese Kategorien erläutert. Die Klasse ThreadGroup stellt Methoden zur Verfügung, die die Threads und Untergruppen einer Thread-Gruppe verwalten und die es anderen Objekten erlauben, die Thread-Gruppe nach Inhaltsinformationen zu fragen. So kann bspw. die Methode activeCount verwendet werden, um herauszufinden, wie viele Threads einer Gruppe momentan aktiv sind. Diese Methode wird oft eingesetzt, um mit Hilfe der Methode enumerate eine Liste zu erzeugen, die Referenzen zu allen aktiven Threads einer Thread-Gruppe enthält. Das folgende Beispiel stellt ein Code-Segment dar, das eine derartige Liste erzeugt und die Namen der Threads ausgibt.

      code 

      public class aufzaehlungThreads {

        public void listeThreads() {

          ThreadGroup aktuelleGruppe = Thread.currentThread().getThreadGroup();
          int anzahlThreads = aktuelleGruppe.activeCount();
          Thread[] listeDerThreads = new Thread[anzahlThreads];
          aktuelleGruppe.enumerate(listeDerThreads);
          for (int i = 0; i < anzahlThreads; i++)

            System.out.println("Thread " + i + " = " + listeDerThreads[i].getName());

        }

      }

      Weitere Methoden, die von der Klasse ThreadGroup angeboten werden, beinhalten bspw. activeGroupCount zur Zählung der momentan aktiven Thread-Gruppen und die Methode list.

      Neben der Verwaltung von Threads werden in einem ThreadGroup-Objekt verschiedene Attribute gesetzt und abgerufen, die sich auf die gesamte Gruppe beziehen. Diese Attribute spezifizieren z. B. die maximale Priorität, die Threads in einer Gruppe haben können, den Namen der Gruppe und die Elterngruppe. Die Methoden, die zum Setzen und Abfragen dieser Attribute verwendet werden, arbeiten immer auf Gruppenebene. Attribute von Threads werden hierbei nie verändert. Die folgende Liste gibt die Methoden eines ThreadGroup-Objekts an, die auf Gruppenebene arbeiten:

      • getMaxPriority und setMaxPriority,
      • getDaemon und setDaemon,
      • getName,
      • getParent, parentOf und
      • toString.
      • Wenn bspw. die Methode setMaxPriority verwendet wird, um die maximale Priorität einer Gruppe zu verändern, so wird lediglich das Attribut des Gruppenobjekts verändert, nicht aber die Priorität der Threads der Gruppe. Das folgende Beispiel stellt dies dar.

      code 

      public class Beispiel {

        public static void main(String[] args) {

          ThreadGroup normaleGruppe = new ThreadGroup("Gruppe mit normaler Prioritaet");
          Thread maxPrio = new Thread(normaleGruppe, "Thread mit maximaler Prioritaet");
          // setze die Prioritaet des Thread auf maximal 10
          maxPrio.setPriority(Thread.MAX_PRIORITY);
          // umsetzen der Prioritaet der Gruppe auf 5
          normaleGruppe.setMaxPriority(Thread.NORM_PRIORIT Y);
          System.out.println("Maximale Prioritaet der Gruppe = "
          normaleGruppe.getMaxPriority());
          System.out.println("Prioritaet des Thread = " + priorityMAX.getPriority());

        }

      }

      Wenn die Thread-Gruppe normaleGruppe erzeugt wird, erbt sie das Attribut „maximale Priorität" von der Elterngruppe. In diesem Fall ist die Priorität der Elterngruppe MAX_PRIORITY, die in Java maximal erlaubte Priorität. Im nächsten Schritt wird die Priorität des Threads maxPrio auf den maximal möglichen Wert gesetzt. Anschließend wird die Priorität der Gruppe wieder auf die normale Priorität (NORM_PRIORITY) verringert. Hierbei wirkt sich die Anwendung der Methode setMaxPriority nicht auf die Priorität des Threads maxPrio aus, so dass dieser nach wie vor eine Priorität von 10 hat, die größer ist, als die maximale Priorität seiner Gruppe.

      Es wird erkennbar, dass ein Thread genau dann eine höhere Priorität als seine Gruppe hat, wenn seine Priorität gesetzt wird, bevor die maximal mögliche der Gruppe verringert wird. Die maximale Priorität einer Gruppe wird verwendet, um die Priorität eines Threads zu begrenzen, wenn dieser das erste Mal in einer Gruppe erzeugt wird oder wenn mittels setPriority die Priorität verändert wird. setMaxPriority verändert die maximale Priorität aller Thread-Gruppen, die sich im Hierarchiebaum unterhalb der Gruppe befinden, aus der der Aufruf gestartet wird.

      Eine weitere Funktionalität der Klasse ThreadGroup, die Modifikation des Status aller Threads in einer Gruppe, wird mit den folgenden drei Methoden realisiert:

      • resume,
      • stop,
      • suspend.

      Diese Methoden bewirken, dass sich eine Statusänderung auf alle Threads einer Thread-Gruppe auswirkt. Dies beinhaltet auch die Untergruppen.

      Die Klasse ThreadGroup beinhaltet keinerlei Zugriffsrestriktionen, wie bspw. Genehmigungen, dass Threads einer Gruppe Threads einer anderen Gruppe inspizieren oder auch modifizieren dürfen. Aus diesem Grund werden die Klassen Thread und auch ThreadGroup meist zusammen mit Security-Managern (siehe Kapitel 5.2) verwendet, die ihrerseits Zugriffseinschränkungen auferlegen können, die auf der Gruppenmitgliedschaft basieren. Sowohl Thread als auch ThreadGroup beinhalten die Methode checkAccess, die die Methode checkAccess des gerade verwendeten Security-Managers aufruft. Anschließend entscheidet der Security-Manager, ob der Zugriff aufgrund der Gruppenmitgliedschaft der beteiligten Threads gewährt werden kann. Ist dies möglich, so endet die Methode checkAccess. Anderenfalls wird eine Fehlermeldung (SecurityException) ausgelöst. Die folgenden Methoden der Klasse ThreadGroup rufen die Methode checkAccess der Thread-Gruppe auf, bevor die eigentliche Methode ausgeführt wird. Erst wenn der Security-Manager dies bewilligt, wird der Zugriff erlaubt.

      • Konstruktor ThreadGroup(ThreadGroup eltern, String name),
      • Methode setMaxPriority(int maxPrioritaet),
      • Methode stop,
      • Methode suspend,
      • Methode resume,
      • Methode destroy.

      Die folgenden Methoden der Thread-Klasse rufen checkAccess auf, bevor sie in der Verarbeitung fortfahren:

      • Konstruktoren, die eine Thread-Gruppe spezifizieren,
      • Methode stop,
      • Methode suspend,
      • Methode resume,
      • Methode setPriority(int prioritaet),
      • Methode setName(String name).

      Standardmäßig verwendet eine Java-Application keinen Security-Manager. Threads werden daher auch keinerlei Restriktionen auferlegt, wodurch jeder Thread jeden anderen inspizieren und auch modifizieren kann. Die Gruppe, in der ein Thread Mitglied ist, spielt hierbei keine Rolle. Eigene Zugangsbeschränkungen können definiert und implementiert werden, indem Subklassen des Security-Managers erzeugt werden, die die notwendigen Methoden überschreiben, und indem der Security-Manager für die Anwendung gesetzt wird. Detaillierte Informationen hierzu befinden sich in Kapitel 5.2.

      Zusammenfassung

      Die folgende Aufstellung, die die Packages angibt, die mit Threads in Verbindung stehen, fasst dieses Teilkapitel zusammen.

      • java.lang.Thread
        In der Entwicklungsumgebung von Java sind Threads Objekte, die von der Klasse
        java.lang abgeleitet sind. Die Klasse Thread definiert und implementiert Java-Threads. Zu jedem Thread kann eine Subklasse erzeugt werden, um eigene Implementierungen zu verwenden. Anderenfalls ist das Runnable-Interface zu benutzen.
      • java.lang.Runnable
        Die Java-Sprachbibliothek definiert weiterhin das Interface
        Runnable, das es einer Klasse erlaubt, den Rumpf (die run-Methode) eines Threads zu verwenden.
      • java.lang.Object
        Die Wurzelklasse
        Object definiert drei Methoden, die zur Synchronisation von Methoden bezüglich einer Bedingungsvariablen verwendet werden können: wait, notify und notifyAll.
      • java.lang.ThreadGroup
        Alle Threads gehören zu einer Thread-Gruppe, deren Funktion die Gruppierung von Threads mit ähnlichen Aufgaben ist. Die Klasse
        ThreadGroup des Packages java.lang implementiert Gruppen von Threads.
      • java.lang.ThreadDeath
        Ein Thread wird normalerweise beendet, indem ihm ein ThreadDeath-Objekt übertragen wird.

      Threads und native Code

      Die Java-Plattform ist als Multi-Thread-System ausgelegt. Dies impliziert, dass mehrere Threads zur selben Zeit ein und dieselbe Methode in native Code aufrufen können. Es ist daher außerordentlich wichtig, dass derartige Methoden keine globalen Variablen ungesichert verändern können.

      Der JNI-Interface-Zeiger (JNIEnv *) ist nur im derzeit aktiven Thread gültig und darf daher nicht von einem Thread an einen anderen übergeben oder in mehreren Threads gleichzeitig verwendet werden. Die Java Virtual Machine übergibt bei mehreren Aufrufen derselben Methode in native Code aus demselben Thread jeweils den gleichen Zeiger. Unterschiedliche Threads erhalten jedoch verschiedene Zeiger.

      Analog dürfen keine lokalen Referenzen von einem Thread an einen anderen übergeben werden, da lokale Referenzen ungültig werden können, bevor ein anderer Thread die Möglichkeit hat, diese zu verwenden. Müssen daher Referenzen von Thread zu Thread übergeben werden, so sollten diese vorher in globale Referenzen konvertiert werden. Es sei darauf hingewiesen, dass globale Variablen einerseits einen schlechten Programmierstil darstellen und andererseits dementsprechend vorsichtig einzusetzen sind, da mehrere Threads gleichzeitig auf dieselbe Variable zugreifen können, woraus sich Seiteneffekte ergeben können. Zur Absicherung darf daher auf globale Variablen nur dann zugegriffen werden, wenn diese vorher gesperrt werden.

      Thread-Synchronisierung in native Code

      JNI stellt zwei Synchronisierungsfunktionen zur Verfügung, mit denen synchronisierte Anweisungsblöcke implementiert werden können: MonitorEnter und MonitorExit. Diese Funktionen werden genauso verwendet wie die Anweisung synchronized (obj) in Java, mit der der Zugriff auf Blöcke synchronisiert werden kann. Das folgende Beispiel erläutert die Verwendung dieser Funktionen.

      code 

      static jclass klasse = 0;
      static jfieldID fid;
      JNIEXPORT void JNICALL Java_Beispiel_bspGlob(JNIEnv *env, jobject obj) {

        int wert = 0;
        //Feststellen des Klassen-Objekts
        if (klasse == 0)

          jclass klasse1 = (*env)->GetObjectClass(env, obj);

        if (klasse1 == 0) //Fehler

          return;

        klasse = (*env)->NewGlobalRef(env, klasse1);
        //Sperren der Variable
        (*env)->MonitorEnter(env, fid);
        //ID der Variable wert als globale Referenz
        fid = (*env)->GetStaticFieldID(env, klasse,"wert", "I");
        // Abarbeitung von Exceptions
        if (fid == 0) {

          (*env)->MonitorExit(env, fid);
          return;

        }
        wert = (*env)->GetStaticIntField(env, klasse, fid);
        printf("  Zugriff auf wert = %d\n", wert);
        (*env)->SetStaticIntField(env, klasse, fid, 1);
        //Freigeben der Variable
        (*env)->MonitorExit(env, fid);

      }

      Ein Thread muss in den Monitorbereich, der mit einer Variablen assoziiert ist, eintreten, bevor die Ausführung fortgesetzt werden kann. Der Monitorbereich kann mehrmals betreten werden. Ein Zähler hält hierzu fest, wie oft ein Thread einen Monitorbereich betreten hat. Die Operation MonitorEnter erhöht diesen Zähler, während MonitorExit ihn wieder um eins verringert. Andere Threads dürfen den Monitorbereich nur betreten, wenn der Zähler auf null (Anfangswert) steht.

      Mittels der Funktionen Object.wait, Object.notify und Object.notifyAll steht eine weitere Synchronisationsmöglichkeit zur Verfügung. Das JNI unterstützt diese Funktionen zwar nicht direkt, eine Methode in native Code kann allerdings den JNI-Mechanismus zum Aufruf von Methoden verwenden, um diese Java-Methoden aufzurufen.


SPNavRight SPNavRight SPNavRight
BuiltByNOF