v8
August 20

V8. Работа со строкам. Пополняем словарный запас

Что такое строка

Для того чтобы лучше понять, что происходит под капотом V8, для начала стоит вспомнить немного теории.

Спецификация ECMA-262 гласит:

The String type is the set of all ordered sequences of zero or more 16-bit unsigned integer values (“elements”) up to a maximum length of 2**53 - 1 elements.

Тип String — это набор всех упорядоченных последовательностей из нуля или более 16-разрядных целых беззнаковых чисел (“элементов”) максимальной длиной 2**53 - 1 элементов.

На практике в машинной памяти вместе со строками необходимо хранить дополнительную информацию, чтобы иметь возможность определить конец строки в общей куче. Для этой цели существует два подхода. Первый — массив символов: структура, представляющая собой последовательность элементов и отдельное поле — длину этой последовательности. Второй — метод завершающего байта, то есть в конце последовательности элементов должен стоять некий служебный символ, обозначающий конец строки. В качестве завершающего символа, в зависимости от системы, может выступать байт 0xFF или, например, ASCII-код символа. Но наибольшее распространение получил байт 0x00. А строки с таким завершающим элементом получили название нуль-терминированные. Оба метода имеют свои плюсы и минусы, поэтому в современном мире часто оба метода объединяют, и строки в памяти могут одновременно быть нуль-терминированными и хранить значение длины. Например, в языке C++ начиная с версии 11.

Кроме общего определения строки, спецификация ECMA-262 имеет ряд других требований наряду с остальными типами данных языка JavaScript. Подробно о типах и их представлении внутри движка V8 я писал в статье "Глубокий JS. В память о типах и данных". В этой статье мы узнали, что все типы имеют свой скрытый класс, который хранит всю необходимую атрибутику, служебную информацию, а также инкапсулирует логику работы с этим типом. Очевидно, что для работы со строками стандартной библиотеки C++ std::string — недостаточно, и они преобразуются во внутренний класс v8::String.

Какие бывают строки

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

Однобайтные / Двубайтные

Несмотря на то что спецификация прямо определяет все строки как последовательность 16-разрядных элементов, с точки зрения оптимизации это довольно расточительно. Ведь далеко не всем символам требуется 2 байта для представления. Стандартная таблица ASCII содержит 128 элементов, среди которых арабские цифры, буквы латинского алфавита в верхнем и нижнем регистрах без диакритических знаков, основные знаки препинания, математические знаки и управляющие символы. Вся эта таблица предполагает кодирование всего 8 битами, при этом сам набор символов является широко распространенным и встречается на практике наиболее часто. В связи с этим разработчиками V8 было принято решение кодировать те строки, которые содержат только 8-разрядные символы, отдельно от стандартных 16-разрядных. Это позволяет существенно экономить память.

Интернализированные / Экстернализированные строки

Помимо разрядности, строки различаются местом хранения. После преобразования во внутреннюю структуру строка попадает в так называемую таблицу строк (StringTable), о которой мы поговорим чуть ниже. Пока обозначу, что таких таблиц три. Собственно, сама StringTable хранит строки внутри одного Isolate (грубо говоря, внутри одного таба браузера). Кроме того, движок позволяет хранить строки за пределами кучи, и даже за пределами самого движка во внешнем хранилище. Указатели на такие строки помещаются в отдельную таблицу ExternalStringTable. Дополнительно есть еще одна таблица — StringForwardingTable для нужд сборщика мусора. Да, строки, как и объекты, участвуют в процессе сборки мусора, и так как хранятся они в отдельных таблицах, им для этого нужны специфические механизмы. Об этом тоже чуть ниже.

Строки по назначение

Язык JavaScript является довольно гибким. Во время исполнения кода строки могут многократно трансформироваться и участвовать в смежных процессах. Для большей производительности им выделено несколько функциональных типов. К ним относятся: SeqString, ConsString, ThinString, SlicedString и ExternalString. О каждом типе мы поговорим подробно далее. А пока давайте соберём всё вышесказанное вместе.

