Monitoraggio della memoria nativa in JVM

1. Panoramica

Ti sei mai chiesto perché le applicazioni Java consumano molta più memoria rispetto alla quantità specificata tramite i noti flag di ottimizzazione -Xms e -Xmx ? Per una serie di motivi e possibili ottimizzazioni, la JVM può allocare memoria nativa aggiuntiva. Queste allocazioni aggiuntive possono eventualmente aumentare la memoria consumata oltre il limite di -Xmx .

In questo tutorial enumereremo alcune fonti comuni di allocazioni di memoria nativa nella JVM, insieme ai loro flag di ottimizzazione del dimensionamento, e quindi impareremo come utilizzare Native Memory Tracking per monitorarli.

2. Allocazioni native

L'heap di solito è il più grande consumatore di memoria nelle applicazioni Java, ma ce ne sono altri. Oltre all'heap, la JVM alloca una porzione abbastanza grande dalla memoria nativa per mantenere i metadati della sua classe, il codice dell'applicazione, il codice generato da JIT, le strutture di dati interne, ecc. Nelle sezioni seguenti esploreremo alcune di queste allocazioni.

2.1. Metaspace

Per mantenere alcuni metadati sulle classi caricate, JVM utilizza un'area non di heap dedicata chiamata Metaspace . Prima di Java 8, l'equivalente si chiamava PermGen o Permanent Generation . Metaspace o PermGen contiene i metadati sulle classi caricate piuttosto che le istanze di esse, che sono conservate all'interno dell'heap.

La cosa importante qui è che le configurazioni di dimensionamento dell'heap non influenzeranno la dimensione del Metaspace poiché il Metaspace è un'area dati fuori heap. Per limitare la dimensione di Metaspace, utilizziamo altri flag di ottimizzazione:

  • -XX: MetaspaceSize e -XX: MaxMetaspaceSize per impostare la dimensione minima e massima di Metaspace
  • Prima di Java 8, -XX: PermSize e -XX: MaxPermSize per impostare la dimensione minima e massima di PermGen

2.2. Discussioni

Una delle aree dati che consumano più memoria nella JVM è lo stack, creato contemporaneamente a ogni thread. Lo stack memorizza le variabili locali e i risultati parziali, giocando un ruolo importante nelle invocazioni dei metodi.

La dimensione predefinita dello stack di thread dipende dalla piattaforma, ma nella maggior parte dei moderni sistemi operativi a 64 bit è di circa 1 MB. Questa dimensione è configurabile tramite il flag di ottimizzazione -Xss .

A differenza di altre aree di dati, la memoria totale allocata agli stack è praticamente illimitata quando non vi sono limitazioni al numero di thread. Vale anche la pena ricordare che la stessa JVM necessita di alcuni thread per eseguire le sue operazioni interne come GC o compilazioni just-in-time.

2.3. Cache del codice

Per eseguire il bytecode JVM su piattaforme diverse, è necessario convertirlo in istruzioni macchina. Il compilatore JIT è responsabile di questa compilazione quando il programma viene eseguito.

Quando la JVM compila il bytecode in istruzioni di assembly, memorizza tali istruzioni in una speciale area dati non di heap chiamata Code Cache. La cache del codice può essere gestita proprio come le altre aree dati nella JVM. I flag di ottimizzazione -XX: InitialCodeCacheSize e -XX: ReservedCodeCacheSize determinano la dimensione iniziale e massima possibile per la cache del codice.

2.4. Raccolta dei rifiuti

La JVM viene fornita con una manciata di algoritmi GC, ciascuno adatto a diversi casi d'uso. Tutti questi algoritmi GC condividono una caratteristica comune: hanno bisogno di utilizzare alcune strutture di dati off-heap per svolgere le loro attività. Queste strutture di dati interne consumano più memoria nativa.

2.5. Simboli

Cominciamo con le stringhe, uno dei tipi di dati più comunemente usati nel codice dell'applicazione e della libreria. A causa della loro ubiquità, di solito occupano una grande porzione dell'Heap. Se un numero elevato di queste stringhe contiene lo stesso contenuto, una parte significativa dell'heap verrà sprecata.

Per risparmiare un po 'di spazio nell'heap, possiamo memorizzare una versione di ogni stringa e fare in modo che altre si riferiscano alla versione memorizzata. Questo processo è chiamato String Interning. Poiché la JVM può internare solo costanti stringa tempo di compilazione, possiamo chiamare manualmente il metodo intern () sulle stringhe che intendiamo internare.

JVM memorizza le stringhe internate in una speciale tabella hash nativa di dimensioni fisse chiamata String Table, nota anche come String Pool . Possiamo configurare la dimensione della tabella (cioè il numero di bucket) tramite il flag di ottimizzazione -XX: StringTableSize .

Oltre alla tabella delle stringhe, c'è un'altra area dati nativa chiamata Runtime Constant Pool. JVM utilizza questo pool per memorizzare costanti come valori letterali numerici in fase di compilazione o riferimenti a metodi e campi che devono essere risolti in fase di esecuzione.

