2 Das Java-Memory-Modell imÜberblick
Im vorangegangenen Kapitel haben wir erläutert, dass man dasvolatile-Schlüsselwort zur Optimierung verwendet, um die relative teure Synchronisation von konkurrierenden Zugriffen auf gemeinsam verwendete veränderliche Daten zu vermeiden. Dabei haben wir die Sichtbarkeitsregeln erwähnt, die sich im Zusammenhang mitvolatile und Synchronisation aus dem Java-Memory-Modell ergeben. In diesem Kapitel wollen wir einenÜberblicküber Konsistenzregeln für den konkurrierenden Zugriff auf veränderliche Daten geben. Es geht dabei insbesondere um die Atomarität von Zugriffen auf gemeinsam verwendete Daten, deren Reihenfolge und die Sichtbarkeit etwaiger Modifikationen an den Daten.
Im Zusammenhang mitvolatile und Synchronisation haben wir bereits den Begriff„Sequential Consistency“ erwähnt. Sequential Consistency ist ein Modell, mit dem sich viele Java-Entwickler das Multithreading in Java vorstellen, obwohl Java gar keine Sequential Consistency unterstützt. Wenn man dennoch so programmiert, als gäbe es Sequential Consistency in Java, können die im letzten Kapitel besprochenen Fehler entstehen.
Das Modell der Sequential Consistency ist ein relativ einfaches Datenkonsistenzmodell. Esähnelt der Funktionsweise von Multithreading auf einer Single-CPU-Umgebung. Die Vorstellung ist Folgende: Die Threads laufen nicht wirklich parallel, sondern es gibt einen Thread Scheduler, der den einzelnen Threads abwechselnd Zeitscheiben der CPU zuteilt. Ein Thread darf ein paar Operationen ausführen, wird dann verdrängt, es kommt ein anderer Threads dran, der wiederum ein paar Operationen machen darf usw., sodass die einzelnen Threads ihre Operationen in einer sequenziellen Reihenfolge ausführen. Damit verbunden ist die Vorstellung, dass Threads, die später drankommen, Modifikationen im Speicher sehen können, die von Threads vorgenommen wurden, die vorher dran waren. Das ist ein einfaches Konsistenzmodell, das aber in Java nicht unterstützt wird.
Bei einem Konsistenzmodell geht es ganz allgemein (unabhängig von Java) um Regeln für die Zugriffe auf den Speicher. Wenn sich der Programmierer an die Regeln hält, dann gibt ihm das System (in unserem Fall Java und seine virtuelle Maschine) Garantien für die Effekte von Speicherzugriffen, damit der Programmierer weiß, was zur Laufzeit geschehen wird und er die Effekte seiner Speicheroperationen vorhersehen kann. In High-Level-Sprachen wie Java müssen der Compiler und das Laufzeitsystem dafür sorgen, dass die High-Level-Sprachkonstrukte gemäß den Regeln des Konsistenzmodells in Low-Level-Operationen umgesetzt werden.
Auch Java hat ein Konsistenzmodell. Es ist aber nicht das Modell der Sequential Consistency, sondern Java hat ein eigenes Memory-Modell, das als JMM (Java Memory Model) bezeichnet wird. Seine Regeln sind deutlich anders und schwächer als die der Sequential Consistency.
Wir wollen in diesem Beitrag einenÜberblicküber die Regeln des JMM geben. Das Thema ist ein wenig theoretisch und es ist nicht unmittelbar einsichtig, was all die Regeln für die Praxis der Java-Programmierung bedeuten. Trotzdem wollen wir erst einmal einenÜberblicküber das Modell als solches geben, ehe wir im Folgenden die einzelnen Aspekte noch einmal näher auf ihre Bedeutung für die Praxis untersuchen.
2.1 Das Java-Memory-Modell
Das Memory-Modell in Javaähnelt einer abstrakten SMP-(Symmetric-Multi-Processing-)Maschine: Die Threads laufen parallel, und konzeptionell haben alle Threads Zugriff auf einen gemeinsamen Hauptspeicher (Main Memory), in dem die gemeinsam verwendeten Variablen abgelegt sind. Daneben hat jeder Thread einen eigenen lokalen Speicherbereich (Cache), in den er Variablen hineinladen und lokal bearbeiten kann. Das Zurückschreiben der lokalen Daten in den Hauptspeicher (Flush) und das Hereinladen von Daten aus dem Hauptspeicher (Refresh) muss nach den Regeln des JMM geschehen. Das JMM beschreibt nun, in welcher Reihenfolge Aktionen passieren und welche Aktionen einen Flush oder Refresh auslösen.
Abbildung 2.1: Abstrakte SMP-Maschine
Eine dieser Regeln besagt zum Beispiel, dass beim Start eines Threads alle relevanten Daten aus dem Hauptspeicher in den lokalen Arbeitsspeicher des Threads geladen werden. Dann darf der Thread mit diesen lokalen Daten arbeiten und muss gar nicht mehr ins Main Memory schauen, weil er die Daten im Cache hat. Am Ende des Threads muss der gesamte lokale Arbeitsspeicher des Threads wieder in den Hauptspeicher zurückgeschrieben werden. Daraus ergibt sich das Verhalten, dass wir auch intuitiv erwarten. Wenn ein Thread mitjoin auf das Ende eines anderen Threads wartet, kann der wartende Thread sehen, welche Modifikationen der andere, bereits beendete Thread gemacht hat.
Das JMM ist auch wieder nur ein Modell, mit dem sich der Java-Programmierer das Verhalten von Threads in einer JVM erklären kann. In Wirklichkeit muss die virtuelle Maschine die Regeln des JMM auf die Hardware abbilden, die ihr eigenes Hardware-Memory-Modell hat. Die heutigen Multi-Core-Prozessoren arbeiten nicht nur mit einem Cache pro Prozessorkern, sondern teilweise mit mehreren Ebenen von Caches und komplexeren Caching-Mechanismen, als sie das JMM vorsieht. Deshalb fällt der virtuellen Maschine die Aufgabe zu, mit geeigneten Anweisungen an die Hardware die Regeln des JMM zu implementieren.
Abbildung 2.2: Schichten des Memory Modells
2.2 Sichtbarkeitsregeln im JMM
Das Memory-Modell von Java regelt drei Dinge:
- Atomicity: Welche Operationen sind atomar, das heißt werden nicht durch andere Threads unterbrochen?
- Ordering: In welcher Reihenfolge passieren die Aktionen?
- Visibility: Wann werden Modifikationen im Speicher anderen Threads sichtbar gemacht?
Wir wollen an dieser Stelle nicht das komplette Memory-Modell erläutern. Es soll kurz auf die eben genannten Regeln eingegangen werden.
2.2.1 Atomicity
Bei der Atomicity geht es zum Beispiel darum, dass der Zugriff auf Variablen von primitivem Typ (außerlong unddouble) sowie auf Referenzvariablen ununterbrechbar ist. Gleiches gilt fürvolatile-Variablen (diesmal inklusivelong unddouble). Die Operationen auf atomaren Variablen im Packagejava.util.concurrent.atomic sind ununterbrechbar. Gleiches gilt für einige der Operationen der Concurrent Collections im Packagejava.util.concurrent, zum Beispiel die MethodeputIfAbsent derConcurrentMap. Bei Referenzvariablen darf man nicht vergessen, dass stets nur der Zugriff auf die Referenz selbst, das heißt auf die Adresse, atomar ist, nicht etwa der Zugriff auf das referenzierte Objekt. Ansonsten sind die Regeln zur Atomarität relativ einfach zu verstehen.