本文是笔者在学习《JavaScript高级程序设计》中垃圾回收那一节,并了解了V8引擎对垃圾回收算法的优化后作出的一点总结。
js垃圾回收
JavaScript是使用垃圾回收语言,也就是说执行环境负责在代码执行的时候管理内存。JS会自动实现内存管理和闲置资源的回收
标记清理
标记清除法是最常用的垃圾回收策略。简单来说,当变量进入上下文时,这个变量会被加上一个存在于上下文中的标记。当变量离开上下文时,也会加上离开上下文的标记。增加标记的方法有很多种,比如进入上下文中,可以反转某一位,或者维护一个存在上下文和不在上下文中的两个列表,可以把变量从一个列表转移到另一个列表当中。
标记过程的实现并不重要,关键的是策略
- 垃圾回收程序运行的时候,会标记内存中存储的所有变量
- 将所有在上下文中的变量,以及上下文中变量引用的变量的标记去掉
- 垃圾回收程序做一次内存清理,销毁带标记的所有值并回收它们的内存
这种策略主要是运用了一种可达性
的思想,思路大致如下
- 从根节点出发,遍历所有的对象
- 可以遍历到的节点,就是可达的
- 没有遍历到的节点,就是不可达的
在浏览器环境中,根节点有很多种,主要包括有
- 全局对象window
- 文档的DOM树
引用计数
引用计数是另一种不常用的垃圾回收策略,其思路就是对每个值都记录它被引用的次数。当引用数为0的时候,垃圾回收程序下次运行时就会回收对应的内存。
虽然引用计数法看起来非常简单,但是同时也存在一个非常大的问题,那就是循环引用
。即对象 A 有一个指针指向对象 B,而对象 B 也引用了对象 A ,如下面这个例子
function test(){ let A = new Object() // 此对象的引用计数是1(A引用) let B = new Object() // 此对象的引用计数是1(B引用) A.b = B //此对象的引用计数是2(B引用 ,A.b引用) B.a = A //此对象的引用计数是2(A引用 ,B.a引用) }
根据上述方案,它们的引用就是都是2,且函数执行完毕后内存并不会被回收,那么就会造成大量的内存不会被释放。
另外,在 IE8 以及更早版本的 IE 中,BOM
和 DOM
对象并非是原生 JavaScript
对象,它是由 C++
实现的组件对象模型对象(COM,Component Object Model),而 COM
对象使用 引用计数算法来实现垃圾回收,所以即使浏览器使用的是标记清除算法,只要涉及到 COM
对象的循环引用,就还是无法被回收掉,就比如两个互相引用的 DOM
对象等等,而想要解决循环引用,需要将引用地址置为 null
来切断变量与之前引用值的关系,如下
// COM对象 let ele = document.getElementById("xxx") let obj = new Object() // 造成循环引用 obj.ele = ele ele.obj = obj // 切断引用关系 obj.ele = null ele.obj = null
不过在 IE9 及以后的 BOM
与 DOM
对象都改成了 JavaScript
对象,也就避免了上面的问题,就可以使用统一的标记清除法
V8引擎对垃圾回收的优化
V8引擎我们经常听到也经常说,可是你真的了解V8吗?它的作用是什么,它的结构是怎么样的?
笔者了解的也十分片面,下面是我的一些总结
V8的功能
百度百科的解释如下:
V8使用C++开发,并在谷歌浏览器中使用。在运行JavaScript之前,相比其它的JavaScript的引擎转换成字节码或解释执行,V8将其编译成原生机器码,并且使用了如内联缓存(inline caching)等方法来提高性能。有了这些功能,JavaScript程序在V8引擎下的运行速度媲美二进制程序。
我们可以得出:每个浏览器都有自己的JS引擎,而谷歌的Chrome浏览器使用的是V8这种引擎。 Js引擎的作用是将JavaScript编译成本地机器代码来供CPU执行。
高级代码是如何执行的
高级代码的执行方式主要有两种,分别是解释执行和编译执行
- 解释器执行的启动速度快,但是执行时的速度慢
- 编译执行的启动速度慢,但是执行时的速度快
解释执行
首先将输入的源代码通过解析器编译成中间代码,之后直接使用解释器解释执行中间代码,然后直接输出结果,具体流程如下
编译执行
采用这种方式,也需要先将源代码转换成中间代码,然后我们的编译器再将中间代码转换成机器代码.通常编译成的机器代码是以二进制文件形式存储的,需要执行这段程序的时候直接执行二进制文件就可以了,还可以使用虚拟机将编译后的机器代码保存在内存中,然后直接执行内存中的二进制代码
V8执行Js
实际上V8没有采用某种单一的技术,而是混合编译执行和解释执行这两种手段,我们把这种混合使用编译器和解释器的技术称为JIT
技术
V8
采用了一种权衡策略,在启动过程中采用了解释执行的策略,但是如果某段代码的执行频率超过一个值,那么V8
就会采用优化编译器将其编译成执行效率更加高效的机器代码。
上述图中解释器附近有个监控机器人,这是一个监控解释器执行状态的模块,在解释执行字节码的过程中,如果发现了某一段代码会被重复多次执行,那么监控机器人就会将这段代码标记为热点代码。
当某段代码被标记为热点代码后,V8
就会将这段字节码丢给优化编译器,优化编译器会在后台将字节码编译为二进制代码,然后再对编译后的二进制代码执行优化操作,优化后的二进制机器代码的执行效率会得到大幅提升。如果下面再执行到这段代码时,那么V8
会优先选择 优化之后的二进制代码,这样代码的执行速度就会大幅提升。
不过,和静态语言不同的是,JavaScript
是一种非常灵活的动态语言,对象的结构和属性是可 以在运行时任意修改的,而经过优化编译器优化过的代码只能针对某种固定的结构,一旦在 执行过程中,对象的结构被动态修改了,那么优化之后的代码势必会变成无效的代码,这时候优化编译器就需要执行反优化操作,经过反优化的代码,下次执行时就会回退到解释器解释执行。
总结
从计算机对语言的编译和执行过程入手,我们可以将v8执行js的流程整理如下:
- 初始化基础环境
- 解析源码生成AST和作用域
- 依据AST和作用域生成字节码
- 解释执行字节码
- 监听热点代码
- 优化热点代码为二进制的机器代码
- 反优化热点代码为二进制的机器代码
V8的内存结构
从上图中我们可以看出,V8的内存空间主要分成了堆内存
和栈内存
。栈内存就是我们代码的一个执行环境,同时,堆内存也分成了好几份区域:
- large object space 大于默认定义大小的空间的变量,就会存放在这里
- code space 代码空间(JIT), 即时编译器(我们刚刚提到的一种混合编译的方式),相当于跑代码
- cell space | property space | map space (这些空间我也不太了解)
- 新生代空间
- 老生代空间
内存大小
新老生代内存空间的大小主要和操作系统有关
- 64位操作系统是1.4G,32位是0.7G
- 64位操作系统中 新生代空间是64MB,老生代是1400MB
- 32位操作系统中 新生代空间为32MB,老生代是700MB
- 最新版node(V14)的内存是2GB
为什么内存空间只分配了最多1.4G呢,相较于其他语言来说,这个内存空间并不是很大,这就要从JS语言的特性来说了。
最早的时候,JS是为了浏览器渲染使用,并且是异步单线程的,它没有考虑一些特殊情况比如node读写大文件,内存空间可能会超出1.4G。它认为JS是不需要持久化的,运行完就可以销毁,所以内存占用并不是很多。由于JS是异步单线程的,所以运行代码的时候,是不能运行垃圾回收的;运行垃圾回收的时候,也是不能运行JS代码的。如果垃圾越多,那么我们等待的时间也是越长的,你能接受这个等待的时间吗?所以,就没有必要设计那么大的内存空间。
如果我们需要使用大内存呢?有办法改变吗?
我们可以通过node设置max-old-space-size
和max-new-space-size
来更改新老生代的内存空间
新生代
在V8引擎的内存结构中,新生代主要用于存放存活时间较短的对象。新生代内存是由两个semispace(半空间)
构成的,分别是semispace from
和 semispace to
,在新生代的垃圾回收过程中主要采用了Scavenge
算法。
比如在我们新生代的From空间中,已经有了四个变量,当我们想塞进第五个变量的时候,发现from空间已经满了。那我们就会将2,5这两个变量复制进To空间中,并清除掉1,3,4三个垃圾变量。并将两个空间对调,结果如下图所示
疑问:为什么新生代采用这种方式
从上述的介绍中我们可以得出新生代内存空间的特性
- 内存大小一共是64MB,对半分每个空间就是32MB
- 老生代空间有1400MB
- 最后进行一次复制操作,将一个区的变量复制到另一个区中
- 新生代主要用于存放存活时间较短的对象,会进行频繁的删除添加等
我们一般衡量一个算法是从时间复杂度和空间复杂度来衡量的。因为新生代存放的是活动时间较短的对象,所以我们会对新生代的空间进行频繁的删除复制操作,即时间复杂度不能太高。我们可以牺牲一部分的空间复杂度来换取时间复杂度上的改进,并且我们浪费的一半空间只有32MB也不是很多,这种方法是可取的。
对象晋升
当一个对象在经过多次复制之后依旧存活,那么它会被认为是一个生命周期较长的对象,在下一次进行垃圾回收时,该对象会被直接转移到老生代中,这种对象从新生代转移到老生代的过程我们称之为晋升
。
对象晋升的条件主要有以下两个:
- 对象是否经历过一次
Scavenge(新生代)
算法 - To空间的内存占比是否已经超过25%
老生代
在老生代中,因为管理着大量的存活对象,如果依旧使用Scavenge
算法的话,很明显会浪费一半的内存。因此已经不再使用Scavenge算法,而是主要采用标记清除
和标记整理
算法
标记清除
标记清除主要分成两个阶段,标记
和清除
(哈哈哈好像那个废话文学)
在标记阶段会层序遍历(广度优先)堆中的所有对象,然后标记活着的对象,在清除阶段中,会将死亡的对象进行清除。标记清除算法主要是通过判断某个对象是否可以被访问到,从而知道该对象是否应该被回收,具体步骤如下:
- 垃圾回收器会在内部构建一个根列表,用于从根节点出发去寻找那些可以被访问到的变量。比如在JavaScript中,window全局对象可以看成一个根节点。
- 然后,垃圾回收器从所有根节点出发,遍历其可以访问到的子节点,并将其标记为活动的,根节点不能到达的地方即为非活动的,将会被视为垃圾。
- 最后,垃圾回收器将会释放所有非活动的内存块,并将其归还给操作系统。
标记整理
标记清除算法存在一个问题,就是在经历过一次标记清除后,内存空间可能会出现不连续的状态,会出现一些内存碎片的问题,导致后面如果需要分配一个大对象而空闲内存不足以分配,就会提前触发垃圾回收,而这次垃圾回收其实是没必要的,因为我们确实有很多空闲内存,只不过是不连续的。所以我们采用了一种改进后的方法标记整理
在标记完成之后,我们并没有立即做清除的操作。而是做了一次整理,将其变成一段连续的内存,然后将空出来的直接删掉清除就可以了。
先整理再清除的好处:我们先整理一次,不仅可以先把需要整理的对象的移动了,而且还覆盖了需要清除的垃圾,获得了一块连续的内存空间,而不需要一段一段的去清除我们的垃圾,只需要最后清除一整块就可以了。
全停顿标记向三色标记和增量标记的改进
刚刚我们提到标记整理的时候它会从根节点进行一次广度扫描,把所有的活动对象标记出来。一般来说,老生代会保存大量存活的对象,如果在标记阶段将整个堆内存遍历一遍,那么势必会造成严重的卡顿。
所以之后采用了三色标记和增量标记的方法。改进之后就可以实现 JS运行 -> 垃圾回收 -> JS运行 -> 垃圾回收 这种效果
三色标记
第一次,我们先将root的第一层节点变成灰色,它的儿子会变成黑色。下一次垃圾回收的时候,会将灰色节点当成临时的根节点进行遍历
然后将根节点的下一层节点变成灰色,灰色节点的下一层节点变成黑色,并把第一次遍历的节点变成白色,依次进行标记整理清除。
这样我们每一个标记一小段,就不需要等待很长的时间,感受不到垃圾回收的存在
总结
可能理解深度垃圾回收对我们解决实际业务中的需求中并没有什么用,但是我觉得深度思考的过程很重要,为什么会这么做,这么做的好处是什么,为什么要分成不同的模式?在以后学习新的知识点的过程中,我们应该也用这样的问题问自己,深度思考学习,不断的进步!