JSON Toolkit

Lejar Memori JSON: Apa Sebenarnya Kos String, Objek, dan Tatasusunan Besar

Inti pati: Fail JSON 10MB boleh menjadi 30-50MB dalam heap V8. Ketahui kos sebenar string, objek dan nombor, cara mengambil garis dasar dengan process.memoryUsage, dan bila penstriman membantu.

Lanjutan~11 min bacaanDiterbitkan 2026-10-31Dikemas kini 2026-10-31
Lejar memori JSON: kos heap bagi string, objek, dan tatasusunan dalam V8

Kesimpulan utama: ambil garis dasar dengan process.memoryUsage dahulu, catat teks dan graf objek sebagai dua kenaikan berasingan, kemudian buang satu salinan penuh sebelum mencuba apa-apa yang lain — jangan JSON.parse(JSON.stringify(x)), jangan balut dokumen mentah dengan reaktif. Potong rujukan jangka panjang seterusnya, dan pertimbangkan penstriman paling akhir: lengkung memorinya yang rata berbaloi kehilangan akses rawak hanya apabila fail tidak muat atau anda hanya perlukan beberapa medan.

Dokumen JSON 10MB boleh menjadi 30MB hingga 50MB dalam heap selepas JSON.parse, dan puncak sementara naik lebih tinggi lagi. Tanpa membuat aritmetik ini, "pengoptimuman" hanyalah agakan.

1. Teks dan graf objek ialah dua lejar berbeza

Di cakera dan dalam badan permintaan, JSON ialah jujukan bait. Sebaik sahaja ia masuk ke memori, ia menjadi dua perkara: string, dan graf objek yang terdiri daripada objek, tatasusunan, nombor, dan boolean. Menjaga dua lejar itu berasingan ialah satu-satunya cara untuk menjelaskan mengapa fail 10MB boleh membuat proses menggunakan 80MB.

Bentuk data Graf objek vs saiz teks Kos dominan
Tatasusunan objek rata (kira-kira 8 medan setiap satu) Kira-kira 2.5-4x Satu pengepala objek dan slot sifat per elemen
Konfigurasi bersarang dalam Kira-kira 3-5x Banyak objek kecil, masing-masing dengan pengepalanya sendiri
Tatasusunan string sangat panjang Kira-kira 1.2-2x Pengepala string serta data aksara
Nombor besar dengan perpuluhan Kira-kira 2-3x Satu HeapNumber per nilai
Dokumen dengan banyak kunci berulang Berkadar dengan bilangan kunci Hashing kunci dan persediaan sifat semasa penghuraian

Pendarab ini ialah rujukan urutan magnitud yang bergantung pada versi V8, pemampatan penunjuk, bilangan medan, dan kandungan string. Jadual ini menyampaikan satu perkara: graf objek bersaiz "bilangan nod x overhed per nod," bukan mengikut bilangan bait dalam teks.

2. String: item baris yang paling dipandang rendah

String V8 bukan tatasusunan aksara kosong. Ia membawa pengepala objek (penunjuk map, medan hash, dan panjang), yang menelan kira-kira selusin bait pada binaan 64-bit biasa dengan pemampatan penunjuk, sebelum sebarang aksara. Aksara disimpan dalam salah satu daripada dua cara:

  • String satu bait untuk aksara dalam julat Latin-1: 1 bait per aksara;
  • String dua bait apabila kandungan termasuk aksara CJK, emoji, atau apa-apa di luar Latin-1: 2 bait per aksara.

Aksara Cina menduduki 3 bait dalam fail UTF-8 tetapi 2 bait per aksara dalam string dua bait, ditambah pengepala. Saiz teks dan saiz heap bukan ukuran yang sama, dan ia tidak selalu bergerak ke arah yang sama.

Tiga butiran penting secara langsung untuk kerja JSON:

  1. Slices mengekalkan induk hidup. V8 mewakili substring yang lebih panjang sebagai SlicedString, yang menyimpan rujukan induk serta offset dan panjang. Simpan 200 aksara yang dipotong daripada log 80MB dan keseluruhan 80MB kekal boleh dicapai sehingga slice itu dilepaskan.
  2. Penggabungan mencipta ConsString. += berulang pada string besar membina pokok serpihan yang hanya diratakan menjadi memori bersebelahan apabila aksara benar-benar dibaca. Menggabung sepuluh ribu kali kemudian membaca sekali menghasilkan lonjakan peruntukan besar pada saat itu.
  3. Nilai berulang tidak dikongsi secara automatik. Sepuluh ribu rekod yang mengandungi "status": "paid" biasanya mendapat sepuluh ribu objek string bebas; penghurai tidak menunjuk mereka ke satu pemalar. Untuk menjimatkan kos itu, anda perlu memetakan string kerap ke enum atau nombor sendiri.

