12.5 Synchronisation über kritische Abschnitte
Wenn Threads in Java ein eigenständiges Leben führen, ist dieser Lebensstil nicht immer unproblematisch für andere Threads, insbesondere beim Zugriff auf gemeinsam genutzte Ressourcen. In den folgenden Abschnitten erfahren wir mehr über gemeinsam genutzte Daten und Schutzmaßnahmen beim konkurrierenden Zugriff durch mehrere Threads.
12.5.1 Gemeinsam genutzte Daten

Ein Thread besitzt zum einen seine eigenen Variablen, etwa die Objektvariablen, kann aber auch statische Variablen nutzen, wie das folgende Beispiel zeigt:
class T extends Thread
{
static int result;
public void run() { ... }
}
In diesem Fall können verschiedene Exemplare der Klasse T, die jeweils einen Thread bilden, Daten austauschen, indem sie die Informationen in result ablegen oder daraus entnehmen. Threads können aber auch an einer zentralen Stelle eine Datenstruktur erfragen und dort Informationen entnehmen oder Zugriff auf gemeinsame Objekte über eine Referenz bekommen. Es gibt also viele Möglichkeiten, wie Threads – und damit potenziell parallel ablaufende Aktivitäten – Daten austauschen können.
12.5.2 Probleme beim gemeinsamen Zugriff und kritische Abschnitte

Da Threads ihre eigenen Daten verwalten – sie haben alle eigene lokale Variablen und einen Stack –, kommen sie sich gegenseitig nicht in die Quere. Auch wenn mehrere Threads gemeinsame Daten nur lesen, ist das unbedenklich; Schreiboperationen sind jedoch kritisch. Wenn sich zehn Nutzer einen Drucker teilen, der die Ausdrucke nicht als unteilbare Einheit bündelt, lässt sich leicht ausmalen, wie das Ergebnis aussieht. Seiten, Zeilen oder gar einzelne Zeichen aus verschiedenen Druckaufträgen werden bunt gemischt ausgedruckt.
Die Probleme haben ihren Ursprung in der Art und Weise, wie die Threads umgeschaltet werden. Der Scheduler unterbricht zu einem uns unbekannten Zeitpunkt die Abarbeitung eines Threads und lässt den nächsten arbeiten. Wenn nun der erste Thread gerade Programmzeilen abarbeitet, die zusammengehören, und der zweite Thread beginnt, parallel auf diesen Daten zu arbeiten, so ist der Ärger vorprogrammiert. Wir müssen also Folgendes ausdrücken können: »Wenn ich den Job mache, dann möchte ich der Einzige sein, der die Ressource – etwa einen Drucker – nutzt.« Erst nachdem der Drucker den Auftrag eines Benutzers fertiggestellt hat, darf er den nächsten in Angriff nehmen.
Kritische Abschnitte
Zusammenhängende Programmblöcke, denen während der Ausführung von einem Thread kein anderer Thread »reinwurschteln« sollte und die daher besonders geschützt werden müssen, nennen sich kritische Abschnitte. Wenn lediglich ein Thread den Programmteil abarbeitet, dann nennen wir dies gegenseitigen Ausschluss oder atomar. Wir könnten das etwas lockerer sehen, wenn wir wüssten, dass innerhalb der Programmblöcke nur von den Daten gelesen wird. Sobald aber nur ein Thread Änderungen vornehmen möchte, ist ein Schutz nötig. Denn arbeitet ein Programm bei nebenläufigen Threads falsch, ist es nicht thread-sicher (engl. thread-safe).
Wir werden uns nun Beispiele für kritische Abschnitte anschauen und dann sehen, wie wir diese in Java realisieren können.
Nicht kritische Abschnitte
Wenn mehrere Threads auf das gleiche Programmstück zugreifen, muss das nicht zwangsläufig zu einem Problem führen, und Thread-Sicherheit ist immer gegeben. Immutable Objekte – nehmen wir an, ein Konstruktor belegt einmalig die Zustände – sind automatisch thread-sicher, da es keine Schreibzugriffe gibt und bei Lesezugriffen nichts schiefgehen kann. Immutable-Klassen wie String oder Wrapper-Klassen kommen daher ohne Synchronisierung aus.
Das Gleiche gilt für Methoden, die keine Objekteigenschaften verändern. Da jeder Thread seine thread-eigenen Variablen besitzt – jeder Thread hat einen eigenen Stack –, können lokale Variablen, auch Parametervariablen, beliebig gelesen und geschrieben werden. Wenn zum Beispiel zwei Threads die folgende statische Utility-Methode aufrufen, ist das kein Problem:
public static String reverse( String s )
{
return new StringBuilder( s ).reverse().toString();
}
Jeder Thread wird eine eigene Variablenbelegung für s haben und ein temporäres Objekt vom Typ StringBuilder referenzieren.
Thread-sichere und nicht thread-sichere Klassen der Java Bibliothek
Es gibt in Java viele Klassen, die nicht thread-sicher sind – das ist sogar der Standard. So sind etwa alle Format-Klassen, wie MessageFormat, NumberFormat, DecimalFormat, ChoiceFormat, DateFormat und SimpleDateFormat nicht für den nebenläufigen Zugriff gemacht. In der Regel steht das in der JavaDoc, etwa bei DateFormat:
»Synchronization. Date formats are not synchronized. It is recommended to create separate format instances for each thread. If multiple threads access a format concurrently, it must be synchronized externally.«Wer also Objekte nebenläufig verwendet, der sollte immer in der Java API-Dokumentation nachschlagen, ob es dort einen Hinweis gibt, ob die Objekte überhaupt thread-sicher sind.
In einigen wenigen Fällen haben Entwickler die Wahl zwischen thread-sicheren und nicht thread-sicheren Klassen im JDK:
Nicht thread-sicher | Thread-sicher |
StringBuilder | StringBuffer |
ArrayList | Vector |
HashMap | Hashtable |
Obwohl es die Auswahl bei den Datenstrukturen im Prinzip gibt, werden Vector und Hashtable dennoch nicht verwendet.
12.5.3 Punkte parallel initialisieren

