Ist flüchtig teuer?

111

Nach dem Lesen des JSR-133-Kochbuchs für Compiler-Writer über die Implementierung flüchtiger Elemente, insbesondere des Abschnitts "Interaktionen mit atomaren Anweisungen", gehe ich davon aus, dass für das Lesen einer flüchtigen Variablen ohne Aktualisierung eine LoadLoad- oder eine LoadStore-Barriere erforderlich ist. Weiter unten auf der Seite sehe ich, dass LoadLoad und LoadStore auf X86-CPUs praktisch keine Operationen sind. Bedeutet dies, dass flüchtige Lesevorgänge ohne explizite Cache-Ungültigmachung auf x86 ausgeführt werden können und so schnell sind wie ein normaler Lesevorgang (ohne Berücksichtigung der Neuordnungsbeschränkungen für flüchtige Verbindungen)?

Ich glaube, ich verstehe das nicht richtig. Könnte es jemanden interessieren, mich aufzuklären?

EDIT: Ich frage mich, ob es Unterschiede in Multiprozessor-Umgebungen gibt. Auf Systemen mit einer CPU sieht die CPU möglicherweise ihre eigenen Thread-Caches aus, wie John V. feststellt. Auf Systemen mit mehreren CPUs muss es jedoch eine Konfigurationsoption für die CPUs geben, die nicht ausreicht, und der Hauptspeicher muss getroffen werden, wodurch die Volatilität langsamer wird auf Multi-CPU-Systemen, richtig?

PS: Auf meinem Weg, mehr darüber zu erfahren, bin ich über die folgenden großartigen Artikel gestolpert. Da diese Frage für andere interessant sein kann, werde ich meine Links hier teilen:

Daniel
quelle
1
Sie können meine Bearbeitung über die Konfiguration lesen, auf die sich mehrere CPUs beziehen. Es kann vorkommen, dass auf Systemen mit mehreren CPUs für eine kurzlebige Referenz nicht mehr als ein einzelnes Lesen / Schreiben in den Hauptspeicher stattfindet.
John Vint
2
Das flüchtige Lesen selbst ist nicht teuer. Die Hauptkosten sind, wie Optimierungen verhindert werden. In der Praxis sind die Kosten im Durchschnitt auch nicht sehr hoch, es sei denn, volatile werden in einer engen Schleife verwendet.
unwiderlegbar
2
Dieser Artikel über infoq ( infoq.com/articles/memory_barriers_jvm_concurrency ) könnte Sie ebenfalls interessieren. Er zeigt die Auswirkungen von flüchtig und synchronisiert auf den generierten Code für verschiedene Architekturen. Dies ist auch ein Fall, in dem die JVM eine bessere Leistung als ein Compiler im Voraus erzielen kann, da sie weiß, ob sie auf einem Einprozessorsystem ausgeführt wird, und einige Speicherbarrieren weglassen kann.
Jörn Horstmann

Antworten:

123

Unter Intel ist ein unbestrittener flüchtiger Lesevorgang recht günstig. Wenn wir den folgenden einfachen Fall betrachten:

public static long l;

public static void run() {        
    if (l == -1)
        System.exit(-1);

    if (l == -2)
        System.exit(-1);
}

Mit der Fähigkeit von Java 7, Assemblycode zu drucken, sieht die Ausführungsmethode folgendermaßen aus:

# {method} 'run2' '()V' in 'Test2'
#           [sp+0x10]  (sp of caller)
0xb396ce80: mov    %eax,-0x3000(%esp)
0xb396ce87: push   %ebp
0xb396ce88: sub    $0x8,%esp          ;*synchronization entry
                                    ; - Test2::run2@-1 (line 33)
0xb396ce8e: mov    $0xffffffff,%ecx
0xb396ce93: mov    $0xffffffff,%ebx
0xb396ce98: mov    $0x6fa2b2f0,%esi   ;   {oop('Test2')}
0xb396ce9d: mov    0x150(%esi),%ebp
0xb396cea3: mov    0x154(%esi),%edi   ;*getstatic l
                                    ; - Test2::run@0 (line 33)
0xb396cea9: cmp    %ecx,%ebp
0xb396ceab: jne    0xb396ceaf
0xb396cead: cmp    %ebx,%edi
0xb396ceaf: je     0xb396cece         ;*getstatic l
                                    ; - Test2::run@14 (line 37)
0xb396ceb1: mov    $0xfffffffe,%ecx
0xb396ceb6: mov    $0xffffffff,%ebx
0xb396cebb: cmp    %ecx,%ebp
0xb396cebd: jne    0xb396cec1
0xb396cebf: cmp    %ebx,%edi
0xb396cec1: je     0xb396ceeb         ;*return
                                    ; - Test2::run@28 (line 40)
