Mémoire et performance JS/TS - 19 - Comparaison avec d'autres langages

Comment JavaScript, Rust, Go, Java et Python gerent la mémoire, et pourquoi JS reste un bon choix pour la plupart des cas.

  1. 01 Mémoire et performance JS/TS - 00 - Pourquoi la mémoire compte meme avec un garbage collector
  2. 02 Mémoire et performance JS/TS - 01 - Stack vs Heap
  3. 03 Mémoire et performance JS/TS - 02 - Le cycle de vie de la mémoire
  4. 04 Mémoire et performance JS/TS - 03 - Le garbage collector
  5. 05 Mémoire et performance JS/TS - 04 - V8 en profondeur
  6. 06 Mémoire et performance JS/TS - 05 - Les 6 fuites mémoire classiques
  7. 07 Mémoire et performance JS/TS - 06 - Closures et mémoire
  8. 08 Mémoire et performance JS/TS - 07 - WeakRef, WeakMap et WeakSet
  9. 09 Mémoire et performance JS/TS - 08 - FinalizationRegistry : savoir quand le GC passe
  10. 10 Mémoire et performance JS/TS - 09 - DevTools Memory : investiguer dans Chrome
  11. 11 Mémoire et performance JS/TS - 10 - Profiling mémoire en Node.js
  12. 12 Mémoire et performance JS/TS - 11 - Détecter et corriger les fuites mémoire
  13. 13 Mémoire et performance JS/TS - 12 - ArrayBuffer et TypedArrays
  14. 14 Mémoire et performance JS/TS - 13 - Workers et mémoire partagee
  15. 15 Mémoire et performance JS/TS - 14 - Streams et backpressure
  16. 16 Mémoire et performance JS/TS - 15 - Fuites mémoire en React
  17. 17 Mémoire et performance JS/TS - 16 - Serveurs Node.js et mémoire
  18. 18 Mémoire et performance JS/TS - 17 - Mémoire et Docker
  19. 19 Mémoire et performance JS/TS - 18 - Optimisations mémoire
  20. 20 Mémoire et performance JS/TS - 19 - Comparaison avec d'autres langages
  21. 21 Mémoire et performance JS/TS - 20 - Tester la mémoire
  22. 22 Mémoire et performance JS/TS - 21 - Glossaire

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


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

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.