Native Memory Tracking in JVM

1. Übersicht

Haben Sie sich jemals gefragt, warum Java-Anwendungen über die bekannten Optimierungsflags -Xms und -Xmx viel mehr Speicher als die angegebene Menge verbrauchen ? Aus verschiedenen Gründen und möglichen Optimierungen kann die JVM zusätzlichen nativen Speicher zuweisen. Diese zusätzlichen Zuweisungen können möglicherweise den verbrauchten Speicher über die -Xmx- Beschränkung hinaus erhöhen .

In diesem Lernprogramm werden wir einige häufig verwendete Quellen für native Speicherzuweisungen in der JVM zusammen mit ihren Größenoptimierungsflags auflisten und dann lernen, wie Sie Native Memory Tracking verwenden , um sie zu überwachen.

2. Native Zuordnungen

Der Heap ist normalerweise der größte Speicherverbraucher in Java-Anwendungen, aber es gibt auch andere. Neben dem Heap weist die JVM einen ziemlich großen Teil des nativen Speichers zu, um die Klassenmetadaten, den Anwendungscode, den von JIT generierten Code, interne Datenstrukturen usw. zu verwalten. In den folgenden Abschnitten werden einige dieser Zuordnungen erläutert.

2.1. Metaspace

Um einige Metadaten zu den geladenen Klassen zu verwalten, verwendet die JVM einen dedizierten Nicht-Heap-Bereich namens Metaspace . Vor Java 8 hieß das Äquivalent PermGen oder Permanent Generation . Metaspace oder PermGen enthält die Metadaten zu den geladenen Klassen und nicht deren Instanzen, die im Heap gespeichert sind.

Wichtig hierbei ist, dass die Heap-Größenkonfigurationen keinen Einfluss auf die Metaspace-Größe haben, da der Metaspace ein Datenbereich außerhalb des Heaps ist. Um die Metaspace-Größe zu begrenzen, verwenden wir andere Tuning-Flags:

  • -XX: MetaspaceSize und -XX: MaxMetaspaceSize zum Festlegen der minimalen und maximalen Metaspace-Größe
  • Vor Java 8, -XX: PermSize und -XX: MaxPermSize , um die minimale und maximale PermGen-Größe festzulegen

2.2. Themen

Einer der speicherintensivsten Datenbereiche in der JVM ist der Stapel, der gleichzeitig mit jedem Thread erstellt wird. Der Stapel speichert lokale Variablen und Teilergebnisse und spielt eine wichtige Rolle bei Methodenaufrufen.

Die Standardgröße des Thread-Stacks ist plattformabhängig, in den meisten modernen 64-Bit-Betriebssystemen beträgt sie jedoch etwa 1 MB. Diese Größe kann über das Tuning-Flag -Xss konfiguriert werden .

Im Gegensatz zu anderen Datenbereichen ist der den Stapeln zugewiesene Gesamtspeicher praktisch unbegrenzt, wenn die Anzahl der Threads nicht begrenzt ist. Erwähnenswert ist auch, dass die JVM selbst einige Threads benötigt, um ihre internen Operationen wie GC oder Just-in-Time-Kompilierungen auszuführen.

2.3. Code-Cache

Um JVM-Bytecode auf verschiedenen Plattformen ausführen zu können, muss er in Maschinenanweisungen konvertiert werden. Der JIT-Compiler ist für diese Kompilierung verantwortlich, wenn das Programm ausgeführt wird.

Wenn die JVM Bytecode zu Assembly-Anweisungen kompiliert, speichert sie diese Anweisungen in einem speziellen Nicht-Heap-Datenbereich namens Code Cache. Der Code-Cache kann wie andere Datenbereiche in der JVM verwaltet werden. Die Optimierungsflags -XX: InitialCodeCacheSize und -XX: ReservedCodeCacheSize bestimmen die anfängliche und maximal mögliche Größe für den Code-Cache.

2.4. Müllabfuhr

Die JVM wird mit einer Handvoll GC-Algorithmen geliefert, die jeweils für unterschiedliche Anwendungsfälle geeignet sind. Alle diese GC-Algorithmen haben ein gemeinsames Merkmal: Sie müssen einige Off-Heap-Datenstrukturen verwenden, um ihre Aufgaben auszuführen. Diese internen Datenstrukturen verbrauchen mehr nativen Speicher.

2.5. Symbole

Beginnen wir mit Strings, einem der am häufigsten verwendeten Datentypen im Anwendungs- und Bibliothekscode. Aufgrund ihrer Allgegenwart nehmen sie normalerweise einen großen Teil des Haufens ein. Wenn eine große Anzahl dieser Zeichenfolgen denselben Inhalt enthält, wird ein erheblicher Teil des Heaps verschwendet.

