React.js 以高效的 UI 渲染著称,其中一个很重要的原因是它维护了一个虚拟 DOM,用户可以直接在虚拟 DOM 上进行操作,React.js 用 diff 算法得出需要对浏览器 DOM 进行的最小操作,这样就避免了手动大量修改 DOM 的时候造成的性能损失。等等,明明是在中间加了一层,为什么结果反而变快了呢?React.js 的核心思想是认为 DOM 操作是缓慢的,因此可以需要最小化 DOM 操作,以换取整体的性能提升。DOM 操作慢是有目共睹的,而其他 JavaScript 脚本的运行速度就一定快吗?
在 V8 出世之前,这个问题的答案是否定的。Google 早年商业模式建立在 Web 的基础上,当它在浏览器中写出 Gmail 这样一个无比复杂的 Web app 的时候,它不可能意识不到浏览器难以忍受的性能,而这主要是因为 JavaScript 的执行速度太慢。2008 年 9 月,Google 决定自己造一个 JavaScript 引擎来改变这一现状—— V8。当搭载着 V8 的 Chrome 浏览器出现在市场上的时候,它的速度远远甩开了当时的所有浏览器。浏览器性能的空前提升让复杂的 Web app 成为了可能。
近七年过去,浏览器的性能随着 CPU 的性能不断上升,但再也没有获得过 2008 年那样突破性的增长。V8 到底用了什么样的技术让 JavaScript 的性能获得了如此大的提升呢?
V8 的优化
要说如何让 JavaScript 变快,就应该先来谈谈它为什么会慢。众所周知 JavaScript 是 Brendan Eich 这个家伙用了一周多的时间开发出来的,相比现如今如日中天的 Swift 是 Apple 的一个团队四年工作的成果,你首先可能就不应该对它有过高的期待。事实上,Brendan Eich 并未意识到自己要开发的是这样一个体量的语言。为了程序员编写时的灵活,他将 JavaScript 设计成为弱类型的语言,并且在运行时可以对对象的属性增添删改。难倒一大群人的 C++ 中的继承、多态,还有什么模板、虚函数、动态绑定这些概念在 JavaScript 中完全不存在了。那这些工作谁来做了呢?自然就只有 JavaScript 引擎。由于不知道变量类型,它在运行时做着大量的类型推导工作。在 Parser 完成工作建出一棵抽象语法树(AST)的时候,引擎会把这棵 AST 翻译成字节码(bytecode)交给字节码解释器去执行。其中最拖慢性能的一步就是解释器执行字节码的阶段。回望当时,大家不知道解释器性能低下吗?其实不是,这样设计的原因是当时的人们普遍认为 JavaScript 作为一种给设计师开发的语言(前端工程师有没有心里一凉?),并不需要太高的性能,这样做符合成本,也满足需求。
V8 做的工作主要就是去掉了这个拖慢引擎速度的部分,它从 AST 直接生成了 CPU 可执行的机器码。这种即时编译的技术被称为 JIT (Just in time)。如果你足够好奇,一个自然的想法就是,这到底是怎么办到的?
我们举一个例子来说:
function Foo(x, y) {
this.x = x;
this.y = y;
}
var foo = new Foo(7, 8);
var bar = new Foo(8, 7);
foo.z = 9;
属性读取
首先是数据结构。你打算如何索引对象的属性?我们已经太熟悉 JSON 中 key: value
的数据结构,但在内存中可以以 key
来索引吗?value
在内存中的位置可以确定吗?当然可以,只要对每个对象维护一个表,里面存着每个 key
对应的value
在内存中的位置就可以了不是吗?
这里的陷阱在于,你需要对每一个对象都维护这样一个表。为什么?我们来看看 C 语言是怎么做的。
struct Foo {
int x, y;
};
struct Foo foo, bar;
foo.x = 7;
foo.y = 8;
bar.x = 8;
bar.y = 7;
// Cant' set foo.z
仔细想想大学时候的教材,foo.x
和 foo.y
的地址是可以直接算出来的呀。这是因为成员 x
和 y
的类型是确定的,JavaScript 里完全可以 foo.x = "Hello"
,而 C 语言就没办法这样做了。
V8 不想给每个对象都维护一个这样的表。它也想让 JavaScript 拥有 C/C++ 直接用偏移就读出属性的特性。所以它的解决思路就是让动态类型静态化。V8 实现了一个叫做隐藏类(Hidden Class)的特性,即给每个对象分配一个隐藏类。对于foo
对象,它生成一个类似于这样的类:
class Foo {
int x, y;
}
当新建一个 bar
对象的时候,它的 x
和 y
属性恰好都是 int
类型,那么它和 foo
对象就共享了这个隐藏类。把类型确定以后,读取属性就只是在内存中增加一个偏移的事情了。而当 foo
新建了 z
属性的时候,V8 发现原来的类不能用了,于是就会给 foo
新建一个隐藏类。修改属性类型也是类似。
Inline caching
由上可知,当访问一个对象的属性的时候,V8 首先要做的就是确定对象当前的隐藏类。但每次这样做的开销也很大,那很容易想到的另一个计算机中常用的解决方案,就是缓存。在第一次访问给定对象属性的时候,V8 将假设所有同一部分代码的其他对象也都使用了这个对象的隐藏类,于是会告诉其他对象直接使用这个类的信息。在访问其他对象的时候,如果校验正确,那么只需要一条指令就可以得到所需的属性,如果失败,V8 就会自动取消刚才的优化。上面这段话用代码来表述就是:
foo.x
# ebx = the foo object
cmp [ebx,<hidden class offset>],<cached hidden class>
jne <inline cache miss>
mov eax,[ebx, <cached x offset>]
这极大提升了 V8 引擎的速度。
还能更快吗?
随着 Intel 宣布 Tick-Tock 模型的延缓,CPU 处理速度不再能像之前一样稳步增长了,那么浏览器还能继续变快吗?V8 的优化是浏览器性能的终点吗?
JavaScript 的问题在于错误地假设前端工程师都是水平不高的编程人员(如果不是,你应该不会读到这里),岂图让程序员写得舒服而让计算机执行得痛苦。在现代浏览器引擎已经优化到这个地步的时候,我们不禁想问:为什么一定是 JavaScript ?前端工程师是不是可以让出一步,让自己多做一点点事情,而让引擎得以更高效地优化性能?JavaScript 成为事实上的浏览器脚本标准有历史原因,但这不能是我们停止进步的借口。
当 Web Assembly 正式宣布的时候,我才确定了不仅仅是我一个名不见经传的小程序员有这样的想法,那些世界上最顶级的头脑已经开始行动了。浏览器在大量需求的驱动下正在朝着一个高性能的方向前进,浏览器究竟可以有多快,2015 可能是这条路上另一个转折点。
(题图来自:kendsnyder.com)
原创文章,作者:ItWorker,如若转载,请注明出处:https://blog.ytso.com/47207.html