![]() |
|
|
Wenn wir auf unser Punkte-Problem zurückkommen, so stellen wir fest, dass zwei Zeilen auf eine Variable zugreifen: p.x = x; p.y = y; int xc = p.x, yc = p.y; Das Problem ist lösbar, wenn der Zugriff auf den Punkt nur über jeweils einen Thread erfolgt. Wenn also einer der Threads mit p.x = x beginnt, muss er so lange den exklusiven Zugriff bekommen, bis er mit yc = p.y endet. 9.5.6 Schützen mit ReentrantLock
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface java.util.concurrent.locks.Lock |
| void lock() Wartet so lange, bis der kritische Abschnitt betreten werden kann, und markiert ihn dann als betreten. |
| boolean tryLock() Wenn der kritische Abschnitt sofort betreten werden kann, ist die Funktionalität wie lock() und die Rückgabe true. Ist der Lock gesetzt, so wartet die Methode nicht wie lock(), sondern kehrt mit einem false zurück. |
| boolean tryLock( long time, TimeUnit unit ) throws InterruptedException Wartet eine Zeit lang auf den Lock wie tryLock(). Das Warten kann unterbrochen werden, was die Methode mit einer Exception beendet. |
| void unlock() Verlässt den kritischen Block. |
| void lockInterruptibly() throws InterruptedException Ermöglicht das Warten auf den Zutriff mit interrupt() vom wartenden Thread zu unterbrechen. Der lock()-Methode ist ein Interrupt egal. Implementierende Klassen der Schnittstelle müssen diese Operation nicht zwingend anbieten. |
| Beispiel Wenn wir sofort in den kritischen Abschnitt können, machen wir das; sonst etwas anders. |
Lock lock = ...; if ( lock.tryLock() ) { try { ... } finally { lock.unlock(); } } else ... |
Die Implementierung ReentrantLock kann noch ein bisschen mehr als lock() und unlock():
| class java.util.concurrent.locks.ReentrantLock implements Lock, Serializable |
| ReentrantLock() Erzeugt ein neues Lock-Objekt, welches nicht den am längsten Wartenden den ersten Zugriff gibt. |
| ReentrantLock( boolean fair ) Erzeugt ein neues Lock-Objekt mit fairem Zugriff, gibt also dem längsten Wartenden den ersten Zugriff. |
| boolean isLocked() Anfrage, ob der Lock gerade genutzt wird und im Moment kein Betreten möglich ist. |
| int getHoldCount() Wie viele auf das Betreten des Blockes warten. |
Beispiel Das Warten auf den Lock kann unterbrochen werden.
Lock l = new ReentrantLock(); try { l.lockInterruptibly(); try { ... } finally { l.unlock();} } catch ( InterruptedException e ) { ... }Ohne den Lock bekommen zu haben, dürfen wir ihn auch nicht freigeben! |
Unsere Klasse ReentrantLock blockt bei jedem lock() und lässt keinen Interessenten in den kritischen Abschnitt. Viele Szenarien sind jedoch nicht so streng, und so kommt es zu Situationen, in denen lesender Zugriff durchaus von mehreren Parteien möglich ist, schreibender Zugriff aber blockiert wird.
Für diese Lock-Situation gibt es die Schnittstelle ReadWriteLock, die nicht von Lock abgeleitet ist, sondern mit readLock() und writeLock() die Lock-Objekte liefert. Die bisher einzige Implementierung der Schnittstelle ist java.util.concurrent.locks.ReentrantReadWriteLock. Ein Programmausschnitt könnte so aussehen:
ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); try { lock.readLock().lock(); ... } finally { lock.readLock().unlock(); }
Eine Assoziativspeicher ist eine Datenstruktur, die ein Objekt mit einem beliebigen anderen verbinden kann. HashMap ist eine Java-Klasse, die über put() eine Assoziation erreicht und über get() erfragt. Wir wollen nun über ReentrantReadWriteLock die Leseoperationen parallel ermöglichen, aber Schreiboperationen atomar ausführen.
Listing 9.17 ParallelMap.java
import java.util.*; import java.util.concurrent.locks.*; public class ParallelMap<K,V> { private final HashMap<K, V> map = new HashMap<K,V>(); private final ReadWriteLock lock = new ReentrantReadWriteLock(); private final Lock readLock = lock.readLock(), writeLock = lock.writeLock(); public V get( K key ) { readLock.lock(); try { return map.get( key ); } finally { readLock.unlock(); } } public V put( K key, V value ) { writeLock.lock(); try { return map.put( key, value ); } finally { writeLock.unlock(); } } public void clear() { writeLock.lock(); try { map.clear(); } finally { writeLock.unlock(); } } }
Seit Java 1.0 können kritische Abschnitte mit synchronized geschützt werden. Im einfachsten Fall wird die gesamte Methode mit dem Modifizierer synchronized markiert. Ein betretender Thread setzt bei Objektfunktionen den Monitor des this-Objekts und bei statischen Methoden den Lock des dazugehörigen Class-Objektes.
Betritt die JVM die synchronisierte Methode, wird so lange gewartet, bis der Lock frei ist und dann die Methode betreten und abgeschlossen wird. Nach dem Verlassen wird der Lock wieder freigegeben. Damit hängt die Dauer des Locks mit der Dauer des Methodenaufrufs zusammen. Eine Endlosschleife in der synchronisierten Methode würde den Lock niemals freigeben.
Das aus IPlusPlus.java genannte Problem mit dem i++ lässt sich mit synchronized einfach lösen:
synchronized void foo() { i++; }
Bei einem Konflikt (mehrere Threads rufen foo() auf) verhindert synchronized, dass sich mehr als ein Thread gleichzeitig im kritischen Abschnitt, dem Rumpf der Methode foo(), befinden kann. Dies bezieht sich nur auf mehrere Aufrufe von foo() für dasselbe Objekt. Zwei verschiedene Threads können durchaus parallel die Methode foo() für unterschiedliche Objekte ausführen.
Neben diesem speziellen Problem für atomares Verändern von Variablen lassen sich auch Klassen aus dem Paket java.util.concurrent.atomic verwenden – sie werden später vorgestellt.
![]()
Bei einem orthografisch anspruchsvollen Wort wie synchronized ist es praktisch, dass Eclipse auch Schlüsselwörter vervollständigt. Hier reicht ein Tippen von sync und (Strg)+(Leertaste) für einen Dialog.

