Nebenläufig arbeitende Threads können zu schweren Programmfehlern führen, von denen hier beispielhaft lediglich die Schreibkonflikte genannt werden sollen. Eine umfassendere Behandlung dieses Themas muss aus Zeitgründen einer Vertiefungsveranstaltung vorbehalten bleiben.
Das folgende Programmbeispiel LostUpdate.java zeigt einen Programmfehler, der als verloren gegangene Änderung bekannt ist. Programmläufe mit diesem Programm können zu Situationen führen, bei denen die Änderung, die ein Thread an einer Variablen vornimmt, verloren geht. In dem Programm konkurrieren zwei Threads um den Zugriff auf eine gemeinsame int-Variable miteinander. Die Variable wird mit dem Wert 1 vorgelegt, und beide Threads erhöhen ihren Wert jeweils um 1.
Programmläufe mit dem Programm LostUpdate werden nach dem Ende der beiden miteinander konkurrierenden Threads als Wert der gemeinsamen Variablen zwar oft das Ergebnis 3 zeigen, aber ebenfalls oft den Wert 2. Das liegt daran, dass Java-Befehle wie i++ zwar atomar aussehen, dies aber nicht sind. Als atomar wird ein Befehl bezeichnet, wenn er immer ohne jede Unterbrechung durchgeführt wird. Der Scheduler der JVM arbeitet allerdings nicht mit Java-Quellprogrammen, sondern mit Bytecode, und ein Java-Befehl wie i++ besteht aus mehr als einem Bytecode-Befehl. Sinngemäß besteht er aus den folgenden drei Befehlen:
Die drei Bytecode-Befehle einzeln sind atomar. Innerhalb eines solchen Befehls unterbricht der JVM-Scheduler den Thread nicht, aber zwischen ihnen kann er ihn unterbrechen. Angenommen, eine int-Variable i sei mit dem Wert 1 vorbelegt und werde von zwei Threads t1 und t2 bearbeitet, die beide den Befehl i++ ausführen. Dann kann sich folgender Programmlauf ergeben:
Der Thread t1 ist im Zustand Running (er hat Rechenzeit erhalten) und führt die drei Bytecode-Befehle zu i++ aus:
Jetzt wird t1 vom Scheduler unterbrochen und der Thread t2 bekommt Rechenzeit, d.h. er bekommt einen Prozessor und damit einen eigenen Registersatz. Der Thread t2 ist dann im Zustand Running und führt ebenfalls die drei Bytecode-Befehle zu i++ aus:
Jetzt wird t2 vom Scheduler unterbrochen und t1 bekommt wieder Rechenzeit. Der Thread t1 macht dort weiter, wo er unterbrochen worden ist und führt aus:
Damit hat t2 den Java-Befehl i++ abgearbeitet und in i steht der Wert 2. Früher oder später bekommt der Thread t2 wieder Rechenzeit und macht ebenfalls dort weiter, wo er unterbrochen worden ist, d.h. er führt aus:
Der Thread t2 hat seinen Java-Befehl i++ abgearbeitet und den Wert 2 auf den Speicherplatz i geschrieben. Bei der geschilderten nebenläufigen Durchführung des Java-Befehls i++ durch die beiden Threads hat es ein nicht vorhersagbares ungünstiges zeitliches Verhalten gegeben, das zu einem Schreibkonflikt geführt hat, durch den eine der beiden Additionen verloren gegangen ist.
Drohen in einem Prozess wegen nebenläufiger Schreibvorgänge seiner Threads Schreibkonflikte, dann sind folgende prinzipielle Vorgehensweisen möglich: Man kann
Ein Programm so zu konzipieren, dass Schreibkonflikte gar nicht erst entstehen, ist nicht immer möglich. Manche Aufgabenstellungen lassen ein solches Vorgehen nicht zu. Ein Beispiel dafür ist das bekannte Produzenten-Konsumenten-Problem, bei dem zwei Partner miteinander konkurrierend auf ein gemeinsames Lager zugreifen müssen.
Bei der ersten Übungsaufgabe zur Lehrveranstaltung (Abschnitt 7.1 (Nebenläufigkeit mit Threads)) ist dagegen eine Vermeidung von Schreibkonflikten möglich. Die Aufgabenstellung verlangt, dass zwei vom main-Thread aus gestartete Threads unabhängig voneinander Suchvorgänge durchführen und ihre Ergebnisse dem main-Thread übergeben. Legt dieser vor dem Start der beiden Suchthreads eine einzige Datenstruktur, zum Beispiel eine ArrayList, an, in die beide Suchthreads ihre Ergebnisse eintragen, dann sind Schreibkonflikte vorprogrammiert. Legt der main-Thread hingegen zwei Datenstrukturen für die Ergebnisse an, und ordnet jedem Suchthread seine eigene Datenstruktur für das Schreiben der Ergebnisse zu, dann können keine Schreibkonflikte entstehen.
Sollen oder müssen Schreibkonflikte behandelt werden, dann stellt Java dafür einen Synchronisationsmechanismus zur Verfügung. Viele Java-Datenstrukturen, allerdings nicht alle, verwenden diesen Mechanismus, ohne dass dies äußerlich sichtbar ist. Darüber hinaus ist es möglich, irgendeine Java-Datenstruktur zu verwenden und dem Zugriff auf sie den Synchronisationsmechanismus aufzuprägen.
Ist eine Java-Datenstruktur mit dem Synchronisationsmechanismus ausgestattet, dann nennt man sie threadsicher oder synchronized. Eine solche Datenstruktur verhindert, dass zu irgendeinem Zeitpunkt mehr als ein Thread auf sie zugreifen kann. Instanzen von Klassen wie Zaehler im Beispiel LostUpdate.java werden nicht threadsicher genannt. Bei ihrer Verwendung können Schreibkonflikte auftreten.
Bei den Klassen der Standard-Java-Bibliothek ist in der Regel angegeben, ob sie threadsicher sind. So steht in der Java-Dokumentation beispielsweise bei der Klasse StringBuffer:
Das ist demnach eine threadsichere, veränderliche Folge von Zeichen. Hingegen ist bei der Klasse ArrayList angegeben:
Beim nebenläufigen Beschreiben einer ArrayList ist mit Schreibkonflikten zu rechnen, denn diese Datenstruktur ist nicht threadsicher.
Der Synchronisationsmechanismus, den Java einsetzt, um Threadsicherheit zu erreichen, steht der Programmierung auch als Sprachmittel zur Verfügung. In der Klasse Object ist festgelegt, dass diese Klasse und jede ihrer Instanzen jeweils eine Sperre besitzen. Da jede andere Klasse von Object abgeleitet ist, hat demnach jede Klasse und jede Instanz einer Klasse eine Sperre. Beispielsweise stehen mit der Anweisung
zwei Sperren zur Verfügung:
Die Konstruktionen
und
zeigen das Ansprechen beider Sperren im Programmcode. Sie heißen synchronisierte Blöcke. Im Parameter der synchronized()-Anweisung wird eine Referenz auf eine Instanz oder auf eine Klasse angegeben, deren Sperre verwendet werden soll. Zu dem Parameter der zweiten Konstruktion ist zu anzumerken, dass es in Java weitere Möglichkeiten gibt, mit denen eine Referenz auf eine Klasse gebildet werden kann.
Kommt die Abarbeitung eines Programms an einen synchronisierten Block, dann wird durch die synchronized()-Anweisung zunächst geprüft, ob die angesprochene Sperre bereits gesetzt ist. Ist dies der Fall, dann wird mit der Abarbeitung des Blocks so lange gewartet, bis die Sperre wieder freigegeben worden ist. Anderenfalls wird die Sperre gesetzt und der Block wird abgearbeitet. Beim Verlassen des Blocks gibt die synchronized()-Anweisung die Sperre wieder frei.
Man beachte, dass eine Sperre nur dann wirkt, wenn sie respektiert wird. Angenommen, ein Thread t1 hat die Anweisung synchronized(abc) mit einer Stringinstanz abc erfolgreich ausgeführt und bearbeitet in dem synchronisierten Block bestimmte Variablen. Wird er jetzt vom Time Sharing der JVM unterbrochen und ein Thread t2 ohne eine entsprechende synchronized()-Anweisung bekommt Rechenzeit, dann beachtet t2 die Sperre nicht. Er kann ungehindert und konkurrierend mit t1 dieselben Variablen manipulieren wie dieser.
Angenommen, ein Thread t1 hat eine Sperre gesetzt und ein Thread t2 will jetzt dieselbe Sperre setzen, dann wird t2 durch die synchronized()-Anweisung in einen Wartezustand (Blocked) versetzt. Wollen jetzt noch weiterere Threads ebenfalls diese Sperre setzen, dann kommen auch diese in einen Wartezustand. Ist schließlich t1 mit der Abarbeitung des synchronisierten Blocks fertig, wird die Sperre freigegeben, was zur Folge hat, dass alle, an der Sperre wartetenden Threads miteinander um das Setzen der Sperre konkurrieren. Derjenige dieser Threads, der vom Scheduler als erster Rechenzeit bekommt, kann die Sperre setzen, alle anderen müssen weiter warten.
Eine Sperre kann nur wirken, wenn alle miteinander konkurrierenden Threads exakt dieselbe Sperre benutzen. Zwei Threads beispielsweise, die beide die Anweisung
ausführen und die Sperre von abc benutzen, haben zwei verschiedene Sperren, weil jeder eine eigene Instanz der Stringklasse benutzt. Um diese Problematik einzuschränken, sind synchronisierte Methoden eingeführt worden. Sie benutzen die Sperre der Instanz this bzw. die der Klasse, die durch this.getClass() referenziert wird.
Bei den folgenden Sprachkonstruktionen ist als Methodentyp void angegeben, aber sie gelten für jeden Typ. Synchronisierte Methoden entstehen in Java dadurch, dass eine Methode der Form
kurz als
geschrieben werden darf. Analog darf für
die folgende Kurzschreibweise verwendet werden:
In dem Programm LostUpdate.java greifen die beiden miteinander konkurrierenden Threads mit Hilfe der update()-Methode auf eine gemeinsame int-Variable zu. Wird diese Methode als synchronized erklärt, ist das Problem der verloren gegangenen Änderung gelöst:
Bei Problemen, wie bei dem am Anfang dieses Abschnitts angesprochenen Produzenten-Konsumenten-Problem, gelangen Threads während sie die Befehle eines synchronisierten Blocks (oder einer synchronisierten Methode) abarbeiten in eine Wartesituation, aus der sie nur durch Aktionen eines anderen Threads befreit werden können. Wenn dieser andere Thread aber dafür auf Variablen zugreifen muss, die durch die Sperre geschützt sind, dann muss er ebenfalls warten. Um mit solchen Situationen umgehen zu können, bedarf es Hilfsmittel, die über synchronisierte Blöcke und Methoden hinausgehen. Sie vorzustellen bleibt einer Vertiefungsveranstaltung vorbehalten.
Wird ein Java-Thread beendet, werden - auch im Fehlerfall - alle Sperren, die er gesetzt hat, frei gegeben.
Es ist in Java zulässig, dass ein Thread, der eine Sperre gesetzt hat, eine weitere Sperre setzen (eine synchronized()-Methode aufrufen) kann. Man sagt dazu, Java-Sperren seien reentrant (wiedereintrittsfähig). Hat ein Thread eine Sperre gesetzt und setzt sie erneut ohne vorherige Freigabe, dann bleibt dieses Vorgehen wirkungslos und führt auch nicht zu einer Fehlermeldung.