Entgegen dem ersten Eindruck befasst sich dieser Artikel allerdings nicht mit psycho-sozialen Auswirkungen unserer Gesellschaft auf den Menschen, sondern der Parallel-Verarbeitung in Java. Jeder seriöse Java-Entwickler sollte mit dem Konzept und dessen Anwendung vertraut sein. Deshalb richtet sich dieser Artikel nicht nur an den Java-Neuling, sondern soll auch dem erfahrenen Entwickler zur Auffrischung, ggf. Ergänzung seiner Kenntnisse dienen.
Allgemeiner Hinweis
Die Java Thread-API weist mehrere veraltete Teile (Deprecated) auf. Dieser Artikel berücksichtigt diese nicht, da derartige Methoden nicht mehr verwendet werden sollten. Das hat wiederum Auswirkungen auf den Inhalt des Artikels. Deshalb sei hier ausdrücklich darauf hingewiesen, dass Sachverhalte unter Umständen anders dargestellt werden als vor der Aufhebung der Gültigkeit dieser Methoden.Wer schon mal eine Anwendung mit Benutzeroberfläche für ein Single-Tasking-Betriebssystem (z.B. MS DOS) entwickelt hat, weiß, dass auch die Programmierung der GUI, eine entsprechende API vorausgesetzt, sehr simple war. Im Allgemeinen wurden potentielle Ereignisquellen (z.B. Tastatur, Maus) in einer Schleife abgeklappert, um auf Benutzereingaben zu reagieren. Bei anspruchsvolleren Anwendungen stieß man aber recht schnell auf Probleme, welche eher schlecht als recht gelöst werden konnten - nämlich dann, wenn zusätzlich zur Benutzerinteraktion fortlaufende Aktionen auszuführen waren - z.B. bei industriellen Anwendungen wie Überwachung von Maschinen. Beim Single-Tasking kann zu einem Zeitpunkt nur eine Aufgabe ausgeführt werden, deshalb müssen Verarbeitung der Benutzereingabe und Kommunikation mit externen Gerätschaften sequentiell erfolgen. Dies hat wiederum zur Folge, dass bei der Verarbeitung der Benutzereingabe unter Umständen wichtige Daten verloren gehen. Weiterhin wird kostbare Rechenzeit verschwendet, da die Ausführung eines einzelnen Programms den Rechner nur äußerst selten voll auslastet. Aufgrund dessen wurden schon sehr früh Multi-Tasking-Betriebssysteme entwickelt (z.B. Unix), auch in Java wurde natürlich nicht auf dieses wertvolle Feature verzichtet. Die Multi-Tasking-Unterstützung, in Form von Threads, ist zum einen in der Laufzeitumgebung (JVM) und zum anderen in der Sprache selbst implementiert.
Threads ermöglichen die parallele Ausführung von Programmen bzw. Programmteilen. Dabei werden diese, sofern es sich nicht um ein Multi-Prozessor-System handelt, natürlich nicht wirklich gleichzeitig ausgeführt. Geschicktes Umschalten der JVM zwischen den einzelnen Ausführungseinheiten erweckt lediglich den Anschein als würden diese parallel arbeiten. Selbst das für jeden Einsteiger obligatorische Hello World !!!-Programm muss schon mit anderen Threads um Systemressourcen kämpfen. Denn die JVM verwaltet zum einen im Hintergrund System-Threads, welche z.B. Eingaben verarbeiten oder nicht mehr benötigten Speicher freigeben (Garbage Collector), zum anderen wird ein separater Thread erzeugt, welcher das eigentliche Programm ausführt.
In Java wird ein Thread von der Klasse java.lang.Thread repräsentiert. Um ihn zu starten, genügt es seine start-Methode aufzurufen. Dabei wird dem Thread Scheduler mitgeteilt, dass dieser bereit zur Ausführung ist. Aufgrund der wenig restriktiven Spezifikation kann der Scheduler Teil der JVM sein, aber auch Teil des Betriebssystems. Seine Aufgabe ist zu bestimmen, welchem Thread die CPU zugeteilt wird. Aber was führt ein Thread eigentlich aus?
Was ein Thread ausführt
Ein Thread führt eine Methode mit folgender Signatur aus:public void run(){};
Listing 1
public class CounterThread extends Thread{public void run(){for(int i = 1; i <= 10; i++){System.out.println("Zählerstand: " + i);try{Thread.sleep(1000); // weiter in mind. einer Sekundecatch(InterruptedException ie){ // ... }}}}public class Main{public static void main(String[] args){CounterThread ct = new CounterThread();ct.start();}}
Listing 2
public class Counter implements Runnable{public void run(){for(int i = 1; i <= 10; i++){System.out.println("Zählerstand: " + i);try{Thread.sleep(1000); // weiter in mind. einer Sekundecatch(InterruptedException ie){ // ... }}}}public class Main{public static void main(String[] args){Thread t = new Thread(new Counter());t.start();}}
Thread vs. Runnable
Nun stellt sich berechtigt die Frage: Warum diese zwei Arten der Thread-Erzeugung und wann wende ich die eine bzw. die andere an? Zum einen ist die Spezifikation der Schnittstelle java.lang.Runnable und deren Implementierung in der Thread-Klasse sauberer OO-Stil, d.h. zunächst wird festgelegt, welche Schnittstelle etwas Lauffähiges haben muss, um anschließend sagen zu können, dass ein Thread lauffähig ist. Zum anderen liegt dieser Ansatz in der restriktiven Auslegung der Programmiersprache selbst begründet: In Java ist bekanntermaßen Mehrfachvererbung nicht möglich, was wiederum bedeutet, dass durch das Erben der Thread-Klasse die erbende Klasse in diesem Vererbungsschritt keine weiteren Eigenschaften mehr erben kann - Interfaces dagegen können beliebig implementiert werden. Daher ist es guter Stil, Klassen mittels java.lang.Runnable lauffähig zu machen. Ist allerdings der Zugriff auf nicht-statische Methoden des Threads nötig, führt an dessen Vererbung kein Weg vorbei.Wann endet die Ausführung?
Sobald die run-Methode verlassen wird - unabhängig davon, ob per return oder per Exception (bzw. java.langThrowable) -, der Thread also seine Aufgabe mehr oder weniger erledigt hat, ist dieser tot. Wie im wirklichen Leben kann etwas Totes für gewöhnlich auch nicht wieder zum Leben erweckt werden. Die einzige Möglichkeit, die Arbeit erneut auszuführen, ist eine neue Thread-Instanz zu erzeugen. Es sei aber darauf hingewiesen, dass es von schlechtem Design zeugt fortwährend aufs Neue Threads zu erzeugen und nach getaner Arbeit wieder wegzuwerfen. Denn das Erzeugen eines Threads bedarf größerer Anstrengungen für die JVM, einschließlich signifikantem Ressourcen-Verbrauch.Thread-Prioritäten
Jedem Thread ist eine Priorität zugeteilt. Dabei handelt es sich um einen integer-Wert von 1 bis 10, wobei 1 die niedrigste und 10 die höchste Priorität darstellt. Die Priorität wird vom Scheduler dazu verwendet zu entscheiden, welchem bereiten Thread die CPU als nächstes zugeteilt wird. Für gewöhnlich wird der Thread mit der höchsten Priorität ausgewählt. Warten dabei mehrere, gleichhoch-priore Threads auf die Zuteilung der CPU, wählt der Scheduler irgendeinen aus. Es gibt keine Garantie, dass der Thread gewählt wird, welcher am längsten wartet. Wie sich Prioritäten auf das Scheduling auswirken ist plattformabhängig, da die Spezifikation lediglich angibt, dass Threads Prioritäten haben müssen, aber vernachlässigt, was der Scheduler mit ihnen anfangen soll.In java.lang.Thread sind drei Prioritäts-Konstanten definiert: MAX_PRIORITY, MIN_PRIORITY und NORM_PRIORITY. Mittels setPriority() lässt sich ein neuer Wert setzen, getPriority() liefert den aktuellen. Erzeugt ein Thread während seine Ausführung weitere Threads (Deamon Threads), erben diese seine Priorität.
Scheduling-Implementierungen
Historisch bedingt gibt es zwei Ansätze für Scheduling-Strategien: Preemtive und Time Sliced (bzw. Round Robin). Beim preemtive Scheduling wird einem Thread die CPU entzogen sobald ein anderer mit höherer Priorität in den Zustand Ready übergeht. Beim time sliced Scheduling erhält jeder Thread ein gewisses Zeitquantum zur Ausführung. Nach dessen Ablauf kommt der nächste Thread an die Reihe, bis alle Threads einmal an der Reihe waren. Anschließend wird wieder beim Ersten begonnen. Time Slicing hat den Vorteil, dass nicht ein einziger hochpriorer Thread alle andern an deren Ausführung hindern kann, hat aber zugleich den Nachteil, dass so das System nicht deterministisch ist; ab einem gewissen Zeitpunkt ist nicht mehr vorhersagbar, welcher Thread gerade ausgeführt wird und wie lange dieser für seine Arbeit insgesamt benötigt.Nun stellt sich berechtigterweise die Frage, welche Strategie die JVM verwendet. Auch hier ist die Antwort: das ist plattformabhängig. Soviel ist sicher: Für Solaris wird preemtive, für Macintosh time sliced und für Windows ebenfalls time sliced (ab JDK 1.0.2, vorher preemtive) Scheduling verwendet.
Thread-Zustände
Abpictureung 1 zeigt alle Zustände und deren Folgezustände, in welchen sich ein Thread befinden kann, wobei Synchronizing ein noch zu verfeinernder Zustand ist und lediglich der Übersichtlichkeit halber als eigenständiger dargestellt wird. Wie bereits erwähnt beginnt ein Thread, nachdem er gestartet wurde, nicht sofort mit der Ausführung. Er befindet sich zunächst im Zustand Ready und wartet auf die Zuteilung der CPU. Wurde ihm diese vom Scheduler zugeteilt, beginnt er mit der Ausführung der run-Methode und hat somit den Zustand Running inne. Wie ein Thread in den Zustand Dead gelangt, wurde bereits beschrieben (siehe Wann endet die Ausführung?). Das Zustandsdiagramm macht deutlich, dass es aus diesem Zustand kein Zurück mehr gibt. Befindet sich der Thread in einem der anderen Zustände (Synchronizing, Sleeping, Blocked), wartet er auf das Eintreffen eines Ereignisses und geht anschließend in den Zustand Ready über. Nun ist es am Scheduler diesem die CPU erneut zuzuteilen. Wie die Zustandsübergänge programmatisch erreicht werden, wird im Folgenden gezeigt.Yielding
Ein Thread kann das ihm zugeteilte Recht der Ausführung freiwillig abgeben, um anderen, auf die Ausführung wartenden Threads die Chance einzuräumen, aktiv zu werden. Dies wird mit dem Aufruf der Methode Thread.yield() erreicht. Befindet sich mindestens ein weiterer Thread im Zustand Ready, entzieht der Scheduler dem aufrufenden Thread die CPU, womit er sich ebenfalls im Zustand Ready befindet und entscheidet, welchem der anderen Threads diese zugeteilt wird. Es sei noch erwähnt, dass der Scheduler im Allgemeinen die Ausführung des aktuellen Threads nicht unterbricht, wenn sich lediglich Threads mit niedriger Priorität im Zustand Ready befinden. Dieses Verhalten ist aber plattformabhängig und damit nicht garantiert.Yielding ist ein sehr nützliches Feature, welches bei arbeitsintensiven Threads zum Einsatz kommen sollte. Man stelle sich eine Anwendung mit GUI vor, mittels welcher unter anderem Daten aus mehreren Dateien importiert und in geeigneter Weise transformiert werden. Während des Imports und der Transformation sollen bestimmte Status-Informationen des Vorgangs in der GUI angezeigt werden (z.B. aktuell bearbeitete Datei, Fortschritt der Bearbeitung). Würde man die Ausführung nicht von Zeit zu Zeit unterbrechen, könnte der GUI-Thread weder die Status-Informationen auf dem Bildschirm anzeigen noch auf Eingaben des Benutzers reagieren, da der Import-Vorgang, sofern nicht durch I/O-Zugriffe blockiert, die CPU voll beansprucht.
Sleeping
Wenn ein Thread schläft, wartet er auf das Verstreichen einer definierten Zeit und geht anschließend in den Zustand Ready über. Da er nicht sofort wieder in den Zustand Running wechselt, sondern auf die Zuteilung der CPU wartet, kann lediglich garantiert werden, dass der Thread mindestens die definierte Zeit ruht bis er mit der Ausführung fortfährt. Mittels Thread.sleep() wird dieses Verhalten erreicht. Der aufrufende Thread legt sich dann für (mindestens) die angegeben Dauer schlafen. Je nachdem mit welcher Genauigkeit die Dauer angegeben werden soll, existieren zwei Methoden:- public static void sleep(long milliseconds)
- throws InterruptedException
- public static void sleep(long milliseconds, int nanoseconds) throws InterruptedException
Blocking
Viele I/O-Routinen, welche mit der Außenwelt (außerhalb der JVM) interagieren, müssen von Zeit zu Zeit auf das Eintreffen eines Ereignisses warten, bevor sie fortfahren können. Der aufrufende Thread befindet sich dann im Zustand Blocking. Ein gutes Beispiel dafür ist das Lesen von Sockets:1. Socket s = new Socket("aServer", 1411);2. InputStream in = s.getInputStream();3. int b = in.read();
Synchronizing
Es wurde bereits erwähnt, dass es sich bei dem Zustand Synchronizing um eine Abstraktion mehrerer Zustände bzw. eines bestimmten Verhaltens handelt.- Genau einen Lock für jede Klasse
- Genau einen Lock für jedes Objekt
- Das Schlüsselwort synchronized
- Die Methoden wait(), notify() und notifyAll(), der Klasse java.lang.Object
Objekt-Lock und Synchronisation
Jedes Objekt hat einen so genannten Lock. Dieses Schloss lässt sich nur unter Zuhilfenahme des Schlüssels synchronized verwenden. Es regelt gewissermaßen den Zugriff auf den Code eines Objektes, welcher mittels synchronized gesichert wurde. Dabei wird sichergestellt, dass zu einem Zeitpunkt jeweils nur ein Thread diesen ausführen darf, d.h. parallele Zugriffe werden synchronisiert, sodass diese sequentiell erfolgen. Dies ist nötig, um sicherzustellen, dass Objekte bzw. deren Daten immer konsistent bleiben - also z.B. nicht der Fall eintreten kann, dass ein Thread gerade die Daten eines Objektes ändert, währenddessen verdrängt wird und ein anderer diese teilweise veränderten und damit nicht korrekten Daten liest. Durch Synchronisation werden Zugriffe auf Objekte gewissermaßen unteilbar.Möchte nun ein Thread den geschützten Code eines Objektes ausführen, muss er zunächst warten bis ihm der Lock für dieses Objekt zugeteilt wird - er befindet sich im Zustand Seeking-Lock. Nach dem Erhalt des Locks wechselt er in den Zustand Ready. Verlässt er den synchronisierten Code, wird der Lock wieder freigegeben. Ein Java-Programmierer bekommt davon selbstverständlich nichts mit. Das Einzige, um das dieser sich kümmern muss, ist anzugeben, welcher Bereich zu schützen ist. Dabei gibt es zwei Möglichkeiten:
public synchronized void aMethod(){// ...}
public void aMethod(){synchronized(this){// ...}}
Bislang wurden Locks für Klassen vernachlässigt. Diese werden zur Synchronisation von statischen Methoden benötigt. Dies ist, wenn überhaupt, ein mögliches Anwendungsgebiet zur Synchronisation mittels anderer Objekte als this, da es in einem statischen Kontext bekanntlich kein this gibt.
class StrangeSynchronization{private final static Object mutex = new Object();public static void aMethod(){synchronized(mutex){// ...}}}
Das Begrenzen des zu synchronisierenden Codes ist immer dann ratsam, wenn eine Methode relativ viel leistet, aber eigentlich nur ein geringer Teil geschützt werden müsste. Die Einschränkung des zu synchronisierenden Codes kann so mitunter die gesamte Ausführungsgeschwindigkeit der Anwendung steigern, da parallel arbeitende Threads nun weniger lang warten müssen. Allerdings ist von Außen nun nicht mehr ersichtlich, dass in dieser Methode synchronisiert wird. Dies sollte in der Dokumentation (JavaDoc) zusätzlich angegeben werden.
wait(), notify() und notifyAll()
Im Abschnitt Synchronizing wurde kurz erwähnt, was ein Monitor ist und wie dieser allgemein verwendet wird. In Java-Terminologie ist dagegen ein Monitor lediglich ein Objekt, welches synchronisierten Code besitzt.Abpictureung 2 zeigt einen bislang noch nicht behandelten Zustand, welcher aber für das allgemeine Monitor-Konzept essentiell ist: Waiting. Ein Thread gelangt in den Warte-Zustand, wenn ihm das alleinige Zugriffsrecht für ein Objekt zugesprochen wurde, er sich also in synchronisiertem Code bewegt und er die Methode wait() dieses Objektes aufruft, um auf dessen Zustandsänderung zu warten. Dabei wird allerdings der Lock des Objektes wieder freigegeben. Sonst könnte kein anderer Thread mehr den geschützten Code betreten, um die erwartete Zustandsänderung herbeizuführen. Der Thread würde dann bis in alle Ewigkeit warten (Deadlock). Die Klasse java.lang.Object weist drei unterschiedliche Signaturen der wait-Methode auf. Sie bietet unter anderem die Möglichkeit, die Wartezeit zu begrenzen und auf Milli- bzw. Nanosekunden genau anzugeben. Im Normalfall wird jedoch die parameterlose Variante der Methode verwendet und zunächst für unbestimmte Zeit gewartet. Es führen genau drei Wege aus dem Warte-Zustand wieder heraus:
- Ein anderer Thread signalisiert den Zustandwechsel mittels notify() bzw. notifyAll()
- Die angegebene Zeit ist abgelaufen
- Ein anderer Thread ruft die Methode interrupt() des wartenden Threads auf
Ähnlich wie beim Zustand Sleeping kann der Warte-Zustand von einem anderen Thread unterbrochen werden, in dem er die interrupt-Methode des wartenden Threads aufruft. Auch hier wird eine java.lang.InterruptedException geworfen. Der Thread befindet sich daraufhin im Zustand Seeking Lock. Nach Erhalt des Locks wird der entsprechende Exception-Handler ausgeführt.
public synchronized void aMethod(){while( ... ){ // gewünschter Zustand ?try{wait();}catch(InterruptedException ie){ ... }}// ...notifyAll();}
Grundsätzlich kommt es auf die konkrete Anwendung an, ob notify() oder notifyAll() zur Signalisierung des Zustandswechsels verwendet wird. Sollen mehrere Threads auf einen Zustandswechsel reagieren, ist notifyAll() die richtige Wahl. Denn wie erwähnt erweckt notify()lediglich einen einzigen Thread, alle anderen bleiben im Warte-Zustand bis einer der oben genannten Fälle eintritt. Analog dazu sollte notify() immer dann angewandt werden, wenn lediglich ein nicht näher bestimmter Thread die Verarbeitung vornehmen soll.
Die Methoden wait(), notify() und notifyAll() sind nur innerhalb synchronisiertem Code erlaubt. Grundregel hier ist, dass ein Thread diese Methoden nur bei einem Objekt verwenden darf, dessen Lock er auch besitzt, d.h. die Synchronisation muss sich auf dieses Objekt beziehen. Missachtung dieser Vorschrift wird mit einer java.lang.IllegalMonitorStateException bestraft. Hierbei handelt es sich um eine RuntimeException, weshalb diese auch nicht abgefangen werden muss. Werden die Methoden wie in obigem Beispiel angewandt, wird dieser Fall auch nicht eintreten. Das folgende Beispiel zeigt einen Fall, der zu hundert Prozent mit einer Ausnahme geahndet wird:
class IllegalSynchronization{private final Object mutex = new Object();public synchronized void aMethod(){mutex.notifyAll();}}
class StrangeSynchronization_2{private final Object mutex = new Object();public void aMethod(){synchronized(mutex){mutex.notifyAll();}}}
Thread Utilities
Das JDK stellt drei nützliche Utility-Klassen zur Verfügung, welche alle im Package java.lang zu finden sind und abschließend kurz vorgestellt werden sollen.Die Klasse ThreadGroup ermöglicht das Gruppieren von Threads. ThreadGroups können sowohl Threads als auch wiederum ThreadGroups enthalten. Damit ist es auf einfache Weise möglich eine Hierarchie von Threads und Gruppen von Threads in Form eines Baumes zu implementieren. Zu den Key-Features gehört das Ermitteln aller aktiven Threads und Thread-Gruppen einer ThreadGroup sowie deren Eltern-Gruppe. Die Baumstruktur lässt sich denkbar einfach traversieren, um an einzelne Threads zu gelangen. Sie ist somit auch ein Hilfsmittel, um Referenzen von Threads zu verwalten und sinnvoll zu strukturieren.
Bislang gab es noch keine Möglichkeit Inhalte bzw. Werte von Variablen einem bestimmten Thread zuzuordnen, sodass zwar jeder Thread auf die gleiche Weise auf diese Variable zugreift, dennoch jeder seine eigene Kopie erhält und manipuliert. Man denke zum Beispiel an eine Anwendung, welche Transaktionen auszuführen hat und diese von Arbeits-Threads bearbeiten lässt. Ein Thread erhält nun den Auftrag eine Transaktion auszuführen. Mit einer Transaktion sind auch spezifische Informationen verknüpft, z.B. Transaktions-ID, und Ähnliches. Diese Informationen werden unter Umständen in verschiedenen Klassen (bzw. Methoden), welche in die Bearbeitung involviert sind, benötigt. Nun könnte man zum Beispiel diese Informationen als Objekt entlang der Methoden-Aufrufkette weiterreichen, um sie an entsprechender Stelle zu verwenden. Eleganter lässt sich das Problem mittels der Klasse ThreadLocal lösen, welche mit dem JDK 1.2 eingeführt wurde. Das folgende Beispiel soll die Verwendung von thread-lokalen Variablen verdeutlichen:
public class TransactionInfo{private static int nextTransactionID = 1;private static ThreadLocal tid = new ThreadLocal() {protected synchronized Object initialValue() {return new Integer(nextTransactionID++);}};public static int getTransactionID() {return ((Integer)(tid.get())).intValue();}}
Für den Fall, dass ein Thread weitere Threads erzeugt und diese die Werte von dessen thread-lokalen Variablen übertragen bekommen sollen, wurde die Klasse InheritableThreadLocal geschaffen, d.h. bei der Erzeugung bekommen alle Kinder-Threads die Werte der vererbbaren thread-lokalen Variablen mitgeliefert. Um eine andere Initialisierung als mit den Werten des Vater-Threads zu erzwingen, ist die Methode childValue() der Klasse InheritableThreadLocal entsprechend zu überschreiben. Beispielsweise könnte man obige Anwendung so erweitern, dass eine Transaktion in mehrere Sub-Transaktionen gegliedert werden kann. Um jede Sub-Transaktion kümmert sich dann ebenfalls ein separater Thread. Um die Transaktions-ID vererbbar zu machen, genügt es den Typ von tid durch InheritableThreadLocal zu ersetzen. Damit gehört jede Sub-Transaktion zu einer bestimmten (Haupt-) Transaktion, deren ID durch getTransactionID() ermittelt werden kann.




