垃圾回收机制详解

公众号:小博的前端笔记

浏览器垃圾回收机制详解

核心目标: 自动管理内存,回收不再使用的对象占用的空间,防止内存泄漏。

一、 关键概念

  1. 可达性 (Reachability):

    • 判断对象是否"存活"的根本标准。
    • 根对象 (Roots): 包括全局对象(如 window)、当前执行函数的局部变量和参数、活跃的闭包作用域链上的变量、DOM 元素引用等。
    • 可达对象: 从根对象出发,通过引用链(对象属性、数组元素等)能够访问到的对象。
    • 不可达对象: 从任何根对象出发都无法访问到的对象。这些对象就是垃圾回收的目标。
  2. 常见垃圾回收算法:

    • 1. 标记-清除 (Mark-and-Sweep) - 主流浏览器核心算法

      • 阶段1:标记 (Mark): 从所有根对象开始,递归遍历所有能被访问到的对象,将它们标记为"可达"。

      • 阶段2:清除 (Sweep): 遍历整个堆内存,将所有未被标记为"可达"的对象视为垃圾,回收它们占用的内存空间。

      • 优点: 解决了循环引用问题(两个或多个对象互相引用,但整体不可达时,也能被回收)。

      • 缺点:

        • 会产生内存碎片。回收后内存空间不连续,可能导致后续大对象分配失败,即使总空闲内存足够。
        • 执行期间(尤其是全堆标记),可能会阻塞 JavaScript 主线程,造成页面卡顿(Stop-The-World)。
    • 2. 引用计数 (Reference Counting) - 已逐渐被淘汰,但需了解

      • 原理: 每个对象维护一个引用计数器。当有其他对象引用它时,计数器加1;当引用解除时,计数器减1。当计数器变为0时,立即回收该对象。

      • 优点: 回收及时,一旦计数为0立刻回收。

      • 致命缺点:

        • 无法解决循环引用问题: 如果对象 A 引用了 B,B 又引用了 A,即使它们都已不再被外部使用,它们的引用计数永远为1,无法回收,导致内存泄漏。这在早期 IE(涉及 COM 对象)中非常常见。
      • 现状: 现代浏览器主 GC 算法不再使用纯引用计数。但一些局部场景(如某些 DOM API 实现)可能仍隐含有类似思想。

二、 现代浏览器的优化策略(以 V8 引擎为例)

为了克服标记-清除的缺点(碎片、卡顿),现代浏览器(尤其是 V8)采用了更高级的分代和并行/增量/并发回收策略:

  1. 分代收集 (Generational Collection):

    • 核心思想: 根据对象的存活时间将堆内存划分为不同的"代"(通常是两代)。

      • 新生代 (New Space/Young Generation): 存放新创建 的对象。大部分对象在这里"朝生夕死"(存活时间短)。空间较小,回收频繁且快速 (使用 Scavenge 算法,一种复制算法)。
      • 老生代 (Old Space/Old Generation): 存放从新生代中存活过多次 GC 的对象,以及一些大对象 (直接分配在老生代)。对象存活时间长。空间较大,回收较少但耗时较长 (主要使用标记-清除 或优化的标记-整理/标记-压缩算法)。
    • 优势: 针对不同生命周期的对象使用不同的回收策略,大幅提升效率。大部分快速回收发生在新生代,对用户感知小。

  2. 新生代回收:Scavenge 算法 (复制算法)

    • 将新生代划分为两个等大的 From 空间To 空间

    • 新对象只分配到 From 空间。

    • 垃圾回收时:

      1. 标记 From 空间中存活的对象。
      2. 将存活的对象复制To 空间(保持紧凑排列,无碎片)。
      3. 清空整个 From 空间。
      4. 交换 FromTo 空间的角色。
    • 对象晋升 (Promotion): 如果一个对象在新生代的多次回收(通常1-2次)中都能存活下来,或者 To 空间使用率超过25%,则将它移动到老生代。

    • 优点: 极其快速、无内存碎片。

    • 缺点: 只能使用一半的新生代空间(空间换时间)。

  3. 老生代回收:标记-清除/标记-整理/增量标记

    • 标记-清除: 基础算法,但会产生碎片。

    • 标记-整理 (Mark-Compact): 在标记完成后,将所有存活的对象向内存一端移动 ,然后清理掉边界外的内存。解决了碎片问题,但移动对象成本更高。

    • 增量标记 (Incremental Marking):

      • 问题: 标记整个老生代可能耗时很长,导致明显卡顿。
      • 方案: 将标记过程拆分成很多小的"步进"。让 GC 工作与 JS 主线程交替执行。每次只标记一小部分对象,然后让主线程执行一会儿 JS,再回来标记下一小部分。
      • 难点: 在增量过程中,JS 执行可能修改对象的引用关系(比如删除了一个已标记对象的引用),导致标记结果不准确("写屏障" Write Barrier 技术用于跟踪这些修改)。
    • 并发标记 (Concurrent Marking) / 并行标记 (Parallel Marking):

      • 并发标记: 利用辅助线程(与 JS 主线程并发运行)进行标记工作。主线程几乎不阻塞。
      • 并行标记: 利用多个辅助线程并行进行标记工作。加速标记过程。
    • 惰性清理/增量清理: 类似增量标记,将清理阶段也拆分成小块执行。

    • 三色标记法 (Tri-color Marking):

      • 用于支持增量/并发标记的状态跟踪机制。
      • 白色: 尚未被 GC 访问(初始状态或确定为垃圾)。
      • 灰色: 已被 GC 访问,但其引用的对象还未被完全检查(在待处理队列中)。
      • 黑色: 已被 GC 访问,且其引用的所有对象也都已被检查过(安全对象)。
      • GC 从灰色对象开始工作(广度优先遍历),目标是所有对象最终非黑即白。白色对象即可回收。

