Zurück zum Inhaltsverzeichnis des Manuskripts verteilte Systeme

1.3.2 Java-Threads

Java-Programmierung

Die Programmiersprache Java stellt Threads als Sprachmittel zur Verfügung. Sie sind hier ein Konzept der Anwendungsprogrammierung und werden von der Virtuellen Java-Maschine (JVM) verwaltet. Java stellt mehrere Verfahren zur Einrichtung und Steuerung von Threads zur Verfügung, von denen hier nur das elementarste vorgestellt wird. An dieser Stelle ist ein Hinweis zum Programmieren in der Lehrveranstaltung angebracht: Die Lehrveranstaltung verteilte Systeme geht davon aus, dass die Studierenden zwei Semester Programmiergrundlagenausbildung in Java erfolgreich durchlaufen haben. Das heißt, dass in der Veranstaltung darauf aufbauend lediglich Konzepte des Programmierens verteilter Systeme vermittelt werden.

main-Thread

Wird ein Java-Programm gestartet, entsteht ein Prozess, der zunächst nur einen einzigen Thread enthält, der main-Thread genannt wird. Davon kann man sich an Hand des folgenden Programmbeispiels, das den Namen dieses Threads auf der Konsole ausgibt, leicht überzeugen:

class MyThread { public static void main(String[] args) { System.out.println("Ich bin " + Thread.currentThread().getName()); } }

Das Programm gibt bei einem Programmlauf

Ich bin main

auf der Konsole aus. Der Prozess, der nach der Übersetzung des Programms MyThread.java in MyThread.class durch einen Konsolenbefehl wie

java MyThread

entsteht, beginnt mit einem Thread, der die Befehle der Methode main() sequenziell abarbeitet. Im Beispiel enthält main() nur einen einzigen Befehl, der in der println()-Methode auf die Klassenmethode currentThread() der Threadklasse zugreift, die eine Referenz auf das aktuelle Threadobjekt zurückliefert. Dessen Instanzmethode getName() wird dann benutzt, um zu dem Namen des aktuellen Threadobjekts zu gelangen.

Erzeugen weiterer Threads

Sollen von dem main-Thread oder einem anderen bereits vorhandenen Thread weitere Threads erzeugt werden, ist dafür entweder

In beiden Fällen ist eine run()-Methode auszuprogrammieren. Die Klasse Thread implementiert die Schnittstelle Runnable, die das Ausfüllen der Instanzmethode run() verlangt. Die Methode hat die Signatur:

public void run()

Die Klasse Thread läßt run() leer und vererbt die Methode. Die run()-Methode legt die Threadaktivitäten fest.

Die Klasse Thread vererbt auch eine bereits ausprogrammierte Instanzmethode start(). Diese richtet den Thread ein und ruft dessen run()-Methode auf. Wird run() direkt aufgerufen, entsteht kein Thread, weil in diesem Fall nur ein elementarer Methodenaufruf vorgenommen wird. Im Folgenden werden die beiden Erzeugungsmethoden für Threads an einem sehr einfachen Beispiel kurz vorgestellt.

Ableitung der Threadklasse

Bei dem ersten Verfahren wird die Klasse Thread abgeleitet. In dem folgenden Beispiel wird diese Ableitung in einer anderen Klasse instanziiert und die Instanz gestartet:

class MyThread extends Thread { public void run() { System.out.println("Ich bin " + Thread.currentThread().getName()); } } class Work { public static void main(String[] args) { MyThread mt = new MyThread(); mt.start(); System.out.println("ich bin " + Thread.currentThread().getName()); } }

Die run()-Methode der Klasse MyThread legt die Threadaktivitäten fest. Im Beispiel führt der Thread die bereits bekannte Namensausgabe durch. Die Klasse Work enthält die main()-Methode. Wird Work.class gestartet, entsteht der main-Thread, der die Klasse MyThread instanziiert und den zugehörigen Thread einrichtet und startet. Danach führt der main-Thread ebenfalls eine Namensausgabe durch.

Die Ausgabe eines Programmlaufs von Work.class ist nicht genau vorhersagbar, weil sie von dem Verhalten des Schedulers der JVM abhängt. Je nachdem, welchem Thread zuerst Rechenzeit zugeteilt wird, wird auf der Konsole entweder