Так выглядит общая классификация типов строк в V8. Помимо основных, есть ещё несколько производных типов. Например, SeqString и ExternalString могут быть размещены в общей Shared-куче, тогда их типы могут быть SharedSeq<one|two>ByteString и SharedExternal<one|two>ByteString соответственно. Кроме того, ExternalString может быть некэшируемым и иметь дополнительные типы UncachableExternal<one|two>ByteString и даже SharedUncachableExternal<one|two>ByteString.

AST

Прежде чем строка приобретёт свой окончательный вид в недрах V8, движок должен её сначала прочитать и интерпретировать. Делается это тем же механизмом, что и интерпретация всех остальных синтаксических единиц языка. А именно, посредством составления абстрактного синтаксического дерева (AST), про которое я говорил в статье Глубокий JS. Области тьмы или где живут переменные.

Получив на вход строковую ноду через строковый литерал или каким-то другим образом, V8 создаёт экземпляр класса AstRawString. Если строка является конкатенацией двух или более других строк, то AstConsString. Эти классы предназначены для хранения строк вне кучи V8, в так называемой AstValueFactory. Позже этим строкам будет определён тип, и они будут интернализированы, т.е. перемещены в кучу V8.

Длина строки

Размер каждой строки в памяти зависит от её длины. Чем длиннее строка, тем больше памяти она занимает. Но существуют ли какие-то ограничения на максимальный размер строки со стороны движка? Спецификация прямо указывает на то, что строка не может быть больше 2**53 - 1 элементов. Однако на практике это число может быть другим. Ведь тогда максимальная однобайтная строка весила бы 8 192 ТБ (8 ПБ), а двубайтная, соответственно, 4 096 ТБ (4 ПБ). Очевидно, что ни один персональный компьютер не имеет такого количества оперативной памяти, поэтому JS-движки имеют свои собственные лимиты, которые значительно строже требований спецификации. Конкретно в V8 (версия V8 на момент написания статьи 12.8.325) максимальная длина строки зависит от архитектуры системы.

/include/v8-primitive.h#126

static constexpr int kMaxLength =
    internal::kApiSystemPointerSize == 4 ? (1 << 28) - 16 : (1 << 29) - 24;

Для 32-разрядных систем это число 2**28 - 16, а в 64-разрядных — чуть больше: 2**29 - 24. Такие ограничения не позволят в 32-разрядных системах одной однобайтной строке занять более 256 МБ, а двубайтной — более 512 МБ. В 64-разрядных системах максимальные значения составляют соответственно не более 512 МБ и не более 1024 МБ. В случае если строка превысит лимит длины, движок вернёт ошибку Invalid string length.

const length = (1 << 29) - 24;
const longString = '"' + new Array(length - 2).join('x');

String.prototype.link(longString); // RangeError: Invalid string length

Объективности ради стоит отметить, что косвенную роль играет и максимальный размер самой кучи, так как он не является константой и может регулироваться системой или конфигурацией движка.

// d8 --max-heap-size=128

const length = (1 << 27) + 24; // 129 MB
const longString = '"' + new Array(length - 2).join('x');

String.prototype.link(longString);

// Fatal JavaScript out of memory: Reached heap limit

StringTable

В ходе выполнения JS-программа может оперировать большим количеством как самих строк, так и переменных, которые на них ссылаются. Как мы увидели, каждая строка может потреблять значительный объём памяти. При этом сами строки в ходе выполнения JS-программы могут многократно клонироваться, изменяться, склеиваться и вообще трансформироваться самыми разными способами. В результате этого в памяти могут оказаться дубликаты строковых значений. Хранить множество копий одной и той же последовательности символов было бы крайне расточительно, поэтому в мире системного программирования уже давно активно применяется практика хранения строковых значений в некой отдельной структуре, которая заботится о том, чтобы в ней не было дублирования значений, а все строковые переменные ссылались на эту структуру. В Java и .NET, например, такая структура называется "StringPool", а в JavaScript - StringTable.

