Погружение в V8

9 марта 2026

Часть 1. История

Введение

В предыдущем исследовании мы много говорили о Node.js: как устроен его Event Loop и какую роль в этом играет библиотека libuv.

Теперь настало время поговорить про то, что объединяет Node.js и браузеры. Речь, конечно, о V8 — движке, на котором исполняется JavaScript.

В этом большом исследовании мы разберём основные компоненты V8, интересные особенности и внутренние механизмы. Мы попробуем заглянуть внутрь, а также проанализировать работу JavaScript-кода с помощью профилирования. Но начать стоит с истории.

Происхождение V8

V8 — это высокопроизводительный движок с открытым исходным кодом, разработанный для выполнения JavaScript. Он был представлен Google в 2008 году вместе с первым релизом браузера Chrome.

Сегодня V8 используется в Chrome и Node.js. В последнем он позволяет исполнять JavaScript вне браузера и обращаться к системным возможностям.

Название «V8», кстати, отсылает к восьмицилиндровому двигателю внутреннего сгорания — и действительно, этот движок стал одним из самых быстрых благодаря гибридной компиляции, множеству оптимизаций и эффективной системе управления памятью.

JIT-компиляция и байткод

Всё, что исполняется на компьютере, в конечном итоге превращается в машинный код — последовательность нулей и единиц, понятная процессору. Это низкоуровневый язык, специфичный для архитектуры устройства (например, x86 или ARM).

Разработчики, конечно, пишут не на машинном коде, а на языках высокого уровня. Чтобы такой код можно было исполнить, он должен пройти через серию преобразований. Существуют разные подходы к этому процессу:

  • Интерпретация. Код исполняется построчно, без предварительного преобразования в машинный код. Такой подход прост и гибок, но медленный.
  • AOT-компиляция (ahead-of-time). Программа полностью компилируется в машинный код заранее, до запуска. Это даёт высокую скорость выполнения, но снижает гибкость.
  • JIT-компиляция (just-in-time). Промежуточный подход: код компилируется в машинный во время исполнения, по мере необходимости.

До появления V8 большинство JavaScript-движков работали как интерпретаторы. Код выполнялся строчка за строчкой, что значительно ограничивало производительность. Например, в SpiderMonkey от Mozilla использовалась именно такая модель.

С ростом популярности динамичных веб-приложений началась гонка за скорость. WebKit представил проект SquirrelFish Extreme, в котором уже применялась JIT-компиляция, но её архитектура была менее производительной и универсальной.

Революционность V8 заключалась в том, что он первым стал компилировать JavaScript напрямую в машинный код, минуя промежуточную стадию байткода. Это обеспечило значительный прирост производительности по сравнению как с интерпретаторами, так и с ранними JIT-подходами.

Байткод — это промежуточный код, создаваемый на основе исходного. Он не зависит от архитектуры процессора и не исполняется напрямую, но может интерпретироваться или служить основой для компиляции в машинный код.
Со временем выяснилось, что отсутствие байткода затрудняет управление памятью и замедляет запуск, особенно при выполнении кода, который вызывается однократно. Поэтому в 2016 году в V8 появился Ignition — интерпретатор, преобразующий JavaScript в байткод и исполняющий его. Это позволило ускорить старт программы.

Сейчас JIT-компиляция в V8 устроена следующим образом:

  1. JavaScript-код парсится в абстрактное синтаксическое дерево (AST).
  2. AST компилируется в байткод интерпретатором Ignition.
  3. Байткод исполняется. При этом V8 отслеживает, какие участки кода повторяются чаще всего.
  4. «Горячий» код передаётся компилятору, который преобразует его в машинный код.
  5. Если поведение программы изменилось (например, типы данных стали другими), может произойти деоптимизация — возврат к интерпретации байткода.

Такой подход позволяет достичь баланса: сначала начать выполнение как можно быстрее, а затем — ускорить работу критических участков кода.

Дополнительно V8 использует скрытые классы, встроенные кэши и продвинутые алгоритмы управления памятью, что позволяет запускать сложные веб-приложения с производительностью, близкой к нативным.

Этапы развития

V8 появился в 2008 году вместе с браузером Chrome. Его разработкой руководил Ларс Бак. В ранних версиях использовались простые парсер и компилятор. Тогда же использовался ассемблер Strongtalk.

Позднее движок развивался следующим образом:

  • 2010 — появление Crankshaft, оптимизирующего компилятора, который значительно повысил скорость исполнения.
  • 2015 — переход к TurboFan, который стал более универсальным и позволил улучшить поддержку новых возможностей JavaScript.
  • 2016 — добавлен Ignition — интерпретатор, исполняющий байткод. Это позволило ускорить запуск и снизить издержки на старте.
  • 2021 — появился SparkPlug, компилятор, призванный сократить разрыв между интерпретацией и оптимизацией.
  • 2023 — добавлен Maglev, компилятор с упором на скорость и эффективное использование памяти.

Альтернативы V8

Существуют и другие движки JavaScript, используемые в разных браузерах и системах:

  • JavaScriptCore (JSC) — движок от Apple, лежит в основе Safari. Он сфокусирован не только на скорости, но и на энергоэффективности, что особенно важно для мобильных устройств.
  • SpiderMonkey — движок Mozilla Firefox. Один из первых движков для JavaScript, с поддержкой всех современных стандартов.

Главное отличие V8 — приоритет на JIT-компиляцию и адаптацию под современные веб-приложения. Его широкое распространение через Chrome и Node.js сделало его де-факто стандартом в мире фронтенд-разработки.

Часть 2. Из чего состоит движок

Введение

В первой части мы рассмотрели историю создания и развития движка V8, а также его отличия от других JavaScript-движков. Мы говорили, что V8 состоит из нескольких компонентов, и в этой части исследования разберём каждый из них подробнее. Посмотрим, как они устроены, в каком порядке работают и какие идеи лежат в основе их архитектуры.

Full-codegen и первые шаги V8

Изначально V8 был представлен в официальном блоге Chromium как движок, компилирующий JavaScript напрямую в машинный код. Вместе с этим он внедрил систему скрытых классов, которая используется до сих пор. Первоначальный компилятор был достаточно простым, не поддерживал серьёзных оптимизаций и работал со всем кодом одинаково. Позже он получил название Full-codegen.

Crankshaft и адаптивные оптимизации

Следующим этапом стал компилятор Crankshaft, название которого переводится как «коленчатый вал» — отсылка к автомобильной теме, проходящей через всё в V8. Анонс Crankshaft сопровождался заметным улучшением производительности.

Crankshaft впервые внедрил стратегию адаптивной компиляции: деление на «горячий» код, который исполняется часто и подлежит глубокой оптимизации, и «холодный» код, который можно оставить без изменений. Это объясняет, почему тесты без «прогрева» не показывали существенного прироста производительности: оптимизации применялись только со временем.

Важнейшие техники, применяемые Crankshaft:

  • SSA (Static Single Assignment form) — представление кода, в котором каждой переменной присваивается значение только один раз. Это упрощает анализ зависимостей и позволяет применять другие оптимизации. Например:
    let x = 1;
    x = x + 2;
    x = x * 3;
    
    В SSA это выглядит так:
    let x1 = 1;
    let x2 = x1 + 2;
    let x3 = x2 * 3;
    
  • Loop-invariant code motion — перенос выражений, не изменяющихся внутри цикла, за его пределы. Например, arr.length не нужно пересчитывать на каждой итерации.
  • Linear-scan register allocation — распределение переменных между регистрами и памятью с целью держать как можно больше активных значений в регистрах процессора, откуда доступ к ним осуществляется быстрее.
  • Inlining — встраивание тел небольших функций вместо их вызовов, что уменьшает накладные расходы на «перепрыгивания».
  • Типовые предположения. JavaScript — динамический язык, и типы заранее неизвестны. Поэтому движок собирает статистику: например, переменная чаще всего — число. Это позволяет применять более эффективные оптимизации.