Ich bin main Ich bin Thread-0

oder

Ich bin Thread-0 Ich bin main

ausgegeben.

Implementierung der Schnittstelle Runnable

Alternativ zur Ableitung der Threadklasse kann die Schnittstelle Runnable direkt implementiert werden. Dann allerdings muss für die Einrichtung des Threads ein geeigneter Konstruktor der Threadklasse verwendet werden. Das folgende Programm zeigt dieses Vorgehen, wobei wieder lediglich zwei Namensausgaben vorgenommen werden:

class MyThread implements Runnable { public void run() { System.out.println("Ich bin " + Thread.currentThread().getName()); } } class Work { public static void main(String[] args) { MyThread mt = new MyThread(); Thread t = new Thread(mt); t.start(); System.out.println("ich bin " + Thread.currentThread().getName()); } }

Bei diesem Vorgehen implementiert die Klasse MyThread die Schnittstelle Runnable direkt. Auch hier legt ihre run()-Methode die Threadaktivitäten fest. In der Klasse Work wird jetzt zunächst die Klasse MyThread instanziiert und danach eine Threadinstanz mit einem Konstruktor gebildet, dem eine Referenz auf die MyThreadinstanz übergeben wird. Auch bei diesem Verfahren wird durch die start()-Methode der Thread eingerichtet und gestartet. Das Ausgabeverhalten ist identisch zu dem des vorherigen Beispiels.

Namen von Threads

Jeder Thread hat einen Namen. Der main-Thread heißt zunächst main, alle weiteren heißen Thread-n mit einer laufenden Nummer n ab 0. Einem Thread, auch dem main-Thread, kann ein neuer Name gegeben werden. Dazu gibt es in der Threadklasse eine Instanzmethode namens setName(). Bei den Threads, außer dem main-Thread, kann zur Namensvergabe auch ein entsprechender Konstruktor verwendet werden.

Prozessende

Ein Java-Prozess mit allen seinen Threads kann durch den Aufruf von

System.exit(int n)

beendet werden. Durch diesen Aufruf wird die ausführende JVM beendet. Die Variable n kann eine Fehlernummer transportieren, die von dem aufrufenden System abgefragt werden kann. Um diese Wertrückgabe etwas zu erläutern, betrachte man ein Javaprogramm, das mit dem Befehl

System.exit(7);

enden und unter Microsoft-Windows in einem Kommandoausführungsfenster gestartet werden soll. Dann kann dort nach dem Programmende zum Beispiel mit dem Konsolenkommando

if errorlevel 7 echo Ende mit 7

auf diesen Rückgabewert zugegriffen werden. Wird das Javaprogramm in einer PowerShell gestartet, steht nach dem Programmende der Rückgabewert in der Shellvariablen $lastexitcode. Üblicherweise gibt ein Programm bei einem korrekten Ende den Wert Null zurück.

Threadende

Enthält ein Prozess mehr als einen Thread, dann ist es möglich, dass mehrere dieser Threads ein und dieselbe Datenstruktur bearbeiten. In solchen Situationen kann das Beenden von einzelnen dieser Threads zu Inkonsistenzen, z.B. zu nur teilweise bearbeiteten Daten, führen.

Es ist empfehlenswert, einen einzelnen Thread nur dadurch zu beenden, indem seine run()-Methode fehlerfrei beendet wird. Dies kann durch einen explizit angegebenen return-Befehl oder durch eine Programmstruktur, wie die folgende, erreicht werden:

