La memoria virtual utilizada por un proceso de Java se extiende mucho más allá de solo Java Heap. Ya sabes, JVM incluye muchos subsistemas:Recolector de basura, Carga de clases, compiladores JIT, etc., y todos estos subsistemas requieren cierta cantidad de RAM para funcionar.
JVM no es el único consumidor de RAM. Las bibliotecas nativas (incluida la biblioteca de clases de Java estándar) también pueden asignar memoria nativa. Y esto ni siquiera será visible para Native Memory Tracking. La propia aplicación Java también puede usar memoria fuera del montón por medio de ByteBuffers directos.
Entonces, ¿qué requiere memoria en un proceso Java?
Partes de JVM (principalmente mostradas por Seguimiento de memoria nativa)
- Montón de Java
La parte más obvia. Aquí es donde viven los objetos de Java. El montón ocupa hasta -Xmx
cantidad de memoria.
- Recolector de basura
Las estructuras y los algoritmos de GC requieren memoria adicional para la gestión del montón. Estas estructuras son Mark Bitmap, Mark Stack (para atravesar gráficos de objetos), Recorded Sets (para registrar referencias entre regiones) y otras. Algunos de ellos son directamente ajustables, p. -XX:MarkStackSizeMax
, otros dependen del diseño del montón, p. cuanto más grandes son las regiones G1 (-XX:G1HeapRegionSize
), los más pequeños son conjuntos recordados.
La sobrecarga de memoria del GC varía entre los algoritmos del GC. -XX:+UseSerialGC
y -XX:+UseShenandoahGC
tener los gastos generales más pequeños. G1 o CMS pueden usar fácilmente alrededor del 10 % del tamaño total del almacenamiento dinámico.
- Caché de código
Contiene código generado dinámicamente:métodos compilados por JIT, intérprete y apéndices en tiempo de ejecución. Su tamaño está limitado por -XX:ReservedCodeCacheSize
(240M por defecto). Desactivar -XX:-TieredCompilation
para reducir la cantidad de código compilado y, por lo tanto, el uso de caché de código.
- Compilador
El propio compilador JIT también requiere memoria para hacer su trabajo. Esto se puede reducir de nuevo desactivando la compilación por niveles o reduciendo el número de subprocesos del compilador:-XX:CICompilerCount
.
- Carga de clases
Los metadatos de clase (códigos de bytes de métodos, símbolos, grupos de constantes, anotaciones, etc.) se almacenan en un área fuera del montón llamada Metaspace. Cuantas más clases se cargan, más metaespacio se utiliza. El uso total puede estar limitado por -XX:MaxMetaspaceSize
(ilimitado por defecto) y -XX:CompressedClassSpaceSize
(1G por defecto).
- Tablas de símbolos
Dos tablas hash principales de JVM:la tabla de símbolos contiene nombres, firmas, identificadores, etc. y la tabla de cadenas contiene referencias a cadenas internas. Si el seguimiento de memoria nativa indica un uso de memoria significativo por parte de una tabla de cadenas, probablemente signifique que la aplicación llama excesivamente a String.intern
.
- Hilos
Las pilas de subprocesos también son responsables de tomar RAM. El tamaño de la pila está controlado por -Xss
. El valor predeterminado es 1 millón por subproceso, pero afortunadamente las cosas no están tan mal. El sistema operativo asigna las páginas de memoria de forma perezosa, es decir, en el primer uso, por lo que el uso real de la memoria será mucho menor (normalmente, 80-200 KB por pila de subprocesos). Escribí un script para estimar cuánto de RSS pertenece a las pilas de subprocesos de Java.
Hay otras partes de JVM que asignan memoria nativa, pero por lo general no juegan un papel importante en el consumo total de memoria.
Búferes directos
Una aplicación puede solicitar explícitamente memoria fuera del montón llamando a ByteBuffer.allocateDirect
. El límite fuera del montón predeterminado es igual a -Xmx
, pero se puede anular con -XX:MaxDirectMemorySize
. Los ByteBuffers directos están incluidos en Other
sección de salida NMT (o Internal
antes de JDK 11).
La cantidad de memoria directa utilizada es visible a través de JMX, p. en JConsole o Java Mission Control:
Además de ByteBuffers directos, puede haber MappedByteBuffers
- los archivos asignados a la memoria virtual de un proceso. NMT no los rastrea, sin embargo, MappedByteBuffers también puede tomar memoria física. Y no hay una manera simple de limitar cuánto pueden tomar. Puede ver el uso real mirando el mapa de memoria del proceso: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
^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^
Bibliotecas nativas
Código JNI cargado por System.loadLibrary
puede asignar tanta memoria fuera del montón como quiera sin control desde el lado de JVM. Esto también afecta a la biblioteca de clases Java estándar. En particular, los recursos de Java no cerrados pueden convertirse en una fuente de pérdida de memoria nativa. Ejemplos típicos son ZipInputStream
o DirectoryStream
.
Agentes JVMTI, en particular, jdwp
agente de depuración:también puede causar un consumo excesivo de memoria.
Esta respuesta describe cómo perfilar las asignaciones de memoria nativa con async-profiler.
Problemas del asignador
Un proceso normalmente solicita memoria nativa directamente desde el sistema operativo (mediante mmap
llamada del sistema) o usando malloc
- asignador libc estándar. A su vez, malloc
solicita grandes porciones de memoria del sistema operativo usando mmap
y luego administra estos fragmentos de acuerdo con su propio algoritmo de asignación. El problema es que este algoritmo puede generar fragmentación y un uso excesivo de la memoria virtual.
jemalloc
, un asignador alternativo, a menudo parece más inteligente que la libc normal malloc
, así que cambia a jemalloc
puede resultar en una huella más pequeña de forma gratuita.
Conclusión
No existe una forma garantizada de estimar el uso total de la memoria de un proceso Java, porque hay demasiados factores a considerar.
Total memory = Heap + Code Cache + Metaspace + Symbol tables +
Other JVM structures + Thread stacks +
Direct buffers + Mapped files +
Native Libraries + Malloc overhead + ...
Es posible reducir o limitar ciertas áreas de memoria (como Code Cache) mediante indicadores de JVM, pero muchas otras están fuera del control de JVM.
Un posible enfoque para establecer los límites de Docker sería observar el uso real de la memoria en un estado "normal" del proceso. Existen herramientas y técnicas para investigar problemas con el consumo de memoria de Java:seguimiento de memoria nativa, pmap, jemalloc, async-profiler.
Actualizar
Aquí hay una grabación de mi presentación Huella de memoria de un proceso Java.
En este video, discuto lo que puede consumir memoria en un proceso de Java, cómo monitorear y restringir el tamaño de ciertas áreas de memoria y cómo perfilar las fugas de memoria nativa en una aplicación de Java.
https://developers.redhat.com/blog/2017/04/04/openjdk-and-containers/:
¿Por qué cuando especifico -Xmx=1g mi JVM usa más memoria que 1 gb de memoria?
Especificar -Xmx=1g le está diciendo a la JVM que asigne un montón de 1 gb. No le dice a la JVM que limite el uso total de su memoria a 1 gb. Hay tablas de tarjetas, cachés de código y todo tipo de otras estructuras de datos fuera del montón. El parámetro que utiliza para especificar el uso total de la memoria es-XX:MaxRAM. Tenga en cuenta que con -XX:MaxRam=500m su montón será de aproximadamente 250mb.
Java ve el tamaño de la memoria del host y no tiene conocimiento de las limitaciones de la memoria del contenedor. No crea presión de memoria, por lo que GC tampoco necesita liberar memoria usada. Espero XX:MaxRAM
le ayudará a reducir la huella de memoria. Eventualmente, puede modificar la configuración del GC (-XX:MinHeapFreeRatio
,-XX:MaxHeapFreeRatio
, ...)
Hay muchos tipos de métricas de memoria. Docker parece estar informando el tamaño de la memoria RSS, que puede ser diferente de la memoria "comprometida" informada por jcmd
(Las versiones anteriores de Docker informan RSS+caché como uso de memoria). Buena discusión y enlaces:Diferencia entre el tamaño del conjunto residente (RSS) y la memoria asignada total (NMT) de Java para una JVM que se ejecuta en un contenedor Docker
(RSS) la memoria también puede ser consumida por otras utilidades en el contenedor:shell, administrador de procesos, ... No sabemos qué más se está ejecutando en el contenedor y cómo se inician los procesos en el contenedor.
TL;DR
El uso detallado de la memoria lo proporcionan los detalles de seguimiento de memoria nativa (NMT) (principalmente metadatos de código y recolector de elementos no utilizados). Además de eso, el compilador y optimizador de Java C1/C2 consume la memoria que no se informa en el resumen.
La huella de memoria se puede reducir usando banderas JVM (pero hay impactos).
El dimensionamiento del contenedor Docker debe realizarse mediante pruebas con la carga esperada de la aplicación.
Detalle de cada componente
El espacio de clase compartido se puede deshabilitar dentro de un contenedor ya que las clases no serán compartidas por otro proceso JVM. Se puede utilizar la siguiente bandera. Eliminará el espacio de clase compartido (17 MB).
-Xshare:off
El recolector de basura serial tiene una huella de memoria mínima a costa de un tiempo de pausa más largo durante el procesamiento de recolección de basura (ver la comparación de Aleksey Shipilëv entre GC en una imagen). Se puede habilitar con la siguiente bandera. Puede ahorrar hasta el espacio utilizado en el GC (48 MB).
-XX:+UseSerialGC
El compilador C2 se puede deshabilitar con el siguiente indicador para reducir los datos de perfil utilizados para decidir si optimizar o no un método.
-XX:+TieredCompilation -XX:TieredStopAtLevel=1
El espacio del código se reduce en 20 MB. Además, la memoria fuera de JVM se reduce en 80 MB (diferencia entre el espacio NMT y el espacio RSS). El compilador de optimización C2 necesita 100 MB.
Los compiladores C1 y C2 se puede deshabilitar con la siguiente bandera.
-Xint
La memoria fuera de la JVM ahora es menor que el espacio comprometido total. El espacio de código se reduce en 43 MB. Tenga cuidado, esto tiene un gran impacto en el rendimiento de la aplicación. Deshabilitar el compilador C1 y C2 reduce la memoria utilizada en 170 MB.
Uso del compilador Graal VM (reemplazo de C2) conduce a una huella de memoria un poco más pequeña. Aumenta en 20 MB el espacio de memoria de código y disminuye en 60 MB desde fuera de la memoria JVM.
El artículo Java Memory Management for JVM proporciona información relevante sobre los diferentes espacios de memoria. Oracle proporciona algunos detalles en la documentación de Native Memory Tracking. Más detalles sobre el nivel de compilación en la política de compilación avanzada y en la desactivación de C2 reducen el tamaño del caché de código en un factor de 5. Algunos detalles sobre ¿Por qué una JVM informa más memoria comprometida que el tamaño del conjunto residente del proceso de Linux? cuando ambos compiladores están deshabilitados.