2.6. Buffer byte nativi

La JVM è il solito sospetto per un numero significativo di allocazioni native, ma a volte gli sviluppatori possono allocare direttamente anche la memoria nativa. Gli approcci più comuni sono la chiamata malloc di JNI e ByteBuffer diretti di NIO.

2.7. Flag di ottimizzazione aggiuntivi

In questa sezione, abbiamo utilizzato una manciata di flag di ottimizzazione JVM per diversi scenari di ottimizzazione. Utilizzando il seguente suggerimento, possiamo trovare quasi tutti i flag di ottimizzazione relativi a un particolare concetto:

$ java -XX:+PrintFlagsFinal -version | grep 

I PrintFlagsFinal stampe tutti il - XX opzioni in JVM. Ad esempio, per trovare tutti i flag relativi a Metaspace:

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

3. Native Memory Tracking (NMT)

Ora che conosciamo le origini comuni delle allocazioni di memoria nativa nella JVM, è il momento di scoprire come monitorarle. Innanzitutto, dovremmo abilitare il tracciamento della memoria nativa utilizzando ancora un altro flag di ottimizzazione JVM: -XX: NativeMemoryTracking = off | sumary | detail. Per impostazione predefinita, l'NMT è disattivato ma possiamo abilitarlo a vedere un riepilogo o una vista dettagliata delle sue osservazioni.

Supponiamo di voler tenere traccia delle allocazioni native per una tipica applicazione Spring Boot:

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

In questo caso, stiamo abilitando l'NMT allocando 300 MB di spazio heap, con G1 come algoritmo GC.

3.1. Istantanee istantanee

Quando NMT è abilitato, possiamo ottenere le informazioni sulla memoria nativa in qualsiasi momento utilizzando il comando jcmd :

$ jcmd  VM.native_memory

Per trovare il PID per un'applicazione JVM, possiamo usare jpscomando:

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

Ora, se usiamo jcmd con il pid appropriato , VM.native_memory fa in modo che JVM stampi le informazioni sulle allocazioni native:

$ jcmd 7858 VM.native_memory

Analizziamo l'output NMT sezione per sezione.

3.2. Allocazioni totali

NMT riporta la memoria totale riservata e impegnata come segue:

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

La memoria riservata rappresenta la quantità totale di memoria che la nostra app può potenzialmente utilizzare. Al contrario, la memoria impegnata è uguale alla quantità di memoria che la nostra app sta utilizzando in questo momento.

Nonostante l'allocazione di 300 MB di heap, la memoria totale riservata per la nostra app è di quasi 1,7 GB, molto di più. Allo stesso modo, la memoria impegnata è di circa 440 MB, che è, ancora una volta, molto più di quei 300 MB.

Dopo la sezione totale, NMT riporta le allocazioni di memoria per origine di allocazione. Quindi, esploriamo ogni fonte in profondità.

3.3. Mucchio

NMT riporta le nostre allocazioni di heap come previsto:

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

300 MB di memoria sia riservata che impegnata, che corrisponde alle nostre impostazioni di dimensione dell'heap.

3.4. Metaspace

Ecco cosa dice l'NMT sui metadati della classe per le classi caricate:

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

Quasi 1 GB riservato e 45 MB impegnati per caricare 6566 classi.

3.5. Filo

Ed ecco il rapporto NMT sulle allocazioni dei thread:

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

In totale, 36 MB di memoria sono allocati a stack per 37 thread, quasi 1 MB per stack. JVM alloca la memoria ai thread al momento della creazione, quindi le allocazioni riservate e impegnate sono uguali.

3.6. Cache del codice

Vediamo cosa dice NMT sulle istruzioni di assemblaggio generate e memorizzate nella cache da JIT:

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

Attualmente, quasi 13 MB di codice vengono memorizzati nella cache e questa quantità può potenzialmente arrivare a circa 245 MB.

3.7. GC

Ecco il rapporto NMT sull'utilizzo della memoria di 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, utilizzando i segni + e -, ci dirà come è cambiato l'utilizzo della memoria in quel periodo:

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

La memoria totale riservata e impegnata è aumentata rispettivamente di 3 MB e 6 MB. Altre fluttuazioni nelle allocazioni di memoria possono essere individuate altrettanto facilmente.

3.10. NMT dettagliato

NMT può fornire informazioni molto dettagliate su una mappa dell'intero spazio di memoria. Per abilitare questo rapporto dettagliato, dovremmo utilizzare il flag -XX: NativeMemoryTracking = ottimizzazione dei dettagli .

4. Conclusione

In questo articolo, abbiamo enumerato diversi contributori alle allocazioni di memoria nativa nella JVM. Quindi, abbiamo imparato come ispezionare un'applicazione in esecuzione per monitorare le sue allocazioni native. Con queste informazioni, possiamo ottimizzare le nostre applicazioni e ridimensionare i nostri ambienti di runtime in modo più efficace.