TurboFan: многоступенчатая оптимизация

Позже Crankshaft был заменён TurboFan, который используется в V8 до сих пор. Главное отличие — это более мощный и универсальный подход к построению оптимизированного машинного кода.

  • TurboFan использует граф зависимостей (sea of nodes), промежуточное представление, где операции представлены как узлы, а зависимости между ними — как рёбра. Это даёт гибкость при реорганизации кода и поиске неиспользуемых кусков.
  • Компилятор построен как многоступенчатый pipeline: код проходит через серию трансформаций, на каждом этапе применяются определённые правила и анализы.
  • Вместо сложных алгоритмов используются локальные правила переписывания: x * 1 → x, x + 0 → x, if (true) → убрать ветвление.
  • TurboFan умеет делать анализ диапазонов значений, что позволяет применять ещё более точные оптимизации.
  • Так как код в виде графа не привязан к строгому порядку, TurboFan может свободно переносить узлы: вынести лишние вычисления из циклов, переместить их в менее часто выполняемые пути, а часто выполняемые оставить максимально «чистыми».
  • TurboFan генерирует код, ориентируясь на конкретную архитектуру процессора: учитывает SIMD-инструкции, особенности регистров и команд и т.д.

Ignition: регистровый интерпретатор и байткод

Несмотря на эффективность JIT-компиляторов, какими являлись и Crankshaft, и TurboFan, для некоторых сценариев — особенно на мобильных устройствах — важнее были скорость запуска и низкое потребление памяти. Поэтому в V8 появился интерпретатор Ignition, который используется до сих пор.

Ignition работает как регистровая машина (register machine).

При подходе stack machine (например, у JVM) каждая операция работает со стеком: «взять два значения со стека → сложить → результат обратно на стек». Это выглядит проще и компактнее в итоговом байткоде, но добавляет много накладных расходов при исполнении.

В случае же с register machine каждая инструкция говорит прямо, с какими виртуальными регистрами работать. Это даёт выигрыш в скорости и упрощает дальнейшую JIT-компиляцию.

Также у Ignition есть специальный аккумулятор: он используется как «неявный» регистр для большинства временных значений. Например, выражение a + b * c может хранить промежуточные результаты прямо в аккумуляторе, не делая кучу лишних загрузок/выгрузок.

Кроме того, при генерации байткода сразу применяются простые оптимизации: замена паттернов, устранение лишних операций, минимизация движений между регистрами. В результате байткод получается не только компактным, но и быстрым в исполнении.

Отказ от Full-codegen и Crankshaft

До появления Ignition и TurboFan в V8 использовалась связка Full-codegen и Crankshaft. Full-codegen обеспечивал базовое исполнение кода, а Crankshaft подключался при повторяющихся вызовах, чтобы применить оптимизации. Но со временем выяснилось, что такая архитектура имеет серьёзные недостатки: Full-codegen генерировал слишком много машинного кода, даже для кода, исполняемого всего один раз, а Crankshaft был слишком сложен и плохо поддерживал новые возможности JavaScript.

Ignition и TurboFan позволили отказаться от этой связки. В 2017 году команда V8 официально удалила Full-codegen и Crankshaft. С этого момента код в V8 стал жить по двум сценариям:

  • Если код «холодный», он остаётся в виде байткода и исполняется интерпретатором Ignition.
  • Если код становится «горячим», его байткод профилируется и передаётся TurboFan, где он компилируется в высокооптимизированный машинный код.

Такой подход улучшил стартовое время загрузки скриптов и сократил объём потребляемой памяти.

Sparkplug: baseline-компилятор

Несмотря на эффективность Ignition и TurboFan, в реальных приложениях есть большой слой кода, который исполняется нечасто, но всё же достаточно активно, чтобы оправдать переход от интерпретации к компиляции. Однако TurboFan — тяжёлый инструмент, и использовать его на всё подряд нецелесообразно. Поэтому был создан Sparkplug — лёгкий baseline-компилятор, который дополняет существующую цепочку.

Sparkplug компилирует функции не из исходного JavaScript, а из уже готового байткода. Это значит, что работа по разбору синтаксиса, разрешению переменных и деструктуризации уже выполнена. Sparkplug не создаёт промежуточных представлений вроде графа зависимостей, как TurboFan. Вместо этого он проходит по байткоду линейно и генерирует машинный код сразу. Весь компилятор по сути представляет собой большой switch внутри for, где на каждую инструкцию байткода приходится кусок готовой логики генерации.

Он почти не делает оптимизаций, за исключением локальных (например, убирает x + 0). Но это не проблема, потому что он не предназначен для достижения пиковой производительности. Его задача — избавить интерпретатор от накладных расходов: декодирования инструкций, предсказания ветвлений, извлечения операндов из памяти. Вместо этого Sparkplug просто «сериализует» выполнение интерпретатора. Он также активно использует встроенные фрагменты машинного кода (code stubs), общие с Ignition, чтобы не дублировать сложную реализацию JavaScript-операций. Благодаря этому компиляция быстрая, а память используется экономно.

Maglev: быстрые оптимизации между Sparkplug и TurboFan

В 2023 году команда V8 представила Maglev — ещё один JIT-компилятор, который занял промежуточное положение между Sparkplug и TurboFan. Он появился потому, что разрыв в производительности между ними оказался слишком большим: Sparkplug компилирует быстро, но почти не оптимизирует, а TurboFan оптимизирует глубоко, но компилирует долго.

Maglev строит граф зависимостей и использует SSA-подобное представление (когда каждой переменной значение присваивается только один раз), упрощённое по сравнению с TurboFan. Это позволяет ему выполнять базовые оптимизации:

  • удаление мёртвого кода,
  • упрощение выражений,
  • перестройку ветвлений,
  • перемещение инструкций.

Также Maglev использует данные профилирования и делает предположения о типах (например, «эта переменная всегда число»), что позволяет вставлять быстрые версии операций без дополнительных проверок.

Ключевая особенность Maglev — баланс между скоростью компиляции и качеством кода. Он компилируется в 10–20 раз быстрее TurboFan, но при этом даёт сопоставимую производительность в большинстве распространённых сценариев. Это позволяет применять его чаще, не рискуя перегрузить систему по времени или памяти.

Общая схема исполнения кода

Сегодня в V8 работает следующий многоуровневый pipeline:

JS

Ignition (байткод + интерпретация для холодных операций)

Sparkplug (на основе байткода компилирует baseline машинный код)

Maglev (на основе байткода компилирует средне-оптимизированный машинный код)

TurboFan (на основе байткода компилирует сильно оптимизированный машинный код)

Этот подход позволяет эффективно запускать код, не тратя ресурсы на редко используемые участки, и при этом достигать высокой производительности там, где это действительно важно.

Однако, это далеко не все составляющие большой системы движка V8. О других её частях мы поговорим в следующих частях исследования.

Часть 3. Парсинг, AST и анализ кода

Введение

В предыдущих частях мы рассмотрели историю V8 и архитектуру его интерпретатора Ignition и компиляторов Sparkplug, Maglev и TurboFan. Но прежде чем любой из них сможет начать работу, JavaScript-код должен пройти через несколько подготовительных этапов.

Когда движок получает исходный код, первым делом он должен понять, что этот код означает. Для этого строка символов превращается в структурированное представление, с которым могут работать компиляторы.

