19 - Comparaison avec d'autres langages
Ce que tu vas apprendre
- Les 3 grandes stratégies de gestion mémoire : GC avec pause, GC concurrent, pas de GC
- Comment Rust elimine le GC grâce à l'ownership
- Pourquoi Go a un GC plus previsible que JavaScript
- Les options de GC en Java (G1, ZGC, Shenandoah)
- Le référencé counting de Python et ses limites
- Dans quels cas chaque approche brille
Prerequisites
- Avoir lu le garbage collector et V8 en profondeur
- Aucune experience dans les autres langages n'est requise
Cinq langages, cinq philosophies
J'ai écrit du code en production dans chacun de ces langages. Pas a volume egal -- JavaScript reste mon quotidien -- mais assez pour avoir des opinions sur la gestion mémoire de chacun. Ce qui suit n'est pas un benchmark. C'est un tour d'horizon des compromis que chaque langage fait.
JavaScript : le GC qui s'arrêté
Tu connais deja bien V8 si tu as suivi cette serie. Le GC de V8 est generationnel : un Scavenger rapide pour le new space, un Mark-Sweep-Compact pour le old space. Le Minor GC prend quelques millisecondes. Le Major GC peut prendre 50 a 200ms sur un gros heap.
JavaScript (V8)
+-----------+ +-----------+
| New space | --> | Old space |
| Scavenger | | Mark-Sweep|
| ~1-5ms | | ~50-200ms |
+-----------+ +-----------+
Le problème : les pauses "stop-the-world" du Major GC. Pendant ce temps, ton code ne s'exécuté pas. Pour une API, 200ms de pause ca veut dire 200ms de latence ajoutee a la requête en cours. V8 a fait des progres énormes avec le marking concurrent et incremental (Orinoco), mais les pauses existent toujours.
Pour la majorite des applications web, ces pauses sont invisibles. Mais pour du temps réel (jeux, audio, trading), c'est un vrai frein.
Rust : pas de GC du tout
Rust est le seul langage mainstream qui n'a pas de garbage collector et pas de référencé counting par défaut. La mémoire est geree a la compilation grace au système d'ownership et de borrowing.
rustfn main() {
let s1 = String::from("hello"); // s1 possede la string
let s2 = s1; // ownership transfere a s2
// println!("{}", s1); // Erreur de compilation ! s1 n'existe plus
println!("{}", s2); // OK
} // s2 sort du scope, la memoire est liberee. Pas de GC.
Chaque valeur a exactement un proprietaire. Quand le proprietaire sort du scope, la valeur est détruite (son drop est appele). Le compilateur vérifié ca a la compilation. Si tu fais une erreur, le code ne compile pas.
Le résultat : zero pause GC, zero overhead runtime, consommation mémoire minimale. Un serveur Rust utilise typiquement 5 a 10x moins de mémoire que l'équivalent Node.js.
Le coût : le compilateur te crie dessus. Beaucoup. Le borrow checker est strict, et tu passes du temps a satisfaire ses regles. Un développeur JavaScript productif en 2 jours met 2 a 4 semaines avant d'etre productif en Rust.
Quand ca brille : systèmes embarques, parsers a haut debit, proxies réseau, tout ce qui doit tourner longtemps sans fuite et sans pause.
Go : un GC concurrent et previsible
Go a un garbage collector concurrent. Pendant que ton code tourne, le GC trace les objets en parallèle. Les pauses stop-the-world existent, mais elles sont extremement courtes : typiquement moins de 1ms, parfois quelques centaines de microsecondes.
gofunc main() {
// Go gere la memoire automatiquement comme JS
data := make([]byte, 10*1024*1024) // 10 Mo
process(data)
// data sera collecte quand il n'est plus reference
// Pause GC : ~0.5ms meme avec des Go de heap
}
Go fait un compromis différent de V8 : il préféré des pauses courtes a un debit maximal. Le GC de Go ne compacte pas la mémoire (pas de defragmentation), ce qui signifie un peu plus de fragmentation mais beaucoup moins de pauses.
Pour des serveurs HTTP a forte charge, Go offre une latence P99 plus previsible que Node.js. Le GC ne te surprend pas avec une pause de 200ms sur la requête d'un client premium.
Quand ca brille : microservices, proxies, outils CLI, tout ce qui a besoin de concurrence (goroutines) et de latence basse.
Java : le royaume du tuning GC
Java a le GC le plus configurable de tous. Tellement configurable que "tuner le GC" est une specialite a part entière.
G1 (defaut depuis Java 9)
- Bon equilibre entre debit et latence
- Pauses : 10-200ms selon la taille du heap
ZGC (depuis Java 15)
- Pauses < 1ms, meme avec des heaps de 16 To
- Overhead CPU : ~5-15%
Shenandoah (Red Hat)
- Similaire a ZGC, concurrent compaction
- Pauses < 10ms
bash# Lancer avec ZGC
java -XX:+UseZGC -Xmx4g -jar app.jar
# Lancer avec G1 (defaut)
java -Xmx4g -jar app.jar
ZGC est impressionnant : des pauses sous la milliseconde avec des heaps de plusieurs teraoctets. Mais le JVM lui-meme consomme beaucoup de mémoire. Un "Hello World" en Java prend 50 Mo. L'équivalent en Node.js prend 30 Mo. L'équivalent en Rust prend 1 Mo.
Quand ca brille : gros systèmes d'entreprise, applications qui tournent 24/7 avec des heaps massifs, cas ou la JVM a le temps de "chauffer" (JIT compilation).
Python : le comptage de références
Python utilise le référencé counting comme mecanisme principal. Chaque objet a un compteur de références. Quand le compteur tombe a zero, l'objet est immédiatement libéré. Pas de pause GC pour ca.
pythona = [1, 2, 3] # refcount = 1
b = a # refcount = 2
del a # refcount = 1
del b # refcount = 0 -> libere immediatement
Ca marche bien sauf pour les cycles. Si A référencé B et B référencé A, les deux ont un refcount de 1 meme si plus personne d'autre ne les utilise. Python a un detecteur de cycles qui tourne periodiquement pour trouver et collecter ces cycles.
python# Cycle : ni a ni b ne sera libere par le refcount seul
a = {}
b = {}
a["ref"] = b
b["ref"] = a
del a, b
# Le cycle detector de Python finira par les trouver
Le référencé counting a un overhead constant : chaque assignation, chaque passage de paramètre incremente et decremente un compteur. Sur du code numérique intensif, ca peut representer 10 a 20% de perte de performance. C'est pour ca que NumPy fait tout le travail lourd en C.
Quand ca brille : scripting, data science (via NumPy/Pandas qui gerent leur propre mémoire en C), prototypage rapide.
Tableau comparatif
| Langage | Strategie | Pause typ. | Memoire baseline | Complexite |
|------------|-------------------|------------|------------------|------------|
| JavaScript | GC generationnel | 1-200ms | ~30 Mo | Faible |
| Rust | Ownership | 0ms | ~1 Mo | Haute |
| Go | GC concurrent | <1ms | ~10 Mo | Faible |
| Java | GC configurable | <1ms (ZGC) | ~50 Mo | Moyenne |
| Python | Refcount + cycles | ~0ms* | ~15 Mo | Faible |
(*) Python n'a pas de pauses GC classiques, mais le GIL impose ses propres contraintes.
Pourquoi JavaScript reste un bon choix
Apres tout ca, tu pourrais te dire "je devrais tout reecrire en Rust". Mais la réalité, c'est que 95% des applications web n'ont pas besoin de pauses GC sous la milliseconde. Un serveur Express qui répond en 50ms ne souffre pas d'une pause GC de 5ms toutes les 30 secondes.
JavaScript a d'autres atouts : un ecosysteme massif, un seul langage front et back, un temps de développement court, et des outils de profiling matures. Sur paltemps.fr, tout tourne en TypeScript/Node.js. Les pauses GC ne sont meme pas visibles dans les metriques. Le goulot, c'est toujours la base de donnees ou le réseau, jamais le GC.
Le bon langage pour la mémoire, c'est celui qui correspond a tes contraintes reelles, pas a tes contraintes imaginaires.
Résumé
- JavaScript : GC generationnel avec pauses, simple a utiliser, suffisant pour 95% des cas web.
- Rust : zero GC, zero pause, mais courbe d'apprentissage raide.
- Go : GC concurrent avec pauses sous la milliseconde, excellent pour les serveurs.
- Java : GC tres configurable (ZGC pour les pauses minimales), mais overhead JVM eleve.
- Python : référencé counting immediat, mais le cycle detector et le GIL ajoutent de la complexité.
- Choisis ton langage selon tes contraintes reelles de latence et de mémoire, pas selon les benchmarks.
Precedent : Optimisations mémoire Suivant : Tester la mémoire