背景

开发某JS应用时使用了一个较大的数据列表,在探究性能和内存过程中,观察到了反常的数据内存变化,从而引发了本文相关内容的研究。本文绝大部分对象设计和实现细节的内容和结论来自于V8的源码阅读、以及Chrome上的JS实验,如有错误欢迎指出纠正。

引子

假设有100,000*100的数据存储在一个JSON文件中,表达形式是一个含10万个对象的数组,其中每个对象有相同的100个属性,属性名和属性值非常简单,比如{“a0”:0, “a1″:0,”a2”:0…}。

JSON.parse加载此数据后,JS内存占用是42.6MB(所有Chrome内存汇报都已经过垃圾回收)。

此时,如果我们删除每一个元素中间的某个属性,如:

arr.forEach((item) => { delete item[`a0`]; }))

删除一个属性,大家以为内存有会变化吗?刚开始我以为数据量没变内存变化不会太大,然而JS堆内存飙升到324MB,内存却增加近8倍,为什么?

图片

你可能会发觉在这些特定的场景下,JS对象的存储结构发生了变化,事实确实如此。Chrome的内核是V8引擎,V8是如何设计JS对象,对象什么情况下会发生存储结构变化,如何避免和削弱负面影响,这是本文探讨的几个问题。

相关测试代码如下:

// 创建一个空数组
const data = [];

// 生成100个对象
for (let i = 0; i < 100000; i++) {
  // 创建一个空对象
  const obj = {};

  // 生成100个属性
  for (let j = 0; j < 100; j++) {
    // 属性名和属性值都是数字
    const propName = `a${j}`;
    const propValue = 0;
    obj[propName] = propValue;
  }

  // 将对象添加到数组中
  data.push(obj);
}

// 将数据转换为JSON字符串
const jsonString = JSON.stringify(data);

// 将JSON字符串写入文件
const fs = require('fs');
fs.writeFileSync('data.json', jsonString);
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button id="click">点击</button>
    <div>点击按钮执行 arr.forEach((item) => { delete item[`a0`]; })</div>
    <script>
      var xhr = new XMLHttpRequest();
      // 方便在Heap Snapshot观测
      function createObject(data) {
        this["json"] = data;
      }
      var obj = new createObject();
      xhr.onreadystatechange = function () {
        if (this.readyState == 4 && this.status == 200) {
          var data = JSON.parse(this.responseText);
          obj["json"] = data;
        }
      };
      xhr.open("GET", "data.json", true);
      xhr.send();

      const btn = document.getElementById("click");
      btn.addEventListener("click", () => {
        obj.json.forEach((item) => {
          delete item[`a0`];
        });
      });
    </script>
  </body>
</html>
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button id="click">点击</button>
    <div>点击按钮执行 arr.forEach((item) => { delete item[`a0`]; })</div>
    <script>
      var xhr = new XMLHttpRequest();
      // 方便在Heap Snapshot观测
      function createObject(data) {
        this["json"] = data;
      }
      var obj = new createObject();
      xhr.onreadystatechange = function () {
        if (this.readyState == 4 && this.status == 200) {
          var data = JSON.parse(this.responseText);
          obj["json"] = data;
        }
      };
      xhr.open("GET", "data.json", true);
      xhr.send();

      const btn = document.getElementById("click");
      btn.addEventListener("click", () => {
        obj.json.forEach((item) => {
          delete item[`a0`];
        });
      });
</script>
  </body>
</html>

 

JSObject基本结构

JSObject最少会有三个指针,分别指向HiddenClass,Properties store和Elements store,V8中的Map在一些文章中也被叫做hidden class,本文出现的所有Map均指hidden class,简单来说Map用于描述对象的结构数据的。这里引用V8官方文档中的一张图。

图片
V8 支持所谓的对象内属性(in-object properties),这些属性直接存储在对象本身上。这些是 V8 中可用的最快属性,因为它们无需任何间接即可访问。
对象内属性的数量由对象的初始大小预先确定,如果添加的属性数多于对象中的空间,则它们将存储在Properties store中,Properties store增加了一个间接级别,但可以独立增长。
在对象中的数字属性({1 : “a”, 2: “b”})称为排序属性,数字属性将存储在Elements store中,元素和属性存储在两个单独的数据结构中,这使得添加和访问属性或元素对于不同的使用模式更加高效。如图一个简单🌰:
图片
V8倾向于具有相同结构的对象共享相同Map,有很多对象具备相似属性结构,只是属性的数值不同,共享这些结构数据是一个节约存储的好方法,V8通过构建transition tree来实现这一点。
图片
每次添加新属性时,对象的 map 都会更改。V8 创建了一个将 map 链接在一起的transition tree。例如上图中将属性 “a” 添加到空对象时,V8 知道要采用哪个 map。此过渡树可确保以相同的顺序添加相同的属性时,最终会得到相同的最终 map。下面的示例显示,即使我们在两者之间添加简单的索引属性,我们也会遵循相同的转换树。
图片
但是,如果我们创建一个添加了不同属性的新对象,在本例中为属性 “d”,V8 会为新的 map 创建一个单独的分支。
图片
简单概述了 V8中对象的基本结构之后,下面让我们了解一下如何避免或消弱对象结构变化带来的负面影响👇👇👇。
如何避免或削弱对象结构变换带来的负面影响

