A substring() nem másol: húsz karakter, ami 200 megabájtot tart életben

A V8 háromféle adatszerkezetet rejt egy JavaScript-string mögé: flat, cons és sliced. Ettől a += olcsóbb, mint a régi tanácsok állítják, a substring() viszont képes 200 megabájtot életben tartani húsz karakterért. Lemértem.

Van az a fajta memóriaszivárgás, amit addig nem hiszel el, amíg magad le nem méred. Egy Node-os feldolgozó megabájtos szövegekből kivesz egy-egy húszkarakteres azonosítót, elteszi őket egy Mapbe, a nagy stringeket elengedi. A GC rendben lefut, a heap mégis csak hízik. A heap snapshotban aztán ott a magyarázat egy ártatlan zárójeles sorban: (sliced string).

A JavaScript-string kifelé immutábilis karaktersorozat, ennyi a szerződés. Befelé viszont a V8 legalább háromféle adatszerkezetet hív stringnek, és hogy éppen melyiket kapod, az azon múlik, hogyan állítottad elő. Ez a cikk arról szól, hogy ettől miért olcsóbb a +=, mint a régi tanácsok állítják – és miért tud húsz karakter 200 megabájtot túszul ejteni.

Diagram: fent összefűzött stringdarabokból épülő cons-fa, lent hosszú memóriasáv, amelynek kiemelt kis szeletére egy különálló slice-objektum mutat

Három string, ami ugyanannak látszik

Az „igazi” string a V8-ban a SeqString: egybefüggő karakterpuffer, ahogy az ember elképzeli. A másik kettő okoskodás. A ConsString az összefűzés eredménye: nem másol semmit, csak egy két pointerből álló párt allokál a két operandusra. A SlicedString a kivágásé: pointer a szülő stringre, plusz egy offset és egy hossz. Mindkettőnek van alsó határa: 13 karakter alatt a V8 inkább másol, mert annyiért nem éri meg külön objektumot fenntartani – a konstans kMinLength néven ül a string.h-ban, mindkét osztálynál ugyanazzal az értékkel.

Mindezt nem kell elhinni, Node alól ki lehet mérni:

// futtasd: node --allow-natives-syntax types.js
const a = 'abcdefghijklmnopqrstuvwxyz';
const b = '0123456789012345678901234567890123456789';

const cons = a + b;                 // nincs másolás: (a, b) pointerpár
const sub  = cons.substring(3, 30); // pointer + offset + hossz
const kis  = cons.substring(0, 5);  // 13 karakter alatt: valódi másolat

%DebugPrint(cons); // - type: CONS_ONE_BYTE_STRING_TYPE
%DebugPrint(sub);  // - type: SLICED_ONE_BYTE_STRING_TYPE
%DebugPrint(kis);  // - type: SEQ_ONE_BYTE_STRING_TYPE

A += számláját később kézbesítik

A „ciklusban ne fűzz stringet, gyűjts tömbbe és join-olj” tanács a 2000-es évek végének böngészőiből maradt ránk, és azóta is öröklődik code review-ról code review-ra. A ConsString miatt viszont a += már régóta nem kvadratikus: minden lépés egy fix méretű csomópont, a karakterekhez hozzá sem nyúl. A V8 gyakorlatilag kötelet (rope-ot) épít a hátad mögött. Egymillió tízkarakteres darab összefűzése a gépemen (Node 22, V8 12.4) körülbelül 70 ms – tízmillió karakterre, lineárisan.

A becsülethez tartozik, hogy a join ettől még nem halott: ugyanez push + join párossal nálam ~37 ms, mert egymillió cons csomópont allokálása és bejárása sem ingyenes. De ez konstans szorzó, nem nagyságrend – és nem ezért érdemes erről a részről tudni. Hanem ezért:

let s = '';
for (let i = 0; i < 1_000_000; i++) s += '0123456789';
// idáig ~70 ms: 10 MB-nyi "kötél" épült, karaktermásolás nélkül

s.charCodeAt(0); // ~10 ms: MOST másolódik ki mind a 10 millió karakter
s.charCodeAt(1); // ~0.001 ms: a kilapított eredmény megmarad

Kötelet nem lehet O(1) alatt indexelni, ezért az első karakterszintű hozzáférés kiváltja a flatten-t: a V8 egyszer kimásolja az egészet egy egybefüggő pufferbe. A stringépítés költsége tehát nem ott jelenik meg a profilerben, ahol építesz, hanem ahol először olvasol – egy charCodeAt-nál, egy regexnél, vagy éppen a JSON.stringify-ban, amelynek gyorsítósávjáról a ConsString egyébként is lecsúszik. Ha valaha bámultál már értetlenül egy flame graphot, amiben egy ártatlan olvasó sor vitte az időt, lehet, hogy csak valaki más kötelét lapítottad ki.

