Comment empêcher le dépassement de mémoire lors de l'utilisation de collections Java

Bonjour à tous!

Notre expérience avec les étapes du cours Java Developer se poursuit et, curieusement, même avec un certain succès (en quelque sorte): comme il s'est avéré, planifier un effet de levier de quelques mois avec la prochaine transition vers une nouvelle étape à tout moment opportun est beaucoup plus pratique que si Allouez près de six mois à un cours aussi difficile. On soupçonne donc que ce sont précisément les cours complexes que nous allons bientôt commencer à transférer lentement vers un tel système.

Mais c'est moi pour le nôtre, pour otusovsky, je suis désolé. Comme toujours, nous continuons à étudier des sujets intéressants qui, bien qu'ils ne soient pas abordés dans notre programme, mais qui sont discutés avec nous, nous avons donc préparé une traduction de l'article le plus intéressant à notre avis sur l'une des questions que nos professeurs ont posées.

C'est parti!



Les collections du JDK sont les implémentations de bibliothèque standard de listes et de cartes. Si vous regardez un instantané d'une grande application Java typique, vous verrez des milliers voire des millions d'instances de java.util.ArrayList , java.util.HashMap , etc. Les collections sont indispensables pour stocker et manipuler des données. Mais vous êtes-vous déjà demandé si toutes les collections de votre application font un usage optimal de la mémoire? En d'autres termes, si votre application se bloque avec la honteuse OutOfMemoryError ou provoque de longues pauses dans le garbage collector, avez-vous déjà vérifié les collections utilisées pour les fuites.

Tout d'abord, il convient de noter que les collections internes de JDK ne sont pas une sorte de magie. Ils sont écrits en Java. Leur code source est fourni avec le JDK, vous pouvez donc l'ouvrir dans votre IDE. Leur code peut également être facilement trouvé sur Internet. Et, il s'avère que la plupart des collections ne sont pas très élégantes en termes d'optimisation de la quantité de mémoire consommée.

Prenons, par exemple, l'une des collections les plus simples et les plus populaires - la classe java.util.ArrayList . En interne, chaque ArrayList fonctionne avec un tableau de Object[] elementData . C'est là que les éléments de la liste sont stockés. Voyons comment ce tableau est traité.

Lorsque vous créez un ArrayList avec le constructeur par défaut, c'est-à-dire appelez new ArrayList() , elementData pointe vers un tableau générique de taille zéro ( elementData peut également être défini sur null , mais le tableau offre quelques avantages mineurs d'implémentation). Lorsque vous ajoutez le premier élément à la liste, un véritable tableau unique de elementData et l'objet fourni y est inséré. Afin d'éviter de changer la taille du tableau à chaque fois, lors de l'ajout d'un nouvel élément, il est créé avec une longueur égale à 10 («capacité par défaut»). Il s'avère donc que si vous n'ajoutez plus d'éléments à ce ArrayList , 9 emplacements sur 10 dans le tableau elementData resteront vides. Et même si vous effacez la liste, la taille du tableau interne ne sera pas réduite. Voici un diagramme de ce cycle de vie:



