首页>>前端>>JavaScript->JavaScript执行机制三

JavaScript执行机制三

时间:2023-12-01 本站 点击:0

垃圾回收器

在讲垃圾收回器之前,首先需要明确一些概念。 JavaScript 是弱类型的动态语言。

弱类型表示不需要告诉 JavaScript 声明的语言是什么类型,JavaScript 便能自动计算出类型;

动态表示 JavaScript 可以用一个变量存储不同类型的语言;

JavaScript 的数据类型:

基础类型:Undefined、Null、String、Boolean、Number、BigInt、Symbol

引用类型:Object

了解了上面的概率后,先来做一道题:

// 题1function show1() {  var a = 1;  var b = a;  a = 100;  console.log(a, b);}show1();// 题2function show2() {  var a = { name: "zhangsan" };  var b = a;  a.name = "lisi";  console.log(a.name, b.name);}show2();

猜猜上面的两个方法执行后,分别打印的是什么? 执行方法 show1,很明显打印的值,a为100,b为1;执行方法 show2,打印的值a、b的name值都为lisi。 这是为什么呢?方法 show1 中,声明的 a、b 变量都是基础类型,方法 show2 中的声明的 a、b 变量是 object,即为引用类型,所以区别应该就在此。 要完全弄懂这点,需要先知道 JavaScript 中,变量在内存空间中是如何存储的。

内存空间

要理解 JavaScript 代码运行过程中,是如何存储的,首先要弄清楚存储空间的种类:

代码空间:存储可执行代码的空间;

栈空间:存储基础类型的空间;

堆空间:存储引用类型的空间;

如下图所示:

代码空间会在后续的文章中进行讲解,本文不会涉及。

栈空间和堆空间

栈空间就是前面文章中反复提到过的调用栈,用来存储执行上下文。先复习一下栈空间中是如何存储执行上下文,看下面的代码:

function show() {  var a = 1;  var b = { name: "zhangsan" };  var c = a;  var d = b;}show();

当代码编译后,遇到函数调用,就会在栈空间中创建一个执行上下文,然后在按照顺序执行下去。调用 show 方法时,会在栈空间生成一段执行上下文,存储 show 方法中声明的变量,如下图所示:

从图中可以看到,代码执行到变量 b 时,判断到 b 声明的变量是一个引用类型,JavaScript 声明引用类型的变量并不是存在栈空间中,而是存在堆空间中,存储后堆空间中会生成一个地址,然后将地址写进变量 b 的值,同样在执行 d = b 时,d 的变量值存储的是堆中的地址,如图:

由图可知,b 和 d 在栈空间存储的是堆空间的引用地址,b 和 d 指向堆中同一个地址空间,因此,这儿就解释了,在开头例子中,改变了 a.name 的值,b.name 的值也会跟随改变。

到这里,我们知道栈空间存储的基础类型,堆空间存储的是引用类型。声明引用类型的变量,会在堆中开辟一片空间进行存储,并在堆中生成一个地址,栈空间对应值存的只是堆的地址。相当于做了一次中转。

了解了栈空间和堆空间存储过程,再来看上一篇文章JavaScript 执行机制二(深入之闭包),相信对闭包会有一个深入理解。

GC 如何自动回收

上面通篇在说变量是如何存的,基础类型存储在栈空间,引用类型存储在堆空间,如果空间分配后,变量就不再使用,那么这些声明变量就成为垃圾数据。内存中存的垃圾数据不回收,随着程序的运行,内存中会存在大量的垃圾数据,占用内存越大,必然会造成卡顿问题。 所以,JavaScript 会对这些垃圾数据进行自动回收,自动释放空间。 JavaScript 对于栈空间和堆空间的垃圾数据回收策略是不一样的,接下来会一步一步分析,它们是如何回收的。

调用栈的回收策略

还是先看一段代码,如下:

function show() {  var a = 1;  var b = { name: "张三" };  function showName() {    var c = 2;    var d = { name: "李四" };  }  showName();}show();

上面代码,调用 show 方法,会产生一个 show 的执行上下文,进入调用栈,该执行上下文的变量环境中有两个变量,a 是基础类型的变量,存放值为 1,b 是一个引用类型的变量,存放值为堆空间中的地址。同样调用 showName 方法时,创建一个 showName 的执行上下文,变量环境中 c 存放 2,d 存放对应在堆空间中的地址。如下图所示:

在执行 showName 函数,会创建 showName 的执行上下文,同时还会有一个记录当前执行状态的指针(ESP),指向当前正在执行的执行上下文 showName,当 showName 执行完成后,ESP 指针下移,这时 showName 执行上下文就应该销毁了,如下图所示:

所以,当执行上下文执行完成后,JavaScript 会通过向下移动 ESP 的方式来销毁该函数在内存中的执行上下文。

堆空间的回收策略

堆空间中的回收策略相对于栈空间回收策略要复杂很多,在上面的例子中,当指针下移指向全局执行上下文后,showName 和 show 的执行上下被销毁,并回收对应内存空间,b 和 d 的真实数据存放在堆空间中,并没有被回收,如下:

