Jour 49 Day 49 · vendredi 16 octobre 2026 Friday 16 October 2026 CS Avancé
Garbage collection & gestion mémoire Garbage collection & memory management
Stack vs heap, refcounting, mark & sweep, GC générationnel et fuites mémoire malgré le GC : de quoi répondre au « comment marche la mémoire dans ton langage ? » sans trembler. Stack vs heap, refcounting, mark & sweep, generational GC and memory leaks despite the GC: enough to answer "how does memory work in your language?" without flinching.
L’essentiel
Un programme range sa mémoire à deux endroits. La stack : les variables locales et les frames d’appel, allouées/libérées automatiquement à l’entrée et à la sortie de chaque fonction — rapide, ordonnée, mais petite et liée à la durée de vie de la fonction. Le heap : tout ce qui doit survivre à la fonction qui l’a créé (objets, tableaux, closures) — flexible, mais il faut bien libérer un jour, sinon la mémoire fuit.
Tout le problème tient en une question : quand peut-on libérer un objet du heap ? Réponse : quand plus personne ne peut y accéder. En C, c’est au développeur de le décider (free) — d’où les bugs légendaires : use-after-free, double free, fuites. Le garbage collector automatise cette décision : il détecte les objets devenus inaccessibles et récupère leur mémoire. Java, JavaScript, Python, Go, C# : tous reposent sur un GC. Rust prend une troisième voie, sans GC ni free manuel (on y revient).
Ce que le GC garantit : pas de use-after-free, pas de double free. Ce qu’il ne garantit pas : l’absence de fuites — un objet encore référencé mais devenu inutile ne sera jamais collecté.
Comment ça marche
STACK (par thread) HEAP (partagé)
┌───────────────┐ ┌──────────────────────────┐
│ frame main() │ │ Young gen │ Old gen │
│ frame f() │ │ ┌───┐ ┌───┐ │ ┌───────┐ │
│ x: 42 │ │ │obj│ │obj│ │ │ obj │ │
│ p: ● ───────┼──▶│ └───┘ └───┘ │ └───────┘ │
└───────────────┘ │ minor GC │ major GC │
libérée au return │ fréquent │ rare, cher │
└──────────────────────────┘
Deux grandes familles d’algorithmes :
- Reference counting : chaque objet porte un compteur de références ; à zéro, il est libéré immédiatement. Simple, libération prévisible… mais deux objets qui se référencent mutuellement gardent un compteur ≥ 1 pour toujours : les cycles ne sont jamais libérés. CPython l’utilise, complété par un détecteur de cycles ; Swift (ARC) impose des
weakreferences pour casser les cycles. - Tracing (mark & sweep) : on part des racines (stack, variables globales, registres), on marque tout ce qui est atteignable en suivant les références, puis on balaie (sweep) tout ce qui n’est pas marqué. Les cycles inaccessibles sont collectés naturellement : un cycle que personne ne pointe n’est jamais marqué.
| Reference counting | Tracing (mark & sweep) | |
|---|---|---|
| Libération | Immédiate (compteur à 0) | Différée (au passage du GC) |
| Cycles | Non collectés (il faut un mécanisme en plus) | Collectés naturellement |
| Coût | Réparti (incr/décr à chaque affectation) | Concentré (pauses de collection) |
| Prévisibilité | Bonne | Pauses variables |
| Exemples | CPython, Swift (ARC) | JVM, V8, Go, .NET |
Le raffinement clé : le GC générationnel, fondé sur l’hypothèse générationnelle — la plupart des objets meurent jeunes (variables temporaires, objets d’une requête HTTP). On sépare donc le heap en une young generation, collectée souvent et très vite (minor GC : on ne parcourt que les survivants, peu nombreux), et une old generation pour les objets qui survivent à plusieurs collections, parcourue rarement (major/full GC, plus coûteux). V8 (Scavenger + Mark-Compact) et la JVM (G1, ZGC) fonctionnent ainsi.
Le prix à payer : les pauses stop-the-world — pour marquer un graphe d’objets cohérent, le GC doit suspendre le programme. Les GC modernes rendent la majeure partie du travail concurrente ou incrémentale (ZGC vise des pauses < 1 ms même sur des heaps énormes), mais « GC » implique toujours un compromis débit / latence / mémoire.
Rust, l’alternative : l’ownership fait vérifier à la compilation que chaque valeur a un unique propriétaire et que sa mémoire est libérée exactement quand il sort du scope. Zéro GC, zéro pause, sécurité mémoire garantie — au prix d’un apprentissage plus rude (le borrow checker).
Concepts clés à maîtriser
- Racines (GC roots) : stack, globals, registres — le point de départ du marquage. « Inaccessible » signifie : aucun chemin depuis une racine.
- Fuites mémoire MALGRÉ le GC : le GC collecte l’inaccessible, pas l’inutile. Les quatre suspects habituels : listeners jamais retirés (le DOM ou l’emitter garde une référence vers votre callback, qui capture tout son scope), caches sans borne (une
Mapqui grossit pour toujours — penserWeakMapou une éviction LRU), closures qui capturent de gros objets, globals qui accumulent.
La fuite la plus classique en JavaScript, et sa correction :
// ❌ FUITE : à chaque création du widget, un listener de plus.
// Le document référence le callback → le callback capture
// `bigData` → rien n'est jamais collecté, même widget détruit.
class Widget {
constructor() {
this.bigData = new Array(1e6).fill("…");
document.addEventListener("click", () => this.render());
}
}
// ✅ CORRECT : garder la référence du handler et le retirer
// quand le widget meurt. Plus de chemin depuis une racine
// → widget et bigData deviennent collectables.
class Widget {
constructor() {
this.bigData = new Array(1e6).fill("…");
this.onClick = () => this.render();
document.addEventListener("click", this.onClick);
}
destroy() {
document.removeEventListener("click", this.onClick);
}
}
- Profiler une fuite : symptôme = mémoire qui monte en marches d’escalier sans jamais redescendre après GC. Méthode : DevTools → onglet Memory → heap snapshot avant/après le scénario suspect, comparer, trier par retained size, puis remonter la chaîne des retainers (qui référence quoi) jusqu’à la racine coupable. Côté Node :
--inspect+ Chrome DevTools ouprocess.memoryUsage()pour la tendance.
💡 Retained vs shallow size — la shallow size est la taille de l’objet seul ; la retained size est tout ce qui serait libéré si cet objet disparaissait. C’est la retained size qui désigne les vrais coupables : un petit listener peut retenir 200 Mo.
En entretien
« Stack vs heap ? » — Stack : frames d’appel et locales, allocation/libération automatique par simple déplacement d’un pointeur, très rapide, durée de vie liée à la fonction. Heap : objets à durée de vie arbitraire, gérés par allocateur + GC (ou manuellement en C). Bonus : chaque thread a sa stack, le heap est partagé — d’où les problèmes de concurrence sur le heap.
« Comment marche un GC mark & sweep ? » — Depuis les racines (stack, globals), on marque récursivement tout objet atteignable ; ce qui n’est pas marqué est balayé. Ajouter : la compaction (déplacer les survivants pour défragmenter) et le fait que les cycles inaccessibles sont collectés — contrairement au refcounting.
« Pourquoi un GC générationnel ? » — Hypothèse générationnelle : la plupart des objets meurent jeunes. Collecter souvent une petite young gen (minor GC, rapide car on ne copie que les rares survivants) et rarement l’old gen (major GC) donne des pauses courtes la plupart du temps. C’est le design de V8 et de la JVM.
« Un GC empêche-t-il les fuites mémoire ? » — Non (voir callout ci-dessous) — c’est la question piège favorite ; répondre « oui » est éliminatoire à ce niveau.
« Comment Rust s’en sort sans GC ? » — Ownership : chaque valeur a un propriétaire unique, la libération est insérée à la compilation quand le propriétaire sort du scope ; le borrow checker vérifie statiquement qu’aucune référence ne survit à la valeur. Sécurité mémoire sans runtime.
Pièges & idées reçues
🎤 En entretien — « Un GC empêche-t-il les fuites mémoire ? » Non. Le GC ne libère que ce qui est inaccessible ; une référence oubliée (listener, cache, global) rend l’objet accessible donc incollectable, même si plus personne ne s’en sert. Une fuite en langage managé = un problème de références, pas d’allocation. Citer le trio listener/cache/closure + le heap snapshot comme méthode de diagnostic : réponse complète.
- « Le refcounting suffit » — les cycles (deux objets qui se pointent, liste doublement chaînée, parent ↔ enfant) ne tombent jamais à zéro. Il faut un détecteur de cycles (CPython) ou des weak references (Swift).
- « Les pauses GC, c’est du passé » — atténuées, pas disparues : ZGC ou le GC incrémental de V8 réduisent énormément les pauses, mais le travail de collection se paie toujours quelque part (CPU, débit, mémoire supplémentaire).
deleteen JavaScript ne libère pas la mémoire — il retire une propriété d’un objet. On ne « libère » jamais explicitement en JS : on supprime des références (= null, sortie de scope) et le GC fait le reste.- Forcer le GC (
System.gc(),global.gc()) — au mieux inutile, au pire contre-productif : le runtime planifie mieux que vous. Si vous en avez besoin, c’est le design qui fuit.
Pour aller plus loin
- V8 — Trash talk: the Orinoco garbage collector : le GC de V8 expliqué par ses auteurs
- MDN — Gestion de la mémoire en JavaScript : refcounting vs mark & sweep, accessible
- Chrome DevTools — Record heap snapshots : le mode d’emploi du diagnostic de fuite
- The Rust Book — Ownership : l’alternative sans GC, chapitre fondateur
- Exercice : ouvrir DevTools sur une SPA, prendre deux heap snapshots autour d’une navigation répétée, et chercher les detached DOM nodes — la fuite front la plus courante
The essentials
A program stores its memory in two places. The stack: local variables and call frames, allocated/freed automatically on function entry and exit — fast, orderly, but small and tied to the function’s lifetime. The heap: everything that must outlive the function that created it (objects, arrays, closures) — flexible, but it must eventually be freed, or memory leaks.
The whole problem fits in one question: when can a heap object be freed? Answer: when nobody can reach it anymore. In C, the developer decides (free) — hence the legendary bugs: use-after-free, double free, leaks. The garbage collector automates that decision: it detects objects that have become unreachable and reclaims their memory. Java, JavaScript, Python, Go, C#: all rely on a GC. Rust takes a third path, with neither GC nor manual free (more below).
What the GC guarantees: no use-after-free, no double free. What it does not guarantee: the absence of leaks — an object still referenced but no longer useful will never be collected.
How it works
STACK (per thread) HEAP (shared)
┌───────────────┐ ┌──────────────────────────┐
│ frame main() │ │ Young gen │ Old gen │
│ frame f() │ │ ┌───┐ ┌───┐ │ ┌───────┐ │
│ x: 42 │ │ │obj│ │obj│ │ │ obj │ │
│ p: ● ───────┼──▶│ └───┘ └───┘ │ └───────┘ │
└───────────────┘ │ minor GC │ major GC │
freed on return │ frequent │ rare, $$ │
└──────────────────────────┘
Two big algorithm families:
- Reference counting: each object carries a reference counter; at zero, it is freed immediately. Simple, predictable release… but two objects referencing each other keep a counter ≥ 1 forever: cycles are never freed. CPython uses it, complemented by a cycle detector; Swift (ARC) requires
weakreferences to break cycles. - Tracing (mark & sweep): start from the roots (stack, global variables, registers), mark everything reachable by following references, then sweep everything unmarked. Unreachable cycles are collected naturally: a cycle nobody points to never gets marked.
| Reference counting | Tracing (mark & sweep) | |
|---|---|---|
| Release | Immediate (counter hits 0) | Deferred (when the GC runs) |
| Cycles | Not collected (needs an extra mechanism) | Collected naturally |
| Cost | Spread out (incr/decr on every assignment) | Concentrated (collection pauses) |
| Predictability | Good | Variable pauses |
| Examples | CPython, Swift (ARC) | JVM, V8, Go, .NET |
The key refinement: the generational GC, based on the generational hypothesis — most objects die young (temporaries, objects scoped to one HTTP request). So the heap is split into a young generation, collected often and very fast (minor GC: only the few survivors are traversed), and an old generation for objects that survive several collections, traversed rarely (major/full GC, more expensive). V8 (Scavenger + Mark-Compact) and the JVM (G1, ZGC) work this way.
The price: stop-the-world pauses — to mark a consistent object graph, the GC must suspend the program. Modern GCs make most of the work concurrent or incremental (ZGC targets sub-millisecond pauses even on huge heaps), but “GC” always implies a throughput / latency / memory trade-off.
Rust, the alternative: ownership has the compiler verify that every value has a single owner and that its memory is freed exactly when the owner goes out of scope. Zero GC, zero pauses, guaranteed memory safety — at the cost of a steeper learning curve (the borrow checker).
Key concepts to master
- GC roots: stack, globals, registers — the starting point of marking. “Unreachable” means: no path from any root.
- Memory leaks DESPITE the GC: the GC collects the unreachable, not the useless. The four usual suspects: listeners never removed (the DOM or emitter keeps a reference to your callback, which captures its whole scope), unbounded caches (a
Mapthat grows forever — thinkWeakMapor LRU eviction), closures capturing large objects, globals that accumulate.
The most classic JavaScript leak, and its fix:
// ❌ LEAK: every widget creation adds one more listener.
// The document references the callback → the callback
// captures `bigData` → nothing is ever collected, even
// after the widget is "destroyed".
class Widget {
constructor() {
this.bigData = new Array(1e6).fill("…");
document.addEventListener("click", () => this.render());
}
}
// ✅ CORRECT: keep a reference to the handler and remove it
// when the widget dies. No more path from a root
// → widget and bigData become collectable.
class Widget {
constructor() {
this.bigData = new Array(1e6).fill("…");
this.onClick = () => this.render();
document.addEventListener("click", this.onClick);
}
destroy() {
document.removeEventListener("click", this.onClick);
}
}
- Profiling a leak: symptom = memory climbing in a staircase pattern, never coming back down after GC. Method: DevTools → Memory tab → heap snapshot before/after the suspect scenario, compare, sort by retained size, then walk the retainers chain (who references what) up to the guilty root. On Node:
--inspect+ Chrome DevTools, orprocess.memoryUsage()for the trend.
💡 Retained vs shallow size — shallow size is the object’s own size; retained size is everything that would be freed if that object disappeared. Retained size is what points to the real culprits: a tiny listener can retain 200 MB.
In an interview
“Stack vs heap?” — Stack: call frames and locals, automatic allocation/release by just moving a pointer, very fast, lifetime tied to the function. Heap: objects with arbitrary lifetimes, managed by an allocator + GC (or manually in C). Bonus: each thread has its own stack, the heap is shared — hence concurrency problems live on the heap.
“How does a mark & sweep GC work?” — From the roots (stack, globals), recursively mark every reachable object; whatever isn’t marked gets swept. Add: compaction (moving survivors to defragment) and the fact that unreachable cycles are collected — unlike with refcounting.
“Why a generational GC?” — Generational hypothesis: most objects die young. Collecting a small young gen often (minor GC, fast because only the few survivors are copied) and the old gen rarely (major GC) yields short pauses most of the time. That’s the design of V8 and the JVM.
“Does a GC prevent memory leaks?” — No (see callout below) — it’s the favorite trap question; answering “yes” is disqualifying at this level.
“How does Rust manage without a GC?” — Ownership: every value has a single owner, the release is inserted at compile time when the owner goes out of scope; the borrow checker statically verifies that no reference outlives the value. Memory safety with no runtime.
Pitfalls & misconceptions
🎤 In an interview — “Does a GC prevent memory leaks?” No. The GC only frees what is unreachable; a forgotten reference (listener, cache, global) keeps the object reachable and thus uncollectable, even if nobody uses it anymore. A leak in a managed language = a references problem, not an allocation problem. Cite the listener/cache/closure trio + the heap snapshot as the diagnostic method: complete answer.
- “Refcounting is enough” — cycles (two objects pointing at each other, doubly linked lists, parent ↔ child) never drop to zero. You need a cycle detector (CPython) or weak references (Swift).
- “GC pauses are a thing of the past” — mitigated, not gone: ZGC or V8’s incremental GC massively reduce pauses, but the collection work is always paid somewhere (CPU, throughput, extra memory).
deletein JavaScript does not free memory — it removes a property from an object. You never explicitly “free” in JS: you drop references (= null, out of scope) and the GC does the rest.- Forcing the GC (
System.gc(),global.gc()) — useless at best, counterproductive at worst: the runtime schedules better than you. If you need it, your design is what’s leaking.
Going further
- V8 — Trash talk: the Orinoco garbage collector: V8’s GC explained by its authors
- MDN — JavaScript memory management: refcounting vs mark & sweep, approachable
- Chrome DevTools — Record heap snapshots: the how-to for leak diagnosis
- The Rust Book — Ownership: the GC-free alternative, the founding chapter
- Exercise: open DevTools on a SPA, take two heap snapshots around a repeated navigation, and look for detached DOM nodes — the most common front-end leak