Nebenläufigkeit in Java

Werbung
Nebenläufigkeit in Java
Maurice Schoenmakers
[email protected]−muenchen.de
Inhaltsverzeichnis
1 Einleitung .................................................................................................................................... 2
2 Prozesse und Threads in Java..................................................................................................... 2
2.1 Die Klasse Thread ................................................................................................................. 3
2.2 Der aktuelle Thread................................................................................................................ 3
2.3 Mehrere Threads................................................................................................................... 4
2.4 Mehrere Threads mit gemeinsamen Variablen....................................................................... 5
3 Synchronisation paralleler Threads in Java.............................................................................. 7
3.1 Kritischer Bereich.................................................................................................................. 9
3.2 Bewachte kritische Bereiche ................................................................................................ 13
3.3 Das Monitor−Konzept in Java.............................................................................................. 15
3.4 Semaphore........................................................................................................................... 19
3.4.1 Ganzzahlige Semaphore ............................................................................................... 19
3.4.2 Boolesche Semaphore................................................................................................... 19
3.5 Verklemmung...................................................................................................................... 20
3.5.1 Koch−Beispiel.............................................................................................................. 20
3.5.2 Verschachteltes Monitor Problem................................................................................. 22
4 Zuteilungsstrategien in Java für Threads................................................................................ 23
4.1 Zeitscheibenstrategie............................................................................................................ 25
4.2 Sperre mit Warteschlange..................................................................................................... 26
5 Anmerkungen............................................................................................................................ 27
6 Literatur..................................................................................................................................... 28
1 Einleitung
Ziel dieser Zusammenfassung ist es, die Programmierkonzepte in Java zur Erzeugung und
Synchronisation von Nebenläufigkeit zu erläutern. Einfache Grundkentnisse in Java werden dabei
vorausgesetzt. Die erläuterten Programmierkonzepte werden im Bezug gesetzt zu den
Grundbegriffen zur Programmierung verteilter Systeme aus dem Kapitel 1 des Buches "Informatik,
Eine grundlegende Einführung" [Bro98].
2 Prozesse und Threads in Java
Ein Java−Programm besitzt in der Regel eine Klasse mit einer statischen Methode main() .
Bei jedem Start eines Java Programms erzeugt das Betriebsystem einen Prozeß und startet die
virtuelle Maschine (VM). Die virtuelle Maschine erzeugt einen Haupt−Thread (engl.main thread)
auch Programmfaden, Kontrollfluß oder leichtgewichtiger Prozeß genannt. Der Haupt−Thread führt
daraufhin die Methode main() aus.
Es wird also zwischen Java−Prozesse und −Threads (threads) unterschieden.
Ein Java−Prozeß kann als Realisierung eines Prozesses des Prozeßmodells der Vorlesung gesehen
werden. Ein Java−Thread entspricht einem sequentiellen Teilprozeß. Alle Teilprozesse zusammen
formen den Gesamtprozeß.
Eine Methode im Quelltext besteht aus eine Sequenz von Anweisungen. Beim Übersetzen des
Quelltextes, wird jede Anweisung in mehrere elementare Teilanweisungen (byte codes) für die VM
zerlegt. Ein Java−Threads führt diese einzelnen elementaren Anweisungen einer Methode und die
Anweisungen der darin aufgerufenen Methoden sequentiell aus. Die elementaren Anweisungen sind
als Aktionen des Teilprozesses zu sehen.
Bei Applets startet der Internet−Browser ein Java−Programm. Für jedes Applet wird ein eigener
Thread erzeugt, der dann die Methode init() ausführt. Mehrere Applets laufen immer gemeinsam
innerhalb des vom Browser erzeugten Prozesses.
Innerhalb eines Java−Programms ist es möglich mit Hilfe der Klasse Thread weitere
nebenläufige Threads zu erzeugen. Mehrere Threads laufen parallel ab. Sie k önnen gleichzeitig auf
gleiche Objekte und Daten zugreifen. Dadurch kann es zu der, in der Vorlesung erw ähnten
Konflikte, kommen. Auf die Konflikte und wie sie g elöst werden, wird später detailliert
eingegangen.
Mehrere Threads in einem Programm werden gebraucht, um mehrere Aufgaben parallel zu
erledigen. Beispiele:
•
In einem Textprogramm wird ein Druckauftrag erteilt. Mit dem Editieren kann fortgefahren
werden. Im Hintergrund wird die Aufbereitung des Textes f ür den Drucker erledigt.
•
In einer Anwendung zum Erfassen und Pflegen von Daten kann in einer Maske eine Suche
gestartet werden. In einer anderen Maske können währenddessen Daten weiter editiert werden.
2 / 28
Java Threads und das Betriebssystem
Java−Prozesse werden auf Betriebssystem−Prozesse abgebildet. Sie stellen in Java streng getrennte
Bereiche dar. So kann ein Thread eines Java−Prozesses nie auf Objekte in anderen Prozessen
zugreifen. Eine Kommunikation ist nicht direkt, sondern nur über Verteilungstechnologien wie
CORBA oder DCOM, möglich. Diese basieren indirekt auf Netzwerkdiensten des Betriebsystems.
Die Java−Threads können von der virtuellen Maschine auf Betriebsystem−Threads abgebildet
werden. Man spricht von einer VM, die native threads unterstützt. Dies ist jedoch nicht immer der
Fall. Die meisten ersten Unix−Implementierungen der VM verwenden noch ein eigenes
Betriebssystem unabhängiges Verfahren, häufig green threads genannt. Dabei werden Threads
simuliert. Diese Simulation benutzt ein speziellen Scheduler, der die Reihenfolge der Ausführung
festlegt , und oft zu einem unerwarteten und manchmal unerwünschten deterministischen Verhalten.
(Siehe auch 4. Zuteilungsstrategien in Java)
2.1 Die Klasse Thread
Java bietet eine Klasse Thread an, die einen Thread repräsentiert. Mit Hilfe dieser Klasse können
Threads erzeugt werden. Es werden hier nur die wichtigsten Methoden besprochen.
2.2 Der aktuelle Thread
Die statische Methode currentThread() liefert den Zugriff auf den aktuellen Thread, der die
Methode gerade ausführt.
Ein einfaches Programm, das den aktuellen Thread am Bildschirm ausgibt:
public class ThreadTest
{
public static void main( String[] arguments )
{
System.out.println("THREAD:" + Thread.currentThread() );
}
}
Am Ende der Methode main() ist auch der Thread und damit der Prozeß beendet.
Ausgabe am Bildschirm:
THREAD:Thread[main,5,main]
Hinter dem Text THREAD: steht, das was die Methode toString() der Klasse Thread zurück
liefert. Das sind der Name, die Priorität und die Thread−Gruppe. Auf die Priorit ät und Gruppe wird
später unter 4. genauer eingegangen, sie sind zunächst unwichtig.
3 / 28
2.3 Mehrere Threads
Jetzt fügen wir eine Klasse Counter hinzu mit einer Methode run(). Die Methode soll die Zahl 0
bis einschließlich 1000 ausgeben.
Weiterhin erzeugen wir einen zweiten Thread, um zweimal parallel die Methode run()
auszuführen.
Die Klasse Counter implementiert die Schnittstelle Runnable um damit die Methode run() für
Threads als die auszuführende Methode anzugeben.
class Counter implements Runnable
{
public void run()
{
Thread thread = Thread.currentThread();
for( long count = 0 ; count <= 1000 ; ++count )
{
System.out.println( thread + "COUNT:" + count );
}
}
}
public class MultipleThreadTest
{
public static void main( String[] arguments )
{
Counter counter = new Counter();
Thread secondThread = new Thread( counter, "second" );
secondThread.start();
counter.run();
}
}
Der Haupt−Thread legt in der Methode main() zunächst ein Objekt counter der Klasse Counter
als Zähler an. Ein zweites Thread Objekt, secondThread, wird erzeugt und dabei wird der Z ähler
mitgegeben. Mit start() startet der Haupt−Thread den zweiten Thread. Dieser ruft intern die
run() Methode des Zählers auf. Der Haupt−Thread läuft parallel weiter und ruft selbst auch die
run() Methode auf.
Die Methode run() wird jetzt parallel von zwei Threads ausgeführt. Jeder Thread arbeitet seine
eigene Schleife ab. Der Schleifen−Zähler, bzw. den Zählerstand, count, ist eine lokale Variable
der Methode run() und wird auf dem Stack abgelegt. Jeder Thread besitzt sein eigenen Stack.
Damit existiert der Zählerstand jetzt zweimal.
Auszug aus einer möglichen Ausgabe:
Thread[second,5,main]COUNT:95
Thread[second,5,main]COUNT:96
Thread[second,5,main]COUNT:97
Thread[main,5,main]COUNT:95
Thread[second,5,main]COUNT:98
Thread[main,5,main]COUNT:96
Thread[second,5,main]COUNT:99
4 / 28
Thread[main,5,main]COUNT:97
Thread[second,5,main]COUNT:100
Thread[main,5,main]COUNT:98
Thread[second,5,main]COUNT:101
Thread[main,5,main]COUNT:99
Thread[second,5,main]COUNT:102
Thread[main,5,main]COUNT:100
Thread[second,5,main]COUNT:103
Thread[main,5,main]COUNT:101
Thread[main,5,main]COUNT:102
Thread[main,5,main]COUNT:103
Bemerkung zu den Ausgaben
Da ein regelmäßiges Abwechseln von Threads für eine Implementierung einer Java VM nicht
vorgeschrieben ist, können die Ausgaben immer gleich sequentiell erscheinen. Verwenden Sie für
ein weniger deterministisches Verhalten den RoundRobinScheduler aus 4.1 Zeitscheibenstrategie
und schreiben Sie in die erste Zeile der Methode main() :
new RoundRobinScheduler(10).start();
Bemerkung zur Methode run()
Die Signatur der Methode public void run() wird durch die Schnittstelle Runnable vorgegeben.
Diese Schnittstelle wird von der Klasse Thread vorausgesetzt. Man hat grundsätzlich die Wahl,
von der Klasse Thread abzuleiten und dessen Methode run() zu überschreiben, oder die
Schnittstelle Runnable zu implementieren und dann beim Erzeugen eines Threads das ausführbare
Objekt anzugeben.
Die Methode run() hat keine Parameter, daher müssen benötigte Daten anderweitig weitergereicht
werden. Beispielsweise über Attribute des Objektes, das die Runnable Schnittstelle implementiert.
Bemerkung zu JDK 1.2
Einige Methoden der Klasse Thread werden ab Java 1.2 nicht mehr unterstützt:
Die Methode stop() und suspend() gaben keine Sperren frei und konnten somit einfach eine
Verklemmung produzieren. Die Methode resume() machte ohne die Methode suspend() keinen
Sinn mehr. Sperren und Verklemmung werden noch genauer besprochen.
2.4 Mehrere Threads mit gemeinsamen Variablen
Wir ändern die Klasse Counter so ab, daß der Zählerstand count ein Attribut wird.
Beide Threads versuchen ihrerseits den Zählerstand zu inkrementieren.
Ziel ist, daß beide gemeinsam den Zähler von 0 auf 1000 hochzählen.
5 / 28
class Counter implements Runnable
{
private long count = 0;
public void run()
{
Thread thread = Thread.currentThread();
for( ; count <= 1000 ; ++count )
{
System.out.println( thread + "COUNT:" + count );
}
}
}
Auszüge aus einer mögliche Ausgabe:
...
Thread[main,5,main]COUNT:44
Thread[main,5,main]COUNT:45
Thread[main,5,main]COUNT:46
Thread[main,5,main]COUNT:47
Thread[main,5,main]COUNT:48
Thread[main,5,main]COUNT:49
Thread[second,5,main]COUNT:49
Thread[main,5,main]COUNT:50
Thread[second,5,main]COUNT:51
Thread[main,5,main]COUNT:52
...
Thread[second,5,main]COUNT:995
Thread[second,5,main]COUNT:996
Thread[second,5,main]COUNT:997
Thread[second,5,main]COUNT:998
Thread[second,5,main]COUNT:999
Thread[second,5,main]COUNT:1000
Thread[main,5,main]COUNT:1001
!!!
!!!!!
Diese Ausgaben sind, wenn man den Code sieht, nicht direkt plausibel.
Was ist z.B. bei der letzten Ausgabe 1001 passiert ?
Sei count = 999. Jetzt kann folgender Ablauf stattfinden:
•
•
•
Der Haupt−Thread "main" prüft ob count <= 1000 ist. Dies ist der Fall.
Jetzt kommt Thread "second" an dieser Stelle an und prüft ebenfalls ob count <= 1000 ist.
Dies ist der Fall und count wird inkrementiert und ausgegeben.
Der Haupt−Thread inkrementiert count ebenfalls und gibt ihn aus.
Das Attribut count war inzwischen jedoch bereits von "second" inkrementiert worden, somit ist
der Wert jetzt 1001 !
Offensichtlich ist es notwendig, beide Threads zu koordinieren. Sie müssen so synchronisiert
werden, daß nur ein Thread das Prüfen der Bedingung und die Inkrementierung atomar, d.h.
vollständig ohne Unterbrechung, ausführt.
6 / 28
Wie immer bei fehlender Synchronisation muß der im Beispiel gezeigte Fehler nicht zwingend
auftreten und kann lange Zeit völlig unbemerkt bleiben. Je nachdem, wann die Threads aktiv sind
bzw. bei einem Prozessor sich abwechseln, ergeben sich unterschiedliche Resultate. Dies macht es
extrem schwer in einem fertigen System fehlerhafte Code−Stellen zu finden. Vor allem kann allein
das Suchen eines Fehlers mit Hilfe des Debuggers, den Fehler verschwinden lassen. Durch die
Beobachtung mit dem Debugger wird das Abwechseln der Threads beeinflußt.
3 Synchronisation paralleler Threads in Java
Mehrere Threads können nur über gemeinsame Variablen kommunizieren. Java stellt keine anderen
Kommunikationsmittel zur Verfügung.
Folgende Konflikte können dabei auftauchen:
•
Gleichzeitiges Lesen und Schreiben.
Ein Thread liest und ein anderer ändert gerade das Objekt, ist aber noch nicht fertig.
•
Gleichzeitiges Schreiben.
Mehrere versuchen das Objekt gleichzeitig zu ändern.
Gemeinsam benutzte Objekte sollten aus der Sicht eines Threads einen korrekten konsistenten
Zustand haben. Der Zustand eines Objektes besteht aus den Werten seiner Attribute und den
Zuständen der enthaltenen Objekte.
Das Ausführen einer Methode kann den Zustand eines Objektes ändern. Eine Methode sollte das
Objekt von einem stabilen korrekten Zustand in einen anderen stabilen korrekten Zustand
überführen. Beim Übergang von einem Zustand in einem anderen Zustand können
Zwischenzustände auftreten, die nach außen nicht sichtbar werden sollen. Sie sind in der Regel von
der Implementierung der Methode abhängig.
Rufen mehrere Threads Methoden gleichzeitig auf, dann kann ohne Synchronisation ein
Zwischenzustand einer Methode den Ablauf einer anderen Methode beeinflussen.
Um Konflikte zwischen Threads zu vermeiden gibt es verschiedene Möglichkeiten.
•
Schreiben nicht zulassen
Eine Klasse wird so definiert, daß eine Änderung seiner Attribute nach dem Erzeugen der
Objekte nicht mehr möglich ist. ( engl. immutable class). Dies scheint eine drastische
Einschränkung, kann aber in manchen Fällen durchaus sinnvoll sein. Vor allem, wenn ein Objekt
einen Wert darstellt und die Identität nicht wesentlich ist, kann man sich die meist zeitintensive
Synchronisation sparen.
Bei Java können z.B. mehrere Threads problemlos auf ein gleiches Objekt der Klasse
java.lang.String zugreifen, da ein String nie mehr geändert werden kann. Jede Methode der
Klasse produziert bei einer Änderung des Wertes ein neues String Objekt. Für eine performante
Änderung eines String steht die Klasse java.lang.StringBuffer zur Verfügung, diese ist aber
nicht sicher für die gleichzeitige Verwendung durch mehrere Threads. ( engl. thread safe)
7 / 28
•
Lesen und Schreiben synchronisieren
Für die meisten Objekte jedoch ist die Identität wichtig. Nur eine explizite Synchronisation, die
nachfolgend erläutert wird, kann Konflikte vermeiden.
Die Sperre
Java verknüpft jede Instanz der Klasse java.lang.Object mit einer sogenannten Sperre ( engl.
lock). Jedes Objekt ist in Java direkt oder indirekt von der Klasse java.lang.Object abgeleitet,
somit ist mit jedem Objekt genau eine Sperre verknüpft.
Diese Sperre entspricht einem Booleschen Semaphor. Eine Sperre wird einem einzigen Thread
zugeteilt. Die anderen Threads müssen gegebenenfalls warten, bis der Thread die Sperre wieder
freigibt. Mit diesem gegenseitigen Ausschluß (engl. mutually exclusive lock kurz mutex lock) lassen
sich Zugriffe auf Objekte so koordinieren, daß Konflikte vermieden werden.
Betrachten wir einen Bereich, in dem gemeinsame Variablen geschrieben oder gelesen werden.
Benötigt jeder Thread eine Sperre bevor er diesen Bereich ausführt und gibt er danach die Sperre
wieder frei, dann kann zu jedem Zeitpunkt nur ein einziger Thread in dem Bereich aktiv sein. Somit
ist ein gleichzeitiges Lesen und Scheiben oder ein gleichzeitiges Schreiben der gemeinsamen
Variablen in diesem Bereich unmöglich.
In Java müssen alle Bereiche, in dem gemeinsame Variablen geschrieben oder gelesen werden,
explizit mit dem Schlüsselwort synchronized versehen werden, um Konflikte zu vermeiden. Sei
O ein beliebiger Ausdruck, der ein Java−Objekt liefert, und S eine Menge von Anweisungen, dann
ist die Syntax:
synchronized( O )
{
S
}
Erreicht ein Thread die synchronized Anweisung, dann gelten folgende Regeln:
•
•
•
•
•
Der Thread versucht den exklusiven Zugriff auf das Objekt O zu erhalten.
Wenn kein anderer Thread den exklusiven Zugriff hat, gelingt dies.
Besitzt ein anderer Thread den exklusiven Zugriff auf das Objekt, dann wartet der Thread, bis
das Objekt O freigegeben wird. Es können mehrere Threads auf die Freigabe warten. (Siehe auch
3. Zuteilungstrategien).
Erhält ein Thread den exklusiven Zugriff auf O, dann tritt er in den Anweisungsblock ein.
Verläßt der Thread den Anweisungsblock, in dem er den exklusiven Zugriff erhalten hat, dann
gibt er das Objekt O wieder frei.
Die mit einem Objekt verknüpfte Sperre ist sonst nicht direkt zugänglich und kann nur mit dem
Schlüsselwort synchronized gesetzt und freigegeben werden.
8 / 28
Ausnahmebehandlung
Achtung: Wenn eine Ausnahme ( engl. exception) auftritt, die nicht innerhalb eines
Anweisungsblocks abgefangen wird, dann wird wie üblich der Anweisungsblock automatisch
verlassen und dabei auch die Sperre freigegeben. Daher sollte man auch nach einer Ausnahme einen
konsistenten Zustand hinterlassen.
Verschachtelte synchronized Anweisungsblöcke
Verschachteln von synchronisierten Anweisungsblöcken ist möglich, auch wenn mehrmals mit dem
gleichen Objekt synchronisiert wird: Wenn ein synchronisierter Anweisungsblock erreicht wird,
kann es also sein, daß der Thread bereits den Zugriff auf das Objekt hat. Dann kann der Thread
sofort in dem Anweisungsblock eintreten. Beim Verlassen dieses Anweisungsblocks, wird das
Objekt noch nicht freigegeben. Erst beim Verlassen des äußeren Anweisungsblocks, dort wo er den
Zugriff ursprünglich erhalten hat, wird das Objekt freigegeben.
Damit wird verhindert, daß ein Thread auf sich selbst wartet.
Mit einem synchronized Block wird also gewährleistet, daß nur ein Thread in dem
Anweisungsblock zu einem Zeitpunkt aktiv sein kann.
3.1 Kritischer Bereich
Wir können jetzt das synchronized Schlüsselwort einsetzen, um den vorher beim Zählerstand
gezeigten Fehler zu beheben. Die Prüfung und die Inkrementierung stellen einen sogenannten
kritischen Bereich dar. Dies ist ein Bereich, wo Konflikte zwischen Threads auftreten können.
Wir ändern jetzt den Code so ab, daß die Prüfung und die Inkrementierung nur zusammen
ausgeführt werden können.
class Counter implements Runnable
{
private long count = 0 ;
public void run()
{
Thread thread = Thread.currentThread();
boolean bContinue = true;
while ( bContinue )
{
System.out.println(
thread + "COUNT:" + count );
synchronized( this )
{
if( count < 1000 )
{
++count;
}
else bContinue = false;
}
}
}
}
9 / 28
Warum wurde this angegeben ?
Das Attribut count kann man nicht angeben. Der Typ von count ist long. Dieser Typ ist ein
sogenannter primitiver Typ, wie auch short, int, byte, char, float und double. Damit ist count
kein Objekt. Allerdings ist count ein Attribut von der Klasse Counter. Folglich gehört zu jedem
Attribut namens count genau ein Objekt der Klasse Counter. Daher ist es einfach das Attribut
count mit dem zugehörigen Objekt zu synchronisieren. Das Objekt wird in der Methode mit this
angegeben.
Die for−Schleife wurde in eine while−Schleife geändert, damit die Prüfung und die
Inkrementierung in einem Block innerhalb der Schleife stattfinden können.
Was passiert, wenn der synchronisierte Anweisungsblock die gesamte for−Schleife umfassen
würde ? Warum ist es Problematisch, daß die Ausgabe außerhalb des synchronized Bereiches
gemacht wird ?
Synchronisierte Methoden
Wir ändern die Klasse Counter jetzt so, daß die Prüfung und das Inkrementieren in einer eigenen
increment() Methode passieren. Der Rückgabewert gibt an, ob eine Inkrementierung möglich war.
class Counter implements Runnable
{
private long count = 0 ;
public void run()
{
Thread thread = Thread.currentThread();
boolean bContinue = true;
while ( bContinue )
{
System.out.println(
thread + "COUNT:" + count );
bContinue = increment();
}
}
public boolean increment()
{
synchronized( this )
{
if ( count < 1000 )
{
++count;
return true;
}
return false;
}
}
Jetzt ist die gesamte Implementierung der Methode increment() synchronisiert. Java erlaubt dafür
folgende semantisch äquivalente Schreibweise:
10 / 28
Wir ändern die Methode increment() ab:
public synchronized boolean increment()
{
if ( count <= 1000 )
{
++count;
return true;
}
return false;
}
Dies wird eine synchronisierte Methode genannt. Es wird gewährleistet, daß nur ein Thread
gleichzeitig in genau dieser Methode aktiv sein kann. Es wird nicht gewährleistet, daß kein anderer
Thread gleichzeitig in einer anderen Methode aktiv ist. D.h. count kann in einer anderen Methode
weiterhin gleichzeitig geändert werden.
Lesende Methoden müssen synchronisiert werden
Wir führen ein Methode getCount() ein, die den Zählerstand zurück liefert.
public long getCount()
{
return count;
}
Nehmen wir an, ein Thread ruft irgendwann getCount() auf. Auf den ersten Blick ist nicht
ersichtlich, daß hier ein Problem vorliegt.
Die Methode getCount() kann ein fehlerhaftes Ergebnis haben und zwar aus zwei folgenden
Gründen:
• getCount() kann ausgeführt werden, während irgendeine andere auch synchronisierte Methode
z.B increment() oder eine andere Methode aus einer Subklasse ausgeführt wird. Diese andere
Methode könnte count in ihrem Ablauf zwischenzeitlich auf den Wert −1 setzen und erst am
Ende den korrekten Wert zuweisen. Die Methode getCount() könnte dann zwischenzeitlich den
unsinnigen Wert −1 liefern.
• Auch wenn keiner den Wert zwischenzeitlich auf −1 setzt, kann getCount() einen falschen
Wert liefern. Java garantiert, daß alle Operationen auf primitive Typen atomar ausgeführt
werden, außer für double und long. D.h. Die Ausführung von ++count, könnte in 2 Schritten
ablaufen. Zwischen diesen beiden Schritten kann ein Threadwechsel auftreten. Der Wert von
count nach dem 1. Schritt ist nicht spezifiziert.
Was wir eigentlich erreichen wollten ist eine immer konsistente Sicht auf Counter von außen.
Daher muß auch getCount() synchronisiert werden:
11 / 28
public synchronized long getCount()
{
return count;
}
Damit ist auch klar, warum eine Methode getCount() unerläßlich ist, und count nicht einfach
public gemacht werden kann. Aus der bisherigen Beschreibung ist ersichtlich, daß, um mehrere
Threads zu unterstützen, wesentliche Codeänderungen notwendig waren.
Vollständig synchronisierte Klassen
Sind in einer Klasse alle nicht statischen Methoden über dasselbe Objekt synchronisiert und alle
Attribute private, dann kann ein Objekt dieser Klasse nur von jeweils einem Thread bearbeitet
werden. Dies entspricht dem Konzept des Monitors, bei dem nur eine Operation eines Monitors
gleichzeitig ausgeführt werden kann.
Die beim Monitor üblichen Benachrichtigungsmechanismen existieren auch in Java. Sie werden
anschließend im Abschnitt 2.3 erläutert.
Synchronisation statischer Methoden
In Java gehört zu jeder Klasse eine, vom System erzeugte Instanz der Klasse java.lang.Class.
Diese Instanz beschreibt den Aufbau der Klasse und macht diese zur Laufzeit verfügbar.
Der Klassenname gefolgt von ¨.class¨ ,liefert diese Instanz. Wenn eine statische Methode als
synchronized gekennzeichnet wird, dann entspricht dies einer Synchronisierung mit der zu dieser
Klasse gehörenden Instanz der Klasse java.lang.Class.
Beispiel:
public class Counter
{
private static long maximum = 1000;
public static synchronized long getMaximum()
{
return maximum;
}
…
}
Die Methode getMaximum() entspricht folgender Implementierung:
public static long getMaximum()
{
synchronized( Counter.class )
{
return maximum;
}
}
12 / 28
Synchronisierte Methoden und Vererbung.
Die Synchronisation gehört in Java nur zur Implementierung und wird nicht vererbt ! Daher kann
auch eine Schnittstellen−Definition nicht das Schlüsselwort synchronized enthalten.
Beim überschreiben einer Methode, darf daher die Synchronisation bei der neuen Implementierung
nicht vergessen werden.
3.2 Bewachte kritische Bereiche
Bisher konnte ein Thread nur auf eine einzige Bedingung warten. Und zwar darauf, ob ein Zugriff
zur Verfügung steht oder nicht. Manchmal jedoch möchte man komplexere zustandsabhängige
Bedingungen formulieren auf deren Erfüllung man wartet.
Man spricht dann von einem bewachten kritischen Bereich. Ein Thread wartet bis eine Bedingung
erfüllt ist, bevor er in dem Bereich eintritt. ( engl. guarded suspension)
In der Vorlesung wurde dies durch
await E then S endwait
zum Ausdruck gebracht. Wobei E einen booleschen Ausdruck und S eine Menge von Anweisungen
ist. Der Ausdruck E stellt den Wächter ( engl.guard ) dar. Sobald E erfüllt ist , wird S exklusiv von
einem einzigen Thread ausgeführt.
Die exklusive Ausführung wird in Java durch das Schlüsselwort synchronized erreicht. Das
Warten, hier auf eine Bedingung, wird durch ein Aufruf der Methode wait() eines Objektes
eingeleitet:
public class ClassWithGuardedSuspension
{
…
private synchronized void awaitConditionE()
{
while( ! E )
{
try
{
wait();
}
catch( InterruptedException ex ){}
}
}
public void synchronized guardedStatementsS()
{
awaitConditionE();
S
}
}
13 / 28
Wozu wird wait() gebraucht ?
Würde man statt wait() eine Schleife wie while(!E){ … } verwenden, dann spricht man von
busy waiting. Der Thread wertet die ganze Zeit, meistens unnötigerweise, die Bedingung aus.
Weiterhin wird dabei die Sperre nicht abgegeben. Dadurch kann ein anderer Thread nie an die
Reihe kommen, um den Zustand so zu ändern, daß die Bedingung jemals wahr wird !
Busy waiting ist folglich zu vermeiden. Ein wartender Thread mu ß auch benachrichtigt (engl.
notified) werden, daß er weiter machen kann.
Daher existieren folgende vom Laufzeitsystem vorgegeben Methoden der Klasse Object:
•
•
•
wait()
notify()
notifyAll()
Nur wenn ein Thread den exklusiven Zugriff auf ein Objekt hat, kann er dessen wait() , notify()
oder notifyAll() Methode aufrufen. Ansonsten wird ein IllegalMonitorStateException vom
Laufzeitsystem erzeugt.
•
wait()
•
•
•
notify()
•
•
•
•
Der aktuelle Thread wird auf wartend gesetzt
und in einem mit dem Objekt verknüpften Wartebereich gestellt
Den exklusiven Zugriff, den der Thread hat, wird freigegeben. Was Voraussetzung daf ür ist,
daß andere Threads überhaupt notify() oder notifyAll() aufrufen können.
Wenn wartende Threads existieren , dann wird nichtdeterministisch einer ausgewählt und
dieser aus dem Wartebereich entfernt.
Der ausgewählte Thread versucht Zugriff auf das Objekt zu bekommen. Der ausgewählte
Thread kann den Zugriff nur dann bekommen, wenn der Thread der notify() aufgerufen hat
und noch im Besitz des Zugriffs, diesen freigibt und kein anderer Thread den Zugriff erhält.
Erhält der ausgewählter Thread später den Zugriff, dann setzt er die Ausführung mit dem auf
wait() folgendem Code fort.
notifyAll()
•
Verläuft wie notify() mit dem Unterschied, daß alle wartenden Threads aus dem
Wartebereich entfernt werden und wieder versuchen die Sperre zu bekommen.
Es existiert noch die Möglichkeit bei wait() die maximale Wartezeit anzugeben. Ist diese
verstrichen, dann wird automatisch für diesen Thread wie bei notify() verfahren.
Wenn ein Unterbrechung ( engl. interrupt) während eines wait() passiert ( z.B. durch Aufruf der
interrupt() Methode der Klasse Thread), dann wird wie bei notify() verfahren. Die Ausführung
wird aber fortgefahren mit dem Werfen einer Ausnahme der Klasse InterruptedException.
Das Ziel ist es mit wait() auf ein Ereignis zu warten. Das Ereignis wird durch das Objekt oder
einen Zustand des Objektes repräsentiert. Mit notify() wird dann das Eintreten des Ereignisses
mitgeteilt.
14 / 28
Da die Auswahl der Threads beim notify() nichtdeterministisch ist, kann es zum Aushungern
kommen. D.h. bestimmte Threads werden nie aus dem Wartbereich ausgewählt. Nur eine selbst
programmierte Warteschlange kann hier Fairness garantieren. (siehe auch 2.6.2 )
Somit kann jedes Java−Objekt als Signal, wie beim Monitorkonzept in der Vorlesung definiert,
verwendet werden.
3.3 Das Monitor−Konzept in Java
Das Monitor−Konzept der Vorlesung läßt sich in Java realisieren durch eine Klasse, in der alle
Attribute private sind, alle nicht statischen Methoden als synchronized gekennzeichnet sind und
das Objekt selbst immer als Signal verwendet wird:
•
•
Die Kennzeichnung aller Methoden als synchronized garantiert den gegenseitigen Ausschluß.
Die Verwendung des Objektes selbst als Signal, sorgt für eine Freigabe der Sperre beim wait(),
und ermöglicht damit die Ausführung der Methoden durch andere Threads.
Bei der Verwendung als Signal, wird für ein Objekt o in der Regel angenommen, daß man beim
Aufruf von o.wait() auf eine Zustandsänderung von o wartet. Bei o.notify() gibt man dann die
Zustandsänderung bekannt. Diese Interpretation ist jedoch nicht zwingend.
Beispiel für einen Monitor unter Verwendung von wait() und notifyAll():
Wir ändern jetzt increment(), so daß inkrementiert wird, sobald dies möglich ist. Der
Rückgabewert ist damit überflüssig. Analog zu increment() führen wir eine Methode
decrement() und ein Minimum ein. Die Methode decrement() versucht zu dekrementieren,
sobald dies möglich ist. Dann lassen wir einen Thread hochzählen und einen anderen Thread
runterzählen.
public class Counter
{
private static long maximum = 1000;
private static long minimum = 0;
public static synchronized long getMaximum()
{
return maximum;
}
public static synchronized long getMinimum()
{
return minimum;
}
private long count = getMinimum();
private synchronized void awaitIncrementable()
{
while( !( count < getMaximum() ) )
{
try
{
wait();
}
15 / 28
catch( InterruptedException e ) {}
}
}
public synchronized void increment()
{
awaitIncrementable();
setCount( count +1 );
}
private synchronized void awaitDecrementable()
{
while( !( count > getMinimum() ) )
{
try
{
wait();
}
catch( InterruptedException e ){}
}
}
public synchronized void decrement()
{
awaitDecrementable();
setCount( count −1 );
}
public synchronized void setCount( long count )
{
this.count = count;
System.out.println( Thread.currentThread() + "COUNT:" + count );
notifyAll();
}
public synchronized long getCount()
{
return count;
}
}
public class CounterTest
{
public static void main( String[] arguments )
{
final Counter counter = new Counter();
Thread secondThread = new Thread( "second" )
{
public void run()
{
while ( true )
{
counter.increment();
}
}
};
secondThread.start();
while( true )
{
counter.decrement();
}
}
}
16 / 28
Jedesmal wenn count sich ändert, wird setCount und damit notifyAll() aufgerufen. Ein Thread
der z.B. bei einem awaitIncrementable wartet, wird dann geweckt. Ist der count < maximum,
dann wird inkrementiert.
Warum wird die Bedingung in einer Schleife geprüft ?
Der notifyAll() deutet nur eine Änderung an. Nach einem notifyAll() ist keineswegs die
Erfüllung der Bedingung gewährleistet.
Warum wird notifyAll() verwendet ?
Der Aufruf von notify() statt notifyAll(), ist nur dann zu empfehlen, wenn es nur eine
Bedingung gibt und nachher garantiert ist, daß nur ein einziger Thread aktiv werden kann.
Wie kann die Anzahl der notify() Aufrufe und der damit verbundene Synchronisationsaufwand
reduziert werden? Hinweis: Wenn der Zustandsraum aufgeteilt wird, ergeben sich nur bestimmte
Situationen, wo ein notifyAll() sinnvoll ist.
Bemerkungen
Unsere Klasse Counter ist ein Monitor im Sinne der Vorlesung.
Wenn in einer synchronisierten Methode ein wait() aufgerufen wird, dann kann die Operation
nicht mehr als atomar betrachtet werden! Beim wait() wird die Sperre freigegeben und andere
Threads können den Zustand ändern. Daher sollte das Objekt in einen konsistenten Zustand sein
bevor wait() aufgerufen wird. Am einfachsten ist dies dadurch zu erreichen, indem vor einem
wait() keine Änderungen am Objekt gemacht werden.
Wird der Zustand so geändert, daß ein anderer Thread weitermachen könnte, ruft man notifyAll()
oder notify() auf. Danach sollte der Thread, solange er den Zugriff hat, den Zustand nicht mehr
ändern. Die geweckten Threads können erst nach der Freigabe der Sperre aktiv werden und nicht
schon unmittelbar nach dem notifyAll() Aufruf. Ein geweckter Thread findet dann vielleicht
einen Zustand vor, in dem die Bedingung eventuell nicht mehr erfüllt ist. Am einfachsten ist es,
wenn ein notify() Aufruf als möglichst letzter Aufruf vor dem Verlassen der Methode verwendet
wird.
Eine Nachrichtenschlange in Java
Ein Sender und Empfänger kommunizieren über eine Nachrichtenschlange.
Der Empfänger soll so lange warten bis ein Objekt vorliegt.
public class MessageQueue
{
private Vector queue = new Vector();
public synchronized void send( Object object )
{
queue.addElement( object );
notifyAll();
}
17 / 28
public synchronized Object receive()
{
while( queue.size() == 0 )
{
try
{
wait();
}
catch( InterruptedException e ) {}
}
Object object = queue.elementAt(0);
queue.removeElementAt(0);
return object;
}
}
// send a string character by character in an endless loop
public class Sender extends Thread
{
private String
text;
private MessageQueue messageQueue;
public Sender( String text, MessageQueue messageQueue )
{
super( "sender of " + text );
this.text = text;
this.messageQueue = messageQueue;
}
public void run()
{
while ( true )
{
for( int i = 0 ; i < text.length() ; ++i )
{
messageQueue.send( text.substring( i,i+1 ) );
}
}
}
}
public class MessageQueueTest
{
public static void main( String[] arguments )
{
MessageQueue messageQueue = new MessageQueue();
Thread sosThread = new Sender( "S.O.S!!!", messageQueue );
sosThread.start();
Thread helloThread = new Sender( "Hello??", messageQueue );
helloThread.start();
while ( true )
{
System.out.print( messageQueue.receive() );
};
}
}
Beachte die Analogie der Klasse MessageQueue und der Klasse Counter aus dem vorangehenden
Beispiel. Erweiteren Sie die Klasse MessageQueue so, daß die Nachrichtenschlange nur eine
maximale Anzahl an Elementen beinhalten kann.
18 / 28
3.4 Semaphore
Mit bewachten kritischen Bereiche können, analog zu den Definitionen der Vorlesung, auch
Semaphore in Java umgesetzt werden. In der Regel ist eine direkte Verwendung der bestehenden
Synchronisationsmechanismen jedoch einfacher.
3.4.1 Ganzzahlige Semaphore
Die ganzzahligen Semaphore lassen sich wie folgt realisieren:
public final class CountingSemaphore
{
private int count = 0;
public CountingSemaphore( int initialCount )
{
count = initialCount;
}
public synchronized void P()
{
while( count <= 0 )
{
try
{
wait();
}
catch( InterruptedException ex ) {}
}
−−count;
}
public synchronized void V()
{
++count;
notify();
}
}
Der Aufruf von notify() statt notifyAll() ist ausreichend, da nur ein einziger Thread nach der
Freigabe aktiv werden kann: Dieser nimmt sofort die frei gewordene Ressource und die anderen
müssen weiterhin warten.
3.4.2 Boolesche Semaphore
Die boolesche Semaphore werden analog zu den ganzzahligen Semaphoren implementiert:
public final class BooleanSemaphore
{
private boolean isFree;
public BooleanSemaphore( boolean isFree )
{
this.isFree = isFree;
}
19 / 28
public synchronized void P()
{
while( !isFree )
{
try
{
wait();
}catch( InterruptedException ex ) {}
}
isFree = false;
}
public synchronized void V()
{
isFree = true;
notify();
}
}
3.5 Verklemmung
3.5.1 Koch−Beispiel
Eine Verklemmung kann leicht auftreten, falls mehrere Threads die gleichen Objekte als
Ressourcen in einer verschiedenen Reihenfolge verwenden.
Nehmen wir an, es stehen in einer Küche nur eine Schüssel und nur ein Maßbecher sowie weitere
übliche Utensilien zur Verfügung.
Nehmen wir weiterhin an, ein Koch kann mehrere Omelettes machen:
•
•
•
•
Er nimmt eine Schüssel,
füllt die Schüssel mit aufgeschlagenen Eiern und rührt diese.
Mit einem Maßbecher mißt er ab, wieviel er für jedes Omelett braucht.
Er backt die einzelnen Omeletts in der Pfanne.
Ein Koch kann auch Kekse machen:
•
•
•
Er nimmt einen Maßbecher, um die Menge an Mehl zu bestimmen.
Er gibt den Inhalt des Maßbechers in eine Schüssel und rührt die Ingredienzen zusammen.
Anschließend formt er die Kekse und backt sie im Backofen.
Das ganze in pseudo Java−Code:
20 / 28
class Koch
{
static
static
static
static
...
Schuessel
Massbecher
Mehl
Ei[]
schuessel;
massbecher;
mehl;
eier;
void macheOmeletts( int anzahl )
{
synchronized( schuessel )
{
schuessel.fuelleMit( brecheEier( eier ) );
schuessel.ruehren();
synchronized( massbecher )
{
massbecher.fuelleMit( schuessel.inhalt() );
massbecher.messeAnteil(anzahl);
...
}
}
}
void macheKekse()
{
synchronized( massbecher )
{
massbecher.messe( mehl );
synchronized( schuessel )
{
schuessel.fuelle( massbecher );
schuessel.ruehren();
...
}
}
}
}
Nehmen wir an es gibt zwei Köche, Hugo und Fritz. Koch Hugo macht Omeletts und Koch Fritz
macht Kekse.
Wenn zwei Köche sich gleichzeitig in der Küche aufhalten, dann muß eine Koordination
stattfinden. Die allererste selbstverständliche Koordination, die uns einfällt ist, daß die Schüssel
nicht von mehreren Köchen gleichzeitig verwendet wird, sondern nur exklusiv von einem Koch.
Analoges gilt für den Maßbecher. Ansonsten könnte es passieren, daß die Ingredienzen der Kekse
und der Omeletts unkontrolliert vermischt werden. Ein Koch wartet sozusagen als Thread auf eine
Freigabe der Schüssel.
Dennoch ist diese Koordination nicht ausreichend, die Reihenfolge ist entscheidend. Folgende
Situation kann auftreten:
•
Hugo nimmt eine Schüssel, füllt diese mit Eiern.
•
Fritz nimmt gleichzeitig den Maßbecher und mißt Mehl ab.
•
Hugo möchte jetzt abmessen wieviel er für jedes Omelett braucht. Er braucht den Maßbecher,
der ist leider noch in Gebrauch.
•
Fritz möchte jetzt die Schüssel nehmen, um das Mehl rein zu geben, die ist leider noch in
Gebrauch.
21 / 28
Beide warten endlos aufeinander. Ein Verklemmung ist aufgetreten. (engl. deadlock)
Mögliche Lösungen sind:
•
•
•
Es darf nur ein Koch gleichzeitig in die Küche. Damit erhöht man die Sperr−Granularität. Dies
verringert die Nebenläufigkeit.
Oder die Köche reservieren die benötigten Utensilien immer in der gleichen Reihenfolge.
Dadurch wird implizit eine Sperren−Hierarchie vorgeschrieben.
Bei einer Verklemmung wird von einer übergeordneten Instanz die Verklemmung aufgelöst. Ein
Koch muß die Zwischenergebnisse verwerfen und neu anfangen. Diese Möglichkeit wird von
Java selbst nicht unterstützt.
3.5.2 Verschachteltes Monitor Problem
Eine Klasse wie die MessageQueue ist ein Monitor. Beinhaltet ein Monitor ein weiteren Monitor
und ist dieser nur über den äußeren Monitor zugänglich, dann kann es ebenfalls leicht zu einer
Verklemmung (engl. deadlock) kommen.
Wir ändern jetzt die Klasse MessageQueue so ab, daß das Signal ’’Die Nachrichtenschlange ist
nicht leer’’, durch eine Boolesche Semaphore dargestellt wird.
public class MessageQueue
{
private Vector queue = new Vector();
private BooleanSemaphore nonEmpty = new BooleanSemaphore( false );
public synchronized void send( Object object )
{
queue.addElement( object );
nonEmpty.V();
}
public synchronized Object receive()
{
nonEmpty.P();
Object object = queue.elementAt(0);
queue.removeElementAt(0);
return object;
}
}
Wenn ein Thread receive() aufruft und wenn die Schlange leer ist, dann wird beim wait(); in
der Methode P() nur die Sperre für die Semaphore freigegeben, nicht die Sperre der
MessageQueue. Dadurch kann ein anderer Thread nicht in send() eintreten, d.h. nie mehr eine
Nachricht in die Schlange stellen und den Thread, der in P() wartet, nie mehr benachrichtigen. Der
Empfänger sperrt somit den Sender aus ( engl. lock out )
Das Problem läßt sich lösen indem:
entweder synchronized beim äußeren Objekt entfernt wird, falls dies möglich ist.
Oder indem man dem inneren aggregierten Objekt einen Verweis auf das Äußere mitgibt. Somit
könnte das innere Objekt das äußere Objekt für die Synchronisation verwenden.
Dies bedingt aber eine Codeänderung der Klasse BooleanSemaphore !
•
•
22 / 28
4 Zuteilungsstrategien in Java für Threads
Bisher wurde nur von mehreren gleichzeitig aktiven oder wartenden Threads ausgegangen. Thread
benötigen für ihre Ausführung einen Prozessor. Viele Java−Implementierungen unterstützen bisher
nur einen Prozessor. Bei nur einem Prozessor, kann nur ein Thread aktiv sein. Der Thread wird dem
Prozessor zugeteilt. Man spricht auch von Scheduling (engl.). Die Zuteilungsstrategie beeinflußt
wesentlich das Verhalten des Systems bei mehreren Threads.
Um die Zuteilungsstrategien (engl. scheduling strategies) besser zu verstehen, unterscheiden wir die
folgenden Zustände eines Threads:
Initialzustand:
Der Thread wurde erzeugt, führt aber noch keine Methode aus. d.h. start() wurde noch nicht
aufgerufen.
Lauffähig:
Ein Thread geht vom Initialzustand mit start() in den Zustand ¨Lauffähig¨ über.
Aktiv:
Ein Thread wird aktiv, wenn er von Javas virtueller Maschine (JVM) dazu aus der Menge der
lauffähigen Threads ausgewählt wird. In diesem Zustand führt der Thread tatsächlich Code aus.
Der Thread wird vom der JVM dann entweder in den Zustand lauffähig zurück gesetzt oder er
kommt in den Zustand ¨Blockiert¨.
Blockiert:
Ein Thread ist blockiert, wenn er auf den exklusiven Zugriff auf eines bei synchronized
angegeben Objektes oder auf ein Ereignis mittels wait() wartet. Ist ein Thread blockiert, dann geht
er in den Zustand ¨Lauffähig¨ über, falls ein anderer Thread die Sperre freigibt oder falls ein
anderer Thread mit einem notify() oder notifyAll() das Ereignis meldet und daraufhin die JVM
den blockierten Thread auswählt.
Eine andere Möglichkeit blockiert zu werden , ist der Aufruf der Methode void sleep( long
milliseconds ) der Klasse Thread. Wenn die Zeit abgelaufen ist, wird die JVM den Thread
wieder in den Zustand ¨Lauffähig¨ setzen. Sperren werden in der Zwischenzeit nicht freigegeben.
Endzustand:
Ein Thread ist in seinem Endzustand, falls die run() Methode ausgeführt wurde, oder mit einer
Ausnahme beendet wurde.
23 / 28
Auswahl durch
JVM nach notify
start()
Intial
Lauffähig
Auswahl durch
JVM
Blockiert
notify()
notifyAll()
Aktiv
run() , sleep() oder
in synchronized()
warten auf den
Zugriff
Ende
Ende von run() oder
unbehandelte Ausnahme
Dieser Automat zeigt die Übergänge zwischen den Zuständen eines Threads
Die entscheidende Frage ist, wie das Java Laufzeitsystem einen Thread zum aktiven Thread
bestimmt.
Prioritäten
Threads können unterschiedliche Prioritäten haben. Diese werden nur vom Programmierer
festgelegt. Die Java virtuelle Maschine garantiert, daß der Thread mit der höchsten Priorität, immer
der aktive Thread ist. D.h. wird ein Thread lauffähig und hat dieser eine höhere Priorität als der
derzeit aktive Thread, dann wird der derzeit aktive Thread unterbrochen und in den Zustand
lauffähig gesetzt. Der Thread mit der höheren Priorität wird aktiv gesetzt.
Damit besteht in Java die Gefahr des Aushungerns (engl. starvation). Ein Thread niedriger Priorität
läuft Gefahr niemals aktiv zu werden, wenn immer mindestens ein Thread mit einer höheren
Priorität lauffähig ist.
Existieren nur lauffähige Threads gleicher Priorität, dann wird nichtdeterministisch ein aktiver
Thread gewählt. Auch bei notify() wird nichtdeterministisch ein Thread gewählt, der in den
Zustand lauffähig übergeht. Daher können die Bildschirmausgaben in den Beispielen bei jedem
Durchlauf variieren.
Thread−Wechsel
Die virtuelle Maschine muß einen neuen Thread immer nur dann als aktiv auswählen wenn:
ein Thread blockiert,
oder ein Thread höherer Priorität, als der aktive Thread, lauffähig wird.
•
•
Folglich:
• Ein notify() bedingt nicht notwendigerweise einen Threadwechsel.
• Eine Freigabe von Sperren bedingt nicht notwendigerweise einen Threadwechsel.
24 / 28
Die virtuelle Maschine darf, aber muß niemals, neue Threads aktiv setzen aufgrund von anderen
Ereignissen. z.B. Unterbrechungen (engl. interrupts) oder Zeiteinheiten.
Dies hängt von der jeweiligen Implementierung der VM ab.
Java garantiert insbesondere nicht, daß alle lauffähigen Threads nacheinander für eine feste
Zeiteinheit (Zeitscheiben) aktiviert werden. (engl. round−robin−strategy)
Ohne eine explizite Angabe, haben alle erzeugten Threads die gleiche Priorität.
Viele Unix−Implementierungen wechseln die Threads nur dann, wenn sie müssen.
Die Windows−Implementierungen jedoch meistens auch zu anderen nicht genauer definierten
Zeitpunkten. Folglich ist es durchaus möglich, daß bei den Beispielen die Threads nur nacheinander
aktiv werden. Dies ist an sich nicht schlimm, da semantisch die gleiche Aufgabe erledigt wird.
Dadurch können aber Synchronisationsfehler unentdeckt bleiben.
Hintergrund−Thread
Java kennt neben normalen Benutzer−Threads (engl.user threads) sogenannte Hintergrund−
Threads (engl. daemon threads). In Java endet ein Prozeß sobald alle Benutzer−Threads zu Ende
sind. Dann werden auch alle Hintergrund−Threads automatisch beendet. Mit den Methoden void
setDaemon( boolean on ) und boolean isDaemon() der Klasse Thread kann diese Eigenschaft
eines Threads gesetzt und abgefragt werden.
Beispielsweise gibt es in einem Java−Prozeß immer ein Hintergrund−Thread, der nicht mehr
referenzierte Objekte aufräumt. (engl. garbage collection thread)
4.1 Zeitscheibenstrategie
Um Abhilfe gegen die sequentielle Ausführung der Threads zu schaffen und das Verhalten der
Testprogrammen besser zu illustrieren, können wir eine Zeitscheibenstrategie leicht
implementieren.
class RoundRobinScheduler extends Thread
{
int timeslice;
public RoundRobinScheduler( int timeslice )
{
this.timeslice = timeslice;
setPriority( Thread.MAX_PRIORITY );
setDaemon(true);
}
public void run()
{
while( true )
{
try
{
sleep( timeslice );
}
catch( Exception e ){}
}
}
}
25 / 28
Jedesmal, wenn dieser Thread lauffähig wird, wird er aktiviert, da er die höchste Priorität hat.
Anschließend blockiert er mit sleep() für einige Millisekunden. Ein anderer Thread kommt an die
Reihe usw.
Eine Zeitscheiben−Strategie alleine garantiert nicht, daß alle Threads gleichmäßig an die Reihe
kommen.
Nehmen wir unsere Klasse Counter mit zwei Threads gleicher Priorität; folgender Ablauf ist
denkbar und um so wahrscheinlicher, je mehr Threads das gleiche Counter Objekt verwenden:
Beide Threads arbeiten in einer Schleife und der Haupt−Thread hat den Zugriff auf den Zähler.
•
•
•
•
•
•
•
•
Die Zeitscheibe ist vorbei, ein Threadwechsel wird erzwungen.
Der zweite Thread wird aktiv, wird jedoch gleich blockiert, da der Haupt−Thread den Zugriff
hat.
Der Haupt−Thread wird wieder aktiv. Der Zugriff wird freigegeben.
Der zweite Thread wird lauffähig aber nicht automatisch aktiv.
Der Haupt−Thread erhält wieder den Zugriff.
Die Zeitscheibe ist vorbei, ein Threadwechsel wird erzwungen.
Der zweiter Thread wird jetzt aktiv aber blockiert sofort wieder.
usw...
Nur wenn eine Unterbrechung stattfindet während der Haupt−Thread den Zugriff freigegeben hat,
dann kann der zweite Thread an die Reihe kommen und den Zugriff erhalten.
Erst eine Warteschlange bei der Vergabe des Zugriffs würde Abhilfe schaffen.
D.h. Die Wartenden reihen sich ein. Nur der Erste in der Schlange kann den Zugriff nach einer
Freigabe erhalten.
4.2 Sperre mit Warteschlange
Im folgenden implementieren wir eine Sperr, die eine Warteschlange beinhaltet mit den Standard−
Java−Synchronisationsmechanismen. Ein Thread soll warten, bis er als erster in der Warteschlange
steht.
Die Schnittstelle lautet:
interface QueuedLock
{
aquire()
release();
}
Damit läßt sich
synchronized( o )
{ ... }
26 / 28
durch
o.lock.aquire();
{ ...
}
o.lock.release();
ersetzen.
Wobei lock ein öffentliches Attribut von o ist. Das Attribut lock ist dann vom Typ QueuedLock .
Achtung: Eine Schachtelung von o.lock.aquire(){...}o.lock.release(); Bereiche, ohne
sich selbst aus zu sperren, wie bei synchronized , sollte möglich sein.
Mit dem Standard−Mechanismus synchronized muß der Thread immer warten, falls er den
Zugriff nicht enthalten kann. Manchmal gäbe es aber die Möglichkeit eine Alternative auszuführen.
Erweitern Sie QueuedLock um eine Methode boolean tryAquire().
5 Anmerkungen
Sicherheit
Threads werden von der virtuellen Maschine hierarchisch gruppiert. Zugang zu dieser Struktur
bietet die Klasse ThreadGroup. Alle Applets werden in einem Prozeß ausgeführt. Jedes Applet hat
einen eigenen ThreadGroup. Applets sollten nicht die Threads von anderen Applets beeinflussen
können, es sei denn, es wird explizit erlaubt. Daher werden Aufrufe f ür Threads aus einer anderen
ThreadGroup als der eigenen geprüft. Der Sicherheitsmanager, d.h. die Instanz der Klasse
SecurityManager, wird jedesmal gefragt, ob der Aufruf erlaubt ist.
Richtlinien
Die Programmierung für mehrere Threads ist nicht trivial. Nachträglich sequentiellen Code für
mehrere Threads tauglich (engl. thread safe) zu machen, liefert nur selten befriedigende Ergebnisse.
Vor allem die Strategie der Sperrenvergabe ist vorher zu überlegen:
•
Die Sperren−Granularität sollte so grob wie möglich gewählt werden.
Dies schränkt zwar die Nebenläufigkeit ein, verringert jedoch die Anzahl der
Konfliktsituationen. Solange der Ablauf fair ist entsteht auch ein sinnvolles Ergebnis. Es ist
immer abzuwägen, ob nur das Endergebnis zählt, oder ob auch Zwischenergebnisse wichtig sind.
Wenn nur das Endergebnis zählt, ist eine geringere Nebenläufigkeit tragbar. Da die meisten
virtuellen Maschinen bisher nur einen Prozessor unterstützen, verlangsamt ein häufiger Wechsel
der Threads die Berechnung eher.
Nur wenn Zwischenergebnisse gebraucht werden, sollte die Granularität verfeinert werden.
•
Die Anzahl der gemeinsamen Variablen sollte so klein wie möglich bleiben.
Z.B. bei der Druckaufbereitung in einem eigenem Thread ist es erheblich einfacher den gesamten
Text im Speicher zu kopieren und dann zu formatieren, als die Zugriffe auf dem Text zu
synchronisieren.
27 / 28
Fehler
Ein unbedachter Einsatz von Nebenläufigkeit in Java führt auf eine tückische Fehlerquelle. Fehler
tauchen in bereits getesteten Programmen unvermittelt, je nach Javaimplementierung, Rechner,
Ablaufgeschwindigkeit, Betriebssystem usw. auf. Die Fehlersuche ist aufwendig. die Fehler sind
schwer reproduzierbar. Eine Suche mit einem Debugger kann den Ablauf stark beeinflussen.
Dadurch können Fehler "verschwinden". Auch die Ausgabe von Meldungen kann die
Synchronisation beeinflussen.
Wiederverwendung
Synchronisation bedeutet ein erhebliches mehr an Code bzw. Code−Änderungen.
Synchronisations−Code legt eine Verwendungsart fest. Z.B. kann in einer Methode gewartet
werden, bis eine Bedingung eintritt oder einfach eine Fehlermeldung zurückgeben werden. Beide
Verhaltensweisen können je nach Kontext sinnvoll sein.
Es kann sinnvoll sein, den Synchronisations−Code und den funktionalen Kern zu trennen.
Der Synchronisations−Code wird dann in eine Adapter−Klasse verlagert, die den funktionalen Kern
aufruft. Den funktionalen Kern kann man dann auch in einem sequentiellen Kontext einsetzen, wo
eine Synchronisation nicht erwünscht ist. Denn Synchronisations−Code beansprucht auch
erhebliche Rechenzeit.
6 Literatur
[Bro98] M. Broy, Informatik Eine grundlegende Einf ührung Band 2, Springer Verlag, 1998
[Dou97] Doug Lea, Concurrent Programming in Java, Design Principles and Patterns, Addison
Wesley, 1997
[LiF97] Tim Lindholm & Frank Yellin, The Java Virtual Maschine Specification, Addision
Wesley, 1997
[ScH97] Scott Oaks & Henry Wong, Java Threads, O´Reilly & Associates, 1997
28 / 28
Herunterladen