合理使用构造函数对性能带来的优化

本文探讨了JavaScript引擎V8中的HiddenClasses概念及其对性能的影响。通过实例展示了如何通过合理构造对象来避免不必要的HiddenClasses创建,进而提高代码执行效率。

如果你非常注重性能,那么下面的代码可能对你很重要。

function Article() { 
	this.title = 'Inauguration Ceremony Features Kazoo Band'; 
}
let a1 = new Article();
let a2 = new Article();

构造函数跟性能有什么关系呢?这就要从js引擎(Chrome V8)的工作原理说起了——


使用隐藏类HiddenClasses

运行期间,V8会将创建的对象与隐藏类关联起来,以跟踪它们的属性特征。能够共享相同隐藏类的对象性能会更好,V8会针对这种情况进行优化 ——《Javascript高级程序设计》

什么是隐藏类?

This HiddenClass stores meta information about an object, including the number of properties on the object and a reference to the object’s prototype. HiddenClasses are conceptually similar to classes in typical object-oriented programming languages. However, in a prototype-based language such as JavaScript it is generally not possible to know classes upfront. Hence, in this case V8, HiddenClasses are created on the fly and updated dynamically as objects change. HiddenClasses serve as an identifier for the shape of an object and as such a very important ingredient for V8’s optimizing compiler and inline caches. The optimizing compiler for instance can directly inline property accesses if it can ensure a compatible objects structure through the HiddenClass —— v8 docs

HiddenClass 存储了一个对象的元数据,包括对象和对象引用原型的数量。HiddenClasses 在典型的面向对象的编程语言的概念中和“类”类似。然而,在像 JavaScript 这样的基于原型的编程语言中,一般不可能预先知道类。因此,在这种情况下,在 V8 引擎中,HiddenClasses 创建和更新属性的动态变化。HiddenClasses 作为一个对象模型的标识,并且是 V8 引擎优化编译器和内联缓存的一个非常重要的因素。通过 HiddenClass 可以保持一个兼容的对象结构,这样的话实例可以直接使用内联的属性。

HiddenClasses

关于 HiddenClasses 的基本假设是对象具有相同的结构,例如,相同的顺序对应相同的属性,则共用相同的 HiddenClass。当我们给一个对象添加属性或删除属性时,将产生新的HiddenClasses:

var o = {}
o.a = 'foo'
o.b = 'bar'
o.c = 'baz'

steps

所以,实际开发中,应尽量做到在构造函数中全量覆盖所有可能出现的属性。

function MyObject(valueA,valueB,valueC) {
	this.a = valueA
	this.b = valueB
	this.c = valueC
}

let o1 = new MyObject('foo','bar','baz')
let o2 = new MyObject('foo1','bar1','baz1')

//good,use one of the same HiddenClass

同时,避免出现先创建后添加

function MyObject(value) {
	this.a = value
}

let o = new MyObject('foo')
o.b = 'newValue' //bad,create new HiddenClasses

先创建后删除

function MyObject(valueA,valueB) {
	this.a = valueA
	this.b = valueB
}

let o = new MyObject('foo','bar')
delete o.b //bad,create new HiddenClasses

顺序不同

function MyObject(valueA,valueB) {
	this.a = valueA
	this.b = valueB
}
function AnotherObject(valueA,valueB) {
	this.b = valueB
	this.a = valueA
}
let o = new MyObject('foo','bar')
let o2 = new AnotherObject('foo2','bar2')
//bad,because of the different sequence,o and o2 use different HiddenClasses

内联缓存Inline Caches(ICs)

同时,V8还使用内联缓存,借鉴某些汇编语言内存偏移量的机制,优化对象属性的读取、存储速度。

何为偏移量?

偏移量

通俗来说就是:把整个内存地址分为若干段,每段都由一些存储单元构成,叫做内存段。内存段上的某个存储单元距离段首的距离,就叫做偏移量。