Суть в том, что после того, как строка попала в AST-дерево, для неё на основании типа и значения генерируется хэш-ключ. Сгенерированный ключ сохраняется в объекте строки, и далее по нему запускается проверка наличия строки в специальной таблице строк, размещённой в куче. Если такой ключ есть в таблице, соответствующая JS-переменная получит ссылку на существующий объект строки. В противном случае будет создан новый объект строки, а ключ будет помещён в таблицу. Этот процесс называется интернмазацией.

InternalizedString

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

Как обычно, давайте посмотрим на структуру строки внутри движка, скомпилировав V8 в режиме debug и запустив её с флагом --allow-natives-syntax.

d8> %DebugPrint("FrontendAlmanac")

DebugPrint: 0x28db000d9b99: [String] in OldSpace: #FrontendAlmanac
0x28db000003d5: [Map] in ReadOnlySpace
 - map: 0x28db000004c5 <MetaMap (0x28db0000007d <null>)>
 - type: INTERNALIZED_ONE_BYTE_STRING_TYPE
 - instance size: variable
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - stable_map
 - non-extensible
 - back pointer: 0x28db00000061 <undefined>
 - prototype_validity cell: 0
 - instance descriptors (own) #0: 0x28db00000701 <DescriptorArray[0]>
 - prototype: 0x28db0000007d <null>
 - constructor: 0x28db0000007d <null>
 - dependent code: 0x28db000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

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

d8> const string2 = "FrontendAlmanac";
d8> %DebugPrint(string2);

DebugPrint: 0x28db000d9b99: [String] in OldSpace: #FrontendAlmanac
0x28db000003d5: [Map] in ReadOnlySpace
 - map: 0x28db000004c5 <MetaMap (0x28db0000007d <null>)>
 - type: INTERNALIZED_ONE_BYTE_STRING_TYPE
 - instance size: variable
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - stable_map
 - non-extensible
 - back pointer: 0x28db00000061 <undefined>
 - prototype_validity cell: 0
 - instance descriptors (own) #0: 0x28db00000701 <DescriptorArray[0]>
 - prototype: 0x28db0000007d <null>
 - constructor: 0x28db0000007d <null>
 - dependent code: 0x28db000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

Обратите внимание, что константа string2 ссылается ровно на тот же экземпляр строки по адресу 0x28db000d9b99.

Аналогичную картину мы можем увидеть и в браузере Chrome.

// Замкнем значения в функции
function V8Snapshot() {
  this.string1 = "FrontendAlmanac";
  this.string2 = "FrontendAlmanac";
}

const v8Snapshot = new V8Snapshot();

Оба свойства V8Snapshot ссылаются на один и тот же адрес @61559. Содержимое всей StringTable можно увидеть тут же, в слепке в объекте (string).

Здесь мы можем найти нашу строку и посмотреть, какие переменные на неё ссылаются.

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

SeqString

Понятие InternalizedString появилось сравнительно недавно, в 2018 году, в рамках оптимизирующего компилятора TurboFan. До этого простые строки имели тип SeqString. Технически, InternalizedString отличается от SeqString своей внутренней структурой. Если точнее, в классах SeqOneByteString и SeqTwoByteString имеется указатель на массив символов chars и ряд методов, которые умеют взаимодействовать с ним. Имплементация класса SeqString выглядит буквально следующим образом:

/src/objects/string.h#733

// The SeqString abstract class captures sequential string values.
class SeqString : public String {
 public:
  // Truncate the string in-place if possible and return the result.
  // In case of new_length == 0, the empty string is returned without
  // truncating the original string.
  V8_WARN_UNUSED_RESULT static Handle<String> Truncate(Isolate* isolate,
                                                       Handle<SeqString> string,
                                                       int new_length);
                                                       