三、 常见内存泄漏场景(面试重点!)

即使有 GC,代码不当仍会导致内存泄漏(对象不再使用但意外地保持可达):

  1. 意外的全局变量: 未使用 var/let/const 声明的变量会成为 window 的属性。

    javascript

    ini 复制代码
    function leak() {
        oops = 'I am global!'; // 泄漏到 window
        this.accidental = 'Me too!'; // 在非严格模式且 leak 被当作函数调用时,this 指向 window
    }
  2. 遗忘的定时器 (Timers) / 回调 (Callbacks):

    javascript

    ini 复制代码
    let bigData = getHugeData();
    setInterval(() => {
        const node = document.getElementById('node');
        if (node) {
            node.innerHTML = JSON.stringify(bigData); // bigData 被 interval 回调闭包引用,即使节点移除也不会释放
        }
    }, 1000);
    // 忘记 clearInterval 时,bigData 和关联的闭包作用域会一直存在
  3. 游离的 DOM 引用: 在 JS 中保留了 DOM 元素的引用,即使该元素已从 DOM 树中移除。

    javascript

    javascript 复制代码
    let elements = {
        button: document.getElementById('myButton'),
        image: document.getElementById('myImage')
    };
    function removeButton() {
        document.body.removeChild(elements.button); // 从 DOM 移除按钮
        // elements.button 仍然存在引用!无法被 GC
    }
  4. 闭包: 过度或不慎使用闭包可能导致外部函数的变量被长期持有。

    javascript

    javascript 复制代码
    function outer() {
        let hugeArray = new Array(1000000).fill('*');
        return function inner() {
            console.log('Hello, but I hold a reference to hugeArray!');
        };
    }
    const holdHuge = outer(); // inner 函数闭包引用了 hugeArray,即使 outer 执行完毕
    // 除非 holdHuge 被释放,否则 hugeArray 不会被回收
  5. 未清理的事件监听器: 在不需要时未移除事件监听器,特别是对已移除的 DOM 元素或即将销毁的组件。

    javascript

    javascript 复制代码
    function setupListener() {
        const element = document.getElementById('clickMe');
        element.addEventListener('click', onClick); // 添加监听器
    }
    function tearDown() {
        // 忘记移除监听器!即使 element 被移除,onClick 和它引用的任何变量都会被保留
        // document.body.removeChild(document.getElementById('clickMe'));
    }