Nehmen wir an, ein Thread T1 möchte ein Point-Objekt p mit den Werten (1,1) belegen und ein zweiter Thread T2 möchte eine Belegung mit den Werten (2,2) durchführen.
Thread T1 | Thread T2 |
p.x = 1; |
p.x = 2; |
Beide Threads können natürlich bei einem 2-Kern-Prozessor parallel arbeiten, aber da sie auf gemeinsame Variablen zugreifen, ist der Zugriff auf x bzw. y von p trotzdem sequenziell. Um es nicht allzu kompliziert zu machen, vereinfachen wir unser Ausführungsmodell so, dass wir zwar zwei Threads laufen haben, aber nur jeweils einer ausgeführt wird. Dann ist es möglich, dass T1 mit der Arbeit beginnt und x = 1 setzt. Da der Thread-Scheduler einen Thread jederzeit unterbrechen kann, kann nun T2 an die Reihe kommen, der x = 2 und y = 2 setzt. Wird dann T1 wieder Rechenzeit zugeteilt, darf T1 an der Stelle weitermachen, wo er aufgehört hat, und y = 1 folgt. In einer Tabelle ist das Ergebnis noch besser zu sehen:
Thread T1 | Thread T2 | x/y |
p.x = 1; | 1/0 | |
p.x = 2; | 2/0 | |
p.y = 2; | 2/2 | |
p.y = 1; | 2/1 |
Wir erkennen das nicht beabsichtigte Ergebnis (2,1), es könnte aber auch (1,2) sein, wenn wir das gleiche Szenario beginnend mit T2 durchführen. Je nach zuerst abgearbeitetem Thread wäre jedoch nur (1,1) oder (2,2) korrekt. Die Threads sollen ihre Arbeit aber atomar erledigen, denn die Zuweisung bildet einen kritischen Abschnitt, der geschützt werden muss. Standardmäßig sind die zwei Zuweisungen nicht-atomare Operationen und können unterbrochen werden. Um dies an einem Beispiel zu zeigen, sollen zwei Threads ein Point-Objekt verändern. Die Threads belegen x und y immer gleich, und immer dann, wenn sich die Koordinaten unterscheiden, soll es eine Meldung geben:
Listing 12.11: com/tutego/insel/thread/concurrent/ParallelPointInit.java, main()
final Point p = new Point();
Runnable r = new Runnable()
{
@Override public void run()
{
int x = (int)(Math.random() * 1000), y = x;
while ( true )
{
p.x = x; p.y = y; // *
int xc = p.x, yc = p.y; // *
if ( xc != yc )
System.out.println( "Aha: x=" + xc + ", y=" + yc );
}
}
};
new Thread( r ).start();
new Thread( r ).start();
Die interessanten Zeilen sind mit * markiert. p.x = x; p.y = y; belegt die Koordinaten neu, und int xc = p.x, yc = p.y; liest die Koordinaten erneut aus. Würden Belegung und Auslesen in einem Rutsch passieren, dürfte überhaupt keine unterschiedliche Belegung von x und y zu finden sein. Doch das Beispiel zeigt es anders:
Aha: x=58, y=116
Aha: x=116, y=58
Aha: x=58, y=116
Aha: x=58, y=116
...
Was wir mit den parallelen Punkten vor uns haben, sind Effekte, die von den Ausführungszeiten der einzelnen Operationen abhängen. In Abhängigkeit von dem Ort der Unterbrechung wird ein fehlerhaftes Verhalten produziert. Dieses Szenario nennt sich im Englischen race condition beziehungsweise race hazard (zu Deutsch auch Wettlaufsituation).
12.5.4 Kritische Abschnitte schützen

