Retour au cours

frontend / javascript

Performance et mémoire : garbage collector, fuites

Leçon 191 exercice

Explication

Ce que vous allez apprendre

  • Expliquer le principe d'atteignabilité utilisé par le garbage collector
  • Identifier les trois causes les plus fréquentes de fuite mémoire en JavaScript
  • Nettoyer correctement un setInterval et un event listener pour éviter une fuite
  • Utiliser WeakMap/WeakSet pour associer des métadonnées sans empêcher la collecte
  • Relier la stabilité de structure d'un objet aux optimisations du moteur V8

Dans quel contexte ?

Une application single-page devient de plus en plus lente après plusieurs minutes d'utilisation, au point de devoir recharger la page. Le profiling mémoire des DevTools montre une courbe qui monte sans jamais redescendre : à chaque changement de vue, un composant attache un setInterval de rafraîchissement mais ne l'arrête jamais dans sa fonction de nettoyage. Chaque navigation accumule un nouveau timer actif, qui retient en mémoire toute une closure de données jamais libérée par le garbage collector.

1. Une mémoire libérée automatiquement

JavaScript libère automatiquement la mémoire des objets dont le programme n'a plus besoin, grâce à un mécanisme appelé garbage collector (GC). Contrairement au C, le développeur n'a jamais à libérer manuellement un bloc mémoire lui-même.

2. Mais les fuites restent possibles

Cette gestion automatique ne rend pas les fuites mémoire impossibles pour autant : elles restent l'une des sources de bugs les plus difficiles à diagnostiquer en production.

3. La règle que suit le garbage collector