  struct DataAndPaddingSizes {
    const int data_size;
    const int padding_size;
    bool operator==(const DataAndPaddingSizes& other) const {
      return data_size == other.data_size && padding_size == other.padding_size;
    }
  };
  DataAndPaddingSizes GetDataAndPaddingSizes() const;
  
  // Zero out only the padding bytes of this string.
  void ClearPadding();
  
  EXPORT_DECL_VERIFIER(SeqString)
};

А один из его наследников, SeqOneByteString, так:

/src/objects/string.h#763

// The OneByteString class captures sequential one-byte string objects.
// Each character in the OneByteString is an one-byte character.
V8_OBJECT class SeqOneByteString : public SeqString {
 public:
  static const bool kHasOneByteEncoding = true;
  using Char = uint8_t;
  
  V8_INLINE static constexpr int32_t DataSizeFor(int32_t length);
  V8_INLINE static constexpr int32_t SizeFor(int32_t length);
  
  // Dispatched behavior. The non SharedStringAccessGuardIfNeeded method is also
  // defined for convenience and it will check that the access guard is not
  // needed.
  inline uint8_t Get(int index) const;
  inline uint8_t Get(int index,
                     const SharedStringAccessGuardIfNeeded& access_guard) const;
  inline void SeqOneByteStringSet(int index, uint16_t value);
  inline void SeqOneByteStringSetChars(int index, const uint8_t* string,
                                       int length);
                                       
  // Get the address of the characters in this string.
  inline Address GetCharsAddress() const;
  
  // Get a pointer to the characters of the string. May only be called when a
  // SharedStringAccessGuard is not needed (i.e. on the main thread or on
  // read-only strings).
  inline uint8_t* GetChars(const DisallowGarbageCollection& no_gc);
  
  // Get a pointer to the characters of the string.
  inline uint8_t* GetChars(const DisallowGarbageCollection& no_gc,
                           const SharedStringAccessGuardIfNeeded& access_guard);
                           
  DataAndPaddingSizes GetDataAndPaddingSizes() const;
  
  // Initializes padding bytes. Potentially zeros tail of the payload too!
  inline void clear_padding_destructively(int length);
  
  // Maximal memory usage for a single sequential one-byte string.
  static const int kMaxCharsSize = kMaxLength;
  
  inline int AllocatedSize() const;
  
  // A SeqOneByteString have different maps depending on whether it is shared.
  static inline bool IsCompatibleMap(Tagged<Map> map, ReadOnlyRoots roots);
  
  class BodyDescriptor;
  
 private:
  friend struct OffsetsForDebug;
  friend class CodeStubAssembler;
  friend class ToDirectStringAssembler;
  friend class IntlBuiltinsAssembler;
  friend class StringBuiltinsAssembler;
  friend class StringFromCharCodeAssembler;
  friend class maglev::MaglevAssembler;
  friend class compiler::AccessBuilder;
  friend class TorqueGeneratedSeqOneByteStringAsserts;
  
  FLEXIBLE_ARRAY_MEMBER(Char, chars);
} V8_OBJECT_END;

Для сравнения, класс InternalizedString выглядит следующим образом:

/src/objects/string.h#758

V8_OBJECT class InternalizedString : public String{

  // TODO(neis): Possibly move some stuff from String here.
} V8_OBJECT_END;

Как видите, здесь вообще нет никакой реализации, так как само понятие InternalizedString было введено лишь для удобства терминологии. Фактически, вся логика по выделению памяти, кодированию и декодированию, сравнению и модификации строк лежит в базовом классе String. Символы хранятся не в виде массива, а непосредственно в виде 32-разрядной или 64-разрядной последовательности кодов символов в памяти. Сам класс имеет только системный указатель на начало соответствующей области. Такая структура называется "FlatContent", а такие строки соответственно — плоскими.

V8_OBJECT class String : public Name {
  ...
 private:
  union {
    const uint8_t* onebyte_start;
    const base::uc16* twobyte_start;
  };
  ...
}

Так что же такое SeqString на практике?

