Как V8 хранит строки
V8 представляет строки деревом типов: плоские Seq-строки (Latin1 или UTF-16), ConsString (узел конкатенации за O(1)), SlicedString (подстрока за O(1), которая пинит родителя), ThinString и ExternalString. Internalized-строки сравниваются по указателю. Flattening
Вы парсите лог-файл на 50 МБ, вытаскиваете 12-символьный request ID через line.slice(0, 12), кладёте его в массив, а всё остальное отпускаете. Часами позже процесс сидит на сотнях мегабайт ради массива крошечных ID. Сохранённые вами строки — по 12 символов каждая, но каждая втайне держит всю свою исходную строку, а через неё — весь буфер, живыми. Строковые представления V8 — это и есть почему, и почему один + '' это чинит.
Строка не всегда плоский массив символов
Наивная модель — «строка это непрерывный блок символов» — лишь одно из нескольких строковых представлений V8. V8 выбирает представление так, чтобы частые операции (конкатенация, нарезка, сравнение) были дёшевы, и материализует плоский массив символов лишь когда обязан. Одно строковое значение JS может быть любым из этих видов HeapObject:
- SeqOneByteString / SeqTwoByteString — плоский случай. Символы хранятся непрерывно прямо в объекте. V8 использует однобайтный (Latin1) backing, когда каждый символ умещается в 8 бит, и двухбайтный (UTF-16) иначе — поэтому ASCII-строка занимает вдвое меньше памяти, чем строка с любым не-Latin1 символом.
- ConsString — узел конкатенации.
a + bне копирует символы; он аллоцирует маленький узел, держащий указатели наaиb(firstиsecond) и общую длину. Конкатенация поэтому O(1), а повторная конкатенация строит дерево ConsString. - SlicedString — представление подстроки.
str.slice(i, j)аллоцирует узел, держащий указатель на родительскую строку плюс смещение и длину. Подстрока — O(1) и делит символы родителя — но держит всего родителя живым. - ThinString — переадресующий указатель. Когда строку интернализируют (см. ниже), но старая неинтернализированная копия ещё существует, старая становится ThinString, который просто указывает на каноническую интернализированную строку.
- ExternalString — символы, которыми владеет C++ вне кучи V8 (например, исходный файл, который встраиватель держит в своём буфере). V8 держит указатель на это внешнее хранилище вместо копирования.
Главный вывод по всем пяти: V8 откладывает копирование символов как можно дольше. ConsString и SlicedString — O(1) именно потому, что не материализуют плоский буфер; цена — скрытый указатель на исходные данные, которые могут быть огромными. Когда вы передаёте строку в нативный API или индексируете её, V8 наконец платит за копию — вопрос в том, хотите ли вы, чтобы эта копия произошла с прикреплённым родителем или уже после того, как вы разорвали цепочку.
Internalized-строки: равенство за один цикл
Задумывались ли вы, почему доступ к свойству остаётся быстрым, если имена свойств — строки, а сравнение строк должно быть O(длина)? Ответ — интернализация. V8 поддерживает внутреннюю таблицу строк (string table — таблица с дедуплицированными канониками строк). Идентификаторы, имена свойств и строковые литералы интернализируются: дедуплицируются так, что существует ровно одна каноническая копия каждой различной строки. Выигрыш огромен для собственной работы движка — сравнить две интернализированные строки на равенство — это одно сравнение указателей (1 цикл), а не посимвольный проход, чья стоимость растёт с длиной.
Вот почему доступ к свойству быстр: имя свойства в вашем коде и имя, хранимое в Map объекта, — одна и та же интернализированная строка, поэтому поиск сравнивает указатели. Когда вы создаёте свежую неинтернализированную строку, равную существующей интернализированной (скажем, прочитав её из входа), и она затем используется как ключ свойства, V8 может превратить её в ThinString, переадресующий на каноническую копию, восстанавливая равенство-по-указателю для будущих сравнений.
Flattening: скрытая цена использования ConsString
ConsString дёшев строить, но не может использоваться напрямую для операций, требующих непрерывных символов — важнее всего индексация (str[i], charCodeAt), regex и передача в C++-API. При первом действии, которому нужна плоская форма, V8 делает flatten (выпрямляет) строку: он обходит дерево ConsString и копирует все символы в одну Seq-строку, затем переписывает узел, чтобы тот указывал на плоский результат.
Так что цена строки, построенной через +, отложена, а не бесплатна. Если вы строите огромную строку через += в цикле и потом индексируете её один раз, вы платите один O(n) flatten в этот момент. Обычно это нормально — но патологический паттерн — это построение глубокого дерева ConsString и повторное провоцирование переflatten’а или удержание множества невыпрямленных cons-деревьев, раздувающих кучу. (Современный V8 хорошо обрабатывает +=-циклы; классический совет всегда использовать массив + join важнее всего для действительно огромных построений.)
Ловушка памяти: slice, который пинит родителя
Вернёмся к Hook. bigString.slice(0, 12) возвращает SlicedString, держащий указатель на bigString. Пока этот 12-символьный slice достижим, bigString нельзя собрать — GC видит живую ссылку на него через slice. Держите тысячи крошечных slice гигантского файла — и вы держите гигантский файл (и его backing-буфер) целиком живым, хотя логически вам нужны были лишь несколько сотен байт.
// УТЕЧКА: каждый id — это SlicedString, пинящий всю свою 50 МБ исходную строку / буфер.
const ids = lines.map(line => line.slice(0, 12));
// ФИКС: вынудить свежую, плоскую, независимую копию, которая НЕ ссылается на родителя.
const ids = lines.map(line => (line.slice(0, 12) + '').slice());
// или явно и понятно:
const ids = lines.map(line => {
const s = line.slice(0, 12);
return s.repeat(1); // вынуждает flatten в самостоятельную Seq-строку
});Лекарство — вынудить flatten в самостоятельную строку, которая копирует символы и сбрасывает указатель на родителя. Конкатенация + '' или любая операция, дающая свежую Seq-строку, разрывает цепочку удержания, и GC может вернуть родителя. Размен — копия за O(длина-slice) сейчас в обмен на освобождение удержания за O(длина-родителя) — почти всегда стоит того для долгоживущих маленьких slice.
- ASCII backing (Latin1)
- 1 байт / символ
- Не-Latin1 backing (UTF-16)
- 2 байта / символ
- Стоимость конката ConsString
- O(1) — узел, без копии
- Стоимость подстроки SlicedString
- O(1) — пинит родителя
- Равенство internalized-строк
- 1 сравнение указателей
- Flatten при первой индексации cons
- O(n) разовая копия
Что на самом деле аллоцирует `a + b` в V8 для двух существующих строк и какова стоимость?
Вы держите массив из 10 000 двенадцатисимвольных slice, взятых из 10 000 больших строк, а сами строки отбрасываете. Почему память остаётся высокой?
Расставьте, что происходит, когда вы строите строку повторным + и затем один раз вызываете на ней charCodeAt.
- 1 Каждый + аллоцирует узел ConsString, указывающий на свои два операнда
- 2 Повторный + строит дерево ConsString, символы не копируются
- 3 charCodeAt нужны непрерывные символы по заданному индексу
- 4 V8 делает flatten: обходит дерево и копирует все символы в одну Seq-строку
- 5 Узел переписывается, чтобы указывать на плоский результат для будущего доступа
▸Граничные случаи
Выбор одно- против двухбайтного делается per-строку и решается при создании: строка из чистого ASCII получает Latin1-backing по 1 байту на символ; введите единственный эмодзи или кириллический символ — и вся строка хранится двухбайтной по 2 байта на символ. Вот почему конкатенация пользовательского текста с не-Latin1 содержимым может удвоить память иначе-ASCII нагрузки, и почему некоторые серверы нормализуют или сегментируют текст, чтобы держать большие горячие строки однобайтными.
- 01Назовите строковые представления V8 и что оптимизирует каждое.
- 02Что такое internalized-строки и почему они делают доступ к свойствам быстрым?
- 03Объясните flattening и ловушку удержания SlicedString, и как починить утечку.
V8 не хранит каждую строку как плоский массив символов; он выбирает среди нескольких представлений HeapObject, чтобы держать частые операции дёшево. Seq-строки — плоская форма, с backing Latin1 по байту на символ для чисто-ASCII текста и UTF-16 по два байта иначе, поэтому единственный не-Latin1 символ удваивает память строки. ConsString делает конкатенацию O(1), аллоцируя узел, который указывает на свои два операнда, а не копирует символы, строя дерево при повторном +. SlicedString делает подстроки O(1), держа указатель на родителя плюс смещение и длину — но этот указатель держит всего родителя живым, поэтому удержание множества маленьких slice больших источников — классическая утечка памяти, чинимая принуждением к самостоятельной плоской копии вроде slice + ”. ThinString переадресует на каноническую internalized-строку, а ExternalString указывает на символы, которыми владеет C++ встраивателя, чтобы избежать копии. Internalized-строки, хранимые в таблице строк V8, позволяют равенству и сравнению имён свойств быть одним сравнением указателей вместо посимвольного прохода, что — главная причина быстрого доступа к свойствам. Отложенная цена построения через + — это flattening: первая операция, которой нужны непрерывные символы (индексация, regex), обходит cons-дерево и копирует всё в одну Seq-строку за O(n). Теперь, когда встретишь процесс с утечкой памяти на массиве «маленьких» строк после парсинга большого файла, или профилировщик с неожиданными аллокациями на одном вызове charCodeAt — ты знаешь, проверить, не являются ли эти строки SlicedString, пинящими родителей, а + '' — твой первый диагностический шаг.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.