دفتر حافظه JSON: رشتهها، اشیا و آرایههای بزرگ واقعاً چه هزینهای دارند
جمعبندی: فایل JSON ده مگابایتی میتواند در heap V8 به ۳۰ تا ۵۰ مگابایت تبدیل شود. بیاموزید رشتهها، اشیا و عددها واقعاً چه هزینهای دارند، چگونه با process.memoryUsage خط پایه بگیرید و جریان چه زمانی کمک میکند.

جمعبندی: اول با process.memoryUsage خط پایه بگیرید و متن و گراف شیء را بهعنوان دو افزایش جدا ثبت کنید، سپس پیش از هر چیز یک کپی کامل را حذف کنید — بدون JSON.parse(JSON.stringify(x))، بدون پیچیدن سند خام در reactive. بعد ارجاعهای بلندعمر را قطع کنید و جریان را آخر در نظر بگیرید: منحنی حافظه تختش فقط وقتی ارزش از دست دادن دسترسی تصادفی را دارد که فایل جا نمیشود یا فقط چند فیلد میخواهید.
یک سند JSON ده مگابایتی پس از
JSON.parseمیتواند در heap به ۳۰ تا ۵۰ مگابایت تبدیل شود، و اوج گذرا بالاتر هم میرود. بدون این حساب، «بهینهسازی» حدس است.
۱. متن و گراف شیء دو دفتر متفاوتاند
روی دیسک و در بدنه درخواست، JSON دنبالهای از بایت است. بهمحض ورود به حافظه، دو چیز میشود: یک رشته، و یک گراف شیء ساختهشده از اشیا، آرایهها، عددها و بولیها. جدا نگه داشتن این دو دفتر تنها راه توضیح این است که چرا فایل ۱۰ مگابایتی میتواند فرایندی را به مصرف ۸۰ مگابایت برساند.
| شکل داده | گراف شیء در برابر اندازه متن | هزینه غالب |
|---|---|---|
| آرایه مسطح از اشیا (حدود ۸ فیلد هر یک) | تقریباً ۲.۵ تا ۴ برابر | یک سرآیند شیء و جایگاههای ویژگی به ازای هر عنصر |
| پیکربندی عمیقاً تودرتو | تقریباً ۳ تا ۵ برابر | اشیای کوچک زیاد، هر یک با سرآیند خودش |
| آرایه رشتههای بسیار بلند | تقریباً ۱.۲ تا ۲ برابر | سرآیند رشته بهعلاوه داده نویسه |
| عددهای بزرگ اعشاری | تقریباً ۲ تا ۳ برابر | یک HeapNumber به ازای هر مقدار |
| اسناد با کلیدهای تکراری زیاد | متناسب با تعداد کلید | هش کردن کلید و برپایی ویژگی هنگام تجزیه |
این ضرایب مرجع مرتبه بزرگیاند که به نسخه V8، فشردهسازی اشارهگر، تعداد فیلد و محتوای رشته بستگی دارند. جدول یک نکته را میگوید: اندازه گراف شیء بر اساس «تعداد گره × سربار هر گره» است، نه تعداد بایتهای متن.
۲. رشتهها: کمبرآوردشدهترین ردیف
یک رشته V8 آرایه نویسه لخت نیست. سرآیند شیء حمل میکند (اشارهگر map، فیلد هش و طول) که روی بیلد معمول ۶۴ بیتی با فشردهسازی اشارهگر حدود یک دوجین بایت هزینه دارد، پیش از هر نویسه. نویسهها به یکی از دو شکل ذخیره میشوند:
- رشتههای یکبایتی برای نویسههای بازه Latin-1: ۱ بایت به ازای هر نویسه؛
- رشتههای دوبایتی وقتی محتوا نویسههای CJK، ایموجی یا چیزی بیرون از Latin-1 دارد: ۲ بایت به ازای هر نویسه.
یک نویسه چینی در فایل UTF-8 سه بایت اشغال میکند اما در رشته دوبایتی ۲ بایت به ازای هر نویسه، بهعلاوه سرآیند. اندازه متن و اندازه heap یک اندازهگیری نیستند و همیشه در یک جهت حرکت نمیکنند.
سه جزئیات مستقیماً برای کار با JSON مهماند:
- برشها والد را زنده نگه میدارند. V8 زیررشتههای بلندتر را بهصورت
SlicedStringنمایش میدهد که ارجاع والد بهعلاوه offset و طول را ذخیره میکند. ۲۰۰ نویسه بریدهشده از لاگ ۸۰ مگابایتی را ذخیره کنید و کل ۸۰ مگابایت تا آزاد شدن آن برش قابل دسترس میماند. - الحاق
ConsStringمیسازد.+=مکرر روی رشتههای بزرگ درختی از قطعهها میسازد که فقط وقتی نویسهها واقعاً خوانده شوند در حافظه پیوسته تخت میشود. ده هزار بار الحاق و سپس یک بار خواندن، در همان لحظه جهش تخصیص بزرگی تولید میکند. - مقادیر تکراری خودکار مشترک نمیشوند. ده هزار رکورد حاوی
"status": "paid"معمولاً ده هزار شیء رشته مستقل میگیرند؛ پارسر آنها را به یک ثابت اشاره نمیدهد. برای ذخیره این هزینه باید خودتان رشتههای پرتکرار را به enum یا عدد نگاشت کنید.
وقتی باید برشی کوتاه را مدت طولانی نگه دارید، آن را به رشته مستقل تبدیل و اصلی را آزاد کنید:
// 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');
۳. اشیا: هر کلید یک جایگاه اشغال میکند
یک شیء ساده حداقل سه چیز دارد: اشارهگری به کلاس پنهانش (map)، اشارهگری به انبار ویژگی و اشارهگری به انبار عنصرش. با فشردهسازی اشارهگر هر اشارهگر ۴ بایت است و سرآیند شیء heap بیشتر اضافه میکند، پس حتی شیء خالی حدود شانزده تا بیستوچند بایت هزینه دارد. روی بیلد ۶۴ بیتی بدون فشردهسازی اشارهگر، دو برابرش کنید.
ویژگیهای نامدار در یکی از دو جا مینشینند:
- ویژگیهای درونشیء: جایگاههایی که مستقیماً بعد از سرآیند شیء چیده میشوند و سریعترین دسترسی را میدهند؛
- یک
PropertyArray: وقتی جایگاههای درونشیء تمام شوند، ویژگیها به آرایه جداگانه میروند و شیء اشارهگری به آن نگه میدارد.
هر جایگاه ۴ یا ۸ بایت است و رشته کلید جدا حساب میشود. همین دلیل است که نامهای فیلد بلند در صدها هزار رکورد هزینه واقعی میشوند، نه فقط مسئله زیبایی.
خبر خوب این است که کلاسهای پنهان مشترکاند: یک میلیون شیء با همان کلیدها در همان ترتیب، یک map مشترک دارند نه کپی اطلاعات ساختاری. خبر بد این است که این الگوها آن اشتراک را میشکنند یا شیء را به نمایش گرانتری میبرند:
- برخی رکوردها فیلد
remarkدارند و برخی نه، که دو شکل و یک بررسی شکل اضافه در هر دسترسی تولید میکند؛ - ترتیب درج متفاوت میتواند شکلهای متفاوت تولید کند؛
- تعداد ویژگی بسیار زیاد (فراتر از حدود هزار) یا عملیات
deleteمکرر شیء را به حالت dictionary میبرد که هر ویژگی یک ورودی جدول هش هم حمل میکند؛ Object.create(null)از تولد در حالت dictionary است. جدول جستوجوی خوبی است و انتخاب بدی برای رکورد با schema ثابت که در حلقه داغ دسترسی میشود.
یک باور غلط رایج شایسته اصلاح است: JSON کلید تکراری را مجاز میداند و {"a":1,"a":2} سند معتبری است که نتیجهاش a === 2 است. شیء نهایی یک مقدار نگه میدارد، اما پارسر همچنان هر occurrence را پویش و انتساب میکند. کلیدهای تکراری حافظه ذخیره نمیکنند؛ فقط زمان تجزیه هزینه دارند.
۴. آرایهها و عددها: انواع element عرض را تعیین میکنند
V8 چند «نوع element» برای آرایهها ردیابی میکند و هر یک عرض متفاوت به ازای هر جایگاه دارد:
| محتوای آرایه | نوع element | هزینه تقریبی هر جایگاه |
|---|---|---|
| عددهای صحیح کوچک (در بازه Smi) | PACKED_SMI_ELEMENTS |
۴ بایت |
| اعشار یا عددهای صحیح بسیار بزرگ | PACKED_DOUBLE_ELEMENTS |
۸ بایت |
| رشتهها یا اشیا | PACKED_ELEMENTS |
اشارهگر ۴ بایتی بهعلاوه خود مقدار |
آرایههای دارای حفره از delete |
HOLEY_* |
همان، بهعلاوه بررسی حفره |
| آرایههای بسیار پراکنده | DICTIONARY_ELEMENTS |
ورودی هش، معمولاً ۲۰+ بایت |
دو قاعده ارزش حفظ کردن دارند:
اول، انواع element فقط تنزل میکنند. آرایهای که با عددهای صحیح کوچک شروع میشود، بهمحض درج 0.5 آرایه double میشود و با درج رشته، آرایه element عمومی. هرگز خودکار بازیابی نمیشود؛ باید آرایه جدید بسازید.
دوم، جای نشستن عدد JSON به مقدارش بستگی دارد. عددهای صحیح در بازه Smi (تقریباً مثبت یا منفی ۱.۰۷ میلیارد) مستقیماً بهصورت Smi ذخیره میشوند و یک جایگاه اشارهگر اشغال میکنند. عددهای اعشاری یا تواندار، یا مقادیر بیرون از آن بازه، HeapNumber حدود ۱۶ بایتی تخصیص میدهند با اشارهگری به آن در آرایه. پس [1, 2, 3] و [0.1, 0.2, 0.3] نه فقط در مقدار بلکه در «یک جایگاه ۴ بایتی» در برابر «یک جایگاه ۸ بایتی بهعلاوه شیء heap» تفاوت دارند.
این ریشه مشکل دقت شناسه عدد صحیح بزرگ هم هست: فراتر از بازه صحیح امن، عددهای JSON بهعنوان double رفتار میشوند و رقمهای کمارزش صرفاً ناپدید میشوند. آرایههای JSON بهصورت packed و بدون حفره میرسند، پس پس از تجزیه عناصر را delete نکنید؛ کل آرایه را به نمایش گرانتری میراند.
۵. اوج تجزیه و سریالایز: لحظهای که فرایند واقعاً میمیرد
حافظه حالت پایدار یک دفتر است؛ اوج دفتر دیگر. هنگام اجرای JSON.parse(text)، فرایند سه چیز را همزمان نگه میدارد:
- رشته ورودی
text؛ - گراف شیئی که ساخته میشود و هنوز کامل نیست؛
- ساختارهای موقت خود پارسر.
اوج تجزیه تقریباً «متن + گراف شیء + سربار موقت» است. پس از تجزیه، اگر text همچنان توسط متغیری، closureای، کش سطح ماژول یا store reactive ارجاع شود، آن حافظه متن هرگز برنمیگردد.
سریالایز هم اوج دارد. JSON.stringify(data) گراف شیء کامل را هنگام ساخت رشته خروجی نگه میدارد، با بافرهای الحاق در میانه. وقتی خروجی به محدودیت طول رشته V8 (حدود ۵۰۰ میلیون نویسه) نزدیک میشود، RangeError میاندازد؛ این شکست معمولاً در اسکریپتهایی ظاهر میشود که کل پایگاهداده را بهصورت یک سند JSON صادر میکنند.
گرانترین الگوی رایج از سریالایز بهعنوان شبیهسازی عمیق استفاده میکند:
// Peak is roughly: original object graph + temporary text + new object graph
const safeCopy = JSON.parse(JSON.stringify(bigData));
آن یک خط همان داده را سه بار ذخیره میکند. برای کار فقط-خواندنی، ارجاع بدهید. وقتی جداسازی واقعاً لازم است، از structuredClone استفاده کنید و بپذیرید که آن هم کل گراف شیء را بازمیسازد.
۶. اندازهگیری با process.memoryUsage
در Node، process.memoryUsage() پنج عدد را یکجا برمیگرداند: rss (حافظه فیزیکی مصرفی فرایند)، heapTotal، heapUsed (heap JS مصرفی)، external (حافظه نگهداشتهشده توسط اشیای C++) و arrayBuffers (حافظه ArrayBuffer و Buffer). هنگام مطالعه مدیریت JSON، heapUsed لنز درست برای گراف شیء است و rss میگوید کل فرایند از کنترل خارج شده یا نه.
اسکریپت اندازهگیری سه انضباط میخواهد: اول global.gc() صدا بزنید، مرحلهای نمونه بگیرید و هر بار یک متغیر را تغییر دهید. این اسکریپت افزایشهای «خواندن متن» و «تجزیه به شیء» را جدا ثبت میکند:
// 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,
});
چند تله:
--expose-gcباید صریح فعال شود؛ وگرنهglobal.gcوجود ندارد؛heapUsedفقط heap JS را پوشش میدهد. Bufferهای خواندن فایل زیرexternalوarrayBuffersزندگی میکنند، پس یک عدد را برای توضیح همهچیز استفاده نکنید؛- بین نمونهها شیء بزرگ را
console.logنکنید؛ ارجاع موقت را زنده نگه میدارد؛ - اجرای اول شامل گرمشدن JIT و کامپایل تنبل است. دستکم سه دور اجرا کنید و میانه را بگیرید؛
- برای مقایسه در مرورگر، heap snapshot در پنل Memory ابزار توسعه بگیرید و
Retained Sizeرا بخوانید نه اینکه به دندانه ارهای Allocation Timeline زل بزنید.
بدون این خط پایه، ادعاهایی مانند «روی آوردن به جریان حلش میکند» یا «کش سریعترش میکند» فقط احساساند.
۷. جریان اوج حافظه میخرد، نه زمان کل
معامله در تجزیه جریانی صریح است: دسترسی تصادفی در ازای حافظهای که دیگر با حجم فایل مقیاس نمیگیرد.
| رویکرد | اوج حافظه | دسترسی تصادفی | مناسبترین برای |
|---|---|---|---|
JSON.parse کامل |
متن بهعلاوه گراف شیء کامل | بله | زیر حدود ۱۰۰ مگابایت و بیشتر فیلدها لازماند |
پارسر SAX مانند stream-json |
یک تکه بهعلاوه عنصر جاری | نه | فایلهای عظیم، یا فقط چند فیلد لازم است |
| JSON Lines خطبهخط | شیء یک خط | نه | دادهای که طبیعتاً دستهای رکورد است |
| ایندکس یا پایگاهداده بیرونی | هیچچیز کامل بارگذاری نمیشود | بله | پرسوجوهای مکرر روی همان داده |
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);
}
این کد هرگز درخت کامل نمیسازد، پس منحنی حافظه تخت میماند. هزینهها هم واقعیاند: نمیتوانید برای فیلد ردشده برگردید، تجمیعهای کلی سخت میشوند، بازیابی خطا مسئولیت شماست، و پویش نویسهبهنویسه JS خالص معمولاً یک مرتبه بزرگی کندتر از JSON.parse بومی است.
تصمیم «آیا فایل از ۱۰ مگابایت گذشت» نیست. دو پرسش بپرسید: آیا پس از تجزیه بیشتر فیلدها را لازم دارم؟ آیا با دسترسی ترتیبی میتوانم زندگی کنم؟ اگر هر دو پاسخ بله است، تجزیه کامل درست است و تلاش شما متعلق به کاهش کپی و رندر است. اگر هر یک نه است، جریان را در نظر بگیرید. برای راهبرد کامل لایهای، تجزیه، رندر و جریان دادن JSON بزرگ را ببینید.
۸. کپی کمتر، آزادسازی زودتر
در کار حافظه، حذف یک کپی کامل غالباً از تعویض پارسر ارزشمندتر است:
- برای کار فقط-خواندنی، ارجاع بدهید. «برای امنیت» شبیهسازی عمیق نکنید، بهویژه با
JSON.parse(JSON.stringify(x))؛ - سند خام را در سیستم reactive نپیچید.
reactiveدر Vue کل گراف شیء را بازگشتی proxy میکند و لایه proxy هزینه حافظه خودش را دارد؛ - داده مشتق را جدا محاسبه و کش کنید. اگر فهرست فقط id، title و status میخواهد، رکوردهای کامل را در store نگذارید؛
- رشتههای پرتکرار را نرمال کنید. مقادیری مانند
"paid"و"pending"را به enum عددی نگاشت کنید تا هم شیء رشته و هم هزینه مقایسه ذخیره شود؛ - ارجاعهای بلندعمر را قطع کنید. اشیای بزرگ را پس از پایان null کنید، کشها را پاک کنید و callbackها را لغو ثبت کنید تا closureها آنها را به صفحه بعد نبرند؛
- هزینه کپی Worker را حساب کنید.
postMessageبهصورت پیشفرض از کلون ساختاری استفاده میکند؛ فرستادن گراف شیء ۵۰ مگابایتی به رشته اصلی کپی دوم میسازد. هر جا ممکن است خلاصه برگردانید.
نیمه دیگر دفتر اندازه در فشردهسازی JSON را به ترتیب اشتباه انجام ندهید زندگی میکند: بایتهای دیسک و بایتهای سیم حساب متفاوتی از گراف شیء در heap هستند و هیچ درصد واحدی هر دو را توضیح نمیدهد. برای رفتار ویرایشگرها و ابزارهای CLI با حافظه، ویرایشگرها، ابزارهای خط فرمان و خودکارسازی را ببینید.
۹. چکلیست
| نشانه | علت محتملتر | اقدام اول |
|---|---|---|
| فایل ۱۰ مگابایتی فرایند را به ۵۰ مگابایت میرساند | گراف شیء و متن هر دو زنده میمانند | مرحلهای اندازه بگیرید؛ بررسی کنید آیا متن نگه داشته شده |
| OOM هنگام تجزیه، نرمال در حالت پایدار | اوج تجزیه با داده قدیمی همپوشانی دارد | اشیای قدیمی را پیش از تجزیه فایل جدید آزاد کنید |
| حافظه آرام بالا میرود و هرگز پایین نمیآید | برش، کش یا closure ارجاع نگه میدارد | heap snapshot بگیرید و بر اساس retained size مرتب کنید |
| آرایهها بزرگتر از انتظارند | یک اعشار نوع element را تنزل داده | نوعهای عددی را یکدست کنید؛ از delete پرهیز کنید |
| افزودن کش همهچیز را کندتر کرد | کلیدها و مقادیر حافظه مصرف میکنند و فشار GC را بالا میبرند | کش را محدود کنید و فقط خلاصهها را ذخیره کنید |
چرا حافظه پس از JSON.parse سه یا چهار برابر رشد میکند؟
چون متن فقط ورودی است؛ محصول تجزیه یک گراف شیء است. هر شیء سرآیند دارد، هر ویژگی جایگاه دارد و هر رشته سرآیند خودش را. با گرههای کافی، سربار کل از تعداد بایت متن بیشتر میشود. ضریب به شکل داده بستگی دارد و آرایههای شیء مسطح با اسناد عمیقاً تودرتو بسیار متفاوتاند.
فایل JSON ده مگابایتی را کامل تجزیه کنم یا جریانی؟
اول هدف را ببینید. اگر به دسترسی تصادفی و بیشتر فیلدها نیاز دارید و فایل در حافظه جا میشود، JSON.parse کامل استفاده کنید. اگر فقط چند فیلد لازم دارید یا فایل جا نمیشود، جریانیاش کنید. ۱۰ مگابایت مرز نیست؛ جریان معمولاً جایی بالاتر از ۱۰۰ مگابایت الزامی میشود.
چرا process.memoryUsage().heapUsed با پایش سیستم نمیخواند؟
heapUsed فقط بخش مصرفی heap JS را پوشش میدهد، بدون external، arrayBuffers و code space. پایش سیستم rss را گزارش میکند که اندازهگیری متفاوتی است. heapUsed و rss را با هم ردیابی کنید؛ شکاف بزرگ به Bufferها و اشیای بومی اشاره میکند.
آیا JSON.parse(JSON.stringify(x)) شبیهسازی عمیق امنی است؟
نه. گراف اصلی، متن موقت و گراف جدید را همزمان نگه میدارد، پس اوج تقریباً سه برابر حالت پایدار است، و undefined، تابعها و معناشناسی Date را میاندازد. برای کار فقط-خواندنی ارجاع بدهید؛ وقتی جداسازی لازم است از structuredClone استفاده کنید و همان هزینه بازسازی را بپذیرید.