Neraca Memori JSON: Berapa Biaya Sebenarnya untuk String, Object, dan Array Besar
Intinya: File JSON 10MB bisa menjadi 30-50MB di heap V8. Pelajari biaya nyata string, object dan number, cara membuat baseline dengan process.memoryUsage, dan kapan streaming membantu.

Intinya: buat baseline dengan process.memoryUsage dulu, catat teks dan object graph sebagai dua kenaikan terpisah, lalu hilangkan satu salinan penuh sebelum mencoba hal lain — jangan JSON.parse(JSON.stringify(x)), jangan membungkus dokumen mentah dengan sistem reaktif. Berikutnya potong referensi berumur panjang, dan pertimbangkan streaming paling akhir: kurva memorinya yang datar layak ditukar dengan hilangnya random access hanya ketika file tidak muat atau Anda hanya butuh beberapa field.
Dokumen JSON 10MB bisa menjadi 30MB sampai 50MB di heap setelah
JSON.parse, dan puncak sementara bisa lebih tinggi lagi. Tanpa menghitung aritmetika ini, "optimasi" hanyalah dugaan.
1. Teks dan object graph adalah dua neraca berbeda
Di disk dan di request body, JSON adalah rangkaian byte. Begitu masuk memori, ia menjadi dua hal: sebuah string, dan sebuah object graph yang terdiri dari object, array, number, dan boolean. Memisahkan kedua neraca itu adalah satu-satunya cara menjelaskan mengapa file 10MB bisa membuat proses memakai 80MB.
| Bentuk data | Object graph vs ukuran teks | Biaya dominan |
|---|---|---|
| Array datar berisi object (sekitar 8 field per object) | Kira-kira 2,5-4x | Satu object header dan slot properti per elemen |
| Konfigurasi bersarang dalam | Kira-kira 3-5x | Banyak object kecil, masing-masing dengan header sendiri |
| Array berisi string sangat panjang | Kira-kira 1,2-2x | String header plus data karakter |
| Number besar dengan desimal | Kira-kira 2-3x | Satu HeapNumber per nilai |
| Dokumen dengan banyak key berulang | Proporsional dengan jumlah key | Hashing key dan penyiapan properti saat parsing |
Pengali ini adalah acuan orde besaran yang bergantung pada versi V8, pointer compression, jumlah field, dan isi string. Tabel ini menyampaikan satu poin: object graph berukuran menurut "jumlah node x overhead per node", bukan menurut jumlah byte teks.
2. String: pos biaya yang paling sering diremehkan
String V8 bukan array karakter polos. Ia membawa object header (pointer map, field hash, dan panjang), yang pada build 64-bit umum dengan pointer compression memakan sekitar belasan byte, sebelum karakter apa pun. Karakter disimpan dengan salah satu dari dua cara:
- String satu byte untuk karakter dalam rentang Latin-1: 1 byte per karakter;
- String dua byte ketika isinya mencakup karakter CJK, emoji, atau apa pun di luar Latin-1: 2 byte per karakter.
Satu karakter Tionghoa memakan 3 byte di file UTF-8 tetapi 2 byte per karakter dalam string dua byte, plus header. Ukuran teks dan ukuran heap bukan pengukuran yang sama, dan keduanya tidak selalu bergerak ke arah yang sama.
Tiga detail relevan langsung untuk pekerjaan JSON:
- Slice menahan induknya tetap hidup. V8 merepresentasikan substring panjang sebagai
SlicedString, yang menyimpan referensi induk plus offset dan panjang. Simpan 200 karakter hasil slice dari log 80MB dan seluruh 80MB tetap reachable sampai slice itu dilepas. - Konkatenasi membuat
ConsString.+=berulang pada string besar membangun pohon fragmen yang baru diratakan ke memori bersambung ketika karakter benar-benar dibaca. Menggabungkan sepuluh ribu kali lalu membaca sekali menghasilkan lonjakan alokasi besar pada saat itu. - Nilai berulang tidak otomatis dibagi. Sepuluh ribu record yang berisi
"status": "paid"biasanya menghasilkan sepuluh ribu object string independen; parser tidak menunjuk mereka ke satu konstanta. Untuk menghemat biaya itu Anda harus memetakan string yang sering muncul ke enum atau number sendiri.
Ketika Anda perlu menyimpan potongan pendek untuk waktu lama, ubah ia menjadi string independen dan lepaskan yang asli:
// Buruk: slice mereferensikan seluruh dokumen, sehingga logText tidak bisa dikumpulkan
const summary = logText.slice(0, 200);
// Baik: buat string independen, lalu lepaskan logText
const summaryCopy = Buffer.from(summary, 'utf8').toString('utf8');
3. Object: setiap key menempati satu slot
Plain object berisi setidaknya tiga hal: pointer ke hidden class (map), pointer ke property store, dan pointer ke element store. Dengan pointer compression setiap pointer berukuran 4 byte, dan heap object header menambah lebih banyak, sehingga object kosong pun memakan kira-kira enam belas sampai dua puluhan byte. Pada build 64-bit tanpa pointer compression, lipatkan jumlah itu.
Properti bernama mendarat di salah satu dari dua tempat:
- In-object properties: slot yang diletakkan langsung setelah object header, memberi akses tercepat;
PropertyArray: begitu slot in-object habis, properti pindah ke array terpisah dan object menyimpan pointer ke sana.
Setiap slot berukuran 4 atau 8 byte, dan string key diperhitungkan terpisah. Itulah sebabnya nama field yang panjang menjadi biaya nyata pada ratusan ribu record, bukan sekadar persoalan estetika.
Kabar baiknya, hidden class dibagi: sejuta object dengan key yang sama dalam urutan yang sama berbagi satu map alih-alih menyalin informasi struktural. Kabar buruknya, pola berikut memutus pembagian itu atau mendorong object ke representasi yang lebih mahal:
- Sebagian record punya field
remarkdan sebagian tidak, menghasilkan dua bentuk dan pengecekan bentuk tambahan pada setiap akses; - Urutan penyisipan yang berbeda bisa menghasilkan bentuk yang berbeda;
- Jumlah properti yang sangat besar (melebihi sekitar seribu) atau operasi
deleteyang sering mendorong object ke dictionary mode, di mana setiap properti juga membawa entri hash table; Object.create(null)berada di dictionary mode sejak lahir. Ia lookup table yang baik dan pilihan buruk untuk record dengan skema tetap yang diakses dalam hot loop.
Satu kesalahpahaman umum perlu dikoreksi: JSON mengizinkan key duplikat, dan {"a":1,"a":2} adalah dokumen valid yang hasilnya a === 2. Object akhir menyimpan satu nilai, tetapi parser tetap memindai dan meng-assign setiap kemunculan. Key duplikat tidak menghemat memori; mereka hanya memakan waktu parsing.
4. Array dan number: element kind menentukan lebar
V8 melacak beberapa "element kind" untuk array, dan masing-masing punya lebar per slot yang berbeda:
| Isi array | Element kind | Perkiraan biaya per slot |
|---|---|---|
| Integer kecil (dalam rentang Smi) | PACKED_SMI_ELEMENTS |
4 byte |
| Desimal atau integer sangat besar | PACKED_DOUBLE_ELEMENTS |
8 byte |
| String atau object | PACKED_ELEMENTS |
Pointer 4 byte plus nilainya sendiri |
Array dengan lubang akibat delete |
HOLEY_* |
Sama, plus pengecekan lubang |
| Array sangat sparse | DICTIONARY_ELEMENTS |
Entri hash, biasanya 20+ byte |
Dua aturan layak dihafal:
Pertama, element kind hanya bisa menurun. Array yang dimulai dengan integer kecil menjadi double array begitu Anda menyisipkan 0.5, dan menjadi generic element array ketika Anda menyisipkan string. Ia tidak pernah pulih otomatis; Anda harus membangun array baru.
Kedua, tempat sebuah number JSON mendarat bergantung pada nilainya. Integer dalam rentang Smi (kira-kira plus minus 1,07 miliar) disimpan langsung sebagai Smi dan menempati satu slot pointer. Number dengan desimal atau eksponen, atau nilai di luar rentang itu, mengalokasikan HeapNumber sekitar 16 byte, dengan pointer ke sana disimpan di array. Jadi [1, 2, 3] dan [0.1, 0.2, 0.3] tidak hanya berbeda nilai, tetapi juga "satu slot 4 byte" versus "slot 8 byte plus satu heap object."
Ini juga akar masalah presisi ID berupa integer besar: di luar rentang safe integer, number JSON diperlakukan sebagai double dan digit orde rendah hilang begitu saja. Array JSON datang packed dan tanpa lubang, jadi jangan delete elemen setelah parsing; itu mendorong seluruh array ke representasi yang lebih mahal.
5. Puncak parse dan serialize: momen ketika proses benar-benar mati
Memori steady-state adalah satu neraca; puncak adalah neraca lain. Saat JSON.parse(text) berjalan, proses memegang tiga hal sekaligus:
- String input
text; - Object graph yang sedang dibangun dan belum lengkap;
- Struktur sementara milik parser sendiri.
Puncak parse kira-kira "teks + object graph + overhead sementara." Setelah parsing, jika text masih direferensikan oleh variabel, closure, cache level modul, atau store reaktif, memori teks itu tidak pernah dikembalikan.
Serialisasi juga punya puncak. JSON.stringify(data) memegang object graph penuh saat membangun string output, dengan buffer konkatenasi di antaranya. Ketika output mendekati batas panjang string V8 (sekitar 500 juta karakter), ia melempar RangeError; kegagalan ini biasanya muncul di skrip yang mengekspor seluruh basis data sebagai satu dokumen JSON.
Pola umum paling mahal memakai serialisasi sebagai deep clone:
// Puncaknya kira-kira: object graph asli + teks sementara + object graph baru
const safeCopy = JSON.parse(JSON.stringify(bigData));
Satu baris itu menyimpan data yang sama tiga kali. Untuk pekerjaan read-only, oper referensi. Ketika isolasi benar-benar dibutuhkan, gunakan structuredClone dan terima kenyataan bahwa ia juga membangun ulang seluruh object graph.
6. Mengukur dengan process.memoryUsage
Di Node, process.memoryUsage() mengembalikan lima angka sekaligus: rss (memori fisik yang dipakai proses), heapTotal, heapUsed (JS heap terpakai), external (memori yang dipegang object C++), dan arrayBuffers (memori ArrayBuffer dan Buffer). Saat mempelajari penanganan JSON, heapUsed adalah lensa yang tepat untuk object graph dan rss memberi tahu apakah seluruh proses di luar kendali.
Skrip pengukuran butuh tiga disiplin: panggil global.gc() dulu, ambil sampel bertahap, dan ubah satu variabel setiap kali. Skrip ini mencatat kenaikan untuk "membaca teks" dan "parse menjadi object" secara terpisah:
// Jalankan: 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 jebakan:
--expose-gcharus diaktifkan secara eksplisit; jika tidak,global.gctidak ada;heapUsedhanya mencakup JS heap. Buffer pembaca file berada diexternaldanarrayBuffers, jadi jangan pakai satu angka untuk menjelaskan semuanya;- Jangan
console.logobject besar di antara sampel; itu menahan referensi sementara tetap hidup; - Jalankan pertama mencakup pemanasan JIT dan kompilasi lazy. Jalankan setidaknya tiga putaran dan ambil median;
- Untuk membandingkan di browser, ambil heap snapshot di panel Memory DevTools dan baca
Retained Sizealih-alih menatap gigi gergaji di Allocation Timeline.
Tanpa baseline ini, klaim seperti "pindah ke streaming akan memperbaikinya" atau "cache akan membuatnya lebih cepat" hanyalah perasaan.
7. Streaming membeli puncak memori, bukan total waktu
Perdagangan dalam parsing streaming bersifat eksplisit: random access ditukar dengan memori yang tidak lagi berskala dengan ukuran file.
| Pendekatan | Puncak memori | Random access | Cocok untuk |
|---|---|---|---|
JSON.parse penuh |
Teks plus object graph lengkap | Ya | Di bawah sekitar 100MB dan sebagian besar field dibutuhkan |
Parser SAX seperti stream-json |
Satu chunk plus elemen saat ini | Tidak | File sangat besar, atau hanya beberapa field dibutuhkan |
| JSON Lines baris per baris | Object untuk satu baris | Tidak | Data yang memang berupa batch record |
| Indeks eksternal atau basis data | Tidak ada yang dimuat penuh | Ya | Kueri 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);
}
Kode ini tidak pernah membangun tree penuh, sehingga kurva memori tetap datar. Biayanya juga nyata: Anda tidak bisa kembali untuk field yang dilewati, agregasi global menjadi sulit, pemulihan error menjadi tanggung jawab Anda, dan pemindaian karakter demi karakter murni JS biasanya satu orde lebih lambat daripada JSON.parse native.
Keputusannya bukan "apakah file melebihi 10MB." Ajukan dua pertanyaan: Apakah saya perlu sebagian besar field setelah parsing? Bisakah saya hidup dengan akses sekuensial? Jika kedua jawabannya ya, parsing penuh adalah pilihan yang benar dan usaha Anda sebaiknya dialihkan ke mengurangi salinan dan rendering. Jika salah satunya tidak, pertimbangkan streaming. Untuk strategi berlapis lengkap, lihat Parsing, Rendering, dan Streaming JSON Besar.
8. Lebih sedikit salinan, pelepasan lebih awal
Dalam pekerjaan memori, menghapus satu salinan penuh sering lebih berharga daripada mengganti parser:
- Oper referensi untuk pekerjaan read-only. Jangan deep-clone "demi keamanan," terutama jangan dengan
JSON.parse(JSON.stringify(x)); - Jangan membungkus dokumen mentah dengan sistem reaktif.
reactiveVue mem-proxy seluruh object graph secara rekursif, dan lapisan proxy itu punya biaya memori sendiri; - Hitung dan cache data turunan secara terpisah. Jika sebuah daftar hanya butuh id, title, dan status, jangan taruh seluruh record ke store;
- Normalkan string frekuensi tinggi. Petakan nilai seperti
"paid"dan"pending"ke enum numerik untuk menghemat object string sekaligus biaya perbandingan; - Potong referensi berumur panjang. Nolkan object besar setelah selesai, bersihkan cache, dan lepas pendaftaran callback agar closure tidak membawanya ke halaman berikutnya;
- Perhitungkan biaya salinan Worker.
postMessagememakai structured clone secara default; mengirim object graph 50MB kembali ke main thread membangun salinan kedua. Kembalikan ringkasan kapan pun memungkinkan.
Separuh lain dari neraca ukuran ada di Berhenti Melakukan Kompresi JSON dalam Urutan yang Salah: byte disk dan byte wire adalah rekening yang berbeda dari object graph di heap, dan tidak ada satu persen pun yang menjelaskan keduanya. Untuk perilaku editor dan alat CLI terhadap memori, lihat Editor, Alat Command-Line, dan Otomasi.
9. Checklist
| Gejala | Penyebab yang lebih mungkin | Tindakan pertama |
|---|---|---|
| File 10MB mendorong proses ke 50MB | Object graph dan teks keduanya tetap hidup | Ukur bertahap; periksa apakah teks ditahan |
| OOM saat parsing, normal saat steady state | Puncak parse bertumpang tindih dengan data lama | Lepaskan object lama sebelum mem-parse file baru |
| Memori naik perlahan dan tidak pernah turun | Slice, cache, atau closure menahan referensi | Ambil heap snapshot dan urutkan berdasarkan retained size |
| Array lebih besar dari perkiraan | Desimal menurunkan element kind | Seragamkan tipe numerik; hindari delete |
| Menambah cache malah memperlambat | Key dan value memakan memori, menaikkan tekanan GC | Batasi cache dan simpan ringkasan saja |
Mengapa memori tumbuh tiga atau empat kali setelah JSON.parse?
Karena teks hanyalah input; produk parsing adalah object graph. Setiap object punya header, setiap properti punya slot, dan setiap string punya header sendiri. Dengan cukup banyak node, total overhead melebihi jumlah byte teks. Pengalinya bergantung pada bentuk data, dan array object datar sangat berbeda dari dokumen bersarang dalam.
File JSON 10MB sebaiknya di-parse penuh atau di-stream?
Lihat tujuannya dulu. Jika Anda butuh random access, butuh sebagian besar field, dan file muat di memori, gunakan JSON.parse penuh. Jika Anda hanya butuh beberapa field, atau file tidak muat, stream-lah. 10MB bukan batas; streaming biasanya menjadi wajib di suatu titik di atas 100MB.
Mengapa process.memoryUsage().heapUsed tidak cocok dengan pemantauan sistem?
heapUsed hanya mencakup bagian JS heap yang terpakai, tidak termasuk external, arrayBuffers, dan code space. Pemantauan sistem melaporkan rss, yang merupakan pengukuran berbeda. Lacak heapUsed dan rss bersama; selisih besar menunjuk ke Buffer dan object native.
Apakah JSON.parse(JSON.stringify(x)) adalah deep clone yang aman?
Tidak. Ia memegang graph asli, teks sementara, dan graph baru sekaligus, sehingga puncaknya kira-kira tiga kali steady state, dan ia menghilangkan undefined, fungsi, dan semantik Date. Oper referensi untuk pekerjaan read-only; ketika isolasi dibutuhkan, gunakan structuredClone dan terima biaya pembangunan ulang yang sama.