Mémoire et performance JS/TS - 18 - Optimisations mémoire

Object pooling, arrays plats, generators, Flyweight : les techniques concrètes pour réduire la consommation mémoire en JavaScript.

  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

18 - Optimisations mémoire

Ce que tu vas apprendre

  • Le pooling d'objets : réutiliser au lieu de créer et collecter
  • Pourquoi les arrays plats battent les arrays d'objets en mémoire et en vitesse
  • Comment les generators evitent les allocations intermediaires
  • Le pattern Flyweight et les buffers pre-alloues
  • Quand l'optimisation est prematuree (et quand elle ne l'est pas)

Prerequisites


Le jeu qui laggait a 60 fps

Un collegue bossait sur un jeu en canvas. 60 fps, des dizaines de particules a l'ecran, chaque frame créé de nouveaux objets { x, y, vx, vy, life } pour les particules. Ca tournait bien... pendant 30 secondes. Puis le GC se declenchait, gelait le rendu pendant 20ms, et le jeu saccadait.

Le problème n'etait pas la quantité de mémoire. C'etait le rythme de création et destruction. 1000 objets créés par seconde = 1000 objets a collecter par seconde = des pauses GC frequentes.

Object pooling

L'idee : au lieu de créer un nouvel objet et laisser le GC le collecter, tu reutilises des objets existants.

typescriptclass ParticlePool {
  private pool: Particle[] = [];
  private active: Particle[] = [];

  acquire(): Particle {
    const particle = this.pool.pop() || new Particle();
    particle.reset();
    this.active.push(particle);
    return particle;
  }

  release(particle: Particle): void {
    const index = this.active.indexOf(particle);
    if (index !== -1) {
      this.active.splice(index, 1);
      this.pool.push(particle);
    }
  }
}

class Particle {
  x = 0;
  y = 0;
  vx = 0;
  vy = 0;
  life = 0;

  reset(): void {
    this.x = 0;
    this.y = 0;
    this.vx = 0;
    this.vy = 0;
    this.life = 100;
  }
}

Avec un pool, le nombre d'allocations tombe a zero pendant le jeu (apres le warmup initial). Plus de pauses GC. Le collegue est passe de 20ms de freeze toutes les 2 secondes a zero freeze.

Mais attention : le pooling ajoute de la complexité. Tu dois gerer le cycle de vie manuellement. Un objet pas remis dans le pool, c'est une fuite. Un objet remis trop tot et réutilisé ailleurs, c'est un bug subtil. Utilise ca uniquement dans les boucles chaudes (game loop, traitement de flux).

String interning : V8 le fait pour toi

typescriptconst a = "hello";
const b = "hello";
// V8 ne cree qu'une seule string en memoire. a et b pointent au meme endroit.

const c = "hel" + "lo";
// V8 internalise aussi le resultat si c'est une constante a la compilation.

V8 internalise automatiquement les petites chaînes et les chaînes identiques. Tu n'as pas besoin de gerer ca toi-meme. Par contre, les chaînes construites dynamiquement (par concatenation avec des variables) ne sont pas toujours internalisees.

Si tu as 100 000 objets avec un champ status qui vaut "active" ou "inactive", V8 ne stocke ces deux chaînes qu'une fois. Les 100 000 objets pointent vers les deux memes strings.

Arrays plats vs arrays d'objets

typescript// Version objets : chaque element est un objet sur le heap
const points = [
  { x: 1, y: 2 },
  { x: 3, y: 4 },
  { x: 5, y: 6 },
];
// 3 objets + 1 tableau = 4 allocations

// Version plate : un seul tableau
const flatPoints = new Float64Array([1, 2, 3, 4, 5, 6]);
// 1 allocation, donnees contigues en memoire
// flatPoints[i*2] = x, flatPoints[i*2+1] = y

Pour 100 000 points, la version objets créé 100 001 allocations (100 000 objets + le tableau). Chaque objet a un overhead de 32 a 64 octets pour les metadonnees V8. Total : environ 5 Mo.

La version plate : une seule allocation de 1.6 Mo (100 000 * 2 * 8 octets). Et comme les donnees sont contigues en mémoire, le cache CPU peut les prefetcher. L'itération est 2 a 5 fois plus rapide.

J'utilise ce pattern sur paltemps.fr pour les donnees de temperature horaire. 8760 heures par an, 5 stations, ca fait 43 800 nombres. Un Float32Array de 175 Ko au lieu de 43 800 objets a 2 Mo.

Éviter les intermediaires inutiles

typescriptconst data = getHugeArray(); // 1 million d'elements