В этой части мы разберём, как V8 разбирает JavaScript-код: от первого символа до готового байткода, который может исполнить Ignition.

Лексический анализ

Первый этап обработки любого кода — лексический анализ или токенизация. Его задача — разбить поток символов на токены — минимальные значимые единицы языка.

Например, код:

const x = 42;

Превращается в последовательность токенов:

  • Token::CONST (ключевое слово)
  • Token::IDENTIFIER со значением "x"
  • Token::ASSIGN (оператор =)
  • Token::NUMBER со значением 42
  • Token::SEMICOLON (завершающая точка с запятой)

V8 использует сканер, который читает исходный код символ за символом и группирует их в токены.

Важно отметить, что сканер работает не с готовой строкой, а с потоком символов. Это позволяет ему обрабатывать скрипт ещё до того, как весь файл полностью загружен по сети. Благодаря этому V8 может начать сканирование и подготовку к парсингу уже на первых байтах загружаемого скрипта, не дожидаясь окончания передачи. Это особенно критично для больших бандлов или медленного соединения.

Обработка whitespace и комментариев

Хотя пробелы, табы и комментарии кажутся «шумом», для V8 они играют важную роль. Все такие символы классифицируются как Token::WHITESPACE, и перед обработкой нового токена движок последовательно их пропускает. Однако есть одна важная деталь: если среди пропущенного whitespace встретился перевод строки, это может повлиять на дальнейшую токенизацию — особенно из-за правил автоматической вставки точки с запятой.

function test() {
    return
    42;
}

Сканер должен понимать, что после return нужно вставить точку с запятой, и функция вернёт undefined, а не 42.

Abstract Syntax Tree (AST)

После токенизации начинается синтаксический анализ — построение Abstract Syntax Tree (AST). AST — это древовидная структура, которая отражает синтаксическую структуру программы, но абстрагируется от конкретных деталей записи.

Например, выражение a + b * c превращается в дерево:

    +
   / \
  a   *
     / \
    b   c

Для примера в JavaScript:

function add(a, b) {
    return a + b;
}

add(2, 3);

Представление AST для функции add будет выглядеть так:

--- AST ---
FUNC at 12
. KIND 0
. LITERAL ID 1
. SUSPEND COUNT 0
. NAME "add"
. INFERRED NAME ""
. PARAMS
. . VAR (0x11c00e33470) (mode = VAR, assigned = false) "a"
. . VAR (0x11c00e334f0) (mode = VAR, assigned = false) "b"
. DECLS
. . VARIABLE (0x11c00e33470) (mode = VAR, assigned = false) "a"
. . VARIABLE (0x11c00e334f0) (mode = VAR, assigned = false) "b"
. RETURN at 25
. . kAdd at 34
. . . VAR PROXY parameter[0] (0x11c00e33470) (mode = VAR, assigned = false) "a"
. . . VAR PROXY parameter[1] (0x11c00e334f0) (mode = VAR, assigned = false) "b"

А для корня скрипта:

--- AST ---
FUNC at 0
. KIND 0
. LITERAL ID 0
. SUSPEND COUNT 0
. NAME ""
. INFERRED NAME ""
. DECLS
. . FUNCTION "add" = function add
. EXPRESSION STATEMENT at 42
. . kAssign at -1
. . . VAR PROXY local[0] (0x11c00e334e8) (mode = TEMPORARY, assigned = true) ".result"
. . . CALL
. . . . VAR PROXY unallocated (0x11c00e333e0) (mode = VAR, assigned = true) "add"
. . . . LITERAL 2
. . . . LITERAL 3
. RETURN at -1
. . VAR PROXY local[0] (0x11c00e334e8) (mode = TEMPORARY, assigned = true) ".result"

Отложенный парсинг функций

Чтобы ускорить загрузку скриптов и сэкономить ресурсы, V8 применяет стратегию отложенного парсинга (lazy parsing). Вместо того чтобы сразу разбирать весь код и строить для него AST, движок может временно ограничиться предварительной проверкой функции с помощью preparser'а.

Preparser — это облегчённый синтаксический анализ, который:

  • Проверяет валидность кода (нет ли синтаксических ошибок).
  • Отслеживает объявления переменных и ссылки на них — чтобы корректно распределить переменные по стеку или контексту.
  • Пропускает внутренние функции и не строит для них AST до тех пор, пока они реально не понадобятся.

Это особенно полезно при работе с большими скриптами, в которых много функций может не использоваться при старте. Например:

function outerFunction() {
    // Эта функция будет preparsed
    function innerFunction() {
        console.log("Hello");
    }

    // А эта часть распарсится полностью
    console.log("Outer");
}

В отличие от полноценного парсера, preparser не сохраняет дерево синтаксического разбора. Вместо этого он собирает минимальный набор данных — например, информацию о замыканиях (closure scope) и необходимости использования контекста (heap allocation).

Исключение для preparser'а составляют функции, которые, скорее всего, будут выполнены сразу, например:

(function() { /* ... */ })();

Такие конструкции в V8 называются PIFEs (Possibly-Invoked Function Expressions). Их preparser не откладывает и сразу отправляет в полноценный парсинг. Для этого он использует эвристику, основанную на анализе синтаксических конструкций, включая наличие скобок вокруг объявления функции.

Preparser позволяет V8 балансировать между скоростью старта и полнотой разбора кода, экономя ресурсы без потерь в совместимости.

Анализ скоупов и переменных

Помимо парсинга, при разборе кода внутри V8 также выполняется анализ скоупов — определение областей видимости переменных и их связей.

Каждая переменная либо размещается в стеке, либо в контексте, если к ней могут обращаться вложенные функции.

Рассмотрим пример:

function outer() {
    const b = 1;
    function inner(a) {
      console.log(a + b);
    }
    return inner;
}

Если вывести для него информацию о скоупах, то получим следующие данные:

Inner function scope:
function outer () { // (0x12400e33220) (14, 111)
  // NormalFunction
  // 2 heap slots
  // local vars:
  CONST b;  // (0x12400e30a48) forced context allocation, never assigned
  VAR inner;  // (0x12400e30d58) never assigned

  function () { // (0x12400e30a78) (54, 91)
    // NormalFunction
    // 2 heap slots
    // local vars:
    VAR a;  // (0x12400e30cb0) never assigned
  }
}
Global scope:
global { // (0x12400e33030) (0, 112)
  // will be compiled
  // NormalFunction
  // local vars:
  VAR outer;  // (0x12400e334d8) 

  function outer () { // (0x12400e33220) (14, 111)
    // lazily parsed
    // NormalFunction
    // 2 heap slots
  }
}

Обратим внимание на строчку CONST b; // (0x12400e30a48) forced context allocation, never assigned — это значит, что b доступна в контексте inner.

Если мы уберём использование b внутри inner:

function outer() {
    const b = 1;
    function inner(a) {
      console.log(a);
    }
    return inner;
}

то данные изменятся на CONST b; // (0x10c00e30a48) never assigned.

А если совсем разделим функции:

function outer() {
    const b = 1;
}

function inner(a) {
    console.log(a);
}

то и скоупы тоже будут отдельные:

Inner function scope:
function outer () { // (0x13c00e33220) (14, 37)
  // NormalFunction
  // 2 heap slots
  // local vars:
  CONST b;  // (0x13c00e30a48) never assigned
}
Inner function scope:
function inner () { // (0x13c00e33410) (53, 80)
  // NormalFunction
  // 2 heap slots
  // local vars:
  VAR a;  // (0x13c00e30a30) never assigned
}
Global scope:
global { // (0x13c00e33030) (0, 81)
  // will be compiled
  // NormalFunction
  // local vars:
  VAR inner;  // (0x13c00e335d0) 
  VAR outer;  // (0x13c00e333e0) 

  function inner () { // (0x13c00e33410) (53, 80)
    // lazily parsed
    // NormalFunction
    // 2 heap slots
  }

  function outer () { // (0x13c00e33220) (14, 37)
    // lazily parsed
    // NormalFunction
    // 2 heap slots
  }
}

