Привет, Хабаровчане! Во второй статье, хочу поделиться наблюдениями из документации V8 и немного нудной информацией для многих :)

Что есть Объект?

Казалось бы, объект в JS — это просто набор ключ-значений. Но для движка V8 это структура с жёсткой схемой: каждый объект имеет Hidden Class ( или Map), который описывает:

  • Какие свойства есть у объекта;

  • Их порядок;

  • Смещения в памяти для быстрого доступа.

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

Как же формируются Map и почему важен порядок?

Допустим у нас есть следующий код:

function createUser() {
  const user = {};
  user.name = "Victor";
  user.age = 30;
  return user;
}

const obj1 = createUser();

Внутри V8 создаётся цепочка Map transitions:

Empty → { name } → { name, age }

Все объекты, созданные этой функцией, будут иметь одинаковый Map.

А теперь изменим порядок:

function createUser() {
  const user = {};
  user.age = 30; // Поместили свойство age выше
  user.name = "Victor";
  return user;
}

const obj2 = createUser();

Теперь цепочка выглядит так:

Empty → { age } → { age, name }

Теперь obj1 и obj2 — это разные структуры для V8, что ломает оптимизацию.

Можно протестировать через d8. d8 - shell, позволяющий работать с V8 как с интерпретатором или компилятором JavaScript. Про него написано в документации. Проходим круги ада, собрав его и запускаем для теста нашего кода:

function User1() {
  this.name = "Victor";
  this.age = 30;
}

function User2() {
  this.age = 30;
  this.name = "Victor";
}

const obj1 = new User1();
const obj2 = new User2();

%DebugPrint(obj1);
%DebugPrint(obj2);

В консоли мы наблюдаем следующее:

DebugPrint: 0x7f8a9c1234: [JS_OBJECT_TYPE]
 - map: 0x5e3b7d84f2 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x7f8a9a77bc
 - properties: 0x3d4f8a1b92
 - elements: 0x6b7d2c9e18
 - name: "Victor"
 - age: 30

DebugPrint: 0x7f8a9c5678: [JS_OBJECT_TYPE]
 - map: 0x4ad2f38c91 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x7f8a9a77bc
 - properties: 0x5a6c1e8b77
 - elements: 0x1c9e2a3f54
 - age: 30
 - name: "Victor"

Как мы видим map у объектов разные (0x5e3b7d84f2 и 0x4ad2f38c91) и порядок свойств отличается. Это и есть разные Hidden Classes (Map)

Почему это так важно?

V8 использует JIT и Inline Caching. Если функция видит только один Map — то мономофризм IC (когда функция видит только один тип объекта). Если 2–3 — полиморфизм IC ( когда функция встречает два-три разных типа объектов и доступ уже чуть медленнее). Если выше - мега-морфизм IC (когда функция сталкивается со множеством разных типов объектов).

Вот еще Бенчмарк кода:

function makeObjOrdered() {
  return { a: 1, b: 2, c: 3 };
}

function makeObjRandom() {
  const o = {};
  if (Math.random() > 0.5) {
    o.a = 1; o.b = 2; o.c = 3;
  } else {
    o.c = 3; o.a = 1; o.b = 2;
  }
  return o;
}

console.time("ordered");
for (let i = 0; i < 1e7; i++) {
  const o = makeObjOrdered();
  void o.a;
}
console.timeEnd("ordered");

console.time("random");
for (let i = 0; i < 1e7; i++) {
  const o = makeObjRandom();
  void o.a;
}
console.timeEnd("random");

Его можно запустить с помощью Node.js и мы увидим следующее:

ordered: 5.959ms
random: 156.761ms

Неплохая такая разница, правда? Это всё из за мега-морфного доступа IC.

UPD

Как заметили в комментариях коллеги по цеху, моё упущение было, не сказав и не показав тесты на более равных условиях. Поэтому исправляюсь.

При строгом контроле структур объектов и минимальной вариативности разница по времени небольшая (10–15%). Но даже такая разница показывает, что разные Map влияют на производительность. На практиrе с более хаотичными структурами и большим разбросом разница будет гораздо сильнее (как в исходном примере).