Um etwas Heap-Speicherplatz zu sparen, können wir eine Version jedes Strings speichern und andere auf die gespeicherte Version verweisen lassen. Dieser Vorgang wird als String Interning bezeichnet. Da die JVM nur Compile Time String-Konstanten internieren kann , können wir die intern () -Methode für Strings, die wir internieren möchten, manuell aufrufen .

JVM speichert internierte Zeichenfolgen in einer speziellen nativen Hashtabelle mit fester Größe, die als String-Tabelle bezeichnet wird und auch als String-Pool bezeichnet wird . Wir können die Tabellengröße (dh die Anzahl der Buckets) über das Tuning-Flag -XX: StringTableSize konfigurieren .

Neben der Zeichenfolgentabelle gibt es einen weiteren nativen Datenbereich namens Runtime Constant Pool. JVM verwendet diesen Pool, um Konstanten wie numerische Literale zur Kompilierungszeit oder Methoden- und Feldreferenzen zu speichern, die zur Laufzeit aufgelöst werden müssen.

2.6. Native Byte-Puffer

Die JVM ist der übliche Verdächtige für eine erhebliche Anzahl nativer Zuweisungen, aber manchmal können Entwickler auch nativen Speicher direkt zuweisen. Die gängigsten Ansätze sind der Malloc- Aufruf von JNI und die direkten ByteBuffers von NIO .

2.7. Zusätzliche Stimmflaggen

In diesem Abschnitt haben wir eine Handvoll JVM-Optimierungsflags für verschiedene Optimierungsszenarien verwendet. Mit dem folgenden Tipp können wir fast alle Tuning-Flags finden, die sich auf ein bestimmtes Konzept beziehen:

$ java -XX:+PrintFlagsFinal -version | grep 

Das PrintFlagsFinal druckt alle - XX- Optionen in JVM. So finden Sie beispielsweise alle Metaspace-bezogenen Flags:

$ java -XX:+PrintFlagsFinal -version | grep Metaspace // truncated uintx MaxMetaspaceSize = 18446744073709547520 {product} uintx MetaspaceSize = 21807104 {pd product} // truncated

3. Native Memory Tracking (NMT)

Nachdem wir die allgemeinen Quellen für native Speicherzuordnungen in der JVM kennen, ist es an der Zeit, herauszufinden, wie diese überwacht werden können. Zunächst sollten wir die native Speicherverfolgung mithilfe eines weiteren JVM- Optimierungsflags aktivieren : -XX: NativeMemoryTracking = off | sumary | detail. Standardmäßig ist das NMT deaktiviert, aber wir können es aktivieren, um eine Zusammenfassung oder detaillierte Ansicht seiner Beobachtungen anzuzeigen.

Angenommen, wir möchten native Zuordnungen für eine typische Spring Boot-Anwendung verfolgen:

$ java -XX:NativeMemoryTracking=summary -Xms300m -Xmx300m -XX:+UseG1GC -jar app.jar

Hier aktivieren wir die NMT, während wir 300 MB Heap-Speicherplatz zuweisen, wobei G1 unser GC-Algorithmus ist.

3.1. Sofortige Schnappschüsse

Wenn NMT aktiviert ist, können wir die nativen Speicherinformationen jederzeit mit dem Befehl jcmd abrufen :

$ jcmd  VM.native_memory

Um die PID für eine JVM-Anwendung zu finden, können wir die jps verwendenBefehl:

$ jps -l 7858 app.jar // This is our app 7899 sun.tools.jps.Jps

Nun , wenn wir verwenden jcmd mit dem entsprechenden pid , die VM.native_memory macht die JVM die Informationen über nativen Zuweisungen ausdrucken:

$ jcmd 7858 VM.native_memory

Lassen Sie uns die NMT-Ausgabe abschnittsweise analysieren.

3.2. Gesamtzuweisungen

NMT meldet den gesamten reservierten und festgeschriebenen Speicher wie folgt:

Native Memory Tracking: Total: reserved=1731124KB, committed=448152KB

Der reservierte Speicher gibt die Gesamtmenge an Speicher an, die unsere App möglicherweise verwenden kann. Umgekehrt entspricht der festgeschriebene Speicher der Speichermenge, die unsere App derzeit verwendet.

Trotz der Zuweisung von 300 MB Heap beträgt der reservierte Gesamtspeicher für unsere App fast 1,7 GB, viel mehr. In ähnlicher Weise beträgt der festgeschriebene Speicher etwa 440 MB, was wiederum viel mehr als diese 300 MB ist.