Hier klicken, um das Bild zu Vergrößern
Die statische Funktion Thread.holdsLock() zeigt an, ob der aktuelle Thread den Lock nutzt.
Listing 9.18 HoldsLockDemo.java, main()
final Object obj = new Object(); System.out.println( Thread.holdsLock(obj) ); // false synchronized ( obj ) { System.out.println( Thread.holdsLock(obj) ); // true }
Und Thread.holdsLock(this) wird etwa in einer Objektmethode feststellen können, ob der Lock durch eine synchronisierte Methode oder einen synchronized(this)-Block gelockt ist.
In nebenläufigen Programmen kann es schnell zu unerwünschten Nebeneffekten kommen. Das ist auch der Grund, warum Thread-lastige Programme schwer dezubuggen sind. Warum sollten wir also nicht alle Methoden synchronisieren? Wäre dann nicht das Problem aus der Welt geschafft? Prinzipiell würde das einige Probleme lösen, doch hätten wir uns damit andere Nachteile eingefangen.
| Methoden, die synchronisiert sind, müssen von der JVM besonders bedacht werden, damit keine zwei Threads die Methode für das gleiche Objekt ausführen. Wenn also ein zweiter Thread in die Methode eintreten möchte, kann er das nicht einfach machen, sondern muss vielleicht erst neben vielen anderen Threads warten. Es muss also eine Datenstruktur geben, in der wartende Threads eingetragen und ausgewählt werden. Das kostet zusätzlich Zeit und ist im Vergleich zu einem normalen Methodenaufruf viel teurer. |
| Zusätzlich kommt als Problem hinzu, wenn eine nicht notwendigerweise, also überflüssige, synchronisierte Methode eine Endlosschleife oder lange Operationen durchführt. Dann warten alle anderen Threads auf die Freigabe, und das kann im Fall der Endlosschleife ewig sein. Auch bei Multiprozessorsystemen profitieren wir nicht von dieser Programmiertechnik. Das synchronized macht die Vorteile von Mehrprozessormaschinen zunichte. |
| Wenn alle Methoden synchronisiert sind, steigt auch die Gefahr eines unnötigen Deadlocks. In den folgenden Abschnitten erfahren wir etwas mehr über Deadlocks. |
Wir wollen uns noch anhand einiger Beispiele ansehen, an welchen Objekten der Monitor beziehungsweise Lock gespeichert wird. Zunächst betrachten wir die Methode charAt() der Klasse StringBuffer und versuchen zu verstehen, warum die Methode synchronized ist.
public synchronized char charAt( int index ) { if ( (index < 0) || (index >= count) ) throw new StringIndexOutOfBoundsException( index ); return value[index]; }
Neben charAt() sind noch eine ganze Reihe anderer Methoden synchronisiert, etwa getChars(), setCharAt() und append(). Bei einer synchronized-Methode wird also der Lock bei einem konkreten StringBuffer-Objekt gespeichert. Wäre die Methode charAt() nicht atomar, dann könnte es passieren, dass durch Multithreading zwei Threads das gleiche StringBuffer-Objekt bearbeiten. Probleme kann es zum Beispiel dadurch geben, dass ein Thread gerade den String verkleinert und gleichzeitig charAt() aufgerufen wird. Und wenn zuerst charAt() einen gültigen Index feststellt, dann aber der StringBuffer verkleinert wird, gibt es ein Problem. Dann wäre nämlich der Index ungültig und value[index] fehlerhaft. Da aber charAt() synchronisiert ist, kann kein anderer Thread dasselbe StringBuffer-Objekt über synchronisierte Methoden modifizieren.
| Beispiel Das StringBuffer-Objekt sb1 wird von zwei Threads T1 und T2 bearbeitet, indem synchronisierte Methoden genutzt werden. Bearbeitet Thread T1 den StringBuffer sb1 mit einer synchronisierten Methode, dann kann T2 erst dann eine synchronisierte Methode für sb1 aufrufen, wenn T1 die Methode abgearbeitet hat. Denn T1 setzt bei sb1 die Sperre, die T2 warten lässt. Gleichzeitig kann aber T2 synchronisierte Methoden für ein anderes StringBuffer-Objekt sb2 aufrufen, da sb2 einen eigenen Monitor besitzt. Das macht noch einmal deutlich, dass die Locks zu einem Objekt gehören und nicht zur synchronisierten Methode. |
Wenn wir mit Lock-Objekten arbeiten, können wir den Block so fein wählen, wie erforderlich ist. Mit synchronized haben wir bisher nur eine gesamte Methode sperren können, was in machen Fällen etwas viel ist. Dann kann eine allgemeinere Variante in Java eingesetzt werden, die nur einen Block synchronisiert. Dazu schreiben wir in Java Folgendes:
synchronized ( objektMitDemMonitor ) { ... }
Der Block wird in die geforderten geschweiften Klammern gesetzt, und hinter dem Schlüsselwort in Klammern muss ein Objekt stehen, das den zu verwendenden Monitor besitzt. Die Konsequenz ist, dass über einen beliebigen Monitor synchronisiert werden kann und nicht unbedingt über den Monitor des Objekts, für das die synchronisierte Methode aufgerufen wurde, wie es bei synchronisierten Objektmethoden üblich ist.
Hinweis Eine synchronisierte Objektmethode ist nichts anderes als eine Variante von:
synchronized( this ) { // Code der Methode. } |
Nicht nur Objektmethoden, auch Klassenmethoden können sychronized sein. Doch die Nachbildung in einem Block sieht etwas anders aus, da es keine this-Referenz gibt. Hier kann ein Object-Exemplar für ein Lock herhalten, das extra für die Klasse angelegt wird. Dies ist eines der seltenen Beispiele, in denen ein Exemplar der Klasse Object Sinn ergibt.
Listing 9.19 StaticSync.java
class StaticSync { private static final Object o = new Object(); static void staticFoo() { synchronized( o ) { // ... } } }
Alternativ könnten wir auch das zugehörige Class-Objekt einsetzen. Wir müssen das entsprechende Klassenobjekt dann nur mittels StaticSync.class erfragen. Würden wir gleich mit Lock-Objekten arbeiten, stellt sich die Frage erst gar nicht.
| Hinweis Bei Lock-Objekten oder synchronized-Blöcken kann der zwingend synchronisierbare Teil in einem kleinen Abschnitt bleiben. Die JVM kann die anderen Teile parallel abarbeiten, und andere Threads dürfen die anderen Teile betreten. Als Resultat ergibt sich eine verbesserte Geschwindigkeit. |
Kommt es innerhalb eines synchronized Blocks beziehungsweise innerhalb einer synchronisierten Methode zu einer nicht überprüften RuntimeException, wird die JVM den Lock automatisch freigeben. Der Grund: Die Laufzeitumgebung gibt den Lock automatisch frei, wenn der Thread den synchronisierten Block verlässt, was bei einer Exception der Fall ist.
Werden die mit dem Schlüsselwort synchronized geschützten Blöcke durch Lock-Objekte umgesetzt, ist darauf zu achten, die Locks auch im Exception-Fall wieder freizugeben. Ein finally mit unlock()kommt da gerade recht, denn finally wird ja immer ausgeführt, egal, ob es einen Fehler gab oder nicht.
Listing 9.20 UnlockInFinally.java, main()
ReentrantLock lock = new ReentrantLock(); try { lock.lock(); System.out.println( lock.getHoldCount() ); // 1 try { System.out.println( 12 / 0 ); } finally { lock.unlock(); } } catch ( Exception e ) { System.out.println( e.getMessage() ); // / by zero } System.out.println( lock.getHoldCount() ); // 0
Nach dem lock() liefert getHoldCount() eins, da ein Thread den Block betreten hat. Die Division durch null provoziert eine RuntimeException, und finally gibt den Lock frei. Die Ausnahme wird abgefangen, und getHoldCount() liefert wieder null, da finally das unlock() ausführte. Würden wir die Zeile mit unlock() auskommentieren, würde getHoldCount() weiterhin eins liefern, was ein Fehler ist.
Obwohl viele Java-Funktionen in der Standardbibliothek synchronisiert sind, gibt es immer noch einige Methoden, bei denen die Entwickler auf eine Synchronisierung verzichtet haben – etwa setLocation() bei Point. Wenn keine ausdrücklichen Gründe für die Synchronisierung vorliegen und im Allgemeinen nur maximal ein Thread die Methode gleichzeitig aufruft, muss der Entwickler eine Absicherung nicht standardmäßig in Erwägung ziehen. Synchronisierung führt zu Geschwindigkeitsverlusten, und wenn keine Parallelität üblich ist, warum bezahlen für das, was keiner bestellt hat?
Im ersten motivierenden Beispiel haben wir die Initialisierung eines Point-Objekts betrachtet. Dass der Zugriff auf zwei Variablen nicht atomar sein kann, ist klar. Auch die Nicht-synchronisiert-Methode setLocation() bringt uns nicht weiter, weil ein Thread in dieser Methode unterbrochen werden könnte.
Wollen wir nachträglich sichergehen, dass setLocation() atomar ist, können wir zwei Dinge andenken:
| Wir verwenden ein Lock-Objekt, das allen Threads zugänglich ist. Das Objekt nutzten sie zur Synchronisation. |
| Wir besorgen uns einen Monitor auf das Point-Objekt und synchronisieren über diesen. |
Die erste Variante haben wir schon gesehen, sodass wir uns ein Beispiel für die zweite Variante anschauen:
Point p = new Point(); synchronized( p ) { p.setLocation( 1, 2 ); }
Auf diese Weise kann jeder Aufruf einer nicht synchronisierten Methode nachträglich synchronisiert werden. Jedoch muss dann jeder Zugriff wiederum mit einem synchronized-Block geschützt sein, sonst besteht keine Sicherheit, weil setLocation() selbst auf keinen Monitor achtet. Ruft demnach ein anderer Thread setLocation() außerhalb des synchronized-Blocks auf, ist die atomare Bearbeitung nichtig.
Betritt das Programm eine synchronisierte Methode, bekommt es den Monitor des aufrufenden Objekts. Wenn diese Methode eine andere aufruft, die am gleichen Objekt synchronisiert ist, dann kann sie sofort eintreten und muss nicht warten. Diese Eigenschaft heißt reentrant. Ohne diese Möglichkeit würde Rekursion nicht funktionieren!
Wenn das Programm den synchronisierten Block betritt, reserviert er den Monitor und kann alle synchronisierten Methoden ohne weitere Überprüfungen ausführen. Im Allgemeinen reduziert diese Technik aber auch die Parallelität, da der kritische Abschnitt künstlich vergrößert wird. Die Technik kann geschwindigkeitssteigernd sein, wenn viele synchronisierte Methoden hintereinander aufgerufen werden.
Beispiel In StringBuffer sind viele Methoden synchronisiert. Das heißt, dass bei jedem Aufruf einer Methode der Monitor reserviert werden muss. Das kostet natürlich eine Kleinigkeit, und als Lösung bietet sich an, die Aufrufe in einem eigenen synchronisierten Block zu bündeln.
StringBuffer sb = new StringBuffer(); synchronized( sb ) { sb.append( "Transpirations-" ); sb.append( "Illustration" ); sb.append( "\t" ); sb.append( "Röstreizstoffe" ); } |
| Wir können uns vorstellen, dass bei der Klasse ein kleiner Zähler ist, der bei jedem Betreten inkrementiert und beim Verlassen dekrementiert wird. Ist der Zähler null, befindet sich kein Thread im Block. Ist er größer null, haben wir einen reentranten Zugriff. |
Die Klasse ReentrantLock verwaltet den Zähler – er geht bis 2^31 – selbst, und einige Methoden geben Zugriff auf die Informationen, die meistens zum Testen nützlich sind. Mit isLocked() finden wir heraus, ob der Lock frei ist oder nicht. isHeldByCurrentThread() liefert true, wenn der ausführende Thread den Lock verwendet. getHoldCount() liefert die Anzahl Anfragen, die der aktuelle Thread an den Lock gestellt hat. Ist die Rückgabe null, so schließt der aktuelle Thread nicht ab, doch könnte dies wohl aber ein anderer Thread erledigen. getQueueLength() gibt eine (durch race conditions mögliche) Schätzung über die Anzahl der wartenden Threads ab, die lock() aufgerufen haben.
Synchronisierte Methoden stellen sicher, dass bei mehreren parallel ausführenden Threads die Operationen atomar ausgeführt werden. Das gilt jedoch ausschließlich für jede synchronisierte Methode, aber nicht für eine Sequenz von synchronisierten Methoden. Wir wissen zum Beispiel, dass StringBuffer alle Methoden synchronisiert und daher der StringBuffer bei parallelen Zugriffen keine inkonsistenten Zustände erzeugt. Was geschieht, wenn zwei Threads auf den folgenden Block zugreifen, wobei sb eine Variable ist, die auf einen gemeinsamen StringBuffer zeigt?
for ( char c = 'a'; c <= 'z'; c++ ) sb.append( c );
Greifen zwei Threads – nennen wir sie T1 und T2 – auf sb zu, erzeugen möglicherweise beide zusammen die folgende Zeichenkette: abcabcdefgdhihij... Das Ergebnis ist logisch, denn synchronized bedeutet nur, dass zwei Threads eine einzelne Operation atomar ausführen, aber kein Bündel.
Diese Aufgabe löst ein synchronisierter Block ausgezeichnet.
synchronized ( sb ) { for ( char c = 'a'; c <= 'z'; c++ ) sb.append( c ); }
Betritt der erste Thread den synchronisierten Block, schließt er ab, so dass andere Threads warten müssen. Der betretende Thread selbst kann aber, weil er den Monitor des StringBuffers schon besitzt, reentrant die anderen synchronisierten Methoden aufrufen.
Das Beispiel zeigt, wie gut sich ein sychronized Block nutzen lässt, wenn an anderen Objekten synchronisiert wird. Mit einem Lock-Objekt könnten wir hier nicht arbeiten, weil es zwar die einzelnen append()-Aufrufe zusammenfasst, aber von außen eine Unterbrechung nicht verhindern kann. Wenn ein zweiter Thread sich in die Aufrufkette mogelt, kann er jedes Mal, wenn ein append() verlassen und dabei der Monitor frei wird, ein neues append() aufrufen und so außerhalb des lock()/unlock() Blockes eintreten.
Ein Deadlock (zu Deutsch etwa »tödliche Umarmung«) kommt beispielsweise dann vor, wenn ein Thread A eine Ressource belegt, die ein anderer Thread B haben möchte. Dieser Thread B belegt aber eine Ressource, die A gerne bekommen würde. In dieser Situation können beide nicht vor und zurück und befinden sich in einem dauernden Wartezustand. Deadlocks können in Java-Programmen nicht erkannt und verhindert werden. Uns fällt also die Aufgabe zu, diesen ungünstigen Zustand gar nicht erst herbeizuführen.

Hier klicken, um das Bild zu Vergrößern
| Beispiel Zwei Threads schlagen sich um die Objekte a und b. Dabei kommt es zu einem Deadlock, da sie einen Lock besetzen, den der jeweils andere zum Weiterarbeiten benötigt. |
Listing 9.21 Deadlock.java
class Deadlock { static final Object a = new Object(), b = new Object(); static class T1 extends Thread { @Override public void run() { synchronized( a ) { System.out.println( "T1: Lock auf a bekommen" ); warte(); synchronized( b ) { System.out.println( "T1: Lock auf b bekommen" ); } } } private void warte() { try { Thread.sleep( 1000 ); } catch ( InterruptedException e ) { e.printStackTrace(); } } } static class T2 extends Thread { @Override public void run() { synchronized( b ) { System.out.println( "T2: Lock auf b bekommen" ); synchronized( a ) { System.out.println( "T2: Lock auf a bekommen" ); } } } } public static void main( String[] args ) { new T1().start(); new T2().start(); } }
In der Ausgabe sehen wir nur zwei Zeilen, und dann hängt das gesamte Programm.
T1: Lock auf a bekommen T2: Lock auf b bekommen
Eine Lösung des Problems wäre, bei geschachteltem Synchronisieren auf mehrere Objekte diese immer in der gleichen Reihenfolge zu belegen, also etwa immer erst a, dann b. Bei unbekannten, dynamisch wechselnden Objekten muss dann unter Umständen eine willkürliche Ordnung festgelegt werden.
Die Sun JVM verfügt über eine eingebaute Deadlock-Erkennung, die auf der Konsole aktiviert werden kann. Dazu ist unter Windows die Tastenkombination (Crtl)-(Break) zu drücken und unter Linux oder Solaris (Crtl)+(\). Für das obige Programm ergibt sich unter den letzten Ausgabezeilen:
Java stack information for the threads listed above: =================================================== "Thread-2": at Deadlock$T2.run(Deadlock.java:36) – waiting to lock <02A2F0E8> (a java.lang.Object) – locked <02A2F0F0> (a java.lang.Object) "Thread-1": at Deadlock$T1.run(Deadlock.java:16) – waiting to lock <02A2F0F0> (a java.lang.Object) – locked <02A2F0E8> (a java.lang.Object) Found 1 deadlock.
Das ist genau die Meldung, die bei gegenseitigem Blockieren hilft. Zwar findet das Tool nicht alle Deadlocks, insbesondere nicht die, in denen Threads mit wait() auf Monitore warten, sonst aber kann es insbesondere bei grafischen Programmen eine gute Hilfe sein.
1 Machbar zum Beispiel mit dem jeder Java-Distribution beiliegenden Dienstprogramm javap-c.
| << zurück |
Copyright © Galileo Press GmbH 2005
Für Ihren privaten Gebrauch dürfen Sie die Online-Version natürlich ausdrucken. Ansonsten unterliegt das <openbook> denselben Bestimmungen, wie die gebundene Ausgabe: Das Werk einschließlich aller seiner Teile ist urheberrechtlich geschützt. Alle Rechte vorbehalten einschließlich der Vervielfältigung, Übersetzung, Mikroverfilmung sowie Einspeicherung und Verarbeitung in elektronischen Systemen.