JavaScript垃圾回收机制

117 阅读8分钟

前言

javascript引擎的内存空间主要分为栈和堆。

栈是临时存储空间,主要是存储局部变量和函数调用。

基本数据类型(Number,Boolean,String,Null,Undefined,Symbol,Bigint)保存在栈内存中。引用类型数据保存在堆中,引用数据类型的变量(指针)是一个指向堆内存中实际对象的引用,存在栈中。

基本数据类型赋值,系统会为新的变量在栈内存中分配一个新值。
引用类型赋值,系统会在栈内存中分配一个值,而这个值仅仅只是指向同一个对象的一个引用,和原对象的引用都指向堆内存中的同一个对象

对于函数,解释器创建了“执用栈”来记录函数的调用过程。每调用一个函数,解释器就可以把该函数添加进执用栈,解释器会为被添加进来的函数创建一个栈帧(用来保存函数的局部变量以及执行语句)并立即执行。如果正在执行的函数调用其他函数,新函数会继续被添加到执行栈,函数执行完毕,对应的栈帧被销毁(弹出)。

查看两种执行栈的方法:

  1. 使用console.trace()向web控制台输出一个堆栈跟踪。
  2. 浏览器开发者工具断点调试。

栈虽然轻量,在使用时创建,结束后销毁,但也不是无限增长的,被分配的执行栈空间被占满时,就会引起“栈溢出”的错误。

堆空间存储的数据比较复杂,大致可以分为5个区域:代码区(code space)、Map区(map space)、大对象区(large object space)、新生代(new space)、老生代(old space)。

新生代内存是临时分配的内存,存活时间短,老生代内存属于常驻内存,存活时间长。

扩展
为什么基本数据类型存储在栈中,引用数据类型存储在堆中?

javascript引擎需要用栈来维护程序执行期间的上下文状态,如果栈空间很大的话,所有数据都存放在栈空间里,这样会影响到上下文切换的效率,影响到整个程序的执行效率。

简介

垃圾回收又称为GC(Garbage Collecation)。在编写javascript程序的时候,开发者不需要手动跟踪内存的使用情况,只要按照标准书写javascript代码。javascript程序运行所需内存的分配以及无用内存的回收完全是自动管理的。javascript的自动垃圾回收机制的原理为:

找出那些不再使用的变量,然后释放其占用的内存。
垃圾收集器会按照固定的时间间隔(或者预定的收集时间)周期性的执行此操作。

javascript中的栈内存与堆内存

  • 基本类型值在内存中占固定大小的空间,因此被保存在栈内存中;
  • 引用类型值是对象,保存在堆内存中。包含引用类型值得变量实际包含并非对象本身,二是指向该对象的指针。一个变量从另一个变量复制引用类型的值时,复制的也是该对象的指针。

局部变量的声明周期

局部变量只有在函数执行的过程中存在 javascript中的垃圾收集器必须跟踪每个变量是否有用,需要为不在有用的变量打上标记,用来回收其占用的内存。标识无用变量的策略有两个:

  • 标记清除
  • 引用计数

标记清除

标记清除是javascript中最常用的垃圾回收方式,运行机制如下:

  • 当变量进入环境时,就将其标记为“ 进入环境 ”。
  • 当变量离开环境时将其标记为“ 离开环境 ”。

标记清除采用的手机策略为:

  • javascript中的垃圾收集器运行时会给存储在内存中的所有变量都加上标记。
  • 然后去掉环境中的变量以及被环境中的变量引用的变量的标记。
  • 此后,再被加上标记的变量被视为准备删除的变量。
  • 最后,垃圾收集器完成内存清除,销毁那些带标记的值并回收其占用的内存空间。

2008年之前,IE、Firefox、Opera、Chrome 和 Safari 的 JavaScript实现使用的均为 标记清除式的垃圾回收策略,区别可能在垃圾收集的时间间隔

引用计数

