JSON Toolkit

פנקס הזיכרון של JSON: מה באמת עולים מחרוזות, אובייקטים ומערכים גדולים

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

מתקדםכ-11 דקות קריאהפורסם 2026-10-31עודכן 2026-10-31
פנקס זיכרון JSON: עלות ה-heap של מחרוזות, אובייקטים ומערכים ב-V8

שורה תחתונה: קודם קבעו קו בסיס עם 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:

  1. פרוסות מחזיקות את האב בחיים. V8 מייצג תת-מחרוזות ארוכות יותר כ-SlicedString, ששומר הפניה לאב ועוד היסט ואורך. שמרו 200 תווים שנחתכו מלוג של 80MB — וכל ה-80MB יישארו נגישים עד שהפרוסה תשוחרר.
  2. שרשור יוצר ConsString. חזרה על += על מחרוזות גדולות בונה עץ של שברים שמתאחד לזיכרון רציף רק כשבאמת קוראים את התווים. שרשור עשרת אלפים פעמים ואז קריאה אחת מייצרים קפיצת הקצאה גדולה באותו רגע.
  3. ערכים חוזרים לא משותפים אוטומטית. עשרת אלפים רשומות שמכילות "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) רץ, התהליך מחזיק שלושה דברים בבת אחת:

  1. מחרוזת הקלט text;
  2. גרף האובייקטים שנבנה ועדיין לא הושלם;
  3. מבני העזר הזמניים של הפרסר עצמו.

שיא הניתוח הוא בערך "טקסט + גרף אובייקטים + תקורה זמנית". אחרי הניתוח, אם 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 וקבלו את אותה עלות בנייה מחדש.