Java usa mucha más memoria que el tamaño del montón (o el tamaño correcto del límite de memoria de Docker)

118

Para mi aplicación, la memoria utilizada por el proceso de Java es mucho mayor que el tamaño del montón.

El sistema donde se ejecutan los contenedores comienza a tener problemas de memoria porque el contenedor está ocupando mucha más memoria que el tamaño del montón.

El tamaño del montón se establece en 128 MB ( -Xmx128m -Xms128m) mientras que el contenedor ocupa hasta 1 GB de memoria. En condiciones normales, necesita 500 MB. Si el contenedor de la ventana acoplable tiene un límite por debajo (por ejemplo mem_limit=mem_limit=400MB), el proceso es eliminado por el asesino de memoria insuficiente del sistema operativo.

¿Podría explicar por qué el proceso de Java utiliza mucha más memoria que el montón? ¿Cómo dimensionar correctamente el límite de memoria de Docker? ¿Hay alguna forma de reducir la huella de memoria fuera del montón del proceso de Java?


Reúno algunos detalles sobre el problema usando el comando del seguimiento de la memoria nativa en JVM .

Desde el sistema host, obtengo la memoria utilizada por el contenedor.

$ docker stats --no-stream 9afcb62a26c8
CONTAINER ID        NAME                                                                                        CPU %               MEM USAGE / LIMIT   MEM %               NET I/O             BLOCK I/O           PIDS
9afcb62a26c8        xx-xxxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.0acbb46bb6fe3ae1b1c99aff3a6073bb7b7ecf85   0.93%               461MiB / 9.744GiB   4.62%               286MB / 7.92MB      157MB / 2.66GB      57

Desde el interior del contenedor, obtengo la memoria utilizada por el proceso.

$ ps -p 71 -o pcpu,rss,size,vsize
%CPU   RSS  SIZE    VSZ
11.2 486040 580860 3814600

$ jcmd 71 VM.native_memory
71:

Native Memory Tracking:

Total: reserved=1631932KB, committed=367400KB
-                 Java Heap (reserved=131072KB, committed=131072KB)
                            (mmap: reserved=131072KB, committed=131072KB) 