引用计数是另一种垃圾回收策略。引用计数的本质是跟踪记录每个值被引用的次数。其执行机制如下:

  • 当声明一个变量并将一个引用类型赋值给该变量时,这个值得引用次数为1。
  • 若同一个值(变量)又被赋值给另一个变量,则该值得引用次数加1。
  • 但是如果包含对这个值引用的变量又取得另外一个值,则这个值得引用次数减1。
  • 当这个值得引用次数为0时,则无法再访问这个值,就可回收其占用的内存空间。

垃圾收集器下次运行时,会释放那些引用次数为0的值所占用的内存;
引用计数存在一个致命的问题循环引用。循环引用是指,对象A中包含一个指向B的指针,而对象B中也包含一个指向对象A的引用。示例:

function code() {
    var A = new Object();
    var B = new Object();
    A.b = B;
    B.a = A;
}

JavaScript V8 引擎的垃圾回收机制

在javascript脚本中,绝大多数对象的生存周期很短,只有部分对象的生存周期较长。所以,V8中的垃圾回收主要使用的是分代回收

  • 对象最初会被分在新生区(代)*(1-8M),新生区的内存分配只需要保有一个指向内存区的指针,不断根据内存大小进行递增,当指针达到新生区的末尾,会有一次垃圾回收清理(小周期),清理掉新生区不在活跃的死对象。

  • 对于超过 2 个小周期的对象,则将其移动至老生区(代)。老生区 标记-清除或 标记-紧缩的过程(大周期)中进行回收。 大周期进行的并不频繁,一次大周期通常是在移动足够多的对象至老生区后才会发生。

新生代内存回收

新生代中用 Scavenge 算法来处理。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域(from),一半是空闲区域 (to)。
新的对象会首先被分配到 from 空间,当进行垃圾回收的时候,会先将 from 空间中的 存活的对象复制到 to 空间进行保存,对未存活的对象的空间进行回收。
复制完成后, from 空间和 to 空间进行调换,to 空间会变成新的 from 空间,原来的 from 空间则变成 to 空间。这种算法称之为 “Scavenge”。 新生代内存回收频率很高,速度也很快,但是空间利用率很低,因为有一半的内存空间处于"闲置"状态。

老生代内存回收

新生代中多次进行回收仍然存活的对象会被转移到空间比较大的老生代内存中,这种现象称为晋升。以下两种情况:

  1. 在垃圾回收过程中,发现某个对象之前被清理过,那么将会晋升到老生代的内存空间中。

  2. 在from空间和to空间进行翻转的过程中,如果to空间中的使用量已经超过25%,那么就将from中的对象直接晋升到老生代内存空间中。

标记-清除(Mark-Sweep)

  • 老生代采用的是“标记清除”来回收未存活的对象。
  • 分为标记清除两个阶段。标记阶段会遍历堆中所有的对象,并对存活的对象进行标记,清除阶段则是对未标记的对象进行清除。

标记-整理(Mark-Compact)
标记清除不会对内存一分为二,所以不会浪费空间。但是经过标记清除之后的内存空间会生产很多不连续的碎片空间,这种不连续的碎片空间中,在遇到较大的对象时可能会由于空间不足而导致无法存储。为了解决内存碎片的问题,需要使用另外一种算法:标记-整理(Mark-Compact)。标记整理对待未存活对象不是立即回收,而是将存活对象移动到另一边,然后直接清理掉端边界以为的内存。

增量标记

  • 为了避免出现javascript应用程序和垃圾回收器看到不一致的情况,进行垃圾回收的时候,都需要将正在运行的程序停下来,等待垃圾回收执行完毕之后再恢复程序的执行,这种现象称为 全停顿 。如果需要回收的数据过多,那么停顿的时间就会很长,会影响其他程序的正常执行。
  • 为了避免垃圾回收时间较长影响其他程序执行,V8将标记过程分成一个个小的子标记过程,同时让垃圾回收和javascript程序代码交替执行,知道标记阶段完成,这个过程我们成为 增量标记 算法。 其实就是javascript把垃圾回收这个大任务分解成一个一个的小任务,和javascript程序交替穿插执行。