Komprimierte OOPs in der JVM

1. Übersicht

Die JVM verwaltet den Speicher für uns. Dies entlastet die Entwickler von der Speicherverwaltung, sodass wir Objektzeiger nicht manuell bearbeiten müssen , was sich als zeitaufwändig und fehleranfällig erwiesen hat.

Unter der Haube enthält die JVM viele raffinierte Tricks, um den Speicherverwaltungsprozess zu optimieren. Ein Trick ist die Verwendung von komprimierten Zeigern , die wir in diesem Artikel bewerten werden. Lassen Sie uns zunächst sehen, wie die JVM Objekte zur Laufzeit darstellt.

2. Laufzeitobjektdarstellung

Die HotSpot-JVM verwendet eine Datenstruktur namens oop s oder Ordinary Object Pointers , um Objekte darzustellen. Diese oops entsprechen nativen C-Zeigern. Die instanceOop s sind eine spezielle Art von oop , die die Objektinstanzen in Java darstellt . Darüber hinaus unterstützt die JVM auch eine Handvoll anderer Oops , die im OpenJDK- Quellbaum gespeichert sind.

Mal sehen, wie die JVM instanceOop s im Speicher auslegt.

2.1. Objektspeicherlayout

Das Speicherlayout eines instanceOop ist einfach: Es ist nur der Objektheader, unmittelbar gefolgt von null oder mehr Verweisen auf Instanzfelder.

Die JVM-Darstellung eines Objektheaders besteht aus:

  • Ein Markierungswort dient vielen Zwecken, wie z. B. voreingenommenes Sperren , Identitäts-Hash-Werte und GC . Es ist kein oop, aber aus historischen Gründen befindet es sich im oop - Quellbaum des OpenJDK . Außerdem enthält der Markierungswortstatus nur ein uintptr_t, daher variiert seine Größe in 32-Bit- bzw. 64-Bit-Architekturen zwischen 4 und 8 Byte
  • Ein möglicherweise komprimiertes Klass-Wort , das einen Zeiger auf Klassenmetadaten darstellt. Vor Java 7 zeigten sie auf die permanente Generation , aber ab Java 8 zeigten sie auf den Metaspace
  • Eine 32-Bit-Lücke zum Erzwingen der Objektausrichtung. Dies macht das Layout hardwarefreundlicher, wie wir später sehen werden

Unmittelbar nach dem Header müssen null oder mehr Verweise auf Instanzfelder vorhanden sein. In diesem Fall ist ein Wort ein natives Maschinenwort, also 32-Bit auf älteren 32-Bit-Maschinen und 64-Bit auf moderneren Systemen.

Der Objektheader von Arrays enthält neben markierten und klassifizierten Wörtern ein 32-Bit-Wort, um seine Länge darzustellen.

2.2. Anatomie der Abfälle

Angenommen, wir wechseln von einer älteren 32-Bit-Architektur zu einer moderneren 64-Bit-Maschine. Zunächst können wir mit einer sofortigen Leistungssteigerung rechnen. Dies ist jedoch nicht immer der Fall, wenn die JVM beteiligt ist.

Der Hauptschuldige für diesen möglichen Leistungsabfall sind 64-Bit-Objektreferenzen. 64-Bit-Referenzen nehmen doppelt so viel Platz ein wie 32-Bit-Referenzen, was zu einem höheren Speicherverbrauch im Allgemeinen und häufigeren GC-Zyklen führt. Je mehr Zeit für GC-Zyklen aufgewendet wird, desto weniger CPU-Ausführungsscheiben für unsere Anwendungsthreads.

Sollen wir also zurückschalten und diese 32-Bit-Architekturen wieder verwenden? Selbst wenn dies eine Option wäre, könnten wir nicht mehr als 4 GB Heap-Speicherplatz in 32-Bit-Prozessbereichen ohne etwas mehr Arbeit haben.

3. Komprimierte OOPs

Wie sich herausstellt, kann die JVM durch Komprimieren der Objektzeiger oder oops keine Speicherverschwendung verursachen , sodass wir das Beste aus beiden Welten haben können: mehr als 4 GB Heap-Speicherplatz mit 32-Bit-Referenzen auf 64-Bit-Computern zulassen!

3.1. Grundlegende Optimierung

Wie wir bereits gesehen haben, fügt die JVM den Objekten Auffüllungen hinzu, sodass ihre Größe ein Vielfaches von 8 Bytes beträgt. Bei diesen Auffüllungen sind die letzten drei Bits in oops immer Null. Dies liegt daran, dass Zahlen, die ein Vielfaches von 8 sind, immer in binärer 000 enden .