public void run() { boolean fertig = false; while( ! fertig ) { // Threadaktivitäten } }

Soll dieser Thread an einer bestimmten Stelle der Threadaktivitäten beendet werden, kann die while-Schleife mit einem break-Befehl verlassen oder die Variable fertig auf true gesetzt und mit der continue-Anweisung ihre Prüfung am Schleifenanfang veranlasst werden.

Synchronisation mit dem Ende eines Threads

Mit der Instanzmethode join() der Threadklasse wartet ein Thread, der eine Referenz auf eine Threadinstanz besitzt (das ist meistens der erzeugende Thread), auf das Ende des zu dieser Instanz gehörenden Threads. Die Methode hat die Signatur:

public final void join() throws InterruptedException

join() ist eine Instanzmethode. Es ist mit ihr nicht möglich, unspezifisch auf das Ende irgendeines Threads zu warten. Dass beim Programmieren mit Synchronisationsbefehlen wie join() sehr sorgfältig umgegangen werden muss, zeigt das folgende Beispiel, das zu einer Verklemmung (einem Deadlock) des main-Threads mit sich selbst führt:

class Deadlock { public static void main(String[] args) { try { Thread.currentThread().join(); } catch(Exception e) { System.out.println("Fehler"); } } }

Java-Scheduling

Die Virtuelle Java-Maschine (JVM) enthält einen eigenen Scheduler, der die Zuweisung von Rechenzeit an die vorhandenen Threads vornimmt. Seine Strategie bestimmt, welchem Thread als nächstes der (oder ein) Prozessor zugeteilt wird. Er verwendet eine Prioritätssteuerung ohne Zeitscheibe mit 10 Prioritätsstufen. Stets erhält der Thread mit der jeweils höchsten Priorität Rechenzeit. Gibt es mehrere Threads mit der gleichen höchsten Priorität, wird von ihnen der Thread ausgewählt, der bisher am längsten gewartet hat. Man beachte, dass blockierte Threads dem Scheduling entzogen sind. Ihre Prioritität ist bedeutungslos.

Threadprioritäten

Die 10 Prioritätsstufen sind durchnummeriert, wobei Threads auf der Stufe 1 die niedrigste und Threads auf der Stufe 10 die höchste Priorität haben. Dazu gibt es in der Threadklasse die drei Klassenvariablen:

public final static int MAX_PRIORITY = 10; public final static int MIN_PRIORITY = 1; public final static int NORM_PRIORITY = 5;

Der main-Thread startet mit der Priorität 5 und kann sie ändern. Jeder neue Thread erbt die Priorität seines Erzeugers und kann diese für sich ebenfalls ändern. Die Instanzmethoden

public final int getPriority() und public final void setPriority(int prio)

der Threadklasse gestatten die Abfrage bzw. das Neusetzen der Priorität. Eine Beeinflussung der Normpriorität von 5 kann sinnvoll sein, wenn beispielsweise in einem Prozess den dialogintensiven Threads eine hohe und den rechenintensiven eine niedere Priorität zugewiesen wird. Allgemein ist zu bemerken, dass beim Programmieren kein Threadverhalten, wie z.B. eine Synchronisation, auf Prioritäten und damit auf eine Spekulation auf das Scheduling aufgebaut werden darf.

Freigabe des Prozessors

Ein Thread kann mit der folgenden Methode den Prozessor freigeben und damit den Scheduler der JVM implizit aufrufen:

public static void yield()

Man beachte, dass durch einen yield()-Aufruf die Priorität des Threads nicht verändert wird. Hat er nach einem yield()-Aufruf immer noch die höchste Priorität und gibt es keinen laufbereiten Thread mit gleicher Priorität, bekommt er bei der nächsten Prozessorzeitzuweisung sofort wieder Rechenzeit.

Laufzeitverhalten

Die Verwaltung von Threads benötigt Ressourcen und wirkt sich auf die Laufzeit des Prozesses aus. Es gibt viele Untersuchungen darüber, wie sich diese Laufzeit mit der Anzahl der Threads verändert. Üblicherweise zeigt sich durch die Nebenläufigkeit der Threads zunächst ein günstiges zeitliches Verhalten. Jedoch steigt nach dem Durchlaufen eines aufgaben- und prozessorabhängigen Minimums die Laufzeit wieder an.

Umsetzung eines Wartegraphen

Im Abschnitt 1.2.2 (Darstellung zeitlicher Abläufe) war der folgende Wartegraph gezeigt worden, der mit den begin-end- und parbegin-parend-Spachkonstruktionen nicht in ein Programm umgesetzt werden kann:

Wartegraph

Mit Threads hingegen ist eine Überführung in ein Programm leicht möglich, allerdings auf Kosten der Programmstruktur. Das folgende Javaprogramm zeigt eine solche Umsetzung:

Umsetzung des Wartegraphen


Zurück zum Inhaltsverzeichnis des Manuskripts verteilte Systeme