V8 最佳实践:从 JavaScript 变量使用姿势说起
在弱类型语言 JavaScript 中,变量上能有多少优化窍门?本文从最基础的变量类型说起,带你深入 V8 底层类型变换与优化机制。真正的老司机,一行代码可见一斑。以后你可以说,我写的代码,连变量声明都比你快…
本文参考 V8 开发者博客中关于 React 性能断崖的一篇分析,细节很多,整理一下与大家分享。
JavaScript 作为弱类型语言,我们可以对一个变量赋予任意类型值,但即使如此,对于各类 JavaScript 值,V8 仍需要对不同类型值应用特定的内存表示方式。充分了解底层原理后,我们甚至可以从变量使用方式上入手,写出更加优雅、符合引擎行为的代码。
先从为人熟知的 JavaScript 8大变量类型讲起。
JavaScript 变量类型
八大变量类型
按照当前 ECMAScript 规范,JavaScript 中值的类型共有以下八种:Number
, String
, Symbol
, BigInt
, Boolean
, Undefined
, Null
, Object
。
这些类型值都可以通过 typeof
操作符监测到,除了一个例外:
typeof 42;// → 'number'typeof 'foo';// → 'string'typeof Symbol('bar');// → 'symbol'typeof 42n;// → 'bigint'typeof true;// → 'boolean'typeof undefined;// → 'undefined'typeof null;// → 'object' 注意这里typeof { x: 42 };// → 'object'
为什么 typeof null === 'object'
在规范中, Null
虽然作为 null
本身的类型,但 typeofnull
却返回 object
。想知道背后的设计原理,首先要了解 JavaScript 中的一个定义,在 JavaScript 中所有类型集合都被分为两个组:
- objects(引用类型,比如
Object
的类型) - primitives(原始类型,所有非引用类型的值)
在定义中, null
意为 noobjectvalue
,而 undefined
意为 novalue
。
按照上图构想,JavaScript 的创始人 Brendan Eich 在设计之初就将属于 objects
和 null
类型集合下的所有类型值统一返回了 'object'
类型。
事实上,这是当时受到了 Java 的影响。在 Java 中, null
从来就不是一个单独的类型,它代表的是所有引用类型的默认值。这就是为什么尽管规范中规定了 null
有自己单独的 Null
类型,而 typeofnull
仍旧返回 'object'
的原因。
值的内存表示方式
JavaScript 引擎必须能够在内存中表示任意值,而需要注意的是,同一类型值其实也会存在不同的内存表示方式。
比如值 42
在 JavaScript 中的类型是 number
:
typeof 42;// → 'number'
而在内存上有许多种方式可以用来表示 42
:
representation |
bits |
---|---|
8位二进制补码 |
00101001 |
32位二进制补码 |
00000000000000000000000000101010 |
二进制编码的十进数码 |
01000010 |
32位 IEEE-754 单精度浮点 |
01000010001010000000000000000000 |
64位 IEEE-754 双精度浮点 |
0100000001000101000000000000000000000000000000000000000000000000 |
ECMAScript 标准约定 number
数字需要被当成 64 位双精度浮点数处理,但事实上,一直使用 64 位去存储任何数字实际是非常低效的,所以 JavaScript 引擎并不总会使用 64 位去存储数字,引擎在内部可以采用其他内存表示方式(如 32 位),只要保证数字外部所有能被监测到的特性对齐 64 位的表现就行。
例如我们知道,ECMAScript 中的数组合法索引范围在 [0,2³²−2]
:
array[0]; // Smallest possible array index.array[42];array[2**32-2]; // Greatest possible array index.
通过下标索引访问数组元素时,V8 会使用 32 位的方式去存储这些合法范围的下标数字,这是最佳的内存表示方式。用 64 位去存储数组下标会导致极大浪费,每次访问数组元素时引擎都需要不断将 Float64 转换为二进制补码,此时若使用 32 位去存储下标则能省下一半的转换时间。
32 位二进制补码表示法不仅仅应用在数组读写操作中,所有 [0,2³²−2]
内的数字都会优先使用 32 位的方式去存储,而一般来说,处理器处理整型运算会比处理浮点型运算快得多,这就是为什么在下面例子里,第一个循环的执行效率比第二个循环的执行效率快上将近两倍:
for (let i = 0; i < 100000000; ++i) { // fast → 77ms}
for (let i = 0.1; i < 100000000.1; ++i) { // slow → 122ms}
对运算符也是一样,下面例子中 mod 操作符的执行性能取决于两个操作数是否为整型:
const remainder = value % divisor;// Fast: 如果`value`和`divisor`都是被当成整型存储// slow: 其他情况
值得一提的是,针对 mod 运算,当 divisor
的值是 2 的幂时,V8 为这种情况添加了额外的快捷处理路径。
另外,整型值虽然能用32位去存储,但是整型值之间的运算结果仍有可能产生浮点型值,并且 ECMAScript 标准本身是建立在 64 位的基础上的,因此规定了运算结果也必须符合 64 位浮点的表现。这个情况下,JS 引擎需要特别确保以下例子结果的正确性:
// Float64 的整数安全范围是 53 位,超过这个范围数值会失去精度2**53 === 2**53+1;// → true
// Float64 支持负零,所以 -1 * 0 必须等于 -0,但是在 32 位二进制补码中无法表示出 -0-1*0 === -0;// → true
// Float64 有无穷值,可以通过和 0 相除得出1/0 === Infinity;// → true-1/0 === -Infinity;// → true
// Float64 有 NaN0/0 === NaN;
Smi、HeapNumber
针对 31 位有符号位范围内的整型数字,V8 为其定义了一种特殊的表示法 Smi
,其他任何不属于 Smi
的数据都被定义为 HeapObject
, HeapObject
代表着内存的实体地址。
对于数字而言,非 Smi
范围内的数字被定义为 HeapNumber
, HeapNumber
是一种特殊的 HeadObject
。
-Infinity // HeapNumber-(2**30)-1 // HeapNumber -(2**30) // Smi -42 // Smi -0 // HeapNumber 0 // Smi 4.2 // HeapNumber 42 // Smi 2**30-1 // Smi 2**30 // HeapNumber Infinity // HeapNumber NaN // HeapNumber
Smi
范围的整型数在 JavaScript 程序中非常常用,因此 V8 针对 Smi
启用了一个特殊优化:当使用 Smi
内的数字时,引擎不需要为其分配专门的内存实体,并会启用快速整型操作。
通过以上讨论我们可以知道,即使值拥有相同的 JavaScript 类型,引擎内部依然可以使用不同的内存表示方式去达到优化的手段。
Smi vs HeapNumber vs MutableHeapNumber
Smi
与 HeapNumber
是如何运作的呢?假设我们有一个对象:
const o = { x: 42, // Smi y: 4.2, // HeapNumber};
o.x
中的 42
会被当成 Smi
直接存储在对象本身,而 o.y
中的 4.2
需要额外开辟一个内存实体存放,并将 o.y
的对象指针指向该内存实体。
此时,当我们运行以下代码片段:
o.x += 10;// → o.x is now 52o.y += 1;// → o.y is now 5.2
在这个情况下, o.x
的值会被原地更新,因为新的值 52
仍在 Smi
范围中。而 HeapNumber
是不可变的,当我们改变 o.y
的值为 5.2
时,V8 需要再开辟一个新的内存实体给到 o.y
引用。
借助 HeapNumber
不可变的特性,V8 可以启用一些手段,如以下代码,我们将 o.y
的值引用赋予 o.x
:
o.x = o.y;// → o.x is now 5.2
在这样的情况下,V8 不需要再为 o.x
新的值 5.2
去开辟一块内存实体,而是直接使用同一内存引用。
在具有以上优点的同时, HeapNumber
不可变的特性也有一个缺陷,如果我们需要频繁更新 HeapNumber
的值,执行效率会比 Smi
慢得多:
// 创建一个`HeapNumber`对象const o = { x: 0.1 };
for (let i = 0; i < 5; ++i) { // 创建一个额外的`HeapNumber`对象 o.x += 1;}
在这个短暂的循环中,引擎不得不创建 6 个 HeapNumber
实例, 0.1
、 1.1
、 2.1
、 3.1
、 4.1
、 5.1
,而等到循环结束,其中 5 个实例都会成为垃圾。
为了防止这个问题,V8 提供了一种优化方式去原地更新非 Smi
的值:当一个数字内存区域拥有一个非 Smi
范围内的数值时,V8 会将这块区域标志为 Double
区域,并会为其分配一个用 64 位浮点表示的 MutableHeapNumber
实例。
此后当你再次更新这块区域,V8 就不再需要创建一个新的 HeapNumber
实例,而可以直接在 MutableNumber
实例中进行更新了。
前面说到, HeapNumber
和 MutableNumber
都是使用指针引用的方式指向内存实体,而 MutableNumber
是可变的,如果此时你将属于 MutableNumber
的值 o.x
赋值给其他变量 y
,你一定不希望你下次改变 o.x
时, y
也跟着改变。为了防止这种情况,当 o.x
被共享时, o.x
内的 MutableHeapNumber
需要被重新封装成 HeapNumber
传递给 y
:
Shape 的初始化、弃用与迁移
不同的内存表示方式对应不同的
Shape
,Shape 可以理解为数据结构类一样的存在。
问题来了,如果我们一开始给一个变量赋值 Smi
范围的数字,紧接着再赋值 HeapNumber
范围的数字,引擎会怎样处理呢?
下面例子,我们用相同的数据结构创建两个对象,并将对象中的 x
值初始化为 Smi
:
const a = { x: 1 };const b = { x: 2 };// → objects have `x` as `Smi` field now
b.x = 0.2;// → `b.x` is now represented as a `Double`
y = a.x;
这两个对象指向相同的数据结构,其中 x
都为 Smi
。
紧接着当我们修改 b.x
数值为 0.2
时,V8 需要分配一个新的被标志为 Double
的 Shape 给到 b
,并将新的 Shape 指针重新指向回空 Shape,除此之外,V8 还需要分配一个 MutableHeapNumber
实例去存储这个 0.2
。而后 V8 希望尽可能复用 Shape,紧接着会将旧的 Shape 标志为 deprecated
。
可以注意到此时 a.x
其实仍指向着旧 Shape,V8 将旧 Shape 标志为 deprecaed
的目的显然是要想移除它,但对于引擎来说,直接遍历内存去找到所有指向旧 Shape 的对象并提前更新引用,其实是非常昂贵的操作。V8 采用了懒处理方案:当下一次 a
发生任何属性访问和赋值时再将 a
的 Shape 迁移到新的 Shape 上。这个方案最终可以使得旧 Shape 失去所有引用计数,而只需等待垃圾回收器释放它。
小结
我们深入讨论了以下知识点:
- JavaScript 底层对
primitives
和objects
的区分,以及typeof
的不准确原因。 - 即使变量的值拥有相同的类型,引擎底层也可以使用不同的内存表示方式去存储。
- V8 会尝试找一个最优的内存表示方式去存储你 JavaScript 程序中的每一个属性。
- 我们讨论了 V8 针对 Shape 初始化、弃用与迁移的处理方案。
基于这些知识,我们可以得出一些能帮助提高性能的 JavaScript 编码最佳实践:
- 尽量用相同的数据结构去初始化你的对象,这样对 Shape 的利用是最高效的。
- 为你的变量选择合理的初始值,让 JavaScript 引擎可以直接使用对应的内存表示方式。
- write readable code, and performance will follow
我们通过了解复杂的底层知识,获得了很简单的编码最佳实践,或许这些点能带来的性能提升很小。但所谓厚积薄发,恰恰是清楚这些有底层理论支撑着的优化点,我们写代码时才能做到心中有数。
另外我很喜欢这类以小见大的技术点,以后当别人问你为什么要这样声明变量时,你往往就能开始表演……
参考文章:The story of a V8 performance cliff in React
- 随时随地部署Kubernetes
- 使用CoreOs,Docker和Nirmata来部署微服务风格的应用程序
- 使用ACS和Kubernetes部署Red Hat JBoss Fuse
- 教你快速安装OpenShift容器平台3.6
- 面向开发者的Cloud Foundry
- 云数据库安全与农场和餐馆:知道来源的重要性
- 云数据库安全,农场和餐馆:知道你的来源的重要性
- NO.32 不堪重负:线程池拒绝策略
- 工厂模式进阶之Android中工厂模式源码分析
- C加加游戏编程,大神十年的绝技,正确的入门,这才叫学习
- 我们应该担心吗?人工智能现在可以通过交谈来学习新单词!
- 印度财政部:比特币是纯粹投机行为 区块链资产是“庞氏骗局”
- 法律人工智能实验室成立,法官和律师会丢饭碗吗?
- 让GridView中CheckBox列支持FireFox
- JavaScript 教程
- JavaScript 编辑工具
- JavaScript 与HTML
- JavaScript 与Java
- JavaScript 数据结构
- JavaScript 基本数据类型
- JavaScript 特殊数据类型
- JavaScript 运算符
- JavaScript typeof 运算符
- JavaScript 表达式
- JavaScript 类型转换
- JavaScript 基本语法
- JavaScript 注释
- Javascript 基本处理流程
- Javascript 选择结构
- Javascript if 语句
- Javascript if 语句的嵌套
- Javascript switch 语句
- Javascript 循环结构
- Javascript 循环结构实例
- Javascript 跳转语句
- Javascript 控制语句总结
- Javascript 函数介绍
- Javascript 函数的定义
- Javascript 函数调用
- Javascript 几种特殊的函数
- JavaScript 内置函数简介
- Javascript eval() 函数
- Javascript isFinite() 函数
- Javascript isNaN() 函数
- parseInt() 与 parseFloat()
- escape() 与 unescape()
- Javascript 字符串介绍
- Javascript length属性
- javascript 字符串函数
- Javascript 日期对象简介
- Javascript 日期对象用途
- Date 对象属性和方法
- Javascript 数组是什么
- Javascript 创建数组
- Javascript 数组赋值与取值
- Javascript 数组属性和方法
- java序列化和序列化ID的作用
- python函数式编程
- 让Vim打造成强大的IDE,附_vimrc的配置和使用
- python 中面向切面编程AOP和装饰器
- HashMap&ConcurrentHashMap&HashTable
- python中的垃圾回收机制
- python中值传递还是引用传递?
- 基于Docker+Jenkins+Git的集成开发环境搭建
- python 函数的本质理解
- centOS(离线) off-line install docker-ce
- Java 工厂 Simple Factory&Factory&Abstract Factory
- python 性能的优化
- python中列表的常见操作
- Aop 源码解读
- python字典的合并排序添加查询