Speicher-Hardware-Architektur

Die moderne Speicherhardwarearchitektur unterscheidet sich vom internen Speichermodell von Java. Daher müssen Sie die Hardwarearchitektur verstehen, um zu wissen, wie das Java-Modell damit funktioniert. In diesem Abschnitt wird die allgemeine Speicherhardwarearchitektur beschrieben und im nächsten Abschnitt wird beschrieben, wie Java damit arbeitet.

Hier ist ein vereinfachtes Diagramm der Hardwarearchitektur eines modernen Computers:

Speicher-Hardware-Architektur

In der modernen Welt verfügt ein Computer über zwei oder mehr Prozessoren und das ist bereits die Norm. Einige dieser Prozessoren verfügen möglicherweise auch über mehrere Kerne. Auf solchen Computern ist es möglich, mehrere Threads gleichzeitig auszuführen. Jeder Prozessorkern ist in der Lage, jeweils einen Thread auszuführen. Dies bedeutet, dass jede Java-Anwendung von vornherein Multithread-fähig ist und in Ihrem Programm jeweils ein Thread pro Prozessorkern ausgeführt werden kann.

Der Prozessorkern enthält eine Reihe von Registern, die sich in seinem Speicher (im Kern) befinden. Es führt Operationen an Registerdaten viel schneller durch als an Daten, die sich im Hauptspeicher (RAM) des Computers befinden. Dies liegt daran, dass der Prozessor viel schneller auf diese Register zugreifen kann.

Jede CPU kann auch über eine eigene Cache-Schicht verfügen. Die meisten modernen Prozessoren verfügen darüber. Der Prozessor kann viel schneller auf seinen Cache zugreifen als der Hauptspeicher, jedoch nicht so schnell wie seine internen Register. Der Wert der Cache-Zugriffsgeschwindigkeit liegt ungefähr zwischen den Zugriffsgeschwindigkeiten des Hauptspeichers und der internen Register.

Darüber hinaus verfügen Prozessoren über die Möglichkeit, einen mehrstufigen Cache zu haben. Dies ist jedoch nicht so wichtig zu wissen, um zu verstehen, wie das Java-Speichermodell mit dem Hardwarespeicher interagiert. Es ist wichtig zu wissen, dass Prozessoren möglicherweise über einen gewissen Cache verfügen.

In gleicher Weise verfügt auch jeder Computer über RAM (Hauptspeicherbereich). Alle Kerne können auf den Hauptspeicher zugreifen. Der Hauptspeicherbereich ist in der Regel deutlich größer als der Cache-Speicher der Prozessorkerne.

In dem Moment, in dem der Prozessor Zugriff auf den Hauptspeicher benötigt, liest er einen Teil davon in seinen Cache-Speicher. Es kann auch einige Daten aus dem Cache in seine internen Register lesen und dann Operationen daran ausführen. Wenn die CPU das Ergebnis zurück in den Hauptspeicher schreiben muss, löscht sie die Daten aus ihrem internen Register in den Cache und irgendwann in den Hauptspeicher.

Im Cache gespeicherte Daten werden normalerweise in den Hauptspeicher zurückgespült, wenn der Prozessor etwas anderes im Cache speichern muss. Der Cache hat die Möglichkeit, seinen Speicher zu löschen und gleichzeitig Daten zu schreiben. Der Prozessor muss nicht jedes Mal während eines Updates den gesamten Cache lesen oder schreiben. Normalerweise wird der Cache in kleinen Speicherblöcken aktualisiert, sie werden „Cache-Zeile“ genannt. Eine oder mehrere „Cache-Zeilen“ können in den Cache-Speicher eingelesen werden, und eine oder mehrere Cache-Zeilen können in den Hauptspeicher zurückgespült werden.

Kombination von Java-Speichermodell und Speicherhardwarearchitektur

Wie bereits erwähnt, unterscheiden sich das Java-Speichermodell und die Speicherhardwarearchitektur. Die Hardwarearchitektur unterscheidet nicht zwischen Thread-Stacks und Heaps. Auf der Hardware befinden sich der Thread-Stack und der HEAP (Heap) im Hauptspeicher.

Teile von Stacks und Thread-Heaps können manchmal in Caches und internen Registern der CPU vorhanden sein. Dies ist im Diagramm dargestellt:

