V8 指针技术
标记指针
Tagged Pointer 是一个指针(内存地址),它具有与其关联的附加数据:
- 大多数体系结构都是字节可寻址的(最小的可寻址单元是字节),但是某些类型的数据通常会与数据的大小对齐,这种差异使指针的一些最低有效位未被使用,它们可以用于标签-通常用作位字段(每个位是一个单独的标签),只要使用该指针的代码在访问前将这些位屏蔽掉即可
- 相反,在某些操作系统中,虚拟地址的宽度比整个体系结构的宽度窄,从而使最高有效位可用于标签(注意,某些处理器特别禁止在处理器级别使用此类标记指针,尤其是 x86-64,这要求操作系统使用规范形式的地址,且最高有效位全为0或全为1)
V8 在堆中按字对齐的地址分配对象,这使得它可以使用最低有效位进行标记:
- 在 32 位架构中,V8使用最低有效位去区分小整数 Smis 和堆对象指针
- 对于堆指针,V8使用第二低有效位去区分强引用和弱引用
指针压缩
V8 使用了指针压缩的技术,仅在内存中存储指针的下部32位,并将基本高32位存储在特定寄存器中(V8 中的 JavaScript 的对象,数组,数字或者字符串都用对象表示,并且分配在 V8 堆区,这使得我们可以用一个指向对象的指针表示任何值)
如果将V8堆(heap)放在其他地方的连续4GB地址空间(所有指针的高位32位都相同),那么一个从 base 开始的无符号32位偏移量将唯一标识一个指针:
1 | |----- 32 bits -----|----- 32 bits -------| |
1 | |----- 32 bits -----|----- 32 bits -------| |
- 这里
s
是Smi
有效载荷的符号值 - 如果再有使用符号扩展表示,我们就可以仅用64位字的一位算数移位来压缩和解压
Smis
V8 对象管理
JS 对象的基础属性
一,prototype 原型:
- js 中每个数据类型都是对象(除了 null 和 undefined),而每个对象都继承自另外一个对象,后者称为“原型”(prototype)对象(只有 null 除外,它没有自己的原型对象)
- 为了解决构造函数的对象实例之间无法共享属性的缺点,js 提供了 prototype 属性
案例1:
1 | function Person(name,height){ |
- 对象 boy 和对象 girl 共享同一个 hobby 方法
- 当某个对象实例没有该属性和方法时,就会到原型对象上去查找(会一直向上寻找,直到最顶层的
Object.prototype
还是找不到,则返回undefined
) - 如果实例对象自身有某个属性或方法,就不会去原型对象上查找
二,constructor 构造函数:
- 该属性描述的就是其构造函数,默认指向 prototype 对象所在的构造函数(返回创建该对象的函数的引用)
- 此属性的值是对函数本身的引用,而不是一个包含函数名称的字符串
案例2:
1 | function A(){}; /* 构造函数 */ |
- a 是构造函数 A 的实例对象,但是 a 自身没有 constructor 属性,因此该属性是从原型链读取的
1 | d8> %DebugPrint(a); |
案例3:
1 | function Tree(name) { /* 构造函数 */ |
1 | theTree.constructor is function Tree(name) { |
- 注意:
theTree.constructor
的类型为函数,而不是字符串
三,map 映射:
对象的映射 map
是一种特殊的属性,其中包含以下信息:
- 对象的动态类型,即
String Uint8Array HeapNumber ...
- 对象的大小(以字节为单位)
- 对象的属性及其存储位置
- 数组元素的类型(例如:未装箱的双精度或标记的指针)
- 对象的原型
Map
将提供属性值在相应区域中的确切位置(本质上,映射定义了应如何访问对象):
1 | 0x1dfe0019b715: [Map] in OldSpace |
四,element 索引属性,property 命名属性:
- element 用数字进行索引(类似于数组的索引)
- property 用字符串进行索引(类似于键值对的索引)
案例4:
1 | function Foo(properties, elements) { /* 构造函数 */ |
- 尝试在 GDB 中打印信息
1 | d8> function Foo(properties, elements) {for (let i = 0; i < elements; i++) {this[i] = `element${i}`}for (let i = 0; i < properties; i++) {this[`property${i}`] = `property${i}`}} |
- 先打印对象
JS_OBJECT_TYPE
的信息:
1 | pwndbg> x/20xw 0x34af0004ed89-1 /* JS_OBJECT_TYPE */ |
- 前3个数据分别为
map properties elements
的地址低4字节 - 接下来10个数据就是 [
property0
:property9
] - 注意:
- V8 使用了标记指针,因此在打印前需要先把指针还原
- V8 使用了指针压缩的技术,仅在内存中存储指针的下部32位,并将基本高32位存储在特定寄存器中
指针 properties
和 elements
与 V8 的两种属性有关:
1 | function Foo(properties, elements) { |
- 第一个 for 循环定义了
elements
个数组索引属性(Array-indexed Properties) - 第二个 for 循环定义了
properties
个命名属性(Named Properties)
V8 遍历时一般会先遍历前者,前后两者在底层存储在两个单独的数据结构中,分别用 elements
和 properties
两个指针指向它们

- PS:V8 有一种策略,如果命名属性少于等于10个时,命名属性会直接存储到对象本身,而无需先通过 properties 指针查询(直接存储到对象本身的属性被称为对象内属性 In-object Properties)
1 | pwndbg> x/20xw 0x34af0004edd1-1 /* elements */ |
- 从第3个指针开始,就是:[
element0
:element11
]
1 | pwndbg> x/20xw 0x34af0004f4a1-1 /* properties */ |
- 从第3个指针开始,就是:[
property10
:property11
](前10个都是对象内属性)
JS 函数对象
JavaScript 函数是第一类对象(first-class object),被称为一等公民
函数与对象共存,我们也可以认为函数就是其他任意类的对象(对象有的功能,函数也会拥有)
- 可以通过字面量来创建
1 | var test = function testFunction() {} |
- 可以被赋值变量,数组项,或是其他对象的属性
1 | var testFunction = {}; |
- 可以作为参数传递给其他函数
1 | function call(testFunction){ |
- 可以作为函数的返回值
1 | function returnFunction() { |
- 能够具有动态创建和分配的属性(这里和函数指针有所不同)
1 | var testFunction = function(){}; |
在编译阶段 V8 解析到函数声明和函数表达式(变量声明)时:
- 函数声明,将其转换为内存中的函数对象,并放到作用域中
- 变量声明,将其值设置为
undefined
,并当道作用域中
V8 内存管理
V8会为每个 service worker 开启一个新的进程
在V8进程中,一个正在运行的程序总是由一些分配的内存来表示,这称为常驻集(Resident Set),可以进一步划分以下不同的部分:

堆 Heap
堆是V8存储对象和动态数据的地方,又可以分为以下几个区域:
- 新空间 New space:
- 存储新对象的地方,并且大部分对象的声明周期都很短
- 这片空间由 Scavenger(Minor GC) 来管理
- 新生代空间的大小可以由
--min_semi_space_size
(初始值) 和--max_semi_space_size
(最大值) 两个V8标志来控制
- 老空间 Old space:
- 存储的是在新生代空间中经过了两次 Minor GC 后存活下来的数据
- 这片空间由 Major GC(Mark-Sweep & Mark-Compact) 来管理
- 老生代空间的大小可以
--initial_old_space_size
(初始值) 和--max_old_space_size
(最大值) 两个V8标志来控制
- 大对象空间 Large object space
- 大于其他空间大小限制的对象存储的地方,每个对象都有自己的内存区域
- 大对象是不会被垃圾回收的
- 代码空间 Code-space
- 即时(JIT)编译器存储编译代码块的地方
- 这是唯一有可执行内存的空间(尽管代码可能被分配在“大对象空间”中,它们也是可执行的)
- 单元空间 Cell space、属性单元空间 Property cell space、映射空间 Map space
- 这些空间分别包含 Cell,PropertyCell 和 Map
- 这些空间中的每一个都包含相同大小的对象,并且对它们指向的对象类型有一些限制,这简化了收集
- 每个空间都由一组页组成(使用 mmap 从操作系统分配的连续内存块)
栈 Stack
每个V8进程有一个栈,这里存储静态数据,包括方法/函数框架、原语值和指向对象的指针
栈内存限制可以使用 --stack_size
V8 标志设置
栈中保存的数据:
- 全局作用域保存在栈上的全局框架(Global frame)中
- 所有局部变量(包括参数和返回值)都保存在栈的函数框块中
- 像
int string
这样的基元类型都直接存储在栈上 - 每个函数调用都作为帧块添加到堆栈内存中:
- 当前函数调用的函数将被推到栈的顶部
- 当函数返回时,它的框架帧块将被移除
一旦主进程完成,堆上的对象就不再有来自栈的指针,成为 孤立对象(即不再直接或间接从 Stack 中引用的对象),除非显式复制,否则其他对象中的所有对象引用都是使用引用指针完成的
孤立对象将会被 V8 垃圾收集给回收
V8 垃圾收集
在 JavaScript 中,根据对象存活的周期分为两种类型:
- 生存时间较短的对象:
- 对象经过一次垃圾回收之后,不在需要被使用的对象,就被释放回收
- 生存时间较长的对象:
- 对象经过多次垃圾回收之后,还继续存活
- 对于不同存活时间的对象,V8 使用分代回收的方法来处理,V8 将堆分为两个部分,新空间和老空间
V8 通过垃圾收集来管理堆内存,释放孤立对象(即不再直接或间接从堆栈中引用的对象)使用的内存,以便为创建新对象腾出空间
V8 垃圾回收器是分代的(堆中的对象按其年龄分组并在不同阶段清除),V8 有两个阶段和三种不同的垃圾收集算法
- Minor GC:针对新生代,使用 Scavenger 和 Cheney’s algorithm 两种算法
- Major GC:针对老生代,使用 Mark-Sweep-Compact 算法
新生代:存放生存时间短的对象
- 容量小,
1~8M
- 使用副垃圾回收器(Minor GC)
- 使用 Scavenge 算法,将新生代区域分成两部分(对象区域
from-space
,空闲区域to-space
),详细步骤如下:- 对象区域放新加入的对象
- 对象区域快满的时候,执行垃圾清理(先标记,再清理)
- 把活动对象复制到空闲区域,并且排序(空闲区域就没有内存碎片了)
- 复制完之后,把对象区域和空闲区域进行翻转
- 重复执行上面的步骤
- 经过两次垃圾回收后还存在的对象,移动到老生代中
老生代:存放生存时间久的对象
- 容量大(对象占用空间大,对象存活时间长)
- 使用主垃圾回收器(Major GC)
- 使用标记 - 清除算法(Mark-Sweep)
- 标记:从根元素开始,找到活动对象,找不到的就是垃圾
- 清理:直接清理垃圾(会产生垃圾碎片)
- 使用标记 - 整理算法(Mark-Compact)
- 标记:从根元素开始,找到活动对象,找不到的就是垃圾
- 整理:把活动对象向同一端移动,另一端直接清理(不会产生垃圾碎片)
V8 编译流水线
宿主环境
V8 引擎需要一个宿主环境才可以执行JS代码,这个宿主环境可以是浏览器、Node.js 进程,也可以是其他的定制开发环境
- 浏览器为 V8 提供了基础的消息循环系统、全局变量、web API
- V8 只需提供 ECMAScript 定义的一些对象和核心函数,这包括了 Object、Function、String
- V8 还提供了垃圾回收器、协程等基础内容,不过这些功能依然需要宿主环境的配合才能完整执行
Node.js 也是 V8 的另外一种宿主环境,它提供了不同的宿主对象和宿主的 API,但是整个流程依然是相同的,比如 Node.js 也会提供一套消息循环系统,也会提供一个运行时的主线程
构造事件循环系统
V8 需要一个主线程,用来执行 JavaScript 和执行垃圾回收等工作
V8 是寄生在宿主环境中的,它并没有自己的主线程,而是使用宿主所提供的主线程,V8 所执行的代码都是在宿主的主线程上执行的
如果只有一个主线程依然是不行的,因为线程执行完一段代码后就会自动退出,为了让线程在执行完代码之后继续运行,需要添加一个循环语句,在循环语句中监听下个事件,不断地获取新事件并执行:
1 | while(1){ |
- 类似于 Linux 内核中的进程调度器
如果主线程正在执行一个任务,这时候又来了一个新任务,那么这种情况下就需要引入一个 消息队列:
- 让下载完成的事件暂存到消息队列中,等当前的任务执行结束之后,再从消息队列中取出正在排队的任务
- 当执行完一个任务之后,我们的事件循环系统会重复这个过程,继续从消息队列中取出并执行下个任务
生成字节码
对 JavaScript 代码进行解析 (Parser),并生成为 AST 和作用域信息,之后 AST 和作用域信息被输入到一个称为 Ignition 的解释器中,并将其转化为字节码
然后再根据情况解释执行字节码或者直接将字节码编译成二进制代码然后执行
执行字节码
V8 使用 Ignition 解释器来解释执行字节码:
1 | function add(x, y) { |
1 | StackCheck |
- 解释器就是模拟物理机器来执行字节码的
- 比如可以实现如取指令、解析指令、执行指令、存储数据等
通常有两种类型的解释器,基于栈 (Stack-based) 和基于寄存器 (Register-based):
- 基于栈的解释器使用栈来保存函数参数、中间运算结果、变量
- 基于寄存器的虚拟机则支持寄存器的指令操作,使用寄存器来保存参数、中间计算结果
编译字节码
字节码虽然可以直接解释执行,但是耗时较长,为了优化代码执行速度,V8 在解释器内增加了一个监控机器人,在解释执行字节码的过程中,如果发现某一段代码被重复执行多次,那么监控机器人会将这段代码标记为 热点代码
- 当某段代码被标记为热点代码后,V8 就会将这段字节码丢给优化编译器 TurboFan
- 优化编译器会在后台将字节码编译成二进制代码,然后再对编译后的二进制代码进行优化操作,优化后的二进制机器代码的执行效率会得到大幅提升
- 如果下面再执行到这段代码时,那么 V8 会优先选择优化之后的二进制代码,这样代码的执行速度就会大幅提升
不过,和静态语言不同的是,JavaScript 是一种非常灵活的动态语言,对象的结构和属性是可以在运行时任意修改的,而经过优化过的代码只能针对某种固定的结构,一旦在执行过程中, 对象的结构被动态修改了,那么优化之后的代码势必会变成无效的代码,这时候优化编译器就需要执行 反优化 操作,经过反优化的代码,下次执行时就会回退到解释器解释执行
编译流水线流程图

整个编译流水线的流程依次为:
- 准备基础环境
- 全局执行上下文:全局作用、全局变量、内置函数
- 初始化内存中的堆和栈结构
- 初始化消息循环系统:消息驱动器和消息队列
- 结构化 JavaScript 源代码
- 生成抽象语法树 AST
- 生成相关作用域
- 生成字节码
- 字节码是介于 AST 和机器码的中间代码
- 解释器可以直接执行
- 编译器需要将其编译为二进制的机器码再执行
- 标记热点代码
- 解释器:按照顺序执行字节码,并输出执行结果
- 监控机器人:如果发现某段代码被重复多次执行,将其标记为热点代码
- 优化热点代码
- 优化编译器将热点代码编译为机器码
- 对编译后的机器码进行优化
- 再次执行到这段代码时,将优先执行优化后的代码
- 反优化
JavaScript
对象在运行时可能会被改变,这段优化后的热点代码就失效了- 进行反优化操作,给到解释器解释执行
JavaScript SandBox & JIT
对于 JavaScript 来说,沙箱并非传统意义上的沙箱,它只是一种语法上的 Hack 写法,沙箱是一种安全机制,把一些不信任的代码运行在沙箱之内,使其不能访问沙箱之外的代码
当需要解析或者执行不可信的 JavaScript 代码时,需要隔离被执行代码的执行环境,并对执行代码中可访问对象进行限制,通常开始可以把 JavaScript 中处理模块依赖关系的闭包称之为沙箱
我们大致可以把沙箱的实现总体分为两个部分:
- 构建一个闭包环境
- 模拟原生浏览器对象
一个重要的保护是,它将所有外部指针转换为查找表的索引,例如指向 Web 程序集 RWX 页的指针和 ArrayBuffer 后备存储的指针,因此,我们不能使用普通方法来实现任意读写
而 JavaScript JIT(Just-In-Time)则是另一种机制:
JIT compiler 混合了编译器和解释器的优点,大幅提高了 JavaScript 的运行速度:
- 一开始只是简单的使用解释器执行,当某一行代码被执行了几次,这行代码会被打上 Warm 的标签,当某一行代码被执行了很多次,这行代码会被打上 Hot 的标签
- 被打上 Warm 标签的代码会被传给 Baseline Compiler 编译且储存,同时按照行数和变量类型被索引
- 被打上 Hot 标签的代码会被传给 Optimizing compiler,这里会对这部分带码做更优化的编译
- 当发现执行的代码命中索引,会直接取出编译后的代码执行,从而不需要重复编译已经编译过的代码
利用 JIT 机制就可以绕过 SandBox:
1 | const foo = ()=> |