Nach dem Gesamtabschnitt meldet NMT Speicherzuordnungen pro Zuordnungsquelle. Lassen Sie uns also jede Quelle eingehend untersuchen.

3.3. Haufen

NMT meldet unsere Heap-Zuweisungen wie erwartet:

Java Heap (reserved=307200KB, committed=307200KB) (mmap: reserved=307200KB, committed=307200KB)

300 MB reservierter und festgeschriebener Speicher, entsprechend unseren Einstellungen für die Heap-Größe.

3.4. Metaspace

Das NMT sagt Folgendes zu den Klassenmetadaten für geladene Klassen:

Class (reserved=1091407KB, committed=45815KB) (classes #6566) (malloc=10063KB #8519) (mmap: reserved=1081344KB, committed=35752KB)

Fast 1 GB reserviert und 45 MB zum Laden von 6566 Klassen verpflichtet.

3.5. Faden

Und hier ist der NMT-Bericht über Thread-Zuweisungen:

Thread (reserved=37018KB, committed=37018KB) (thread #37) (stack: reserved=36864KB, committed=36864KB) (malloc=112KB #190) (arena=42KB #72)

Insgesamt werden Stacks für 37 Threads 36 MB Speicher zugewiesen - fast 1 MB pro Stack. JVM weist den Speicher zum Zeitpunkt der Erstellung Threads zu, sodass die reservierten und festgeschriebenen Zuordnungen gleich sind.

3.6. Code-Cache

Mal sehen, was NMT über die von JIT generierten und zwischengespeicherten Montageanweisungen sagt:

Code (reserved=251549KB, committed=14169KB) (malloc=1949KB #3424) (mmap: reserved=249600KB, committed=12220KB)

Derzeit werden fast 13 MB Code zwischengespeichert, und diese Menge kann möglicherweise bis zu 245 MB betragen.

3.7. GC

Hier ist der NMT-Bericht über die Speichernutzung von G1 GC:

GC (reserved=61771KB, committed=61771KB) (malloc=17603KB #4501) (mmap: reserved=44168KB, committed=44168KB)

As we can see, almost 60 MB is reserved and committed to helping G1.

Let's see how the memory usage looks like for a much simpler GC, say Serial GC:

$ java -XX:NativeMemoryTracking=summary -Xms300m -Xmx300m -XX:+UseSerialGC -jar app.jar

The Serial GC barely uses 1 MB:

GC (reserved=1034KB, committed=1034KB) (malloc=26KB #158) (mmap: reserved=1008KB, committed=1008KB)

Obviously, we shouldn't pick a GC algorithm just because of its memory usage, as the stop-the-world nature of the Serial GC may cause performance degradations. There are, however, several GCs to choose from, and they each balance memory and performance differently.

3.8. Symbol

Here is the NMT report about the symbol allocations, such as the string table and constant pool:

Symbol (reserved=10148KB, committed=10148KB) (malloc=7295KB #66194) (arena=2853KB #1)

Almost 10 MB is allocated to symbols.

3.9. NMT Over Time

The NMT allows us to track how memory allocations change over time. First, we should mark the current state of our application as a baseline:

$ jcmd  VM.native_memory baseline Baseline succeeded

Then, after a while, we can compare the current memory usage with that baseline:

$ jcmd  VM.native_memory summary.diff

NMT würde uns mit den Zeichen + und - mitteilen, wie sich die Speichernutzung in diesem Zeitraum geändert hat:

Total: reserved=1771487KB +3373KB, committed=491491KB +6873KB - Java Heap (reserved=307200KB, committed=307200KB) (mmap: reserved=307200KB, committed=307200KB) - Class (reserved=1084300KB +2103KB, committed=39356KB +2871KB) // Truncated

Der gesamte reservierte und festgeschriebene Speicher wurde um 3 MB bzw. 6 MB erhöht. Andere Schwankungen der Speicherzuordnungen können ebenso leicht erkannt werden.

3.10. Detaillierte NMT

NMT kann sehr detaillierte Informationen über eine Karte des gesamten Speicherplatzes bereitstellen. Um diesen detaillierten Bericht zu aktivieren, sollten wir das Flag -XX: NativeMemoryTracking = Detail- Tuning verwenden.

4. Fazit

In diesem Artikel haben wir verschiedene Mitwirkende an nativen Speicherzuordnungen in der JVM aufgelistet. Anschließend haben wir gelernt, wie eine laufende Anwendung überprüft wird, um ihre nativen Zuordnungen zu überwachen. Mit diesen Erkenntnissen können wir unsere Anwendungen effektiver optimieren und unsere Laufzeitumgebungen dimensionieren.