栈、堆和垃圾回收:监控大屏三天一崩,逼我补完了这课内存基础
最难查的前端问题之一,不是报错,而是页面越跑越慢,最后直接崩掉。控制台干干净净,功能每次刷新看起来都对,只有内存一点点涨上去,等涨到浏览器扛不住时,才用一张“页面崩溃了”的哭脸告诉你前面一直在漏。
这种问题几乎一定要回到内存模型里看。变量放在哪,对象为什么还活着,垃圾回收凭什么没把它收走,定时器、闭包、DOM 引用和缓存又是怎么把本该结束的生命周期偷偷拉长的,答案都在那里。
下面会把一次长期运行页面的内存泄漏排查,和栈、堆、可达性、垃圾回收这些基础概念放在一起讲。
先分清两块地:栈和堆
内存里给数据住的地方粗略分两块。栈适合放函数调用、局部变量这类生命周期明确的数据;堆适合放对象、数组、函数这类大小不固定、生命周期不确定的数据。JavaScript 里可以简单对应:
1var count = 1 2var user = { name: 'Tom' }
count 是基本类型,值本身简单;user 变量里存的是一个引用,对象内容住在堆里。
为什么要分两块?因为栈的管理方式简单到粗暴:函数调用时局部变量压栈,函数返回时整个栈帧直接弹掉,内存自动回收,不需要任何判断。这要求栈上的数据大小提前可知,所以适合数字、布尔这种定长的简单值。而对象和数组的大小运行时才知道、生命周期也不确定——可能被到处引用、活很久——没法"用完即弹",只能放堆里,交给垃圾回收去决定什么时候释放。
这个区分还顺手解答了我踩过的一个坑:递归太深会报 Maximum call stack size exceeded,因为每层调用占一个栈帧、栈空间有限;但创建几万个对象一般不会爆栈,它们在堆里。我写递归处理树形菜单时就被真实数据爆过栈,后来改成用一个数组当显式栈的循环写法才解决——本质上是把调用栈搬到了堆上。
复制的是值,还是引用
基本类型复制的是值,互不影响:
1var a = 1 2var b = a 3 4b = 2 5 6console.log(a) // 1
引用类型复制的是引用,两个变量指向堆里同一个对象:
1var a = { name: 'Tom' } 2var b = a 3 4b.name = 'Jerry' 5 6console.log(a.name) // Jerry
这是深浅拷贝所有坑的地基,也是能在 Vue 里直接坑到人的:我刚学 Vue 那会儿改一个表单,直接把 this.form 赋给 data 里另一个变量再去改,结果原对象跟着变了——两个变量指的是同一个堆对象。要断开就得拷贝。浅拷贝(Object.assign({}, obj) 或 {...obj})只复制第一层,嵌套对象还是共享引用;要彻底断开得深拷贝:
1var clone = JSON.parse(JSON.stringify(obj))
JSON 这招简单够用,但它会丢函数和 undefined、Date 变字符串、循环引用直接报错。项目里没现成工具就先用它顶着,真要严谨就上 lodash 的 cloneDeep。理解了"复制的是引用还是值",这些坑就都顺理成章。
垃圾回收在替谁做主
JavaScript 有垃圾回收,开发者通常不手动释放内存。GC 的活是找出"不再被使用"的对象,把内存收回来:
1var user = { name: 'Tom' } 2user = null
如果没有其他引用指向那个对象,它就有机会被回收。
判断"还在不在用"的标准是可达性:从一组根对象出发——全局对象 window、当前正在执行的函数调用栈——能顺着引用链走到的对象就是可达的、要保留;走不到的就是垃圾。早期有些引擎用引用计数(数有几个引用指着它,归零就收),但引用计数有个致命缺陷——循环引用收不掉:A 引用 B、B 引用 A,哪怕外界已经没人用它俩,计数都不为零,就这么漏了。现代引擎(V8)用的是标记-清除:从根出发标记所有可达对象,没被标记的统一清掉,天然不怕循环引用。
这个"循环引用泄漏"不是纯理论。早年 IE 有个臭名昭著的坑就是它——IE 里 JS 对象用标记-清除,但 DOM 和 BOM 对象走的是引用计数(COM 那套)。一旦你在 DOM 节点上挂一个 JS 对象、这个 JS 对象又反过来引用了那个 DOM 节点,就形成了一个跨两套回收机制的循环,两边都不肯松手,页面刷新前这块内存永远回不来。当年为了躲这个坑,代码里要专门在 onunload 时手动把这些互相引用置 null。到了 V8 时代,JS 世界这一侧统一用可达性判断,这类循环引用不再是问题;但这段历史提醒我一件事:"引擎会自动 GC"不等于"你可以完全不管引用关系"——引擎能处理循环引用,处理不了的是你自己在某个全局位置、某个长命对象上死死攥着一条引用链不放,那条链从根可达,GC 就永远认为它有用。后面查大屏泄漏,本质上就是在找这样一条"本该断、却没断"的可达链。
还有两点这次补课才真正弄懂。一是 GC 不归你指挥,开发者无法精确控制它什么时候跑(window.gc() 只在特殊启动参数下才有)。二是 GC 执行时会短暂"停下整个世界"(Stop-The-World),V8 为了减少这种卡顿做了分代回收——新创建、很快就死的对象放新生代,频繁快速地收;活得久的晋升到老生代,收得没那么勤。所以**"少产生临时垃圾"对性能是有实际意义的**,比如别在动画的每一帧里疯狂创建新对象。
分代这套设计背后有个很朴素的经验规律,叫"代际假说":绝大多数对象都是"朝生暮死"的——一个函数里 new 出来的临时对象,函数一返回就没人用了。既然大部分对象活不过一次回收,就没必要每次都去扫整个堆,把新对象单独放一小块地方、频繁地快扫,性价比最高。
新生代用的回收算法叫 Scavenge,具体是"复制式回收"(Cheney 算法那一套)。它把新生代那块空间从中间一切两半,一半叫 From、一半叫 To,同一时刻只用 From 那半装对象。等 From 快满了触发回收时,把 From 里还活着(可达)的对象整块复制到 To 空间去,复制完 From 里剩下的全是垃圾,直接把整个 From 清空,然后 From 和 To 角色对调。这么做的代价是永远有一半空间闲着,但好处是回收极快——它只需要搬运"活着的少数对象",而根据代际假说活着的本来就没几个,死掉的那一大批连碰都不用碰。一个对象如果在新生代里熬过了一两轮 Scavenge 还没死,V8 就认为它是个"长寿命对象",把它晋升(promote)到老生代去。老生代对象多、存活率高,不适合再用"浪费一半空间来复制"的策略,改用标记-清除加上标记-整理(mark-compact,回收后把存活对象往一端挪、消除内存碎片)。这也解释了为什么新生代空间被 V8 故意设得很小(当年 64 位机上也就几十 MB)——就是要让它频繁触发、每次扫得快,把 Stop-The-World 的停顿压到几乎无感。
老生代那次全量的标记-清除才是真正可能造成明显卡顿的地方——堆里存活对象一多,一口气标记完可能要几十甚至上百毫秒,用户能直接感觉到掉帧。V8 为此做了增量标记(incremental marking):把"一次性标记整个老生代"拆成很多小步,穿插在 JS 执行的间隙里一点点做,做一小段就把主线程还给业务代码,避免长时间独占。后来还进一步把部分标记和清理工作挪到了后台线程去做(并发/并行回收)。这些优化对我们业务开发是透明的,平时感知不到,但知道它们存在有个好处:当你发现页面在某些时刻莫名卡一下、Performance 面板里那一格是 GC,就知道这是老生代在做大扫除,而治本的办法从来不是去调 GC,而是从源头少制造需要被回收的长命垃圾。
崩溃那一下,到底是谁扛不住了
顺着"越涨越高最后崩掉"这个现象,我还专门查了一个一直含糊的问题:为什么内存涨到某个点,标签页就直接崩了,而不是无限涨下去?答案是 V8 给堆设了个上限。当年的 V8 里,老生代的堆大小是有天花板的,64 位下大概在 1.4GB 左右(32 位更小),Node 里可以用 --max-old-space-size 调,但浏览器里的页面基本就吃这个默认限制。为什么要设限?一方面是历史上 32 位地址空间的约束,另一方面前面说过——GC 是要"停下整个世界"的,堆越大、一次全量标记-清除要扫的对象越多、停顿就越长,与其让一个失控的页面把堆撑到十几个 G、每次 GC 卡死几秒,不如到了上限直接判它出局。
所以大屏那种"每十秒漏一点"的泄漏,宿命是注定的:内存曲线是一条只涨不跌的楼梯,涨到撞上这条 1.x GB 的红线,V8 分配不出新内存,就抛出 FATAL ERROR: ... Allocation failed - JavaScript heap out of memory,页面崩溃。它不报业务错误,因为业务逻辑一行没错,错的是我们让一堆本该死掉的对象一直活着,把额度一点点吃光。理解了这条红线的存在,也就理解了为什么这类问题拖不得——它不是"慢一点",是"到点必崩",只是这个点来得早晚而已,数据量小、开得久,红线迟早会到。
闭包:它"记住"的比你以为的多
1function createCounter() { 2 var count = 0 3 4 return function() { 5 count += 1 6 return count 7 } 8} 9 10var counter = createCounter()
createCounter 执行完了,count 却不会被释放——它还被返回的函数引用着。从可达性的角度看这完全合理:只要 counter 这个函数活着(被变量、事件监听器、定时器引用着),count 所在的作用域就一直可达。
闭包不是坏东西,但它会延长变量的生命周期。真正的坑是无意中用闭包抓住了一个很大的对象,又忘了放手。我遇过一个典型场景:循环里给每个 DOM 元素绑事件,回调闭包里引用了循环外一个体积很大的数据对象——只要这些元素和监听还在,那个大对象就一直吊在内存里下不来:
1function bindRows(rows, hugeRawData) { 2 for (var i = 0; i < rows.length; i++) { 3 rows[i].onclick = function () { 4 // 这里其实只用到 hugeRawData 里某一项的 id, 5 // 但闭包把整个 hugeRawData 都圈进了作用域,跟着回调一起长命 6 console.log(hugeRawData[i].id) 7 } 8 } 9}
这里有两层坑叠在一起。一是闭包把整个 hugeRawData 抓住了,哪怕每个回调只用得到其中一个 id;二是这里的 var i 还带着老生常谈的循环变量绑定问题——所有回调共享同一个 i,循环结束后 i 早就是 rows.length 了,点谁都取到 undefined。前一个是内存问题,后一个是逻辑问题,但根子是同一个:闭包记住的是变量本身和它所在的整个作用域,不是你以为的那"一小份值"。
解决思路是缩小闭包抓取的范围:只把真正用到的字段拿进闭包,别把整个大对象拖进去:
1function bindRows(rows, hugeRawData) { 2 rows.forEach(function (row, i) { 3 // 提前把用到的那一小份摘出来,闭包只抓这个 id,不再吊着整个大对象 4 var id = hugeRawData[i].id 5 row.onclick = function () { 6 console.log(id) 7 } 8 }) 9}
forEach 顺带把 var i 共享的问题也解决了——每次迭代 i 和 id 都是独立的一份。不再需要的引用还可以主动置 null,帮 GC 早点断链。闭包该用就用,但心里要清楚它记住了哪些东西、这些东西多大、什么时候该松手。
嫌疑人名单:四类经典泄漏
带着上面的理论回到大屏。前端的内存泄漏翻来覆去就那几类,我先把嫌疑人名单列了出来。
第一,事件没有解绑:
1window.addEventListener('resize', handler)
组件销毁时应该:
1window.removeEventListener('resize', handler)
第二,定时器没有清理:
1var timer = setInterval(update, 1000)
销毁时:
1clearInterval(timer)
第三,全局变量意外引用:
1cache = largeData
少了 var,这个 cache 就挂到了全局对象上,从根永远可达,永远收不掉。
第四,DOM 被移除但仍被 JS 引用:
1var node = document.getElementById('box') 2document.body.removeChild(node)
只要 node 变量还攥着它,这个节点连同它下面整棵子树都释放不掉。这种叫游离 DOM(detached DOM)——元素已经从页面上摘掉了,JS 里还留着引用。SPA 里频繁切换页面、列表反复重渲染时特别容易积累。
更隐蔽的一种是把 DOM 存进一个长命的缓存对象里想"复用",结果 DOM 被移除后缓存没清:
1var domCache = {} 2 3function renderRow(id) { 4 var el = document.createElement('div') 5 // ...填充内容 6 domCache[id] = el // 存起来打算下次复用 7 return el 8} 9 10// 后来列表刷新,页面上的行被整体替换掉了, 11// 但 domCache 里这些 el 还在,它们连同子树全成了游离 DOM
只要 domCache 这个对象活着,里面攥着的每个 el 就都从根可达,GC 一个都收不掉。缓存 DOM 本身不是错,错的是没有一条"什么时候把它从缓存里删掉"的对应逻辑——和事件、定时器一样,凡是"存进去"的动作,都得配一个"清出来"的动作。
框架时代对付这四类坑有个统一抓手:组件卸载的生命周期钩子就是专门给你做清理的地方。我现在的固定习惯是——在哪个钩子里 addEventListener、setInterval、建 WebSocket,就一定在卸载钩子里成对地 remove、clear、close:
1// Vue 组件里 2mounted() { 3 window.addEventListener('resize', this.onResize) 4 this.timer = setInterval(this.refresh, 1000) 5}, 6beforeDestroy() { 7 window.removeEventListener('resize', this.onResize) 8 clearInterval(this.timer) 9}
绑定和解绑成对出现,写的时候一起写,别指望事后补,事后基本都会漏。另外注意 removeEventListener 必须传和绑定时同一个函数引用才解得掉,所以别用匿名函数绑事件——绑得上、解不掉,又是一个隐蔽泄漏点。
Vue 项目里还有个第五号嫌疑人值得单独点名:event bus。我们后台好几个页面用 var bus = new Vue() 做兄弟组件通信,bus.$on 订阅的回调挂在这个全局 bus 上,组件销毁时 Vue 只会清掉组件自己身上的监听,bus 上的订阅不解,回调连同它闭包里的整个组件实例就一直挂在全局。每进出一次页面多挂一份,和游离 DOM 是一个性质。规矩同样是成对写:
1created() { 2 bus.$on('order-updated', this.onOrderUpdated) 3}, 4beforeDestroy() { 5 bus.$off('order-updated', this.onOrderUpdated) 6}
keep-alive 缓存的组件也算半个嫌疑人——它是故意把组件实例留在内存里的,不是泄漏,但缓存的页面多了、每个页面又抱着大列表数据,内存曲线一样下不来,该用 include 限制范围就限制。
审讯过程:快照对比和那条锯齿线
名单有了,怎么定罪?单看一张内存快照基本看不出什么,我这次用的手法是"重复操作 + 对比快照",值得完整记一遍。
打开 Chrome DevTools 的 Memory 面板,先拍一张 heap snapshot;然后把可疑操作重复执行几十次——大屏的场景就是让定时刷新多跑一阵,普通页面可以反复开关某个弹窗;再拍第二张,用 Comparison 视图对比两张快照之间新增了哪些对象、增量多少。如果某类对象——某个组件实例、一批 DOM 节点——数量随操作次数线性上涨、从不回落,泄漏点基本就锁定了。
还有个更直观的先导工具:Performance 面板录制时勾上 Memory,录一段操作,看 JS Heap 那条曲线。健康的曲线是锯齿形——涨上去,GC 一跑又掉回来;如果是阶梯形只涨不跌,那就是在泄漏。大屏的曲线录出来是一条标准的楼梯,每一格正好对应一次定时刷新,铁证如山。
拍快照前还有个不写没人告诉你的细节:先把控制台 Clear 一下。DevTools 的 console 会攥住所有打印过的对象引用——你调试时 console.log 出来的那个大数组,只要控制台没清,GC 就永远收不掉它,快照里它赫然在列,像极了泄漏,其实是 DevTools 自己制造的假案。我第一轮排查就被这个坑晃了半小时,对着一个"泄漏"的数组百思不解,清了控制台重拍,它消失了。
快照对比之外还有一件更趁手的兵器:Memory 面板的 Allocation instrumentation on timeline。它一边录一边把每次内存分配画在时间轴上,蓝色柱子表示这批对象还活着、灰色表示已经被回收。健康的操作录完应该满屏变灰;哪一段蓝柱子始终不褪色,点进去就能看到是哪些对象在哪里分配的。相比"拍两张快照再肉眼对比",它直接把"什么时候分配、到现在还没死"这条时间信息给了你,锁定定时刷新这种周期性泄漏尤其好用。
定位到可疑对象后,在快照里点中它,右下角的 Retainers 面板会展示引用链——到底是谁还攥着它不放。顺着这条链往上找,通常就能摸到那个忘了清理的事件、定时器或缓存。这次的大屏我就是顺着 Retainers 找到了根源:一个图表组件在数据更新时销毁重建,但旧实例里有个忘了 clear 的 setInterval,它的回调闭包死死拽着整个旧组件实例和一段历史数据不放。每十秒重建一次,就每十秒多一个僵尸定时器和一份收不掉的数据。
在重建逻辑里补上 clearInterval、把图表实例的 destroy 调对,再录一遍 Performance,楼梯变回了锯齿。年前上线,大屏挂到今天没再崩过。
补两件顺手学到的东西
排查过程里还捞到两个知识点,一并记下。
一个是 ES6 的 WeakMap 和 WeakSet。普通 Map 拿对象当 key 时,Map 会强引用这个对象——哪怕外界都不用它了,只要它还在 Map 里当 key,GC 就收不掉。WeakMap 的引用是弱的,key 对象在外界不可达时可以照常被回收,特别适合"给 DOM 节点或对象挂关联数据"这种缓存场景:
1var meta = new WeakMap() 2meta.set(node, { clicks: 0 }) 3// node 被移除且无人引用后,这条关联数据跟着一起被回收,不用手动清
代价是 WeakMap 不能遍历、拿不到 size——它里面有什么,取决于 GC 心情,这正是"弱"的含义。用普通对象或 Map 做长生命周期缓存前,先想想是不是 WeakMap 更合适。
另一个是给常驻页面留的工程兜底。7×24 的页面就算把已知泄漏都清了,也难保三方图表库内部一点不漏,所以我给大屏加了个保险:每天凌晨四点没人看的时候 location.reload() 一次,顺便用 performance.memory(Chrome 私有 API,数值只能看个趋势)粗粗打点上报,内存异常抬升能提前在群里冒个泡。排查解决存量问题,兜底防住未知问题,两手都要有。
第二课收工
这一课收下来的几句话:栈管调用和简单值,用完即弹;堆管对象,靠 GC 按可达性回收。GC 只认引用不认意图——你觉得没用了不算数,引用链断了才算数。所以前端说的"内存泄漏",绝大多数不是引擎的锅,而是我们自己在某个角落里还攥着引用:没解绑的事件、没清的定时器、意外的全局变量、游离的 DOM、抓太多的闭包。
第一课的进程线程还停留在"能讲明白",这一课直接在机房的电视上兑了现,运维年前在群里发了句"大屏稳了",比什么打卡记录都提气。下回再有页面"越用越卡",我知道先去看那条 JS Heap 曲线是锯齿还是楼梯,再用快照和 Retainers 一步步查下去。年假回来接着排第三课。