概要
内存管理的基础概念
如果开发者在编写代码时不够了解内存管理的相关机制,就容易编写出一些不容易察觉的内存型问题代码,因此了解其机制是非常有必要的
内存:由可读写单元组成,表示一片可操作空间
管理:人为的去操作一片空间的申请、使用和释放
内存管理:开发者主动申请空间、使用空间、释放空间
管理流程:申请 - 使用 - 释放
Note:由于 ECMAScript 并没有提供申请内存空间及释放内存空间的相关 API,因此 Javascript 实际上不支持开发者主动进行内存管理,但是我们需要知道 Javascript 内部有自己的一套内存管理机制,我们的代码会时刻通过该机制反映出程序的性能问题
Javascript中的内存管理概念
- Javascript 中内存管理是自动的,比如在谷歌浏览器中由 V8 引擎维护
- 对象不再被引用时是垃圾
- 不能从根上访问到的对象是垃圾对象
- 可以从根上开始遍历并访问到的对象就是可达对象
- 可达的标准就是从根出发是否能够被找到
- Javascript 中的根可以认为是全局变量对象
- 垃圾回收即是找到不可达对象并释放这些对象占据的空间
GC算法介绍
- GC 是垃圾回收机制的简写
- GC 通过一套或多套算法规则找到内存中的垃圾并回收释放这些垃圾占据的内存空间
- 常见的GC算法有
引用计数、标记清除、标记整理、分代回收
引用计数算法介绍
核心思想:通过为每一个对象设置单独的引用计数器,每当对象的引用关系发生变化时,修改对应计数器的值,当引用数为零时,该对象即可被认为是垃圾并可回收
引用计数器相关概念:
- 引用计数器的维护会使得 CPU 产生额外的负担
- 引用关系改变时修改引用数字
- 引用数为零时立即回收,可以实现垃圾的即时回收
- 减少程序的卡顿时间
引用计数的优缺点
虽然引用计数的概念容易理解并且普遍,但是就目前而言,很少有 JS 引擎使用它作为 GC 的算法规则了
优点:
- 发现垃圾时会立即回收,可以根据引用数是否为零决定一个对象是否是一个垃圾
缺点:
- 无法回收循环引用的对象
- 时间开销大(需要为每一个对象维护一个值随时可能发生变化的引用计数器)
标记清除算法实现原理
核心思想:将整个垃圾回收操作分为标记和清除两个阶段,第一个阶段它会从根上开始遍历所有的对象,找到活动对象并进行标记至此,第一个阶段就完成了。第二个阶段,它会再次从根上遍历所有的对象,对于没有标记的对象,都会被认为是垃圾对象并回收释放它们的内存空间
优势:相对于引用计数来说,它可以实现循环引用对象的回收
缺点:经过多轮回收之后,可能会产生内存剩余空间不连续的问题
标记整理算法
实现原理:标记整理算法可以看做是标记清除算法的增强版,二者在标记阶段工作内容一致,都会遍历所有的对象并对可达活动对象进行标记操作。二者的区别就在于清除阶段,标记整理算法会先对内存中剩余的不连续的空间进行整理操作(通过移动对象存储位置实现占据空间的集中化,从而让剩余空间集中)
认识V8
- V8 是一款主流的 Javascript 执行引擎
- V8 采用即时编译,对于 V8 之前的 Javascript 引擎,需要先将 Javascript 源代码转为字节码,之后再转为机器码执行。而对于 V8 来说,可以直接将源码转为机器码去执行,编译速度大大提升
- V8 的内存设限,对于64位的操作系统,最大不超过 1.5G,而对于 32 位的操作系统,这个上限值是 800M
- V8 设置内存上限制的原因:V8 为浏览器而生,对于网页应用,1.5G 的上限值绰绰有余,其次,V8 内部采用的 GC 回收机制决定了采用这样的大小是合理的,官方曾经做过测试,当google浏览器内存空间使用达到 1.5G 时,V8 的 GC 采用增量标记算法完成回收需要10毫秒,而采用标记清除则需要1秒,从用户体验角度来说,1秒是一个可以明显感知的时间长度了,从这两个角度考虑,V8 有这样的内存上限设置。
V8垃圾回收策略
- 采用分代回收的思想
- 内存分为新生代、老代,不同代采用的回收方式是不一样的
- V8 中会用到的 GC 算法:分代回收、空间复制、标记清除、标记整理、增量标记
V8如何实现新生代内存区域回收
V8 将内存区域划分为新生代和老代,其中新生代只占有很少的一块区域,并且这块区域还会被等分为二,其中一块用于存储活动对象(From),另一块则处于空闲(To)
新生代对象区域存储的是存活时间较短的变量对象,例如函数局部作用域中建立的变量对象,正常情况下在函数调用完成后就会被回收。而老代对象存储区域里存储的对象大都生命周期较长,如全局变量,该区域需要执行检查的频率就相对低很多,提高了回收效率
其实这种思路很类似于我们放置书籍的行为,将常用的、阅读频率高的书放在小书桌前,而冷门的、不经常阅读的书籍放在大书柜里,因为小书桌空间小,易于搜素,而大书柜空间大,找东西就相对不方便
对于 From 和 To 空间来说,活动对象通常都存储在 From 空间里,To 空间则处于空闲中,而当 From 空间不足时,便会对 From 空间进行标记整理后(复制前,会进行标记和整理两步操作,防止产生碎片化的空间)原封不动的复制到 To 空间,并将 From 空间的区域全部回收,这时 From 空间会和 To 空间完成置换,原来的 From 变为 To,原来的 To 变成了 From,这样就完成了新生代内存区域的回收操作
Note:经过至少两轮的回收后,如果某个活动对象在新生代区域中依然存活着,它会被移动至老代存储区域中
Note:某次回收时如果 To 空间的内存使用率达到了25%,这时新生代存储空间的所有对象都会被整体移动到老代存储区域中存放
V8如何实现老代内存区域回收
老代存储空间有大小限制,在64位操作系统中为1.4G,而32位操作系统中则是700M
老代内存区域存储的都是存活时间较长的对象,如全局变量,被全局变量引用的闭包,它主要使用标记清除算法完成内存空间的回收,这会导致剩余空间碎片化的问题,不过虽然如此,由于空间足够大,并且标记整理算法消耗的时间大于标记清除,从空间换时间的角度考虑,它大多数情况下依然会使用标记清除算法
当新生代存储区域的内容向老代空间移动并且老代存储区域的剩余空间不足以容纳这些转移对象时,便会采用标记整理算法去整理那些碎片化的空间进行空间优化
老代存储区域还会使用增量标记的方法来提升回收效率,如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。所以引擎试图将垃圾收集工作分成几部分来做。然后将这几部分会逐一进行处理。这需要它们之间有额外的标记来追踪变化,但是这样会有许多微小的延迟而不是一个大的延迟。这种方式被称作增量标记,可以有效解决清除大对象集回收时间过长(其实最长也不过1秒)的问题
细节对比
- 新生代区域垃圾回收采用空间换时间,因为其采用了复制算法,也就意味着每时每刻都有一半的内存空间是处于空闲状态,不过由于新生代存储区域空间本来就占比很小,因此,这种算法使用小部分的空间浪费换得了回收效率的极大提升
- 既然复制更快,为什么老代存储区域不使用这种算法,因为老代存储区域空间较大,如果使用复制,就必须要有一半的空间(至少几百兆)时刻处于空闲中,这样做会极大地浪费空间,其次,老代存储区域存放的数据可能会很多,复制算法随着数据的增多也需要更多的时间,因此,老代存储区域不适合复制算法