Java utilise beaucoup plus de mémoire que la taille du tas (ou taille correctement la limite de mémoire Docker)

119

Pour mon application, la mémoire utilisée par le processus Java est bien supérieure à la taille du tas.

Le système sur lequel les conteneurs sont exécutés commence à avoir des problèmes de mémoire car le conteneur utilise beaucoup plus de mémoire que la taille du tas.

La taille du tas est définie sur 128 Mo ( -Xmx128m -Xms128m) tandis que le conteneur occupe jusqu'à 1 Go de mémoire. Dans des conditions normales, il a besoin de 500 Mo. Si le conteneur docker a une limite inférieure (par exemple mem_limit=mem_limit=400MB), le processus est tué par le tueur de mémoire insuffisante du système d'exploitation.

Pouvez-vous expliquer pourquoi le processus Java utilise beaucoup plus de mémoire que le tas? Comment dimensionner correctement la limite de mémoire Docker? Existe-t-il un moyen de réduire l'empreinte mémoire hors tas du processus Java?


Je rassemble quelques détails sur le problème à l'aide de la commande du suivi de la mémoire native dans JVM .

Du système hôte, j'obtiens la mémoire utilisée par le conteneur.

$ 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

De l'intérieur du conteneur, j'obtiens la mémoire utilisée par le processus.

$ 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

L'application est un serveur Web utilisant Jetty / Jersey / CDI dans un gros lointain de 36 Mo.

La version suivante du système d'exploitation et de Java est utilisée (à l'intérieur du conteneur). L'image Docker est basée sur 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

Nicolas Henneaux
la source
6
Le tas est l'endroit où les objets sont alloués, mais la JVM a de nombreuses autres régions de mémoire, notamment des bibliothèques partagées, des tampons de mémoire directs, des piles de threads, des composants d'interface graphique, une méta-espace. Vous devez examiner la taille de la JVM et rendre la limite suffisamment élevée pour que vous préfériez que le processus meure plutôt que d'en utiliser davantage.
Peter Lawrey
2
Il semble que le GC utilise beaucoup de mémoire. Vous pouvez essayer d'utiliser le collecteur CMS à la place. Il semble qu'environ 125 Mo sont utilisés pour le code metaspace +, mais sans réduire votre base de code, il est peu probable que vous puissiez la réduire. L'espace engagé est proche de votre limite, il n'est donc pas surprenant qu'il soit tué.
Peter Lawrey
où / comment définissez-vous la configuration -Xms et -Xmx?
Mick
1
Le programme exécute-t-il de nombreuses opérations sur les fichiers (par exemple, crée des fichiers en gigaoctet)? Si tel est le cas, sachez que cela cgroupsajoute un cache disque à la mémoire utilisée - même s'il est géré par le noyau et qu'il est invisible pour le programme utilisateur. (Attention, commandes pset docker statsne comptez pas le cache disque.)
Lorinczy Zsigmond

Réponses:

207

La mémoire virtuelle utilisée par un processus Java va bien au-delà du simple Java Heap. Vous savez, JVM comprend de nombreux sous-systèmes: Garbage Collector, Class Loading, JIT compilers etc., et tous ces sous-systèmes nécessitent une certaine quantité de RAM pour fonctionner.

JVM n'est pas le seul consommateur de RAM. Les bibliothèques natives (y compris la bibliothèque de classes Java standard) peuvent également allouer de la mémoire native. Et cela ne sera même pas visible pour le suivi de la mémoire native. L'application Java elle-même peut également utiliser de la mémoire hors tas au moyen de ByteBuffers directs.

Alors, qu'est-ce qui prend de la mémoire dans un processus Java?