Le principe est simple à énoncer : un objet devient éligible à la collecte dès qu'il n'est plus ATTEIGNABLE depuis les "racines" du programme (variables globales, pile d'appels, closures actives).

Prérequis

Comprendre les closures (leçon 2) est indispensable ici : une closure qui capture une donnée volumineuse sans en avoir vraiment besoin est l'une des causes les plus fréquentes de fuite mémoire en JavaScript.

4. Comment un objet reste "vivant" par erreur

Le problème, c'est que ces racines retiennent parfois un objet sans que ce soit intentionnel. Un timer jamais arrêté (setInterval), un listener jamais retiré, ou une closure qui capture une donnée volumineuse : autant de façons de garder un objet vivant indéfiniment aux yeux du GC.

Cause de fuiteExemple concretCorrection
Timer jamais arrêtésetInterval sans clearIntervalStocker l'id et appeler clearInterval au nettoyage
Listener jamais retiréaddEventListener sans removeEventListenerRetirer le listener quand le composant est détruit
Closure trop gourmandeFonction qui capture un objet volumineux inutilementNe capturer que les données réellement nécessaires

Piège fréquent

Chaque addEventListener devrait avoir son removeEventListener symétrique au bon moment (démontage d'un composant, fin d'utilisation d'un élément). Un listener orphelin retient tout ce qu'il capture en mémoire, même si l'élément DOM associé a été supprimé de la page.

5. Une solution élégante : les références faibles

WeakMap et WeakSet retiennent leurs clés SANS empêcher leur collecte, idéal pour associer des métadonnées à des objets sans risquer de les maintenir artificiellement en vie.

6. Un aperçu de ce qui vient plus tard

Cette leçon touche aussi aux "hidden classes" de V8, approfondies bien plus loin dans le cours (leçon 24) : garder une structure d'objet stable, avec les mêmes propriétés toujours dans le même ordre, aide le moteur à optimiser l'accès à vos données.

Commandes & code

Performance et mémoire : garbage collector, fuites

Comprendre comment V8 gère la mémoire, et éviter les fuites classiques.

js
// --- Le garbage collector de V8 : generationnel, avec Mark-and-Sweep ---
// Heap divise en "young generation" (Scavenger, GC frequent et rapide) et
// "old generation" (Mark-Compact, GC moins frequent mais plus couteux).
// Un objet qui survit a plusieurs cycles de jeune generation est "promu" en vieille generation.

// Un objet devient eligible au GC quand il n'est plus ACCESSIBLE depuis les racines
// (variables globales, pile d'appel, closures actives) -- pas juste "non utilise".

// --- Fuite memoire n°1 : variables globales accidentelles ---
function fuiteGlobale() {
    donneesOubliees = new Array(1_000_000).fill("x");    // pas de let/const : devient globale !
}
// "use strict" empeche ce piege en levant une erreur explicite

// --- Fuite memoire n°2 : timers/listeners jamais nettoyes ---
class ComposantAvecFuite {
    constructor() {
        this.donnees = new Array(100_000).fill("data");
        setInterval(() => {
            console.log(this.donnees.length);     // la closure retient "this" indefiniment
        }, 1000);
        // Le composant ne sera JAMAIS collecte tant que l'intervalle tourne, meme si "detruit"
    }
}

class ComposantSansFuite {
    #intervalId;
    constructor() {
        this.donnees = new Array(100_000).fill("data");
        this.#intervalId = setInterval(() => console.log(this.donnees.length), 1000);
    }
    detruire() {
        clearInterval(this.#intervalId);    // libere la reference explicitement
    }
}

// --- Fuite memoire n°3 : closures qui retiennent des objets volumineux inutilement ---
function creerGestionnaireEvenement() {
    const grosseDonnee = new Array(1_000_000).fill("x");     // gros objet
    const idNecessaire = grosseDonnee[0];                       // on n'a besoin que d'UNE valeur

    // MAUVAIS : la closure capture TOUTE la variable grosseDonnee, meme si seul idNecessaire compte
    return function () {
        console.log(grosseDonnee[0]);
    };
    // BON : n'extraire QUE ce qui est necessaire avant de retourner la closure
    // return function () { console.log(idNecessaire); };
}

// --- Fuite memoire n°4 : caches non bornes ---
const cacheNonBorne = new Map();
function memoiserSansLimite(cle, calcul) {
    if (!cacheNonBorne.has(cle)) {
        cacheNonBorne.set(cle, calcul());       // grandit indefiniment, jamais purge
    }
    return cacheNonBorne.get(cle);
}

// Solution : cache LRU borne, ou WeakMap si les cles sont des objets
class CacheLRU {
    #capacite;
    #cache = new Map();
    constructor(capacite) {
        this.#capacite = capacite;
    }
    obtenir(cle) {
        if (!this.#cache.has(cle)) return undefined;
        const valeur = this.#cache.get(cle);
        this.#cache.delete(cle);
        this.#cache.set(cle, valeur);       // remet en fin (le plus recemment utilise)
        return valeur;
    }
    definir(cle, valeur) {
        if (this.#cache.has(cle)) this.#cache.delete(cle);
        else if (this.#cache.size >= this.#capacite) {
            this.#cache.delete(this.#cache.keys().next().value);   // supprime le plus ancien
        }
        this.#cache.set(cle, valeur);
    }
}

// --- WeakMap/WeakSet : references qui n'empechent PAS le GC ---
const metadonneesParObjet = new WeakMap();     // les cles doivent etre des objets
function associerMetadonnees(objet, meta) {
    metadonneesParObjet.set(objet, meta);
    // si "objet" n'a plus d'autre reference ailleurs, il PEUT etre collecte,
    // et son entree dans la WeakMap disparait automatiquement -- pas de fuite
}

// --- Optimisations V8 : shapes / hidden classes (voir lecon internals pour le detail) ---
// Garder une structure d'objet STABLE (memes proprietes, meme ordre) permet a V8
// d'optimiser fortement l'acces aux proprietes.
function CreerPointOptimal(x, y) {
    this.x = x;      // toujours dans le meme ordre -> meme "shape" -> code optimise reutilise
    this.y = y;
}
function creerPointSousOptimal(x, y, ajouterZ) {
    const p = { x, y };
    if (ajouterZ) p.z = 0;      // shape DIFFERENTE selon les appels -> deoptimisation possible
    return p;
}

// --- Mesurer la memoire : performance.memory (Chrome) ou --inspect (Node) ---
// console.log(performance.memory.usedJSHeapSize);

Résumé

  • Un objet est collectable dès qu'il n'est plus atteignable depuis les racines (globales, pile, closures actives).
  • Les fuites classiques : variables globales accidentelles, timers/listeners jamais nettoyés, caches non bornés.
  • WeakMap/WeakSet ne retiennent pas artificiellement leurs clés : idéales pour des métadonnées liées à des objets.
  • Garder une structure d'objet stable (mêmes propriétés, même ordre) aide V8 à optimiser via les "hidden classes".

Exercices pratiques

1 disponible
1

Mission : le composant qui ne se libère jamais de la mémoire

Objectif : Diagnostiquer une fuite mémoire causée par un setInterval jamais nettoyé, et remplacer un cache non borné par une structure sûre.

Contexte

Le tableau de bord de Technologik affiche des widgets qui se créent et se détruisent souvent (changement d'onglet). Après une heure d'utilisation, l'outil de profiling mémoire du navigateur montre des dizaines de milliers d'entrées de tableau jamais libérées. Le code suspect :

js
class WidgetStats {
  constructor() {
    this.donnees = new Array(100_000).fill("data");
    setInterval(() => console.log(this.donnees.length), 1000);
  }
  detruire() {
    this.donnees = null; // le développeur pense que ça suffit
  }
}

Un second problème : un Map utilisé comme cache de résultats de calculs grossit indéfiniment, sans jamais rien supprimer, sur une session utilisateur de plusieurs heures.

Résoudre l’exercice →