Beginnen wir mit einem anschaulichen Alltagsbeispiel. Gehen wir aufs Klo, schließen wir die Tür hinter uns. Möchte jemand anderes auf die Toilette, muss er warten. Vielleicht kommen noch mehrere dazu, die müssen dann auch warten, und eine Warteschlage bildet sich. Dass die Toilette besetzt ist, signalisiert die abgeschlossene Tür. Jeder Wartende muss so lange vor dem Klo ausharren, bis das Schloss geöffnet wird, selbst wenn der auf der Toilette Sitzende nach einer langen Nacht einnicken sollte.
Wie übertragen wir das auf Java? Wenn die Laufzeitumgebung nur einen Thread in einen Block lassen soll, ist ein Monitor[186](Der Begriff geht auf C. A. R. Hoare zurück, der in seinem Aufsatz »Communicating Sequential Processes « von 1978 erstmals dieses Konzept veröffentlichte.) nötig. Ein Monitor wird mithilfe eines Locks (zu Deutsch Schloss) realisiert, das ein Thread öffnet oder schließt. Tritt ein Thread in den kritischen Abschnitt ein, muss Programmcode wie eine Tür abgeschlossen werden (engl. lock). Erst wenn der Abschnitt durchlaufen wurde, darf die Tür wieder aufgeschlossen werden (engl. unlock), und ein anderer Thread kann den Abschnitt betreten.
Java-Konstrukte zum Schutz der kritischen Abschnitte
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;
Diese beiden Zeilen bilden also einen kritischen Abschnitt, den jeweils nur ein Thread betreten darf. 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.
Aber wie wird nun ein kritischer Abschnitt bekannt gegeben? Zum Markieren und Abschließen dieser Bereiche gibt es zwei Konzepte:
Konstrukt | Eingebautes Schlüsselwort | Java-Standardbibliothek |
Schlüsselwort/Typen | synchronized | java.util.concurrent.locks.Lock |
Nutzungsschema |
synchronized |
lock.lock(); |
Beim synchronized entsteht Bytecode, der der JVM sagt, dass ein kritischer Block beginnt und endet. So überwacht die JVM, ob ein zweiter Thread warten muss, wenn er in einen synchronisierten Block eintritt, der schon von einem Thread ausgeführt wird. Bei Lock ist das Ein- und Austreten explizit vom Entwickler programmiert, und vergisst er das, ist das ein Problem. Und während bei der Lock-Implementierung das Objekt, an dem synchronisiert wird, offen hervortritt, ist das bei synchronized nicht so offensichtlich. Hier gilt es zu wissen, dass jedes Objekt in Java implizit mit einem Monitor verbunden ist. Da moderne Programme aber mittlerweile mit Lock-Objekten arbeiten, tritt die synchronized-Möglichkeit, die schon Java 1.0 zur Synchronisation bot, etwas in den Hintergrund.
Fassen wir zusammen: Nicht thread-sichere Abschnitte müssen geschützt werden. Sie können entweder mit synchronized geschützt werden, bei dem der Eintritt und Austritt implizit geregelt ist, oder durch Lock-Objekte. Befindet sich dann ein Thread in einem geschützten Block und möchte ein zweiter Thread in den Abschnitt, muss er so lange warten, bis der erste Thread den Block wieder freigibt. So ist die Abarbeitung über mehrere Threads einfach synchronisiert, und das Konzept eines Monitors gewährleistet seriellen Zugriff auf kritische Ressourcen. Die kritischen Bereiche sind nicht per se mit einem Monitor verbunden, sondern werden eingerahmt, und dieser Rahmen ist mit einem Monitor (Lock) verbunden.
Mit dem Abschließen und Aufschließen werden wir uns noch intensiver in den folgenden Abschnitten beschäftigen.
12.5.5 Kritische Abschnitte mit ReentrantLock schützen