先划重点:要拥有最高的性能,尽量让对象处于快速模式
看一段在jsPerf平台简单实验对比:
图片
这段测试代码很简单,声明两个对象fasetObject和slowObject,然后循环100次访问各自a、c两个属性。Ops/sec 表示测试结果以每秒钟执行测试代码的次数显示,这个数值是越大越好,可以看到图中fastObject比slowObject对象测试出来的Ops/sec大了很多。
要解释这个现象,我们就要先了解 V8 对于 JavaScript 对象的两种访问模式:
Fast Mode:将存储在线性属性存储中的属性定义为 “fast”。快速属性只需通过属性存储中的索引即可访问。要从属性名称到属性存储中的实际位置,必须查阅 map 上的描述符数组。
Dictionary Mode:字典模式也称为哈希表模式,V8 使用哈希表来存储对象的属性。
在这里给出上文中引子实验案例内存飙升的答案,内存飙升的原因就是Array中的10万个对象全部发生了fast到slow的模式转换,对象存储结构发生了变化。通过上面一段benchmark可以看出,fast与slow模式访问属性速度也有一定差距。
从fast模式到slow模式的转换,一般两种情况一是属性总数太多,二是删除属性(主要由删除非最后添加属性造成),有时候难以避免遇到慢对象,下面举几个慢对象转换为快对象的方法。
  1. 当对象被设置成为一个函数(或对象)的原型时会从Dictionary Mode优化成为Fast Mode
/ node --allow-natives-syntax xxx.js
function toFastProperties(o) {
  function A() {
   this.x = 'x'
  }
  A.prototype = o;
  const a = new A();
  function ic() {
    return typeof a.b;
  }
  ic();
  ic();
  return o;
}

const o = {a:1,b:2};

console.log(%HasFastProperties(o)); // true
delete o.a;
console.log(%HasFastProperties(o)); // false 
toFastProperties(o);
console.log(%HasFastProperties(o)); // true

 

在设置对象为函数原型后,又进行了实例化和两次属性查询,感兴趣的可以看下V8系列中的 lnline Caches 或其它有关的博文。

  1. 使用JSON.stringify和JSON.parse解析对象
const o = {};
for (let i = 0; i < 127; ++i) {
  o[`${i}i`] = i;
}
const json = JSON.stringify(o);
const o1 = JSON.parse(json);

// in-object属性数量越多我们能添加的快速属性就越多
console.log(%DebugPrint(o1));
console.log(%HasFastProperties(o1));

// 执行 node --allow-natives-syntax xx.js

通过测试发现此方法最多可以解析127个属性的快对象并能共享map。

  1. 非必要不使用Object.create(null)创建对象
const x = Object.create(null);console.log(%HasFastProperties(x)); // false
Object.create(null)创建出来的对象是Dictionary Mode,尽量不使用这种方式创建对象。
JS对象内存优化的解决方案

在实践中场景是一个目录,使用的数据结构是一个长数组(3000左右元素),每个元素对象有50个左右属性,其中有字符串、布尔值、嵌套数组等,下面介绍两种优化策略,并附上部分实验数据。贴一张优化前数组的内存图,每个元素占用1580字节。
图片
方法一:迁移慢对象到fast模式下
当一个对象被设为原型时,V8会对其进行优化,通过将对象设置为某种原型,能迁移慢对象到快速模式,在此模式下我们可以持续添加快速属性直到达到快属性数量的上限。通过此方法优化对象存储大小为220字节,相比优化前对象存储大小降低⬇️ 7倍。
图片
不过使用这种方法转换的对象属性不能保证共享map,同时由于被指定为原型,相比一般快速对象会略大一点点(部分相关引用对象新建),为了进一步降低数据内存,尽可能的让对象属性共享map。
方法二:使用JSON.stringify和JSON.parse解析对象
JSON.stringify/JSON.parse最多可以解析127个属性的快对象并能共享map, in-object属性数量越多我们能添加的快速属性就越多,实践中数组的每个元素50个属性,此方法可以适用,相关代码可见上文👆👆👆。
使用这种方法优化后对象存储大小为208字节,相比优化前对象存储大小降低⬇️ 7.5倍。
图片
小结

简单分析了V8中object的实现细节,对于 JavaScript 开发人员来说,V8其中许多内部决策并不直接可见,但它们解释了为什么某些代码模式比其他代码模式更快。更改属性或元素类型通常会导致 V8 创建不同的Map,这可能会导致类型污染,从而阻止 V8 生成最佳代码。
探讨了部分有效同构对象内存优化思路,虽然文章结合实际案例给出了两个内存优化手段,可能大部分js开发者实践中未必会遭遇此类问题,如有遇到在实践中仍需要针对具体情景进行设计。有多少个属性,数据在使用时有添加和删除的场景吗,添加时可能会添加多少,删除的规则是什么,理解对象结构和转换逻辑才是学习优化的根本。

参考资料

  • Fast properties in V8 · V8:https://v8.dev/blog/fast-properties
  • Elements kinds in V8 · V8:https://v8.dev/blog/elements-kinds
  • Explaining JavaScript VMs in JavaScript – Inline Caches:https://mrale.ph/blog/2012/06/03/explaining-js-vms-in-js-inline-caches.html
  • Understanding the size of an object in Chrome/V8:https://www.mattzeunert.com/2017/03/29/v8-object-size.html
  • A tour of V8: object representation:https://jayconrod.com/posts/52/a-tour-of-v8-object-representation
扫码领红包

微信赞赏支付宝扫码领红包

发表回复

后才能评论