Pièces JVM (principalement affichées par Native Memory Tracking)

  1. Tas Java

    La partie la plus évidente. C'est là que vivent les objets Java. Heap prend jusqu'à la -Xmxquantité de mémoire.

  2. Éboueur

    Les structures et algorithmes GC nécessitent une mémoire supplémentaire pour la gestion du tas. Ces structures sont Mark Bitmap, Mark Stack (pour parcourir le graphe d'objets), Remembered Sets (pour enregistrer des références inter-régions) et autres. Certains d'entre eux sont directement réglables, par exemple -XX:MarkStackSizeMax, d'autres dépendent de la disposition du tas, par exemple plus les régions G1 sont grandes ( -XX:G1HeapRegionSize), plus les ensembles sont mémorisés plus petits.

    La surcharge de mémoire GC varie selon les algorithmes GC. -XX:+UseSerialGCet -XX:+UseShenandoahGCont les plus petits frais généraux. G1 ou CMS peuvent facilement utiliser environ 10% de la taille totale du tas.

  3. Cache de code

    Contient du code généré dynamiquement: méthodes compilées par JIT, interpréteur et stubs d'exécution. Sa taille est limitée par -XX:ReservedCodeCacheSize(240M par défaut). Désactivez -XX:-TieredCompilationpour réduire la quantité de code compilé et donc l'utilisation du cache de code.

  4. Compilateur

    Le compilateur JIT lui-même a également besoin de mémoire pour faire son travail. Ceci peut être réduit à nouveau en coupant Tiered Compilation ou en réduisant le nombre de fils de compilateur: -XX:CICompilerCount.

  5. Chargement de classe

    Les métadonnées de classe (bytecodes de méthode, symboles, pools de constantes, annotations, etc.) sont stockées dans une zone hors tas appelée Metaspace. Plus il y a de classes chargées - plus la métaspace est utilisée. L'utilisation totale peut être limitée par -XX:MaxMetaspaceSize(illimité par défaut) et -XX:CompressedClassSpaceSize(1G par défaut).

  6. Tables de symboles

    Deux principales tables de hachage de la JVM: la table Symbol contient des noms, des signatures, des identifiants, etc. et la table String contient des références à des chaînes internées. Si le suivi de la mémoire native indique une utilisation importante de la mémoire par une table String, cela signifie probablement que l'application appelle excessivement String.intern.

  7. Fils

    Les piles de threads sont également responsables de la prise de RAM. La taille de la pile est contrôlée par -Xss. La valeur par défaut est de 1M par thread, mais heureusement, les choses ne sont pas si mauvaises. Le système d'exploitation alloue les pages de mémoire paresseusement, c'est-à-dire lors de la première utilisation, de sorte que l'utilisation réelle de la mémoire sera beaucoup plus faible (généralement 80-200 Ko par pile de threads). J'ai écrit un script pour estimer la part de RSS appartenant aux piles de threads Java.

    Il existe d'autres composants JVM qui allouent de la mémoire native, mais ils ne jouent généralement pas un rôle important dans la consommation totale de mémoire.

Tampons directs

Une application peut demander explicitement de la mémoire hors tas en appelant ByteBuffer.allocateDirect. La limite par défaut hors tas est égale à -Xmx, mais elle peut être remplacée par -XX:MaxDirectMemorySize. Les ByteBuffers directs sont inclus dans la Othersection de sortie NMT (ou Internalavant JDK 11).

La quantité de mémoire directe utilisée est visible via JMX, par exemple dans JConsole ou Java Mission Control:

BufferPool MBean

En plus des ByteBuffers directs, il peut y avoir MappedByteBuffers- les fichiers mappés à la mémoire virtuelle d'un processus. NMT ne les suit pas, cependant, MappedByteBuffers peut également prendre de la mémoire physique. Et il n'y a pas de moyen simple de limiter ce qu'ils peuvent prendre. Vous pouvez simplement voir l'utilisation réelle en regardant la carte mémoire du processus: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
                           ^^^^^               ^^^^^^^^^^^^^^^^^^^^^^^^

Bibliothèques natives

Le code JNI chargé par System.loadLibrarypeut allouer autant de mémoire hors tas qu'il le souhaite sans aucun contrôle du côté JVM. Cela concerne également la bibliothèque de classes Java standard. En particulier, les ressources Java non fermées peuvent devenir une source de fuite de mémoire native. Des exemples typiques sont ZipInputStreamou DirectoryStream.

Les agents JVMTI, en particulier l' jdwpagent de débogage, peuvent également entraîner une consommation excessive de mémoire.

Cette réponse décrit comment profiler les allocations de mémoire natives avec async-profiler .

Problèmes d'allocateur

Un processus demande généralement de la mémoire native soit directement du système d'exploitation (par mmapappel système), soit en utilisant l' mallocallocateur libc standard. À son tour, mallocdemande de gros morceaux de mémoire à partir du système d'exploitation mmap, puis gère ces morceaux en fonction de son propre algorithme d'allocation. Le problème est que cet algorithme peut entraîner une fragmentation et une utilisation excessive de la mémoire virtuelle .

jemalloc, un allocateur alternatif, semble souvent plus intelligent que la libc ordinaire malloc, donc le passage à jemallocpeut entraîner une plus petite empreinte gratuite.

Conclusion

Il n'existe aucun moyen garanti d'estimer l'utilisation totale de la mémoire d'un processus Java, car il y a trop de facteurs à prendre en compte.

Total memory = Heap + Code Cache + Metaspace + Symbol tables +
               Other JVM structures + Thread stacks +
               Direct buffers + Mapped files +
               Native Libraries + Malloc overhead + ...

Il est possible de réduire ou de limiter certaines zones de mémoire (comme le cache de code) par des indicateurs JVM, mais beaucoup d'autres ne sont pas du tout sous le contrôle de la JVM.

Une approche possible pour définir les limites de Docker serait de surveiller l'utilisation réelle de la mémoire dans un état «normal» du processus. Il existe des outils et des techniques pour étudier les problèmes de consommation de mémoire Java: Native Memory Tracking , pmap , jemalloc , async-profiler .

Mettre à jour

Voici un enregistrement de ma présentation Empreinte mémoire d'un processus Java .

Dans cette vidéo, je discute de ce qui peut consommer de la mémoire dans un processus Java, comment surveiller et limiter la taille de certaines zones de mémoire et comment profiler les fuites de mémoire native dans une application Java.

apangin
la source
1
Les chaînes internes ne sont-elles pas dans le tas depuis jdk7? ( bugs.java.com/bugdatabase/view_bug.do?bug_id=6962931 ) - peut-être que je me trompe.
j-keck
5
@ j-keck Les objets String sont dans le tas, mais la table de hachage (les buckets et les entrées avec références et codes de hachage) est en mémoire hors tas. J'ai reformulé la phrase pour être plus précise. Merci de l'avoir signalé.
apangin
Pour ajouter à cela, même si vous utilisez des ByteBuffers non directs, la JVM allouera des tampons directs temporaires dans la mémoire native sans aucune limite de mémoire imposée. Cf. evanjones.ca/java-bytebuffer-leak.html
Cpt. Senkfuss
17

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

Pourquoi est-ce lorsque je spécifie -Xmx = 1g ma JVM utilise plus de mémoire que 1 Go de mémoire?

Spécifier -Xmx = 1g indique à la JVM d'allouer un tas de 1 Go. Il ne dit pas à la JVM de limiter toute son utilisation de la mémoire à 1 Go. Il existe des tables de cartes, des caches de code et toutes sortes d'autres structures de données hors tas. Le paramètre que vous utilisez pour spécifier l'utilisation totale de la mémoire est -XX: MaxRAM. Sachez qu'avec -XX: MaxRam = 500m, votre tas sera d'environ 250 Mo.

Java voit la taille de la mémoire de l'hôte et ne connaît aucune limitation de la mémoire du conteneur. Cela ne crée pas de pression sur la mémoire, donc GC n'a pas non plus besoin de libérer la mémoire utilisée. J'espère que XX:MaxRAMcela vous aidera à réduire l'empreinte mémoire. Finalement, vous pouvez modifier la configuration GC ( -XX:MinHeapFreeRatio, -XX:MaxHeapFreeRatio, ...)


Il existe de nombreux types de métriques de mémoire. Docker semble signaler la taille de la mémoire RSS, qui peut être différente de la mémoire «engagée» rapportée par jcmd(les anciennes versions de Docker rapportent le cache RSS + comme utilisation de la mémoire). Bonne discussion et liens: Différence entre Resident Set Size (RSS) et la mémoire totale engagée Java (NMT) pour une JVM exécutée dans un conteneur Docker

La mémoire (RSS) peut également être consommée par d'autres utilitaires du conteneur - shell, gestionnaire de processus, ... Nous ne savons pas ce qui est en cours d'exécution dans le conteneur et comment démarrer les processus dans le conteneur.

Jan Garaj
la source
C'est en effet mieux avec -XX:MaxRam. Je pense qu'il utilise encore plus que le maximum défini mais c'est mieux, merci!
Nicolas Henneaux
Peut-être avez-vous vraiment besoin de plus de mémoire pour cette instance Java. Il y a 15267 classes, 56 threads.
Jan Garaj
1
Voici plus de détails, les arguments Java -Xmx128m -Xms128m -Xss228k -XX:MaxRAM=256m -XX:+UseSerialGC, produit Docker 428.5MiB / 600MiBet jcmd 58 VM.native_memory -> Native Memory Tracking: Total: reserved=1571296KB, committed=314316KB. JVM prend environ 300 Mo tandis que le conteneur a besoin de 430 Mo. Où sont les 130 Mo entre les rapports JVM et les rapports OS?
Nicolas Henneaux
1
Ajout d'informations / lien sur la mémoire RSS.
Jan Garaj
Le RSS fourni provient de l'intérieur du conteneur pour le processus Java uniquement ps -p 71 -o pcpu,rss,size,vsizeavec le processus Java ayant pid 71. En -XX:MaxRamfait, cela n'aidait pas, mais le lien que vous avez fourni aide avec le GC série.
Nicolas Henneaux
8

TL; DR

L'utilisation détaillée de la mémoire est fournie par les détails NMT (Native Memory Tracking) (principalement les métadonnées du code et le garbage collector). En plus de cela, le compilateur et l'optimiseur Java C1 / C2 consomment la mémoire non signalée dans le résumé.

L'encombrement mémoire peut être réduit à l'aide des indicateurs JVM (mais il y a des impacts).

Le dimensionnement du conteneur Docker doit être effectué via des tests avec la charge attendue de l'application.


Détail pour chaque composant

L' espace de classe partagé peut être désactivé à l'intérieur d'un conteneur car les classes ne seront pas partagées par un autre processus JVM. Le drapeau suivant peut être utilisé. Cela supprimera l'espace de classe partagé (17 Mo).

-Xshare:off

Le garbage collector série du a une empreinte mémoire minimale au prix d'un temps de pause plus long pendant le traitement du ramasse-miettes (voir la comparaison d'Aleksey Shipilëv entre GC sur une image ). Il peut être activé avec l'indicateur suivant. Il peut économiser jusqu'à l'espace GC utilisé (48 Mo).

