GNU/Linux >> Tutoriels Linux >  >> Linux

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

La mémoire virtuelle utilisée par un processus Java s'étend bien au-delà de Java Heap. Vous savez, JVM comprend de nombreux sous-systèmes :Garbage Collector, Class Loading, compilateurs JIT, 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 la mémoire hors tas au moyen de ByteBuffers directs.

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

Parties JVM (principalement affichées par le suivi de la mémoire native)

  1. Tas Java

La partie la plus évidente. C'est là que vivent les objets Java. Le tas prend jusqu'à -Xmx quantité de mémoire.

  1. Garbage Collector

Les structures et les algorithmes GC nécessitent de la 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 les références inter-régions) et autres. Certains d'entre eux sont directement réglables, par ex. -XX:MarkStackSizeMax , d'autres dépendent de la disposition du tas, par ex. les plus grandes sont les régions G1 (-XX:G1HeapRegionSize ), plus les ensembles mémorisés sont petits.

La surcharge de mémoire GC varie entre les algorithmes GC. -XX:+UseSerialGC et -XX:+UseShenandoahGC ont le moins de frais généraux. G1 ou CMS peuvent facilement utiliser environ 10 % de la taille totale du tas.

  1. Cache de code

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

  1. Compilateur

Le compilateur JIT lui-même nécessite également de la mémoire pour faire son travail. Cela peut être réduit à nouveau en désactivant la compilation hiérarchisée ou en réduisant le nombre de threads du compilateur :-XX:CICompilerCount .

  1. 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 le métaspace est utilisé. L'utilisation totale peut être limitée à -XX:MaxMetaspaceSize (illimité par défaut) et -XX:CompressedClassSpaceSize (1G par défaut).

  1. Tableaux des symboles

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

  1. 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 1M par thread, mais heureusement les choses ne vont pas si mal. 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 quantité 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 grand rôle dans la consommation totale de mémoire.

Tampons directs

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

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

Outre les ByteBuffers directs, il peut y avoir MappedByteBuffers - les fichiers mappés sur 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 la quantité qu'ils peuvent prendre. Vous pouvez simplement voir l'utilisation réelle en regardant la carte de mémoire de 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

Code JNI chargé par System.loadLibrary peut allouer autant de mémoire hors tas qu'il le souhaite sans 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 ZipInputStream ou DirectoryStream .

Agents JVMTI, en particulier jdwp agent de débogage - peut é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'allocation

Un processus demande généralement de la mémoire native soit directement à partir du système d'exploitation (par mmap appel système) ou en utilisant malloc - l'allocateur libc standard. À son tour, malloc demande de gros morceaux de mémoire au système d'exploitation en utilisant mmap , puis gère ces blocs selon 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 alternateur alternatif, apparaît souvent plus intelligent que la libc normale malloc , donc passer à jemalloc peut entraîner une empreinte plus petite gratuitement.

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 drapeaux JVM, mais beaucoup d'autres sont hors du contrôle de JVM.

Une approche possible pour définir les limites de Docker consisterait à surveiller l'utilisation réelle de la mémoire dans un état "normal" du processus. Il existe des outils et des techniques pour enquêter sur 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 Memory Footprint of a Java Process.

Dans cette vidéo, j'aborde 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.


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

Pourquoi est-ce que 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 y a 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 n'a connaissance d'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 de la mémoire utilisée. J'espère XX:MaxRAM vous aidera à réduire l'empreinte mémoire. Finalement, vous pouvez modifier la configuration du 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" signalée par jcmd (les anciennes versions de Docker signalent RSS + cache comme utilisation de la mémoire)

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


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 de code et le ramasse-miettes). En plus de cela, le compilateur Java et l'optimiseur C1/C2 consomment la mémoire non rapportée dans le résumé.

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

Le dimensionnement du conteneur Docker doit être effectué en testant la charge attendue de l'application.

Détail pour chaque composant

L'espace de cours 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 éboueur serial a une empreinte mémoire minimale au prix d'un temps de pause plus long pendant le traitement de la récupération de place (voir la comparaison d'Aleksey Shipilëv entre GC dans une image). Il peut être activé avec le drapeau suivant. Il peut économiser jusqu'à l'espace GC utilisé (48 Mo).

-XX:+UseSerialGC

Le compilateur C2 peut être désactivé avec le drapeau 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 80Mo (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 peut être désactivé avec le drapeau 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 des compilateurs C1 et C2 réduit la mémoire utilisée de 170 Mo.

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 la mémoire externe de la JVM.

L'article Java Memory Management for JVM fournit quelques 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 politique de compilation avancée et dans la désactivation de 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.


Linux
  1. Linux - Pourquoi Linux affiche-t-il à la fois plus et moins de mémoire que ce que j'ai physiquement installé?

  2. Remplacer un mot ou un nombre dans une phrase inférieure à 8 en utilisant Awk ou Sed ?

  3. Pourquoi tout le monde utilise Docker ?

  4. Existe-t-il un moyen de définir la taille de la liste d'historique dans bash à plus de 5 000 lignes ?

  5. Vérifiez si la taille du fichier est supérieure à 1 Mo en utilisant la condition IF

Comment définir la mémoire Docker et la limite d'utilisation du processeur

Utilisation de la commande gratuite Linux

Comment vérifier la taille du tas pour un processus sous Linux

comment vérifier la taille du tas allouée pour jvm par linux

Une expérience Java sur Raspberry PI ?

Limite de mémoire et limite de processeur dans le conteneur Docker