Apabila anda perlu menyimpan petikan pendek untuk masa yang lama, tukarkannya menjadi string bebas dan lepaskan yang asal:

// 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. Objek: setiap kunci menduduki satu slot

Objek biasa mengandungi sekurang-kurangnya tiga perkara: penunjuk ke kelas tersembunyinya (map), penunjuk ke stor sifatnya, dan penunjuk ke stor elemennya. Dengan pemampatan penunjuk setiap penunjuk ialah 4 bait, dan pengepala objek heap menambah lebih banyak, jadi objek kosong pun menelan kira-kira enam belas hingga dua puluh lebih bait. Pada binaan 64-bit tanpa pemampatan penunjuk, gandakan jumlah itu.

Sifat bernama mendarat di salah satu daripada dua tempat:

  • Sifat dalam objek (in-object): slot yang disusun terus selepas pengepala objek, memberikan akses paling pantas;
  • PropertyArray: sebaik slot dalam objek habis, sifat berpindah ke tatasusunan berasingan dan objek memegang penunjuk kepadanya.

Setiap slot ialah 4 atau 8 bait, dan string kunci dikira secara berasingan. Itulah sebabnya nama medan yang panjang menjadi kos sebenar merentasi ratusan ribu rekod, bukan sekadar isu estetik.

Berita baiknya ialah kelas tersembunyi dikongsi: sejuta objek dengan kunci sama dalam susunan sama berkongsi satu map dan bukannya menyalin maklumat struktur. Berita buruknya ialah corak ini memecahkan perkongsian itu atau menolak objek ke representasi yang lebih mahal:

  • Sesetengah rekod mempunyai medan remark dan yang lain tidak, menghasilkan dua bentuk dan semakan bentuk tambahan pada setiap akses;
  • Susunan sisipan yang berbeza boleh menghasilkan bentuk yang berbeza;
  • Bilangan sifat yang sangat besar (melebihi kira-kira seribu) atau operasi delete yang kerap menolak objek ke mod kamus, di mana setiap sifat juga membawa entri jadual hash;
  • Object.create(null) berada dalam mod kamus sejak lahir. Ia jadual carian yang baik dan pilihan yang buruk untuk rekod skema tetap yang diakses dalam gelung panas.

Satu tanggapan salah biasa patut dikoreksi: JSON membenarkan kunci pendua, dan {"a":1,"a":2} ialah dokumen sah yang hasilnya a === 2. Objek akhir menyimpan satu nilai, tetapi penghurai tetap mengimbas dan menetapkan setiap kejadian. Kunci pendua tidak menjimatkan memori; ia hanya menambah masa penghuraian.

4. Tatasusunan dan nombor: jenis elemen menetapkan lebar

V8 menjejak beberapa "jenis elemen" untuk tatasusunan, dan setiap satu mempunyai lebar per slot yang berbeza:

Kandungan tatasusunan Jenis elemen Kos anggaran per slot
Integer kecil (dalam julat Smi) PACKED_SMI_ELEMENTS 4 bait
Perpuluhan atau integer sangat besar PACKED_DOUBLE_ELEMENTS 8 bait
String atau objek PACKED_ELEMENTS Penunjuk 4 bait serta nilai itu sendiri
Tatasusunan dengan lubang dicipta delete HOLEY_* Sama, ditambah semakan lubang
Tatasusunan sangat jarang DICTIONARY_ELEMENTS Entri hash, biasanya 20+ bait

Dua peraturan patut dihafal:

Pertama, jenis elemen hanya merosot. Tatasusunan yang bermula dengan integer kecil menjadi tatasusunan double sebaik anda menyisipkan 0.5, dan tatasusunan elemen generik apabila anda menyisipkan string. Ia tidak pernah pulih secara automatik; anda perlu membina tatasusunan baharu.

Kedua, tempat nombor JSON mendarat bergantung pada nilainya. Integer dalam julat Smi (kira-kira tambah atau tolak 1.07 bilion) disimpan terus sebagai Smi dan menduduki satu slot penunjuk. Nombor dengan perpuluhan atau eksponen, atau nilai di luar julat itu, memperuntukkan HeapNumber kira-kira 16 bait, dengan penunjuk kepadanya disimpan dalam tatasusunan. Jadi [1, 2, 3] dan [0.1, 0.2, 0.3] berbeza bukan sekadar dari segi nilai tetapi dari segi "satu slot 4 bait" berbanding "slot 8 bait ditambah objek heap."

Ini juga punca masalah ketepatan ID integer besar: di luar julat integer selamat, nombor JSON dilayan sebagai double dan digit tertib rendah terus hilang. Tatasusunan JSON datang padat dan tanpa lubang, jadi jangan delete elemen selepas penghuraian; itu menolak keseluruhan tatasusunan ke arah representasi yang lebih mahal.