От AST к байткоду

После завершения анализа AST передаётся генератору байткода. Этот компонент обходит дерево и для каждого узла генерирует соответствующие инструкции байткода.

Для нашего первого примера:

function add(a, b) {
    return a + b;
}

add(2, 3);

байткод для функции add будет выглядеть так:

0x153a001000c8 @    0 : 0b 04             Ldar a1
0x153a001000ca @    2 : 40 03 00          Add a0, [0]
0x153a001000cd @    5 : b7                Return

А для корня скрипта:

0x153a00100084 @    0 : 13 00             LdaConstant [0]
0x153a00100086 @    2 : d1                Star1
0x153a00100087 @    3 : 1b fe f7          Mov <closure>, r2
0x153a0010008a @    6 : 6e 70 01 f8 02    CallRuntime [DeclareGlobals], r1-r2
0x153a0010008f @   11 : 23 01 00          LdaGlobal [1], [0]
0x153a00100092 @   14 : d1                Star1
0x153a00100093 @   15 : 0d 02             LdaSmi [2]
0x153a00100095 @   17 : d0                Star2
0x153a00100096 @   18 : 0d 03             LdaSmi [3]
0x153a00100098 @   20 : cf                Star3
0x153a00100099 @   21 : 6c f8 f7 f6 02    CallUndefinedReceiver2 r1, r2, r3, [2]
0x153a0010009e @   26 : d2                Star0
0x153a0010009f @   27 : b7                Return

На этапах парсинга и генерации байткода внутри V8 уже применяются простые оптимизации, такие как замена паттернов инструкций на более эффективные или удаление недостижимого кода.

// Будет удалена
function test() {
 const y = 7;
}

const x = 2 + 3; // Превращается в const x = 5;

Влияние на производительность

Скорость разбора кода напрямую влияет на время запуска приложения. Чем быстрее движок разберёт скрипт, тем быстрее он начнёт выполняться. Есть несколько подходов, которые помогают ускорить этот процесс:

  • Минификация. Как показали исследования, сокращённые имена переменных и отсутствие лишних пробелов действительно ускоряют токенизацию.
  • Code splitting. Разделение кода на модули позволяет парсить только ту часть, которая нужна в текущий момент. Остальное можно отложить до первого использования, что экономит ресурсы.
  • Простая структура функций. Чем меньше глубоко вложенных функций и IIFE, тем легче preparser-у определить, что можно пока не разбирать. Это снижает нагрузку на ранних этапах и ускоряет старт.

Часть 4. Управление памятью и сборка мусора

Введение

Многие современные языковые движки, такие как V8, динамически управляют памятью для запуска приложений, избавляя разработчиков от необходимости самостоятельно заботиться об этом. Движок периодически просматривает выделенную приложению память, определяет, какие данные больше не нужны, и очищает её, освобождая место. Этот процесс называется сборкой мусора (Garbage Collection, GC). Для оптимизации V8 использует сложную систему управления памятью с несколькими видами сборщиков мусора.

Основы управления памятью в V8

JavaScript-движок работает с двумя основными областями памяти: стеком и кучей.

Стек — это быстрая и компактная структура, где хранятся примитивы и ссылки на объекты. Он работает по принципу LIFO (Last-In, First-Out), и данные в нём живут очень недолго, например, пока выполняется функция. Все «тяжёлые» сущности вроде объектов, массивов или замыканий хранятся в куче (heap), и именно с ней работают сборщики мусора.

Куча в V8 разделена на поколения. Есть молодое поколение (Young Generation), где появляются все новые объекты, и старое поколение (Old Generation), куда со временем перемещаются выжившие. Такое деление основано на статистике: большая часть объектов «умирает молодыми», то есть живут недолго и быстро становятся мусором. Если бы GC проверял всю кучу каждый раз, это было бы очень дорого. Но если сосредоточиться на молодом поколении, можно собрать большое количество мусора быстро и почти незаметно для пользователя.

Generational Garbage Collection

Поколенческая модель в V8 устроена так: все новые объекты создаются в Nursery — самой маленькой области кучи. Если они переживают первую сборку мусора, они перемещаются в Intermediate Generation, а если вторую — то дальше в Old Generation. Для движка это выгодно: короткоживущие объекты исчезают сразу, а GC не тратит время на проверку долгоживущих снова и снова.

Представим, что вы вызываете функцию тысячу раз и каждый раз создаёте временный объект. Почти все они умрут сразу после выхода из функции, и сборщик быстро подчистит Nursery. А вот глобальный кэш приложения доживёт до Old Generation. Там уже действуют более сложные алгоритмы, потому что память становится большой и фрагментированной.

Young Generation и Minor GC

Раньше молодое поколение убиралось с помощью простого алгоритма Cheney. Его суть заключается в следующем. Память делится на две равные части: from-space и to-space. Когда запускается сборка, GC берёт все живые объекты из from-space, копирует их в to-space и переставляет указатели. Всё, что осталось во from-space, считается мусором и освобождается целиком. Затем роли областей меняются.

Такой подход позволяет за один проход избавиться от фрагментации: новые объекты в to-space оказываются в плотном куске памяти, и выделение новых будет работать быстро. Цена — это необходимость скопировать данные, но поскольку речь идёт только о молодом поколении, которое обычно занимает десятки мегабайт, пауза очень короткая.

Однако, хоть этот алгоритм и был эффективен, он не мог использовать преимущества многоядерных процессоров. Поэтому со временем V8 перешёл на Parallel Scavenger. Это параллельный копирующий сборщик, который может использовать несколько потоков для обработки молодого поколения.

Parallel Scavenger работает следующим образом:

  • Память молодого поколения по-прежнему делится на две области (from-space и to-space).
  • Живые объекты копируются из одной области в другую.
  • Ключевое отличие: работа распределяется между несколькими потоками динамически.
  • Используется work-stealing алгоритм — если один поток завершил свою работу, он может помочь другим.

Такой подход позволяет уменьшить время сборки молодого поколения, при этом сохраняются все преимущества копирующего алгоритма: отсутствие фрагментации и компактное размещение выживших объектов.

Old Generation и Major GC

Когда объект добрался до Old Generation, всё становится сложнее. Здесь память гораздо больше, и простое копирование уже не подходит. В ход идут алгоритмы Mark-Sweep и Mark-Compact.

Сначала движок делает фазу mark: обходит граф объектов и отмечает все живые. Потом идёт sweep: освобождаются неотмеченные участки памяти. Но такой метод оставляет дыры, и со временем куча фрагментируется. Чтобы это исправить, запускается compacting — объекты сдвигаются, память «сжимается», и освобождаются большие непрерывные куски.

Если бы это всё делалось разом, мы получали бы длинные stop-the-world паузы, из-за которых интерфейс подтормаживал бы и «лагал». Чтобы этого избежать, в V8 используются параллелизм и инкрементальность:

  • Concurrent Marking — маркировка выполняется в фоновых потоках параллельно с выполнением JavaScript-кода.
  • Parallel Sweeping — освобождение памяти происходит в нескольких потоках одновременно.
  • Incremental Mark-Compact — сжатие памяти разбито на маленькие этапы.