Da die JVM bereits weiß, dass die letzten drei Bits immer Null sind, macht es keinen Sinn, diese unbedeutenden Nullen im Heap zu speichern. Stattdessen wird davon ausgegangen, dass sie vorhanden sind, und es werden 3 weitere wichtigere Bits gespeichert, die zuvor nicht in 32-Bits passen konnten. Jetzt haben wir eine 32-Bit-Adresse mit 3 nach rechts verschobenen Nullen, also komprimieren wir einen 35-Bit-Zeiger in einen 32-Bit-Zeiger. Dies bedeutet, dass wir bis zu 32 GB - 232 + 3 = 235 = 32 GB - Heapspeicher ohne Verwendung von 64-Bit-Referenzen verwenden können.

Damit diese Optimierung funktioniert, verschiebt die JVM, wenn sie ein Objekt im Speicher finden muss, den Zeiger um 3 Bits nach links (fügt diese 3-Nullen im Grunde wieder bis zum Ende hinzu). Wenn andererseits ein Zeiger auf den Heap geladen wird, verschiebt die JVM den Zeiger um 3 Bits nach rechts, um die zuvor hinzugefügten Nullen zu verwerfen. Grundsätzlich führt die JVM etwas mehr Berechnungen durch, um Platz zu sparen. Glücklicherweise ist die Bitverschiebung für die meisten CPUs eine wirklich triviale Operation.

Um die oop- Komprimierung zu aktivieren , können Sie das Tuning-Flag -XX: + UseCompressedOops verwenden. Die oop- Komprimierung ist das Standardverhalten ab Java 7, wenn die maximale Heap-Größe weniger als 32 GB beträgt. Wenn die maximale Heap-Größe mehr als 32 GB beträgt, schaltet die JVM die oop- Komprimierung automatisch aus . Daher muss die Speicherauslastung über eine Heap-Größe von 32 GB hinaus unterschiedlich verwaltet werden.

3.2. Über 32 GB hinaus

Es ist auch möglich, komprimierte Zeiger zu verwenden, wenn Java-Heap-Größen größer als 32 GB sind. Obwohl die Standardobjektausrichtung 8 Byte beträgt, kann dieser Wert mithilfe des Optimierungsflags -XX: ObjectAlignmentInBytes konfiguriert werden . Der angegebene Wert sollte eine Zweierpotenz sein und im Bereich von 8 bis 256 liegen .

Wir können die maximal mögliche Heap-Größe mit komprimierten Zeigern wie folgt berechnen:

4 GB * ObjectAlignmentInBytes

Wenn die Objektausrichtung beispielsweise 16 Byte beträgt, können wir mit komprimierten Zeigern bis zu 64 GB Heap-Speicherplatz verwenden.

Bitte beachten Sie, dass mit zunehmendem Ausrichtungswert auch der nicht verwendete Abstand zwischen Objekten zunehmen kann. Infolgedessen können wir möglicherweise keine Vorteile aus der Verwendung komprimierter Zeiger mit großen Java-Heap-Größen ziehen.

3.3. Futuristische GCs

ZGC, eine neue Erweiterung in Java 11, war ein experimenteller und skalierbarer Garbage Collector mit geringer Latenz.

Es kann verschiedene Bereiche von Heap-Größen verarbeiten, während die GC-Pausen unter 10 Millisekunden liegen. Da ZGC farbige 64-Bit-Zeiger verwenden muss, werden komprimierte Referenzen nicht unterstützt . Die Verwendung eines GC mit extrem geringer Latenz wie ZGC muss also gegen die Verwendung von mehr Speicher abgewogen werden.

Ab Java 15 unterstützt ZGC die komprimierten Klassenzeiger, es fehlt jedoch weiterhin die Unterstützung für komprimierte OOPs.

Bei allen neuen GC-Algorithmen wird der Speicher jedoch nicht gegen eine geringe Latenz ausgetauscht. Zum Beispiel unterstützt Shenandoah GC komprimierte Referenzen und ist nicht nur ein GC mit geringen Pausenzeiten.

Darüber hinaus sind sowohl Shenandoah als auch ZGC ab Java 15 finalisiert.

4. Fazit

In diesem Artikel haben wir ein Problem mit der JVM-Speicherverwaltung in 64-Bit-Architekturen beschrieben . Wir haben uns komprimierte Zeiger und die Ausrichtung von Objekten angesehen und gesehen, wie die JVM diese Probleme beheben kann, sodass wir größere Heap-Größen mit weniger verschwenderischen Zeigern und einem Minimum an zusätzlicher Berechnung verwenden können.

Für eine detailliertere Diskussion über komprimierte Referenzen wird dringend empfohlen, ein weiteres großartiges Stück von Aleksey Shipilëv zu lesen. Informationen zur Funktionsweise der Objektzuweisung in der HotSpot-JVM finden Sie im Artikel Speicherlayout von Objekten in Java.