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

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

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

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

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

Ссылочные типы данных, они же - объекты, это области памяти неопределенного размера, и доступные по идентификатору (ссылке на эту область памяти). В JavaScript, есть только один такой тип - Object. Помимо Object, есть еще отдельная структура Function, которая, по факту, тоже является ObjectObject - единственный мутабиельный (изменяемый) тип данных в 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 возможных значения, nullundefinedtrueи 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 были и остаются такими, как они были заложены в спецификации. Но во время работы с типами данных следует понимать, что понятия эти, скорее логические, нежели физические. Физическая реализация того или иного типа на уровне движка может отличаться и иметь индивидуальные особенности. 

Комментарии (13)


  1. NeoCode
    17.11.2023 08:31

    Интересно, а зачем с точки зрения дизайна языка вообще ввели отдельно null и отдельно undefined? Не проще ли было обойтись каким-то одним? Для реализации абстракции опционалов вполне достаточно одного.

    В этом есть какой-то глубокий (основанный на какой-то умной околоматематической теории) смысл, или просто так сложилось/не сложилось?


    1. virtualtoy
      17.11.2023 08:31

      null планировался для интеропа с Java. Кстати, typeof null == 'object'.


      1. Parker0 Автор
        17.11.2023 08:31

        Согласен, скорее всего, Null - дань другим языкам, популярным во второй половине 90-х.


    1. zede
      17.11.2023 08:31

      Ошибка дизайна языка, когда планировалось делать его схожим с Java? Можно обнаружить в старых спеках языка кучу заразервированных слов из Java/C++ на всякий случай. На практике же undefined и null достаточно сильно отличаются и это важно учитывать. Смысл несут они разный



    1. iliazeus
      17.11.2023 08:31
      +5

      В первых версиях JavaScript не было исключений. Поэтому доступ к несуществующему полю должен был вернуть хоть какое-то значение. null для этой цели не подходил потому, что требовалось, чтобы он вел себя максимально похоже на Java. Поэтому ввели значение-заглушку. undefined тогда даже не было ключевым словом - это было буквально имя переменной, которой договорились ничего не присваивать, чтобы при доступе к ней всегда возвращалось это самое значение-заглушку. В спеке ECMAScript это до сих пор не ключевое слово, а именно глобальная переменная - просто ее сделали доступной только для чтения.


  1. olegkusov
    17.11.2023 08:31

    Хороший материал , спасибо


  1. shsv382
    17.11.2023 08:31

    Круто, люблю иногда покопаться в таких дебрях, спасибо!

    З.ы.на основании статьи - так почему все-таки 0.1+0.2 !== 0.3 ? ????


    1. Parker0 Автор
      17.11.2023 08:31
      +1

      Статья было про типы данных. Бинарную арифметику я затрагивал. Но, если уже поднимать этот вопрос, быстрый ответ, потому что бинарная сумма 0.1+0.2 = 0.30000000000000004. Почему так происходит, думаю, в сети найдется много материала на этот счет. Если коротко, при суммировании двух чисел с плавающей точкой их мантиссы суммируются бинарным способом и иногда, вместо 52-битного слова, может получиться 53-битное. Соответственно, для приведения к 52 битам, результат нужно округлить. Более того, если у двух чисел разные экспоненты, вторая сдвигается относительно первой, что тоже может привести к переполнению разряда, тогда их тоже надо округлять. Отсюда и неточности в вычислении.


  1. Sap_ru
    17.11.2023 08:31
    +1

    Что-то где-то автор попутал. Сделать нестандартный double и потерять в 50..10 раз в скорости даже по сравнению с FPU?! Там наверняка стандартный float double, а представление в виде 2x32 сугубо издержки поддержки 32-битных платформ. То есть это абстракция, уровня аллокатора памяти/объектов и отладчика, а не что-то реальное.


    1. Parker0 Автор
      17.11.2023 08:31

      Не путаю, так уж решили разработчики V8. Сделано это было еще в 2009-м Эриком Корри для ускорения бинарных операций на архитектуре ARM. Суть как раз в том, чтобы делать их в обход FPU или эмуляторов (см коммит https://codereview.chromium.org/119241). Видимо, в 2009 на ARM это было актуально. Сам я в эту сторону далеко не копал.


      1. Sap_ru
        17.11.2023 08:31

        Все правильно. Это сугубо представление для того, чтобы упростить поддержку на некоторых платформах. Но это не значит, что там свой плавающий тип. На подавляющем числе платформ используется аппаратная плавающая точка, и только кое где эмулируется. Причем речь идёт именно об описании распределения памяти, так как по стандарту там слишком много неопределенности в C. Приходится изобретать вот такие хитрые представления переменных в памяти, но по факту практически везде там лежит и используется стандартный float double. Но, опять же, есть возможность перехода в любой момент на эмуляцию малой кровью. Включая всякую нестандартную эмуляцию, которая за счёт более слабой поддержки стандарта в разы быстрее может быть (в пределах своего приложения вы просто знаете о сайд-эффектах и обходите их, а в случае передачи куда-то сторонним приложениями проводите нормализацию - стандартный трюк). Это несколько я это всё понимаю.


    1. zede
      17.11.2023 08:31
      +1

      На самом деле SMI это далеко не только то что это целое представление и оно быстрее FPU. Чтобы этот вопрос отпал сам собой нужно понять как работают идентификаторы в v8. Они всегда работают как указатель на значение в куче. Но каждый раз создавать новое значение в куче / следить за его жизнью / обращаться через особую структуру HeapNumber -- это дорого. В то время как SMI это буквально число вставленное вместо указателя. Те 1ый бит в значении идентификатора говорит: Я не SMI а указатель, иди по нему там реальное значение. А SMI - хей тут уже лежит число, бери его и делай что хочешь. Чем и обусловлено ограничение в 31 бит ибо 32-ой бит это указатель на то что это число. Думаю с этого момента понятно чем обсулавливается кратная разница по перфу