-XX:+UseSerialGC

le compilateur C2 peut être désactivé avec l'indicateur suivant pour réduire les données de profilage utilisées pour décider d'optimiser ou non une méthode.

-XX:+TieredCompilation -XX:TieredStopAtLevel=1

L'espace de code est réduit de 20 Mo. De plus, la mémoire hors JVM est réduite de 80 Mo (différence entre l'espace NMT et l'espace RSS). Le compilateur d'optimisation C2 a besoin de 100 Mo.

Les compilateurs C1 et C2 peuvent être désactivés avec l'indicateur suivant.

-Xint

La mémoire en dehors de la JVM est maintenant inférieure à l'espace total engagé. L'espace de code est réduit de 43 Mo. Attention, cela a un impact majeur sur les performances de l'application. La désactivation du compilateur C1 et C2 réduit la mémoire utilisée de 170 Mo.

L'utilisation du compilateur Graal VM (remplacement de C2) conduit à une empreinte mémoire un peu plus petite. Il augmente de 20 Mo l'espace mémoire du code et diminue de 60 Mo de la mémoire JVM extérieure.

L'article Gestion de la mémoire Java pour JVM fournit des informations pertinentes sur les différents espaces mémoire. Oracle fournit quelques détails dans la documentation Native Memory Tracking . Plus de détails sur le niveau de compilation dans la stratégie de compilation avancée et dans disable C2 réduisent la taille du cache de code d'un facteur 5 . Quelques détails sur Pourquoi une JVM signale-t-elle plus de mémoire engagée que la taille de l'ensemble résident du processus Linux? lorsque les deux compilateurs sont désactivés.