补充:段地址: cpu访问存储器时,地址寄存器所能访问的存储空间达不到地址总线所提供的范围,所以针对这种情况,就把内存地址分为若干段,用段地址(也叫逻辑地址,并不真实存在)表示各个内存段。实际地址: 也叫物理地址,内存中的内存单元实际地址。

优化原理

But where are these property attributes stored in memory? Should we store them as part of the JSObject? If we assume that we’ll be seeing more objects with this shape later, then it’s wasteful to store the full dictionary containing the property names and attributes on the JSObject itself, as the property names are repeated for all objects with the same shape. That’s a lot of duplication and unnecessarily memory usage. As an optimization, engines store the Shape of the object separately.

实际上在JS引擎中对象的属性名和属性值是分别存储的,属性值本身被按顺序保存在对象中,而属性名则建立一个列表(Shape),存储每个属性名的“偏移量(offset)”和其他描述符属性。

在这里插入图片描述

如果一个对象在运行时增加了新的属性,那么这个属性名单会过渡到一个新的Shape(只包含了新添加的属性)并链接回原Shape(原文中称为“过渡链”,transition chains),这样访问属性时如果最新的属性列表中没有找到,可以回溯到上一个列表去检索。

在这里插入图片描述

因为存在不同的对象有相同的属性名称列表而重用Shape,当它们发生不同改变会分别过渡到各自的新Shape,形成分叉结构(transition tree)。

在这里插入图片描述

但是如果频繁扩展对象使得Shape链非常长怎么办呢?引擎内部会针对这样的情况再整理一张表(ShapeTable),把所有属性名都列出来然后分别链接至它们所属的Shape…这看起来还是比较繁琐,但都是为了不要浪费“已经做过的工作”,使保留有用的检索信息——Inline Caches更加方便。

内联缓存与隐藏类结合

前面说过,隐藏类作为一个对象模型的标识,也是 V8 引擎优化编译器和内联缓存的一个非常重要的因素,存储对象属性名的列表(Shape)就保存在隐藏类里。

具有相似结构的对象,被同一个隐藏类所关联起来,在读取属性时,不再使用传统的HashTable方式,而改用偏移量机制直接去读取地址;对于高频(Hot)属性,还可以通过Inline Caches的过渡链(transition chains)减少检索次数,从而带来性能提升。


效果

使用如下两段代码,在相同环境下进行比对测试

//bad
function MyObject() {
	this.a = 0;
}

let o1 = new MyObject();
o1.b = 0 // 生成新的HiddenClass,且之前没有访问过,无内联缓存可用
console.time();
for (let i = 0; i < 1e7; i++) {
	o1.b = i;
}
console.timeEnd();
//good
function MyObject() {
	this.a = 0;
}

let o1 = new MyObject();
console.time();
for (let i = 0; i < 1e7; i++) {
	o1.a = i;
}
console.timeEnd();

测试结果如下(单位:ms):

badgood
25.03198242187518.5478515625
27.844970717.59179688
27.3569335918.62817383
27.460937520.23803711
27.213423418.65380859
26.1999511719.09399414
28.3391113318.97387695
27.3698730518.72119141
27.1879882819.63916016
27.3107910218.92382813
合计271.3159625189.0117188

如有错误,欢迎指正!


参考:


https://v8.dev/blog/fast-properties

https://mathiasbynens.be/notes/shapes-ics

https://blog.youkuaiyun.com/Mart1nn/article/details/81058158