Благодаря этому GC перестаёт быть на 100% блокирующим и уносит большую часть работы в фоновый режим.

Orinoco

Orinoco — это общее название для нового поколения GC в V8. Туда вошёл Parallel Scavenger (многопоточная сборка Young Generation), а также все улучшения сборки для Old Generation. То есть теперь сборщик может одновременно и собирать мусор, и выполнять сжатие памяти, пока JavaScript продолжает работать.

Чтобы сделать это возможным, нужны вспомогательные инструменты.

Одним из ключевых стали write barriers. Этот механизм срабатывает при изменении ссылок на объекты в куче. Он используется, чтобы сборщик мусора был осведомлен обо всех изменениях в графе объектов во время инкрементной или параллельной сборки мусора. Основная задача — гарантировать, что обработанные объекты не указывают на ещё необработанные, и избежать пропуска живых объектов в процессе маркировки. При записи нового указателя из write barrier происходит проверка и, при необходимости, новое поле помечается и добавляется в рабочий список маркировки, чтобы сборщик позже мог его обработать. Это предотвращает ошибочное удаление объектов, на которые только что появились ссылки во время работы сборщика мусора.

В свою очередь, remembered sets — это структуры данных, которые отслеживают ссылки между разными поколениями сборки мусора, например, ссылки из старшего поколения (Old Generation) на молодое (Nursery или Intermediate). Это позволяет сборщику мусора эффективно находить объекты, на которые указывают из других поколений, без необходимости полного сканирования всей кучи.

Кроме того, V8 планирует сборку мусора не только по факту переполнения памяти, но и проактивно — используя idle-time планировщик браузера. Когда движок видит свободное окно (например, несколько миллисекунд до следующего кадра), он запускает подходящий этап GC: быстрый minor GC для молодого поколения, инкрементальную фазу маркировки или фоновый sweep для старого поколения. Если дедлайн небольшой, выполняется только часть работы; если окно длинное — можно позволить себе compacting. Ключевая идея — разбить тяжёлые операции на куски и параллелить их, чтобы не блокировать основной поток.

По итогу, все эти механизмы направлены на то, чтобы сборка была как можно менее заметна. И хотя паузы полностью исключить невозможно, они стали короче и реже.

Другие приёмы управления памятью в V8

Когда мы говорим о памяти в V8, чаще всего имеем в виду работу сборщика мусора. Но на самом деле оптимизации есть и гораздо раньше — на уровне того, как движок хранит данные и управляет кучей.

  • Pointer compression и memory cage. Современные процессоры используют 64-битные адреса, и если хранить каждый указатель в 8 байт, то объём памяти на одни только ссылки сильно вырастает. Чтобы этого избежать, V8 применяет приём сжатия указателей: вместо полного адреса хранится 32-битное смещение от базовой точки (так называемой memory cage). В результате все объекты располагаются внутри одного 4-гигабайтного диапазона, и этого хватает для JS-приложений. Такой подход снижает расход памяти почти вдвое и улучшает работу кэша процессора. Одновременно это повышает безопасность: cage действует как «песочница», не позволяя случайным ссылкам выходить за пределы кучи.

  • Учет внешней памяти (external memory). Не все данные, с которыми работает JS-код, находятся в куче. Например, ArrayBuffer или Buffer в Node.js могут хранить гигабайты бинарных данных вне V8. Чтобы не потерять контроль, движок учитывает такую память и умеет триггерить GC, если общий объём выходит за пределы разумного. Таким образом, даже ресурсы вне кучи включаются в общий бюджет памяти.

  • Oilpan и интеграция с DOM. В экосистеме Chromium есть отдельный проект Oilpan, отвечающий за управление памятью для C++-объектов в движке Blink. Его идея в том, чтобы связать GC V8 и сборку C++-объектов в единый механизм. Так, если у вас есть JS-объект и соответствующий ему DOM-узел, они будут собираться синхронно, без утечек и висячих ссылок.

Часть 5. Скрытые оптимизации

Введение

После парсинга и генерации байткода V8 начинает исполнять JavaScript и «учиться» на нём. Всё, что мы пишем в коде, проходит через сложный цикл интерпретации, оптимизации и возможной деоптимизации. JavaScript — динамический язык, где объекты могут меняться на лету, типы — подменяться, а функции — вызываться с любыми аргументами. Однако V8 научился превращать это в предсказуемый и быстрый машинный код.

В этой части мы разберём, как движок оптимизирует работу с объектами, свойствами и функциями, как он строит внутренние представления данных и как работает оптимизирующий компилятор TurboFan. А также посмотрим на рекомендации, которые даёт команда V8, чтобы ваш код оптимизировался движком по максимуму.

Числа и указатели

В 2014 году Chrome перешёл с 32-битной на 64-битную архитектуру. Это повысило безопасность, стабильность и производительность Chrome, но привело к увеличению потребления памяти, поскольку каждый указатель теперь занимает восемь байт вместо четырёх. Поэтому в 2020 году команда V8 представила концепцию сжатия указателей (Pointer Compression). Цель была проста — вернуть эффективный размер указателя к 32 битам. Мы уже упоминали эту концепцию в предыдущей части, но сейчас рассмотрим некоторые нюансы.

Куча V8 содержит множество элементов: значения с плавающей запятой, строковые символы, байткод интерпретатора. Но около 70% кучи V8 обычно занимают тэгированные значения (tagged values) — особый способ кодирования, при котором несколько битов слова зарезервированы для хранения информации о типе.

В V8 младший бит определяет, является ли значение указателем на объект в куче или простым целым числом. Благодаря этому целое число можно хранить непосредственно в теге, не выделяя для него дополнительную память и не прибегая к лишним проверкам. Простые целые числа, которые хранятся таким образом, называются Smi (Small Integers). Это позволяет экономить память и ускорять доступ к значениям, которые чаще всего встречаются в JavaScript, — целым числам небольшого диапазона.

В случае указателей доступно 31 бит полезной нагрузки под адрес, что даёт 4 ГБ адресного пространства. При этом значение хранится как смещение относительно базового адреса кучи.

В итоге сжатие указателей уменьшило размер кучи V8 до 43% и объём памяти, потребляемой процессом рендеринга Chrome, до 20% на десктопе.

Но есть нюанс: при использовании сжатия указателей объём кучи автоматически ограничивается. В частности, эта оптимизация в Node.js не будет работать при размере кучи больше 4 ГБ.

Массивы

Отдельного внимания заслуживают массивы. В V8 они представляют собой особый тип объектов, оптимизированный под хранение однотипных данных. Для ускорения работы движок использует систему Elements Kinds — внутренние категории, определяющие, как именно хранятся элементы массива в памяти и как к ним обращаться. Эти категории позволяют V8 избежать лишних проверок и выбрать наиболее эффективный способ доступа к данным.

Когда массив создаётся, движок анализирует его содержимое. Если все элементы — маленькие целые числа, массив получает тип PACKED_SMI_ELEMENTS, где значения хранятся компактно и напрямую.

const array = [1, 2, 3];

Если среди элементов появляются числа с плавающей запятой, V8 переводит массив в PACKED_DOUBLE_ELEMENTS, а если в него добавляются объекты — в PACKED_ELEMENTS.

const array = [1, 2, 3];
// elements kind: PACKED_SMI_ELEMENTS
array.push(4.56);
// elements kind: PACKED_DOUBLE_ELEMENTS
array.push('x');
// elements kind: PACKED_ELEMENTS

Появление хотя бы одной «дыры» (элемента с undefined или пропущенным индексом) заставляет движок перейти на версию с приставкой HOLEY, например HOLEY_SMI_ELEMENTS.

