פנקס הזיכרון של JSON: מה באמת עולים מחרוזות, אובייקטים ומערכים גדולים
שורה תחתונה: קובץ JSON של 10MB יכול להפוך ל-30-50MB ב-heap של V8. למדו מה באמת עולים מחרוזות, אובייקטים ומספרים, איך לקבוע קו בסיס עם process.memoryUsage ומתי הזרמה עוזרת.

שורה תחתונה: קודם קבעו קו בסיס עם process.memoryUsage, תוך רישום הטקסט וגרף האובייקטים כשני תוספים נפרדים, ואז הסירו העתקה מלאה אחת לפני כל דבר אחר — בלי JSON.parse(JSON.stringify(x)), בלי עטיפת reactive של המסמך הגולמי. לאחר מכן קצצו הפניות ארוכות-טווח, ושקלו הזרמה רק בסוף: העקומה השטוחה שלה שווה את ויתור הגישה האקראית רק כשהקובץ לא נכנס לזיכרון או כשצריך רק כמה שדות.
מסמך JSON של 10MB יכול להפוך ל-30MB עד 50MB ב-heap אחרי
JSON.parse, ושיא הזיכרון הזמני גבוה עוד יותר. בלי לעשות את החשבון הזה, "אופטימיזציה" היא ניחוש.
1. טקסט וגרף האובייקטים הם שני פנקסים שונים
על הדיסק ובגוף בקשה, JSON הוא רצף של בייטים. ברגע שהוא נכנס לזיכרון, הוא הופך לשני דברים: מחרוזת, וגרף אובייקטים הבנוי מאובייקטים, מערכים, מספרים ובוליאנים. להחזיק את שני הפנקסים האלה בנפרד זו הדרך היחידה להסביר מדוע קובץ של 10MB יכול לגרום לתהליך לצרוך 80MB.
| צורת נתונים | גרף האובייקטים מול גודל הטקסט | העלות השלטת |
|---|---|---|
| מערך שטוח של אובייקטים (כ-8 שדות כל אחד) | בערך פי 2.5-4 | כותרת אובייקט אחת ומשבצות מאפיינים לכל פריט |
| תצורה מקוננת לעומק | בערך פי 3-5 | אובייקטים קטנים רבים, כל אחד עם כותרת משלו |
| מערך של מחרוזות ארוכות מאוד | בערך פי 1.2-2 | כותרות מחרוזות בתוספת נתוני תווים |
| מספרים גדולים עם ספרות עשרוניות | בערך פי 2-3 | HeapNumber אחת לכל ערך |
| מסמכים עם מפתחות חוזרים רבים | פרופורציונלי למספר המפתחות | גיבוב מפתחות והקמת מאפיינים בזמן הניתוח |
המכפילים האלה הם הפניות בסדר גודל שתלויות בגרסת V8, בדחיסת מצביעים, במספר השדות ובתוכן המחרוזות. הטבלה מעבירה נקודה אחת: גרף האובייקטים נמדד לפי "מספר צמתים x תקורה לכל צומת", לא לפי מספר הבייטים בטקסט.
2. מחרוזות: הסעיף הכי פחות מוערך
מחרוזת ב-V8 אינה מערך תווים חשוף. היא נושאת כותרת אובייקט (מצביע map, שדה hash ואורך), שעולה בערך תריסר בייטים בבנייה טיפוסית של 64 סיביות עם דחיסת מצביעים, לפני כל תו. התווים נשמרים באחת משתי דרכים:
- מחרוזות של בייט אחד לתווים בטווח Latin-1: בייט אחד לכל תו;
- מחרוזות של שני בייטים כשהתוכן כולל תווי CJK, אימוג'י או כל דבר מחוץ ל-Latin-1: שני בייטים לכל תו.
תו סיני תופס 3 בייטים בקובץ UTF-8 אבל 2 בייטים לכל תו במחרוזת של שני בייטים, ועוד הכותרת. גודל הטקסט וגודל ה-heap אינם אותה מדידה, והם לא תמיד נעים באותו כיוון.
שלושה פרטים חשובים ישירות לעבודה עם JSON:
- פרוסות מחזיקות את האב בחיים. V8 מייצג תת-מחרוזות ארוכות יותר כ-
SlicedString, ששומר הפניה לאב ועוד היסט ואורך. שמרו 200 תווים שנחתכו מלוג של 80MB — וכל ה-80MB יישארו נגישים עד שהפרוסה תשוחרר. - שרשור יוצר
ConsString. חזרה על+=על מחרוזות גדולות בונה עץ של שברים שמתאחד לזיכרון רציף רק כשבאמת קוראים את התווים. שרשור עשרת אלפים פעמים ואז קריאה אחת מייצרים קפיצת הקצאה גדולה באותו רגע. - ערכים חוזרים לא משותפים אוטומטית. עשרת אלפים רשומות שמכילות
"status": "paid"מקבלות בדרך כלל עשרת אלפים אובייקטי מחרוזת עצמאיים; הפרסר לא מצביע עליהם לקונסטנטה אחת. כדי לחסוך את העלות הזו צריך למפות בעצמכם מחרוזות נפוצות ל-enum או למספרים.
כשצריך לשמור קטע קצר לזמן רב, הפכו אותו למחרוזת עצמאית ושחררו את המקור:
// גרוע: הפרוסה מפנה למסמך כולו, ולכן logText לא יכול להיאסף
const summary = logText.slice(0, 200);
// טוב: צרו מחרוזת עצמאית, ואז שחררו את logText
const summaryCopy = Buffer.from(summary, 'utf8').toString('utf8');
3. אובייקטים: כל מפתח תופס משבצת
אובייקט רגיל מכיל לפחות שלושה דברים: מצביע למחלקה הנסתרת שלו (map), מצביע למאגר המאפיינים שלו ומצביע למאגר ה-elements שלו. עם דחיסת מצביעים כל מצביע הוא 4 בייטים, וכותרת אובייקט ה-heap מוסיפה עוד, כך שאפילו אובייקט ריק עולה בערך שישה-עשר עד עשרים ומשהו בייטים. בבנייה של 64 סיביות בלי דחיסת מצביעים — כפול מזה.
מאפיינים בשם נוחתים באחד משני מקומות:
- מאפיינים in-object: משבצות שמונחות ישירות אחרי כותרת האובייקט, מה שנותן גישה מהירה ביותר;
PropertyArray: ברגע שמשבצות ה-in-object נגמרות, המאפיינים עוברים למערך נפרד והאובייקט מחזיק מצביע אליו.
כל משבצת היא 4 או 8 בייטים, ומחרוזת המפתח נספרת בנפרד. זו הסיבה שמפתחות ארוכים הופכים לעלות אמיתית לאורך מאות אלפי רשומות, ולא רק לעניין אסתטי.
החדשות הטובות הן שמחלקות נסתרות משותפות: מיליון אובייקטים עם אותם מפתחות באותו סדר חולקים map אחד במקום להעתיק מידע מבני. החדשות הרעות הן שהדפוסים האלה שוברים את השיתוף הזה או דוחפים את האובייקט לייצוג יקר יותר:
- לחלק מהרשומות יש שדה
remarkולאחרות אין, מה שיוצר שתי צורות ובדיקת צורה נוספת בכל גישה; - סדרי הכנסה שונים יכולים ליצור צורות שונות;
- מספר מאפיינים גדול מאוד (מעבר לבערך אלף) או פעולות
deleteתכופות דוחפים את האובייקט למצב dictionary, שבו כל מאפיין נושא גם רשומת טבלת hash; Object.create(null)נמצא במצב dictionary מלידתו. הוא טבלת חיפוש טובה ובחירה גרועה לרשומה עם סכימה קבועה שניגשים אליה בלולאה חמה.
תפיסה שגויה נפוצה אחת ראויה לתיקון: JSON מתיר מפתחות כפולים, ו-{"a":1,"a":2} הוא מסמך חוקי שתוצאתו a === 2. האובייקט הסופי שומר ערך אחד, אבל הפרסר עדיין סורק ומשמיע כל מופע. מפתחות כפולים לא חוסכים זיכרון; הם רק עולים זמן ניתוח.
4. מערכים ומספרים: סוגי elements קובעים את הרוחב
V8 עוקב אחרי כמה "סוגי elements" של מערכים, ולכל אחד רוחב שונה לכל משבצת:
| תוכן המערך | סוג ה-element | עלות משוערת לכל משבצת |
|---|---|---|
| מספרים שלמים קטנים (בטווח Smi) | PACKED_SMI_ELEMENTS |
4 בייטים |
| מספרים עשרוניים או שלמים גדולים מאוד | PACKED_DOUBLE_ELEMENTS |
8 בייטים |
| מחרוזות או אובייקטים | PACKED_ELEMENTS |
מצביע של 4 בייטים ועוד הערך עצמו |
מערכים עם חורים שנוצרו ב-delete |
HOLEY_* |
אותו דבר, ועוד בדיקות חורים |
| מערכים דלילים מאוד | DICTIONARY_ELEMENTS |
רשומות hash, בדרך כלל 20+ בייטים |
שני כללים שווים לזכירה:
ראשית, סוגי elements רק מדרדרים. מערך שמתחיל במספרים שלמים קטנים הופך למערך double ברגע שמכניסים 0.5, ולמערך elements גנרי כשי מכניסים מחרוזת. הוא לעולם לא מתאושש אוטומטית; צריך לבנות מערך חדש.
שנית, היכן מספר JSON נוחת תלוי בערכו. מספרים שלמים בטווח Smi (בערך פלוס-מינוס 1.07 מיליארד) נשמרים ישירות כ-Smi ותופסים משבצת מצביע אחת. מספרים עם ספרות עשרוניות או אקספוננטים, או ערכים מחוץ לטווח הזה, מקצים HeapNumber של כ-16 בייטים, עם מצביע אליו במערך. לכן [1, 2, 3] ו-[0.1, 0.2, 0.3] נבדלים לא רק בערך אלא ב"משבצת אחת של 4 בייטים" מול "משבצת של 8 בייטים ועוד אובייקט heap".
זה גם השורש של בעיית הדיוק במזהים של מספרים שלמים גדולים: מעבר לטווח השלמים הבטוח, מספרי JSON מטופלים כ-doubles וספרות מסדר נמוך פשוט נעלמות. מערכי JSON מגיעים packed ובלי חורים, לכן אל תשתמשו ב-delete על elements אחרי הניתוח; זה דוחף את כל המערך לייצוג יקר יותר.
5. שיאי ניתוח וסריאליזציה: הרגע שבו תהליך באמת מת
זיכרון במצב יציב הוא פנקס אחד; השיא הוא פנקס אחר. בזמן ש-JSON.parse(text) רץ, התהליך מחזיק שלושה דברים בבת אחת:
- מחרוזת הקלט
text; - גרף האובייקטים שנבנה ועדיין לא הושלם;
- מבני העזר הזמניים של הפרסר עצמו.
שיא הניתוח הוא בערך "טקסט + גרף אובייקטים + תקורה זמנית". אחרי הניתוח, אם text עדיין מופנה על ידי משתנה, closure, מטמון ברמת מודול או חנות reactive, זיכרון הטקסט הזה לעולם לא מוחזר.
גם לסריאליזציה יש שיא. JSON.stringify(data) מחזיק את גרף האובייקטים המלא בזמן בניית מחרוזת הפלט, עם מאגרי שרשור באמצע. כשהפלט מתקרב למגבלת אורך המחרוזת של V8 (בערך 500 מיליון תווים), הוא זורק RangeError; הכשל הזה מופיע בדרך כלל בסקריפטים שמייצאים מסד נתונים שלם כמסמך JSON אחד.
הדפוס הנפוץ היקר ביותר משתמש בסריאליזציה כשכפול עמוק:
// השיא הוא בערך: גרף האובייקטים המקורי + טקסט זמני + גרף אובייקטים חדש
const safeCopy = JSON.parse(JSON.stringify(bigData));
השורה הבודדת הזו שומרת את אותם נתונים שלוש פעמים. לעבודה בקריאה בלבד, העבירו הפניה. כשבאמת נדרש בידוד, השתמשו ב-structuredClone וקבלו שהוא גם בונה מחדש את כל גרף האובייקטים.
6. מדידה עם process.memoryUsage
ב-Node, process.memoryUsage() מחזיר חמישה מספרים בבת אחת: rss (זיכרון פיזי שהתהליך משתמש בו), heapTotal, heapUsed (ה-heap של JS בשימוש), external (זיכרון שמוחזק על ידי אובייקטי C++) ו-arrayBuffers (זיכרון ArrayBuffer ו-Buffer). כשלומדים עיבוד JSON, heapUsed הוא העדשה הנכונה לגרף האובייקטים ו-rss אומר לכם אם התהליך כולו יצא משליטה.
לסקריפט מדידה נדרשים שלושה כללים: קראו ל-global.gc() קודם, דגמו בשלבים, ושנו משתנה אחד בכל פעם. הסקריפט הזה רושם בנפרד את התוספים של "קריאת הטקסט" ושל "ניתוח לאובייקטים":
// הרצה: 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. Buffers של קריאת קבצים נמצאים תחתexternalו-arrayBuffers, לכן אל תשתמשו במספר אחד כדי להסביר הכול;- אל תשתמשו ב-
console.logעל אובייקט גדול בין מדידות; זה מחזיק הפניה זמנית בחיים; - הריצה הראשונה כוללת חימום JIT וקומפילציה עצלה. הריצו לפחות שלושה סבבים וקחו את החציון;
- כדי להשוות בדפדפן, צלמו snapshots של ה-heap בפאנל Memory ב-DevTools וקראו
Retained Sizeבמקום לבהות בגרף המסונן ב-Allocation Timeline.
בלי קו הבסיס הזה, טענות כמו "מעבר להזרמה יפתור את זה" או "מטמון יעשה את זה מהיר יותר" הן רק תחושות.
7. הזרמה קונה שיא זיכרון, לא זמן כולל
העסקה בניתוח זורם היא מפורשת: גישה אקראית בתמורה לזיכרון שכבר לא מתרחב בהתאם לגודל הקובץ.
| גישה | שיא זיכרון | גישה אקראית | מתאימה ל |
|---|---|---|---|
JSON.parse מלא |
טקסט ועוד גרף האובייקטים המלא | כן | עד בערך 100MB ורוב השדות נדרשים |
פרסר SAX כמו stream-json |
chunk אחד ועוד האלמנט הנוכחי | לא | קבצים ענקיים, או שרק כמה שדות נדרשים |
| 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 native.
ההחלטה אינה "האם הקובץ עבר את ה-10MB". שאלו שתי שאלות: האם אני צריך את רוב השדות אחרי הניתוח? האם אני יכול לחיות עם גישה סדרתית? אם שתי התשובות כן, ניתוח מלא הוא הנכון והמאמץ שלכם שייך להפחתת העתקות ולרינדור. אם אחת מהתשובות לא, שקלו הזרמה. לאסטרטגיה השכבתית המלאה, ראו ניתוח, רינדור והזרמה של JSON גדול.
8. פחות העתקות, שחרור מוקדם יותר
בעבודת זיכרון, הסרת העתקה מלאה אחת שווה לרוב יותר מהחלפת פרסרים:
- העבירו הפניות לעבודה בקריאה בלבד. אל תשכפלו לעומק "למען הבטיחות", במיוחד לא עם
JSON.parse(JSON.stringify(x)); - אל תעטפו את המסמך הגולמי במערכת reactive.
reactiveשל Vue עוטף את כל גרף האובייקטים רקורסיבית, ולשכבת ה-proxy יש עלות זיכרון משלה; - חשבו וזכרו בנפרד נתונים נגזרים. אם רשימה צריכה רק id, title ו-status, אל תשימו רשומות שלמות בחנות;
- נרמלו מחרוזות בתדירות גבוהה. מפו ערכים כמו
"paid"ו-"pending"ל-enum מספרי כדי לחסוך גם אובייקטי מחרוזת וגם עלות השוואה; - קצצו הפניות ארוכות-טווח. אפסו אובייקטים גדולים כשמסיימים, נקו מטמונים ובטלו רישום קולבקים כדי ש-closures לא יישאו אותם לדף הבא;
- התחשבו בעלויות העתקה של Worker.
postMessageמשתמש ב-structured clone כברירת מחדל; שליחת גרף אובייקטים של 50MB חזרה ל-thread הראשי בונה העתקה שנייה. החזירו סיכום בכל הזדמנות.
החצי השני של פנקס הגודל נמצא בהפסיקו לבצע דחיסת JSON בסדר הלא נכון: בייטים על הדיסק ובייטים על הרשת הם חשבון שונה מגרף האובייקטים ב-heap, ואחוז בודד לא מסביר את שניהם. לאופן שבו עורכים וכלי CLI מתנהגים עם זיכרון, ראו עורכים, כלי שורת פקודה ואוטומציה.
9. צ'קליסט
| סימפטום | סיבה סבירה יותר | פעולה ראשונה |
|---|---|---|
| קובץ של 10MB דוחף את התהליך ל-50MB | גרף האובייקטים והטקסט נשארים בחיים | מדדו בשלבים; בדקו אם הטקסט נשמר |
| OOM בזמן הניתוח, תקין במצב יציב | שיא הניתוח חופף לנתונים ישנים | שחררו אובייקטים ישנים לפני ניתוח הקובץ החדש |
| הזיכרון עולה לאט ולא יורד | פרוסה, מטמון או closure מחזיק הפניה | צלמו snapshot של ה-heap ומיינו לפי retained size |
| מערכים גדולים מהצפוי | מספר עשרוני הדרדר את סוג ה-element | אחדו סוגים מספריים; הימנעו מ-delete |
| הוספת מטמון האטה את המערכת | מפתחות וערכים צורכים זיכרון ומעלים לחץ GC | חסמו את המטמון ושמרו רק סיכומים |
מדוע הזיכרון גדל פי שלושה או ארבעה אחרי JSON.parse?
מפני שהטקסט הוא רק הקלט; תוצר הניתוח הוא גרף אובייקטים. לכל אובייקט יש כותרת, לכל מאפיין יש משבצת, ולכל מחרוזת יש כותרת משלה. עם מספיק צמתים, התקורה הכוללת עולה על מספר הבייטים בטקסט. המכפיל תלוי בצורת הנתונים, ומערכים שטוחים של אובייקטים שונים מאוד ממסמכים מקוננים לעומק.
האם קובץ JSON של 10MB צריך להינתח במלואו או בזרימה?
הסתכלו קודם על המטרה. אם צריך גישה אקראית, צריך את רוב השדות והקובץ נכנס לזיכרון, השתמשו ב-JSON.parse מלא. אם צריך רק כמה שדות, או שהקובץ לא נכנס, הזרימו אותו. 10MB אינו גבול; הזרמה הופכת חובה בדרך כלל במקום כלשהו מעל 100MB.
מדוע process.memoryUsage().heapUsed לא תואם לניטור המערכת?
heapUsed מכסה רק את החלק בשימוש של ה-heap של JS, בלי external, arrayBuffers ו-code space. ניטור המערכת מדווח rss, שהיא מדידה אחרת. עקבו אחרי heapUsed ו-rss יחד; פער גדול מצביע על Buffers ואובייקטים native.
האם JSON.parse(JSON.stringify(x)) הוא שכפול עמוק בטוח?
לא. הוא מחזיק בו-זמנית את הגרף המקורי, טקסט זמני וגרף חדש, ולכן השיא הוא בערך פי שלושה ממצב יציב, והוא מאבד undefined, פונקציות וסמנטיקת Date. העבירו הפניות לעבודה בקריאה בלבד; כשנדרש בידוד, השתמשו ב-structuredClone וקבלו את אותה עלות בנייה מחדש.