Rachunek pamięci JSON: ile naprawdę kosztują łańcuchy, obiekty i duże tablice
Podsumowanie: Plik JSON o rozmiarze 10 MB może stać się 30–50 MB w stercie V8. Dowiedz się, ile naprawdę kosztują łańcuchy, obiekty i liczby, jak ustanowić baseline przez process.memoryUsage i kiedy strumieniowanie pomaga.

Najważniejsze: najpierw ustanów baseline przez process.memoryUsage, rejestrując tekst i graf obiektów jako dwa osobne przyrosty, a następnie usuń jedną pełną kopię, zanim spróbujesz czegokolwiek innego — żadnego JSON.parse(JSON.stringify(x)), żadnego owijania surowego dokumentu w reaktywność. Potem utnij długo żyjące referencje, a strumieniowanie rozważ na końcu: jego płaska krzywa pamięci jest warta utraty dostępu swobodnego tylko wtedy, gdy plik się nie mieści albo potrzebujesz zaledwie kilku pól.
Dokument JSON o rozmiarze 10 MB może stać się 30–50 MB w stercie po
JSON.parse, a przejściowy szczyt idzie jeszcze wyżej. Bez tej arytmetyki „optymalizacja” jest zgadywaniem.
1. Tekst i graf obiektów to dwa różne rachunki
Na dysku i w treści żądania JSON to sekwencja bajtów. Gdy trafi do pamięci, staje się dwiema rzeczami: łańcuchem oraz grafem obiektów złożonym z obiektów, tablic, liczb i wartości logicznych. Utrzymywanie tych dwóch rachunków osobno to jedyny sposób, by wyjaśnić, dlaczego plik o rozmiarze 10 MB może sprawić, że proces zużywa 80 MB.
| Kształt danych | Graf obiektów vs. rozmiar tekstu | Dominujący koszt |
|---|---|---|
| Płaska tablica obiektów (około 8 pól każda) | Mniej więcej 2,5–4x | Jeden nagłówek obiektu i sloty właściwości per element |
| Głęboko zagnieżdżona konfiguracja | Mniej więcej 3–5x | Wiele małych obiektów, każdy z własnym nagłówkiem |
| Tablica bardzo długich łańcuchów | Mniej więcej 1,2–2x | Nagłówki łańcuchów plus dane znaków |
| Duże liczby z ułamkami | Mniej więcej 2–3x | Jedna HeapNumber per wartość |
| Dokumenty z wieloma powtórzonymi kluczami | Proporcjonalnie do liczby kluczy | Haszowanie kluczy i konfiguracja właściwości podczas parsowania |
Te mnożniki są odniesieniem rzędu wielkości, zależnym od wersji V8, kompresji wskaźników, liczby pól i zawartości łańcuchów. Tabela robi jeden punkt: graf obiektów ma rozmiar „liczba węzłów x narzut per węzeł”, a nie liczbę bajtów w tekście.
2. Łańcuchy: najbardziej niedoszacowana pozycja
Łańcuch V8 to nie naga tablica znaków. Niesie nagłówek obiektu (wskaźnik mapy, pole hasha i długość), który kosztuje około kilkunastu bajtów w typowej 64-bitowej kompilacji z kompresją wskaźników, zanim pojawi się jakikolwiek znak. Znaki są przechowywane na jeden z dwóch sposobów:
- Łańcuchy jednobajtowe dla znaków w zakresie Latin-1: 1 bajt per znak;
- Łańcuchy dwubajtowe, gdy treść zawiera znaki CJK, emoji lub cokolwiek spoza Latin-1: 2 bajty per znak.
Chiński znak zajmuje 3 bajty w pliku UTF-8, ale 2 bajty per znak w łańcuchu dwubajtowym, plus nagłówek. Rozmiar tekstu i rozmiar sterty to nie ta sama miara i nie zawsze poruszają się w tym samym kierunku.
Trzy szczegóły mają bezpośrednie znaczenie dla pracy z JSON:
- Wycinki utrzymują rodzica przy życiu. V8 przedstawia dłuższe podłańcuchy jako
SlicedString, który przechowuje referencję do rodzica plus przesunięcie i długość. Przechowaj 200 znaków wyciętych z 80-megabajtowego logu, a całe 80 MB pozostaje osiągalne, dopóki wycinek nie zostanie zwolniony. - Konkatenacja tworzy
ConsString. Powtarzane+=na dużych łańcuchach buduje drzewo fragmentów, które jest spłaszczane do ciągłej pamięci dopiero przy faktycznym odczycie znaków. Dziesięć tysięcy konkatenacji i jeden odczyt produkują duży skok alokacji w tym momencie. - Powtarzane wartości nie są automatycznie współdzielone. Dziesięć tysięcy rekordów zawierających
"status": "paid"zwykle dostaje dziesięć tysięcy niezależnych obiektów łańcuchowych; parser nie wskazuje ich na jedną stałą. Aby oszczędzić ten koszt, musisz samodzielnie zmapować częste łańcuchy na wyliczenia lub liczby.
Gdy musisz trzymać krótki fragment przez długi czas, zamień go w niezależny łańcuch i zwolnij oryginał:
// 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. Obiekty: każdy klucz zajmuje slot
Zwykły obiekt zawiera co najmniej trzy rzeczy: wskaźnik do swojej ukrytej klasy (mapy), wskaźnik do magazynu właściwości i wskaźnik do magazynu elementów. Przy kompresji wskaźników każdy wskaźnik ma 4 bajty, a nagłówek obiektu sterty dokłada więcej, więc nawet pusty obiekt kosztuje około szesnastu do dwudziestu kilku bajtów. W 64-bitowej kompilacji bez kompresji wskaźników podwój to.
Właściwości nazwane lądują w jednym z dwóch miejsc:
- Właściwości w obiekcie: sloty ułożone bezpośrednio za nagłówkiem obiektu, co daje najszybszy dostęp;
PropertyArray: gdy sloty w obiekcie się wyczerpią, właściwości przenoszą się do osobnej tablicy, a obiekt trzyma wskaźnik do niej.
Każdy slot ma 4 lub 8 bajtów, a łańcuch klucza jest rozliczany osobno. Dlatego długie nazwy pól stają się realnym kosztem w setkach tysięcy rekordów, a nie tylko kwestią estetyki.
Dobra wiadomość: ukryte klasy są współdzielone: milion obiektów z tymi samymi kluczami w tej samej kolejności dzieli jedną mapę, zamiast kopiować informację strukturalną. Zła wiadomość: te wzorce psują to współdzielenie albo wpychają obiekt w droższe przedstawienie:
- Niektóre rekordy mają pole
remark, a inne nie, co produkuje dwa kształty i dodatkową kontrolę kształtu przy każdym dostępie; - Różne kolejności wstawiania mogą produkować różne kształty;
- Bardzo duża liczba właściwości (ponad około tysiąc) lub częste operacje
deletewpychają obiekt w tryb słownikowy, gdzie każda właściwość niesie też wpis tablicy haszującej; Object.create(null)jest w trybie słownikowym od urodzenia. To dobra tablica wyszukiwania i zły wybór dla rekordu o stałym schemacie odczytywanego w gorącej pętli.
Jedno częste błędne przekonanie zasługuje na sprostowanie: JSON pozwala na zduplikowane klucze, a {"a":1,"a":2} to poprawny dokument, którego wynikiem jest a === 2. Końcowy obiekt zachowuje jedną wartość, ale parser nadal skanuje i przypisuje każde wystąpienie. Zduplikowane klucze nie oszczędzają pamięci; kosztują tylko czas parsowania.
4. Tablice i liczby: rodzaje elementów ustalają szerokość
V8 śledzi kilka „rodzajów elementów” dla tablic, a każdy ma inną szerokość per slot:
| Zawartość tablicy | Rodzaj elementów | Przybliżony koszt per slot |
|---|---|---|
| Małe liczby całkowite (w zakresie Smi) | PACKED_SMI_ELEMENTS |
4 bajty |
| Ułamki lub bardzo duże liczby całkowite | PACKED_DOUBLE_ELEMENTS |
8 bajtów |
| Łańcuchy lub obiekty | PACKED_ELEMENTS |
4-bajtowy wskaźnik plus sama wartość |
Tablice z dziurami utworzonymi przez delete |
HOLEY_* |
To samo plus kontrole dziur |
| Bardzo rzadkie tablice | DICTIONARY_ELEMENTS |
Wpisy haszowe, zwykle 20+ bajtów |
Dwie zasady warto zapamiętać:
Po pierwsze, rodzaje elementów tylko się degradują. Tablica, która startuje z małymi liczbami całkowitymi, staje się tablicą double w momencie wstawienia 0.5, a tablicą elementów ogólnych po wstawieniu łańcucha. Nigdy nie wraca automatycznie; musisz zbudować nową tablicę.
Po drugie, gdzie ląduje liczba JSON, zależy od jej wartości. Liczby całkowite w zakresie Smi (około plus/minus 1,07 miliarda) są przechowywane bezpośrednio jako Smi i zajmują jeden slot wskaźnika. Liczby z ułamkami lub wykładnikami albo wartości poza tym zakresem alokują HeapNumber o rozmiarze około 16 bajtów, ze wskaźnikiem do niego w tablicy. Więc [1, 2, 3] i [0.1, 0.2, 0.3] różnią się nie tylko wartością, ale „jednym slotem 4 bajtów” versus „slotem 8 bajtów plus obiekt sterty”.
To także źródło problemu precyzji identyfikatorów dużych liczb całkowitych: poza bezpiecznym zakresem liczby JSON są traktowane jako double i cyfry niższego rzędu po prostu znikają. Tablice JSON przychodzą upakowane i bez dziur, więc nie rób delete na elementach po parsowaniu; to popycha całą tablicę w droższe przedstawienie.
5. Szczyty parsowania i serializacji: moment, w którym proces naprawdę ginie
Pamięć w stanie stabilnym to jeden rachunek; szczyt to inny. Podczas działania JSON.parse(text) proces trzyma naraz trzy rzeczy:
- Łańcuch wejściowy
text; - Budowany, jeszcze niekompletny graf obiektów;
- Własne struktury tymczasowe parsera.
Szczyt parsowania to mniej więcej „tekst + graf obiektów + narzut tymczasowy”. Po parsowaniu, jeśli text nadal jest referencjonowany przez zmienną, domknięcie, cache na poziomie modułu lub reaktywny store, ta pamięć tekstu nigdy nie wraca.
Serializacja też ma szczyt. JSON.stringify(data) trzyma pełny graf obiektów podczas budowania łańcucha wynikowego, z buforami konkatenacji pośrodku. Gdy wynik zbliża się do limitu długości łańcucha V8 (około 500 milionów znaków), rzuca RangeError; ta awaria zwykle pojawia się w skryptach eksportujących całą bazę danych jako jeden dokument JSON.
Najdroższy częsty wzorzec używa serializacji jako głębokiego klona:
// Peak is roughly: original object graph + temporary text + new object graph
const safeCopy = JSON.parse(JSON.stringify(bigData));
Ta jedna linia przechowuje te same dane trzy razy. Do pracy tylko do odczytu przekaż referencję. Gdy izolacja jest naprawdę wymagana, użyj structuredClone i zaakceptuj, że on też odbudowuje cały graf obiektów.
6. Pomiar przez process.memoryUsage
W Node process.memoryUsage() zwraca pięć liczb naraz: rss (pamięć fizyczna używana przez proces), heapTotal, heapUsed (używana sterta JS), external (pamięć trzymana przez obiekty C++) i arrayBuffers (pamięć ArrayBuffer i Buffer). Przy badaniu obsługi JSON heapUsed jest właściwą lunetą na graf obiektów, a rss mówi, czy cały proces wymyka się spod kontroli.
Skrypt pomiarowy wymaga trzech dyscyplin: najpierw wywołaj global.gc(), próbkuj etapami i zmieniaj jedną zmienną naraz. Ten skrypt rejestruje osobno przyrosty dla „wczytaj tekst” i „sparsuj do obiektów”:
// 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,
});
Kilka pułapek:
--expose-gcmusi być włączone jawnie; inaczejglobal.gcnie istnieje;heapUsedobejmuje tylko stertę JS. Bufory czytania plików żyją wexternaliarrayBuffers, więc nie używaj jednej liczby do wyjaśnienia wszystkiego;- Nie rób
console.logna dużym obiekcie między próbkami; to utrzymuje tymczasową referencję przy życiu; - Pierwsze uruchomienie zawiera rozgrzewkę JIT i leniwą kompilację. Uruchom co najmniej trzy rundy i weź medianę;
- Aby porównać w przeglądarce, rób zrzuty sterty w panelu Memory DevTools i czytaj
Retained Size, zamiast gapić się na piłę w Allocation Timeline.
Bez tego baseline twierdzenia takie jak „przejście na strumieniowanie to naprawi” albo „cache przyspieszy” są tylko odczuciami.
7. Strumieniowanie kupuje szczyt pamięci, nie całkowity czas
Handel w parsowaniu strumieniowym jest jawny: dostęp swobodny w wymianie za pamięć, która przestaje skalować się z rozmiarem pliku.
| Podejście | Szczyt pamięci | Dostęp swobodny | Najlepsze do |
|---|---|---|---|
Pełny JSON.parse |
Tekst plus kompletny graf obiektów | Tak | Poniżej około 100 MB i potrzebna jest większość pól |
Parser SAX, taki jak stream-json |
Jeden fragment plus obecny element | Nie | Ogromne pliki albo potrzebne tylko kilka pól |
| JSON Lines linia po linii | Obiekt dla jednej linii | Nie | Danych, które naturalnie są partią rekordów |
| Zewnętrzny indeks lub baza danych | Nic nie wczytane w całości | Tak | Powtarzanych zapytań o te same dane |
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);
}
Ten kod nigdy nie buduje pełnego drzewa, więc krzywa pamięci pozostaje płaska. Koszty też są realne: nie możesz wrócić po pole, które pominąłeś, agregacje globalne stają się trudne, odzyskiwanie błędów jest twoją odpowiedzialnością, a skanowanie znak po znaku w czystym JS jest zwykle o rząd wielkości wolniejsze niż natywny JSON.parse.
Decyzja nie brzmi „czy plik przekroczył 10 MB”. Zadaj dwa pytania: Czy potrzebuję większości pól po parsowaniu? Czy wytrzymam dostęp sekwencyjny? Jeśli obie odpowiedzi są twierdzące, pełne parsowanie jest właściwe, a twój wysiłek należy do redukcji kopii i renderowania. Jeśli którakolwiek odpowiedź jest przecząca, rozważ strumieniowanie. Pełną strategię warstwową opisano w Parsowanie, renderowanie i strumieniowanie dużego JSON.
8. Mniej kopii, wcześniejsze zwolnienie
W pracy z pamięcią usunięcie jednej pełnej kopii jest często warte więcej niż zmiana parsera:
- Przekazuj referencje do pracy tylko do odczytu. Nie rób głębokiego klona „dla bezpieczeństwa”, zwłaszcza nie przez
JSON.parse(JSON.stringify(x)); - Nie owijaj surowego dokumentu w system reaktywny.
reactivew Vue proksuje cały graf obiektów rekurencyjnie, a warstwa proksy ma własny koszt pamięci; - Obliczaj i cache’uj dane pochodne osobno. Jeśli lista potrzebuje tylko id, tytułu i statusu, nie wkładaj całych rekordów do store;
- Normalizuj łańcuchy o wysokiej częstotliwości. Zmapuj wartości takie jak
"paid"i"pending"na numeryczne wyliczenia, aby oszczędzić zarówno obiekty łańcuchowe, jak i koszt porównań; - Utnij długo żyjące referencje. Wyzeruj duże obiekty po zakończeniu, wyczyść cache i wyrejestruj callbacki, aby domknięcia nie przenosiły ich na następną stronę;
- Uwzględnij koszt kopii Workerów.
postMessagedomyślnie używa klona strukturalnego; wysłanie grafu obiektów 50 MB z powrotem do wątku głównego buduje drugą kopię. Zwracaj streszczenie, gdy tylko możliwe.
Druga połowa rachunku rozmiaru żyje w Przestań kompresować JSON w złej kolejności: bajty dyskowe i bajty łącza to inny rachunek niż graf obiektów w stercie, i żaden pojedynczy procent nie wyjaśnia obu. Jak edytory i narzędzia CLI zachowują się z pamięcią, opisano w Edytory, narzędzia wiersza poleceń i automatyzacja.
9. Lista kontrolna
| Objaw | Bardziej prawdopodobna przyczyna | Pierwsze działanie |
|---|---|---|
| Plik 10 MB popycha proces do 50 MB | Graf obiektów i tekst pozostają przy życiu | Zmierz etapami; sprawdź, czy tekst jest utrzymywany |
| OOM podczas parsowania, normalnie w stanie stabilnym | Szczyt parsowania nakłada się na stare dane | Zwolnij stare obiekty przed parsowaniem nowego pliku |
| Pamięć rośnie powoli i nigdy nie spada | Wycinek, cache lub domknięcie trzyma referencję | Zrób zrzut sterty i sortuj według retained size |
| Tablice są większe niż oczekiwano | Ułamek zdegradował rodzaj elementów | Ujednolić typy numeryczne; unikaj delete |
| Dodanie cache spowolniło | Klucze i wartości zużywają pamięć, podnosząc nacisk GC | Ogranicz cache i przechowuj tylko streszczenia |
Dlaczego pamięć rośnie trzy lub cztery razy po JSON.parse?
Bo tekst to tylko wejście; produktem parsowania jest graf obiektów. Każdy obiekt ma nagłówek, każda właściwość ma slot, a każdy łańcuch ma własny nagłówek. Przy odpowiednio dużej liczbie węzłów całkowity narzut przewyższa liczbę bajtów tekstu. Mnożnik zależy od kształtu danych, a płaskie tablice obiektów różnią się znacznie od głęboko zagnieżdżonych dokumentów.
Czy plik JSON 10 MB parsować w całości czy strumieniować?
Najpierw spójrz na cel. Jeśli potrzebujesz dostępu swobodnego, potrzebujesz większości pól, a plik mieści się w pamięci, użyj pełnego JSON.parse. Jeśli potrzebujesz tylko kilku pól albo plik się nie mieści, strumieniuj. 10 MB nie jest granicą; strumieniowanie zwykle staje się obowiązkowe gdzieś powyżej 100 MB.
Dlaczego process.memoryUsage().heapUsed nie zgadza się z monitoringu systemowym?
heapUsed obejmuje tylko używaną część sterty JS, wykluczając external, arrayBuffers i przestrzeń kodu. Monitoring systemowy zgłasza rss, który jest inną miarą. Śledź heapUsed i rss razem; duża luka wskazuje na Bufory i obiekty natywne.
Czy JSON.parse(JSON.stringify(x)) jest bezpiecznym głębokim klonem?
Nie. Trzyma naraz oryginalny graf, tymczasowy tekst i nowy graf, więc szczyt to mniej więcej trzy razy stan stabilny, a także gubi undefined, funkcje i semantykę Date. Przekazuj referencje do pracy tylko do odczytu; gdy wymagana jest izolacja, użyj structuredClone i zaakceptuj ten sam koszt odbudowy.