上图中,当 ESP 指向全局执行上下文时,栈空间的其他执行上下文所占用的空间已经被回收,但是堆空间中的变量 1003 和 1005 仍然存储在内存中。对于堆空间中存储的内存回收,就要涉及到垃圾回收器。

在 V8 中,堆分为新生代和老生代两个区域:

新生代:存放占用内存小、生存时间短的对象,空间内存大约是 1-8M 的容量,使用副垃圾回收器回收;

老生代:存放内存占用大、存放周期久的对象,空间内存相对于新生代大的多,使用主垃圾回收器回收;

无论主垃圾回收器还是副垃圾回收器的回收机制,他们都有一套相同的流程:

标记活跃对象和非活跃对象,活跃对象表示正在运行的对象,非活跃对象表示可以进行垃圾回收的对象;

在标记完成后,统一回收所有被标记可以回收的对象;

非活跃对象回收后,可能会造成内存碎片,所以需要进行内存整理,将不连续的空间整理为连续的内存空间,如果垃圾回收器没有造成内存碎片,那这一步就不会执行;

副垃圾回收器

副垃圾收回器主要负责新生代中的垃圾收回,通常针对一些小的、存活时间短的对象,会被分配到新生代中,虽然新生代中的内存空间不大,但回收频繁。

副垃圾回收器使用的算法是Scavenge算法,把对象分为两个空间,一个是对象区域(又称分配空间,Allocation Space),另一个是空闲区域(又称幸存者空间,Survivor Sapce),把空间分成大小相同的两个空间,如下:

当创建新对象时,会在对象空间中分配内存,当空间满后,会进行一次垃圾回收,使用的是就是简单的 copy 算法,首先标记活跃对象和非活跃对象,将活跃对象复制到空闲区域,并将这些对象有序的排列起来,相当于进行一次内存整理,所以复制到空闲空间后就没有内存碎片了。

复制结束后,将对象空间中的非活跃对象全部清空,此时将对象空间和空闲空间对调,原来的对象空间设置为空闲空间,原来的空闲空间设置为对象空间,这样就完成了垃圾回收的操作,同时角色翻转在新生代的两个区域中可以无限重复使用下去。

前面介绍过,新生代中的空间只有 1-8M 左右,是因为使用的 copy 操作,如果对象占用的内存较大,复制需要很多时间,所以才将新生代的内存设置的比较小。但是因为内存设置的比较少,所以很容易在角色翻转几次后,空间就装满了。JavaScript 采用了晋升策略:

经过两次垃圾回收依然存活的对象,会被移动到老生代空间中;

进行一次垃圾回收,复制到空闲区的对象占用 25%以上,那么这个对象会晋升到老生代中。设置 25%的原因,是因为角色翻转后,如果占用的内存较大,对于新对象存储和分配都会有影响;

主垃圾回收器

主垃圾回收器负责老生代的垃圾回收,对于大多数占用空间大、存活时间长的对象会被分配到老生代空间中,如果在老生代中使用 scavenge 算法进行回收,数据大复制所花费的时间较长,存活时间长,来回复制所需时间也会比较长,运行效率不高。因此,主垃圾回收器使用标记 - 清除(Mark-Sweep)算法进行垃圾回收。 简单来讲,Mark-Sweep 算法由Mark和Sweep两个阶段组成。在 Mark 阶段,垃圾回收器遍历活跃对象,将他们标记为存活。在 Sweep 阶段,回收器遍历整个堆,然后将未被标记的区域回收。

空闲链表

深入讲,Mark-Sweep 用一个链表将所有的空闲空间维护起来,这个链表叫做空闲链表(freelist)。当内存管理器需要申请内存时,便向这个链表查询,找到适合的空闲块,然后将空闲块分配出去,同时将它从空闲链表中移除。如果空间块较大,就需要将空闲块进行分割,一部分用于分割,剩余的部分重新加到空闲链表中。如图所示:

图中 A、C、D、F 是空间中的活跃对象,B、E、G 是三块空闲区域,假设 B 的大小为 20,E 的大小为 36,G 的大小为 60,他们为空闲链表统一管理。现有两个分配请求,都需要分配 30 的空间,那么需要将空间进行分割,才会满足,如下图:

E 空间的大小为 36,满足分配要求,分配后 E 所剩余的空间很小了,分配器不再将其加回空闲链表,因此 E 和 F 间就产生了一块内存碎片。第二个请求在 G 区域进行分配,分配完成后 G 所剩余的空间还比较大,因此分配器会把分割后的空闲区域,也就是 H 挂回空闲链表。上图中显示了分配完成后堆中的最终结果。

标记-清除(Mark-Sweep)
标记阶段(Mark)

