JS 垃圾回收:为什么你明明释放了变量,内存还是爆了?
你以为
delete一下、赋个null就万事大吉了?V8 笑而不语。
一、先讲个鬼故事
你写过这样的代码吗?
javascript
function heavyTask() {
const data = new Array(10000000).fill('💣')
// 一顿操作猛如虎...
return result
}
// 页面越来越卡,内存蹭蹭涨
setInterval(heavyTask, 1000)
理论上 data 是局部变量,函数执行完就该被回收了对吧?但现实往往是------内存占用只增不减,Chrome DevTools 里的 Memory 面板红得刺眼。
这不是 Bug,这是你对垃圾回收的理解还停留在 "引用计数 + 标记清除" 的教科书阶段。
二、JS 垃圾回收到底怎么工作的?
先快速过一遍基础,不然没法聊深。
2.1 栈 vs 堆
JavaScript 的内存分两块:
| 区域 | 存什么 | 特点 |
|---|---|---|
| 栈 | 基本类型、引用地址 | 小、快、自动管理 |
| 堆 | 对象、数组、函数等 | 大、慢、GC 管 |
csharp
let a = 42 // 栈
let b = { name: '元宝' } // 栈存地址,堆存对象
栈内存由系统自动管理------函数调用结束就弹出,干净利落。
真正让人头疼的是堆内存,GC 主要忙活的就是这一块。
2.2 V8 的分代回收
V8 把堆分成两代:
- 新生代:存活时间短的对象(比如函数内的临时变量)
- 老生代:存活时间长的对象(比如全局变量、闭包引用的变量)
回收策略不同:
- 新生代:Scavenge 算法,空间换时间,频繁但快
- 老生代:Mark-Sweep / Mark-Compact,耗时但彻底
关键点:对象从新生代晋升到老生代是有条件的------经过一次 GC 还活着,或者被老生代对象引用着。
这就是第一个陷阱的来源。
三、五个让你内存泄露的隐形杀手
陷阱 1:闭包------你以为的缓存,实际的内存黑洞
javascript
function createCache() {
const cache = {}
return {
set(key, val) { cache[key] = val },
get(key) { return cache[key] }
}
}
const myCache = createCache()
// 用着用着,cache 越来越大,但再也回不去了
为什么不被回收? myCache 引用了返回的两个方法,这两个方法形成了闭包,一直持有对 cache 的引用。只要 myCache 还活着,cache 就永远活在老生代里。
解法:要么限制 cache 大小,要么提供 clear 方法,要么用 WeakMap。
陷阱 2:DOM 引用------元素删了,引用还在
dart
const elements = []
document.querySelectorAll('.item').forEach(el => {
elements.push(el)
})
// 后来 DOM 被移除了
document.body.innerHTML = ''
// 但 elements 数组里还存着这些 DOM 对象的引用
// 于是它们永远无法被回收
更隐蔽的情况:
javascript
const btn = document.getElementById('submit')
btn.onclick = function() {
// 这个匿名函数引用了 btn,形成循环引用
console.log(btn.textContent)
}
// 即使 btn 从 DOM 树移除了,只要这个事件监听还在,两者都释放不了
解法:用完手动置 null,或者用 WeakRef(不过不太推荐,太底层)。
陷阱 3:定时器------最容易被遗忘的常驻内存
scss
function startPolling() {
const data = loadHeavyData()
setInterval(() => {
// 每隔一秒处理一次 data
process(data)
}, 1000)
}
startPolling()
// 页面关了?定时器还在跑!data 还在内存里!
为什么不被回收? 只要 setInterval 没有被 clearInterval,它的回调函数就一直在作用域链里引用着外部的 data。
解法:组件卸载 / 页面关闭时务必清除定时器,或者在回调里判断是否需要继续。
陷阱 4:全局变量------window 上的钉子户
javascript
function leak() {
leaked = '我变成全局变量了' // 忘了写 let/const
}
// 或者更隐蔽的:
function User(name) {
this.name = name
this.data = massiveData
// 如果忘记用 new 调用,this 指向 window
}
为什么不被回收? 全局变量挂在 window 上,只要页面不关,它就永远可达。
解法 :严格模式 'use strict' 可以避免意外创建全局变量。另外,尽量减少挂载在 window 上的数据。
陷阱 5:console.log------调试一时爽,内存火葬场
javascript
function debugTask() {
const hugeData = loadMillionsOfRows()
console.log(hugeData) // 控制台保留了引用
return hugeData
}
为什么不被回收? Chrome 的控制台会保留打印对象的引用,方便你在 DevTools 里展开查看。这意味着即使函数执行完毕,对象也不会被回收。
解法:生产环境去掉 console.log,或者只打印必要信息。
四、深入 V8:你真的了解标记清除吗?
教科书上说:标记清除就是从根对象(全局对象、当前执行上下文等)出发,遍历所有可达对象,标记它们,然后清除未被标记的。
但有个细节很少人提:增量标记和三色标记法。
三色标记
V8 用三种颜色标记对象:
- 白色:未访问(待清除)
- 灰色:自身已访问,但引用的子对象还没访问完
- 黑色:自身和子对象都已访问
过程是这样的:
- 初始所有对象都是白色
- 从根开始,把直接引用的对象标记为灰色
- 取出一个灰色对象,把它引用的白色对象标记为灰色,自己变成黑色
- 重复直到没有灰色对象
- 剩下的白色对象就是不可达的,可以清除了
为什么要有灰色?
因为 GC 不是一口气做完的,它要跟 JS 主线程交替运行(这叫增量标记)。如果没有灰色状态,一旦 GC 暂停又恢复,就搞不清哪些对象已经被检查过了。
灰色就像书签------告诉你"这个地方我已经看了一半,下次从这里继续"。
写屏障
增量标记带来一个新问题:GC 暂停期间,JS 代码可能会修改对象引用关系。比如:
less
GC 标记了 A 为黑色(已处理完子对象)
JS 代码:A.child = newlyCreatedObject // 新对象是白色的
GC 恢复后:黑色对象不再处理,新对象被当成垃圾清掉了!
这就是经典的黑色对象误判问题。
V8 的解决方案叫写屏障:每次给对象添加属性时,如果目标对象是黑色的,就把新对象标记为灰色,强制 GC 重新处理。
代价就是:赋值操作变慢了。这也是为什么频繁操作对象属性的代码会有性能开销。
五、实战:如何排查内存泄露?
5.1 Chrome DevTools Memory 面板
三步走:
- 拍快照:在疑似泄露前拍一张
- 触发操作:反复执行你觉得可能有问题的操作
- 再次拍快照:对比两张快照,看哪些对象数量异常增长
重点关注:
detached开头的 DOM 节点(从 DOM 树分离但未被回收)- 数量持续增长的构造函数实例
5.2 Performance 录制
录制一段操作,看内存曲线的趋势:
- 锯齿状:正常,GC 在正常工作
- 阶梯上升不回弹:大概率有泄露
- 持续高位震荡:内存用量过大,可能是缓存策略有问题
5.3 用代码定位
javascript
// 检测特定类型的实例数量
class LeakDetector {
static instances = new WeakSet()
constructor() {
LeakDetector.instances.add(this)
}
}
// 定期检查实例数量变化
setInterval(() => {
console.log('当前实例数:', LeakDetector.instances.size)
}, 5000)
六、最佳实践:从源头杜绝内存泄露
6.1 用 WeakMap / WeakSet 代替 Map / Set
scss
// ❌ 普通 Map,key 被删除后 value 依然存在
const cache = new Map()
function process(obj) {
if (!cache.has(obj)) {
cache.set(obj, heavyComputation(obj))
}
return cache.get(obj)
}
// ✅ WeakMap,key 被 GC 后 value 自动释放
const cache = new WeakMap()
function process(obj) {
if (!cache.has(obj)) {
cache.set(obj, heavyComputation(obj))
}
return cache.get(obj)
}
WeakMap 的 key 必须是对象,且是弱引用------不影响 GC。
6.2 及时清理事件监听
javascript
// ❌ 现代框架也可能踩坑
useEffect(() => {
window.addEventListener('resize', handleResize)
// 忘了解绑!
}, [])
// ✅ 正确的做法
useEffect(() => {
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
6.3 合理使用闭包
javascript
// ❌ 不必要的闭包
function heavy() {
const bigData = loadBigData()
return function() {
// 只用到了 bigData 的一小部分
return bigData[0]
}
}
// ✅ 只保留需要的
function heavy() {
const bigData = loadBigData()
const firstItem = bigData[0]
return function() {
return firstItem
}
}
6.4 手动解除引用
kotlin
class Component {
destroy() {
this.data = null
this.callbacks = null
this.domRef = null
}
}
虽然大多数情况下没必要,但对于生命周期很长的对象,显式清理是一种好习惯。
七、总结:记住这四点就够了
- 闭包不是免费的------每次创建闭包都可能产生长期存活的对象
- DOM 引用要小心------元素移除不代表内存释放
- 定时器和监听器必须清理------它们是内存泄露的头号元凶
- 工具是你的朋友------Chrome DevTools 的 Memory 面板比直觉靠谱
最后送一句话:
JavaScript 的垃圾回收不是魔法,它只是一个遵循规则的清洁工。只要你遵守规则,它就不会给你添乱。