转发链接:
https://mp.weixin.qq.com/s/8ybGJH7sxWM1Mo7LmrOLdA
前言
前面小编也发布两篇关于JS的回收机制讲解:
「前端进阶」JS中的内存管理
垃圾回收(GC)那些事儿
不知道大家会不会跟我有一样的错觉,垃圾回收?跟我没啥关系啊,JavaScript 是一门自动垃圾回收的语言,不需要我费心内存管理这档子事儿。其实不是这样的,了解垃圾回收机制对我们的开发工作有着很大的帮助。
垃圾数据的产生
先来看一个例子:
const a = new Object();
a.test = new Array(10);
当 JavaScript 在执行这段代码的时候,栈中保存了 a 对象的指针,顺着这个指针可以到达 a 对象,通过 a 对象可以到达test对象,下面是一个示意图。
如果这个时候,创建一个新的对象赋给 a 的 test 属性:
a.test = new Object();
这时,之前定义的数组与 a.test 之间的关系断掉了,没有办法从跟对象遍历到这个 Array 对象,这个 Array 也不再被需要。这样就产生了垃圾数据。
其实,不论是什么程序语言,内存声明周期基本是一致的:
- 分配你所需要的内存
- 使用分配到的内存(读、写)
- 不需要的时候将其释放
所有语言的第二部分都是明确的,而第一和第三部分在底层语言是明确的,像是 C 语言,可以通过malloc() 和 free() 来分配和销毁这些内存,如果一段数据不再需要了,有没有主动调用 free() 函数来释放,会造成内存泄漏的问题。但是像是在JavaScript 这些高级语言中,这两部分基本上是隐含的。
我们称 C 语言这种由代码控制何时分配、销毁内存的策略称为手动垃圾回收。而像是 JavaScript、Java等隐藏第一三部分,产生的垃圾数据由垃圾回收器释放的策略称为自动垃圾回收。
调用栈中的垃圾回收
让我们来看一个例子
function test() {
const a = { name: 'a' };
function showName() {
const b = { name: 'b' };
}
showName();
}
test();
有一个记录当前执行状态的指针(称为 ESP)指向调用栈中的函数执行上下文。当函数执行完成之后,就需要销毁函数的执行上下文了,这时候,ESP 就帮上忙了,JavaScript 会将 ESP 下移到后面的函数执行上下文,这个下移的过程就是销毁当前函数执行上下文的过程。
堆中的垃圾回收
与栈中的垃圾回收不同的是,栈中无效的内存会被直接覆盖掉,而堆中的垃圾回收需要使用 JavaScript 中的垃圾回收器。
垃圾回收一般分为下面的几个步骤:
目前 V8 采用 可访问性(reachablility)算法来判断堆中的对象是否为活动对象。这个算法其实就将一些 GC Root 作为初始存活对象的集合,从 GC Root 对象触发,遍历 GC Root 中的所有对象。
在浏览器环境中 GC Root通常包括并不限于以下几种:
在垃圾回收领域有一个重要的术语—代际假说,它有以下两个特点:
- 大部分对象在内存中存在的时间很短,比如说函数内部的变量,或者块级作用域中的变量,当函数或块级代码块执行结束时,作用域内部定义的变量也会被销毁,这一类对象被分配内存后,很快就会变得不可用。
- 只要不死的对象,都会持续很久的存在,比如说 window、DOM、Web API 等。
既然代际假说将对象大致分为两种,长寿的和短命的,垃圾回收也顺势把堆分为新生代和老生代两块区域,短命对象存放在新生代中,反正新生代中的对象都是短命鬼,那么就没有必要分配很大的内存就管理这一块儿区域,所以新生代一般只支持 1~8M 的容量【当然,最重要的是执行效率的原因,之后会详细讲到】,那么长寿的对象放到哪里呢?老生代存放那些生存时间久的对象,与新生代相比,老生代支持的容量就大的多很多了。
补充说明:老生代内部其实还有更详细的划分:老生指针区、老生数据区、大对象区、代码区、Call区、属性Cell区、Map区等等,这里为了方便说明,简单的将堆划分成新生代与老生代。
既然非活动对象都存放在了两块区域,V8 也就分别使用了两个不同的垃圾回收器来高效的实施垃圾回收:
接下来,让我们来了解一下这两种垃圾回收器。
副垃圾回收器
通常情况下,大多数小的对象都会被分配到新生区,虽然这个区域不大,但是垃圾回收还是进行的非常频繁的。
新生代中采用 Scavenge 算法 处理,就是把新生代空间对半分为对象区域和空闲区域,新加入的对象会放到对象区域,当新生代区域快要被写满的时候就会执行一次垃圾清理的操作。
在垃圾回收的过程中,首先对对象区域做垃圾标记,标记完成后,副垃圾回收器会把存活的对象复制到空闲区域中,同时会把这些对象有序的排列起来,相当于是完成了内存整理的工作,复制后的空闲区域没有内存碎片了。完成复制之后,对象区域与空闲区域会进行角色翻转,这样就完成了垃圾回收的操作。这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去。
每次执行清理操作,都需要将存活的对象区域复制到空闲区域,复制操作需要时间成本,新生区空间设置的越大,那么每次清理的时间也就会越长,所以说,为了执行效率,一般新生区的空间都会设置的很小。
因为新生区空间不大,所以很容易就会被存活对象填满整个区域,这个时候应该怎么办呢?JavaScript 引擎为了解决这个问题,采用了对象晋升策略,简单的讲,就是经过两次垃圾回收依然存活的对象就会被移动到老生区。
主垃圾回收器
前面我们提到了,主垃圾回收器主要是负责老生区的垃圾回收,除了新生区晋升的对象,一些大的对象会被直接分配到老生区。所以老生区的对象一般有两个特点:
面对这种类型的对象,再使用新生区的 Scavenge 算法进行垃圾回收显然就不合理了,不仅复制对象时间要花费的长,还会浪费一半的空间。因此,主垃圾回收器采用 标记-清除(Mark-Sweep) 的算法进行垃圾回收的。
既然是标记-清除,那么第一步就是进行标记,从一组根元素开始递归这组根元素,在这个遍历过程中,能够到达的元素为活动对象,到达不了的元素可以判断为非活动对象,也就是垃圾数据。
第二步就是进行清除,下面是一个简单的图例,这个清除的过程可以理解为是将灰色的部分清除掉:
从图中可以很明显的看出来,如果对一块内存进行多次的标记-清除算法,就是产生大量的内存碎片,这样会导致如果有一个对象需要一块大的连续的内存出现内存不足的情况。为了解决这个问题,于是又引入了另一种算法:标记-整理(Mask-Compact)。
标记-整理 与 标记-清除 算法中,标记的步骤是一样的,只是后续不是直接对垃圾数据清理,而是先将所有存活的对象向一端移动,然后直接清理掉这一段以外的内存,
优化垃圾回收器的执行效率
JavaScript 是运行在主线程之上的,因此,一旦执行垃圾回收算法,需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕之后再恢复脚本执行,我们把这个行为称之为 全停顿(Stop-The-World)。
全停顿会带来什么问题呢?比如说,现在页面正在执行一个 JavaScript 动画,这时候执行垃圾回收,
如果这个垃圾回收执行的时间很长,打个比方,200ms,那么在这200ms内,主线程是没有办法进行其他工作的,动画也就无法执行,这样就会造成页面卡顿的现象出现。
为了解决全停顿带来的用户体验的问题,V8 团队进行多年的努力,向现有的垃圾回收器添加并行、并发和增量等垃圾回收技术,这些技术主要是从两个方面解决垃圾回收效率的问题:
- 既然一个大任务执行需要花费很长时间,那么就把它拆分成多个小任务去执行。
- 将标记、移动对象等任务转移到后台线程进行。这样大大减少主线程暂停的时间,改善页面卡顿的问题。
并行回收
既然主线程执行一次完整的垃圾回收比较耗时,这时大家就会不自觉的想到,在主线程执行任务的时候多开几个辅助线程来并行处理,这样速度不就会加快很多吗?因此,V8 引入了并行回收机制,为孤军奋战苦哈哈执行垃圾回收的主线程搬来了救兵。
采用并行回收时,垃圾回收所消耗的时间,等于总时间除以参与线程的数量,再加上一些同步开销的时间。其实,现在仍然是一种全停顿的垃圾回收模式,在执行垃圾回收的过程中,主线程并不会同步执行 JavaScript 代码,因此,JavaScript 代码不会改变回收的过程,所以我们可以假定内存状态是静态的,只需要保证同时只有一个协助线程在访问对象就好了。
V8 的副垃圾回收器就是采用的这种策略,在执行垃圾回收的过程中同时开启多个辅助线程来对新生代进行垃圾清理的工作,这些线程同时将对象中的数据移动到空闲区域,由于数据地址发生了改变,所以还需要同步更新引用这些对象的指针。
增量回收
老生代中一般存放着比较大的对象,比如说 window、DOM 等,采用并行回收完整的执行垃圾回收依然需要很长时间,这样依然会出现之前提到的动画卡顿的现象,这个时候,V8又引入了增量标记的方式,我们把这种垃圾回收的方式成为增量垃圾回收。
增量垃圾回收就是垃圾收集器将标记工作分成更小的块穿插在主线程的不同任务之间执行。这样,垃圾回收器就没有必要一次执行完整的垃圾回收过程,只要每次执行其中的一小部分工作就可以了:
增量回收也是并发执行的,所以这比全停顿要复杂的多,想要实现增量回收,必须要满足以下两点:
- 垃圾回收可以随时暂停和重启,暂停时需要保存当时扫描的结果,等下一波垃圾回收来了才能继续启动。
- 在暂停期间,如果被标记好的数据被 JavaScript 修改了,那么垃圾回收器需要能够正确的处理。
为了能够实现垃圾回收的暂停和恢复执行。V8 采用了三色标记法(黑白灰)来标记数据:
- 黑色表示这个节点被 GC Root 引用到了,而且这个节点的子节点已经标记完成了。
- 灰色表示这个节点被 GC Root 引用到了,但子节点还没有被垃圾回收器处理【目前正在处理这个节点】。
- 白色表示这个节点没有被访问到,如果本轮遍历结束,这个节点还是白色的,就表示这个数据是垃圾数据,对应的内存会被回收。
这么看来也不复杂啊?为什么说增量回收要比全停顿复杂呢?这不是骗人吗?
其实不是的,让我们来想象一下,什么是失败的垃圾回收?其实无非就是两点:
第二个倒还是小问题,如果出现了第一个问题,那可就严重了。你可能会想,什么时候会出现第一个问题呢?
Dijkstra 在论文里举了一个很顽皮的 mutator:
三个节点 ABC, C 在 AB 之间反复横跳,一会儿只有 A 指向 C,一会儿只有 B 指向 C
- 1. 开始扫描 A 时, 只有 B 指向 C,A 扫描完成变为黑色,C 是白色的。
- 2. 开始扫描 B 时,只有 A 指向 C, B 扫描完成变成黑色,C 还是白色的,
- 3. 由于 A 节点已经变成了黑色,无法继续扫描其子节点,之后继续向后扫描。
- 4. 当遍历完成后,虽然 C 是有用数据,却依然是白色的,就被当做垃圾数据干掉了,A 和 B 站在岸边爱莫能助。
为了解决这个问题,增量回收添加了一个约束条件:不能让黑色节点指向白色节点。通常使用写屏障(Write-barrier)机制来实现这个约束条件:当发生了黑色节点引用了白色节点的情况,写屏障会强制将被引用的白色节点变成灰色,这种方法也被称为强三色不变性。
所以上面的例子,当发生A.C = C 时,会将 C 节点着色并推入灰色栈。
并发回收
虽然通过三色标记法和写屏障机制能够很好的实现增量垃圾回收,但是由于这些操作都是在主线程上执行的,那么当主线程繁忙的时候,增量回收操作依然会降低主线程处理任务的吞吐量(throughput).
这个时候需要并发回收机制了,所谓并发回收,就是指主线程在执行 JavaScript 的过程中,辅助线程能够在后台执行垃圾回收的操作。
从图中可以看出来,并发回收的优势非常明显,主线程不会被挂起,JavaScript 可以自由的执行,在执行的同时,辅助线程可以执行垃圾回收的操作。
与之相对的,并发回收是这三种技术中最难的一种,主要是由于下面的原因:
- 当主线程执行 JavaScript 时,堆中的内容随时可能发生变化,从而使得辅助线程之前做的工作无效
- 主线程和辅助线程可能会在同一时间修改同一个对象,为了避免产生这种问题,必须要额外实现读写锁等功能。
尽管并发回收要额外解决上面两个问题,但是权衡利弊来说,这种方式的效率还是远高于其他方式的。
V8并不是单独的使用了上面说的某一种方式来实现垃圾回收,而是融合在一起使用,下面是一个示意图:
关于引用技术垃圾回收的彩蛋
作为老一代浏览器垃圾回收策略,应用技术也是有优势的:
- 可以立即回收垃圾,因为每个对象都知道自己的引用计数,当变为 0 时就可以立即回收。
- 最大暂停时间短(因执行垃圾回收而暂停执行程序的最长时间),因为只要程序更新指针时程序就会执行垃圾回收,内存管理的开销分布在整个应用程序执行期间,无需挂起应用程序的运行来做,因此消减了最大的暂停时间(但是增多了垃圾回收的次数)。
- 不需要沿指针查找。产生的垃圾会立即连接到空闲列表,所以不需要查找哪些对象是需要回收的。
但是引用计数的问题却是致命的,可能会导致内存泄漏,所以现在流行的浏览器都没有采用引用计数的方式了,那么,引用计数为什么会可能造成内存泄漏这么严重的问题呢?
让我们看一个实例,在 IE6、7 中使用引用计数的方式对 DOM 对象进行垃圾回收,这种方式常常会造成对象被循环引用时内存发生泄漏:
var div;
window.onload = function(){
div = document.getElementById("myDivElement");
div.circularReference = div;
div.lotsOfData = new Array(10000).join("*");
};
在这个例子中,myDivElement 这个 DOM 中的 circularReference 属性引用了 myDivElement, 这样就造成了循环引用,如果这个属性没有被显式的移除或者设置为 null,计数器中的最小值永远是1,不可能为0。如果这个 DOM 元素拥有大量的数据(如上 lotsOfData 属性),而这个数据占用的内存将永远都不会被释放,这就导致了内存泄漏。
推荐JavaScript经典实例学习资料文章
《Webpack 5模块联邦引发微前端的革命?》
《基于 Web 端的人脸识别身份验证「实践」》
《「前端进阶」高性能渲染十万条数据(时间分片)》
《「前端进阶」高性能渲染十万条数据(虚拟列表)》
《图解 Promise 实现原理(一):基础实现》
《图解 Promise 实现原理(二):Promise 链式调用》
《图解 Promise 实现原理(三):Promise 原型方法实现》
《图解 Promise 实现原理(四):Promise 静态方法实现》
《实践教你从零构建前端 Lint 工作流「干货」》
《高性能多级多选级联组件开发「JS篇」》
《深入浅出讲解Node.js CLI 工具最佳实战》
《延迟加载图像以提高Web 站性能的五种方法「实践」》
《比较 JavaScript 对象的四种方式「实践」》
《使用Service Worker让你的 Web 应用如虎添翼(上)「干货」》
《使用Service Worker让你的 Web 应用如虎添翼(中)「干货」》
《使用Service Worker让你的 Web 应用如虎添翼(下)「干货」》
《前端如何一次性处理10万条数据「进阶篇」》
《推荐三款正则可视化工具「JS篇」》
《如何让用户选择是否离开当前页面?「JS篇」》
《JavaScript开发人员更喜欢Deno的五大原因》
《仅用18行JavaScript实现一个倒数计时器》
《图文细说JavaScript 的运行机制》
《一个轻量级 JavaScript 全文搜索库,轻松实现站内离线搜索》
《10个实用的JS技巧「值得收藏」》
《细品269个JavaScript小函数,让你少加班熬夜(一)「值得收藏」》
《细品269个JavaScript小函数,让你少加班熬夜(二)「值得收藏」》
《细品269个JavaScript小函数,让你少加班熬夜(三)「值得收藏」》
《细品269个JavaScript小函数,让你少加班熬夜(四)「值得收藏」》
《细品269个JavaScript小函数,让你少加班熬夜(五)「值得收藏」》
《细品269个JavaScript小函数,让你少加班熬夜(六)「值得收藏」》
《深入JavaScript教你内存泄漏如何防范》
《手把手教你7个有趣的JavaScript 项目-上「附源码」》
《手把手教你7个有趣的JavaScript 项目-下「附源码」》
《JavaScript 使用 mediaDevices API 访问摄像头自拍》
《手把手教你前端代码如何做错误上 「JS篇」》
《一文让你彻底搞懂移动前端和Web 前端区别在哪里》
《63个JavaScript 正则大礼包「值得收藏」》
《提高你的 JavaScript 技能10 个问答题》
《JavaScript图表库的5个首选》
《一文彻底搞懂JavaScript 中Object.freeze与Object.seal的用法》
《可视化的 JS:动态图演示 – 事件循环 Event Loop的过程》
《教你如何用动态规划和贪心算法实现前端瀑布流布局「实践」》
《可视化的 js:动态图演示 Promises & Async/Await 的过程》
《原生JS封装拖动验证滑块你会吗?「实践」》
《如何实现高性能的在线 PDF 预览》
《细说使用字体库加密数据-仿58同城》
《Node.js要完了吗?》
《Pug 3.0.0正式发布,不再支持 Node.js 6/8》
《纯JS手写轮播图(代码逻辑清晰,通俗易懂)》
《JavaScript 20 年 中文版之创立标准》
《值得收藏的前端常用60余种工具方法「JS篇」》
《箭头函数和常规函数之间的 5 个区别》
《通过发布/订阅的设计模式搞懂 Node.js 核心模块 Events》
《「前端篇」不再为正则烦恼》
《「速围」Node.js V14.3.0 发布支持顶级 Await 和 REPL 增强功能》
《深入细品浏览器原理「流程图」》
《JavaScript 已进入第三个时代,未来将何去何从?》
《前端上传前预览文件 image、text、json、video、audio「实践」》
《深入细品 EventLoop 和浏览器渲染、帧动画、空闲回调的关系》
《推荐13个有用的JavaScript数组技巧「值得收藏」》
《前端必备基础知识:window.location 详解》
《不要再依赖CommonJS了》
《36个工作中常用的JavaScript函数片段「值得收藏」》
《Node + H5 实现大文件分片上传、断点续传》
《一文了解文件上传全过程(1.8w字深度解析)「前端进阶必备」》
《【实践总结】关于小程序挣脱枷锁实现批量上传》
《手把手教你前端的各种文件上传攻略和大文件断点续传》
《字节跳动面试官:请你实现一个大文件上传和断点续传》
《谈谈前端关于文件上传下载那些事【实践】》
《手把手教你如何编写一个前端图片压缩、方向纠正、预览、上传插件》
《最全的 JavaScript 模块化方案和工具》
《「前端进阶」JS中的内存管理》
《JavaScript正则深入以及10个非常有意思的正则实战》
《前端面试者经常忽视的一道JavaScript 面试题》
《一行JS代码实现一个简单的模板字符串替换「实践」》
《JS代码是如何被压缩的「前端高级进阶」》
《前端开发规范:命名规范、html规范、css规范、js规范》
《【规范篇】前端团队代码规范最佳实践》
《100个原生JavaScript代码片段知识点详细汇总【实践】》
声明:本站部分文章内容及图片转载于互联 、内容不代表本站观点,如有内容涉及侵权,请您立即联系本站处理,非常感谢!