d8> const seqString = [
  "F", "r", "o", "n", "t", "e", "n", "d",
  "A", "l", "m", "a", "n", "a", "c"
].join("");
d8> 
d8> %DebugPrint(seqString);

DebugPrint: 0x2353001c94e1: [String]: "FrontendAlmanac"
0x235300000105: [Map] in ReadOnlySpace
 - map: 0x2353000004c5 <MetaMap (0x23530000007d <null>)>
 - type: SEQ_ONE_BYTE_STRING_TYPE
 - instance size: variable
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - stable_map
 - non-extensible
 - back pointer: 0x235300000061 <undefined>
 - prototype_validity cell: 0
 - instance descriptors (own) #0: 0x235300000701 <DescriptorArray[0]>
 - prototype: 0x23530000007d <null>
 - constructor: 0x23530000007d <null>
 - dependent code: 0x2353000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

В примере выше строка создана посредством объединения элементов массива. Так как во время этой операции массив в любом случае должен был создаться, а итоговую строку нельзя проверить в StringTable, пока она не будет объединена, переменная получает тип SeqString.

Давайте немного изменим предыдущий пример.

function V8Snapshot() {
  this.string1 = "FrontendAlmanac";
  this.string2 = [
    "F", "r", "o", "n", "t", "e", "n", "d",
    "A", "l", "m", "a", "n", "a", "c"
  ].join("");
}

const v8Snapshot = new V8Snapshot();

Как будет выглядеть таблица строк в этом случае?

Так как string1 и string2 — два разных объекта с разными типами строк, каждая из них имеет свой собственный адрес. Более того, у каждой из них будет свой уникальный хэш-ключ. Поэтому в таблице мы можем видеть два одинаковых значения с разными ключами. Другими словами, имеются дубликаты строк. Вообще, все подобные дубликаты можно увидеть, применив фильтр Duplicated strings в слепке.

Более того, если мы попытаемся создать несколько SeqString с одинаковым значением, их дубликаты также не будут в таблице.

function V8Snapshot() {
  this.string1 = "FrontendAlmanac";
  this.string2 = [
    "F", "r", "o", "n", "t", "e", "n", "d",
    "A", "l", "m", "a", "n", "a", "c"
  ].join("");
  this.string3 = [
    "F", "r", "o", "n", "t", "e", "n", "d",
    "A", "l", "m", "a", "n", "a", "c"
  ].join("");
}

const v8Snapshot = new V8Snapshot();

ConsString

Рассмотрим еще один пример

d8> const consString = "Frontend" + "Almanac";
d8> %DebugPrint(consString);

DebugPrint: 0xf0001c952d: [String]: c"FrontendAlmanac"
0xf000000155: [Map] in ReadOnlySpace
 - map: 0x00f0000004c5 <MetaMap (0x00f00000007d <null>)>
 - type: CONS_ONE_BYTE_STRING_TYPE
 - instance size: 20
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - non-extensible
 - back pointer: 0x00f000000061 <undefined>
 - prototype_validity cell: 0
 - instance descriptors (own) #0: 0x00f000000701 <DescriptorArray[0]>
 - prototype: 0x00f00000007d <null>
 - constructor: 0x00f00000007d <null>
 - dependent code: 0x00f0000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

Переменная consString образована конкатенацией двух строковых литералов. Такие строки получают тип ConsString.

/src/objects/string.h#916

