导图社区 极客时间 - 图解 Google V8 学习笔记
这是一门短小精悍的课程,没有一行一行的分析 Chrome V8 代码。高屋建瓴地拆解 V8 的每一块内容,那估计 100 讲也说不完,编程领域论代码复杂性,老大是操作系统,其次是浏览器,所以专栏作者主要说了 V8 的核心特性,以及从前端角度触发,将两者相结合讲解,对于入门 V8 和进阶高阶 JS 是没什么问题。
编辑于2022-11-08 15:12:06 上海极客时间 - 图解 Google V8 学习笔记
如何学习google高性能 v8
什么是 V8?
V8 是 JavaScript 虚拟机的一种。我们可以简单地把 JavaScript 虚拟机理解成是一个翻译程序,将人类能够理解的编程语言 JavaScript,翻译成机器能够理解的机器语言。如下图所示:
上图中,中间的“黑盒”就是 JavaScript 引擎 V8。目前市面上有很多种 JavaScript 引擎,诸如 SpiderMonkey、V8、JavaScriptCore 等。而由谷歌开发的开源项目 V8 是当下使用最广泛的 JavaScript 虚拟机,全球有超过 25 亿台安卓设备,而这些设备中都使用了 Chrome 浏览器,所以我们写的 JavaScript 应用,大都跑在 V8 上。
V8 率先引入了即时编译(JIT)的双轮驱动的设计
这是一种权衡策略,混合编译执行和解释执行这两种手段,给 JavaScript 的执行速度带来了极大的提升。
V8知识图谱
V8是如何执行一段代码的
什么是v8?
V8 是一个由 Google 开发的开源 JavaScript 引擎,目前用在 Chrome 浏览器和 Node.js 中,其核心功能是执行易于人类理解的 JavaScript 代码。
高级代码为什么需要先编译再执行?
不同的 CPU 有着不同的指令集,如果要使用机器语言或者汇编语言来实现一个功能,那么你需要为每种架构的 CPU 编写特定的汇编代码,在编写汇编代码时,我们还需要了解和处理器架构相关的硬件知识。
因此我们需要一种屏蔽了计算机架构细节的语言,能适应多种不同 CPU 架构的语言,能专心处理业务逻辑的语言,诸如 C、C++、Java、C#、Python、JavaScript 等,这些“高级语言”就应运而生了。
高级语言的执行方式
第一种是解释执行,需要先将输入的源代码通过解析器编译成中间代码,之后直接使用解释器解释执行中间代码,然后直接输出结果。具体流程如下图所示:
第二种是编译执行。采用这种方式时,也需要先使用解析器将源代码转换为中间代码,然后我们的编译器再将中间代码编译成机器代码。通常编译成的机器代码是以二进制文件形式存储的,需要执行这段程序的时候直接执行二进制文件就可以了。还可以使用虚拟机将编译后的机器代码保存在内存中,然后直接执行内存中的二进制代码。
V8 是怎么执行 JavaScript 代码的?
其主要核心流程分为编译和执行两步。首先需要将 JavaScript 代码转换为低级中间代码或者机器能够理解的机器代码,然后再执行转换后的代码并输出执行结果。
v8编译流水线
V8 并没有采用某种单一的技术,而是混合编译执行和解释执行这两种手段,我们把这种混合使用编译器和解释器的技术称为 JIT(Just In Time)技术。
首先 V8 会接收到要执行的 JavaScript 源代码,不过这对 V8 来说只是一堆字符串,V8 并不能直接理解这段字符串的含义,它需要结构化这段字符串。结构化,是指信息经过分析后可分解成多个互相关联的组成部分,各组成部分间有明确的层次结构,方便使用和维护,并有一定的操作规范。
V8 源代码的结构化之后,就生成了抽象语法树 (AST),我们称为 AST,AST 是便于 V8 理解的结构。在生成 AST 的同时,V8 还会生成相关的作用域,作用域中存放相关变量。
有了 AST 和作用域之后,接下来就可以生成字节码了,字节码是介于 AST 和机器代码的中间代码。但是与特定类型的机器代码无关,解释器可以直接解释执行字节码,或者通过编译器将其编译为二进制的机器代码再执行。
生成了字节码之后,解释器就登场了,它会按照顺序解释执行字节码,并输出执行结果。
在解释执行字节码的过程中,如果发现了某一段代码会被重复多次执行,那么监控机器人就会将这段代码标记为热点代码。
当某段代码被标记为热点代码后,V8 就会将这段字节码丢给优化编译器,优化编译器会在后台将字节码编译为二进制代码,然后再对编译后的二进制代码执行优化操作,优化后的二进制机器代码的执行效率会得到大幅提升。如果下面再执行到这段代码时,那么 V8 会优先选择优化之后的二进制代码,这样代码的执行速度就会大幅提升。
不过,和静态语言不同的是,JavaScript 是一种非常灵活的动态语言,对象的结构和属性是可以在运行时任意修改的,而经过优化编译器优化过的代码只能针对某种固定的结构,一旦在执行过程中,对象的结构被动态修改了,那么优化之后的代码势必会变成无效的代码,这时候优化编译器就需要执行反优化操作,经过反优化的代码,下次执行时就会回退到解释器解释执行。
除了 V8 采用了 JIT 技术,还有哪些虚拟机采用了 JIT 技术
函数即对象:彻底搞懂 函数是一等公民的背后含义
什么是 JavaScript 中的对象?
和其他主流语言不一样的是,JavaScript 是一门基于对象 (Object-Based) 的语言,可以说 JavaScript 中大部分的内容都是由对象构成的,诸如函数、数组,也可以说 JavaScript 是建立在对象之上的语言。
虽然 JavaScript 是基于对象设计的,但是它却不是一门面向对象的语言 (Object—Oriented Programming Language),因为面向对象语言天生支持封装、继承、多态,但是 JavaScript 并没有直接提供多态的支持,因此要在 JavaScript 中使用多态并不是一件容易的事。
面向对象语言是由语言本身对继承做了充分的支持,并提供了大量的关键字,如 public、protected、friend、interface 等,众多的关键字使得面向对象语言的继承变得异常繁琐和复杂,而 JavaScript 中实现继承的方式却非常简单清爽,只是在对象中添加了一个称为原型的属性,把继承的对象通过原型链接起来,就实现了继承,我们把这种继承方式称为基于原型链继承。
对象的属性值有三种类型:
演示图
第一种是原始类型 (primitive),所谓的原始类的数据,是指值本身无法被改变,比如 JavaScript 中的字符串就是原始类型,如果你修改了 JavaScript 中字符串的值,那么 V8 会返回给你一个新的字符串,原始字符串并没有被改变,我们称这些类型的值为“原始值”。
JavaScript 中的原始值主要包括 null、undefined、boolean、number、string、bigint、symbol 这七种。
第二种就是我们现在介绍的对象类型 (Object),对象的属性值也可以是另外一个对象,比如上图中的 info 属性值就是一个对象。
第三种是函数类型 (Function),如果对象中的属性值是函数,那么我们把这个属性称为方法,所以我们又说对象具备属性和方法,那么上图中的 showinfo 就是 person 对象的一个方法。
函数的本质
在 JavaScript 中,函数是一种特殊的对象,它和对象一样可以拥有属性和值,但是函数和普通对象不同的是,函数可以被调用。
那么,V8 内部是怎么实现函数可调用特性的呢?
其实在 V8 内部,会为函数对象添加了两个隐藏属性,具体属性如下图所示:
也就是说,函数除了可以拥有常用类型的属性值之外,还拥有两个隐藏属性,分别是 name 属性和 code 属性。
隐藏 name 属性的值就是函数名称,如果某个函数没有设置函数名,如下面这段函数:
该函数对象的默认的 name 属性值就是 anonymous,表示该函数对象没有被设置名称。另外一个隐藏属性是 code 属性,其值表示函数代码,以字符串的形式存储在内存中。当执行到一个函数调用语句时,V8 便会从函数对象中取出 code 属性值,也就是函数代码,然后再解释执行这段函数代码。
函数是一等公民
如果某个编程语言的函数,可以和这个语言的数据类型做一样的事情,我们就把这个语言中的函数称为一等公民。
函数表达式:涉及大量概念,函数表达式到底该怎么学?
函数声明与函数表达式的差异
这两段代码中,函数声明的代码能够正常执行,函数表达式的代码不能够正常执行,我们发现报错了,提示的错误信息如下所示:VM130:1 Uncaught TypeError: foo is not a function at :1:1
主要原因是这两种定义函数的方式具有不同语义,不同的语义触发了不同的行为。
V8 是怎么处理函数声明的?
V8 执行这段代码的流程大致如下图所示:
在编译阶段,如果解析到函数声明,那么 V8 会将这个函数声明转换为内存中的函数对象,并将其放到作用域中。同样,如果解析到了某个变量声明,也会将其放到作用域中,但是会将其值设置为 undefined,表示该变量还未被使用。然后在 V8 执行阶段,如果使用了某个变量,或者调用了某个函数,那么 V8 便会去作用域查找相关内容。
关于这段代码作用域的数据
上面这段就是 V8 生成的作用域,我们可以看到,作用域中包含了变量 x 和 foo,变量 x 的默认值是 undefined,变量 foo 指向了 foo 函数对象,foo 函数对象被 V8 存放在内存中的堆空间了,这些变量都是在编译阶段被装进作用域中的。
因为在执行之前,这些变量都被提升到作用域中了,所以在执行阶段,V8 当然就能获取到所有的定义变量了。我们把这种在编译阶段,将所有的变量提升到作用域的过程称为变量提升。
另外一个代码demo
在 V8 执行var x = 5这段代码时,会认为它是两段代码,一段是定义变量的语句,一段是赋值的表达式: var x = undefined; x = 5;
表达式是不会在编译阶段执行的,首先,在变量提升阶段,V8 并不会执行赋值的表达式,该阶段只会分析基础的语句,比如变量的定义,函数的声明。
而这两行代码是在不同的阶段完成的,var x 是在编译阶段完成的,也可以说是在变量提升阶段完成的,而x = 5是表达式,所有的表达式都是在执行阶段完成的。
表达式是不会在编译阶段执行的在变量提升阶段,V8 将这些变量存放在作用域时,还会给它们赋一个默认的 undefined 值,所以在定义一个普通的变量之前,使用该变量,那么该变量的值就是 undefined。
总的来说,在 V8 解析 JavaScript 源码的过程中,如果遇到普通的变量声明,那么便会将其提升到作用域中,并给该变量赋值为 undefined,如果遇到的是函数声明,那么 V8 会在内存中为声明生成函数对象,并将该对象提升到作用域中。
V8 是怎么处理函数表达式的?
我们在一个表达式中使用 function 来定义一个函数,那么就把该函数称为函数表达式。
函数表达式是在表达式语句中使用 function 的,最典型的表达式是“a=b”这种形式,因为函数也是一个对象,我们把“a = function (){}”这种方式称为函数表达式;
在函数表达式中,可以省略函数名称,从而创建匿名函数(anonymous functions);
一个函数表达式可以被用作一个即时调用的函数表达式——IIFE(Immediately Invoked Function Expression)。
分析这段代码:
当执行这段代码的时候,V8 在编译阶段会先查找声明语句,你可以把这段代码拆分为下面两行代码:
第一行是声明语句,所以 V8 在解析阶段,就会在作用域中创建该对象,并将该对象设置为 undefined
第二行是函数表达式,在编译阶段,V8 并不会处理函数表达式,所以也就不会将该函数表达式提升到作用域中了。那么在函数表达式之前调用该函数 foo,此时的 foo 只是指向了 undefined,所以就相当于调用一个 undefined,而 undefined 只是一个原生对象,并不是函数,所以当然会报错了。
立即调用的函数表达式(IIFE)
在编译阶段,V8 并不会处理函数表达式,而 JavaScript 中的立即函数调用表达式正是使用了这个特性来实现了非常广泛的应用
JavaScript 中有一个圆括号运算符,圆括号里面可以放一个表达式,比如下面的代码:
如果在小括号里面放上一段函数的定义,如下所示:
因为小括号之间存放的必须是表达式,所以如果在小阔号里面定义一个函数,那么 V8 就会把这个函数看成是函数表达式,执行时它会返回一个函数对象。
存放在括号里面的函数便是一个函数表达式,它会返回一个函数对象,如果我直接在表达式后面加上调用的括号,这就称为立即调用函数表达式(IIFE),比如下面代码:
因为函数立即表达式也是一个表达式,所以 V8 在编译阶段,并不会为该表达式创建函数对象。这样的一个好处就是不会污染环境,函数和函数内部的变量都不会被其他部分的代码访问到。
在 ES6 之前,JavaScript 中没有私有作用域的概念,如果在多人开发的项目中,你模块中的变量可能覆盖掉别人的变量,所以使用函数立即表达式就可以将我们内部变量封装起来,避免了相互之间的变量污染。另外,因为函数立即表达式是立即执行的,所以将一个函数立即表达式赋给一个变量时,不是存储 IIFE 本身,而是存储 IIFE 执行后返回的结果。如下所示:
总结
函数声明的本质是语句,而函数表达式的本质则是表达式。
函数声明和变量声明类似,V8 在编译阶段,都会对其执行变量提升的操作,将它们提升到作用域中,在执行阶段,如果使用了某个变量,就可以直接去作用域中去查找。
V8 对于提升函数和提升变量的策略是不同的,如果提升了一个变量,那么 V8 在将变量提升到作用域中时,还会为其设置默认值 undefined,如果是函数声明,那么 V8 会在内存中创建该函数对象,并提升整个函数对象。
函数表达式也是表达式的一种,在编译阶段,V8 并不会将表达式中的函数对象提升到全局作用域中,所以无法在函数表达式之前使用该函数。函数立即表达式是一种特别的表达式,主要用来封装一些变量、函数,可以起到变量隔离和代码隐藏的作用,因此在一些大的开源项目中有广泛的应用。
思考题
代码一
第一题,编译阶段,先在全局作用域内声明变量 n 的值为undefined,然后声明函数表达式;编译阶段结束,开始执行阶段,将 1 赋值给 n,函数表达式“立即执行”,欲将 100 赋值给变量 n,但在当前作用域内没有找到 n 的声明,沿着作用域链向上查找,在全局作用域内找到了变量 n,将 100 赋给它,接着执行立即函数调用表达式中的打印语句,输出 n,为100;接着执行后面的打印语句,此时 n 的值已经由 1 被修改为了 100,因此输出 100;
代码二
第一次打印的时候,函数没有执行,输出的是全局作用域中声明并赋值好的1;然后函数执行,将全局作用域中的 n 修改为 100,因此输出 100。
原型链:V8是如何实现对象继承的?
原型继承是如何实现的?
JavaScript 的每个对象都包含了一个隐藏属性 __proto__ ,我们就把该隐藏属性 __proto__ 称之为该对象的原型 (prototype),__proto__ 指向了内存中的另外一个对象,我们就把 __proto__ 指向的对象称为该对象的原型对象
demo
对象 A 有个属性是 color,那么通过 C.color 访问 color 属性时,V8 会先在 C 对象内部查找,但是没有查找到,接着继续在 C 对象的原型对象 B 中查找,但是依然没有查找到,那么继续去对象 B 的原型对象 A 中查找,因为 color 在对象 A 中,那么 V8 就返回该属性值。
我们看到使用 和 C.color 时,给人的感觉属性 name 和 color 都是对象 C 本身的属性,但实际上这些属性都是位于原型对象上,我们把这个查找属性的路径称为原型链,它像一个链条一样,将几个原型链接了起来。
延迟解析:V8是如何实现闭包的?
V8 执行 JavaScript 代码,需要经过编译和执行两个阶段,其中编译过程是指 V8 将 JavaScript 代码转换为字节码或者二进制机器代码的阶段,而执行阶段则是指解释器解释执行字节码,或者是 CPU 直接执行二进制机器代码的阶段
编译阶段对于js代码的解析,是惰性解析,所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码,原因是:
首先,如果一次解析和编译所有的 JavaScript 代码,过多的代码会增加编译时间,这会严重影响到首次执行 JavaScript 代码的速度,让用户感觉到卡顿。因为有时候一个页面的 JavaScript 代码都有 10 多兆,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间;
其次,解析完成的字节码和编译之后的机器代码都会存放在内存中,如果一次性解析和编译所有 JavaScript 代码,那么这些中间代码和机器代码将会一直占用内存,特别是在手机普及的年代,内存是非常宝贵的资源。
例子:
当把这段代码交给 V8 处理时,V8 会至上而下解析这段代码,在解析过程中首先会遇到 foo 函数,由于这只是一个函数声明语句,V8 在这个阶段只需要将该函数转换为函数对象,如下图所示:
只是采用惰性解析无法实现闭包,在执行foo函数的时候,遇到inner函数声明,会跳过函数内部的代码,因此也就不能知道是否inner函数是否引用外部函数的变量
根据函数的调用栈跟执行上下文的销毁顺序:
从图可以看出来,在执行全局代码时,V8 会将全局执行上下文压入到调用栈中,然后进入执行 foo 函数的调用过程,这时候 V8 会为 foo 函数创建执行上下文,执行上下文中包括了变量 d,然后将 foo 函数的执行上下文压入栈中,foo 函数执行结束之后,foo 函数执行上下文从栈中弹出,这时候 foo 执行上下文中的变量 d 也随之被销毁,
因为函数是一等公民,所以函数可以作为返回值,这时候,由于 inner 函数被保存到全局变量中了,所以 inner 函数依然存在,最关键的地方在于 inner 函数使用了 foo 函数中的变量 d,按照正常执行流程,变量 d 在 foo 函数执行结束之后就被销毁了。
预解析器的引入解决闭包的问题
V8 引入预解析器,比如当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析,其主要目的有两个
第一,是判断当前函数是不是存在一些语法上的错误,如下面这段代码:
在预解析过程中,预解析器发现了语法错误,那么就会向 V8 抛出语法错误,比如上面这段代码的语法错误是这样的:
Uncaught SyntaxError: Invalid regular expression: missing /
第二,除了检查语法错误之外,预解析器另外的一个重要的功能就是检查函数内部是否引用了外部变量,如果引用了外部的变量,预解析器会将栈中的变量复制到堆中,在下次执行到该函数的时候,直接使用堆中的引用,这样就解决了闭包所带来的问题。
思考题
当调用 foo 函数时,foo 函数内部的变量 a 会分别分配到栈上?还是堆上?
第一个a在栈中,第二个形成了闭包,先在栈中存在a,然后在预解析器阶段在堆中复制了一个一样的a,调用foo函数使foo出栈栈中的a被销毁,只剩下堆中的a。
异步编程(一):V8是如何实现微任务的?
宏任务: 指消息队列中的等待被主线程执行的事件
微任务: 每个宏任务执行期间,v8都会为该代码的执行创建一个对应的微任务队列,在主线程代码执行完成,宏任务结束之前,会清空当前的微任务队列里面的任务,清空完毕后,继续检查,如果有,继续出队,继续执行,直至当前微任务队列没有微任务产生,则从宏任务队列取出下一个宏任务进行执行,并且创建对应的微任务队列,在此之前,会清空调用栈,重新创建执行上下文
在递归调用中,宏任务跟微任务的应用
如果是同步代码的递归调用,由于调用栈分配的内存是要求连续的,所以内存大小是有限的,有可能会造成调用栈溢出,而且主线程代码长时间执行,后面的其他代码快速按时顺序执行,也可能下一个宏任务队列的事件如click 滚动 页面渲染无法正常按时进行,导致页面的行为不能快速响应。
考虑通过settimeout宏任务来实现延迟递归,把每次的调用重新封装成宏任务,入队,但是这样又有一个问题,会导致前面的宏任务执行过久,导致该宏任务延迟过久执行,settimtout并不能保证在传递的定时器时间执行。
function foo() { setTimeout(foo, 0)}foo()
考虑到宏任务的调用时间不确定,如果采用微任务的话,就能保证递归函数后面的代码能够按时执行的同时,把递归代码封装成微任务,在当前宏任务结束前的微任务重复调用,也就确定了最晚调用时间,是主线程同步代码执行结束的时候,但是会有一个问题,由于当前宏任务对应的微任务队列一直有微任务(因为每次递归调用,都会往微任务队列增加微任务),无法清空也就当前宏任务无法结束,下个宏任务会被一直延迟取出,无法执行。
function foo() { return Promise.resolve().then(foo)}foo()
备注: 第一次执行的主函数js代码,就是第一次宏任务的调度执行
思考题
浏览器中的 MutationObserver 接口提供了监视对 DOM 树所做更改的能力,它在内部也使用了微任务的技术,那么今天留给你的作业是,查找 MutationObserver 相关资料,分析它是如何工作的,其中微任务的作用是什么?欢迎你在留言区与我分享讨论。
MutationObserver和IntersectionObserver两个性质应该差不多。我这里简称ob。ob是一个微任务,通过浏览器的requestIdleCallback,在浏览器每一帧的空闲时间执行ob监听的回调,该监听是不影响主线程的,但是回调会阻塞主线程。当然有一个限制,如果100ms内主线程一直处于未空闲状态,那会强制触发ob。
异步编程(二):V8是如何实现async/await的?
生成器函数是一个带星号函数,配合 yield 就可以实现函数的暂停和恢复,具体使用说明
首先拿到迭代器对象,调用result.next ()执行函数,如果遇到 yield 关键字,那么 V8 将返回关键字后面的内容给外部,并暂停该生成器函数的执行。生成器暂停执行后,外部的代码便开始执行,外部代码如果想要恢复生成器的执行,可以使用 result.next 方法,并且result.next(args); 会把arg传递给 const res = yield 'xxx' , res这个时候就是args
代码演示
依次打印出: getUserId, getUserName, name
生成器函数内部是如何实现函数的暂停和恢复的?
协程,协程是一种比线程更加轻量级的存在。
你可以把协程看成是跑在线程上的任务,一个线程上可以存在多个协程,但是在线程上同时只能执行一个协程。比如,当前执行的是 A 协程,要启动 B 协程,那么 A 协程就需要将主线程的控制权交给 B 协程,这就体现在 A 协程暂停执行,B 协程恢复执行;同样,也可以从 B 协程中启动 A 协程。通常,如果从 A 协程启动 B 协程,我们就把 A 协程称为 B 协程的父协程。
正如一个进程可以拥有多个线程一样,一个线程也可以拥有多个协程。每一时刻,该线程只能执行其中某一个协程。最重要的是,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行)。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。
ES7 引入了 async/await,这是 JavaScript 异步编程的一个重大改进,它改进了生成器的缺点,提供了在不阻塞主线程的情况下使用同步代码实现异步访问资源的能力
async + await,是通过 生成器,执行器,还有promise 实现的,往底层说,就是微任务和协程应用
async 是一个通过异步执行并隐式返回 Promise 作为结果的函数。
await 可以等待两种类型的表达式:可以是任何普通表达式 ;也可以是一个 Promise 对象的表达式。
如果 await 等待的是一个 Promise 对象,它就会暂停执行生成器函数,直到 Promise 对象的状态变成 resolve,才会恢复执行,然后得到 resolve 的值,作为 await 表达式的运算结果。
如果 await 等待的是一个非 Promise 对象,比如 await 100,那么通用 V8 会隐式地将 await 后面的 100 包装成一个已经 resolve 的对象,其效果等价于下面这段代码:
思考题
了解 async/await 的演化过程,对于理解 async/await 至关重要,在进化过程中,co+generator 是比较优秀的一个设计。今天留给你的思考题是,co 的运行原理是什么?
co源码实现原理:其实就是通过不断的调用generator函数的next()函数,来达到自动执行generator函数的效果(类似async、await函数的自动自行)。
垃圾回收(一):V8的两个垃圾回收器是如何工作的?
垃圾是怎么产生的
window.a = new Object(); window.a.b = [1,2,3]; window.a.b = 12; 根据GC roots ,之前的b 也就是[1,2,3],就是垃圾数据,因为我们无法从一个根对象遍历到这个 Array 对象。不用担心这个数组对象会一直占用内存空间,因为 V8 虚拟机中的垃圾回收器会帮你自动清理。
垃圾回收算法:可访问性(reachability)算法的描述
第一步: 通过 GC Root 来 标记空间中活动对象和非活动对象。
GC Root 有哪些:
在浏览器环境中,GC Root 有很多,通常包括了以下几种 (但是不止于这几种)
全局的 window 对象(位于每个 iframe 中);
文档 DOM 树,由可以通过遍历文档到达的所有原生 DOM 节点组成;
存放栈上变量。
第二步,回收非活动对象所占据的内存。
其实就是在所有的标记完成之后,统一清理内存中所有被标记为可回收的对象。
第三步,做内存整理。
但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片,比如接下来我们要介绍的副垃圾回收器。
一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大的连续内存时,就有可能出现内存不足的情况,所以最后一步需要整理这些内存碎片。
目前 V8 采用了两个垃圾回收器,主垃圾回收器 -Major GC 和副垃圾回收器 -Minor GC (Scavenger)。
V8 之所以使用了两个垃圾回收器,主要是受到了代际假说(The Generational Hypothesis)的影响。
代际假说
第二个是不死的对象,会活得更久,比如全局的 window、DOM、Web API 等对象
第一个是大部分对象都是“朝生夕死”的,也就是说大部分对象在内存中存活的时间很短,比如函数内部声明的变量,或者块级作用域中的变量,当函数或者代码块执行结束时,作用域中定义的变量就会被销毁。因此这一类对象一经分配内存,很快就变得不可访问;
v8 具体如何实现垃圾回收的
在 V8 中,会把堆分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象。
新生代通常只支持 1~8M 的容量,而老生代支持的容量就大很多了
副垃圾回收器 -Minor GC (Scavenger),主要负责新生代的垃圾回收。
主垃圾回收器 -Major GC,主要负责老生代的垃圾回收。
副垃圾回收器
虽然分配的新生代内存比较小,但是是使用较为频繁的垃圾回收器,因为大多数小的对象都是直接分配在新生代,在副垃圾回收器下进行垃圾回收跟晋升的。
副垃圾回收器 使用 了 Scavenge算法 来回收垃圾,将新生代平等分为两部分,一半是对象区域,一半是 活动区域
新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。
在垃圾回收过程中,首先要对对象区域中的垃圾做标记(可访问性算法);标记完成之后,就进入垃圾清理阶段。副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。
完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时,这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。
不过,副垃圾回收器每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域,复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小。
也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域,副垃圾回收器一旦监控对象装满了,便执行垃圾回收。同时,副垃圾回收器还会采用对象晋升策略,也就是移动那些经过两次垃圾回收依然还存活的对象到老生代中。
主垃圾回收器
主垃圾回收器主要负责老生代中的垃圾回收。除了新生代中晋升的对象,一些大的对象会直接被分配到老生代里。因此,老生代中的对象有两个特点:
一个是对象占用空间大;
另一个是对象存活时间长。
由于老生代的对象比较大,若要在老生代中使用 Scavenge 算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高,同时还会浪费一半的空间。所以,主垃圾回收器是采用标记 - 清除(Mark-Sweep)的算法进行垃圾回收的
首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。
接下来就是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同,主垃圾回收器会直接将标记为垃圾的数据清理掉。
对垃圾数据进行标记,然后清除,这就是标记 - 清除算法,不过对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又引入了另外一种算法——标记 - 整理(Mark-Compact)。
这个算法的标记过程仍然与标记 - 清除算法里的是一样的,先标记可回收对象,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉这一端之外的内存。你可以参考下图:
垃圾回收(二):V8是如何优化垃圾回收器执行效率的?
由于 JavaScript 是运行在主线程之上的,因此,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)。
可以看到,执行垃圾回收时会占用主线程的时间,如果在执行垃圾回收的过程中,垃圾回收器占用主线程时间过久,就像上面图片展示的那样,花费了 200 毫秒,在这 200 毫秒内,主线程是不能做其他事情的。比如,页面正在执行一个 JavaScript 动画或者触发的js 事件都无法正常响应,因为垃圾回收器在工作,就会导致这个动画在这 200 毫秒内无法执行,造成页面的卡顿 (Jank),用户体验不佳。
为了解决全停顿而造成的用户体验的问题,V8 向现有的垃圾回收器添加并行、并发和增量等垃圾回收技术,并且也已经取得了一些成效。这些技术主要是从两方面来解决垃圾回收效率问题的:
第一,将一个完整的垃圾回收的任务拆分成多个小的任务,这样就消灭了单个长的垃圾回收任务;
第二,将标记对象、移动对象等任务转移到后台线程进行,这会大大减少主线程暂停的时间,改善页面卡顿的问题,让动画、滚动和用户交互更加流畅。
第一个方案是并行回收,在执行一个完整的垃圾回收过程中,垃圾回收器会使用多个辅助线程来并行执行垃圾回收。
采用并行回收时,垃圾回收所消耗的时间,等于总体所消耗的时间(单个线程所消耗的最长时间),再加上一些同步开销的时间。
因为在执行垃圾标记的过程中,主线程并不会同时执行 JavaScript 代码,因此 JavaScript 代码也不会改变回收的过程。所以我们可以假定内存状态是静态的,因此只要确保同时只有一个协助线程在访问同一个对象就好了。
V8 的副垃圾回收器所采用的就是并行策略,它在执行垃圾回收的过程中,启动了多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域。由于数据的地址发生了改变,所以还需要同步更新引用这些对象的指针。
并行策略能增加垃圾回收的效率,能够很好地优化副垃圾回收器,但是这仍然是一种全停顿的垃圾回收方式
第二个方案是增量式垃圾回收,垃圾回收器将标记工作分解为更小的块(注意:只是标记工作分块,清理工作还是一次性完成的),并且穿插在主线程不同的任务之间执行。采用增量垃圾回收时,垃圾回收器没有必要一次执行完整的垃圾回收过程,每次执行的只是整个垃圾回收过程中的一小部分工作。
增量标记的算法,比全停顿的算法要稍微复杂,这主要是因为增量回收是并发的(concurrent),要实现增量执行,需要满足两点要求:
垃圾回收可以被随时暂停和重启,暂停时需要保存当时的扫描结果,等下一波垃圾回收来了之后,才能继续启动。
在暂停期间,被标记好的垃圾数据如果被 JavaScript 代码修改了,那么垃圾回收器需要能够正确地处理。
使用了增量标记之后,需要改进的原本的垃圾标记方法
这里我们需要知道,在没有采用增量算法之前,V8 使用黑色和白色来标记数据。在执行一次完整的垃圾回收之前,垃圾回收器会将所有的数据设置为白色,用来表示这些数据还没有被标记,然后垃圾回收器在会从 GC Roots 出发,将所有能访问到的数据标记为黑色。遍历结束之后,被标记为黑色的数据就是活动数据,那些白色数据就是垃圾数据。如下图所示:
如果内存中的数据只有两种状态,非黑即白,那么当你暂停了当前的垃圾回收器之后,再次恢复垃圾回收器,那么垃圾回收器就不知道从哪个位置继续开始执行了。
为了解决这个问题,V8 采用了三色标记法,除了黑色和白色,还额外引入了灰色:
黑色表示这个节点被 GC Root 引用到了,而且该节点的子节点都已经标记完成了 ;灰色表示这个节点被 GC Root 引用到,但子节点还没被垃圾回收器标记处理,也表明目前正在处理这个节点;
白色表示这个节点没有被访问到,如果在本轮遍历结束时还是白色,那么这块数据就会被收回。
引入灰色标记之后,垃圾回收器就可以依据当前内存中有没有灰色节点,来判断整个标记是否完成,如果没有灰色节点了,就可以进行清理工作了。
如果还有灰色标记,当下次恢复垃圾回收器时,便从灰色的节点开始继续执行。因此采用三色标记,可以很好地支持增量式垃圾回收
接下来,我们再来分析下,标记好的垃圾数据被 JavaScript 修改了,V8 是如何处理的。我们看下面这样的一个例子:
然后又执行了另外一个代码,这段代码如下所示:window.a.b = Object() //d
执行完之后,垃圾回收器又恢复执行了增量标记过程,由于 b 重新指向了 d 对象,所以 b 和 c 对象的连接就断开了。这时候代码的应用如下图所示:
这就说明一个问题,当垃圾回收器将某个节点标记成了黑色,然后这个黑色的节点被续上了一个白色节点,那么垃圾回收器不会再次将这个白色节点标记为黑色节点了,因为它已经走过这个路径了。
但是这个新的白色节点的确被引用了,所以我们还是需要想办法将其标记为黑色。
为了解决这个问题,增量垃圾回收器添加了一个约束条件:不能让黑色节点指向白色节点。
通常我们使用写屏障 (Write-barrier) 机制实现这个约束条件,也就是说,当发生了黑色的节点引用了白色的节点,写屏障机制会强制将被引用的白色节点变成灰色的,这样就保证了黑色节点不能指向白色节点的约束条件。这个方法也被称为强三色不变性,它保证了垃圾回收器能够正确地回收数据,因为在标记结束时的所有白色对象,对于垃圾回收器来说,都是不可到达的,可以安全释放。
所以在 V8 中,每次执行如 window.a.b = value的写操作之后,V8 会插入写屏障代码,强制将 value 这块内存标记为灰色。
第三个方案是并发回收,回收线程在执行 JavaScript 的过程,辅助线程能够在后台完成的执行垃圾回收的操作。主垃圾回收器就综合采用了所有的方案,副垃圾回收器也采用了部分方案
主垃圾回收器同时采用了这三种策略:
首先主垃圾回收器主要使用并发标记,我们可以看到,在主线程执行 JavaScript,辅助线程就开始执行标记操作了,所以说标记是在辅助线程中完成的。
标记完成之后,再执行并行清理操作。主线程在执行清理操作时,多个辅助线程也在执行清理操作。
另外,主垃圾回收器还采用了增量标记的方式,清理的任务会穿插在各种 JavaScript 任务之间执行。
字节飞书部门大量招人,内推码:MSC9P8X
实习生二维码
社招二维码