JS 垃圾回收:为什么你明明释放了变量,内存还是爆了?

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 用三种颜色标记对象:

  • 白色:未访问(待清除)
  • 灰色:自身已访问,但引用的子对象还没访问完
  • 黑色:自身和子对象都已访问

过程是这样的:

  1. 初始所有对象都是白色
  2. 从根开始,把直接引用的对象标记为灰色
  3. 取出一个灰色对象,把它引用的白色对象标记为灰色,自己变成黑色
  4. 重复直到没有灰色对象
  5. 剩下的白色对象就是不可达的,可以清除了

为什么要有灰色?

因为 GC 不是一口气做完的,它要跟 JS 主线程交替运行(这叫增量标记)。如果没有灰色状态,一旦 GC 暂停又恢复,就搞不清哪些对象已经被检查过了。

灰色就像书签------告诉你"这个地方我已经看了一半,下次从这里继续"。

写屏障

增量标记带来一个新问题:GC 暂停期间,JS 代码可能会修改对象引用关系。比如:

less 复制代码
GC 标记了 A 为黑色(已处理完子对象)
JS 代码:A.child = newlyCreatedObject  // 新对象是白色的
GC 恢复后:黑色对象不再处理,新对象被当成垃圾清掉了!

这就是经典的黑色对象误判问题。

V8 的解决方案叫写屏障:每次给对象添加属性时,如果目标对象是黑色的,就把新对象标记为灰色,强制 GC 重新处理。

代价就是:赋值操作变慢了。这也是为什么频繁操作对象属性的代码会有性能开销。


五、实战:如何排查内存泄露?

5.1 Chrome DevTools Memory 面板

三步走:

  1. 拍快照:在疑似泄露前拍一张
  2. 触发操作:反复执行你觉得可能有问题的操作
  3. 再次拍快照:对比两张快照,看哪些对象数量异常增长

重点关注:

  • 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
  }
}

虽然大多数情况下没必要,但对于生命周期很长的对象,显式清理是一种好习惯。


七、总结:记住这四点就够了

  1. 闭包不是免费的------每次创建闭包都可能产生长期存活的对象
  2. DOM 引用要小心------元素移除不代表内存释放
  3. 定时器和监听器必须清理------它们是内存泄露的头号元凶
  4. 工具是你的朋友------Chrome DevTools 的 Memory 面板比直觉靠谱

最后送一句话:

JavaScript 的垃圾回收不是魔法,它只是一个遵循规则的清洁工。只要你遵守规则,它就不会给你添乱。

相关推荐
长大19881 小时前
Promise 从入门到踩坑:为什么你的异步代码还是一团乱麻
后端
颜进强1 小时前
Calude Code - 25 CodeGraph:让 AI 真正读懂你的代码库
前端·后端·ai编程
七牛开发者1 小时前
Agent 小知识|长任务不重来:Agent 状态保存的工程设计
前端·javascript·后端
二月龙1 小时前
JS 事件循环完整解析:宏任务、微任务,浏览器到底怎么执行代码
后端
长大19881 小时前
写了多年 JS 却仍被“变量提升”拿捏?这篇文章一次讲透
后端
大勇前进1 小时前
闭包到底有什么用?别只背概念,看完秒懂实际业务场景
后端
神奇小汤圆1 小时前
深入理解 TiDB 分布式事务:Percolator 模型与工程实践
后端
神奇小汤圆2 小时前
Flink SQL 从编写到提交运行的全过程解析
后端
饼干哥哥2 小时前
Codex 必改的8 个基础配置
前端·人工智能·后端