JS垃圾内存回收机制也是面试中常考的一道题了,虽然是现代开发者基本上不需要操心垃圾内存回收的事情了,毕竟这玩意是自动回收的。
但是作为基础,还是了解一下比较好,省的给面试官一种基础不牢的感觉。
正文
以下大多是文字版,看着容易犯困,个人推荐B站的视频:【JavaScript】代码的垃圾自动回收_哔哩哔哩_bilibili
这个UP讲的挺不错的,算是入门篇,若是进阶,推荐:19【JS深度指南】垃圾回收、变量声明周期、标记清除、引用计数。不过这篇讲的太过深入,新人入门的话,不推荐观看。
我这里这篇文档最多只算是个人笔记,若有不对之处,还请指教。
不过,这篇视频依然介绍的是比较浅层的,如果遇到较真的面试官恐怕还是不够,个人更推荐这篇掘金的文档:一文让你彻底搞懂JS垃圾回收机制,这个作者很厉害,不仅仅是这篇文,其他的文更是十分的推荐。
什么是垃圾内存
在正式进入垃圾内存主题之前,先了解几个小概念。
内存管理
不管什么样的编程语言,在代码执行过程中都是需要给它分配内存的,不同的是某些编程语言需要我们自己手动的管理内存,某些编程语言会可以自动帮助我们管理内存。
像 C 语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()
和free()
。
相反,JavaScript 是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放,释放的过程称为垃圾回收。
内存生命周期
在JavaScript中的内存管理中生命周期分为三个阶段,当然其他语言也是一样的:
- 分配内存:当我们申请变量、函数、对象的时候,系统会自动为它们分配内存;
- 内存使用:即读写内存,也就是使用变量、函数等;
- 内存回收:使用完毕,由垃圾回收机制自动回收不再使用的内存;
通过下面的代码我们来简单分析一下整个内存管理的生命周期:
1 | // 分配内存 |
上面这些num,str,foo
就是就是使用者
,我们都知道,JavaScript数据类型分为基础数据类型
和引用数据类型
:
基础数据类型
:拥有固定的大小,值保存在栈内存
里,可以通过值直接访问引用数据类型
:大小不固定(可以加属性),栈内存
中存着指针,指向堆内存
中的对象空间,通过引用来访问
由此,我们可以发现,垃圾内存回收这个概念其实是针对于引用数据类型而产生的。
- 由于栈内存所存的基础数据类型大小是固定的,所以栈内存的内存都是
操作系统自动分配和释放回收的
- 由于堆内存所存大小不固定,系统
无法自动释放回收
,所以需要JS引擎来手动释放这些内存
GC,垃圾内存回收
GC
是 Garbage Collection
,垃圾内存回收,程序过程中会产生很多 垃圾
,这些垃圾是程序不再使用的内存或者一些不可达的对象,而 GC
就是负责回收垃圾的,找到内存中的垃圾、并释放和回收空间。
在JS,JAVA,Python等语言中,都是有垃圾内存回收机制的,通过自动内存管理实现内存分配和闲置资源回收,所以开发者往往不会关注垃圾内存回收这块的机制。
而在 C
和 C++
等语言中,跟踪内存使用对开发者来说是个很大的负担,也是很多问题的来源。
整个过程是周期性的,即垃圾回收程序每隔一定时间或者说代码执行过程中某个预定阶段的收集时间就会自动运行。
垃圾回收过程是一个近似但不完美的方案,因为某块内存垃圾回收是否还有用,属于 不可判定的
问题,意味着靠算法是解决不了的。
要进行垃圾内存回收的原因
在弄明白了什么是垃圾内存之后,以及浏览器的一些简单做法之后,我们还是得了解一下为什么会要设计出垃圾内存回收的原因。
在Chrome中,V8被限制了内存的使用(64位约1.4G/1464MB , 32位约0.7G/732MB)
,为什么要限制呢?
- 表层原因:V8最初为浏览器而设计,不太可能遇到用大量内存的场景
- 深层原因:V8的垃圾回收机制的限制(如果清理大量的内存垃圾是很耗时间,这样回引起JavaScript线程暂停执行的时间,那么性能和应用直线下降)
前面说到栈内的内存,操作系统会自动进行内存分配和内存释放。
而堆中的内存,由JS引擎(如Chrome的V8)手动进行释放,当我们的代码没有按照正确的写法时,会使得JS引擎的垃圾回收机制无法正确的对内存进行释放(内存泄露),从而使得浏览器占用的内存不断增加,进而导致JavaScript和应用、操作系统性能下降。
浏览器内存回收机制
在浏览器的发展历史上,用到过两种主要的标记策略:标记清理
和 引用计数
。
引用计数
引用计算的核心思想是对每个值都记录它被引用的次数。
声明变量并给他赋一个引用值时,这个值的应用数为1,如果同一个值又被赋给另一个变量,那么引用数加 1。
类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减 1。当一个值的引用数为 0 时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。
垃圾回收程序下次运行的时候就会释放引用数为 0 的值的内存。
引用计数有一个严重的问题,就是循环引用,所谓的循环引用,就是对象 A
有一个指针指向对象 B
,而对象 B
也引用了对象 A
,比如:
1 | function foo() { |
在这个例子中,A
和 B
通过各自的属性相互引用,意味着它们的引用数都是2。
在 标记清理策略下,这不是问题,因为在函数结束后,这两个对象都不在作用域中。
而在引用计数策略下,A
和 B
在函数结束后还会存在,因为它们的引用数永远不会变成0。如果函数被多次调用,则会导致大量内存永远不会被释放。
引用计数的优势:
- 实现简单,垃圾对象便于辨识,当被引用数值为0时,对象在变成垃圾的时候就立刻被回收。
- 判定效率高,回收没有延迟性,‘程序’不会暂停去单独使用很长一段时间的GC,那么最大暂停时间很短。
引用计数的缺点:
- 需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
- 时间开销大,因为引用计数算法需要维护引用数,一旦发现引用数发生改变需要立即对引用数进行修改;
- 最大的缺点还是无法解决循环引用的问题;
看一个例子,很鲜明的表示了循环引用对计数法造成的影响。
1 | function foo() { |
foo
在执行完成之后理应回收foo
作用域里面的内存空间,但是因为 A
里面有一个属性引用 B
,导致B
的引用次数始终为1,B
也是如此,而又非专门当做闭包
来使用,所以这里就应该使A
和B
被销毁。
因为算法是将引用次数为0
的对象销毁,此处都不为0,导致GC不会回收他们,那么这就是内存泄漏
问题。
一直手动解决的办法就是把变量设置为 null
实际上会切断变量与其之前引用值之间的关系。
当下次垃圾回收程序运行时, 这些值就会被删除,内存也会被回收。
标记清理
在JavaScript中,最常用的是垃圾回收策略是 标记清理
。
当变量进入上下文,比如在函数 内部声明一个变量时,这个变量会被加上存在于上下文中的标记。
而在上下文中的变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。
当变量离开上下文时, 也会被加上离开上下文的标记。
标记清理分为两个阶段:
- 标记阶段: 把所有活动对象做上标记;
- 清除阶段: 把没有标记或者说非活动对象销毁;
给变量加标记的方式有很多种。
比如,当变量进入上下文时,反转某一位;
或者可以维护在 上下文
中和 不在上下文中
两个变量列表,可以把变量从一个列表转移到另一个列表。
标记过程的实现并不重要,关键是策略。
引擎在执行 标记清除算法
时,需要从出发点去遍历内存中所有对象去打标志,而这个出发点就是 根对象
,在浏览器中你可以理解为 windows
,整个标记清除算法大致过程就像下面这样:
- 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为0;
- 然后从
根对象
开始深度遍历,把不是垃圾的节点改成1; - 清除所有标记为0的垃圾,销毁并回收它们所占用的内存空间;
- 最后把内存中的所有对象标志修改为0,等待下一轮垃圾回收;
如下图,标记清除算法会把最下面的那两个小垃圾清除掉:
标记清除算法的优点:
- 实现简单,打标记也就是打或者不打两种可能,所以就一位二进制位就可以表示;
- 解决了循环引用的问题;
标记清除算法的缺点:
- 内存碎片化(内存零零散散的存放,造成资源浪费);
- 再分配时遍次数多,如果一直没有找到合适的内存块大小,那么会遍历空闲链表(保存堆中所有空闲地址空间的地址形成的链表)一直遍历到尾端;
- 不会立即回收资源;
结语
尽管我已经明白了垃圾内存的回收机制,但是开发时候依然感觉很少用的到。
而且JS毕竟也是有垃圾内存自动回收机制的语言,实际开发过程中,心智负担其实没有那么严重。
而且,看到各路大佬提到V8引擎的中的垃圾内存回收机制。。。说实话,有点害怕,这水有点深。
参考
内存管理 - JavaScript | MDN (mozilla.org)