A contabilidade de memória do JSON: quanto custam realmente strings, objetos e arrays grandes
Resumo: Um arquivo JSON de 10MB pode virar 30-50MB no heap do V8. Aprenda quanto strings, objetos e números realmente custam, como estabelecer a linha de base com `process.memoryUsage` e quando o streaming ajuda.

Resumo da ópera: estabeleça a linha de base com process.memoryUsage primeiro, registrando o texto e o grafo de objetos como dois incrementos separados, e depois remova uma cópia completa antes de tentar qualquer outra coisa — nada de JSON.parse(JSON.stringify(x)), nada de envolver o documento cru em reatividade. Corte referências de vida longa em seguida e considere o streaming por último: sua curva de memória plana só vale a pena perder o acesso aleatório quando o arquivo não cabe ou quando você precisa de apenas alguns campos.
Um documento JSON de 10MB pode virar 30MB a 50MB no heap após o
JSON.parse, e o pico transiente vai mais alto ainda. Sem fazer essa aritmética, "otimização" é adivinhação.
1. Texto e o grafo de objetos são dois livros-caixa diferentes
Em disco e em um corpo de requisição, JSON é uma sequência de bytes. Uma vez que entra na memória, ele se torna duas coisas: uma string e um grafo de objetos feito de objetos, arrays, números e booleanos. Manter esses dois livros-caixa separados é a única forma de explicar por que um arquivo de 10MB pode fazer um processo consumir 80MB.
| Forma dos dados | Grafo de objetos vs. tamanho do texto | Custo dominante |
|---|---|---|
| Array plano de objetos (cerca de 8 campos cada) | Aproximadamente 2,5-4x | Um cabeçalho de objeto e slots de propriedade por elemento |
| Configuração profundamente aninhada | Aproximadamente 3-5x | Muitos objetos pequenos, cada um com seu próprio cabeçalho |
| Array de strings muito longas | Aproximadamente 1,2-2x | Cabeçalhos de string mais dados de caracteres |
| Números grandes com decimais | Aproximadamente 2-3x | Um HeapNumber por valor |
| Documentos com muitas chaves repetidas | Proporcional à contagem de chaves | Hash de chaves e configuração de propriedades durante a análise |
Esses multiplicadores são referências de ordem de magnitude que dependem da versão do V8, da compressão de ponteiros, da contagem de campos e do conteúdo das strings. A tabela faz um ponto: o grafo de objetos é dimensionado por "número de nós x overhead por nó", não pelo número de bytes no texto.
2. Strings: o item mais subestimado
Uma string do V8 não é um array de caracteres nu. Ela carrega um cabeçalho de objeto (um ponteiro de mapa, um campo de hash e um comprimento), que custa cerca de uma dúzia de bytes em uma build típica de 64 bits com compressão de ponteiros, antes de qualquer caractere. Os caracteres são armazenados de uma de duas formas:
- Strings de um byte para caracteres na faixa Latin-1: 1 byte por caractere;
- Strings de dois bytes quando o conteúdo inclui caracteres CJK, emoji ou qualquer coisa fora do Latin-1: 2 bytes por caractere.
Um caractere chinês ocupa 3 bytes em um arquivo UTF-8, mas 2 bytes por caractere em uma string de dois bytes, mais o cabeçalho. Tamanho do texto e tamanho do heap não são a mesma medida, e nem sempre se movem na mesma direção.
Três detalhes importam diretamente para o trabalho com JSON:
- Slices mantêm o pai vivo. O V8 representa substrings mais longas como
SlicedString, que armazena uma referência ao pai mais um offset e um comprimento. Guarde 200 caracteres fatiados de um log de 80MB e os 80MB inteiros permanecem alcançáveis até que esse slice seja liberado. - Concatenação cria
ConsString.+=repetido em strings grandes constrói uma árvore de fragmentos que só é achatada em memória contígua quando os caracteres são realmente lidos. Concatenar dez mil vezes e depois ler uma vez produz um pico grande de alocação naquele momento. - Valores repetidos não são compartilhados automaticamente. Dez mil registros contendo
"status": "paid"normalmente geram dez mil objetos de string independentes; o analisador não os aponta para uma constante. Para economizar esse custo, você precisa mapear strings frequentes para enums ou números você mesmo.
Quando precisar manter um trecho curto por muito tempo, transforme-o em uma string independente e libere a original:
// Ruim: o slice referencia o documento inteiro, então logText não pode ser coletado
const summary = logText.slice(0, 200);
// Bom: crie uma string independente e depois libere logText
const summaryCopy = Buffer.from(summary, 'utf8').toString('utf8');
3. Objetos: cada chave ocupa um slot
Um objeto comum contém pelo menos três coisas: um ponteiro para sua classe oculta (map), um ponteiro para seu armazenamento de propriedades e um ponteiro para seu armazenamento de elementos. Com compressão de ponteiros cada ponteiro tem 4 bytes, e o cabeçalho do objeto no heap acrescenta mais, então até um objeto vazio custa aproximadamente dezesseis a vinte e tantos bytes. Em uma build de 64 bits sem compressão de ponteiros, o dobro.
Propriedades nomeadas caem em um de dois lugares:
- Propriedades in-object: slots dispostos diretamente depois do cabeçalho do objeto, o que dá o acesso mais rápido;
- Um
PropertyArray: quando os slots in-object se esgotam, as propriedades se movem para um array separado e o objeto guarda um ponteiro para ele.
Cada slot tem 4 ou 8 bytes, e a string da chave é contabilizada separadamente. É por isso que nomes de campo longos se tornam um custo real em centenas de milhares de registros, não apenas uma questão estética.
A boa notícia é que as classes ocultas são compartilhadas: um milhão de objetos com as mesmas chaves na mesma ordem compartilham um mapa em vez de copiar informação estrutural. A má notícia é que estes padrões quebram esse compartilhamento ou empurram o objeto para uma representação mais cara:
- Alguns registros têm um campo
remarke outros não, produzindo duas formas e uma verificação extra de forma em cada acesso; - Ordens de inserção diferentes podem produzir formas diferentes;
- Contagens de propriedade muito grandes (acima de cerca de mil) ou operações
deletefrequentes empurram o objeto para o modo dicionário, onde toda propriedade também carrega uma entrada de tabela hash; Object.create(null)está em modo dicionário desde o nascimento. É uma boa tabela de consulta e uma escolha ruim para um registro de schema fixo acessado em um laço quente.
Uma confusão comum merece correção: JSON permite chaves duplicadas, e {"a":1,"a":2} é um documento válido cujo resultado é a === 2. O objeto final mantém um valor, mas o analisador ainda varre e atribui cada ocorrência. Chaves duplicadas não economizam memória; elas só custam tempo de análise.
4. Arrays e números: os kinds de elemento definem a largura
O V8 rastreia vários "kinds de elemento" para arrays, e cada um tem uma largura por slot diferente:
| Conteúdo do array | Kind de elemento | Custo aproximado por slot |
|---|---|---|
| Inteiros pequenos (dentro da faixa Smi) | PACKED_SMI_ELEMENTS |
4 bytes |
| Decimais ou inteiros muito grandes | PACKED_DOUBLE_ELEMENTS |
8 bytes |
| Strings ou objetos | PACKED_ELEMENTS |
Ponteiro de 4 bytes mais o valor em si |
Arrays com lacunas criadas por delete |
HOLEY_* |
O mesmo, mais verificações de lacuna |
| Arrays muito esparsos | DICTIONARY_ELEMENTS |
Entradas de hash, tipicamente 20+ bytes |
Vale memorizar duas regras:
Primeira, os kinds de elemento só degradam. Um array que começa com inteiros pequenos vira um array de doubles no momento em que você insere 0.5, e um array de elementos genéricos quando você insere uma string. Ele nunca se recupera automaticamente; você precisa construir um novo array.
Segunda, onde um número JSON cai depende do seu valor. Inteiros dentro da faixa Smi (aproximadamente mais ou menos 1,07 bilhão) são armazenados diretamente como Smi e ocupam um slot de ponteiro. Números com decimais ou expoentes, ou valores fora dessa faixa, alocam um HeapNumber de cerca de 16 bytes, com um ponteiro para ele armazenado no array. Então [1, 2, 3] e [0.1, 0.2, 0.3] diferem não apenas em valor, mas em "um slot de 4 bytes" versus "um slot de 8 bytes mais um objeto no heap".
Esta é também a raiz do problema de precisão de IDs inteiros grandes: além da faixa de inteiro seguro, números JSON são tratados como doubles e os dígitos de ordem baixa simplesmente desaparecem. Arrays JSON chegam empacotados e sem lacunas, então não faça delete de elementos após a análise; isso empurra o array inteiro para uma representação mais cara.
5. Picos de análise e serialização: o momento em que um processo realmente morre
A memória em regime é um livro-caixa; o pico é outro. Enquanto JSON.parse(text) roda, o processo mantém três coisas ao mesmo tempo:
- A string de entrada
text; - O grafo de objetos sendo construído e ainda incompleto;
- As estruturas temporárias do próprio analisador.
O pico de análise é aproximadamente "texto + grafo de objetos + overhead temporário". Após a análise, se text ainda é referenciado por uma variável, uma closure, um cache de nível de módulo ou um store reativo, essa memória do texto nunca é devolvida.
A serialização também tem um pico. JSON.stringify(data) mantém o grafo de objetos inteiro enquanto constrói a string de saída, com buffers de concatenação no meio. Quando a saída se aproxima do limite de comprimento de string do V8 (cerca de 500 milhões de caracteres), ele lança um RangeError; essa falha normalmente aparece em scripts que exportam um banco de dados inteiro como um único documento JSON.
O padrão caro mais comum usa a serialização como clonagem profunda:
// O pico é aproximadamente: grafo de objetos original + texto temporário + novo grafo de objetos
const safeCopy = JSON.parse(JSON.stringify(bigData));
Essa única linha armazena os mesmos dados três vezes. Para trabalho somente leitura, passe uma referência. Quando o isolamento é realmente necessário, use structuredClone e aceite que ele também reconstrói o grafo de objetos inteiro.
6. Medindo com process.memoryUsage
No Node, process.memoryUsage() retorna cinco números de uma vez: rss (memória física usada pelo processo), heapTotal, heapUsed (heap JS usado), external (memória mantida por objetos C++) e arrayBuffers (memória de ArrayBuffer e Buffer). Ao estudar o processamento de JSON, heapUsed é a lente certa para o grafo de objetos e rss diz se o processo inteiro está fora de controle.
Um script de medição precisa de três disciplinas: chame global.gc() primeiro, amostre em etapas e mude uma variável de cada vez. Este script registra os incrementos de "ler o texto" e "analisar em objetos" separadamente:
// Execute: 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,
});
Algumas armadilhas:
--expose-gcprecisa ser habilitado explicitamente; caso contrárioglobal.gcnão existe;heapUsedcobre apenas o heap JS. Buffers de leitura de arquivo vivem emexternalearrayBuffers, então não use um número para explicar tudo;- Não faça
console.logde um objeto grande entre amostras; isso mantém uma referência temporária viva; - A primeira execução inclui aquecimento do JIT e compilação preguiçosa. Rode pelo menos três rodadas e pegue a mediana;
- Para comparar no navegador, tire heap snapshots no painel Memory do DevTools e leia o
Retained Sizeem vez de encarar o serrilhado na Allocation Timeline.
Sem essa linha de base, afirmações como "mudar para streaming vai resolver" ou "um cache vai deixar mais rápido" são apenas sensações.
7. O streaming compra pico de memória, não tempo total
A troca na análise em streaming é explícita: acesso aleatório em troca de memória que não escala mais com o tamanho do arquivo.
| Abordagem | Pico de memória | Acesso aleatório | Melhor para |
|---|---|---|---|
JSON.parse completo |
Texto mais o grafo de objetos completo | Sim | Abaixo de cerca de 100MB e a maioria dos campos é necessária |
Um analisador SAX como stream-json |
Um chunk mais o elemento atual | Não | Arquivos enormes, ou apenas alguns campos são necessários |
| JSON Lines linha a linha | O objeto de uma linha | Não | Dados que são naturalmente um lote de registros |
| Índice externo ou banco de dados | Nada carregado por inteiro | Sim | Consultas repetidas sobre os mesmos dados |
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);
}
Esse código nunca constrói a árvore completa, então a curva de memória permanece plana. Os custos também são reais: você não consegue voltar para um campo que pulou, agregações globais ficam difíceis, a recuperação de erros é sua responsabilidade, e uma varredura caractere por caractere em JS puro costuma ser uma ordem de magnitude mais lenta que o JSON.parse nativo.
A decisão não é "o arquivo passou de 10MB". Faça duas perguntas: Preciso da maioria dos campos após a análise? Consigo viver com acesso sequencial? Se as duas respostas são sim, a análise completa está certa e seu esforço pertence a reduzir cópias e renderização. Se qualquer resposta é não, considere streaming. Para a estratégia completa em camadas, veja Análise, renderização e streaming de JSON grande.
8. Menos cópias, liberação mais cedo
No trabalho com memória, remover uma cópia completa muitas vezes vale mais do que trocar analisadores:
- Passe referências para trabalho somente leitura. Não faça clonagem profunda "por segurança", especialmente não com
JSON.parse(JSON.stringify(x)); - Não envolva o documento cru em um sistema reativo. O
reactivedo Vue faz proxy do grafo de objetos inteiro recursivamente, e a camada de proxy tem seu próprio custo de memória; - Calcule e faça cache de dados derivados separadamente. Se uma lista só precisa de id, título e status, não coloque registros inteiros no store;
- Normalize strings de alta frequência. Mapeie valores como
"paid"e"pending"para enums numéricos para economizar tanto objetos de string quanto custo de comparação; - Corte referências de vida longa. Anule objetos grandes ao terminar, limpe caches e cancele o registro de callbacks para que as closures não os carreguem para a próxima página;
- Contabilize os custos de cópia do Worker.
postMessageusa clonagem estruturada por padrão; enviar um grafo de objetos de 50MB de volta à thread principal constrói uma segunda cópia. Devolva um resumo sempre que possível.
A outra metade do livro-caixa de tamanho vive em Pare de comprimir JSON na ordem errada: bytes em disco e bytes no fio são uma conta diferente do grafo de objetos no heap, e nenhuma porcentagem única explica os dois. Para como editores e ferramentas de CLI se comportam com memória, veja Editores, ferramentas de linha de comando e automação.
9. Lista de verificação
| Sintoma | Causa mais provável | Primeira ação |
|---|---|---|
| Um arquivo de 10MB empurra o processo para 50MB | O grafo de objetos e o texto ambos permanecem vivos | Meça em etapas; verifique se o texto é retido |
| OOM em tempo de análise, normal em regime | O pico de análise se sobrepõe aos dados antigos | Libere objetos antigos antes de analisar o novo arquivo |
| A memória sobe devagar e nunca cai | Um slice, cache ou closure mantém uma referência | Tire um heap snapshot e ordene por tamanho retido |
| Arrays são maiores do que o esperado | Um decimal degradou o kind de elemento | Unifique os tipos numéricos; evite delete |
| Adicionar um cache deixou as coisas mais lentas | Chaves e valores consomem memória, elevando a pressão de GC | Limite o cache e armazene apenas resumos |
Por que a memória cresce três ou quatro vezes depois do JSON.parse?
Porque o texto é apenas a entrada; o produto da análise é um grafo de objetos. Todo objeto tem um cabeçalho, toda propriedade tem um slot e toda string tem seu próprio cabeçalho. Com nós suficientes, o overhead total excede a contagem de bytes do texto. O multiplicador depende da forma dos dados, e arrays planos de objetos diferem muito de documentos profundamente aninhados.
Um arquivo JSON de 10MB deve ser analisado por inteiro ou em streaming?
Olhe a finalidade primeiro. Se você precisa de acesso aleatório, precisa da maioria dos campos e o arquivo cabe na memória, use JSON.parse completo. Se precisa de apenas alguns campos, ou o arquivo não cabe, faça streaming. 10MB não é uma fronteira; o streaming normalmente se torna obrigatório em algum ponto acima de 100MB.
Por que process.memoryUsage().heapUsed discorda do monitoramento do sistema?
heapUsed cobre apenas a porção usada do heap JS, excluindo external, arrayBuffers e code space. O monitoramento do sistema informa rss, que é outra medida. Acompanhe heapUsed e rss juntos; uma lacuna grande aponta para Buffers e objetos nativos.
JSON.parse(JSON.stringify(x)) é uma clonagem profunda segura?
Não. Ele mantém o grafo original, o texto temporário e o novo grafo ao mesmo tempo, então o pico é aproximadamente três vezes o regime, e ele descarta undefined, funções e a semântica de Date. Passe referências para trabalho somente leitura; quando o isolamento for necessário, use structuredClone e aceite o mesmo custo de reconstrução.