// Mauvais : cree 2 tableaux intermediaires
const result = data
  .map((item) => item.value * 2)    // Tableau 1 : 1 million d'elements
  .filter((val) => val > 100);       // Tableau 2 : N elements

// Mieux : une seule passe avec reduce
const result2 = data.reduce<number[]>((acc, item) => {
  const val = item.value * 2;
  if (val > 100) acc.push(val);
  return acc;
}, []);

// Encore mieux : un simple for
const result3: number[] = [];
for (const item of data) {
  const val = item.value * 2;
  if (val > 100) result3.push(val);
}

Avec map().filter(), tu alloues un tableau intermediaire de 1 million d'éléments qui est immédiatement jete. C'est 8 Mo d'allocation pour rien. Le for ne créé que le tableau final.

Est-ce que ca compte pour un tableau de 50 éléments ? Non. Le gain est negligeable. Mais pour des dizaines de milliers d'éléments, ca fait une différence mesurable.

Generators pour l'itération paresseuse

typescriptfunction* filterMap(
  items: Iterable<DataItem>,
  predicate: (item: DataItem) => boolean,
  transform: (item: DataItem) => number
): Generator<number> {
  for (const item of items) {
    if (predicate(item)) {
      yield transform(item);
    }
  }
}

// Traite 1 element a la fois, pas de tableau intermediaire
for (const value of filterMap(hugeArray, (i) => i.active, (i) => i.score)) {
  process(value);
}

Un generator ne materialise jamais la liste complète en mémoire. Il produit les valeurs une par une. Si tu lis un fichier de 10 Go ligne par ligne avec un generator, tu as une seule ligne en mémoire a la fois.

Le pattern Flyweight

Le Flyweight séparé les donnees partagees (intrinseques) des donnees uniques (extrinseques).

typescript// Sans Flyweight : chaque arbre stocke toute sa configuration
class Tree {
  constructor(
    public x: number,
    public y: number,
    public type: string,      // "oak" repete 5000 fois
    public texture: ImageData, // 2 Mo repete 5000 fois
    public color: string,
  ) {}
}
// 5000 arbres * 2 Mo = 10 Go

// Avec Flyweight : les donnees partagees sont factorisees
class TreeType {
  constructor(
    public type: string,
    public texture: ImageData,
    public color: string,
  ) {}
}

class TreeInstance {
  constructor(
    public x: number,
    public y: number,
    public treeType: TreeType,
  ) {}
}

const oakType = new TreeType("oak", oakTexture, "#4a3");
// 5000 arbres partagent la meme reference
// 1 texture de 2 Mo au lieu de 5000

Buffers pre-alloues

typescript// Mauvais : on cree un nouveau Buffer a chaque lecture
function processChunk(data: Buffer): Buffer {
  const output = Buffer.alloc(data.length * 2);
  // ... transformation
  return output;
}

// Mieux : un buffer reutilisable
const outputBuffer = Buffer.alloc(64 * 1024); // 64 Ko pre-alloue

function processChunkReuse(data: Buffer): Buffer {
  // Ecrit dans le buffer existant
  data.copy(outputBuffer);
  // ... transformation dans outputBuffer
  return outputBuffer.subarray(0, data.length * 2);
}

Meme principe que le pooling, mais pour les buffers. Utile quand tu traites des flux binaires a haut debit.

Quand l'optimisation est prematuree

Tout ce que je viens de decrire a un coût en lisibilité et en complexité. Le pooling rend le code plus fragile. Les arrays plats sont moins lisibles que les objets. Les generators changent le modèle mental.

Ma regle :

  1. Ecris du code lisible d'abord
  2. Mesure avec le profiler
  3. Identifie le goulot réel (pas celui que tu imagines)
  4. Optimise uniquement le goulot
  5. Mesure a nouveau pour vérifier le gain

Si ton appli consomme 50 Mo et que tu n'as pas de problème de performance, ne touche a rien. Optimiser du code qui n'a pas besoin de l'etre, c'est perdre du temps et rendre le code plus dur a maintenir.

Résumé

  • Le pooling évité les allocations repetitives dans les boucles chaudes.
  • V8 internalise automatiquement les strings courtes et identiques.
  • Les arrays plats (Float64Array) sont 3 a 5x plus compacts que les arrays d'objets.
  • map().filter() créé des tableaux intermediaires : utilise un for pour les gros volumes.
  • Mesure avant d'optimiser. Le profiler te dira ou est le vrai problème.

Precedent : Mémoire et Docker Suivant : Comparaison avec d'autres langages

Sources

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