V8_OBJECT class ConsString : public String {
 public:
  inline Tagged<String> first() const;
  inline void set_first(Tagged<String> value,
                        WriteBarrierMode mode = UPDATE_WRITE_BARRIER);
  
  inline Tagged<String> second() const;
  inline void set_second(Tagged<String> value,
                         WriteBarrierMode mode = UPDATE_WRITE_BARRIER);
  
  // Doesn't check that the result is a string, even in debug mode.  This is
  // useful during GC where the mark bits confuse the checks.
  inline Tagged<Object> unchecked_first() const;
  
  // Doesn't check that the result is a string, even in debug mode.  This is
  // useful during GC where the mark bits confuse the checks.
  inline Tagged<Object> unchecked_second() const;
  
  V8_INLINE bool IsFlat() const;
  
  // Dispatched behavior.
  V8_EXPORT_PRIVATE uint16_t
  Get(int index, const SharedStringAccessGuardIfNeeded& access_guard) const;
  
  // Minimum length for a cons string.
  static const int kMinLength = 13;
  
  DECL_VERIFIER(ConsString)
  
 private:
  friend struct ObjectTraits<ConsString>;
  friend struct OffsetsForDebug;
  friend class V8HeapExplorer;
  friend class CodeStubAssembler;
  friend class ToDirectStringAssembler;
  friend class StringBuiltinsAssembler;
  friend class maglev::MaglevAssembler;
  friend class compiler::AccessBuilder;
  friend class TorqueGeneratedConsStringAsserts;
  
  friend Tagged<String> String::GetUnderlying() const;
  
  TaggedMember<String> first_;
  TaggedMember<String> second_;
} V8_OBJECT_END;

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

function V8Snapshot() {
  this.string1 = "FrontendAlmanac";
  this.string2 = "Frontend" + "Almanac";
  this.string3 = "Frontend" + "Almanac";
}

const v8Snapshot = new V8Snapshot();

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

Важно сделать оговорку. Не любая операция конкатенации приводит к созданию ConsString.

d8> const string = "a" + "b";
d8> %DebugPrint(string);

DebugPrint: 0x1d48001c9ec5: [String]: "ab"
0x1d4800000105: [Map] in ReadOnlySpace
 - map: 0x1d48000004c5 <MetaMap (0x1d480000007d <null>)>
 - type: SEQ_ONE_BYTE_STRING_TYPE
 - instance size: variable
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - stable_map
 - non-extensible
 - back pointer: 0x1d4800000061 <undefined>
 - prototype_validity cell: 0
 - instance descriptors (own) #0: 0x1d4800000701 <DescriptorArray[0]>
 - prototype: 0x1d480000007d <null>
 - constructor: 0x1d480000007d <null>
 - dependent code: 0x1d48000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

В примере выше строка представлена типом SeqString. Дело в том, что процедуры чтения и записи в структуру ConsString не бесплатны. Хотя сама структура считается эффективной с точки зрения оптимизации памяти, все преимущества проявляются только на относительно длинных строках. В случае же коротких строк накладные расходы по содержанию бинарного дерева сводят на нет все эти преимущества. Поэтому разработчики V8 эмпирическим путем определили критическую длину строки, короче которой структура ConsString неэффективна. Это число — 13.

/src/objects/string.h#940

// Minimum length for a cons string.
static const int kMinLength = 13;

SlicedString

d8> const parentString = " FrontendAlmanac FrontendAlmanac ";
d8> const slicedString1 = parentString.substring(1, 16);
d8> const slicedString2 = parentString.slice(1, 16);
d8> const slicedString3 = parentString.trim();
d8> 
d8> %DebugPrint(slicedString1);

DebugPrint: 0x312b001c9509: [String]: "FrontendAlmanac"
0x312b000001a5: [Map] in ReadOnlySpace
 - map: 0x312b000004c5 <MetaMap (0x312b0000007d <null>)>
 - type: SLICED_ONE_BYTE_STRING_TYPE
 - instance size: 20
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - stable_map
 - non-extensible
 - back pointer: 0x312b00000061 <undefined>
 - prototype_validity cell: 0
 - instance descriptors (own) #0: 0x312b00000701 <DescriptorArray[0]>
 - prototype: 0x312b0000007d <null>
 - constructor: 0x312b0000007d <null>
 - dependent code: 0x312b000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

Этот тип присваивается строкам, образованным как результат подстроки другой строки. К таким операциям относятся substring(), slice() и методы тримминга trim(), trimStart(), trimEnd().

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

Еще одной важной особенностью является то, что SlicedString не может быть вложенной.