Seit Java 5 gibt es die Schnittstelle Lock, mit der sich ein kritischer Block markieren lässt. Ein Abschnitt beginnt mit lock() und endet mit unlock():
Listing 12.12: com/tutego/insel/thread/concurrent/ParallelPointInitSync.java, main()
final Lock lock = new ReentrantLock();
final Point p = new Point();
Runnable r = new Runnable()
{
@Override public void run()
{
int x = (int)(Math.random() * 1000), y = x;
while ( true )
{
lock.lock();
p.x = x; p.y = y; // *
int xc = p.x, yc = p.y; // *
lock.unlock();
if ( xc != yc )
System.out.println( "Aha: x=" + xc + ", y=" + yc );
}
}
};
new Thread( r ).start();
new Thread( r ).start();
Mit dieser Implementierung wird keine Ausgabe auf dem Bildschirm folgen.
Die Schnittstelle java.util.concurrent.locks.Lock
Lock ist eine Schnittstelle, von der ReentrantLock die wichtigste Implementierung ist. Mit ihr lässt sich der Block betreten und verlassen.
interface java.util.concurrent.locks.Lock |
- void lock()
Wartet so lange, bis der ausführende Thread den kritischen Abschnitt betreten kann, und markiert ihn dann als betreten. Hat schon ein anderer Thread an diesem Lock-Objekt ein lock() aufgerufen, so muss der aktuelle Thread warten, bis der Lock wieder frei ist. Hat der aktuelle Thread schon den Lock, kann er bei der Implementierung ReentrantLock wiederum lock() aufrufen und sperrt sich nicht selbst. - boolean tryLock()
Wenn der kritische Abschnitt sofort betreten werden kann, ist die Funktionalität wie bei lock(), und die Rückgabe ist 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
Versucht in der angegebenen Zeitspanne den Lock zu bekommen. Das Warten kann mit interrupt() auf dem Thread unterbrochen werden, was tryLock() mit einer Exception beendet. - void unlock()
Verlässt den kritischen Block. - void lockInterruptibly() throws InterruptedException
Wartet wie lock(), um den kritischen Abschnitt betreten zu dürfen, kann aber mit einem interrupt() von außen abgebrochen werden (der lock()-Methode ist ein Interrupt egal). Implementierende Klassen müssen diese Vorgabe nicht zwingend umsetzen, sondern können die Methode auch mit einem einfachen lock() realisieren. ReentrantLock implementiert lockInterruptibly() erwartungsgemäß.
Beispiel |
Wenn wir sofort in den kritischen Abschnitt gehen können, tun wir das; sonst tun wir etwas anderes: Lock lock = ...; |
} |
Die Implementierung ReentrantLock kann noch ein bisschen mehr als lock() und unlock():
class java.util.concurrent.locks.ReentrantLock |
- ReentrantLock()
Erzeugt ein neues Lock-Objekt, das nicht dem am längsten Wartenden den ersten Zugriff gibt. - ReentrantLock(boolean fair)
Erzeugt ein neues Lock-Objekt mit fairem Zugriff, gibt also dem am längsten Wartenden den ersten Zugriff. - boolean isLocked()
Fragt an, ob der Lock gerade genutzt wird und im Moment kein Betreten möglich ist. - final int getQueueLength()
Ermittelt, wie viele auf das Betreten des Blocks warten. - int getHoldCount()
Gibt die Anzahl der erfolgreichen lock()-Aufrufe ohne passendes unlock() zurück. Sollte nach Beenden des Vorgangs 0 sein.
Beispiel |
Das Warten auf den Lock kann unterbrochen werden: Lock l = new ReentrantLock(); |
{ |

Abbildung 12.7: Die Klasse ReentrantLock implementiert die Schnittstelle Lock
Ihr Kommentar
Wie hat Ihnen das <openbook> gefallen? Wir freuen uns immer über Ihre freundlichen und kritischen Rückmeldungen.