-                     Class (reserved=1120142KB, committed=79830KB)
                            (classes #15267)
                            (  instance classes #14230, array classes #1037)
                            (malloc=1934KB #32977) 
                            (mmap: reserved=1118208KB, committed=77896KB) 
                            (  Metadata:   )
                            (    reserved=69632KB, committed=68272KB)
                            (    used=66725KB)
                            (    free=1547KB)
                            (    waste=0KB =0.00%)
                            (  Class space:)
                            (    reserved=1048576KB, committed=9624KB)
                            (    used=8939KB)
                            (    free=685KB)
                            (    waste=0KB =0.00%)

-                    Thread (reserved=24786KB, committed=5294KB)
                            (thread #56)
                            (stack: reserved=24500KB, committed=5008KB)
                            (malloc=198KB #293) 
                            (arena=88KB #110)

-                      Code (reserved=250635KB, committed=45907KB)
                            (malloc=2947KB #13459) 
                            (mmap: reserved=247688KB, committed=42960KB) 

-                        GC (reserved=48091KB, committed=48091KB)
                            (malloc=10439KB #18634) 
                            (mmap: reserved=37652KB, committed=37652KB) 

-                  Compiler (reserved=358KB, committed=358KB)
                            (malloc=249KB #1450) 
                            (arena=109KB #5)

-                  Internal (reserved=1165KB, committed=1165KB)
                            (malloc=1125KB #3363) 
                            (mmap: reserved=40KB, committed=40KB) 

-                     Other (reserved=16696KB, committed=16696KB)
                            (malloc=16696KB #35) 

-                    Symbol (reserved=15277KB, committed=15277KB)
                            (malloc=13543KB #180850) 
                            (arena=1734KB #1)

-    Native Memory Tracking (reserved=4436KB, committed=4436KB)
                            (malloc=378KB #5359) 
                            (tracking overhead=4058KB)

-        Shared class space (reserved=17144KB, committed=17144KB)
                            (mmap: reserved=17144KB, committed=17144KB) 

-               Arena Chunk (reserved=1850KB, committed=1850KB)
                            (malloc=1850KB) 

-                   Logging (reserved=4KB, committed=4KB)
                            (malloc=4KB #179) 

-                 Arguments (reserved=19KB, committed=19KB)
                            (malloc=19KB #512) 

-                    Module (reserved=258KB, committed=258KB)
                            (malloc=258KB #2356) 

$ cat /proc/71/smaps | grep Rss | cut -d: -f2 | tr -d " " | cut -f1 -dk | sort -n | awk '{ sum += $1 } END { print sum }'
491080

La aplicación es un servidor web que utiliza Jetty / Jersey / CDI incluido en un ancho de 36 MB.

Se utilizan las siguientes versiones de SO y Java (dentro del contenedor). La imagen de Docker se basa en openjdk:11-jre-slim.

$ java -version
openjdk version "11" 2018-09-25
OpenJDK Runtime Environment (build 11+28-Debian-1)
OpenJDK 64-Bit Server VM (build 11+28-Debian-1, mixed mode, sharing)
$ uname -a
Linux service1 4.9.125-linuxkit #1 SMP Fri Sep 7 08:20:28 UTC 2018 x86_64 GNU/Linux

https://gist.github.com/prasanthj/48e7063cac88eb396bc9961fb3149b58

Nicolás Henneaux
fuente
6
El montón es donde se asignan los objetos, sin embargo, la JVM tiene muchas otras regiones de memoria que incluyen bibliotecas compartidas, búferes de memoria directa, pilas de subprocesos, componentes de GUI, metaespacio. Debe observar qué tan grande puede ser la JVM y hacer que el límite sea lo suficientemente alto como para que prefiera que el proceso muera antes que usar más.
Peter Lawrey
2
Parece que el GC está usando mucha memoria. En su lugar, podría intentar usar el recopilador de CMS. Parece que se utilizan ~ 125 MB para el código metaespacio +, sin embargo, sin reducir la base de su código, es poco probable que pueda hacerlo más pequeño. El espacio comprometido está cerca de su límite, por lo que no es sorprendente que lo maten.
Peter Lawrey
¿Dónde / cómo establece la configuración -Xms y -Xmx?
Mick
1
¿Su programa ejecuta muchas operaciones de archivo (por ejemplo, crea archivos en tamaño de gigabyte)? Si es así, debe saber que cgroupsagrega caché de disco a la memoria utilizada, incluso si es manejada por el kernel y es invisible para el programa de usuario. (Eso sí, comandos psy docker statsno cuentes el caché de disco.)
Lorinczy Zsigmond

Respuestas:

204

La memoria virtual utilizada por un proceso Java se extiende mucho más allá de Java Heap. Ya sabe, 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 Java estándar) también pueden asignar memoria nativa. Y esto ni siquiera será visible para el seguimiento de memoria nativa. La propia aplicación Java también puede usar memoria fuera del montón mediante ByteBuffers directos.

Entonces, ¿qué se necesita memoria en un proceso de Java?

Partes de JVM (mostradas principalmente por Native Memory Tracking)

  1. Montón de Java

    La parte más obvia. Aquí es donde viven los objetos Java. El montón ocupa una -Xmxcantidad máxima de memoria.

  2. 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 el gráfico de objetos), Conjuntos recordados (para registrar referencias entre regiones) y otras. Algunos de ellos se pueden sintonizar directamente, por ejemplo -XX:MarkStackSizeMax, otros dependen del diseño del montón, por ejemplo, cuanto más grandes son las regiones G1 ( -XX:G1HeapRegionSize), más pequeños se recuerdan los conjuntos.

    La sobrecarga de la memoria de GC varía entre los algoritmos de GC. -XX:+UseSerialGCy -XX:+UseShenandoahGCtener la sobrecarga más pequeña. G1 o CMS pueden usar fácilmente alrededor del 10% del tamaño total del montón.

  3. Caché de código

    Contiene código generado dinámicamente: métodos compilados JIT, intérprete y stubs en tiempo de ejecución. Su tamaño está limitado por -XX:ReservedCodeCacheSize(240M por defecto). Desactívelo -XX:-TieredCompilationpara reducir la cantidad de código compilado y, por lo tanto, el uso de la caché de código.

  4. Compilador

    El propio compilador JIT también requiere memoria para hacer su trabajo. Esto se puede reducir de nuevo desconectando gradas de compilación o reduciendo el número de hilos del compilador: -XX:CICompilerCount.

  5. Carga de clases

    Los metadatos de la 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 usa. El uso total puede estar limitado por -XX:MaxMetaspaceSize(ilimitado por defecto) y -XX:CompressedClassSpaceSize(1G por defecto).

  6. Tablas de símbolos

    Dos tablas hash principales de la 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 significativo de memoria por parte de una tabla de cadenas, probablemente significa que la aplicación llama en exceso String.intern.

  7. 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 1M por hilo, pero afortunadamente las cosas no están tan mal. El sistema operativo asigna las páginas de memoria de manera 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 la cantidad de RSS que pertenece a las pilas de subprocesos de Java.

    Hay otras partes de JVM que asignan memoria nativa, pero generalmente no juegan un papel importante en el consumo total de memoria.

Buffers directos

Una aplicación puede solicitar explícitamente memoria fuera del montón llamando ByteBuffer.allocateDirect. El límite predeterminado fuera del montón es igual a -Xmx, pero se puede anular con -XX:MaxDirectMemorySize. Los ByteBuffers directos se incluyen en la Othersección de salida NMT (o Internalantes de JDK 11).

La cantidad de memoria directa utilizada es visible a través de JMX, por ejemplo, en JConsole o Java Mission Control:

BufferPool MBean

Además de ByteBuffers directos, puede haber MappedByteBuffersarchivos asignados a la memoria virtual de un proceso. NMT no los rastrea, sin embargo, MappedByteBuffers también puede tomar memoria física. Y no existe una forma sencilla de limitar la cantidad que 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

El código JNI cargado por System.loadLibrarypuede asignar tanta memoria fuera del montón como desee sin control desde el lado de la JVM. Esto también se refiere a la biblioteca de clases estándar de Java. En particular, los recursos de Java no cerrados pueden convertirse en una fuente de pérdida de memoria nativa. Los ejemplos típicos son ZipInputStreamo DirectoryStream.

Los agentes JVMTI, en particular, el jdwpagente de depuración, también pueden causar un consumo excesivo de memoria.

Esta respuesta describe cómo perfilar las asignaciones de memoria nativa con async-profiler .

Problemas con el asignador

Por lo general, un proceso solicita memoria nativa directamente desde el sistema operativo (mediante una mmapllamada al sistema) o utilizando mallocun asignador libc estándar. A su vez, mallocsolicita grandes trozos de memoria del sistema operativo que utiliza mmapy luego administra estos trozos de acuerdo con su propio algoritmo de asignación. El problema es que este algoritmo puede provocar fragmentación y un uso excesivo de la memoria virtual .

jemalloc, un asignador alternativo, a menudo parece más inteligente que libc normal malloc, por lo que cambiar a jemallocpuede resultar en una huella más pequeña de forma gratuita.

Conclusión

No hay 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: Native Memory Tracking , pmap , jemalloc , async-profiler .

Actualizar

Aquí hay una grabación de mi presentación Huella de memoria de un proceso Java .

En este video, analizo 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 Java.

apangin
fuente
1
¿No están las cadenas internas en el montón desde jdk7? ( bugs.java.com/bugdatabase/view_bug.do?bug_id=6962931 ) - tal vez me equivoque.
j-keck
5
@ j-keck Los objetos String están en el montón, pero la tabla hash (los depósitos y las entradas con referencias y códigos hash) está en la memoria del montón. Reformulé la oración para ser más precisa. Gracias por señalar.
apangin
Para agregar a esto, incluso si usa ByteBuffers no directos, la JVM asignará búferes directos temporales en la memoria nativa sin límites de memoria impuestos. Cf. evanjones.ca/java-bytebuffer-leak.html
Cpt. Senkfuss
16

https://developers.redhat.com/blog/2017/04/04/openjdk-and-containers/ :

¿Por qué cuando especifico -Xmx = 1g, mi JVM usa más memoria que 1gb de memoria?

Especificar -Xmx = 1g le indica a la JVM que asigne un montón de 1 GB. No le está diciendo a la JVM que limite todo el uso de 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 usa para especificar el uso total de 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 en la memoria, por lo que GC tampoco necesita liberar la memoria usada. Espero que XX:MaxRAMle ayude a reducir la huella de memoria. Eventualmente, se puede ajustar la configuración de 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 vínculos: diferencia entre tamaño de conjunto residente (RSS) y memoria total comprometida (NMT) de Java para una JVM que se ejecuta en un contenedor Docker

La memoria (RSS) 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.

Jan Garaj
fuente
De hecho, es mejor con -XX:MaxRam. Creo que todavía está usando más del máximo definido pero es mejor, ¡gracias!
Nicolas Henneaux
Tal vez realmente necesite más memoria para esta instancia de Java. Hay 15267 clases, 56 hilos.
Jan Garaj
1
Aquí hay más detalles, argumentos de Java -Xmx128m -Xms128m -Xss228k -XX:MaxRAM=256m -XX:+UseSerialGC, produce Docker 428.5MiB / 600MiBy jcmd 58 VM.native_memory -> Native Memory Tracking: Total: reserved=1571296KB, committed=314316KB. JVM ocupa alrededor de 300 MB, mientras que el contenedor necesita 430 MB. ¿Dónde están los 130 MB entre los informes de JVM y los informes del sistema operativo?
Nicolas Henneaux
1
Se agregó información / enlace sobre la memoria RSS.
Jan Garaj
El RSS proporcionado es del interior del contenedor para el proceso de Java solo ps -p 71 -o pcpu,rss,size,vsizecon el proceso de Java que tiene pid 71. En realidad -XX:MaxRamno estaba ayudando, pero el enlace que ha proporcionado ayuda con el GC en serie.
Nicolas Henneaux
8

TL; DR

El uso detallado de la memoria lo proporcionan los detalles de Native Memory Tracking (NMT) (principalmente metadatos de código y recolector de basura). Además de eso, el compilador y optimizador de Java C1 / C2 consumen la memoria que no se informa en el resumen.

La huella de memoria se puede reducir utilizando indicadores JVM (pero hay impactos).

El tamaño del contenedor Docker debe realizarse mediante pruebas con la carga esperada de la aplicación.


Detalle de cada componente

El espacio de clases compartido se puede deshabilitar dentro de un contenedor ya que las clases no serán compartidas por otro proceso de JVM. Se puede utilizar la siguiente bandera. Eliminará el espacio de clase compartido (17 MB).

-Xshare:off

La serie del recolector de basura tiene una huella de memoria mínima a costa de un mayor tiempo de pausa durante el procesamiento de recolección de basura (consulte la comparación de Aleksey Shipilëv entre GC en una imagen ). Se puede habilitar con la siguiente bandera. Puede ahorrar hasta el espacio GC utilizado (48 MB).

-XX:+UseSerialGC

El compilador C2 se puede deshabilitar con la siguiente marca para reducir los datos de generación de perfiles utilizados para decidir si optimizar o no un método.

-XX:+TieredCompilation -XX:TieredStopAtLevel=1

El espacio de 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 pueden deshabilitar con la siguiente bandera.

-Xint

La memoria fuera de la JVM ahora es menor que el espacio total comprometido. El espacio de código se reduce en 43 MB. Tenga cuidado, esto tiene un gran impacto en el rendimiento de la aplicación. La desactivación del compilador C1 y C2 reduce la memoria utilizada en 170 MB.

Usando el compilador de VM de Graal (reemplazo de C2) conduce a una huella de memoria un poco menor. Aumenta 20 MB el espacio de memoria del código y disminuye 60 MB desde fuera de la memoria JVM.

El artículo Java Memory Management para JVM proporciona información relevante sobre los diferentes espacios de memoria. Oracle proporciona algunos detalles en la documentación de seguimiento de memoria nativa . 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 de la 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 desactivados.

Nicolás Henneaux
fuente
-1

Java necesita mucha memoria. La propia JVM necesita mucha memoria para ejecutarse. El montón es la memoria que está disponible dentro de la máquina virtual, disponible para su aplicación. Debido a que JVM es un gran paquete repleto de todas las ventajas posibles, se necesita mucha memoria solo para cargar.

Comenzando con java 9, tiene algo llamado proyecto Jigsaw , que puede reducir la memoria utilizada cuando inicia una aplicación java (junto con la hora de inicio). Project Jigsaw y un nuevo sistema de módulos no se crearon necesariamente para reducir la memoria necesaria, pero si es importante, puede intentarlo.

Puede echar un vistazo a este ejemplo: https://steveperkins.com/using-java-9-modularization-to-ship-zero-dependency-native-apps/ . Al utilizar el sistema de módulos, se obtuvo una aplicación CLI de 21 MB (con JRE integrado). JRE toma más de 200mb. Eso debería traducirse en menos memoria asignada cuando la aplicación esté activa (ya no se cargarán muchas clases JRE no utilizadas).

Aquí hay otro buen tutorial: https://www.baeldung.com/project-jigsaw-java-modularity

Si no quiere perder tiempo con esto, simplemente puede asignar más memoria. A veces es lo mejor.

adiian
fuente
El uso jlinkes bastante restrictivo, ya que requería modular la aplicación. El módulo automático no es compatible, por lo que no hay una manera fácil de llegar allí.
Nicolas Henneaux
-1

¿Cómo dimensionar correctamente el límite de memoria de Docker? Verifique la aplicación monitoreándola durante algún tiempo. Para restringir la memoria del contenedor, intente usar la opción -m, --memory bytes para el comando docker run - o algo equivalente si lo está ejecutando de otra manera, como

docker run -d --name my-container --memory 500m <iamge-name>

no puedo responder a otras preguntas.

v_sukt
fuente