Nicolas Henneaux
la source
-1

Java a besoin de beaucoup de mémoire. La JVM elle-même a besoin de beaucoup de mémoire pour fonctionner. Le tas est la mémoire disponible à l'intérieur de la machine virtuelle, disponible pour votre application. Parce que JVM est un gros paquet contenant tous les bonus possibles, il faut beaucoup de mémoire juste pour le charger.

À partir de java 9, vous avez quelque chose appelé project Jigsaw , qui peut réduire la mémoire utilisée lorsque vous démarrez une application java (avec l'heure de début). Le puzzle de projet et un nouveau système de modules n'ont pas nécessairement été créés pour réduire la mémoire nécessaire, mais si c'est important, vous pouvez essayer.

Vous pouvez consulter cet exemple: https://steveperkins.com/using-java-9-modularization-to-ship-zero-dependency-native-apps/ . En utilisant le système de modules, il en a résulté une application CLI de 21 Mo (avec JRE intégré). JRE prend plus de 200 Mo. Cela devrait se traduire par moins de mémoire allouée lorsque l'application est active (un grand nombre de classes JRE inutilisées ne seront plus chargées).

Voici un autre tutoriel sympa: https://www.baeldung.com/project-jigsaw-java-modularity

Si vous ne voulez pas passer du temps avec cela, vous pouvez simplement allouer plus de mémoire. Parfois c'est le meilleur.

adiien
la source
L'utilisation jlinkest assez contraignante car elle nécessitait une modularisation de l'application. Le module automatique n'est pas pris en charge, il n'y a donc pas de moyen facile d'y aller.
Nicolas Henneaux
-1

Comment dimensionner correctement la limite de mémoire Docker? Vérifiez l'application en la surveillant pendant un certain temps. Pour limiter la mémoire du conteneur, essayez d'utiliser l'option -m, --memory bytes pour la commande docker run - ou quelque chose d'équivalent si vous l'exécutez autrement comme

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

ne peut pas répondre à d'autres questions.

v_sukt
la source