JavaScript垃圾回收
橘猫吃不胖~ 人气:21. 垃圾为何要产生并回收
当我们写代码时创建一个基本类型、对象、函数等,都是需要占用内存的,JavaScript基本数据类型存储在栈内存中,引用数据类型存储在堆内存中,但是引用数据类型会在栈内存中存储一个实际对象的引用。
比如说我们创建了一个person
对象,然后将person
对象重新赋值:
var person = { name: "橘猫吃不胖", age: 2 } person = [1, 2, 3]; console.log(person); // [ 1, 2, 3 ]
那么原本堆内存给person
对象开辟了一个空间来存放,栈内存中存放了该引用的地址,但是在下一步中,person
对象成为了一个数组,也就是说引用地址从原来的对象变成了数组,原来的引用关系就没有了,那么这时原来的对象在堆内存中就会成为一个垃圾。
产生的垃圾如果很多,而且一直不清理,堆积起来,就会影响系统的性能,甚至可能造成系统崩溃。
2. 垃圾回收机制
JavaScript中主要的内存管理概念是可达性。
那什么是可达性呢,比如说定义一个对象:
let person = { name: "橘猫吃不胖", age: 2 } console.log(person.name, person.age); // 橘猫吃不胖 2
person
引用了这个对象,通过person.name
可以获取到“橘猫吃不胖”的值,通过person.age
可以获取到2,那么这时就可以认为“橘猫吃不胖”和2是可达的。
person = null; console.log(person.name, person.age); // TypeError: Cannot read properties of null (reading 'name')
如果将person
设置为null
,那么这两个值就没法获得了,它们就是不可达的,这时JavaScript垃圾回收机制就会自动从内存中将其清除。
那么JavaScript的垃圾回收就是定期找出这些不可达的对象,然后将其释放。那么找出这些不可达的对象有两种常用的策略:
- 标记清除法
- 引用计数法
2.1 标记清除法
标记清除法分为标记和清除两个阶段,标记阶段需要从根节点遍历内存中的所有对象,并为可达的对象做上标记,清除阶段则把没有标记的对象(非可达对象)销毁。
标记清除法的优点就是实现简单。
它的缺点有两个,首先是内存碎片化。这是因为清理掉垃圾之后,未被清除的对象内存位置是不变的,而被清除掉的内存穿插在未被清除的对象中,导致了内存碎片化。
第二个缺点是内存分配速度慢。由于空闲内存不是一整块,假设新对象需要的内存是size
,那么需要对空闲内存进行一次单向遍历,找出大于等于size
的内存才能为其分配。
标记清除算法改进—— 标记整理算法
标记清除算法的缺点主要在于内存清理之后剩余的内存位置不变而导致内存碎片化,因此可以使用标记整理算法改进。
标记整理算法的标记阶段与标记清除算法相同,都是从根节点遍历内存中的所有对象,为可达的对象打上一个标记。但是在标记结束后,标记整理算法将这些可达的对象移向内存的一端,然后清理掉边界的内存。
2.2 引用计数法
引用计数法主要记录对象有没有被其他对象引用,如果没有被引用,它将被垃圾回收机制回收。它的策略是跟踪记录每个变量值被使用的次数,当变量值引用次数为0时,垃圾回收机制就会把它清理掉。
示例代码如下:
let person = { name: "橘猫吃不胖" }; // { name: "橘猫吃不胖" } 引用次数为1 let person1 = person; // { name: "橘猫吃不胖" } 引用次数为2 person = null; // { name: "橘猫吃不胖" } 的引用次数为1 person1 = null; // { name: "橘猫吃不胖" } 的引用次数为0
引用计数法的优点是可以实现立即进行垃圾回收。当引用计数在引用值为0时,立即进行垃圾回收,这样可以达到立刻垃圾回收的效果。
它的缺点也有两个,首先它需要一个计数器,这个计数器可能要占据很大的位置,因为我们无法知道被引用数量的多少。
第二个缺点是无法解决当出现循环引用时无法回收的问题。例如a
引用了b
,b
也引用了a
,两个对象相互引用,引用计数不为0,因此无法进行内存清理,如下所示:
let a = { name: "橘猫吃不胖" }; let b = { age: 2 }; a.age = b; b.name = a;
3. V8对垃圾回收机制的优化——分代式垃圾回收机制
目前大多数浏览器都是基于标记清除算法,V8进行了一些优化加工处理,采用分代式垃圾回收机制。
3.1 新生代与老生代
原本的垃圾回收机制在每次回收时都要检查内存中所有的对象,这样的话,一些大、老、存活时间长的对象与新、小、存活时间短的对象检查频率相同,但是前者并不需要频繁进行清理,因此采用分代式垃圾回收机制。
V8中将堆内存分为新生代和老生代两区域,采用不同的垃圾回收策略进行回收。新生代的对象为存活时间较短的对象,通常只支持1~8M的容量,老生代的对象为存活时间较长或常驻内存的对象,容量通常比较大,V8整个堆内存的大小就等于新生代加上老生代的内存。
3.2 新生代的垃圾回收
新生代垃圾回收策略中,将堆内存一分为二,一个是处于使用状态的使用区,一个是处于闲置状态的空闲区。
新加入的对象都会存放到使用区,当使用区快满时,就需要执行一次垃圾清理操作,即新生代垃圾回收机制会对使用区中的活动对象(不需要被清理的对象)做标记,标记完成之后将这些活动对象复制到空闲区并进行排序(避免内存碎片化),然后将使用区清空,原来的空闲区变为使用区,原来的使用区变为空闲区。
当一个对象经过多次复制后依然存活,它将会被认为是生命周期较长的对象,会被移动到老生代的内存中,或者一个对象被复制到空闲区时,空闲区占用空间超过了25%,那么该对象也会进入老生代内存中。
新生代回收策略——并行回收
JavaScript是单线程的语言,当执行垃圾回收时,就会阻塞JavaScript脚本的执行,垃圾回收结束后再继续JavaScript脚本执行,这种情况叫做全停顿(Stop-The-World)。
如果执行一次垃圾回收需要100ms,那么脚本执行就得暂停100ms,如果执行垃圾回收的时间过长,那么就会造成页面卡顿,带来不好的用户体验。对于这样的情况,可以采用并行回收的策略。
并行回收指的是在主线程进行垃圾回收时,同时开启多个辅助线程一起执行垃圾回收。比如说一项任务一个人需要30天才能完成,那么如果安排两个人甚至多个人,可能10来天甚至更短的时间就完成了。实现并行回收可以大大降低垃圾回收的暂停时间。
新生代对象空间就采用并行策略,在执行垃圾回收的过程中,会启动了多个线程来负责新生代中的垃圾清理操作,这些线程同时将对象空间中的数据移动到空闲区域,这个过程中由于数据地址会发生改变,所以还需要同步更新引用这些对象的指针,此即并行回收。
3.3 老生代的垃圾回收
老生代的垃圾回收操作主要就是标记清除算法的步骤了,在标记阶段标记所有的可达对象,清除阶段清除掉未被标记的对象。又由于该算法会出现内存碎片的问题,因此会使用标记整理算法来优化这个过程。
老生代回收策略——增量标记与惰性清理 ①增量标记
增量就是将一次标记的过程,分成了许多次,每执行完一次就让应用逻辑执行一会儿,这样交替多次后完成垃圾回收。但是这会随之而来新的问题,首先是如何暂停每次标记去执行JavaScript代码,还有如果标记好的对象在执行js中改变了状态成为了可达或者不可达对象怎么办,V8对这两个问题对应的解决方案分别是三色标记法与写屏障。
a.三色标记法
三色标记法使用三种颜色白、灰、黑来标记对象的状态。白色表示初始状态,黑色表示已检查状态,灰色表示待检查状态。
它的过程为:
1、将所有的对象设置为白色,然后从root对象出发,将所有可以访问的对象标记为灰色,并用一个数组缓存起来;
2、遍历该数组,每次都把要遍历的对象标记为黑色并移出,并且把他的相邻节点都涂成灰色,并放入队列,直到队列为空
3、继续检查是否有灰色对象,如果有继续放入队列然后循环,直到所有的可访问对象都变成黑色
采用三色标记法后,程序在恢复执行时可以直接判断当前内存中有没有灰色节点,如果有灰色节点,那么从灰色节点开始继续执行,如果没有,直接进入垃圾清理阶段。
b.写屏障
写屏障可以解决第二个问题,如果执行任务程序时内存中标记好的对象引用关系被修改了,比如说黑色对象引用了白色对象,那么它就会将白色对象改成灰色对象,这样就可以保证下一次标记时可以正常进行。
②惰性清理
增量标记完成后,就开始清除垃圾。如果当前的可用内存可以支持快速的执行代码,就没必要立即清理内存,而且清理时没必要一次性清理完,可以按需清理。
优点:大大减少了主线程停顿的时间,让用户与浏览器交互的过程变得更加流畅
缺点:并没有减少主线程的总暂停的时间,甚至会略微增加
老生代回收策略——并发回收
并发回收指的是主线程在执行JavaScript的过程中,辅助线程能够在后台,完成执行垃圾回收的操作,辅助线程在执行垃圾回收的时候,主线程也可以自由执行
垃圾回收机制多次阅读之后,我受益匪浅,因此写该文章记录一下~
加载全部内容