0xb396cec3: add    $0x8,%esp
0xb396cec6: pop    %ebp
0xb396cec7: test   %eax,0xb7732000    ;   {poll_return}
;... lines removed

Wenn Sie sich die 2 Verweise auf getstatic ansehen, beinhaltet die erste das Laden aus dem Speicher, die zweite überspringt das Laden, wenn der Wert aus den Registern wiederverwendet wird, in die er bereits geladen ist (lang ist 64 Bit und auf meinem 32-Bit-Laptop) es werden 2 Register verwendet).

Wenn wir die Variable l flüchtig machen, ist die resultierende Anordnung anders.

# {method} 'run2' '()V' in 'Test2'
#           [sp+0x10]  (sp of caller)
0xb3ab9340: mov    %eax,-0x3000(%esp)
0xb3ab9347: push   %ebp
0xb3ab9348: sub    $0x8,%esp          ;*synchronization entry
                                    ; - Test2::run2@-1 (line 32)
0xb3ab934e: mov    $0xffffffff,%ecx
0xb3ab9353: mov    $0xffffffff,%ebx
0xb3ab9358: mov    $0x150,%ebp
0xb3ab935d: movsd  0x6fb7b2f0(%ebp),%xmm0  ;   {oop('Test2')}
0xb3ab9365: movd   %xmm0,%eax
0xb3ab9369: psrlq  $0x20,%xmm0
0xb3ab936e: movd   %xmm0,%edx         ;*getstatic l
                                    ; - Test2::run@0 (line 32)
0xb3ab9372: cmp    %ecx,%eax
0xb3ab9374: jne    0xb3ab9378
0xb3ab9376: cmp    %ebx,%edx
0xb3ab9378: je     0xb3ab93ac
0xb3ab937a: mov    $0xfffffffe,%ecx
0xb3ab937f: mov    $0xffffffff,%ebx
0xb3ab9384: movsd  0x6fb7b2f0(%ebp),%xmm0  ;   {oop('Test2')}
0xb3ab938c: movd   %xmm0,%ebp
0xb3ab9390: psrlq  $0x20,%xmm0
0xb3ab9395: movd   %xmm0,%edi         ;*getstatic l
                                    ; - Test2::run@14 (line 36)
0xb3ab9399: cmp    %ecx,%ebp
0xb3ab939b: jne    0xb3ab939f
0xb3ab939d: cmp    %ebx,%edi
0xb3ab939f: je     0xb3ab93ba         ;*return
;... lines removed

In diesem Fall beinhalten beide getstatischen Verweise auf die Variable l eine Last aus dem Speicher, dh der Wert kann nicht über mehrere flüchtige Lesevorgänge in einem Register gehalten werden. Um sicherzustellen, dass es einen atomaren Lesevorgang gibt, wird der Wert aus dem Hauptspeicher in ein MMX-Register movsd 0x6fb7b2f0(%ebp),%xmm0gelesen, wodurch die Leseoperation zu einem einzigen Befehl wird (aus dem vorherigen Beispiel haben wir gesehen, dass der 64-Bit-Wert normalerweise zwei 32-Bit-Lesevorgänge auf einem 32-Bit-System erfordert).

Die Gesamtkosten eines flüchtigen Lesevorgangs entsprechen also in etwa einer Speicherlast und können so günstig sein wie ein L1-Cache-Zugriff. Wenn jedoch ein anderer Kern in die flüchtige Variable schreibt, wird die Cache-Zeile ungültig, was einen Hauptspeicher oder möglicherweise einen L3-Cache-Zugriff erfordert. Die tatsächlichen Kosten hängen stark von der CPU-Architektur ab. Selbst zwischen Intel und AMD unterscheiden sich die Cache-Kohärenzprotokolle.

Michael Barker
quelle
Randnotiz, Java 6 hat die gleiche Fähigkeit, Assembly zu zeigen (es ist der Hotspot, der es tut)
Bestss
+1 In JDK5 flüchtige nicht mit Bezug auf die neu geordnet werden , jeden Lese- / Schreib (das die doppelte Kontrolle Verriegelung fixiert, zum Beispiel). Bedeutet das, dass es auch Auswirkungen darauf hat, wie nichtflüchtige Felder manipuliert werden? Es wäre interessant, den Zugang zu flüchtigen und nichtflüchtigen Feldern zu mischen.
Ewernli
@evemli, du musst vorsichtig sein, ich habe diese Aussage selbst einmal gemacht, wurde aber als falsch befunden. Es gibt einen Randfall. Das Java-Speichermodell ermöglicht die Semantik von Roach-Motels, wenn Geschäfte vor flüchtigen Geschäften nachbestellt werden können. Wenn Sie dies aus dem Artikel von Brian Goetz auf der IBM Site übernommen haben, ist es erwähnenswert, dass dieser Artikel die JMM-Spezifikation vereinfacht.
Michael Barker
20

