Introduction
Bien que JavaScript utilise la récupération de mémoire pour la gestion automatique de la mémoire, il ne remplace pas une gestion efficace de la mémoire dans les applications. Les applications JavaScript souffrent des mêmes problèmes de mémoire que les applications natives, tels que les fuites de mémoire et l'encombrement, mais elles doivent également gérer les pauses de récupération de mémoire. Les applications à grande échelle comme Gmail rencontrent les mêmes problèmes que vos applications plus petites. Lisez la suite pour découvrir comment l'équipe Gmail a utilisé les outils pour les développeurs Chrome pour identifier, isoler et résoudre ses problèmes de mémoire.
Session Google I/O 2013
Nous avons présenté ce contenu lors de Google I/O 2013. Regardez la vidéo ci-dessous:
Gmail, il y a un problème…
L'équipe Gmail était confrontée à un problème grave. Des anecdotes de plus en plus fréquentes font état d'onglets Gmail consommant plusieurs gigaoctets de mémoire sur des ordinateurs portables et des ordinateurs de bureau aux ressources limitées, ce qui entraîne souvent l'arrêt complet du navigateur. Des histoires de CPU à 100%, d'applications qui ne répondent pas et d'onglets Chrome tristes ("Il est mort, Jim"). L'équipe ne savait pas par où commencer pour diagnostiquer le problème, et encore moins pour le résoudre. Il n'avait aucune idée de l'ampleur du problème, et les outils disponibles ne permettaient pas de gérer de grandes applications. L'équipe s'est associée aux équipes Chrome pour développer de nouvelles techniques de tri des problèmes de mémoire, améliorer les outils existants et permettre la collecte de données sur la mémoire sur le terrain. Mais avant d'examiner les outils, examinons les principes de base de la gestion de la mémoire JavaScript.
Principes de base de la gestion de la mémoire
Avant de pouvoir gérer efficacement la mémoire en JavaScript, vous devez comprendre les principes de base. Cette section présente les types primitifs, le graphe d'objets, et définit l'encombrement de la mémoire en général et une fuite de mémoire en JavaScript. La mémoire en JavaScript peut être conceptualisée comme un graphique. C'est pourquoi la théorie des graphes joue un rôle dans la gestion de la mémoire JavaScript et dans le Profileur de tas de mémoire.
Types primitifs
JavaScript propose trois types primitifs:
- Nombre (par exemple, 4, 3,14159)
- Booléen (vrai ou faux)
- Chaîne ("Hello World")
Ces types primitifs ne peuvent pas faire référence à d'autres valeurs. Dans le graphique des objets, ces valeurs sont toujours des nœuds terminaux ou des feuilles, ce qui signifie qu'elles n'ont jamais d'arête sortante.
Il n'existe qu'un seul type de conteneur: l'objet. En JavaScript, l'objet est un tableau associatif. Un objet non vide est un nœud interne avec des arêtes sortantes vers d'autres valeurs (nœuds).
Qu'en est-il des tableaux ?
Un tableau en JavaScript est en fait un objet qui possède des clés numériques. Il s'agit d'une simplification, car les environnements d'exécution JavaScript optimisent les objets ressemblant à des tableaux et les représentent sous la forme de tableaux.
Terminologie
- Valeur : instance d'un type primitif, d'un objet, d'un tableau, etc.
- Variable : nom qui fait référence à une valeur.
- Propriété : nom d'un objet qui fait référence à une valeur.
Graphique des objets
Toutes les valeurs en JavaScript font partie du graphique des objets. Le graphique commence par des racines, par exemple l'objet fenêtre. Vous ne pouvez pas gérer la durée de vie des racines GC, car elles sont créées par le navigateur et détruites lorsque la page est désinstallée. Les variables globales sont en fait des propriétés de la fenêtre.
Quand une valeur devient-elle de la "poubelle" ?
Une valeur devient un garbage lorsqu'il n'existe aucun chemin d'accès à partir d'une racine. En d'autres termes, en commençant par les racines et en recherchant de manière exhaustive toutes les propriétés et variables d'objets actives dans le cadre de pile, une valeur ne peut pas être atteinte, elle est devenue des déchets.
Qu'est-ce qu'une fuite de mémoire en JavaScript ?
Une fuite de mémoire en JavaScript se produit le plus souvent lorsque des nœuds DOM ne sont pas accessibles à partir de l'arborescence DOM de la page, mais qu'ils sont toujours référencés par un objet JavaScript. Bien que les navigateurs modernes rendent de plus en plus difficile la création de fuites par inadvertance, cela reste plus facile que ce que l'on pourrait penser. Supposons que vous ajoutiez un élément à l'arborescence DOM comme suit:
email.message = document.createElement("div");
displayList.appendChild(email.message);
Vous supprimez ensuite l'élément de la liste d'affichage:
displayList.removeAllChildren();
Tant que email
existe, l'élément DOM référencé par le message ne sera pas supprimé, même s'il est désormais dissocié de l'arborescence DOM de la page.
Qu'est-ce que l'encombrement ?
Votre page est trop volumineuse lorsque vous utilisez plus de mémoire que nécessaire pour une vitesse de chargement optimale. Les fuites de mémoire entraînent également indirectement un gonflement, mais ce n'est pas intentionnel. Un cache d'application sans limite de taille est une source courante d'encombrement de la mémoire. De plus, votre page peut être encombrée par des données hôtes, par exemple des données de pixel chargées à partir d'images.
Qu'est-ce que la récupération de mémoire ?
La récupération de mémoire est la méthode utilisée pour récupérer de la mémoire dans JavaScript. C'est le navigateur qui décide de ce moment. Lors d'une collecte, toute l'exécution de script sur votre page est suspendue, tandis que les valeurs en direct sont découvertes par une traversée du graphique des objets à partir des racines du GC. Toutes les valeurs qui ne sont pas accessibles sont classées comme déchets. La mémoire pour les valeurs de garbage est récupérée par le gestionnaire de mémoire.
Présentation détaillée du garbage collector V8
Pour mieux comprendre comment se déroule la récupération de mémoire, examinons en détail le garbage collector V8. V8 utilise un collecteur générationnel. La mémoire est divisée en deux générations: les jeunes et les anciens. L'allocation et la collecte dans la jeune génération sont rapides et fréquentes. L'allocation et la collecte dans l'ancienne génération sont plus lentes et moins fréquentes.
Collecteur générationnel
V8 utilise un collecteur à deux générations. L'âge d'une valeur est défini comme le nombre d'octets alloués depuis son allocation. En pratique, l'âge d'une valeur est souvent approximativement déterminé par le nombre de collections de la jeune génération auxquelles elle a survécu. Une fois qu'une valeur est suffisamment ancienne, elle est conservée dans l'ancienne génération.
En pratique, les valeurs fraîchement allouées ne durent pas longtemps. Une étude des programmes Smalltalk a montré que seuls 7% des valeurs survivent après une collecte de la jeune génération. Des études similaires menées sur différents environnements d'exécution ont révélé qu'en moyenne, entre 90% et 70% des valeurs nouvellement allouées ne sont jamais conservées dans l'ancienne génération.
Young Generation
La pile de la jeune génération dans V8 est divisée en deux espaces, nommés "from" et "to". La mémoire est allouée à partir de l'espace de stockage. L'allocation est très rapide, jusqu'à ce que l'espace soit saturé, auquel cas une collecte de la jeune génération est déclenchée. La collection de la jeune génération échange d'abord l'espace "de" et l'espace "à", l'ancien espace "à" (désormais espace "de") est analysé et toutes les valeurs actives sont copiées dans l'espace "à" ou conservées dans l'ancienne génération. Une collection de la jeune génération prend généralement 10 millisecondes (ms).
Intuitif, vous devez comprendre que chaque allocation effectuée par votre application vous rapproche de l'épuisement de l'espace et entraîne une pause GC. Attention, développeurs de jeux: pour garantir un délai d'affichage de 16 ms (nécessaire pour atteindre 60 images par seconde), votre application ne doit effectuer aucune allocation, car une seule collection de la jeune génération occupe la majeure partie du délai d'affichage.
Ancienne génération
La pile de la génération précédente dans V8 utilise un algorithme de marquage-compactage pour la collecte. Les allocations de la génération ancienne se produisent chaque fois qu'une valeur est transférée de la génération jeune à la génération ancienne. Chaque fois qu'une collection de génération ancienne est effectuée, une collection de génération jeune est également effectuée. Votre application sera mise en veille en quelques secondes. En pratique, cela est acceptable, car les collections de l'ancienne génération sont peu fréquentes.
Résumé de la GC V8
La gestion automatique de la mémoire avec la récupération de mémoire est idéale pour la productivité des développeurs, mais chaque fois que vous allouez une valeur, vous vous rapprochez de la pause de récupération de mémoire. Les pauses de récupération de mémoire peuvent ruiner l'expérience utilisateur de votre application en introduisant des à-coups. Maintenant que vous savez comment JavaScript gère la mémoire, vous pouvez faire les bons choix pour votre application.
Résoudre les problèmes liés à Gmail
Au cours de l'année écoulée, de nombreuses fonctionnalités et corrections de bugs ont été intégrées aux outils pour les développeurs Chrome, ce qui les rend plus performants que jamais. De plus, le navigateur lui-même a apporté un changement important à l'API performance.memory, ce qui permet à Gmail et à toute autre application de collecter des statistiques sur la mémoire sur le terrain. Armé de ces outils formidables, ce qui semblait autrefois être une tâche impossible est rapidement devenu un jeu passionnant de recherche des coupables.
Outils et techniques
Données de champ et API performance.memory
Depuis Chrome 22, l'API performance.memory est activée par défaut. Pour les applications de longue durée comme Gmail, les données des utilisateurs réels sont inestimables. Ces informations nous permettent de distinguer les utilisateurs intensifs (ceux qui passent entre 8 et 16 heures par jour sur Gmail et reçoivent des centaines de messages par jour) des utilisateurs plus moyens (ceux qui passent quelques minutes par jour sur Gmail et reçoivent une douzaine de messages par semaine).
Cette API renvoie trois éléments de données:
- jsHeapSizeLimit : quantité de mémoire (en octets) à laquelle le tas de mémoire JavaScript est limité.
- totalJSHeapSize : quantité de mémoire (en octets) allouée par le tas JavaScript, y compris l'espace libre.
- usedJSHeapSize : quantité de mémoire (en octets) actuellement utilisée.
N'oubliez pas que l'API renvoie des valeurs de mémoire pour l'ensemble du processus Chrome. Bien que ce ne soit pas le mode par défaut, Chrome peut ouvrir plusieurs onglets dans le même processus de rendu dans certaines circonstances. Cela signifie que les valeurs renvoyées par performance.memory peuvent contenir l'empreinte mémoire d'autres onglets du navigateur en plus de celui contenant votre application.
Mesurer la mémoire à grande échelle
Gmail a instrumenté son code JavaScript pour utiliser l'API performance.memory afin de collecter des informations sur la mémoire environ toutes les 30 minutes. Étant donné que de nombreux utilisateurs de Gmail laissent l'application ouverte pendant des jours, l'équipe a pu suivre l'augmentation de la mémoire au fil du temps, ainsi que les statistiques globales sur l'espace mémoire utilisé. Quelques jours après avoir instrumenté Gmail pour collecter des informations sur la mémoire auprès d'un échantillon aléatoire d'utilisateurs, l'équipe disposait de suffisamment de données pour comprendre l'ampleur des problèmes de mémoire chez les utilisateurs moyens. Il a défini une référence et utilisé le flux de données entrantes pour suivre la progression vers l'objectif de réduction de la consommation de mémoire. À terme, ces données seront également utilisées pour détecter toute régression de la mémoire.
Au-delà des objectifs de suivi, les mesures sur le terrain fournissent également des informations précises sur la corrélation entre l'empreinte mémoire et les performances de l'application. Contrairement à l'idée reçue selon laquelle "plus la mémoire est importante, meilleures sont les performances", l'équipe Gmail a constaté que plus l'espace mémoire était important, plus les temps de latence étaient longs pour les actions Gmail courantes. Forts de cette révélation, ils étaient plus motivés que jamais à réduire leur consommation de mémoire.
Identifier un problème de mémoire avec la chronologie des outils de développement
La première étape pour résoudre un problème de performances consiste à prouver qu'il existe, à créer un test reproductible et à effectuer une mesure de référence du problème. Sans programme reproductible, vous ne pouvez pas mesurer le problème de manière fiable. Sans mesure de référence, vous ne savez pas dans quelle mesure vous avez amélioré vos performances.
Le panneau "Chronologie" des outils de développement est idéal pour prouver que le problème existe. Il offre un aperçu complet du temps passé lors du chargement et de l'interaction avec votre application ou page Web. Tous les événements, du chargement des ressources à l'analyse JavaScript, au calcul des styles, aux pauses de collecte des déchets et au redessin, sont représentés sur une chronologie. Pour examiner les problèmes de mémoire, le panneau "Chronologie" dispose également d'un mode "Mémoire" qui suit la mémoire allouée totale, le nombre de nœuds DOM, le nombre d'objets de fenêtre et le nombre d'écouteurs d'événements alloués.
Prouver qu'un problème existe
Commencez par identifier une séquence d'actions que vous soupçonnez de provoquer une fuite de mémoire. Commencez à enregistrer la chronologie et effectuez la séquence d'actions. Utilisez le bouton en forme de poubelle en bas pour forcer une récupération de mémoire complète. Si, après quelques itérations, vous voyez un graphique en forme de dent de scie, vous allouez de nombreux objets de courte durée. Toutefois, si la séquence d'actions ne doit pas entraîner de mémoire conservée et que le nombre de nœuds DOM ne revient pas à la valeur de référence à laquelle vous avez commencé, vous avez de bonnes raisons de soupçonner une fuite.
Une fois que vous avez confirmé que le problème existe, vous pouvez obtenir de l'aide pour identifier sa source à l'aide du profileur de tas DevTools.
Détecter les fuites de mémoire avec le Profileur de tas DevTools
Le panneau "Profiler" fournit à la fois un profileur de processeur et un profileur de tas de mémoire. Le profilage de tas consiste à prendre un instantané du graphe des objets. Avant qu'un instantané ne soit créé, les générations jeunes et anciennes sont collectées. En d'autres termes, vous ne verrez que les valeurs qui étaient actives au moment de la création de l'instantané.
Le profileur de tas comporte trop de fonctionnalités pour être suffisamment couvert dans cet article. Vous trouverez toutefois une documentation détaillée sur le site des développeurs Chrome. Nous allons ici nous concentrer sur le profileur d'allocation de tas de mémoire.
Utiliser le profileur d'allocation de tas de mémoire
Le profileur d'allocation de tas combine les informations détaillées sur les instantanés du profileur de tas avec la mise à jour et le suivi incrémentiels du panneau "Chronologie". Ouvrez le panneau "Profils", démarrez un profil Enregistrer les allocations de segments de mémoire, effectuez une séquence d'actions, puis arrêtez l'enregistrement pour l'analyser. Le profileur d'allocation prend des instantanés de tas régulièrement tout au long de l'enregistrement (aussi souvent que toutes les 50 ms) et un dernier instantané à la fin de l'enregistrement.
Les barres en haut indiquent quand de nouveaux objets sont détectés dans la pile. La hauteur de chaque barre correspond à la taille des objets récemment alloués, et la couleur des barres indique si ces objets sont toujours actifs dans l'instantané final de la pile: les barres bleues indiquent les objets qui sont toujours actifs à la fin de la chronologie, les barres grises indiquent les objets qui ont été alloués au cours de la chronologie, mais qui ont depuis été collectés.
Dans l'exemple ci-dessus, une action a été effectuée 10 fois. L'exemple de programme met en cache cinq objets. Les cinq dernières barres bleues sont donc attendues. Toutefois, la barre bleue la plus à gauche indique un problème potentiel. Vous pouvez ensuite utiliser les curseurs de la chronologie ci-dessus pour faire un zoom avant sur cet instantané particulier et afficher les objets qui ont été récemment alloués à ce moment-là. Cliquez sur un objet spécifique dans la pile pour afficher son arbre de rétention dans la partie inférieure de l'instantané de la pile. L'examen du chemin de rétention vers l'objet devrait vous fournir suffisamment d'informations pour comprendre pourquoi l'objet n'a pas été collecté. Vous pouvez ensuite apporter les modifications de code nécessaires pour supprimer la référence inutile.
Résoudre la crise de mémoire de Gmail
Grâce aux outils et aux techniques mentionnés ci-dessus, l'équipe Gmail a pu identifier plusieurs catégories de bugs: des caches illimités, des tableaux de rappels en croissance infinie qui attendent un événement qui ne se produit jamais et des écouteurs d'événements qui conservent involontairement leurs cibles. En corrigeant ces problèmes, l'utilisation globale de la mémoire de Gmail a été considérablement réduite. Les utilisateurs du 99e centile utilisaient 80 % moins de mémoire qu'auparavant, et la consommation de mémoire des utilisateurs médians a chuté de près de 50 %.
Étant donné que Gmail utilisait moins de mémoire, la latence de pause GC a été réduite, ce qui a amélioré l'expérience utilisateur globale.
Par ailleurs, l'équipe Gmail a pu détecter des régressions de collecte des déchets dans Chrome grâce à la collecte de statistiques sur l'utilisation de la mémoire. Plus précisément, deux bugs de fragmentation ont été découverts lorsque les données de mémoire de Gmail ont commencé à montrer une augmentation spectaculaire de l'écart entre la mémoire totale allouée et la mémoire active.
Incitation à l'action
Posez-vous les questions suivantes:
- Combien de mémoire mon application utilise-t-elle ? Il est possible que vous utilisiez trop de mémoire, ce qui, contrairement à la croyance populaire, a un impact négatif sur les performances globales de l'application. Il est difficile de déterminer exactement le nombre idéal, mais assurez-vous de vérifier que le cache supplémentaire utilisé par votre page a un impact mesurable sur les performances.
- Ma page ne présente-t-elle aucune fuite ? Si votre page présente des fuites de mémoire, cela peut avoir un impact non seulement sur ses performances, mais aussi sur celles des autres onglets. Utilisez le traceur d'objets pour identifier les fuites.
- À quelle fréquence ma page est-elle collectée ? Vous pouvez voir toute pause GC à l'aide du panneau "Timeline" dans les outils pour les développeurs Chrome. Si votre page effectue fréquemment une récupération de mémoire, il est probable que vous effectuiez des allocations trop fréquentes, ce qui épuise votre mémoire de jeune génération.
Conclusion
Nous avons commencé dans une situation de crise. Vous avez couvert les principes de base de la gestion de la mémoire en JavaScript et en V8 en particulier. Vous avez appris à utiliser les outils, y compris la nouvelle fonctionnalité de suivi des objets disponible dans les dernières versions de Chrome. Grâce à ces informations, l'équipe Gmail a pu résoudre son problème d'utilisation de la mémoire et améliorer ses performances. Vous pouvez faire de même avec vos applications Web.