深度理解浏览器垃圾回收及优化策略
网络上关于浏览器内存管理的文章颇多,但总体来说内容基本如下:
-
GC
的两种算法,引用计数法及标记清除法。- 引用计数法潜在问题——循环引用
-
常见的内存泄漏
- 全局变量
- 定时器未清除
- 闭包
-
DOM
引用 - 等等……
本文将尝试梳理一些其他问题:
- 在常见的实现中(如
V8
),内存如何分配及管理的?GC
是如何执行的? - 如何减少
GC
触发次数? -
GC
不定时触发,那GC
的触发规律是什么? - 在
GC
的执行及触发规律下,有哪些优化GC
的技巧/策略? - 在极端边界情况下,有哪些优化
GC
的技巧/策略? -
GC
算法在日常开发工作中的应用? - 如何自己实现一个内存管理机制?
- 内存泄漏如何排查?
- 其他注意事项?
GC
的两种回收算法
引用计数法
虚拟机记录对象引用次数,当一个对象被 0
引用,会被标记为“可回收内存垃圾”。
-
问题:
- 循环引用问题
引用计数法无法解决循环引用问题,如a.pro = b; b.pro = a
。此时会造成无法回收。
- 循环引用问题
标记清除法
标记-清除算法包含三个步骤:
- 根:垃圾回收器会构建出一份所有根变量的完整列表。
- 随后,算法会检测所有的根变量及他们的后代变量并标记它们为激活状态(表示它们不可回收)。任何根变量所到达不了的变量(或者对象等等)都会被标记为内存垃圾。
- 最后,垃圾回收器会释放所有非激活状态的内存片段然后返还给操作系统。
现代浏览器基本都采用标记清除法。
V8 中的垃圾回收算法
-
内存分代与弱代假设
- 多数对象的生命周期短。
- 生命周期长的对象,一般是常驻对象。
- 新生代中存储存活时间较短的对象,
32
位系统下空间大小为16MB
,64
位系统下为32MB
。 - 老生代存储存活时间较长或常驻内存的对象,
32
位系统下空间大小约为700MB
,64
位系统下约为1.4GB
。 -
Node
在启动时,可通过--max-old-space-size
或--max-new-space-size
设置大小。-
node --max-old-space-size=1700 test.js
// 单位为MB
-
node --max-new-space-size=1024 test.js
// 单位为KB
-
- 新生代平均分成两块相等的空间,叫作
SemiSpace
。每块为8MB
(32
位系统)|16MB
(64
位系统)。
-
V8
中的GC
算法-
Scavenge
算法
用于新生代中的对象回收,牺牲空间换取时间。具体实现采用Cheney
算法。-
Cheney
算法
采用复制的方式实现垃圾回收算法。将堆内存一分为二,每一部分称为SemiSpace
。一个使用(又称From
空间),一个闲置备用(又称To
空间)。
先在From
空间中分配内存,GC
时,检查From
空间中的存活对象,将存活对象复制到To
空间中。复制后,From
空间和To
空间角色对换。 -
优点:
- 只复制存活对象,时间效率表现优异。
-
缺点:
- 只能使用堆内存的一半,不能大规模用于所有
GC
算法。只适用于新生代空间GC
算法。
- 只能使用堆内存的一半,不能大规模用于所有
- 对象晋升
在一定条件下,将新生代中存活周期长的对象移动到老生代中。 -
对象晋升触发条件
- 对象经历过
Scavenge
回收,若已经历过一次,则会将该对象从From
空间复制到老生代空间。 -
To
空间内存占比。ScavengeGC
时,若To
空间已使用超过25%
,则该对象直接晋升到老生代空间。
- 对象经历过
-
-
标记清除法(
Mark-Sweep
)GC
时,遍历对重所有对象,标记活着的对象。在清除阶段中,清除未被标记的对象。
存在两个标记位图:- 已分配标记位图,针对内存页中每一个可分配的字,使用
1bit
表示其是否已分配出去,可用于快速扫描活跃内存 - 状态标记位图,
V8
中对象大小以2
个字长对齐,状态标记位图以2bit
为一个单元,共可表示4
种状态
- 已分配标记位图,针对内存页中每一个可分配的字,使用
-
`GC` 标记阶段采用三色标记法,将颜色信息记录在状态标记位图中:
- 白色,尚未被 `GC` 发现
- 灰色,已被 `GC` 发现,但邻接对象还未处理完
- 黑色,已被 `GC` 发现,且邻接对象已处理完
初始状态,内存页中所有对象都是白色,标记采用深度优先搜索算法,步骤如下:
- 根可达对象标记为灰,并 `push` 进栈
- `pop` 出栈一个对象,标记为黑
- 将对象的邻接对象标记为灰,并 `push` 进栈,回到步骤二直至栈为空
上述步骤遇到大对象可能导致栈溢出,做法是当出现溢出时只标记为灰但不 `push` 进栈,栈为空后 `GC` 会再次扫描,将之前的灰色对象 `push` 进栈继续处理。因此若程序创建过多的大对象,就会触发多次堆扫描,影响 `GC` 效率。
最终状态,内存页中的对象全部被标记,不存在灰色,白色为可回收,黑色为不可回收。
- 优点:
- 只清除死亡的对象。老生代中,死亡对象存活比例较小。故执行效率较高。
- 缺点:
- 标记清除回收后,内存空间会触发不连续状态(碎片空间)。会对后续内存分配造成影响,若需要分配一个大对象且所有碎片空间不满足,则会提前触发 GC,而这次 GC 是不必要的。
-
标记整理(
Mark-Compact
)
将活着的对象往一端移动,移动完成后,直接清理掉边界外内存。-
优点:
- 没有碎片空间。
-
缺点:
- 需要移动对象,执行效率慢。
-
在 V8
中,标记清除(Mark-Sweep
)和标记整理(Mark-Compact
)算法是结合使用,主要使用标记清除,在空间不足以对从新生代中晋升过来的对象进行分配时,使用标记整理算法。
三种 GC
算法对比:
回收算法 | 标记清除(Mark-Sweep ) |
标记整理(Mark-Compact ) |
Scavenge |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空间开销 | 少(有碎片) | 少(无碎片) | 双倍空间(无碎片) |
是否移动对象 | 否 | 是 | 是 |
- 增量标记(
Incremental Marking
)
在上述三种算法执行时,都需要将暂停应用逻辑(JS
执行),GC
完成后再执行应用逻辑。此时会有一个停顿时间(称为全停顿,stop-the-world
),在新生代回收时,因默认配置较小且存活对象较少,故停顿时间较小。老生代中,空间配置大 & 存活对象较多,停顿时间则会很长。故
V8
采用了增量标记方法,将标记拆分为多个小的“步进”,每“步进”完让 JS 应用逻辑执行一会儿,GC 与应用逻辑交替执行直到标记完成。经过增量标记的改进,GC 的最大停顿时间减少到原本 1/6 左右。
GC 的触发规律
如前所述,GC
的触发规律可总结为:
-
当程序触发内存申请时,浏览器会检测是否到达一个临界值再进行触发
GC
。- 申请新的较小内存时(新生代空间内)。
- 申请较大内存时,可能触发老生代空间的标记清除或标记整理。
- 老生代
GC
的时间与老生代中对象数量成正相关。
如何优化 GC
观察 GC
触发规律,故优化 GC
的指导思想如下:
- 减少内存占用
当内存占用过高时,浏览器可能会频繁的触发GC
,并且老生代GC
耗时越大。 - 减少
GC
次数
如何减少内存占用?(以及注意事项)
-
合理设计页面,按需创建对象/渲染页面/加载图片等。
- 避免一次性请求全部数据。
- 避免一次渲染全部数据。
- 避免一次性加载/渲染全部图片(按需加载/懒加载)。
-
优化
Vue
的data
对象的属性,若字段较多可pick
需要的字段,避免生成过多Observer
。created() { getList().then(res => { const keys = ['id', 'type']; this.data.infos = res.data.map(v => { return pick(v, keys); //pick为 lodash.pick 方法 }); }) }
在列表渲染时,可能效果会很明显。部分后端语言(如
Java
)深层次继承对象之后,列表内元素的字段可能含有大量用不到的属性。
列表元素较多时,造成的性能问题不容忽视。
- 尽可能避免创建对象
如非必要,避免使用创建对象的API
,如Array.slice
、Array.map
、Array.fiter
、字符串相加、$('div')
、ArrayBuffer.slice
、canvas.getImageData()
等。 - 不再使用的对象,手动赋值为
null
。减少虚拟机扫描内存时扫描次数等。 - 使用
WeakMap
和Weekset
。 - 添加的侦听器需要移除。如在
Vue
中,在mounted
周期内addEventListener
,需要在beforeDestroy
周期内removeListener
。 -
避免频繁创建对象,尽可能复用对象。
- 复用对象可避免创建对象(申请新的内存),故如能复用对象则尽可能复用对象。如许多发布订阅模式中的
event
对象,都会尽可能复用,不会创建多个实例。 - 复用
DOM
等,如重复使用一个弹窗而非创建多个。
如Vue-ElementUI
框架中,PopOver/Tooltip
等组件用于表格内时会创建m * n
个实例,可优化为只创建一个实例,动态设置位置及数据(或者有多个容器,但只插入一份内容DOM
)。
- 复用对象可避免创建对象(申请新的内存),故如能复用对象则尽可能复用对象。如许多发布订阅模式中的
-
使用对象池
对象池(英语:
object pool pattern
)是一种设计模式。一个对象池包含一组已经初始化过且可以使用的对象,而可以在有需求时创建和销毁对象。池的用户可以从池子中取得对象,对其进行操作处理,并在不需要时归还给池子而非直接销毁它。这是一种特殊的工厂对象。若初始化、实例化的代价高,且有需求需要经常实例化,但每次实例化的数量较少的情况下,使用对象池可以获得显著的效能提升。从池子中取得对象的时间是可预测的,但新建一个实例所需的时间是不确定。
以上摘自维基百科。
使用对象池技术能显著优化需频繁创建对象的内存消耗。但建议按不同的场景做细微优化。
- 按需创建
默认创建空对象池,按需创建对象,用完归还池子。 - 预创建对象
避免在高频操作下频繁创建对象,如滚动事件、TouchMove
事件、resize
事件、for
循环内部等情况。如有需要,可提前预创建多个对象放入池子。
高频情况下,建议使用截流/防抖、时间切片等相关技术优化。 - 定时释放/清理
对象池内的对象不会被垃圾回收,若极端情况下创建了大量对象回收进池子却不释放只会适得其反。
故池子需设计定时/定量释放对象机制,如以已用容量/最大容量/池子使用时间等参数来定时释放对象。
- 按需创建
-
ImageData
对象是JS
内存杀手,避免重复创建ImageData
对象。 - 生产环境勿用
console.log
大对象,包括DOM
、大数组、ImageData
、ArrayBuffer
等。因为console.log
的对象不会被垃圾回收。详见Will console.log prevent garbage collection?。 - 重复使用
ArrayBuffer
,而非创建新的。 -
合理使用图片,压缩图片、按需加载图片、按需渲染图片,使用恰当的图片尺寸、图片格式,如 WebP 格式等。
这其中涉及到图片渲染流程,网上资料较少。假设渲染一张100KB
大小,300 * 500
的带透明像素的图片,粗略的可分为三个过程(注意,这里并不精确,实际图片渲染会按流式边加载边渲染,此处为简略总结):- 加载图片
从缓存中或者从远程服务器加载图片的二进制格式到内存(并设置缓存)。此时消耗了 100KB
的内存 和100KB
的缓存。 - 解码图片
将二进制格式的图片解码为像素格式,此时占用宽 * 高 * 24
(RGB
值为24
位,若带透明通道,则为ARBG
,占用32
位空间)比特大小的内存, 此处为300 * 500 * 32
。约等于585 KB
。这里约定名为像素格式内存。个人猜测此时浏览器会回收加载图片时创建的100KB
二进制内存,但浏览器会缓存像素格式内存,约585KB
。 -
渲染图片
通过CPU
或者GPU
渲染图片,若为GPU
渲染,则还需上传到GPU
显存。该过程较为耗时,由像素格式内存尺寸 / 显存位宽决定。图片像素内存尺寸越大,则上传时间越慢,占用显存越多。其中,较旧的浏览器如
Firefox
回收像素内存时机较晚,若渲染了大量图片时会内存占用过高。
- 加载图片
PS:浏览器会复用同一份图片二进制内存及像素格式内存,浏览器渲染图片会按以下顺序去获取数据:
显存 >> 像素格式内存 >> 二进制内存 >> 缓存 >> 从服务器获取。我们需控制和优化的是二进制内存及像素内存的大小及回收。
总结一下,浏览器渲染图片时所消耗内存由图片文件大小内存、宽高、透明度等所决定,故建议:
- 使用
CSS3
、SVG
、IconFont
、Canvas
替代图片。展示大量图片的页面,建议使用Canvas
渲染而非直接使用img
标签。具体详见 Javascript 的 Image 对象、图像渲染与浏览器内存两三事。 - 适当压缩图片,可减小带宽消耗及图片内存/缓存占用。
- 使用恰当的图片尺寸,即响应式图片,为不同终端输出不同尺寸图片,勿使用原图缩小代替
ICON
等。 - 使用恰当的图片格式,如使用 WebP 格式等。详细图片格式对比,使用场景等建议查看 web 前端图片极限优化策略。
- 按需加载及按需渲染图片。
- 预加载图片时(使用动态创建
img
设置src
方式),切记要将img
对象赋为null
,否则会导致图片内存无法释放。当实际渲染图片时,浏览器会从缓存中再次读取。 - 将离屏
img
对象赋为null
,src
赋为null
,督促浏览器及时回收内存及像素格式内存。 - 将非可视区域图片移除,需要时再次渲染。和按需渲染结合时实现很简单,切换
src
与v-src
即可。
-
window.URL.createObjectURL
创建的 DOMString 对象,切记使用window.URL.revokeObjectURL
回收。createObjectURL
是创建一个内存空间的引用,并且可用于赋值给img
的src
等,需要通过手动调用revokeObjectURL
触发回收。
复用创建的URL
,而非多次调用createObjectURL
。
极端边界情况下如何优化 GC
- 如前所述,一些极端情况下,提前预创建好相应内存,避免在高频计算里大量申请内存。
- 若是超大量图片展示的站点,请使用
canvas
优化或按需加载/懒加载,移除屏外图片等策略。请参考:Javascript 的 Image 对象、图像渲染与浏览器内存两三事
常见内存泄漏
- 全局变量
- 定时器遗漏
- 闭包的返回对象未回收(会导致闭包作用域内都不能回收)
-
dom
引用(有变量引用了dom
,即便从dom
树中移除,内存中仍有变量引用。)
上述几点请参考内存管理及如何处理 4 类常见的内存泄漏问题 - 不恰当全局缓存
- 不合适的监听器机制
上述两点请参考 轻松排查线上 Node 内存泄漏问题
GC 算法的日常应用及实现一个内存管理机制
-
引用计数法
此处经验为此前开发Flash
游戏时积累的图片缓存(BitmapData
)方案,在前端工作中暂时未有使用,但图片/二进制管理可以套用本方案。
在Flash
中,BitmapData
为图片的像素数据(类似于JS
中的ImageData
)。同一份素材的图片,可以复用同一份BitmapData
。
故为了管理游戏中的图片资源,则是管理游戏中所有的BitmapData
,在需要时缓存,用完时dispose
(销毁)。
具体流程如下:- 图片通过
url
引用BitmapData
时,检测是否有缓存,若没有,步骤二,若有,走步骤三。 - 使用加载队列按优先级加载资源并按
url
缓存,走到步骤三。 - 按
url
将引用次数+1
,并更新资源使用时间。 - 图片清理(
dispose
)时,按url
将引用次数-1
,并更新资源使用时间。 - 定时检测引用次数为
0
的情况,并依据一定策略延迟/定期清理资源。 - 定时上报资源管理器中,缓存的资源及引用计数,以方便排查资源引用情况/遗漏清理情况等。
- 进入战斗场景或对性能吃紧的场景时,清理引用计数为
0
的资源。
- 图片通过
此套方案的缺点在于:如果有图片对象未调用 dispose
,则会内存泄露,但可通过上报排查。
优点则是可精确控制所有图片的资源引用情况。
- LUR 算法
请参考JS 实现缓存算法(FIFO/LRU)
内存溢出如何排查
Chrome
浏览器排查请参考: chrome 内存泄露(一)、内存泄漏分析工具NodeJS
排查请参考:
[[译]Node.js 垃圾回收与内存泄露的排查](https://www.ctolib.com/topics...
node 内存泄漏以及定位
轻松排查线上 Node 内存泄漏问题
参考链接
garbage-collector-friendly-code/
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。