Im Allgemeinen ist bei den meisten modernen Prozessoren eine flüchtige Last mit einer normalen Last vergleichbar. Ein flüchtiger Speicher ist ungefähr 1/3 der Zeit eines Montior-Enter / Monitor-Exits. Dies ist auf Systemen zu sehen, die Cache-kohärent sind.

Um die Frage des OP zu beantworten, sind flüchtige Schreibvorgänge teuer, während dies beim Lesen normalerweise nicht der Fall ist.

Bedeutet dies, dass flüchtige Lesevorgänge ohne explizite Cache-Ungültigmachung unter x86 ausgeführt werden können und so schnell sind wie ein normaler Lesevorgang (ohne Berücksichtigung der Neuordnungsbeschränkungen für flüchtige Verbindungen)?

Ja, manchmal erreicht die CPU beim Überprüfen eines Felds nicht einmal den Hauptspeicher, sondern spioniert stattdessen andere Thread-Caches aus und erhält den Wert von dort (sehr allgemeine Erklärung).

Ich stimme jedoch Neils Vorschlag zu, dass Sie ein Feld, auf das mehrere Threads zugreifen, als AtomicReference umschließen sollten. Als AtomicReference führt es ungefähr den gleichen Durchsatz für Lese- / Schreibvorgänge aus, aber es ist auch offensichtlicher, dass auf das Feld von mehreren Threads zugegriffen und diese geändert werden.

Bearbeiten, um die Bearbeitung von OP zu beantworten:

Die Cache-Kohärenz ist ein kompliziertes Protokoll, aber kurz gesagt: CPUs teilen sich eine gemeinsame Cache-Zeile, die an den Hauptspeicher angeschlossen ist. Wenn eine CPU Speicher lädt und keine andere CPU über diesen verfügt, geht die CPU davon aus, dass dies der aktuellste Wert ist. Wenn eine andere CPU versucht, denselben Speicherort zu laden, ist sich die bereits geladene CPU dessen bewusst und teilt tatsächlich den zwischengespeicherten Verweis auf die anfordernde CPU. Jetzt hat die anfordernde CPU eine Kopie dieses Speichers in ihrem CPU-Cache. (Es musste nie im Hauptspeicher nach der Referenz suchen)

Es gibt einiges mehr Protokoll, aber dies gibt eine Vorstellung davon, was los ist. Um auch Ihre andere Frage zu beantworten: Da mehrere Prozessoren fehlen, können flüchtige Lese- / Schreibvorgänge tatsächlich schneller sein als bei mehreren Prozessoren. Es gibt einige Anwendungen, die tatsächlich schneller gleichzeitig mit einer einzelnen CPU als mit mehreren ausgeführt werden.

John Vint
quelle
5
Eine AtomicReference ist nur ein Wrapper für ein flüchtiges Feld mit zusätzlichen nativen Funktionen, die zusätzliche Funktionen wie getAndSet, compareAndSet usw. bereitstellen. Aus Sicht der Leistung ist die Verwendung nur nützlich, wenn Sie die zusätzliche Funktionalität benötigen. Aber ich frage mich, warum Sie sich hier auf das Betriebssystem beziehen? Die Funktionalität wird direkt in CPU-Opcodes implementiert. Und bedeutet dies, dass auf Multiprozessorsystemen, bei denen eine CPU keine Kenntnis über den Cache-Inhalt anderer CPUs hat, flüchtige Stoffe langsamer sind, weil die CPUs immer den Hauptspeicher erreichen müssen?
Daniel
Sie haben Recht, ich vermisse, sprach über das Betriebssystem, das CPU geschrieben haben sollte, und behebt das jetzt. Und ja, ich weiß, dass AtomicReference einfach ein Wrapper für flüchtige Felder ist, aber es fügt auch als eine Art Dokumentation hinzu, dass auf das Feld selbst mehrere Threads zugreifen können.
John Vint
@ John, warum sollten Sie eine weitere Indirektion über eine AtomicReference hinzufügen? Wenn Sie CAS benötigen - ok, aber AtomicUpdater könnte eine bessere Option sein. Soweit ich mich erinnere, gibt es keine Eigenheiten zu AtomicReference.
Bests
@bestsss Für alle allgemeinen Zwecke gibt es keinen Unterschied zwischen AtomicReference.set / get und flüchtiger Ladung und Speichern. Davon abgesehen hatte ich das gleiche Gefühl (und bis zu einem gewissen Grad), wann ich welche verwenden sollte. Diese Antwort kann es etwas detaillierter beschreiben . Stackoverflow.com/questions/3964317/… . Die Verwendung von beidem ist eher eine Präferenz. Mein einziges Argument für die Verwendung von AtomicReference gegenüber einem einfachen flüchtigen Element ist die klare Dokumentation - das ist selbst nicht das beste Argument, das ich verstehe
John Vint
Nebenbei bemerkt, einige argumentieren, dass die Verwendung eines flüchtigen Feldes / AtomicReference (ohne die Notwendigkeit eines CAS) zu fehlerhaftem
John Vint
12