5. Puncak hurai dan bersiri: saat proses sebenarnya mati

Memori keadaan mantap ialah satu lejar; puncak ialah lejar yang lain. Semasa JSON.parse(text) berjalan, proses memegang tiga perkara sekali gus:

  1. String input text;
  2. Graf objek yang sedang dibina dan belum lengkap;
  3. Struktur sementara penghurai itu sendiri.

Puncak penghuraian kira-kira "teks + graf objek + overhed sementara." Selepas penghuraian, jika text masih dirujuk oleh pemboleh ubah, penutupan, cache peringkat modul, atau stor reaktif, memori teks itu tidak pernah dipulangkan.

Pensirian juga mempunyai puncak. JSON.stringify(data) memegang graf objek penuh semasa membina string output, dengan penampan penggabungan di antara. Apabila output menghampiri had panjang string V8 (sekitar 500 juta aksara), ia melontar RangeError; kegagalan ini biasanya muncul dalam skrip yang mengeksport keseluruhan pangkalan data sebagai satu dokumen JSON.

Corak biasa yang paling mahal menggunakan pensirian sebagai klon mendalam:

// Peak is roughly: original object graph + temporary text + new object graph
const safeCopy = JSON.parse(JSON.stringify(bigData));

Satu baris itu menyimpan data yang sama tiga kali ganda. Untuk kerja baca sahaja, hantar rujukan. Apabila pengasingan benar-benar diperlukan, gunakan structuredClone dan terima hakikat bahawa ia juga membina semula keseluruhan graf objek.

6. Mengukur dengan process.memoryUsage

Dalam Node, process.memoryUsage() memulangkan lima nombor sekali gus: rss (memori fizikal yang digunakan oleh proses), heapTotal, heapUsed (heap JS yang digunakan), external (memori yang dipegang oleh objek C++), dan arrayBuffers (memori ArrayBuffer dan Buffer). Apabila mengkaji pengendalian JSON, heapUsed ialah kanta yang tepat untuk graf objek dan rss memberitahu anda sama ada keseluruhan proses hilang kawalan.

Skrip pengukuran memerlukan tiga disiplin: panggil global.gc() dahulu, sampel secara berperingkat, dan ubah satu pemboleh ubah pada satu masa. Skrip ini merekod kenaikan untuk "baca teks" dan "hurai menjadi objek" secara berasingan:

// 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,
});

Beberapa perangkap:

  • --expose-gc mesti diaktifkan secara eksplisit; jika tidak global.gc tidak wujud;
  • heapUsed hanya meliputi heap JS. Buffer pembacaan fail berada di bawah external dan arrayBuffers, jadi jangan gunakan satu nombor untuk menjelaskan segala-galanya;
  • Jangan console.log objek besar antara sampel; ia mengekalkan rujukan sementara hidup;
  • Larian pertama termasuk pemanasan JIT dan penyusunan malas. Jalankan sekurang-kurangnya tiga pusingan dan ambil median;
  • Untuk membandingkan dalam pelayar, ambil snapshot heap dalam panel Memory DevTools dan baca Retained Size dan bukannya merenung gigi gergaji dalam Allocation Timeline.

Tanpa garis dasar ini, dakwaan seperti "beralih ke penstriman akan menyelesaikannya" atau "cache akan menjadikannya lebih pantas" hanyalah perasaan.

7. Penstriman membeli memori puncak, bukan jumlah masa

Pertukaran dalam penghuraian penstriman adalah jelas: akses rawak ditukar dengan memori yang tidak lagi berskala dengan saiz fail.

Pendekatan Memori puncak Akses rawak Terbaik untuk
JSON.parse penuh Teks serta graf objek lengkap Ya Di bawah kira-kira 100MB dan kebanyakan medan diperlukan
Penghurai SAX seperti stream-json Satu ketulan serta elemen semasa Tidak Fail besar, atau hanya beberapa medan diperlukan
JSON Lines baris demi baris Objek untuk satu baris Tidak Data yang secara semula jadi kelompok rekod
Indeks luaran atau pangkalan data Tiada apa dimuatkan sepenuhnya Ya Pertanyaan berulang atas data yang sama
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);
}

Kod ini tidak pernah membina pokok penuh, jadi lengkung memori kekal rata. Kosnya juga nyata: anda tidak boleh kembali untuk medan yang dilangkau, pengagregatan global menjadi sukar, pemulihan kesalahan menjadi tanggungjawab anda, dan imbasan aksara demi aksara JS tulen biasanya satu urutan magnitud lebih perlahan daripada JSON.parse natif.