讲完空闲链表,接着说 Mark-Sweep,从第一阶段开始,即 Mark 阶段。 Mark 阶段核心任务是从根引用出发,根据引用关系进行遍历,所有可遍历的对象就是活跃对象。我们需要一边遍历一边将活跃对象记录下来,遍历方式采用 BFS 和 DFS 两种策略。当遍历完成后,就找到了所有的活跃对象。 比如最开始的那段代码,当 showName 退出执行后,调用栈和堆空间如下:

从图中可以看到,当 showName 执行完后,ESP 指针向下移动,指向 show 的执行上下文,这时候遍历调用栈,找不到 1003 的引用地址,便会将这块地址标记为垃圾数据。1005 这块数据被 b 引用,标记为活跃对象。 那么活跃对象是怎么被标记的呢?常见的方法有两种:

一种是在每个对象前面增加一个机器字,采用其中的某一位作为"标记位",如果该位置位,就表示这个对象是活跃对象;如果该位未置位,那么表示这个对象是要回收的对象;

另一种方法采用标记位图,将每一个机器字映射成为图中的一个比特。在真正实现时,一般会采用两个位图,一个标记活跃对象的起始位置,另一个标记活跃对象的结束位置;

清除阶段(Sweep)

Sweep 阶段将非活跃对象清除,也就是将垃圾对象占用的空间回收起来,重新将他们放回空闲链表中。具体做法是,按照空间顺序,从头到尾扫描整个空间的所有对象,将非活跃对象的空间回收到空闲链表。下面图示简单展示了回收过程,如图:

从图中可以看到,标记清除后,堆空间中会产生大量不连续内存碎片,而这些内存碎片会导致大对象无法分配到连续的存储空间。因此又出现了标记-整理(Mark-Compact).

标记-整理(Mark-Compact)

标记整理中的标记算法和标记清除算法中的标记算法一样,整理阶段对活跃对象移动到另外一端,然后清理调边界外的内存。如下:

这里只简单讲了如何标记整理的过程,实际上,底层算法是很复杂的,感兴趣的可以了解下,G1 GC 的分区回收算法,大概意思是新生代和老生代不再是一块连续的空间,整个堆被划分成若干个大小相同的Region。Region 的类型有 Eden、Survivor、Old、Humongous 四种,而且每个 Region 都可以单独进行管理。具体挺复杂的,就不在这儿细讲了。

全停顿(Stop The World, SWT)

早期 Mark-Sweep 算法在标记活跃对象时,会将业务线程都停下来,以便垃圾回收器标记活跃对象,假如页面正在运行动画,标记所需时间为 1s,那么就会出现屏幕卡顿现象,把这种情况叫做全停顿世界停止(Stop The World)。 为了解决垃圾回收造成的卡顿问题,后使用了增量标记算法(Incremental Marking)。v8 将标记过程分为一个个子标记过程,同时让 JavaScript 代码和垃圾回收标记交替进行,直到标记完成。如下图所示:

使用增量标记算法,可以把一个完整的垃圾回收任务拆分成若干个小的任务,每个任务耗时很短,将这些任务穿插在 JavaScript 代码中间执行,这样就避免了全停顿问题。

总结

本文开始讲了声明变量在内存中是如何存储的,基础类型存在栈空间中,引用类型存在堆空间中。

栈空间的垃圾回收器,JavaScript 会通过向下移动 ESP 的方式来销毁该函数在内存中的执行上下文。如果被回收的执行上下文中,存在堆空间地址引用,对应存储在堆空间中的对象会被标记为垃圾数据。

堆空间回收机制分为两部分,新生代和老生代:

新生代的空间大小只有 1-8M,用于存放内存占用小,存放时间短的对象,使用 scavenge 算法进行垃圾回收,将新生代的空间等分为两个部分,一个是对象空间,一个是空闲空间,当新对象进来时,会进入对象空间存储,对象空间存满后会进行一次垃圾回收,将对象空间中的对象进行标记,活跃对象和非活跃对象,活跃对象复制到空闲空间,有序排列,非活跃对象回收,然后将对象空间和空闲空间交换,这样就能无限进行下去;

新生代中进行两次复制的对象和空间占用率大于 25%的对象会根据晋升策略晋升到老生代中;

老生代开始讲了空闲链表,用一个空闲链表来管理空闲空间,有空间请求,都需要向空闲链表申请,根据申请空间的大小进行分割、分配;

然后讲了标记-清除(Mark-Sweep)算法,标记清除后会造成大量不连续的内存碎片,因此使用了标记-整理(Mark-Compact)算法,将内存整理为连续的空间;

最后全停顿(Stop-The-World),使用增量标记算法(Incremental Marking)解决全停顿问题。

对于其他语言的垃圾回收器,如 Java,可能会有异同,Java 中也使用了 Mark Sweep,新生代老生代等概率,但垃圾回收涉及分代垃圾回收算法,跨代引用,并发标记算法等和v8的垃圾回收器有一定区别,感兴趣的可以自行去了解。

原文:https://juejin.cn/post/7099370331025965093


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:/JavaScript/6434.html