const array = [1, 2, 3, 4.56, 'x'];
// elements kind: PACKED_ELEMENTS
array.length = 5;
array[9] = 1;
// elements kind: HOLEY_ELEMENTS

Эти переходы, называемые elements-kind transitions, происходят автоматически и влияют на производительность. Когда движок вынужден работать с «дырявыми» массивами или с неоднородными типами элементов, он больше не может использовать быстрый доступ по смещению и вынужден выполнять дополнительные проверки при каждом чтении и записи. Кроме того, такие массивы лишаются некоторых оптимизаций JIT-компилятора, и их операции выполняются через более универсальные, но медленные пути.

Чтобы минимизировать количество переходов, V8 старается сохранять исходный тип элементов как можно дольше. Например, если массив изначально был числовым, но позже в него добавили строку, движок будет вынужден изменить способ хранения для всех элементов. Поэтому чем стабильнее структура данных и чем меньше смешанных типов внутри массива, тем быстрее он работает.

Объекты и Hidden Classes

Не менее важная структура в JavaScript — объект. В отличие от статически типизированных языков, где структура объектов фиксирована, JavaScript позволяет в любой момент добавить или удалить свойство. Если бы движок каждый раз создавал уникальную структуру для каждого объекта, это решение не могло бы быть производительным. Поэтому V8 использует Hidden Classes (или Maps) — внутренние шаблоны, описывающие расположение свойств в памяти. Когда создаётся объект, движок формирует для него скрытый класс, где хранится информация о последовательности свойств и их смещениях.

Каждый раз, когда в объект добавляется новое свойство, создаётся новая версия класса, а старая связывается с ней через transition. Например, если у нас есть объект {a: 1}, а затем мы добавляем b, движок создаёт новую карту для состояния {a, b} и хранит переход от старой к новой. Таким образом, объекты с одинаковым порядком свойств делят один и тот же Hidden Class, что позволяет V8 обращаться к свойствам напрямую по смещению — как в структурах на C++.

Если же два объекта создаются с одинаковыми свойствами, но в разном порядке, они получат разные карты, и движок не сможет оптимизировать доступ одинаково. Это одна из причин, почему рекомендуется инициализировать все свойства в конструкторе и избегать добавления новых свойств динамически. Чем больше объектов делят одну карту, тем предсказуемее их поведение для JIT-компилятора и тем быстрее работает код.

Hidden Classes тесно связаны с другим механизмом — Inline Caching, который позволяет V8 запоминать шаблоны доступа к свойствам и повторно использовать их.

Inline Caching (IC)

Inline Caching — способ, с помощью которого движок запоминает результаты предыдущих обращений к свойствам или функциям.

В JavaScript код obj.a не означает, что можно просто взять значение a из объекта. Во-первых, здесь может вызываться геттер. Если же значения или геттера нет в самом объекте, нужно пройтись по цепочке прототипов и т.д.

При первом доступе к свойству движок выполняет полное разрешение — проходит по всем вариантам и цепочкам и находит нужное значение. После этого он сохраняет результат вместе с информацией о типе объекта. При следующем обращении V8 проверяет, совпадает ли Hidden Class объекта с тем, что был закеширован ранее. Если совпадает, движок обращается к свойству напрямую, минуя всю цепочку поиска.

Существует несколько уровней IC. Monomorphic cache означает, что свойство всегда запрашивается у объектов одного типа. Если движок сталкивается с несколькими типами, кеш становится polymorphic. Когда число различных карт превышает определённый порог, кеш объявляется megamorphic, и дальнейшие оптимизации становятся невозможны. Такой сценарий особенно часто встречается при работе с динамическими структурами данных или кодом, где один и тот же метод вызывается на разных типах объектов.

Понимание работы IC помогает избегать ситуаций, где код становится слишком «мегаморфным». Если функции вызываются с разными типами объектов, движок тратит больше времени на проверки и в итоге может деоптимизировать код. Поэтому стоит писать предсказуемый код, где одна и та же функция обрабатывает данные схожей структуры.

TurboFan и промежуточное представление

TurboFan — сердце производительности V8, оптимизирующий компилятор, который долгое время использовал архитектуру Sea of Nodes. В этой модели программа представляется не в виде привычных строк кода или последовательных инструкций, а как граф зависимостей между узлами. Каждый узел — это операция (например, сложение, сравнение, чтение переменной), а рёбра графа отражают зависимости трёх типов: value edges, control edges и effect edges.

  • value edges описывают передачу данных: результат одной операции используется в другой. Например, если у нас есть x + y, то узлы x и y соединяются со сложением через value-рёбра.
  • control edges определяют порядок выполнения — что должно произойти раньше, а что позже (например, тело if зависит от результата проверки).
  • effect edges фиксируют побочные эффекты: чтение и запись переменных, обращение к памяти, вызовы функций, которые могут что-то изменить.

Благодаря разделению этих трёх типов зависимостей компилятор может свободно переставлять чистые (без побочных эффектов) операции и устранять ненужные вычисления, не нарушая корректность программы.

Рассмотрим простой пример:

let x = 1;
let y = 2;
let z = x + y;

В Sea of Nodes это не три строки, а сеть узлов: 1 и 2 — константные узлы, их значения идут по value-рёбрам к узлу Add, результат которого идёт по value-рёбрам к узлу Store[z]. Порядок не задаётся явно — его определяют control- и effect-рёбра. Рёбра control обеспечивают, чтобы Store[z] не выполнялся до вычисления Add, а effect — что запись в память произойдёт после всех вычислений, влияющих на неё. Такая структура даёт гибкость для оптимизаций: TurboFan может, например, удалить неиспользуемые узлы, объединить одинаковые выражения или переставить независимые операции.

Однако у этой модели есть слабые стороны. Когда в коде появляются ветвления, исключения или асинхронные операции, граф обрастает сложными цепочками зависимостей: каждое условие, каждый побочный эффект тянет за собой control- и effect-связи, из-за чего перестановки становятся ограниченными, а сам граф — плохо читаемым и хуже оптимизируемым. Добавить новую ветку выполнения или новое состояние памяти в такую структуру сложно: приходится перестраивать часть графа и пересчитывать зависимости.

Чтобы решить эти проблемы, команда V8 начала переход к архитектуре на основе CFG (Control Flow Graph), реализованной в проекте Turboshaft. CFG — классическое представление программы как набора базовых блоков (basic blocks) — линейных последовательностей инструкций без ветвлений, связанных между собой переходами (edges), описывающими возможный ход выполнения. В отличие от Sea of Nodes порядок выполнения здесь задан явно: каждый блок знает, кто идёт до него и после него. Это делает оптимизации, связанные с управлением потоком, проще и предсказуемее, а работу компилятора — более управляемой.

Если переписать предыдущий пример на уровне CFG, получится линейная структура:

Block0:
  x = 1
  y = 2
  z = x + y
  goto Block1
Block1:
  return

Никаких «плавающих» зависимостей — всё последовательно и прозрачно.

А если добавить условие, например:

if (x > 0) {
  y = y + 1;
} else {
  y = y - 1;
}

в Sea of Nodes это будет один граф, где узлы сравнения, прибавления и вычитания соединены control- и effect-рёбрами, указывающими, какие из них активны в зависимости от результата проверки. В CFG же структура делится на блоки:

Block0:
  t0 = x > 0
  if t0 goto Block1 else goto Block2

Block1:
  y = y + 1
  goto Block3

Block2:
  y = y - 1
  goto Block3

Block3:
  return

Такое представление делает ветвления и переходы очевидными и позволяет компилятору легко анализировать пути выполнения, объединять блоки, устранять мёртвый код и применять классические оптимизации на уровне потока управления.