function V8Snapshot() {
  this.parentString = " FrontendAlmanac FrontendAlmanac ";
  this.slicedString1 = this.parentString.trim();
  this.slicedString2 = this.slicedString1.substring(0, 15);
}

const v8Snapshot = new V8Snapshot();

В примере выше мы пытаемся создать SlicedString от другой SlicedString. Однако slicedString1 не может выступать в роли родительской строки, так как сама является SlicedString.

Поэтому родительской строкой для обоих свойств будет parentString.

ThinString

Бывает так, что нужно провести интернализацию строки, но по какой-то причине сделать это прямо сейчас нельзя. В таком случае создаётся новый объект строки, интернализируется, а оригинальная строка конвертируется в тип ThinString, который, по сути, является ссылкой на свою интернализированную версию. ThinString очень похож на ConsString, только с одной ссылкой.

d8> const string = "Frontend" + "Almanac"; // создаем ConsString
d8> const obj = {};
d8> 
d8> obj[string]; // конвертируем в ThinString
d8> 
d8> %DebugPrint(string);

DebugPrint: 0x335f001c947d: [String]: >"FrontendAlmanac"
0x335f00000425: [Map] in ReadOnlySpace
 - map: 0x335f000004c5 <MetaMap (0x335f0000007d <null>)>
 - type: THIN_ONE_BYTE_STRING_TYPE
 - instance size: 16
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - stable_map
 - non-extensible
 - back pointer: 0x335f00000061 <undefined>
 - prototype_validity cell: 0
 - instance descriptors (own) #0: 0x335f00000701 <DescriptorArray[0]>
 - prototype: 0x335f0000007d <null>
 - constructor: 0x335f0000007d <null>
 - dependent code: 0x335f000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

В примере выше мы первым делом создаём ConsString. Далее мы используем эту строку в качестве ключа объекта. Чтобы найти ключ в массиве свойств объекта, строка должна быть плоской, однако сейчас она представляет собой бинарное дерево с двумя узлами. В этом случае движок вынужден вычислить плоское значение из ConsString, создать новый объект строки и интернализировать его, а оригинальную строку сконвертировать в ThinString. Подобный кейс, кстати, упоминался в статье про очистку строк на Хабре, правда, не было объяснено, почему так происходит.

Давайте посмотрим в DevTools. ThinString тут мы не увидим. Но можно заметить, что свойство string представлено как InternalizedString, так как ThinString — это, повторюсь, лишь ссылка на интернализированную версию строки.

ExternalString

Еще одним типом строк является ExternalString. Движок V8 предусматривает возможность создания и хранения строк за пределами самого движка. Специально для этого был введён тип ExternalString и соответствующее API. Ссылки на эти строки хранятся в отдельной таблице ExternalStringTable в куче. Как правило, такие строки создаются потребителем движка для каких-либо собственных нужд. Например, браузеры могут таким образом хранить какие-то внешние ресурсы. Также потребитель может полностью контролировать жизненный цикл этих ресурсов, но с одной оговоркой: потребитель должен гарантировать, что такая строка не будет деаллоцирована, пока объект ExternalString жив в куче V8.

На скриншоте выше как раз одна из таких строк, созданных браузером. Но мы можем создать и свою собственную. Для этого можно воспользоваться внутренним API-методом externalizeString (V8 должен быть запущен с флагом --allow-natives-syntax).

//> d8 --allow-natives-syntax --expose-externalize-string
d8> const string = "FrontendAlmanac";
d8> 
d8> externalizeString(string);
d8> 
d8> %DebugPrint(string);

DebugPrint: 0x7d6000da08d: [String] in OldSpace: #FrontendAlmanac
0x7d600000335: [Map] in ReadOnlySpace
 - map: 0x07d6000004c5 <MetaMap (0x07d60000007d <null>)>
 - type: EXTERNAL_INTERNALIZED_ONE_BYTE_STRING_TYPE
 - instance size: 20
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - stable_map
 - non-extensible
 - back pointer: 0x07d600000061 <undefined>
 - prototype_validity cell: 0
 - instance descriptors (own) #0: 0x07d600000701 <DescriptorArray[0]>
 - prototype: 0x07d60000007d <null>
 - constructor: 0x07d60000007d <null>
 - dependent code: 0x07d6000006dd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