Thread-Stack und HEAP

Wenn Objekte und Variablen in verschiedenen Bereichen des Computerspeichers gespeichert werden können, können bestimmte Probleme auftreten. Hier sind die beiden wichtigsten:

  • Sichtbarkeit der Änderungen, die der Thread an gemeinsam genutzten Variablen vorgenommen hat.
  • Racebedingung beim Lesen, Prüfen und Schreiben von Shared-Variablen.

Beide Probleme werden im Folgenden erläutert.

Sichtbarkeit freigegebener Objekte

Wenn zwei oder mehr Threads ein Objekt ohne ordnungsgemäße Verwendung der flüchtigen Deklaration oder Synchronisierung gemeinsam nutzen, sind von einem Thread vorgenommene Änderungen am gemeinsam genutzten Objekt möglicherweise für andere Threads nicht sichtbar.

Stellen Sie sich vor, dass ein gemeinsam genutztes Objekt zunächst im Hauptspeicher gespeichert wird. Ein auf einer CPU ausgeführter Thread liest das gemeinsam genutzte Objekt in den Cache derselben CPU. Dort nimmt er Änderungen am Objekt vor. Bis der Cache der CPU in den Hauptspeicher geleert wurde, ist die geänderte Version des gemeinsam genutzten Objekts für Threads, die auf anderen CPUs ausgeführt werden, nicht sichtbar. Somit kann jeder Thread seine eigene Kopie des gemeinsam genutzten Objekts erhalten, jede Kopie befindet sich in einem separaten CPU-Cache.

Das folgende Diagramm veranschaulicht einen Überblick über diese Situation. Ein auf der linken CPU ausgeführter Thread kopiert das gemeinsam genutzte Objekt in seinen Cache und ändert den Wert von count auf 2. Diese Änderung ist für andere Threads, die auf der rechten CPU ausgeführt werden, nicht sichtbar, da die Aktualisierung der Anzahl noch nicht in den Hauptspeicher zurückgespült wurde.

Um dieses Problem zu lösen, können Sie beim Deklarieren einer Variablen das Schlüsselwort volatile verwenden. Es kann sicherstellen, dass eine bestimmte Variable direkt aus dem Hauptspeicher gelesen und bei Aktualisierung immer in den Hauptspeicher zurückgeschrieben wird.

Rennbedingung

Wenn zwei oder mehr Threads dasselbe Objekt gemeinsam nutzen und mehr als ein Thread Variablen in diesem gemeinsam genutzten Objekt aktualisiert, kann eine Race-Bedingung auftreten.

Stellen Sie sich vor, dass Thread A die Zählvariable des gemeinsam genutzten Objekts in den Cache seines Prozessors liest. Stellen Sie sich außerdem vor, dass Thread B dasselbe tut, jedoch im Cache eines anderen Prozessors. Jetzt addiert Thread A 1 zum Wert von count und Thread B macht dasselbe. Jetzt wurde die Variable zweimal erhöht – separat um +1 im Cache jedes Prozessors.

Wenn diese Inkremente nacheinander durchgeführt würden, würde die Zählvariable verdoppelt und in den Hauptspeicher zurückgeschrieben (ursprünglicher Wert + 2).

Es wurden jedoch zwei Inkremente gleichzeitig ohne ordnungsgemäße Synchronisierung durchgeführt. Unabhängig davon, welcher Thread (A oder B) seine aktualisierte Version von count in den Hauptspeicher schreibt, ist der neue Wert trotz der zwei Inkremente nur um 1 größer als der ursprüngliche Wert.

Dieses Diagramm veranschaulicht das Auftreten des oben beschriebenen Race-Condition-Problems:

Um dieses Problem zu lösen, können Sie einen synchronisierten Java-Block verwenden. Ein synchronisierter Block stellt sicher, dass jeweils nur ein Thread einen bestimmten kritischen Codeabschnitt betreten kann.

Synchronisierte Blöcke garantieren außerdem, dass alle Variablen, auf die innerhalb des synchronisierten Blocks zugegriffen wird, aus dem Hauptspeicher gelesen werden, und wenn der Thread den synchronisierten Block verlässt, werden alle aktualisierten Variablen zurück in den Hauptspeicher geleert, unabhängig davon, ob die Variable als flüchtig oder als Nein deklariert ist.