November 16, 2023

Глубокий JS. В память о типах и данных

Уровень: Senior, Senior+

Всех нас учили, что в JavaScript есть примитивные и ссылочные типы данных. Исчерпывающая информация есть в документации MDN, а на просторах интернета полно статей на этот счет.

Теория теорией, однако, JS-код исполняется не в теории, а на практике. Точнее, его компилирует и исполняет движок JS. Таких движков существует несколько, разрабатывались они разными людьми и для разных целей. Было бы наивно предполагать, что все они полностью идентичны друг другу. А значит, время разобраться, как же на самом деле хранятся вполне конкретные данные на вполне конкретном движке JS. В качестве испытуемого возьмем один из самых распространенных, на сегодняшний день, движок V8 от компании Google.

Но, прежде чем мы приступим к разбору, давайте вспомним основные теоретические моменты.

Примитивные типы данных, это иммутабельные (не изменяемые) значения, хранимые в памяти и представленные в структурах языка на низком уровне. Собственно, в JavaScript к примитивным типам относится все, кроме Object, а именно:

Ссылочные типы данных, они же - объекты, это области памяти неопределенного размера, и доступные по идентификатору (ссылке на эту область памяти). В JavaScript, есть только один такой тип - Object. Помимо Object, есть еще отдельная структура Function, которая, по факту, тоже является Object. Object - единственный мутабельный (изменяемый) тип данных в JavaScript. Это значит, что в переменной хранится не само значение объекта, а только ссылка-идентификатор. Производя какие-либо манипуляции с объектом, меняется значение непосредственно в области памяти, но ссылка на эту область остается прежней, пока мы её не переопределим явным или неявным образом. Объект остается в памяти, пока есть активная ссылка на него. Если ссылка удалена или больше не используется в сценарии, такой объект будет вскоре уничтожен сборщиком мусора, но об этом в следующий раз.

Итак, теорию вспомнили, давайте теперь посмотрим, все ли так однозначно на практике? Эксперименты будем проводить на последней, на момент исследования, версию движка V8 12.1.138 от 15 ноября 2023.

Начнем разбор с самого понятного, вроде бы, типа. Для цифровых систем нет ничего более естественного, чем числа.

Number

Согласно документация, тип Number в JavaScript является 64-битным числом двойной точности в соответствии со стандартом IEEE 754

const number = 1;

// ожидаемое значение в памяти
// 
// 0000 0000 0000 0000 0000 0000 0000 0000
// 0000 0000 0000 0000 0000 0000 0000 0001

Посмотрим на это число в V8 в режиме дебага. Для этого воспользуемся системным хелпером движка %DebugPrint

d8> const number = 1; %DebugPrint(number);
DebugPrint: Smi: 0x1 (1)

1

Выяглядит вполне ожидаемо. Мы видим простое значение 0x1 с неким типом Smi. Но разве тут не должен быть тип Number, как говорится в спецификации ECMAScript? К сожалению, найти ответы на подобные вопросы в официальной документации движка не представляется возможным, поэтому обратимся непосредственно к исходным кодам.

Smi

/src/objects/smi.h