Сборка мусора

Строки, как и любые другие переменные, участвуют в процессе сборки мусора. Об этом я довольно подробно писал в статье Сборка мусора в V8, поэтому останавливаться на этом не буду. Гораздо интереснее, что сама StringTable тоже участвует в процессе. Если говорить точнее, любые трансформации и удаления строк в StringTable происходят во время Full GC. Для этого используется временная таблица StringForwardingTable, в которую во время сборки мусора попадают только актуальные строки. После чего ссылка на StringTable меняется на новую таблицу.

Заключение

Итак, мы познакомились с организацией строк внутри движка V8. Узнали больше про таблицу строк и разные типы самих строк.

Немного основных моментов и выводов.

  • Строки бывают однобайтные и двубайтные. Двубайтным требуется примерно в два раза больше памяти, так как каждый символ такой строки кодируется двумя байтами вне зависимости от того, является ли он символом ASCII или нет. Поэтому, если есть выбор, какую строку использовать, однобайтная в большинстве случаев будет предпочтительнее.
const myMap = {
  "Frontend Almanac": undefined, // однобайтный ключ
  "Frontend Альманах": undefined, // двубайтный ключ
}
  • Несмотря на указанное в спецификации число 2**53 - 1, фактическая длина строки на реальных системах гораздо ниже. В 32-разрядных системах V8 позволяет хранить строки длиной не более 2**28 - 16 символов, а в 64-разрядных — не более 2**29 - 24.
  • Seq, Cons и Sliced строки могут дублироваться в таблице строк.
const string1 = "FrontendAlmanac"; // создает интернализированную строку
const string2 = "FrontendAlmanac"; // не создает новую строку
const string3 = "Frontend" + "Almanac"; // создает дубликат строки
  • SlicedString не могут быть вложенными. Они всегда ссылаются на исходную интернализированную строку. Родитель жив, пока жива хотя бы одна его дочерняя SlicedString.
let parentString = "FrontendAlmanac"; // родительская строка
let slicedString1 = parentString.slice(1); // ссылается на parentString
let slicedString2 = slicedString1.slice(1); // тоже ссылается на parentString
let slicedString3 = slicedString2.slice(1); // и эта тоже

slicedString1 = undefind;
slicedString2 = undefind;
// parentString всё еще не собрана сборщиком мусора,
// так как пока жива slicedString3
  • В случае, если строка требует интернализации, но "на месте" этого сделать нельзя, например, если она Cons или Sliced, движок вычислит плоское значение, создаст новый интернализированный объект строки и сконвертирует ссылку на эту интернализированную версию.
const string = "Frontend" + "Almanac"; // ConsString

const obj = {};
obj[string]; // ThinString

// строка больше не ConsString, теперь она ThinString и ссылается на
// плоское интернализированное значение в StringTable
  • Предыдущий пример можно использовать для избавления от дубликатов строк в StringTable, как, например, предложено в статье про очистку строк.
const string1 = "FrontendAlmanac";
const string2 = "Frontend" + "Almanac"; // создает дубликат
const string3 = "Frontend" + "Almanac"; // создает дубликат

const obj = {};
obj[string2]; // ThinSting
obj[string3]; // ThinString

// Теперь string2 и string3 больше не ConsString и ссылаются
// свою интернализированную версию, в данном случае на string1,
// которая уже присутствует в таблице строк.

Я описал только некоторые случаи работы со строками. Формата статьи не хватит, чтобы покрыть все возможные особенности и частные случаи. Поэтому приглашаю всех желающих описать свои случаи и задать вопросы в комментариях в моём телеграм-канале.


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

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

English version: https://blog.frontend-almanac.com/v8-strings