В итоге команда V8 приняла решение уйти от Sea of Nodes: весь JavaScript-бэкенд уже перешёл на Turboshaft, WebAssembly тоже полностью на Turboshaft, а части Sea of Nodes постепенно вырезаются.

Рекомендации по написанию кода

V8 активно развивается и поддерживает новые стандарты JavaScript, а устройства пользователей вышли на новый уровень по процессорным мощностям и памяти, поэтому нам уже не нужно держать в голове таблицы с «убийцами производительности». Однако, исходя из всего, что мы обсуждали выше, можно собрать некоторые рекомендации, чтобы код работал ещё более оптимально и использовал максимум возможностей движка.

  • Держите массивы «упакованными» и без дыр. Массивы делятся на packed / holey. Любая «дыра» (пропущенный индекс) переводит массив в holey-вариант, что добавляет проверок при доступе. Старайтесь не создавать разрежённые массивы присваиванием далеко за текущий length, не использовать delete, инициализируйте массив целиком, если знаете размер.
// хуже: создаём дыру, массив станет HOLEY_*
const a = [1, 2, 3];
a[10] = 42;        // много "пустых" слотов между 3 и 10

// лучше: растим без дыр
const b = [];
b.push(1); b.push(2); b.push(3); // остаётся PACKED_*
  • Сохраняйте тип элементов стабильным. Как только в SMI-массив попадёт число с плавающей точкой, он перейдёт в DOUBLE-вариант; если прилетит объект/null/undefined, станет PACKED_ELEMENTS. Избегайте непреднамеренных апгрейдов типа.
// хуже: деградация SMI -> DOUBLE
const xs = [1, 2, 3];  // PACKED_SMI_ELEMENTS
xs.push(3.14);         // теперь PACKED_DOUBLE_ELEMENTS

// лучше: сразу определись с типом
const ys = [1, 2, 3, 4];        // все целые
const zs = [1.0, 2.0, 3.5];     // всё double, без переключений
  • Если знаете размер — заполняйте, а не оставляйте «дыры». new Array(n) создаёт массив с «пустыми» слотами (holey) до тех пор, пока их явно не заполнить. Заполните сразу, чтобы остаться в packed-режиме.
// хуже: HOLEY пока элементы не присвоены
const tmp = new Array(3);   // [ <3 empty items> ]

// лучше: сразу PACKED_SMI
const ok = new Array(3).fill(0); // [0, 0, 0]
  • Старайтесь сохранять мономорфность. Inline Cache запоминает «форму» объектов (их hidden class) в месте доступа (call-site). Если в одном и том же месте кода часто встречаются объекты разных форм, IC становится polymorphic, а при больших различиях — megamorphic, и оптимизации обесцениваются. Конструируйте объекты в одном порядке и не добавляйте поля «на лету».
// хуже: один и тот же call-site видит разные формы
function getX(o) { return o.x; }    // хотим monomorphic IC

const a1 = {}; a1.x = 1;            // форма: {x}
const b1 = {}; b1.y = 2; b1.x = 3;  // форма: {y,x} (другой порядок)

getX(a1); // IC #1
getX(b1); // IC видит другую карту -> polymorphic

// лучше: фиксируйте форму через конструктор/класс
function A(x) { this.x = x; this.y = 0; } // порядок един
const a2 = new A(1);
const b2 = new A(3);
getX(a2); getX(b2); // monomorphic IC
  • Нормализуйте входы «горячих» функций. Если в один и тот же горячий участок кода приходят объекты разной формы, вставьте лёгкую нормализацию «до» или разделите путь на несколько функций (по формам), чтобы каждая точка доступа оставалась мономорфной. Это лучше, чем одна «универсальная» точка, скатывающаяся в мегаморфизм.
// разделение путей помогает удержать monomorphic IC в каждом
function readX_A(o /* форма A */) { return o.x; }
function readX_B(o /* форма B */) { return o.x; }

function readX(o) {
  return (o.hasOwnProperty('y') ? readX_A(o) : readX_B(o));
}
  • Помните, что элементы и свойства — разные хранилища. Числовые ключи идут в «elements store», именованные — в «properties store»; смешивание паттернов доступа может приводить к менее предсказуемым переходам карт. Это ещё один довод не превращать массивы в словари и не использовать «дырявые» индексы.

Эти практики позволяют не мешать оптимизатору. Тогда V8 сможет чаще генерировать прямой доступ к нужным данным в объектах и массивах и реже сваливаться в деоптимизацию.

Часть 6. От среды к среде

Введение

В предыдущих частях мы детально разобрали внутреннее устройство V8: от истории его создания и архитектуры компиляторов до механизмов управления памятью и скрытых оптимизаций. Но V8 — это не просто движок, работающий в изоляции. Это фундамент для целой экосистемы технологий, каждая из которых предъявляет свои требования и накладывает свои ограничения.

В этой заключительной части мы рассмотрим, как V8 интегрируется в различные окружения: браузер Chrome, серверную платформу Node.js и экосистему WebAssembly. Мы поговорим о специфике каждой среды, о том, какие задачи решает движок в каждом контексте, и заглянем в будущее — какие изменения ждут V8 и какие направления развития предлагает команда.

Bindings

Браузер — это родная среда для V8. Именно здесь движок появился в 2008 году вместе с Chrome и продолжает развиваться в тесной связке с остальными компонентами браузера.

В браузерном окружении V8 работает не сам по себе, а как часть большой системы. Рядом с ним функционирует движок рендеринга Blink, отвечающий за построение DOM-дерева, вычисление стилей, layout и paint. Между JavaScript и DOM существует мост — bindings, который позволяет коду обращаться к элементам страницы, слушать события, манипулировать стилями и атрибутами.

Bindings обеспечивают коммуникацию между C++-миром движка Blink и объектами JavaScript. Когда скрипт обращается, например, к document.body, V8 на самом деле работает не с самим DOM-объектом, а с его обёрткой — JS-wrapper’ом, созданным для конкретного окружения. Один и тот же DOM-элемент может иметь несколько таких обёрток, если к нему обращаются из разных контекстов — например, из основного документа и из фрейма (<iframe>). Каждая обёртка хранится отдельно, чтобы гарантировать безопасность и изоляцию данных между скриптами.

Основная единица исполнения JavaScript в браузере — это isolate. Он представляет собой отдельный экземпляр движка V8, со своей памятью и сборщиком мусора. На главном потоке у страницы есть один isolate, а каждый Web Worker или Service Worker получает собственный.

Внутри isolate могут существовать несколько контекстов (contexts) — это по сути разные глобальные окружения JavaScript с собственными объектами window, document и прототипами. Например, у страницы и каждого <iframe> — свой контекст, изолированный от остальных.

Поверх этого слоя существует понятие миров (worlds). Основной мир — это код самой страницы. Изолированные миры — это окружения для расширений браузера (extentions). Рабочий мир — это мир Web Worker-а или Service Worker-а.

Подводя итог, можно сказать, что изолят основного потока состоит из одного основного мира и N изолированных миров. Изолят воркера состоит из одного рабочего мира и 0 изолированных миров. Все миры в одном изоляте используют общие базовые объекты C++ DOM, но каждый мир имеет свои собственные JS-обёртки. Каждый мир имеет свой собственный контекст и, следовательно, свою собственную область действия глобальных переменных и цепочки прототипов.

Такая многоуровневая архитектура — isolateworldcontext — позволяет браузеру одновременно обеспечивать и производительность, и безопасность. Например, позволяет расширениям Chrome безопасно взаимодействовать со страницей, не нарушая работу её скриптов.

Браузер

