Le bilan mémoire JSON : ce que coûtent réellement les chaînes, objets et grands tableaux
L’essentiel : Un fichier JSON de 10 Mo peut devenir 30 à 50 Mo dans le tas V8. Apprenez ce que coûtent réellement chaînes, objets et nombres, comment établir une référence avec process.memoryUsage, et quand le flux aide.

L'essentiel : établissez d'abord une référence avec process.memoryUsage, en enregistrant le texte et le graphe d'objets comme deux incréments distincts, puis supprimez une copie complète avant de tenter quoi que ce soit d'autre — pas de JSON.parse(JSON.stringify(x)), pas d'enveloppe réactive du document brut. Coupez ensuite les références de longue durée, et envisagez le flux en dernier : sa courbe mémoire plate vaut la perte d'accès aléatoire seulement quand le fichier ne tient pas ou qu'il faut juste quelques champs.
Un document JSON de 10 Mo peut devenir 30 à 50 Mo dans le tas après
JSON.parse, et le pic transitoire monte encore plus. Sans faire cette arithmétique, l'« optimisation » relève de la supposition.
1. Le texte et le graphe d'objets sont deux bilans différents
Sur disque et dans un corps de requête, JSON est une séquence d'octets. Une fois en mémoire, il devient deux choses : une chaîne, et un graphe d'objets fait d'objets, tableaux, nombres et booléens. Garder ces deux bilans séparés est la seule façon d'expliquer pourquoi un fichier de 10 Mo peut faire consommer 80 Mo à un processus.
| Forme des données | Graphe d'objets vs taille du texte | Coût dominant |
|---|---|---|
| Tableau plat d'objets (environ 8 champs chacun) | Environ 2,5 à 4 fois | Un en-tête d'objet et des emplacements de propriétés par élément |
| Configuration profondément imbriquée | Environ 3 à 5 fois | Beaucoup de petits objets, chacun avec son propre en-tête |
| Tableau de très longues chaînes | Environ 1,2 à 2 fois | En-têtes de chaînes plus données de caractères |
| Grands nombres avec décimales | Environ 2 à 3 fois | Un HeapNumber par valeur |
| Documents avec beaucoup de clés répétées | Proportionnel au nombre de clés | Hachage de clés et configuration de propriétés pendant le parsing |
Ces multiplicateurs sont des références d'ordre de grandeur qui dépendent de la version de V8, de la compression de pointeurs, du nombre de champs et du contenu des chaînes. Le tableau fait un point : le graphe d'objets est dimensionné par « nombre de nœuds × surcoût par nœud », pas par le nombre d'octets du texte.
2. Les chaînes : le poste le plus sous-estimé
Une chaîne V8 n'est pas un simple tableau de caractères. Elle porte un en-tête d'objet (un pointeur de map, un champ de hachage et une longueur), ce qui coûte environ une douzaine d'octets sur une build 64 bits typique avec compression de pointeurs, avant tout caractère. Les caractères sont stockés de deux façons :
- Chaînes sur un octet pour les caractères de la plage Latin-1 : 1 octet par caractère ;
- Chaînes sur deux octets quand le contenu inclut des caractères CJK, des emoji ou quoi que ce soit hors Latin-1 : 2 octets par caractère.
Un caractère chinois occupe 3 octets dans un fichier UTF-8 mais 2 octets par caractère dans une chaîne sur deux octets, plus l'en-tête. La taille du texte et la taille du tas ne sont pas la même mesure, et elles ne vont pas toujours dans le même sens.
Trois détails comptent directement pour le travail JSON :
- Les tranches maintiennent le parent en vie. V8 représente les sous-chaînes plus longues comme
SlicedString, qui stocke une référence au parent plus un offset et une longueur. Stockez 200 caractères découpés d'un journal de 80 Mo et les 80 Mo entiers restent atteignables jusqu'à ce que cette tranche soit libérée. - La concaténation crée des
ConsString. Les+=répétés sur de grandes chaînes construisent un arbre de fragments qui n'est aplati en mémoire contiguë que lorsque les caractères sont réellement lus. Concaténer dix mille fois puis lire une fois produit un gros pic d'allocation à ce moment. - Les valeurs répétées ne sont pas automatiquement partagées. Dix mille enregistrements contenant
"status": "paid"obtiennent généralement dix mille objets de chaîne indépendants ; le parseur ne les fait pas pointer vers une constante. Pour économiser ce coût, il faut soi-même mapper les chaînes fréquentes sur des énumérations ou des nombres.
Quand vous devez garder un court extrait longtemps, transformez-le en chaîne indépendante et libérez l'original :
// Bad: the slice references the whole document, so logText cannot be collected
const summary = logText.slice(0, 200);
// Good: create an independent string, then release logText
const summaryCopy = Buffer.from(summary, 'utf8').toString('utf8');
3. Les objets : chaque clé occupe un emplacement
Un objet ordinaire contient au moins trois choses : un pointeur vers sa classe cachée (map), un pointeur vers son magasin de propriétés et un pointeur vers son magasin d'éléments. Avec la compression de pointeurs chaque pointeur fait 4 octets, et l'en-tête de l'objet de tas en ajoute, donc même un objet vide coûte environ seize à vingt-cinq octets. Sur une build 64 bits sans compression de pointeurs, doublez cela.
Les propriétés nommées atterrissent à l'un de deux endroits :
- Propriétés in-object : des emplacements disposés directement après l'en-tête d'objet, ce qui donne l'accès le plus rapide ;
- Un
PropertyArray: une fois les emplacements in-object épuisés, les propriétés passent dans un tableau séparé et l'objet en détient un pointeur.
Chaque emplacement fait 4 ou 8 octets, et la chaîne de clé est comptée séparément. C'est pourquoi les noms de champs longs deviennent un vrai coût sur des centaines de milliers d'enregistrements, pas seulement une question esthétique.
La bonne nouvelle est que les classes cachées sont partagées : un million d'objets avec les mêmes clés dans le même ordre partagent une map au lieu de copier des informations structurelles. La mauvaise nouvelle est que ces schémas brisent ce partage ou poussent l'objet vers une représentation plus coûteuse :
- Certains enregistrements ont un champ
remarket d'autres non, produisant deux formes et une vérification de forme supplémentaire à chaque accès ; - Des ordres d'insertion différents peuvent produire des formes différentes ;
- Des nombres de propriétés très élevés (au-delà d'environ un millier) ou des
deletefréquents poussent l'objet en mode dictionnaire, où chaque propriété porte aussi une entrée de table de hachage ; Object.create(null)est en mode dictionnaire dès la naissance. C'est une bonne table de correspondance et un mauvais choix pour un enregistrement à schéma fixe accédé dans une boucle chaude.
Une idée fausse courante mérite une correction : JSON autorise les clés dupliquées, et {"a":1,"a":2} est un document valide dont le résultat est a === 2. L'objet final garde une valeur, mais le parseur balaie et assigne quand même chaque occurrence. Les clés dupliquées n'économisent pas de mémoire ; elles ne coûtent que du temps de parsing.
4. Tableaux et nombres : les kinds d'éléments fixent la largeur
V8 suit plusieurs « kinds d'éléments » pour les tableaux, et chacun a une largeur par emplacement différente :
| Contenu du tableau | Kind d'élément | Coût approximatif par emplacement |
|---|---|---|
| Petits entiers (dans la plage Smi) | PACKED_SMI_ELEMENTS |
4 octets |
| Décimales ou très grands entiers | PACKED_DOUBLE_ELEMENTS |
8 octets |
| Chaînes ou objets | PACKED_ELEMENTS |
Pointeur 4 octets plus la valeur elle-même |
Tableaux avec trous créés par delete |
HOLEY_* |
Idem, plus vérifications de trous |
| Tableaux très creux | DICTIONARY_ELEMENTS |
Entrées de hachage, généralement 20+ octets |
Deux règles méritent d'être mémorisées :
Un, les kinds d'éléments ne font que se dégrader. Un tableau qui commence avec de petits entiers devient un tableau de doubles dès que vous insérez 0.5, et un tableau d'éléments générique quand vous insérez une chaîne. Il ne récupère jamais automatiquement ; il faut construire un nouveau tableau.
Deux, l'endroit où atterrit un nombre JSON dépend de sa valeur. Les entiers dans la plage Smi (environ plus ou moins 1,07 milliard) sont stockés directement comme Smi et occupent un emplacement de pointeur. Les nombres avec décimales ou exposants, ou les valeurs hors de cette plage, allouent un HeapNumber d'environ 16 octets, avec un pointeur vers lui dans le tableau. Donc [1, 2, 3] et [0.1, 0.2, 0.3] diffèrent non seulement en valeur mais en « un emplacement de 4 octets » contre « un emplacement de 8 octets plus un objet de tas ».
C'est aussi la racine du problème de précision des identifiants grands entiers : au-delà de la plage entière sûre, les nombres JSON sont traités comme des doubles et les chiffres de poids faible disparaissent simplement. Les tableaux JSON arrivent packés et sans trous, donc ne faites pas de delete d'éléments après le parsing ; cela pousse tout le tableau vers une représentation plus coûteuse.
5. Pics de parsing et de sérialisation : le moment où un processus meurt réellement
La mémoire stable est un bilan ; le pic en est un autre. Pendant que JSON.parse(text) tourne, le processus détient trois choses à la fois :
- La chaîne d'entrée
text; - Le graphe d'objets en construction et pas encore complet ;
- Les structures temporaires propres au parseur.
Le pic de parsing est environ « texte + graphe d'objets + surcoût temporaire ». Après le parsing, si text est encore référencé par une variable, une closure, un cache au niveau module ou un store réactif, cette mémoire de texte n'est jamais rendue.
La sérialisation a aussi un pic. JSON.stringify(data) détient tout le graphe d'objets pendant qu'il construit la chaîne de sortie, avec des tampons de concaténation entre les deux. Quand la sortie approche la limite de longueur de chaîne de V8 (environ 500 millions de caractères), elle lève un RangeError ; cette défaillance apparaît généralement dans les scripts qui exportent toute une base de données en un seul document JSON.
Le schéma courant le plus coûteux utilise la sérialisation comme clone profond :
// Peak is roughly: original object graph + temporary text + new object graph
const safeCopy = JSON.parse(JSON.stringify(bigData));
Cette seule ligne stocke trois fois les mêmes données. Pour du travail en lecture seule, passez une référence. Quand l'isolation est réellement nécessaire, utilisez structuredClone et acceptez qu'il reconstruise aussi tout le graphe d'objets.
6. Mesurer avec process.memoryUsage
Dans Node, process.memoryUsage() renvoie cinq nombres à la fois : rss (mémoire physique utilisée par le processus), heapTotal, heapUsed (tas JS utilisé), external (mémoire détenue par des objets C++), et arrayBuffers (mémoire des ArrayBuffer et Buffer). Pour étudier le traitement JSON, heapUsed est la bonne lentille pour le graphe d'objets et rss vous dit si tout le processus échappe au contrôle.
Un script de mesure exige trois disciplines : appeler global.gc() d'abord, échantillonner par étapes, et changer une seule variable à la fois. Ce script enregistre séparément les incréments pour « lire le texte » et « parser en objets » :
// Run: node --expose-gc measure-json-memory.mjs
import { readFileSync } from 'node:fs';
global.gc();
const baseline = process.memoryUsage().heapUsed;
const text = readFileSync('big.json', 'utf8');
const afterRead = process.memoryUsage().heapUsed;
const data = JSON.parse(text);
const afterParse = process.memoryUsage().heapUsed;
console.log({
textOnHeap: afterRead - baseline,
graphOnHeap: afterParse - afterRead,
parsePeak: afterParse - baseline,
});
Quelques pièges :
--expose-gcdoit être activé explicitement ; sinonglobal.gcn'existe pas ;heapUsedne couvre que le tas JS. Les Buffers de lecture de fichiers vivent sousexternaletarrayBuffers, donc n'utilisez pas un seul nombre pour tout expliquer ;- Ne faites pas de
console.logd'un grand objet entre les échantillons ; cela maintient une référence temporaire en vie ; - La première exécution inclut le préchauffage JIT et la compilation paresseuse. Exécutez au moins trois tours et prenez la médiane ;
- Pour comparer dans un navigateur, prenez des snapshots de tas dans le panneau Memory de DevTools et lisez
Retained Sizeplutôt que de fixer la dent de scie dans l'Allocation Timeline.
Sans cette référence, des affirmations comme « passer au flux va corriger ça » ou « un cache va accélérer » ne sont que des impressions.
7. Le flux achète de la mémoire de pic, pas du temps total
Le compromis du parsing en flux est explicite : accès aléatoire en échange d'une mémoire qui ne croît plus avec la taille du fichier.
| Approche | Mémoire de pic | Accès aléatoire | Idéale pour |
|---|---|---|---|
JSON.parse complet |
Texte plus le graphe d'objets complet | Oui | Moins d'environ 100 Mo et la plupart des champs sont nécessaires |
Un parseur SAX comme stream-json |
Un chunk plus l'élément courant | Non | Fichiers énormes, ou seuls quelques champs sont nécessaires |
| JSON Lines ligne par ligne | L'objet d'une seule ligne | Non | Données qui sont naturellement un lot d'enregistrements |
| Index externe ou base de données | Rien de chargé en entier | Oui | Requêtes répétées sur les mêmes données |
import { createReadStream } from 'node:fs';
import { parser } from 'stream-json';
import { pick } from 'stream-json/filters/Pick';
import { streamArray } from 'stream-json/streamers/StreamArray';
const pipeline = createReadStream('huge.json')
.pipe(parser())
.pipe(pick({ filter: 'items' }))
.pipe(streamArray());
for await (const { value } of pipeline) {
handleItem(value);
}
Ce code ne construit jamais l'arbre complet, donc la courbe mémoire reste plate. Les coûts sont réels aussi : vous ne pouvez pas revenir chercher un champ sauté, les agrégations globales deviennent difficiles, la récupération d'erreurs est votre responsabilité, et un balayage caractère par caractère en pur JS est généralement un ordre de grandeur plus lent que le JSON.parse natif.
La décision n'est pas « le fichier a-t-il dépassé 10 Mo ». Posez deux questions : ai-je besoin de la plupart des champs après le parsing ? Puis-je vivre avec un accès séquentiel ? Si les deux réponses sont oui, le parsing complet est correct et votre effort appartient à la réduction des copies et au rendu. Si l'une des réponses est non, envisagez le flux. Pour la stratégie complète par couches, voir Parser, rendre et diffuser de grands JSON.
8. Moins de copies, libération plus tôt
Dans le travail mémoire, supprimer une copie complète vaut souvent plus que changer de parseur :
- Passez des références pour le travail en lecture seule. Ne clonez pas profondément « pour la sûreté », surtout pas avec
JSON.parse(JSON.stringify(x)); - N'enveloppez pas le document brut dans un système réactif. Le
reactivede Vue proxifie tout le graphe d'objets récursivement, et la couche proxy a son propre coût mémoire ; - Calculez et mettez en cache les données dérivées séparément. Si une liste n'a besoin que de id, title et status, ne mettez pas des enregistrements entiers dans le store ;
- Normalisez les chaînes à haute fréquence. Mappez des valeurs comme
"paid"et"pending"sur des énumérations numériques pour économiser à la fois les objets de chaîne et le coût de comparaison ; - Coupez les références de longue durée. Mettez à null les grands objets quand c'est fini, videz les caches et désenregistrez les callbacks pour que les closures ne les transportent pas vers la page suivante ;
- Tenez compte des coûts de copie des Workers.
postMessageutilise le clonage structuré par défaut ; renvoyer un graphe d'objets de 50 Mo au thread principal construit une seconde copie. Renvoyez un résumé dès que possible.
L'autre moitié du bilan de taille vit dans Arrêtez de compresser JSON dans le mauvais ordre : les octets disque et les octets sur le fil sont un compte différent du graphe d'objets dans le tas, et aucun pourcentage unique n'explique les deux. Pour le comportement mémoire des éditeurs et outils CLI, voir Éditeurs, outils en ligne de commande et automatisation.
9. Liste de vérification
| Symptôme | Cause plus probable | Première action |
|---|---|---|
| Un fichier de 10 Mo pousse le processus à 50 Mo | Le graphe d'objets et le texte restent tous deux en vie | Mesurer par étapes ; vérifier si le texte est retenu |
| OOM au parsing, normal en régime stable | Le pic de parsing chevauche les anciennes données | Libérer les anciens objets avant de parser le nouveau fichier |
| La mémoire monte lentement et ne redescend jamais | Une tranche, un cache ou une closure détient une référence | Prendre un snapshot de tas et trier par taille retenue |
| Les tableaux sont plus gros que prévu | Une décimale a dégradé le kind d'éléments | Unifier les types numériques ; éviter delete |
| Ajouter un cache a ralenti les choses | Les clés et valeurs consomment de la mémoire, augmentant la pression GC | Borner le cache et ne stocker que des résumés |
Pourquoi la mémoire triple-t-elle ou quadruple-t-elle après JSON.parse ?
Parce que le texte n'est que l'entrée ; le produit du parsing est un graphe d'objets. Chaque objet a un en-tête, chaque propriété a un emplacement, et chaque chaîne a son propre en-tête. Avec assez de nœuds, le surcoût total dépasse le nombre d'octets du texte. Le multiplicateur dépend de la forme des données, et les tableaux plats d'objets diffèrent beaucoup des documents profondément imbriqués.
Un fichier JSON de 10 Mo doit-il être parsé en entier ou en flux ?
Regardez d'abord le but. Si vous avez besoin d'un accès aléatoire, de la plupart des champs, et que le fichier tient en mémoire, utilisez JSON.parse complet. Si vous n'avez besoin que de quelques champs, ou que le fichier ne tient pas, passez au flux. 10 Mo n'est pas une frontière ; le flux devient généralement obligatoire quelque part au-delà de 100 Mo.
Pourquoi process.memoryUsage().heapUsed ne concorde-t-il pas avec la surveillance système ?
heapUsed ne couvre que la portion utilisée du tas JS, excluant external, arrayBuffers et l'espace de code. La surveillance système rapporte rss, qui est une mesure différente. Suivez heapUsed et rss ensemble ; un grand écart pointe vers les Buffers et les objets natifs.
JSON.parse(JSON.stringify(x)) est-il un clone profond sûr ?
Non. Il détient le graphe original, le texte temporaire et le nouveau graphe en même temps, donc le pic est environ trois fois l'état stable, et il supprime undefined, les fonctions et la sémantique de Date. Passez des références pour le travail en lecture seule ; quand l'isolation est nécessaire, utilisez structuredClone et acceptez le même coût de reconstruction.