Для длинной строки из 512000000 символов требуется ~ 1 МБ памяти

Максимальная длина строки JavaScript

Согласно спецификации ECMAScript строка не может содержать более 9 007 199 254 740 991 (2⁵³-1) символа. Очевидно, что достижимая максимальная длина также зависит от мощности компьютера. Также браузеры могут устанавливать свои ограничения. Максимальная длина строки, установленная в исходном коде Chrome, составляет 536,8 миллиона символов. Чтобы упростить пример кода, в этом посте я экспериментирую с немного более короткой строкой длиной 512 миллионов символов.

Сходство между строками и объектами

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

Как хранятся результаты конкатенации

В зависимости от характеристик и происхождения в движке JavaScript строки могут быть представлены как экземпляры нескольких классов C ++. Строки, полученные путем конкатенации, представлены специализированным классом ConsString. Экземпляр ConsString содержит пару ссылок на исходные строки и занимает всего 20 байт памяти. Две ссылки могут указывать на строковые значения, а также на любой внутренний тип, представляющий строку. Например, когда инструкция

let d = a + b + c;

выполняется, переменной d присваивается ссылка на ConsString ((_5 _, _ 6 _), _ 7_), в которой одна ссылка указывает на _8 _ (_ 9 _, _ 10_), а вторая ссылка указывает на значение переменной c. Независимо от исходных строк, только 40 дополнительных байтов используются для хранения значения, присвоенного переменной d.

Хранение длинной строки 512 миллионов символов в ~ 1 МБ

Чтобы продемонстрировать, что длина строки может быть намного больше, чем объем памяти, занимаемый строкой, я создам строку длиной 512 миллионов символов, используя функцию double(str, times):

export function double(str, times) {
    for (let i = 0; i < times; i++) {
        str = str + str;
    }
    return str;
}

Функция сначала удваивает полученную строку, а затем продолжает удваивать результат конкатенации указанное количество раз.

Я измеряю используемую память с помощью функции printMemory(label), которую я подробно описал в предыдущем посте. Вкратце, функция основана на новом методе performance.measureUserAgentSpecificMemory(), который ожидает завершения сборки мусора, а затем точно записывает использованную память. По умолчанию метод запускает сборку мусора только в том случае, если этого не происходит в течение 20 секунд. Таким образом, измерения будут довольно медленными, если не использовать запуск Chrome с дополнительным флагом, разрешающим measureUserAgentSpecificMemory() принудительно выполнить немедленную сборку мусора.

Я удваиваю строку длиной 1 миллион символов, которая занимает 1 МБ памяти. Строка генерируется функцией makeString(mbs):

function makeString(mbs) {
    let str = 'X'.repeat(mbs * MB);
    str[0];
    return str;
}

На первый взгляд бессмысленный оператор str[0]; фактически превращает ConsString, созданный 'X'.repeat(mbs * MB);, в непрерывную последовательность символов. ConsString имеет оптимальную структуру, аналогичную дереву, показанному на верхнем рисунке. Перед сведением корень ConsString и его дочерние элементы ссылаются в общей сложности на ~ 17 различных ConsString и, таким образом, занимают менее 1 КБ.

В основном коде используются три функции, описанные выше:

printMemory('baseline')
    .then(() => {
      a = makeString(1);
      console.log('a', a.length);
      return printMemory('a = makeString(1)');
    })
    .then(() => {
      b = double(a, 9);
      console.log('b', b.length);
      return printMemory('b = double(a,9)');
    })
    .then(() => {
      a = null;
      return printMemory('a = null');
    })
    .then(() => {
      b = null;
      return printMemory('b = null');
    })
    .then(() => {
      plot(getHistory());
    });

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

  • Назначает строку размером 1 МБ переменной a и подтверждает в консоли, что длина a равна 1 000 000. Ожидается, что объем используемой памяти будет на 1 МБ больше, чем базовый уровень из предыдущего измерения.
  • Удваивает a девять раз, чтобы результирующая строка была в 2⁹ раза длиннее, чем a, присваивает полученную строку переменной b и подтверждает в консоли, что длина b равна 512 000 000. Используемая память не должна сильно отличаться от предыдущего измерения, потому что девять созданных ConsStrings занимают дополнительно 180 байтов.
  • Устанавливает a в null. Объем используемой памяти при этом не должен сильно измениться.
  • Устанавливает b в null. Исходная строка, присвоенная a, собирается сборщиком мусора. Используемое значение памяти должно вернуться к базовому уровню.

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

Вывод в консоли подтверждает, что даже несмотря на то, что размер используемой памяти не сильно изменился, длина строки, присвоенной b, действительно составляет 512000000 символов:

Вы можете запустить код на образце веб-сайта https://longstring.onrender.com/. Но наберитесь терпения, потому что, как я разъяснил выше и ранее, по умолчанию измерение памяти занимает до 20 секунд.

В следующих постах я исследую возможности оптимизации использования памяти во время построения DOM. Например, знаете ли вы, какие из них требуют меньше памяти - методы, принимающие HTML, такие как innerHTML, или методы, такие как appendChild(), ожидающие узлов? Фактически, это зависит от того, как вы их используете.

Образец кода можно скачать с https://github.com/marianc000/longString.