四、 如何避免内存泄漏 & 性能优化

  1. 避免常见陷阱: 注意上述泄漏场景(全局变量、定时器、DOM 引用、闭包、事件监听器)。

  2. 使用弱引用 (Weak References): 当主引用不需要强持有对象时使用:

    • WeakMap / WeakSet: 键是弱引用(不影响 GC),非常适合存储与 DOM 节点或对象关联的元数据。当键对象被回收时,对应的值也会被自动清除。
    • WeakRef (ES2021) / FinalizationRegistry (ES2021): 提供更底层的弱引用和对象被回收后的回调机制(慎用)。
  3. 及时清理:

    • 使用 clearInterval() / clearTimeout() 清除不需要的定时器。
    • 使用 removeEventListener() 移除不需要的事件监听器。
    • 在单页应用(SPA)组件销毁(如 React componentWillUnmount, Vue beforeDestroy)时,主动清理定时器、事件监听器、取消网络请求、释放大对象引用(设置为 null)。
  4. 合理使用闭包: 明确闭包持有引用的对象及其生命周期。

  5. 利用开发者工具:

    • Chrome DevTools - Memory 面板:

      • Heap Snapshot: 查看某个时刻堆内存中所有对象的分布和大小,定位大对象和泄漏对象。
      • Allocation instrumentation on timeline: 实时追踪对象的分配和保留情况,直观发现未被回收的对象。
      • Allocation sampling: 采样模式,性能开销小,用于发现分配热点。
    • Performance Monitor: 监控 JS Heap Size、DOM Nodes 等指标随时间的变化,观察是否持续增长。

五、 面试回答要点总结

  1. 核心原理: 强调 可达性 是判断垃圾的唯一标准。根对象 -> 引用链。
  2. 主流算法: 明确现代浏览器主要使用 标记-清除 作为基础,配合 分代收集 进行优化。
  3. V8 优化: 重点阐述 新生代 (Scavenge)老生代 (标记-清除/整理 + 增量/并行/并发标记) 的区别和优化点(解决碎片、减少卡顿)。
  4. 内存泄漏: 必须能列举至少 3 种常见泄漏场景(全局变量、定时器回调、游离 DOM 引用)并解释原因。 这是面试官最常问的!
  5. 避免措施: 强调及时清理(定时器、事件监听器)、善用弱引用(WeakMap)、利用开发者工具定位。
  6. 性能意识: 知道 GC 可能导致卡顿(Stop-The-World),优化策略(增量/并发)就是为了减少这种影响。

示例回答 (面试时简化版):

"浏览器垃圾回收的核心目标是自动回收不再使用的内存。它的基本依据是可达性 :从根对象(如全局对象、当前执行栈的变量)出发,沿着引用链能访问到的对象是存活的,否则就是垃圾。主流算法是标记-清除:先标记所有可达对象,然后清除未被标记的垃圾。

现代浏览器(如 Chrome 的 V8 引擎)采用分代收集 优化。内存分为新生代老生代 。新生代存放新对象,使用快速的 Scavenge 算法 (复制,无碎片),回收频繁。存活下来的对象会晋升 到老生代。老生代存放长期存活对象,使用标记-清除标记-整理 (解决碎片)算法,回收较慢。为了减少老生代回收导致的页面卡顿,V8 采用了增量标记 (把标记过程拆分成小块与 JS 交替执行)和并行标记(用多个线程并行标记)。

即使有 GC,代码不当仍会导致内存泄漏 ,常见的有:1)意外的全局变量 ;2)忘记清除的定时器或回调 (其闭包引用的变量无法释放);3)游离的 DOM 引用 (JS 保留了已移除 DOM 的引用);4)未移除的事件监听器 。避免方法包括:避免全局变量、及时 clearInterval/removeEventListener、在组件销毁时清理资源、合理使用闭包,以及利用 WeakMap 存储与 DOM 关联的元数据。

开发者可以用 Chrome DevTools 的 Memory 面板(Heap Snapshots, Allocation Timeline)来检测内存泄漏和性能问题。"

相关推荐
一斤代码5 小时前
vue3 下载图片(标签内容可转图)
前端·javascript·vue
中微子5 小时前
React Router 源码深度剖析解决面试中的深层次问题
前端·react.js
光影少年5 小时前
从前端转go开发的学习路线
前端·学习·golang
中微子5 小时前
React Router 面试指南:从基础到实战
前端·react.js·前端框架
3Katrina5 小时前
深入理解 useLayoutEffect:解决 UI "闪烁"问题的利器
前端·javascript·面试
前端_学习之路6 小时前
React--Fiber 架构
前端·react.js·架构
伍哥的传说7 小时前
React 实现五子棋人机对战小游戏
前端·javascript·react.js·前端框架·node.js·ecmascript·js
qq_424409197 小时前
uniapp的app项目,某个页面长时间无操作,返回首页
前端·vue.js·uni-app
我在北京coding7 小时前
element el-table渲染二维对象数组
前端·javascript·vue.js
布兰妮甜7 小时前
Vue+ElementUI聊天室开发指南
前端·javascript·vue.js·elementui