In den Worten des Java-Speichermodells (wie für Java 5+ in JSR 133 definiert) erstellt jede Operation - Lesen oder Schreiben - für eine volatileVariable eine Vor-Vor- Beziehung in Bezug auf jede andere Operation für dieselbe Variable. Dies bedeutet, dass der Compiler und die JIT gezwungen sind, bestimmte Optimierungen zu vermeiden, z. B. das Neuordnen von Anweisungen innerhalb des Threads oder das Ausführen von Vorgängen nur im lokalen Cache.

Da einige Optimierungen nicht verfügbar sind, ist der resultierende Code notwendigerweise langsamer als er gewesen wäre, wenn auch wahrscheinlich nicht sehr viel.

Sie sollten jedoch keine Variable volatileerstellen, es sei denn, Sie wissen, dass von mehreren Threads außerhalb von synchronizedBlöcken auf sie zugegriffen wird . Selbst dann sollten Sie überlegen, ob Volatile die beste Wahl ist synchronized, AtomicReferenceund seine Freunde, die expliziten LockKlassen usw.

Neil Bartlett
quelle
4

Der Zugriff auf eine flüchtige Variable ähnelt in vielerlei Hinsicht dem Umschließen des Zugriffs auf eine normale Variable in einen synchronisierten Block. Zum Beispiel verhindert der Zugriff auf eine flüchtige Variable, dass die CPU die Anweisungen vor und nach dem Zugriff neu anordnet, und dies verlangsamt im Allgemeinen die Ausführung (obwohl ich nicht sagen kann, um wie viel).

Im Allgemeinen sehe ich auf einem Multiprozessorsystem nicht, wie der Zugriff auf eine flüchtige Variable ohne Strafe erfolgen kann - es muss eine Möglichkeit geben, sicherzustellen, dass ein Schreibvorgang auf Prozessor A mit einem Lesevorgang auf Prozessor B synchronisiert wird.

Krakover
quelle
4
Das Lesen flüchtiger Variablen hat hinsichtlich der Neuordnungsmöglichkeiten von Anweisungen die gleiche Strafe wie das Eingeben eines Monitors, während das Schreiben einer flüchtigen Variablen einem Monitorausgang entspricht. Ein Unterschied kann sein, welche Variablen (z. B. Prozessor-Caches) gelöscht oder ungültig werden. Während synchronisiert alles löscht oder ungültig macht, sollte der Zugriff auf die flüchtige Variable immer vom Cache ignoriert werden.
Daniel
12
-1, Der Zugriff auf eine flüchtige Variable unterscheidet sich erheblich von der Verwendung eines synchronisierten Blocks. Das Eingeben eines synchronisierten Blocks erfordert ein atomares compareAndSet-basiertes Schreiben, um die Sperre aufzuheben, und ein flüchtiges Schreiben, um sie aufzuheben. Wenn die Sperre zufrieden ist, muss die Steuerung vom Benutzerbereich zum Kernelbereich übergehen, um die Sperre zu vermitteln (dies ist das teure Bit). Der Zugriff auf ein flüchtiges Gerät bleibt immer im Benutzerbereich.
Michael Barker
@ MichaelBarker: Sind Sie sicher, dass alle Monitore vom Kernel und nicht von der App geschützt werden müssen?
Daniel
@ Daniel: Wenn Sie einen Monitor mit einem synchronisierten Block oder einer Sperre darstellen, dann ja, aber nur, wenn der Monitor zufrieden ist. Die einzige Möglichkeit, dies ohne Kernel-Arbitrierung zu tun, besteht darin, dieselbe Logik zu verwenden, aber beschäftigt zu drehen, anstatt den Thread zu parken.
Michael Barker
@ MichaelBarker: Okey, für zufriedene Schlösser verstehe ich das.
Daniel