前言
由 C/C++ 入门的我突然转 JS (主要是 Node.js) 感觉整个人都是懵逼的(还不是懂得太少造的o-O),差别真的感觉好大,最神奇的是 JS 竟然不用经过编译就可以运行。
期间总是能遇到 Google V8,不明觉厉,感觉有必要好好了解下,顺便好好梳理下基础知识。
静态编译与动态解释
众所周知,计算机只能理解机器语言,而我们平时编程用的通常是高级语言,所以源代码通常都要经过层层转换最终变成机器语言运行。
早期只有汇编语言没有高级语言,不同的设备有一套自己的对应不同机器语言指令集的汇编语言,也就是说,汇编语言不能在不同系统平台之间移植。同一个软件为了让不同类型的设备都能用要写好几套代码,实在太不方便了,所以后来发展出了跨平台的高级语言。
随着计算机发展,编译器也越来越复杂,发展了很多分支,像是本地编译器、交叉编译器等,这里就不多说了。
那么源码一定要经过编译才能运行吗?
解释器的出现给出了一种不用编译就能运行的能力,也就是我一开始说的让我很不习惯的地方。
前面说的都算是先静态编译到可执行的文件,然后运行可执行的文件来执行程序,而解释器提供了一种边编译边运行的动态运行方法,而也正因为通过解释器运行的代码是边编译边运行的,所以运行的速度比静态编译的那种慢很多。
所以程序运行的方式分为静态编译和动态解释。
我从 C/C++ 跨到 JS 里,就是从静态编译跨到了动态解释里。
即时编译与虚拟机
这小节的概念了解 Java 的人应该很了解,虽然我之前接触过一点 Java 但是直到现在才算是摸清了点真面目,当然学习的过程我也没对 Java 做过多深入,毕竟主旨是 Google V8 呀!
即时编译(Just-in-time compilation)混合了编译器和解释器,在边编译边运行的过程中会将编译过的代码缓存起来,下次运行的时候运行的就是编译后的代码。
当然,虽然即时编译在运行过一次以后有了编译后的代码,再次运行时因为识别编译过的和未编译过的(即修改)代码,速度还是比静态编译的程序运行的慢。
避免二次编译也使得理论上即时编译的总体开销(编译和运行)优于静态编译和动态解释。
这里还出现了一个字节码的概念。
字节码的出现理由有点像交叉编译器(在 A 系统平台下可以产生 B 系统平台的可执行文件的编译器),在源码不能或很难编译成目标平台可执行文件时非常好用。感觉也有点像是跨平台的汇编语言,复杂度介于高级语言和低级语言之间。
在即时编译里出现的字节码是一种动态字节码转译方式,字节码也可以静态转译的,就是先编译成字节码再运行的。
字节码通常运行在一个程序虚拟机上。
图里的虚拟机部分也算是一个解释执行的过程。
广义的虚拟机包括一切跟任何真实机器无关的虚拟架构。
而当前虚拟机的实现主要分成三类:
-
系统虚拟机:虚拟了一个运行完整系统的操作平台。典型代表:VirtualBox。
-
程序虚拟机:为单个计算机程序的运行虚拟必要的环境。典型代表:Java 虚拟机。
-
操作系统层虚拟化:介于系统和单个程序之间,可以运行多个独立应用程序,但是又不用虚拟完整操作系统。典型代表:Docker。
Google V8
终于来到了 Google V8!
V8 是 Google 开发的开源的 JavaScript 引擎,用于 Google Chrome 及 Chromium 中。
JavaScript 引擎是一个专门处理 JavaScript 脚本的虚拟机,一般会附带在网页浏览器之中。
V8 是用 C++ 写的,使用了即时编译技术,工作模式如下图:
感觉到这里已经足够说明什么是 Google V8 了,后面算是拓展阅读吧。
V8 的隐藏类(Hidden Class)
JavaScript 作为一种动态编程语言,对象上的属性(Property)可以随时增减。如果用字典类的数据结构来存储这些对象属性,访问的时候就会带来动态查找的损耗,这也是 JavaScript 比类似 Java 这种类型确定的语言慢的原因之一。
V8 用动态创建隐藏类的方式来减少这种损耗。
举个例子。
有如下简单的一段 JS 代码:
function Point(x, y) {
this.x = x; // E1
this.y = y; // E2
}
new Point(1, 2); // E0
语句按 E0、E1、E2 的顺序执行。
执行 E0 的时候,创建一个隐藏类 C0,对象的类指示器指向 C0。
执行 E1 的时候,在 C0 基础上新建一个隐藏类 C1(C1 知道 x 属性存的位置),并给 C0 增加一个转换指示:如果增加一个 x 属性,就变成 C1。
执行 E2 的时候,在 C1 基础上新建一个隐藏类 C2(C2 知道 x 和 y 属性存位置),并给 C1 增加一个转换指示:如果增加一个 y 属性,就变成 C2。
隐藏类的创建过程就是隐藏类树的创建过程,在之后遇到新建对象实例,就会先试图从已创建的树里找到对应的类,没找到的话才会新建对应的树节点。
这之后,每个对象的类指示器都指向对应的隐藏类,和 Java 里的类与对象关系差不多,JavaScript 在访问属性的时候就避免了相对漫长的查找,从而加快了速度。
从这里也可以得出一个优化代码的方式:尽量用相同的顺序实例化对象属性以最大化复用隐藏类树。
GC(垃圾回收)
V8 将内存分成:
- new-space:对象刚创建的时候分配这里的内存给对象。内存小,GC 删除的一般是这里的数据。
- old-data-space:new-space 里的一些对象经过一轮 GC 没被删除,并且这些对象内部不包含指针(纯数据),就会被移到这里。
- old-pointer-space:new-space 里的一些对象经过一轮 GC 没被删除,并且这些对象内部包含指针(指向别的对象),就会被移到这里。
- large-object-space:大小超过别的 space 大小限制的对象会被放在这里,它们有专门非配的内存,不归 GC 管。
- code-space:代码对象(包含即时编译后的指令)存放的地方。会被执行的代码不是放在这里就是放在 large-object-space 里。
- cell-space,property-cell-space,map-space:分别存放对应名字(cells、propertyCells、maps)的地方(这里我也不太懂这啥)。
GC 首先做的是分清数据对象和指针,因为跟踪指针才能知道哪些对象是不能被回收的。
V8 的 GC 有以下几个特点:
- 运行 GC 的时候停止执行程序。
- 绝大多数的 GC 只处理部分数据对象内存垃圾以最小化对应用程序的影响。
- 总是正确地知道所有的对象和指针的存储位置。这避免了将对象误认为指针导致的内存泄漏。
GC 我也没多看,也说不出个所以然来了,更多可以看A tour of V8: Garbage Collection。
参考:
图[1]来源
维基百科上的各种词条
Design Elements
A tour of V8: Garbage Collection