A szelet, ami horgonyoz

A SlicedString ugyanez a spórolás kivágásra, csak itt a számla nem időben, hanem memóriában érkezik. A substring, a slice, a trim, a match találatai, sőt a split darabjai is – 13 karakter fölött – csak rámutatnak a szülőre. Amíg a szelet rövid életű, ez tiszta nyereség. Csakhogy a GC a szülőt addig nem szabadíthatja fel, amíg egyetlen szelete él. A szelet nem éli túl a szülőt – hanem életben tartja.

// futtasd: node --expose-gc retain.js
function makeSlice() {
  const oriasi = 'x'.repeat(200 * 1024 * 1024) + '!'; // ~200 MB
  return oriasi.substring(5, 25);                     // 20 karakter kell belőle
}

const base = (gc(), process.memoryUsage().heapUsed);
let sub = makeSlice();
gc();
console.log(process.memoryUsage().heapUsed - base); // +200,0 MB

sub = structuredClone(sub); // egybefüggő másolat: a szülő elengedhető
gc();
console.log(process.memoryUsage().heapUsed - base); // +0,2 MB

Húsz karakter, 200 megabájt. Az oriasi változó rég halott, a GC kétszer lefutott, de a szelet mögött ott lóg a teljes szülő. Sorozatban ugyanez: kétszáz darab egymegás stringből kivett húsz-húsz karakteres azonosítóval 200 MB marad a heapen – másolatokkal 1 MB. Ez nem friss regresszió: a V8 issue trackerében bő egy évtizede létezik jegy róla „Substring of huge string retains huge string in memory” címmel, és a Chrome DevTools dokumentációja is külön kitér rá: a heap snapshotban ezek a példányok (sliced string), a kötelek (concatenated string) néven jelennek meg. Ha egy snapshotban gyanúsan sok van belőlük, most már tudod, mit nézel.

Egy megnyugtató részlet: a JSON.parse-ból érkező stringek frissen épített, egybefüggő másolatok – egy nagy JSON-válasz mezőit nyugodtan tárolhatod hosszú távon. A csapda a nyers szövegfeldolgozásnál él: beolvasott fájl, split('\n'), regex-találat, trim. Mindegyiket végigmértem %DebugPrint-tel, mind sliced stringet ad vissza.

Hogyan vágd el a köldökzsinórt

A folklór erre a (' ' + s).slice(1) trükköt ajánlja, és a helyzet ennél csak furcsább: ma is működik, de már nem úgy, ahogy a születésekor. Az eredmény típusra nézve továbbra is sliced string – csak épp a szülője már nem az óriás, hanem az összefűzésből kilapított, apró átmeneti string. A kétszáz egymegás stringes tesztben a trükkel is 1 MB marad a 200 helyett. Kabala helyett viszont van kevésbé kriptikus eszköz is: a structuredClone(s) vagy Node-ban a Buffer.from(s).toString() bizonyítottan egybefüggő másolatot ad, és az óriás szülő felszabadul.

Mikor érdekeljen ez?

Ne kezdd el végig-structuredClone-ozni a kódbázist. A szeletek túlnyomó része rövid életű, és a sliced string pont attól véd meg, hogy minden substring O(n)-be kerüljön – ez az esetek 99%-ában neked dolgozik. Másolni akkor érdemes, ha nagy forrásból kivágott kis darabot teszel hosszú életű struktúrába: cache-be, Mapbe, session-állapotba. Tipikus elkövetők: logfeldolgozók, CSV- és szövegparserek, meg minden, ami megabájtos bufferből toString után szeletel. A += kontra join átírásáért pedig kár PR-t nyitni; a különbség konstans szorzó, és úgyis a flatten viszi el a show-t.

Amit ebből a témából tényleg érdemes hazavinni, az egyetlen mondat: a JavaScript-string költsége nem akkor keletkezik, amikor létrehozod, hanem amikor először komolyan hozzányúlsz – vagy amikor elfelejted elengedni a szülőjét. A cím pedig innen jön: a fenti mérésben szó szerint húsz karakter tartott életben 200 megabájtot. A GC pontosan azt csinálta, amit kértek tőle. Csak épp nem azt, amit vártál.

Források

Leave a Reply

Az e-mail címet nem tesszük közzé. A kötelező mezőket * karakterrel jelöltük