Keputusan bukanlah "adakah fail melebihi 10MB." Tanya dua soalan: Adakah saya memerlukan kebanyakan medan selepas penghuraian? Bolehkah saya hidup dengan akses berjujukan? Jika kedua-dua jawapan ya, penghuraian penuh adalah betul dan usaha anda harus tertumpu pada mengurangkan salinan dan pemaparan. Jika mana-mana jawapan tidak, pertimbangkan penstriman. Untuk strategi berlapis lengkap, lihat Menghurai, Memaparkan, dan Menstrim JSON Besar.

8. Kurang salinan, lepas lebih awal

Dalam kerja memori, membuang satu salinan penuh selalunya lebih bernilai daripada menukar penghurai:

  • Hantar rujukan untuk kerja baca sahaja. Jangan klon mendalam "demi keselamatan," terutamanya bukan dengan JSON.parse(JSON.stringify(x));
  • Jangan balut dokumen mentah dalam sistem reaktif. reactive Vue memproksi keseluruhan graf objek secara rekursif, dan lapisan proksi mempunyai kos memori sendiri;
  • Kira dan cache data terbitan secara berasingan. Jika senarai hanya memerlukan id, title, dan status, jangan masukkan rekod penuh ke dalam stor;
  • Normalkan string frekuensi tinggi. Petakan nilai seperti "paid" dan "pending" ke enum bernombor untuk menjimatkan objek string dan kos perbandingan;
  • Potong rujukan jangka panjang. Null-kan objek besar apabila selesai, kosongkan cache, dan nyahdaftar callback supaya penutupan tidak membawanya ke halaman seterusnya;
  • Akaun kos salinan Worker. postMessage menggunakan klon berstruktur secara lalai; menghantar graf objek 50MB kembali ke main thread membina salinan kedua. Pulangkan ringkasan apabila boleh.

Separuh lagi lejar saiz berada dalam Berhenti Melakukan Pemampatan JSON dalam Urutan Salah: bait cakera dan bait wayar ialah akaun berbeza daripada graf objek dalam heap, dan tiada satu peratusan menjelaskan kedua-duanya. Untuk cara editor dan alat CLI berkelakuan dengan memori, lihat Editor, Alat Baris Perintah, dan Automasi.

9. Senarai semak

Gejala Punca lebih mungkin Tindakan pertama
Fail 10MB menolak proses ke 50MB Graf objek dan teks kedua-duanya kekal hidup Ukur secara berperingkat; semak sama ada teks dikekalkan
OOM semasa penghuraian, normal pada keadaan mantap Puncak penghuraian bertindih dengan data lama Lepaskan objek lama sebelum menghurai fail baharu
Memori naik perlahan dan tidak pernah turun Slice, cache, atau penutupan memegang rujukan Ambil snapshot heap dan susun mengikut saiz tertahan
Tatasusunan lebih besar daripada jangkaan Perpuluhan merosotkan jenis elemen Satukan jenis nombor; elakkan delete
Menambah cache menjadikan lebih perlahan Kunci dan nilai menggunakan memori, meningkatkan tekanan GC Hadkan cache dan simpan ringkasan sahaja

Mengapa memori membesar tiga atau empat kali selepas JSON.parse?

Kerana teks hanyalah input; hasil penghuraian ialah graf objek. Setiap objek mempunyai pengepala, setiap sifat mempunyai slot, dan setiap string mempunyai pengepalanya sendiri. Dengan nod yang cukup, jumlah overhed melebihi kiraan bait teks. Pendarab bergantung pada bentuk data, dan tatasusunan objek rata sangat berbeza daripada dokumen bersarang dalam.

Patutkah fail JSON 10MB dihurai sepenuhnya atau distrim?

Lihat tujuan dahulu. Jika anda memerlukan akses rawak, memerlukan kebanyakan medan, dan fail muat dalam memori, gunakan JSON.parse penuh. Jika anda hanya memerlukan beberapa medan, atau fail tidak muat, strimkannya. 10MB bukan sempadan; penstriman biasanya menjadi wajib di suatu tempat melebihi 100MB.

Mengapa process.memoryUsage().heapUsed bercanggah dengan pemantauan sistem?

heapUsed hanya meliputi bahagian heap JS yang digunakan, tidak termasuk external, arrayBuffers, dan ruang kod. Pemantauan sistem melaporkan rss, iaitu ukuran yang berbeza. Jejak heapUsed dan rss bersama-sama; jurang besar menunjuk kepada Buffer dan objek natif.

Adakah JSON.parse(JSON.stringify(x)) klon mendalam yang selamat?

Tidak. Ia memegang graf asal, teks sementara, dan graf baharu pada masa yang sama, jadi puncaknya kira-kira tiga kali keadaan mantap, dan ia menggugurkan undefined, fungsi, dan semantik Date. Hantar rujukan untuk kerja baca sahaja; apabila pengasingan diperlukan, gunakan structuredClone dan terima kos bina semula yang sama.