function makeObjOrdered() {
    return (Math.random() > 0.5) ? {a: 1, b: 2, c: 3} : {a: 1, b: 2, c: 3}
}

function makeObjRandom() {
    return (Math.random() > 0.5) ? {a: 1, b: 2, c: 3} : {o: 3, a: 1, b: 2}
}

const arr1 = []
console.time("ordered")
for (let i = 0; i < 1e7; i++) {
    const o = makeObjOrdered()
    arr1.push(o.a)
}
console.timeEnd("ordered")
console.log(arr1)
   
const arr2 = []
console.time("random")
for (let i = 0; i < 1e7; i++) {
    const o = makeObjRandom()
    arr2.push(o.a)
}
console.timeEnd("random")
console.log(arr2)

Вывод в консоль:

ordered: 202.108ms
random: 229.607ms

Заключение

Надеюсь вы дочитали до конца эту небольшую и нудную статью. Порядок свойств в объекте — это важный фактор оптимизации в V8. Если структура стабильна, движок использует быстрый monomorphic IC, если нет — то мега-морфный режим. Так что не поскупитесь и нормализуйте данные на бекенде, используйте мидлвары для приведения данных в единую структуру на фронтенде (дабы обезопасить себя), использовать TypeScript,ну и кэшировать объекты, чтобы не создавать новых с разными Maps без необходимости. Буду рад отзывам и комментариям :)

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


  1. SkaJazz
    10.08.2025 16:00

    ordered: 5.959ms random: 156.761ms

    Неплохая такая разница, правда? Это всё из за мега-морфного доступа IC.

    Сорри за странный вопрос, а эта разница точно не из‑за работы Math.random() при каждом вызове? У меня без всяких объектов примерно такое же соотношение по времени выполнения показала пара функций — одна, которая возвращает x и другая, которая возвращает в половине случаев x, а в половине y


    1. Meybiz Автор
      10.08.2025 16:00

      В нашем случае влияние Math.random() на замеры минимально, потому что сама функция вызывается внутри цикла, и мы измеряем именно время выполнения всего цикла.

      Мы видим, что функция с разным порядком свойств (и разными Map) работает значительно медленнее, а не с небольшой разницей в пару пунктов. Это говорит о том, что разница связана именно с перестройкой скрытых классов и ухудшением IC, а не с накладными расходами


      1. Shannon
        10.08.2025 16:00

        Мы видим, что функция с разным порядком свойств (и разными Map) работает значительно медленнее, а не с небольшой разницей в пару пунктов. Это говорит о том, что разница связана именно с перестройкой скрытых классов и ухудшением IC, а не с накладными расходами

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

        function makeObjOrdered() {
            return (Math.random() > 0.5) ? {a: 1, b: 2, c: 3} : {a: 1, b: 2, c: 3}
        }
        
        function makeObjRandom() {
            return (Math.random() > 0.5) ? {a: 1, b: 2, c: 3} : {o: 3, a: 1, b: 2}
        }
        
        const arr1 = []
        console.time("ordered")
        for (let i = 0; i < 1e7; i++) {
            const o = makeObjOrdered()
            arr1.push(o.a)
        }
        console.timeEnd("ordered")
        console.log(arr1)
           
        const arr2 = []
        console.time("random")
        for (let i = 0; i < 1e7; i++) {
            const o = makeObjRandom()
            arr2.push(o.a)
        }
        console.timeEnd("random")
        console.log(arr2)
        

        ordered: 202.108ms
        random: 229.607ms


        1. Meybiz Автор
          10.08.2025 16:00

          Спасибо за уточнение. Да, вы правы. Однако именно эта 15% просадка — это и есть цена за разные Hidden Classes и полиморфный IC, которая становится заметна при большом количестве вызовов. В моём примере была гораздо сильнее — из-за мега-морфного кеша и деоптимизаций. Это я и хотел показать, почему важно держать объекты в максимально унифицированой форме. Наверное моё упущение, что я не показал пример с более равными условиями


          1. Shannon
            10.08.2025 16:00

            Однако именно эта 15% просадка — это и есть цена за разные Hidden Classes и полиморфный IC, которая становится заметна при большом количестве вызовов

            10 миллионов вызовов, куда уж больше. И в реальном проекте не будет даже 15% разницы, как только пройдет jit-оптимизатор, разница будет смазана до нескольких процентов, которые будут не важны, так как основным тормозом будет i/o, который съест все эти микрооптимизации. Эти микрооптимизации работают только в таких бенчмарках с гигантскими циклами.

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

            В моём примере была гораздо сильнее — из-за мега-морфного кеша и деоптимизаций.

            В вашем случае разница потому, что в 1 случае оптимизатор просто выкинул цикл вообще, поэтому и получилось 5 ms.

            Не проблема создать пример, когда ordered будет куда медленнее, чем random:

            Код 1
            function makeObjOrdered() {
              return { a: 1, b: 2, c: 3 };
            }
            
            function makeObjRandom() {
              return Math.random() > 0.5 ? { a: 1, b: 2, c: 3 } : {o: 3, a: 1, b: 2}
            }
            
            let arr1 = []
            console.time("ordered");
            for (let i = 0; i < 1e7; i++) {
              const o = makeObjOrdered();
              arr1.push(o)
            }
            console.timeEnd("ordered");
            console.log(arr1.slice(0,10))
            
            let arr2 = []
            console.time("random");
            for (let i = 0; i < 1e7; i++) {
              const o = makeObjRandom();
              arr2.push(o)
            }
            console.timeEnd("random");
            console.log(arr2.slice(0,10))
            

            ordered: 411.947ms
            random: 251.125ms

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

            Чтобы увидеть работу более тяжелого jit-оптимизатора, который подключается после прогона функции несколько раз, прогоните 3-4 цикла, и у вас уже не будет никакой разницы в итоге, кроме погрешности:

            Код 2
            function makeObjOrdered() {
                return (Math.random() > 0.5) ? {a: 1, b: 2, c: 3} : {a: 1, b: 2, c: 3}
            }
            
            function makeObjRandom() {
                return (Math.random() > 0.5) ? {a: 1, b: 2, c: 3} : {o: 3, a: 1, b: 2}
            }
            
            function getA(obj) {
                return obj.a
            }
            
            for(let i = 0; i < 3; i+=1) {
                const arr1 = []
                console.time("ordered")
                for (let i = 0; i < 1e7; i++) {
                    const o = makeObjOrdered()
                    arr1.push(getA(o))
                }
                console.timeEnd("ordered")
                console.log(arr1)
                
                const arr2 = []
                console.time("random")
                for (let i = 0; i < 1e7; i++) {
                    const o = makeObjRandom()
                    arr2.push(getA(o))
                }
                console.timeEnd("random")
                console.log(arr2)
            }
            

            ordered: 230.519ms
            random: 236.495ms


            1. cpud47
              10.08.2025 16:00

              Не, ну бенчмаркать нужно очень аккуратно. Как минимум, нужно делать бенчи в независимых окружениях: у Вас сначала оптимизатор специализирует getA на один архетип, а потом видит, что эвристика сломалась и переоптимизирует её - вот и дополнительные 6мс. Дальше вопрос в том, что сам код с циклом лучше тоже завернуть в функцию - чтобы сам цикл тоже оптимизировать. Плюс мб имеет смысл сначала заготовить массив объектов, а потом уже получать доступ к свойствам. А то инлайнер может чудеса творить.

              В целом, внезапно неплохой сайт для бенчей у Карловского сделан.


          1. aamonster
            10.08.2025 16:00

            "Уточнение"? Да вы мастер преуменьшений. Вашу статью по сути похоронили одним очевидным любому программисту вопросом, показав вашу некомпетентность в освещаемом вопросе, а вы называете это "уточнением"?!


  1. Politura
    10.08.2025 16:00

    function makeObjOrdered() {
      const o = {};
      if (Math.random() > 0.5) {
        o.a = 1; o.b = 2; o.c = 3;
      } else {
        o.a = 1; o.b = 2; o.c = 3;
      }
      return o;
    }
    ordered: 91.914ms
    random: 243.027ms

    Разница конечно есть, но не такая драмматичная.


  1. McRain
    10.08.2025 16:00

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


  1. Akuma
    10.08.2025 16:00

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

    Знать полезно разве что для общего развития, а применять не стоит (оно того не стоит).