// Smi represents integer Numbers that can be stored in 31 bits.
// Smis are immediate which means they are NOT allocated in the heap.
// The ptr_ value has the following format: [31 bit signed int] 0
// For long smis it has the following format:
//     [32 bit signed int] [31 bits zero padding] 0
// Smi stands for small integer.
class Smi : public AllStatic {

Таким образом, Smi (Small Integer) - это целое 31-битное число. Максимальное значение такого числа +(2**30 - 1), минимальное - -(2**30 - 1)

d8> %DebugPrint(2**30 - 1)
DebugPrint: Smi: 0x3fffffff (1073741823)

1073741823

d8> %DebugPrint(-(2**30 - 1))
DebugPrint: Smi: 0xc0000001 (-1073741823)

-1073741823

Хорошо, но в спецификации говорится, что тип Number позволяет хранить 64-битные числа, однако, Smi способен работать только с 31-битными. Как быть с остальными? Что ж, давайте посмотрим.

HeapNumber

/src/objects/heap-number.h

Возьмем число на 1 больше максимального Smi

d8> %DebugPrint(2**30)
DebugPrint: 0x36ac0011c291: [HeapNumber] in OldSpace
- map: 0x36ac00000789 <Map[12](HEAP_NUMBER_TYPE)>
- value: 1073741824.0
0x36ac00000789: [Map] in ReadOnlySpace
- map: 0x36ac000004c5 <MetaMap (0x36ac0000007d <null>)>
- type: HEAP_NUMBER_TYPE
- instance size: 12
- elements kind: HOLEY_ELEMENTS
- enum length: invalid
- stable_map
- back pointer: 0x36ac00000061 <undefined>
- prototype_validity cell: 0
- instance descriptors (own) #0: 0x36ac000006d9 <DescriptorArray[0]>
- prototype: 0x36ac0000007d <null>
- constructor: 0x36ac0000007d <null>
- dependent code: 0x36ac000006b5 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0

1073741824

Оказывается, 64-битное число в представлении V8 - это объект специально типа HeapNumber. Дело в том, что такие числа (они же - числа с двойной точностью), согласно стандарта IEEE, состоят из нескольких частей, знака (1 бит), экспоненты (11 бит) и мантисы (52 бита). На деле же, подобная структура хранится в памяти двумя 32-х разрядными словами, где первое слово - часть мантисы, второе - микс знака, экспоненты и оставшейся части мантисы. В целях оптимизации производительности V8 самостоятельно реализует математику таких чисел, что и приводит его к описанию соответствующего класса.

Аналогичная картина, очевидно, будет наблюдаться и с числами с плавающей точкой.

d8> %DebugPrint(0.1)
DebugPrint: 0x36ac0011c605: [HeapNumber] in OldSpace
- map: 0x36ac00000789 <Map[12](HEAP_NUMBER_TYPE)>
- value: 0.1
0x36ac00000789: [Map] in ReadOnlySpace
- map: 0x36ac000004c5 <MetaMap (0x36ac0000007d <null>)>
- type: HEAP_NUMBER_TYPE
- instance size: 12
- elements kind: HOLEY_ELEMENTS
- enum length: invalid
- stable_map
- back pointer: 0x36ac00000061 <undefined>
- prototype_validity cell: 0
- instance descriptors (own) #0: 0x36ac000006d9 <DescriptorArray[0]>
- prototype: 0x36ac0000007d <null>
- constructor: 0x36ac0000007d <null>
- dependent code: 0x36ac000006b5 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0

0.1

Наглядно, разницу между Smi и HeapNumber можно увидеть, сняв Heap Snapshot в исполняемой среде. Для этого, создадим небольшой скрипт, который хранит в памяти два числа.

/* Замыкать значения будем в контексте функции */
function V8Snapshot() {
  this.number1 = 1;     // Smi
  this.number2 = 2**30; // HeapNumber
}

// Далее, создадим два экземпляра одного и того же класса,
// таким образом, будем иметь 4 ссылки на 2 значения
const v8Snapshot1 = new V8Snapshot();
const v8Snapshot2 = new V8Snapshot();

Воспользуемся стандартным браузерным инструментарием Chrome Dev Tools -> Memory и снимем слепок Heap Snapshot.

В слепке мы видим два экземпляра класса V8Snapshot, оба хранят указатели на числа number1 и number2.

Примечательно здесь то, что в обоих экземплярах number1 указывает на одну и ту же область память с адресом @233347, тогда как number2 в обоих случаях имеет разные адреса, соответственно, в памяти, на данный момент, хранятся два одинаковых значения number2. В этом и есть принципиальное отличие Smi от HeapNumber. Маленькие числа, фактически, являются константными, и, будучи присвоенными первый раз, в дальнейшем не дублируются, а все указатели на них ссылаются на одно и то же значение. HeapNumber же, структура динамическая, чтобы найти ранее сохраненное значение, его все равно придется предварительно вычислить, что сводит на нет всю пользу от переиспользования.

Вывод

Движок V8, фактически, не имеет типа Number, вместо этого, у него есть два других типа:

  • Smi - целые числа в диапазоне -(2**30 - 1) ... +(2**30 - 1), представляются в памяти в виде 31-битного значения
  • HeapNumber - целые числа за пределами Smi и числа с плавающей точкой, представляются в памяти в виде внутреннего специализированного объекта

С числами, вроде бы, понятно. А как обстоят дела с остальными типами?

String

/src/objects/string.h

// The String abstract class captures JavaScript string values:
//
// Ecma-262:
//  4.3.16 String Value
//    A string value is a member of the type String and is a finite
//    ordered sequence of zero or more 16-bit unsigned integer values.
//
// All string values have a length field.
class String : public TorqueGeneratedString<String, Name> {

Смотрим, что на практике

d8> %DebugPrint("")
DebugPrint: 0x25800000099: [String] in ReadOnlySpace: #
0x258000003d5: [Map] in ReadOnlySpace
- map: 0x0258000004c5 <MetaMap (0x02580000007d <null>)>
- type: INTERNALIZED_ONE_BYTE_STRING_TYPE
- instance size: variabl
- elements kind: HOLEY_ELEMENTS
- enum length: invalid
- stable_map
- non-extensible
- back pointer: 0x025800000061 <undefined>
- prototype_validity cell: 0
- instance descriptors (own) #0: 0x0258000006d9 <DescriptorArray[0]>
- prototype: 0x02580000007d <null>
- constructor: 0x02580000007d <null>
- dependent code: 0x0258000006b5 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0

""

Здесь все довольно очевидно. Мы видим объект типа String c неопределенным размером. Согласно спецификации, String - это массив символов, а массив в JavaScript - это объект. Хоть в спецификации и говориться, что String - это один из примитивных типов, по факту, это полноправный объект со всей, присущей объектам атрибутикой, за исключением мутабельности. Разработчики движка умышленно исключили мутабельность объекта String, как того требует спецификация.

Как и в случае с числами, давайте посмотрим на слепок памяти.

/* Для чистоты эксперимента возьмем пустую строку и не пустую */
function V8Snapshot() {
  this.emptyString = '';
  this.string = 'JavaScript';
}

const v8Snapshot1 = new V8Snapshot();
const v8Snapshot2 = new V8Snapshot();

Здесь мы видим, что в обоих экземплярах используются одинаковые указатели на строки. Более того, запустив скрипт несколько раз, мы каждый раз будем видеть одни и те же адреса. Достигается это за счет так называемой концепции String Pool, применяемой во многих языках программирования. Говоря простым языком, строка - это последовательность символов, на основании этой последовательности можно легко построить хэш всего объекта. Этот хэш, в дальнейшем, и будет указателем на экземпляр объекта в HashMap. Таким образом, получая строку, движок составляет её хэш, смотрит, нет ли в пуле строки с таким хэшем, и, если строка есть, вернет указатель на неё. В противном случае, запишет новую строку в пул.

Boolean, Null, Undefined

В теории, Boolean может принимать только два значения, true или false. Для этого, как правило, достаточно 1 бита, где 0 = false, а 1 = true. Давайте взглянем, так ли это в V8.

Boolean

d8> %DebugPrint(true)
DebugPrint: 0x36ac000000c1: [Oddball] in ReadOnlySpace: #true
0x36ac0000053d: [Map] in ReadOnlySpace
- map: 0x36ac000004c5 <MetaMap (0x36ac0000007d <null>)>
- type: ODDBALL_TYPE
- instance size: 28
- elements kind: HOLEY_ELEMENTS
- enum length: invalid
- stable_map
- non-extensible
- back pointer: 0x36ac00000061 <undefined>
- prototype_validity cell: 0
- instance descriptors (own) #0: 0x36ac000006d9 <DescriptorArray[0]>
- prototype: 0x36ac0000007d <null>
- constructor: 0x36ac0000007d <null>
- dependent code: 0x36ac000006b5 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0

true

Неожиданный поворот. Оказывается, Boolean внутри V8 - тоже объект, почти такой же, как HeapNumber, только с типом Oddball. Что такое Oddball, чуть ниже, а пока, обращу внимание, что аналогичную структуру можно наблюдать и у других простых типов.

Null

d8> %DebugPrint(null)
DebugPrint: 0x36ac0000007d: [Oddball] in ReadOnlySpace: #null
0x36ac00000515: [Map] in ReadOnlySpace
- map: 0x36ac000004c5 <MetaMap (0x36ac0000007d <null>)>
- type: ODDBALL_TYPE
- instance size: 28
- elements kind: HOLEY_ELEMENTS
- enum length: invalid
- stable_map
- undetectable
- non-extensible
- back pointer: 0x36ac00000061 <undefined>
- prototype_validity cell: 0
- instance descriptors (own) #0: 0x36ac000006d9 <DescriptorArray[0]>
- prototype: 0x36ac0000007d <null>
- constructor: 0x36ac0000007d <null>
- dependent code: 0x36ac000006b5 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0

null

Undefined

d8> %DebugPrint(undefined)
DebugPrint: 0x25800000061: [Oddball] in ReadOnlySpace: #undefined
0x258000004ed: [Map] in ReadOnlySpace
- map: 0x0258000004c5 <MetaMap (0x02580000007d <null>)>
- type: ODDBALL_TYPE
- instance size: 28
- elements kind: HOLEY_ELEMENTS
- enum length: invalid
- stable_map
- undetectable
- non-extensible
- back pointer: 0x025800000061 <undefined>
- prototype_validity cell: 0
- instance descriptors (own) #0: 0x0258000006d9 <DescriptorArray[0]>
- prototype: 0x02580000007d <null>
- constructor: 0x02580000007d <null>
- dependent code: 0x0258000006b5 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
- construction counter: 0

undefined

Oddball

/src/objects/oddball.h

// The Oddball describes objects null, undefined, true, and false.
class Oddball : public PrimitiveHeapObject {

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

static const uint8_t kFalse = 0;
static const uint8_t kTrue = 1;
static const uint8_t kNotBooleanMask = static_cast<uint8_t>(~1);
static const uint8_t kNull = 3;
static const uint8_t kUndefined = 4;

Из комментария и структуры понятно, что этот объект описывает 4 возможных значения, null, undefined, true и false. Но ведь значения эти, до неприличия простые. Зачем же нужны такие сложности?

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

Заглянем в слепок памяти.

function V8Snapshot() {
  this.true = true;
  this.false = false;
  this.null = null;
  this.undefined = undefined;
}

const v8Snapshot1 = new V8Snapshot();
const v8Snapshot2 = new V8Snapshot();

Здесь видим, что все 4 значения являются Oddball и имеют постоянные системные адреса, определенные еще до запуска скрипта.

Итог

Итак, мы заглянули под капот движка V8 и посмотрели, как в нем устроены основные типы данных. Исследование показало, что практическая реализация далеко не всегда соответствует заложенной под неё теоретической базе. Это, конечно, не означает, что спецификация ECMAScript не верна или, что разработчики движка не ей не следовали. Тут важно понимать, что спецификация - это некий абстрактный логический слой, который задаёт общие понятия и принципы. Реальная прикладная разработка движка по спецификации - история более низкоуровневая. Помимо реализации основных требований, разработчики должны позаботиться о многих вопросах, связанных с производительностью, оптимизацией и, при этом, учесть особенности разных архитектур и операционных систем.

Как мы видим, практически все типы данных, кроме Smi, в движке V8 являются объектными, а переменные - указатели на них.

В целом, понятия "примитив" и "объект" в JavaScript были и остаются такими, как они были заложены в спецификации. Но во время работы с типами данных следует понимать, что понятия эти, скорее логические, нежели физические. Физическая реализация того или иного типа на уровне движка может отличаться и иметь индивидуальные особенности.

Мои телеграмм-каналы:

EN - https://t.me/frontend_almanac
RU - https://t.me/frontend_almanac_ru

English version: https://blog.frontend-almanac.com/p14TDUH-R4o