La memoria virtuale utilizzata da un processo Java si estende ben oltre il semplice Java Heap. Sai, JVM include molti sottosistemi:Garbage Collector, Class Loading, compilatori JIT ecc., e tutti questi sottosistemi richiedono una certa quantità di RAM per funzionare.
JVM non è l'unico consumatore di RAM. Anche le librerie native (inclusa la libreria di classi Java standard) possono allocare memoria nativa. E questo non sarà nemmeno visibile al Native Memory Tracking. L'applicazione Java stessa può anche utilizzare la memoria off-heap per mezzo di ByteBuffers diretti.
Quindi cosa richiede memoria in un processo Java?
Parti JVM (per lo più mostrate da Native Memory Tracking)
- Heap Java
La parte più ovvia. Qui è dove vivono gli oggetti Java. L'heap occupa fino a -Xmx
quantità di memoria.
- Garbage Collector
Le strutture e gli algoritmi GC richiedono memoria aggiuntiva per la gestione dell'heap. Queste strutture sono Mark Bitmap, Mark Stack (per l'attraversamento di oggetti grafici), Remembered Set (per la registrazione di riferimenti interregionali) e altri. Alcuni di essi sono direttamente sintonizzabili, ad es. -XX:MarkStackSizeMax
, altri dipendono dal layout dell'heap, ad es. le più grandi sono le regioni G1 (-XX:G1HeapRegionSize
), più piccoli sono gli insiemi ricordati.
L'overhead della memoria GC varia tra gli algoritmi GC. -XX:+UseSerialGC
e -XX:+UseShenandoahGC
hanno il minimo sovraccarico. G1 o CMS possono facilmente utilizzare circa il 10% della dimensione totale dell'heap.
- Codice Cache
Contiene codice generato dinamicamente:metodi compilati JIT, interprete e stub di runtime. La sua dimensione è limitata da -XX:ReservedCodeCacheSize
(240 milioni per impostazione predefinita). Disattiva -XX:-TieredCompilation
per ridurre la quantità di codice compilato e quindi l'utilizzo di Code Cache.
- Compilatore
Anche il compilatore JIT stesso richiede memoria per svolgere il proprio lavoro. Questo può essere nuovamente ridotto disattivando la compilazione a livelli o riducendo il numero di thread del compilatore:-XX:CICompilerCount
.
- Caricamento della classe
I metadati di classe (bytecode di metodo, simboli, pool di costanti, annotazioni ecc.) sono archiviati in un'area off-heap chiamata Metaspace. Più classi vengono caricate, più metaspazio viene utilizzato. L'utilizzo totale può essere limitato da -XX:MaxMetaspaceSize
(illimitato per impostazione predefinita) e -XX:CompressedClassSpaceSize
(1G per impostazione predefinita).
- Tabelle dei simboli
Due hashtable principali della JVM:la tabella dei simboli contiene nomi, firme, identificatori ecc. e la tabella delle stringhe contiene riferimenti a stringhe interne. Se Native Memory Tracking indica un utilizzo significativo della memoria da parte di una tabella String, probabilmente significa che l'applicazione chiama eccessivamente String.intern
.
- Discussioni
Gli stack di thread sono anche responsabili dell'occupazione della RAM. La dimensione dello stack è controllata da -Xss
. L'impostazione predefinita è 1M per thread, ma fortunatamente le cose non vanno così male. Il sistema operativo alloca le pagine di memoria in modo pigro, ovvero al primo utilizzo, quindi l'utilizzo effettivo della memoria sarà molto inferiore (in genere 80-200 KB per stack di thread). Ho scritto uno script per stimare quanto RSS appartiene agli stack di thread Java.
Esistono altre parti JVM che allocano la memoria nativa, ma di solito non svolgono un ruolo importante nel consumo totale di memoria.
Buffer diretti
Un'applicazione può richiedere esplicitamente memoria off-heap chiamando ByteBuffer.allocateDirect
. Il limite off-heap predefinito è pari a -Xmx
, ma può essere sovrascritto con -XX:MaxDirectMemorySize
. I ByteBuffer diretti sono inclusi in Other
sezione dell'output NMT (o Internal
prima di JDK 11).
La quantità di memoria diretta utilizzata è visibile tramite JMX, ad es. in JConsole o Java Mission Control:
Oltre ai ByteBuffer diretti ci possono essere MappedByteBuffers
- i file mappati nella memoria virtuale di un processo. NMT non li tiene traccia, tuttavia, MappedByteBuffers può anche occupare memoria fisica. E non esiste un modo semplice per limitare quanto possono prendere. Puoi solo vedere l'utilizzo effettivo guardando la mappa della memoria del processo:pmap -x <pid>
Address Kbytes RSS Dirty Mode Mapping
...
00007f2b3e557000 39592 32956 0 r--s- some-file-17405-Index.db
00007f2b40c01000 39600 33092 0 r--s- some-file-17404-Index.db
^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^
Librerie native
Codice JNI caricato da System.loadLibrary
può allocare tutta la memoria off-heap che desidera senza alcun controllo dal lato JVM. Ciò riguarda anche la libreria di classi Java standard. In particolare, le risorse Java non chiuse possono diventare una fonte di perdita di memoria nativa. Esempi tipici sono ZipInputStream
o DirectoryStream
.
Agenti JVMTI, in particolare jdwp
agente di debug - può anche causare un consumo eccessivo di memoria.
Questa risposta descrive come profilare le allocazioni di memoria nativa con async-profiler.
Problemi con l'allocatore
Un processo in genere richiede memoria nativa direttamente dal sistema operativo (tramite mmap
chiamata di sistema) o utilizzando malloc
- allocatore libc standard. A sua volta, malloc
richiede grossi blocchi di memoria dal sistema operativo utilizzando mmap
, quindi gestisce questi blocchi in base al proprio algoritmo di allocazione. Il problema è che questo algoritmo può portare alla frammentazione e all'eccessivo utilizzo della memoria virtuale.
jemalloc
, un allocatore alternativo, spesso appare più intelligente della normale libc malloc
, quindi passando a jemalloc
può comportare un ingombro inferiore gratuitamente.
Conclusione
Non esiste un modo garantito per stimare l'utilizzo completo della memoria di un processo Java, perché ci sono troppi fattori da considerare.
Total memory = Heap + Code Cache + Metaspace + Symbol tables +
Other JVM structures + Thread stacks +
Direct buffers + Mapped files +
Native Libraries + Malloc overhead + ...
È possibile ridurre o limitare determinate aree di memoria (come Code Cache) tramite i flag JVM, ma molte altre sono fuori dal controllo di JVM.
Un possibile approccio all'impostazione dei limiti di Docker sarebbe osservare l'effettivo utilizzo della memoria in uno stato "normale" del processo. Esistono strumenti e tecniche per indagare sui problemi con il consumo di memoria Java:Native Memory Tracking, pmap, jemalloc, async-profiler.
Aggiorna
Ecco una registrazione della mia presentazione Memory Footprint of a Java Process.
In questo video, discuto cosa può consumare memoria in un processo Java, come monitorare e limitare le dimensioni di determinate aree di memoria e come profilare le perdite di memoria nativa in un'applicazione Java.
https://developers.redhat.com/blog/2017/04/04/openjdk-and-containers/:
Perché quando specifico -Xmx=1g la mia JVM utilizza più memoria di 1 GB di memoria?
Specificare -Xmx=1g indica alla JVM di allocare un heap da 1 GB. Sta chiedendo alla JVM di limitare l'intero utilizzo della memoria a 1 GB. Ci sono tabelle di carte, cache di codice e ogni sorta di altre strutture di dati off heap. Il parametro utilizzato per specificare l'utilizzo totale della memoria è-XX:MaxRAM. Tieni presente che con -XX:MaxRam=500m il tuo heap sarà di circa 250 MB.
Java vede la dimensione della memoria dell'host e non è a conoscenza di alcuna limitazione della memoria del contenitore. Non crea pressione sulla memoria, quindi anche GC non ha bisogno di rilasciare la memoria utilizzata. Spero XX:MaxRAM
ti aiuterà a ridurre l'impronta di memoria. Alla fine, puoi modificare la configurazione del GC (-XX:MinHeapFreeRatio
,-XX:MaxHeapFreeRatio
, ...)
Esistono molti tipi di metriche di memoria. Docker sembra segnalare la dimensione della memoria RSS, che può essere diversa dalla memoria "impegnata" riportata da jcmd
(le versioni precedenti di Docker riportano RSS+cache come utilizzo della memoria). Buona discussione e collegamenti:Differenza tra Resident Set Size (RSS) e Java Total Commited Memory (NMT) per una JVM in esecuzione nel contenitore Docker
La memoria (RSS) può essere consumata anche da altre utilità nel contenitore:shell, gestore processi, ... Non sappiamo cos'altro è in esecuzione nel contenitore e come si avviano i processi nel contenitore.
TL;DR
L'utilizzo dei dettagli della memoria è fornito dai dettagli del Native Memory Tracking (NMT) (principalmente metadati del codice e Garbage Collector). Inoltre, il compilatore Java e l'ottimizzatore C1/C2 consumano la memoria non riportata nel sommario.
Il footprint di memoria può essere ridotto utilizzando i flag JVM (ma ci sono impatti).
Il dimensionamento del contenitore Docker deve essere eseguito tramite test con il carico previsto dell'applicazione.
Dettaglio per ogni componente
Lo spazio di classe condiviso può essere disabilitato all'interno di un contenitore poiché le classi non saranno condivise da un altro processo JVM. È possibile utilizzare il seguente flag. Rimuoverà lo spazio di classe condiviso (17 MB).
-Xshare:off
Il raccoglitore di rifiuti serial ha un footprint di memoria minimo al costo di un tempo di pausa più lungo durante l'elaborazione del garbage collect (vedi il confronto di Aleksey Shipilëv tra GC in un'immagine). Può essere abilitato con il seguente flag. Può risparmiare fino allo spazio GC utilizzato (48 MB).
-XX:+UseSerialGC
Il compilatore C2 può essere disabilitato con il seguente flag per ridurre i dati di profilazione utilizzati per decidere se ottimizzare o meno un metodo.
-XX:+TieredCompilation -XX:TieredStopAtLevel=1
Lo spazio del codice è ridotto di 20 MB. Inoltre, la memoria esterna a JVM è ridotta di 80 MB (differenza tra spazio NMT e spazio RSS). Il compilatore di ottimizzazione C2 richiede 100 MB.
I compilatori C1 e C2 può essere disabilitato con il seguente flag.
-Xint
La memoria all'esterno della JVM è ora inferiore allo spazio totale impegnato. Lo spazio del codice è ridotto di 43 MB. Attenzione, questo ha un forte impatto sulle prestazioni dell'applicazione. La disattivazione del compilatore C1 e C2 riduce la memoria utilizzata di 170 MB.
Utilizzo del compilatore Graal VM (sostituzione di C2) porta a un footprint di memoria leggermente inferiore. Aumenta di 20MB lo spazio di memoria del codice e diminuisce di 60MB la memoria JVM esterna.
L'articolo Java Memory Management per JVM fornisce alcune informazioni rilevanti sui diversi spazi di memoria. Oracle fornisce alcuni dettagli nella documentazione di Native Memory Tracking. Maggiori dettagli sul livello di compilazione nella politica di compilazione avanzata e nella disabilitazione C2 riducono la dimensione della cache del codice di un fattore 5. Alcuni dettagli su Perché una JVM riporta più memoria impegnata rispetto alla dimensione del set residente del processo Linux? quando entrambi i compilatori sono disabilitati.