v8执行js代码的过程
1、将源代码生成抽象语法树AST
源代码进行词法分析和语法分析后生成AST 。
2、生成字节码
根据AST生成字节码,字节码通过解释器转换为机器码才能执行。
3、执行代码
如果一段字节码是第一次执行,解释器会逐行执行;
如果在执行字节码过程中发现有热点函数,JIT即时编译器就会把热点函数编译为机器码,并保存起来,下次就可以直接使用了,省去了将字节码转换为机器码的过程,提升了执行效率。
垃圾回收
V8 的内存构成
新生代内存区,Young Generation 或 New Space。大多数对象都在这里老生代内存区,Old Generation 或 Old Space。常驻内存的对象在这里大对象区,Large Object Space。顾名思义。GC 不会回收这部分内存代码区,Code Space。唯一拥有执行权限的内存Map 区,Map Space。TODO: Cell 和 Map
每个区域都由内存页构成,内存页是 V8 申请内存的最小单位,也是垃圾回收的单位。除了大对象区,其他区域的内存页大小都是 1 MB。
内存的大小是有限制的。64 位环境下,新生代内存默认最大值 32 MB,老生代内存默认最大值 1.4 GB。32 位环境减半。
限制最大值的原因,一个是浏览器端一般来说不会使用很大内存,另一个是太大会影响 GC 的效率和页面响应。因为 GC 时会阻塞 JS 执行,而 1.4 GB 完整 GC 一次需要 1s 以上。这种现象叫做全停顿 Stop-The-World。
新生代内存回收
新生代中用Scavenge 算法来处理。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域(from),一半是空闲区域 (to)。
老生代内存回收
新生代中多次进行回收仍然存活的对象会被转移到空间较大的老生代内存中,这种现象称为晋升。以下两种情况:
1、在垃圾回收过程中,发现某个对象之前被清理过,那么将会晋升到老生代的内存空间中
2、 在 from 空间和 to 空间进行反转的过程中,如果 to 空间中的使用量已经超过了 25% ,那么就将 from 中的对象直接晋升到老生代内存空间中。
老生代的垃圾回收算法
1、标记-清除(Mark-Sweep)
老生代采用的是”标记清除“来回收未存活的对象。
分为标记和清除两个阶段。标记阶段会遍历堆中所有的对象,并对存活的对象进行标记,清除阶段则是对未标记的对象进行清除。
2、标记-整理(Mark-Compact)
标记清除不会对内存一分为二,所以不会浪费空间。但是经过标记清除之后的内存空间会生产很多不连续的碎片空间,这种不连续的碎片空间中,在遇到较大的对象时可能会由于空间不足而导致无法存储。 为了解决内存碎片的问题,需要使用另外一种算法 - 标记-整理(Mark-Compact) 。标记整理对待未存活对象不是立即回收,而是将存活对象移动到一边,然后直接清掉端边界以外的内存。
3、增量标记(v8优化)
为了避免出现JavaScript应用程序与垃圾回收器看到的不一致的情况,进行垃圾回收的时候,都需要将正在运行的程序停下来,等待垃圾回收执行完成之后再回复程序的执行,这种现象称为“全停顿”。如果需要回收的数据过多,那么全停顿的时候就会比较长,会影响其他程序的正常执行。
为了避免垃圾回收时间过长影响其他程序的执行,V8将标记过程分成一个个小的子标记过程,同时让垃圾回收和JavaScript应用逻辑代码交替执行,直到标记阶段完成。我们称这个过程为增量标记算法。
通俗理解,就是把垃圾回收这个大的任务分成一个个小任务,穿插在 JavaScript任务中间执行,这个过程其实跟 React Fiber 的设计思路类似。
垃圾回收器
代际假说(The Generational Hypothesis)是垃圾回收领域中的一个重要术语, V8的垃圾回收的策略也是建立在该假说的基础之上。
代际假说也很简单,主要有两个特点:
- 大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问。
- 不死的对象,会活的更久。
基于这个这个假说 V8 才会把堆分为新生代和老生代两个区域,同时设计了两个垃圾回收器:
- 副垃圾回收器 负责新生代区域的垃圾回收
- 主垃圾回收器 负责老生代区域的垃圾回收
副垃圾回收器(Scavenging)
副垃圾回收器主要用来回收新生代的垃圾,通常我们新创建的对象都会先分配到新生代内存区中。
新生代内存区会分成两个部分(space),from space 和 to space , 这两个区域本质都是一样的,都拥有两个状态 工作状态 和 空闲状态且当一个为工作状态的时候另一个一定是空闲状态。
比如我们新创建一个对象:
- 会向内存堆中的新生代去分配,假如此时新生代中的from spcae 是工作状态,那么对象会分配到from space 中。
- 经过一段时间程序运行,from space的的内存即将达到存储的上限。
- V8引擎此时执行一次垃圾清理操作,会将from space中不再使用的对象(根节点无法遍历到的对象)进行标记。
- 会将未被标记的对象进行复制,复制到空闲状态的to space中并且有序的重新排列起来,再将from space进行清空操作,同时将from space 标记为空闲状态将to space标记为工作状态。
以上就是所谓的置换也可以说是翻转过程,因为这种复制操作需要时间成本,所以新生代的空间往往并不大,所以执行的也较为频繁。
随着程序的运行,某些对象一直在被使用会持续的积压在新生代区域,为了解决这个问题,V8采用了 晋升机制 将满足条件的对象放到老生代内存区中存储,释放新生代内存区域的空间。
晋升机制的条件:
- 经历过一次
Scavenging算法,且并未被标记清除的,也就是过一次翻转置换操作的对象。 - 在进行翻转置换时,被复制的对象大于to space空间的25%。(from space 和 to space 一定是一样大的)
晋升后的对象分配到老生代内存区,便由老生代内存区来管理。
主垃圾回收器(Mark-Sweep & Mark-Compact)
主垃圾回收器主要用来回收老生代的垃圾,通常会有在新生代晋升后的对象以及初始占用空间就很大的对象会存储在老生代内存区。
主垃圾回收器采用的方法和次垃圾回收器的方法完全不同,主垃圾回收器会先使用标记 - 清除(Mark-Sweep)的算法进行垃圾回收。
引用一下李兵老师的描述:
首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。
接下来就是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同,主垃圾回收器会直接将标记为垃圾的数据清理掉。
整个标记 - 清除(Mark-Sweep)的过程相当于清理上图中红色部分区域的过程。
但是我们通过这种标记清除的方式进行内存清理会产生大量不连续的内存碎片,当我们想要存储一个大的对象的时候就可能没有足够的空间,那么除了执行 标记 - 清除(Mark-Sweep) 算法外,还通过 标记 - 整理(Mark-Compact) 算法进行垃圾回收。
标记 - 整理(Mark-Compact) 算法主要也是分两步:
- 首先同样是标记过程。
- 将未标记的对象(存活对象)进行左移,移动完成后清理边界外的内存。
V8通过标记 - 清除(Mark-Sweep) 以及 标记 - 整理(Mark-Compact) 两种算法对老生代内存区进行垃圾回收,这就是主垃圾回收器的主要工作。
垃圾回收优化策略(Orinoco)
上文中描述的V8的两个垃圾回收器所采用的方法其实在具有垃圾回收机制的编程语言中都是非常常见的。
评价一个垃圾回收机制好坏的一个重要标准是取决于执行垃圾回收时主线程挂起的时间,而V8为了优化这一部分体验(减少主线程挂起的时间),启动代号为Orinoco的垃圾回收器项目来专门进行垃圾回收策略的优化。
Orinoco共实现了三个优化
- 并行垃圾回收 (parallel)
- 增量垃圾回收 (incremental)
- 并发垃圾回收 (concurrent)
并行垃圾回收
先说第一个优化 并行垃圾回收,我们之前提到过新生代内存区 和 老生代内存区根据之前讲过的垃圾回收机制,我们可以确定在新生代内存区中的对象和老生代内存区中的对象是完全不同的,那么也就是说新生代在执行 标记->复制->清理 的操作和老生代执行 标记->清理->紧凑 的操作是没有任何依赖关系的。
于是Orinoco判断将没有依赖关系的垃圾清理逻辑(不止上述一种)通过并行执行的方式来优化减少执行垃圾回收占用主进程的时间。所以Orinoco只需要开启辅助几个辅助进程就可以同时完成垃圾清理的工作如下图:
增量垃圾回收
第二个优化 增量垃圾回收, 虽然并行垃圾回收的并行机制可以有效的减少主进程的占用,但是面对一个大的对象一次执行标记也要话很长的时间,从2011年开始V8引入了增量标记机制,也就是增量垃圾回收机制。
将一次大的任务分解为更小的块,允许应用程序在块之间运行。
这种优化对于标记的实现带来了很大的挑战,如何保存当时的扫描结果?标记好的数据如果被主线程修改了,如何正确的处理?
于是V8采用了 标记位 和 标记工作表 来实现标记。
标记位用来标记三种颜色:白色(00) 、灰色(10) 、黑色(11) ,
- 最初状态所有的对象都是 白色 也就是未被根节点引用到的对象。
- 当垃圾回收程序发现一个对象被引用会将这个对象标记为 灰色 并将其推入到 标记工作表 中。
- 标记工作表 会访问所有存在自身的 灰色 对象,并访问该对象的所有子对象,结束后会将该对象标记为黑色。
- 标记工作表 会持续的被注入灰色的对象(每发现一个新的要标记的对象都会注入到标记工作表中)
- 如果 标记工作表 中 没有了灰色 的对象,那么代表所有的对象都是 黑色 或者 白色,之后可以放心的清理掉 白色 的对象。
整个过程如图:
从根节点开始标记
遍历处理
完成后的最终形态
这个过程是不是有点绕,那我举个例子(不知道恰不恰当哈)
比如有一个小偷团伙
- 警察抓到了小偷团伙的A(标记为灰色),但是警察没有办法给他定罪只能交给法庭(标记工作表)。
- 在法庭上 A 供出了团伙的成员 B ,警察将犯罪团伙的 B 抓了回来(标记为灰色)交给了法庭(标记工作表)。
- B 说团伙还有个 C,但是 C 是冤枉的没有犯罪(默认标记白色)。
- 至此结案,会先将 B 定罪(标记为黑色)然后将 A 定罪(标记为黑色),然后A B判刑。
那回到之前的问题,标记好的数据如果被主线程修改了,如何正确的处理? V8 使用了写屏障(write-barrier) 机制来实现,这个机制也不难理解,简单来说就是强制让黑色的对象不能直接指向白色的对象。 比如我们执行一个写入操作:
// 调用 `object.field = value` 之后
write_barrier(object, field_offset, value) {
if (color(object) == black && color(value) == white) {
set_color(value, grey);
marking_worklist.push(value);
}
}
将新写入的对象从初始的白色直接变为灰色,那么 标记工作表 就没有空,那么就继续执行标记的过程,保证了正确的标记数据。
并发垃圾回收
并发垃圾回收和并行垃圾回收是完全不同的用一张图来表示
并行垃圾回收发生在主线程和工作线程上。应用程序在整个并行标记阶段暂停。
并发垃圾回收主要发生在工作线程上。当并发垃圾回收正在进行时,应用程序可以继续运行。
通常以上三种方式也不是单独存在的,而是聚合在一起使用具体如下图:
空闲时垃圾回收
空闲时垃圾回收并不属于Orinoco项目,是V8实现的一种优化策略。
通常调度程序通过对任务队列占用率的了解,以及和V8其他组件接收到的信号,使它可以估计V8何时处于空闲状态,以及可能保持多长时间。利用这个信息,V8可以分配一些优先级不高的垃圾回收任务在这个空闲时间去做。
比如V8会使用Chrome浏览器的task scheduler , 根据从Chrome其他各种组件接收到的信号以及旨在估算用户意图的各种启发式方法,动态地重新分配任务的优先级。例如,如果用户触摸屏幕,则调度程序将在100毫秒的时间段内优先处理屏幕渲染和输入任务,以确保用户界面在用户与网页交互时保持响应。
例如,如果以60 FPS进行渲染,则帧间间隔为16.6 ms。如果没有在屏幕上进行任何有效的更新,则task scheduler 将启动更长的空闲时间,该空闲时间持续到启动下一个待处理任务为止,且上限为50毫秒,以确保Chrome保持对意外用户输入的响应。
内存泄露
哪些操作会造成内存泄漏?
-
意外的全局变量:无法被回收
-
被遗忘的定时器或回调函数:导致所引用的外部变量无法被释放
-
脱离 DOM 的引用:dom 元素被删除时,内存中的引用未被正确清空
-
闭包:会导致父级中的变量无法被释放
面试题
1、垃圾回收会影响应用性能吗?
大家都知道javascript语言的一个特点就是单线程,单线程意味着执行的代码都是按顺序执行的且同一时间也只能处理一个任务,那么V8在执行垃圾回收任务的时候,其他的任务都将处于等待状态,直到垃圾回收任务结束后才能执行其他任务,如果垃圾回收任务的执行时间过长就不可避免的对用户体验造成影响,V8为了减少这种影响也做了一系列的优化,我们一起来看一下V8到底是如何做垃圾回收的并且是如何优化的。
参考:
[译] 通过垃圾回收机制理解 JavaScript 内存管理