Одна из ключевых особенностей браузерного окружения — необходимость обеспечивать плавность интерфейса. Если JavaScript выполняется слишком долго, это блокирует основной поток и приводит к «заморозкам» — пользователь не может взаимодействовать со страницей, анимации останавливаются, интерфейс теряет отзывчивость. Поэтому V8 в браузере активно использует стратегии, направленные на минимизацию пауз: инкрементальную и параллельную сборку мусора, idle-time планирование GC, а также разделение работы компиляторов на фоновые потоки. Про все эти оптимизации мы уже говорили в предыдущих частях.

Важную роль играет интеграция с Event Loop браузера. JavaScript выполняется в едином потоке, где чередуются выполнение скриптов, обработка событий, отрисовка кадров и работа GC. V8 должен уметь вписываться в этот цикл так, чтобы не мешать отрисовке. Например, если до следующего кадра осталось несколько миллисекунд, движок может запустить быструю Minor GC или часть инкрементальной маркировки. Если времени больше — выполнить более тяжёлые операции вроде compacting.

V8 также тесно интегрирован с системой безопасности браузера. Песочница (sandbox) Chrome изолирует процессы рендеринга друг от друга и от системы. Pointer compression и memory cage, о которых мы говорили ранее, усиливают защиту, ограничивая область памяти, к которой может обратиться JavaScript. Это снижает риски уязвимостей и делает атаки сложнее.

Node.js

Node.js вывел V8 за пределы браузера и превратил JavaScript в язык для серверной разработки. Здесь движок работает в совершенно иной среде: нет DOM, нет отрисовки, но есть доступ к файловой системе, сети, процессам операционной системы.

В основе Node.js лежит библиотека libuv, которая обеспечивает асинхронный ввод-вывод и Event Loop. V8 выполняет JavaScript-код, а libuv управляет операциями, которые могут занять время: чтение файлов, сетевые запросы, таймеры. Когда операция завершается, libuv возвращает управление в V8, где выполняется соответствующий callback.

В отличие от браузера, где главный приоритет — отзывчивость интерфейса, в Node.js на первый план выходит пропускная способность. Серверные приложения часто обрабатывают тысячи запросов одновременно, и любая задержка в Event Loop может привести к росту времени ответа. Поэтому настройки GC в Node.js отличаются от браузерных: паузы стараются делать короче, а работу сборщика мусора — более предсказуемой.

Node.js активно использует флаги для тюнинга V8. Например, можно увеличить размер кучи через --max-old-space-size, настроить частоту GC или включить экспериментальные оптимизации. Это даёт разработчикам гибкость, но требует понимания того, как работает движок.

Важная особенность Node.js — работа с нативными модулями. С помощью node-gyp можно подключать библиотеки на C/C++ и вызывать их из JavaScript. Это позволяет использовать существующий код, выполнять критичные к производительности операции вне V8 и интегрироваться с системными API. Но такая интеграция требует осторожности: неправильное управление памятью в нативном коде может привести к утечкам, а блокирующие операции — заморозить Event Loop.

Ещё один аспект — поддержка Worker Threads. Это аналог Web Workers, но для серверной среды. Каждый воркер получает свой изолят V8 и может выполнять код параллельно. Это полезно для CPU-bound задач, которые иначе блокировали бы основной поток. Однако создание изолятов требует ресурсов, и чрезмерное их количество может привести к деградации производительности.

Node.js также использует возможности, которые V8 предоставляет через свой API: профилирование, heap snapshots, coverage. Они позволяют подключиться к работающему процессу и анализировать его в реальном времени — смотреть стек вызовов, память, производительность.

WebAssembly

WebAssembly (Wasm) — это ещё одно направление, где V8 играет ключевую роль. Wasm — это низкоуровневый байткод, который можно выполнять в браузере и в Node.js. Он был создан для задач, требующих высокой производительности: игры, обработка видео, симуляции, портирование существующих приложений на C/C++.

V8 компилирует WebAssembly не через тот же pipeline, что JavaScript, а через специализированный компилятор. Сначала Wasm-модуль проходит через Liftoff — быстрый baseline-компилятор, который генерирует машинный код почти мгновенно. Это позволяет начать выполнение модуля без задержек. Затем, для «горячего» кода, подключается TurboFan, который применяет глубокие оптимизации и создаёт высокопроизводительный машинный код.

Важное отличие Wasm от JavaScript — статическая типизация и предсказуемая структура. Это даёт компилятору больше возможностей для оптимизаций, например, нет необходимости делать предположения о типах. Благодаря этому WebAssembly часто работает быстрее аналогичного JavaScript-кода.

Одна из новых возможностей — WasmGC. Раньше WebAssembly работал только с линейной памятью и вручную управлял выделением. Это было неудобно для языков с автоматической сборкой мусора — например, Kotlin, Dart или Java. Разработчики таких языков были вынуждены компилировать вместе с кодом целый собственный рантайм, включая реализацию GC и модели памяти, что увеличивало размер модулей и снижало производительность. Взаимодействие с JavaScript тоже было громоздким: объекты из мира Kotlin или Java представляли собой просто байты в линейной памяти, и доступ к ним требовал дополнительных прослоек и сериализации данных.

WasmGC решает эти проблемы. Он добавляет в спецификацию WebAssembly поддержку управляемых объектов — структур (structs), массивов (arrays) и ссылочных типов (ref), которые могут храниться в общей куче V8 и собираться тем же сборщиком мусора, что и JavaScript-объекты. Это позволяет языкам с GC отказаться от собственного менеджера памяти и напрямую использовать встроенный в движок механизм. В результате модули становятся компактнее, быстрее и проще интегрируются с JS: теперь можно обмениваться объектами между мирами без дорогостоящего копирования.

Такие изменения происходят на уровне компилятора. Бэкенд, который раньше преобразовывал классы языка в набор байтов в линейной памяти, теперь может компилировать их в нативные GC-структуры WebAssembly. Всё это открывает путь к по-настоящему нативной интеграции высокоуровневых языков с экосистемой WebAssembly и делает границу между JavaScript и другими языками гораздо тоньше.

Будущее V8

Развитие V8 не останавливается. Команда движка постоянно работает над улучшением производительности, снижением потребления памяти и поддержкой новых стандартов. Рассмотрим некоторые из направлений:

  • Переход на Turboshaft. Как мы обсуждали в предыдущей части, V8 постепенно отказывается от архитектуры Sea of Nodes в пользу классического представления на основе CFG — проекта Turboshaft. Этот переход уже завершён для WebAssembly, и постепенно весь старый код будет удалён. Turboshaft упрощает добавление новых оптимизаций, делает компилятор более предсказуемым и ускоряет время компиляции.

  • Maglev и дальнейшая работа над компиляторами. Maglev, появившийся в 2023 году, занял промежуточное место между Sparkplug и TurboFan. Но команда продолжает экспериментировать: возможно, появятся новые уровни компиляции или изменится логика переключения между ними. Цель — достичь оптимального баланса между скоростью запуска, временем компиляции и качеством оптимизаций.

Заключение

V8 — это не просто движок для выполнения JavaScript. Это сложная система, которая эволюционирует вместе с языком, платформами и требованиями пользователей. От первого релиза в 2008 году до современных многоуровневых компиляторов и продвинутых сборщиков мусора — движок прошёл огромный путь и продолжает развиваться.

Понимание того, как работает V8, помогает писать более эффективный код, находить узкие места и использовать возможности платформы по максимуму. Надеюсь, этот цикл статей (теперь оформленный в один большой труд!) дал вам базу для дальнейшего погружения в мир JavaScript-движков и вдохновил исследовать эту область глубже.