04 - V8 en profondeur : comment le moteur organise la mémoire
Ce que tu vas apprendre
- L'architecture mémoire de V8 (new space, old space, large object space, code space)
- L'hypothese generationnelle (la plupart des objets meurent jeunes)
- Le Scavenger (Minor GC) et le Mark-Compact (Major GC)
- La promotion d'objets de new space vers old space
- Les vrais chiffres : tailles par défaut, flags utiles
Prerequisites
- Avoir lu Le garbage collector
- Comprendre mark-and-sweep et le concept de racines
Pourquoi un seul tas ne suffit pas
Si tu avais un seul gros tas (heap) et un seul algorithme GC, tu aurais un problème. Chaque passage du GC devrait parcourir tous les objets. Un heap de 1 Go avec des millions d'objets ? Ca prendrait des dizaines de millisecondes a chaque fois.
La solution de V8 : diviser le heap en zones et utiliser des algorithmes différents pour chaque zone. C'est ce qu'on appelle un GC generationnel.
L'hypothese generationnelle
L'observation empirique est simple : la plupart des objets meurent jeunes. Un objet temporaire créé dans une boucle, un résultat intermediaire, un callback qui s'exécuté une fois... tout ca a une duree de vie tres courte.
A l'inverse, les objets qui survivent aux premiers cycles GC ont tendance a vivre longtemps : le state de l'application, les caches, les singletons.
Duree de vie des objets (typique) :
Nombre
d'objets
|
|####
|########
|############
|################
|######################
|##############################
+------------------------------------> Duree de vie
court long
La grande majorite des objets meurent dans les premieres millisecondes.
V8 exploite ca en separant le heap en deux generations.
L'architecture mémoire de V8
+------------------------------------------------------------------+
| V8 Heap |
| |
| +---------------------------+ +------------------------------+ |
| | NEW SPACE | | OLD SPACE | |
| | (Young Generation) | | (Old Generation) | |
| | | | | |
| | +----------+----------+ | | Objets qui ont survecu | |
| | | Semi- | Semi- | | | plusieurs cycles GC | |
| | | space | space | | | | |
| | | "from" | "to" | | | GC: Mark-Compact | |
| | +----------+----------+ | | (Major GC) | |
| | | | | |
| | GC: Scavenger | +------------------------------+ |
| | (Minor GC) | |
| +---------------------------+ +------------------------------+ |
| | LARGE OBJECT SPACE | |
| +---------------------------+ | (objets > 256 Ko) | |
| | CODE SPACE | +------------------------------+ |
| | (code compile, JIT) | |
| +---------------------------+ |
+------------------------------------------------------------------+
New Space (Young Génération)
C'est là où tous les nouveaux objets sont alloues. Elle est petite (1 a 8 Mo par défaut dans Node.js, 16 Mo max dans Chrome). Petite volontairement : comme la plupart des objets meurent jeunes, le GC passe souvent mais va tres vite (quelques millisecondes).
La new space est divisee en deux semi-spaces de taille egale : "from" et "to". A tout moment, un seul est actif (les allocations vont dedans).
Old Space (Old Génération)
Les objets qui survivent a deux cycles de Scavenger sont promus dans l'old space. C'est une zone beaucoup plus grande (par défaut, le heap peut monter a ~1.5 Go dans Node.js, modifiable avec --max-old-space-size).
Le GC de l'old space est plus lent (il doit tout parcourir) mais passe moins souvent.
Large Object Space
Les objets qui depassent ~256 Ko sont alloues directement ici. Ils ne passent pas par la new space. Ils sont geres par le Major GC.
Code Space
Le code compile par le JIT (TurboFan) vit ici. C'est une zone séparée parce que le code machine a des contraintes de permissions mémoire spécifiques (executable).
Le Scavenger (Minor GC)
Le Scavenger est l'algorithme qui collecte la new space. Il utilise une technique appelee semi-space copying :
Avant le Scavenger :
Semi-space "from" (actif) :
+---+---+---+---+---+---+---+
| A | B | C | D | E | F | G |
+---+---+---+---+---+---+---+
^ ^ ^
| | |
vivant vivant vivant (reference depuis old space ou stack)
Semi-space "to" (vide) :
+---+---+---+---+---+---+---+
| | | | | | | |
+---+---+---+---+---+---+---+
Apres le Scavenger :
Semi-space "from" (vide, sera efface) :
+---+---+---+---+---+---+---+
| A | B | C | D | E | F | G |
+---+---+---+---+---+---+---+
Semi-space "to" (nouveau actif) :
+---+---+---+---+---+---+---+
| A | C | F | | | | | <- seuls les vivants sont copies
+---+---+---+---+---+---+---+
Puis on inverse les roles : "to" devient "from", et vice versa.
Le Scavenger est rapide parce que :
- La new space est petite (quelques Mo)
- La majorite des objets sont morts (pas besoin de les copier)
- Il copie uniquement les vivants (pas de phase de sweep)
La promotion vers l'old space
Un objet est promu de la new space vers l'old space quand il a survecu a deux cycles de Scavenger. La logique est simple : si tu as survecu deux fois, tu vas probablement vivre longtemps.
typescript// Cet objet temporaire meurt dans le premier Scavenger
function processRequest(data: string) {
const temp = { parsed: JSON.parse(data) }; // meurt vite
return temp.parsed.result;
}
// Cet objet survit et sera promu
const cache = new Map<string, object>(); // vit longtemps
cache.set("config", loadConfig());
Le Mark-Compact (Major GC)
Le Major GC traite l'old space. Il utilise Mark-Compact en trois phases :
- Mark : tri-color marking depuis les racines (comme dans l'article précédent)
- Sweep : identifier les zones mémoire libres
- Compact : deplacer les objets vivants pour eliminer la fragmentation
La compaction est ce qui rend le Major GC plus coûteux. Il doit deplacer des objets et mettre à jour toutes les références qui pointent vers eux. Mais ca évité la fragmentation du heap, ce qui permet des allocations plus rapides ensuite.
Les vrais chiffres
Voici les tailles par défaut dans V8 (Node.js) :
| Zone | Taille par defaut | Configurable |
|---------------------|-------------------------|-----------------------|
| New space (total) | 1-8 Mo (adaptative) | --max-semi-space-size |
| Old space | ~1.5 Go (64-bit) | --max-old-space-size |
| Large object seuil | ~256 Ko | non |
| Stack | ~1 Mo (par thread) | --stack-size |
En pratique :
bash# Augmenter l'old space a 4 Go pour un process gourmand
node --max-old-space-size=4096 server.js
# Voir les stats GC en temps reel
node --trace-gc server.js
# Output : [12345:0x...] 42 ms: Scavenge 3.2 (4.0) -> 1.1 (4.0) MB, 0.8 ms
# [12345:0x...] 84 ms: Mark-Compact 12.3 (16.0) -> 8.7 (16.0) MB, 4.2 ms
Le flag --trace-gc est ton meilleur ami pour comprendre le comportement mémoire de ton application. Il te dit a chaque GC : le type (Scavenge ou Mark-Compact), la taille avant et apres, et la duree de la pause.
Quand les problèmes arrivent
Les problèmes de performance lies au GC apparaissent généralement dans deux scénarios :
Trop d'allocations temporaires : le Scavenger passe sans arrêt. Chaque passage est rapide (~1ms) mais 1000 passages par seconde, ca fait 1 seconde perdue.
Heap old space qui gonfle : plus l'old space est gros, plus le Major GC est lent. Un Major GC sur un heap de 2 Go peut prendre 50-100ms. Si tu as une fuite mémoire, ca ne fait qu'empirer.
Sur paltemps.fr, j'ai utilise --trace-gc pour découvrir que le parsing de réponses API JSON creait 50 000 objets temporaires par minute. Chacun mourait dans le premier Scavenger, mais la pression d'allocation etait énorme. En passant a un parsing streaming, j'ai divise la fréquence des Scavenger par 10.
Résumé
- V8 divise le heap en new space (jeune génération) et old space (vieille génération).
- L'hypothese generationnelle : la plupart des objets meurent jeunes, donc on collecte la new space souvent et vite.
- Le Scavenger (Minor GC) utilise le semi-space copying sur la new space. Rapide, frequent.
- Le Mark-Compact (Major GC) traite l'old space. Plus lent, moins frequent.
- Les objets qui survivent 2 Scavenger sont promus vers l'old space.
--max-old-space-sizeet--trace-gcsont les flags essentiels pour le profiling.
Precedent : Le garbage collector Suivant : Les 6 fuites classiques