Combien de mémoire est gaspillée ici? En termes absolus, elle est calculée comme (la taille du pointeur d'objet). Si vous utilisez le JVM HotSpot (fourni avec le JDK Oracle), la taille du pointeur dépendra de la taille de segment de mémoire maximale (pour plus de détails, voir https://blog.codecentric.de/en/2014/02/35gb-heap-less- 32 Go-java-jvm-mémoire-bizarreries / ). En règle générale, si vous spécifiez -Xmx moins de 32 gigaoctets, la taille du pointeur sera de 4 octets; pour les gros tas - 8 octets. Ainsi, une ArrayList , initialisée par le constructeur par défaut, avec l'ajout d'un seul élément, gaspille 36 ou 72 octets.

En fait, une ArrayList vide gaspille également de la mémoire car elle ne porte aucune charge de travail, mais la taille de la ArrayList elle-même n'est pas nulle et plus grande que vous ne le pensez probablement. En effet, d'une part, chaque objet géré par la JVM HotSpot possède un en-tête de 12 ou 16 octets, qui est utilisé par la JVM à des fins internes. De plus, la plupart des objets de la collection contiennent un champ de size , un pointeur vers un tableau interne ou un autre objet «workload media», un champ modCount pour suivre les changements de contenu, etc. Ainsi, même le plus petit objet possible représentant une collection vide aura probablement besoin d'au moins 32 octets de mémoire. Certains, comme ConcurrentHashMap , en prennent beaucoup plus.

Prenons une autre collection courante - la classe java.util.HashMap . Son cycle de vie est similaire au cycle de vie d' ArrayList :



Comme vous pouvez le voir, un HashMap contenant une seule paire clé-valeur dépense 15 cellules internes du tableau, ce qui correspond à 60 ou 120 octets. Ces nombres sont faibles, mais l'étendue de la perte de mémoire est importante pour toutes les collections de votre application. Et il s'avère que certaines applications peuvent dépenser beaucoup de mémoire de cette façon. Par exemple, certains des composants Hadoop open source populaires que l'auteur a analysés perdent environ 20% de leur tas dans certains cas! Pour les produits développés par des ingénieurs moins expérimentés qui ne sont pas soumis à des évaluations régulières des performances, la perte de mémoire peut être encore plus élevée. Il y a suffisamment de cas où, par exemple, 90% des nœuds dans une immense arborescence ne contiennent qu'un ou deux descendants (ou rien du tout), et d'autres situations où le tas est obstrué par des collections à 0, 1 ou 2 éléments.

Si vous trouvez des collections inutilisées ou sous-utilisées dans votre application, comment les corriger? Voici quelques recettes courantes. Ici, on suppose que notre collection problématique est une ArrayList référencée par le champ de données Foo.list .

Si la plupart des instances de la liste ne sont jamais utilisées, essayez de l'initialiser paresseusement. Donc, le code qui ressemblait auparavant ...

 void addToList(Object x) { list.add(x); } 

... devrait être refait en quelque chose comme

 void addToList(Object x) { getOrCreateList().add(x); } private list getOrCreateList() { //   ,         if (list == null) list = new ArrayList(); return list; } 

Gardez à l'esprit que, parfois, vous devrez prendre des mesures supplémentaires pour lutter contre la concurrence potentielle. Par exemple, si vous prenez en charge ConcurrentHashMap , qui peut être mis à jour simultanément par plusieurs threads, le code qui l'initialise ne doit pas autoriser deux threads à créer deux copies de cette carte de manière aléatoire:

 private Map getOrCreateMap() { if (map == null) { //,       synchronized (this) { if (map == null) map = new ConcurrentHashMap(); } } return map; } 

Si la plupart des instances de votre liste ou carte ne contiennent que quelques éléments, essayez de les initialiser avec une capacité initiale plus appropriée, par exemple.

 list = new ArrayList(4); //       4 

Si vos collections sont vides ou contiennent un seul élément (ou une paire clé-valeur) dans la plupart des cas, vous pouvez envisager une forme extrême d'optimisation. Cela ne fonctionne que si la collection est entièrement gérée dans la classe actuelle, c'est-à-dire qu'aucun autre code ne peut y accéder directement. L'idée est que vous modifiez le type de votre champ de données, par exemple, de Liste à un Objet plus général, de sorte qu'il puisse désormais pointer soit vers une vraie liste, soit directement vers un seul élément de liste. Voici un bref croquis:

 // ***   *** private List<Foo> list = new ArrayList<>(); void addToList(Foo foo) { list.add(foo); } // ***   *** //   ,    null.      , //      .       //   ArrayList. private Object listOrSingleEl; void addToList(Foo foo) { if (listOrSingleEl == null) { //   listOrSingleEl = foo; } else if (listOrSingleEl instanceof Foo) { //  Foo firstEl = (Foo) listOrSingleEl; ArrayList<Foo> list = new ArrayList<>(); listOrSingleEl = list; list.add(firstEl); list.add(foo); } else { //      ((ArrayList<Foo>) listOrSingleEl).add(foo); } } 

De toute évidence, le code avec cette optimisation est moins clair et plus difficile à maintenir. Mais cela peut être utile si vous êtes sûr que cela économisera beaucoup de mémoire ou supprimera les longues pauses du ramasse-miettes.

Vous vous êtes probablement déjà demandé: comment savoir quelles collections de mon application utilisent de la mémoire et combien?

En bref: il est difficile de le savoir sans les bons outils. Essayer de deviner la quantité de mémoire utilisée ou dépensée par les structures de données dans une grande application complexe ne mènera presque jamais à rien. Et, ne sachant pas exactement où va la mémoire, vous pouvez passer beaucoup de temps à poursuivre les mauvais objectifs, tandis que votre application continue obstinément à OutOfMemoryError avec OutOfMemoryError .

Par conséquent, vous devez vérifier un tas d'applications à l'aide d'un outil spécial. Par expérience, la façon la plus optimale d'analyser la mémoire JVM (mesurée en fonction de la quantité d'informations disponibles par rapport à l'effet de cet outil sur les performances des applications) est d'obtenir un vidage de tas et de le visualiser hors ligne. Un vidage de tas est essentiellement un instantané complet du tas. Vous pouvez l'obtenir à tout moment en appelant l'utilitaire jmap, ou vous pouvez configurer la machine OutOfMemoryError pour qu'elle se décharge automatiquement si l'application se bloque avec OutOfMemoryError . Si vous recherchez «vidage de tas JVM» sur Google, vous verrez immédiatement un grand nombre d'articles qui expliquent en détail comment obtenir un vidage.

Un vidage de tas est un fichier binaire de la taille d'un tas JVM, il ne peut donc être lu et analysé qu'à l'aide d'outils spéciaux. Il existe plusieurs outils, à la fois open source et commerciaux. L'outil open source le plus populaire est le MAT Eclipse; il existe également VisualVM et quelques outils moins puissants et moins connus. Les outils commerciaux comprennent des profileurs Java à usage général: JProfiler et YourKit, ainsi qu'un outil spécialement conçu pour l'analyse de vidage de tas - JXRay (avertissement: dernière mise au point par l'auteur).

Contrairement à d'autres outils, JXRay analyse immédiatement le vidage de tas pour un grand nombre de problèmes courants, tels que des lignes répétées et d'autres objets, ainsi que des structures de données insuffisamment efficaces. Les problèmes avec les collections décrites ci-dessus entrent dans cette dernière catégorie. L'outil génère un rapport avec toutes les informations collectées au format HTML. L'avantage de cette approche est que vous pouvez visualiser les résultats d'analyse n'importe où et à tout moment et les partager facilement avec d'autres. Vous pouvez également exécuter l'outil sur n'importe quelle machine, y compris les machines grandes et puissantes, mais «sans tête» dans le centre de données.

JXRay calcule la surcharge (combien de mémoire vous économiserez si vous vous débarrassez d'un problème particulier) en octets et en pourcentage du tas utilisé. Il combine des collections de la même classe qui ont le même problème ...



... puis regroupe les collections problématiques accessibles à partir d'une racine du ramasse-miettes via la même chaîne de liens, comme dans l'exemple ci-dessous



Savoir quelles chaînes de liens et / ou champs de données individuels (par exemple, INodeDirectory.children ci-dessus) indiquent que les collections qui dépensent la majeure partie de leur mémoire vous permet d'identifier rapidement et avec précision le code responsable du problème, puis d'apporter les modifications nécessaires.

Ainsi, des collections Java insuffisamment configurées peuvent gaspiller beaucoup de mémoire. Dans de nombreuses situations, ce problème est facile à résoudre, mais parfois vous devrez peut-être modifier votre code de manière non triviale pour obtenir une amélioration significative. Il est très difficile de deviner quelles collections doivent être optimisées afin d'avoir le plus grand impact. Afin de ne pas perdre de temps à optimiser les mauvaises parties du code, vous devez obtenir un vidage de tas JVM et l'analyser à l'aide de l'outil approprié.

LA FIN

Comme toujours, nous sommes intéressés par vos opinions et vos questions, que vous pouvez laisser ici ou laisser passer une leçon ouverte et demander à nos professeurs là-bas.

Source: https://habr.com/ru/post/fr420245/


All Articles