### 移动构造函数与普通构造函数性能差异 移动构造函数和普通构造函数性能上的差异主要体现在资源管理的效率上。以下从多个方面详细分析两者之间的性能差异。 #### 1. 资源转移 vs 深拷贝 普通构造函数(如拷贝构造函数)通常需要对对象的内容进行深拷贝,这意味着会分配新的内存并复制数据[^3]。对于包含大量数据或复杂资源的对象(例如`std::vector`、`std::string`等),深拷贝会导致显著的性能开销。 ```cpp class MyClass { std::vector<int> data; public: MyClass(const MyClass &other) : data(other.data) {} // 深拷贝 }; ``` 相比之下,移动构造函数通过直接转移资源的所有权来避免深拷贝操作。这种方式仅涉及指针或引用的交换,而不涉及实际的数据复制[^4]。 ```cpp class MyClass { std::vector<int> data; public: MyClass(MyClass &&other) noexcept : data(std::move(other.data)) {} // 资源转移 }; ``` #### 2. 内存分配与释放 普通构造函数在处理大型对象时,通常需要进行多次内存分配和释放操作。例如,在拷贝构造函数中,目标对象需要为自身分配新内存,并将源对象的数据逐个复制到新内存中[^3]。 ```cpp MyClass copyObj = originalObj; // 拷贝构造函数调用,可能涉及内存分配和数据复制 ``` 而移动构造函数通过直接接管源对象的资源,避免了额外的内存分配和释放操作,从而显著提升了性能[^4]。 ```cpp MyClass movedObj = std::move(originalObj); // 移动构造函数调用,避免深拷贝 ``` #### 3. 临时对象的优化使用临时对象初始化新对象时,编译器会优先调用移动构造函数,而不是拷贝构造函数。如果类未定义移动构造函数,编译器可能会退而求其次,调用拷贝构造函数[^1]。这种情况下,性能会受到显著影响。 ```cpp MyClass createObject() { return MyClass(); } int main() { MyClass obj = createObject(); // 编译器优先调用移动构造函数 return 0; } ``` 上述代码中,`createObject()`返回的临时对象通过移动构造函数被转移到`obj`,避免了不必要的深拷贝操作。 #### 4. 异常安全性与性能 移动构造函数通常标记为`noexcept`,这不仅表明其不会抛出异常,还允许编译器进行更多优化[^2]。例如,在标准库容器(如`std::vector`)中,`noexcept`的移动构造函数可以显著提升性能,因为编译器可以在某些场景下选择更高效的算法。 ```cpp class MyClass { std::vector<int> data; public: MyClass(MyClass &&other) noexcept : data(std::move(other.data)) {} }; ``` #### 性能比较总结 | 特性 | 普通构造函数 | 移动构造函数 | |---------------------|----------------------------------|----------------------------------| | 数据复制 | 深拷贝,涉及数据逐个复制 | 资源转移,不涉及数据复制 | | 内存分配 | 需要为新对象分配内存 | 不需要额外内存分配 | | 临时对象优化 | 无法直接优化临时对象 | 可以直接优化临时对象 | | 异常安全性 | 可能抛出异常 | 通常标记为`noexcept`,不抛出异常 | ### 示例代码 以下代码展示了移动构造函数与普通构造函数性能差异: ```cpp #include <iostream> #include <vector> class MyClass { std::vector<int> data; public: MyClass(size_t size) : data(size, 42) {} // 普通构造函数 MyClass(const MyClass &other) : data(other.data) { std::cout << "Copy constructor called\n"; } // 拷贝构造函数 MyClass(MyClass &&other) noexcept : data(std::move(other.data)) { std::cout << "Move constructor called\n"; } // 移动构造函数 }; MyClass createObject() { return MyClass(1000000); } int main() { MyClass obj1 = createObject(); // 调用移动构造函数 MyClass obj2 = obj1; // 调用拷贝构造函数 return 0; } ``` #### 输出结果 ``` Move constructor called Copy constructor called ``` 从输出可以看出,`obj1`的初始化调用了移动构造函数,而`obj2`的初始化调用了拷贝构造函数。移动构造函数显著减少了资源复制的开销。 ### 结论 移动构造函数通过资源转移而非深拷贝的方式,显著提升了程序的性能,特别是在处理大型对象或临时对象时。合理实现移动构造函数和移动赋值运算符是现代C++编程中优化性能的重要手段[^2]。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值