![]() |
|
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
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:
Abb. 4.5: Thread-Zustandsdiagramm
// 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:
import java.awt.*; public class Beispiel extends Applet implements Runnable{ Thread t; if (t == null){ t = new Thread (this); } } } 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.
// abzuarbeitender Code des Threads }
import java.applet.Applet; Thread t; if (t == null){ t = new Thread (this); } } Thread runThread = Thread.currentThread(); // abzuarbeitender Code des Threads Thread.sleep(1000); }catch (InterruptedException ex) {} } } 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:
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. public Zaehler zaehler1, zaehler2; zaehler1= new Zaehler(); dummyThread = new Thread(this, "zaehler"); } } Thread tmpThread = Thread.currentThread(); try { Thread.sleep(10); } catch (InterruptedException e) { } } } public class Zaehler extends Thread { int schritt = 1; 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.
public class Zaehler extends Thread { int schritt = 1; this.nummer = nummer; } 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:
Thread 1, schritt = 100 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.
public class Zaehler extends Thread { int schritt = 1; this.nummer = nummer; } 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:
public class Produzent extends Thread { private Speicher speicher; speicher = s; } char zahl; speicher.ablage(zahl); sleep((int)(Math.random() * 1000)); } catch (InterruptedException e) { } } } } Der Konsument liest die Zahl folgendermaßen wieder aus:
public class Konsument extends Thread { private Speicher speicher; speicher = s; } int wert = 0; wert = speicher.hole(); } } } 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:
//Speicherobjekt private int inhalt; //... } //... } } 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.
//Speicherobjekt private int inhalt; // Speicher gesperrt } // Speicher gesperrt } } 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:
public class Speicher { private int inhalt; if (fertig == false) fertig = true; } // Fehlerhaft!!! 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.
//Speicherobjekt private int inhalt; while (fertig == false) { try { wait(); } catch (InterruptedException e) { } } } while (fertig == true) { try { wait(); } catch (InterruptedException e) { } } } } 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:
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
//Hauptprogramm public static void main(String[] args) { Speicher s= new Speicher(); } } 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:
Jeder dieser Konstruktoren erzeugt einen neuen Thread, der basierend auf den Runnable
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.
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:
Im Folgenden werden diese Kategorien erläutert. Die Klasse
public class aufzaehlungThreads { public void listeThreads() { ThreadGroup aktuelleGruppe = Thread.currentThread().getThreadGroup(); 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:
public static void main(String[] args) { ThreadGroup normaleGruppe = new ThreadGroup("Gruppe mit normaler Prioritaet"); } } 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:
Diese Methoden bewirken, dass sich eine Statusänderung auf alle Threads einer Thread-Gruppe auswirkt. Dies beinhaltet auch die Untergruppen. Die Klasse Die folgenden Methoden der Thread-Klasse rufen 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 Zusammenfassung Die folgende Aufstellung, die die Packages angibt, die mit Threads in Verbindung stehen, fasst dieses Teilkapitel zusammen.
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.
static jclass klasse = 0; int wert = 0; jclass klasse1 = (*env)->GetObjectClass(env, obj); if (klasse1 == 0) //Fehler return; klasse = (*env)->NewGlobalRef(env, klasse1); (*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. |
|
|