在 JS 中 值类型数据存储在 栈空间中,引用类型的数据存储在堆空间中。有些数据被使用之后,就不需要了,我们需要将这些 垃圾数据 进行回收从而释放内存空间,防止这些垃圾数据堆积在 内存中。
JS 如何 回收垃圾
在 JS 中,垃圾数据 由 垃圾回收器 来释放,并不需要手动通过代码释放。数据主要存储在栈 和 堆 中,所以我们分两种情况。
1 调用栈中的数据回收
代码示例调用栈详情
调用栈详情当执行到 bar() 这段代码的时候,此时的调用栈 情形如图,此时有个一记录当前执行状态的指针(称为 ESP),指向 bar 函数执行上下文, 记录当前 正在执行到的 地方。当 bar 函数执行完, JS 引擎会 销毁 bar 函数执行上下文,指针移动到上一个 执行上下文,也就是 foo 函数的执行上下文,这个 向下移动的操作就是 销毁 bar 函数执行上下文的过程。
1 2总结:一个函数执行结束之后,JavaScript引擎会通过向下移动ESP来销毁该函数保存在栈中的执行上下文。
2 堆中数据的回收
当 foo 函数执行完成后, ESP 就指向了 全局执行上下文,bar 函数执行上下文 和 foo 函数执行上下文 都被销毁,但是 堆 当中的 两块内存依然 存储着数据。要回收堆当中的数据,就要用到 JS 中的垃圾回收器了。
在垃圾回收中有一个重要的概念:代际假说,所有的垃圾回收策略都是建立在该假说之上的。
代际假说的两个特点: 1. 大部分对象在内存中存在的时间很短,简单来说,就是很多对象⼀经分配内存,很快就变得不可访问; 2. 不死的对象,会活得更久。
垃圾回收算法很多,但不是 一种就能处理所有的情况,需要根据不同的情况,采取不同的算法,所以 JS 把 堆分为 新生代 和 老生代 两个区域, 新生代存放的是生存时间短的对象,老生代存放的生存时间久的对象。
新生代 通常只支持 1-8M 的容量,老生代的容量就大很多。 JS 使用两种不同的垃圾回收器来处理这两块区域的垃圾回收。
1. 副垃圾回收器,主要处理新生代的垃圾回收;
2. 主垃圾回收器,主要负责新生代的垃圾回收。
垃圾回收的工作流程
1. 标记空间中活动对象(还在使用的对象)和 非活动对象(可以进行垃圾回收的对象);
2. 回收非活动对象所占的内存,就是在所有标记完成后,统一清除内存中所有被标记为可回收的对象;
3. 内存整理。通常情况下,在进行垃圾回收后,内存中就会出现 大量不连续的空间,称为 内存碎片。当内存中出现大量 内存碎片后,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况。所以我们需要整理这些 内存碎片(有些垃圾回收器不会产生内存碎片,所以这步骤是可选操作)。
副垃圾回收器
主要负责新生区的垃圾回收。通常情况下,大多数小的对象会被分配到 新生区,所以这个区域大小不大,但是操作频繁。
新生区中 采用 Scavenge 算法,就是把 新生区对半划分为两个区域,一半是对象区域,一半是空闲区域
堆空间新加入的对象都会存放到对象区域,当对象区域要被写满的时候,就需要执行一次垃圾清理。
在垃圾回收过程中,首先对 对象区域 内的垃圾做标记,标记完成后,进入垃圾清理阶段, 副垃圾回收器 会把 还在使用的对象 复制到空闲区域,并且有序的排列起来,相当于完成了碎片的整理,复制完成后 空闲区域就没有了内存碎片。
完成复制后,对象区域 和 空闲区域 角色进行交换,原来的对象区域变成空闲区域,原来的空闲区域变成对象区域,这样就完成了垃圾回收操作,同时这种 角色的对调 能让新生区的这两块区域无限重复使用下去。
由于 新生区采用了 Scavenge 算法,每次执行清理操作,都要进行一次复制,如果新生区空间设置太大,那么 复制的时间成本会变大,清理时间就会更久,所以 为了执行效率,一般新生区的空间会被设置的比较小。但也就是因为空间小,所以 还在使用的对象很容易存满整个区域,为了解决这个问题, JS 采用了 对象晋升策略,就是 经过两次垃圾回收依然存活的对象,会被移到老生区。
主垃圾回收器
主要负责老生区的垃圾回收。除了新生区晋升的对象,一些大的对象会被分配到老生区,因此,老生区的对象有两个特点:占用空间大 和 存活时间长。
由于老生区的对象比较大,采用 Scavenge 算法在复制阶段 会导致 消费大量时间,效率不高,同时还会浪费一半的空间。所以,主垃圾回收器 采用 标记 - 清除(Mark-sweep)的算法进行垃圾回收。
1. 标记过程阶段,从一组根元素开始,遍历这组根元素,在这个遍历过程中,能到达的元素称为 活动对象,没有到达的元素就可以判断为 垃圾数据 。
3当 bar 函数执行结束,ESP 向下移动执行 foo 函数执行上下文,这时候遍历 调用栈,不会找到 引用 1002地址的遍历,编辑为垃圾数据,1001地址有变量在引用,标记为活动对象。
2. 垃圾清除过程,当采用 标记-清除法后,会产生 大量不连续的内存碎片,会导致无法分配到足够的连续内存,于是就产生另一种算法:标记-整理(Mark - Compact),这个过程是将 所有 还在使用的对象 都移动到一端,然后直接清除掉边界之外的内存。
标记清楚和标记整理全停顿
V8 使用 副垃圾回收器 和 主垃圾回收器处理垃圾回收, 不过 JS 是单线程,一旦执行 垃圾回收算法,其他 JS 脚本都会暂停,等待垃圾回收结束才恢复执行,我们称这种行为 为 全停顿(Stop-The-World)。
在 新生区 中的垃圾回收,因其空间小,且 活动对象少,所以全停顿的影响不但,但是 老生区不一样。如果垃圾回收 占用时间越久,那么这个过程中,主线程会被完全占用,不能处理其它事情,比如 页面正在执行 动画,因为垃圾回收器,导致 动画在一段时间无法执行,会使页面出现卡顿现象。
全停顿 为了降低 老生区的垃圾回收造成的卡顿,V8 将标记过程分为一个个子标记过程,同时让垃圾回收标记 和 JS 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为 增量标记(Incremental Marking)算法
采用 增量标记,能将一个完整的垃圾回收任务拆分成很多小任务,这些小任务执行时间较短,可以穿插在其他 JS 任务中执行,比如 动画效果,就不会使页面出现卡顿。
总结
JS 中的 简单数据类型 和 引用数据类型 分别存放在 栈 和 堆 中;
栈中的数据 通过 ESP 向下移动 销毁保存在 栈中的数据;
堆分为两块区域:新生区 和 老生区,主要通过 副垃圾回收器 和 主垃圾回收器 处理垃圾;
副垃圾回收器 采用 Scavenge 算法 将 新生区分为 对象区域 和 空闲区域,通过两个区域不断替换角色来无限使用;
主垃圾回收器 采用标记清除法,标记整理法,增量标记法 进行垃圾回收;
无论 主 副垃圾回收器,流程都是 标记 ---- 清除 ----- 整理 这几个步骤。
网友评论