JavaScript内存管理

365 阅读8分钟

为什么需要了解内存管理?

虽然 JavaScript 拥有自动垃圾回收机制(Garbage Collection, GC),但理解内存管理仍然至关重要,原因如下:

  • 避免内存泄漏: 即使有 GC,不当的代码仍然会导致内存泄漏,最终导致应用卡顿甚至崩溃。
  • 性能优化: 了解内存分配和回收机制,可以写出更高效的代码,减少 GC 带来的性能开销。
  • 排查问题: 当应用出现性能问题时,内存分析是重要的排查手段。

内存的基础概念

  1. 栈内存 (Stack Memory)
    • 栈用于存储固定大小和生命周期比较短的数据(如原始数据类型)。
    • 栈存储的内存是可与以移动的,通过栈顶作为入口,用作为出口。
  2. 堆内存 (Heap Memory)
    • 堆用于存储复杂和属性可调整的数据,如对象和数组。
    • 堆存储的内存不定存在于连续的内存区,因此较为安全,但发给和重构成本较高。

JavaScript 内存生命周期

任何编程语言的内存生命周期都遵循以下步骤:

  1. 分配内存: 创建变量、对象、函数等时,系统会自动分配内存。
  2. 使用内存: 读写变量、调用函数等操作。
  3. 释放内存: 当变量不再需要时,系统回收其占用的内存。

JavaScript 的 GC 负责自动执行第三步。

JavaScript 内存分配

JavaScript 的内存分配主要发生在以下两种情况:

  • 基本类型: NumberStringBooleanNullUndefinedSymbolBigInt,它们的值直接存储在栈内存中,空间小、大小固定,按值访问。
  • 引用类型: ObjectArrayFunction 等,它们的值存储在堆内存中,栈内存中存储的是一个指向堆内存地址的指针。空间大、大小不固定,按引用访问。

栈(Stack)和堆(Heap)的区别:

  • 栈: 自动分配和释放,速度快,空间小。就像一个后进先出的容器。
  • 堆: 动态分配,空间大,但分配和回收速度相对较慢。

JavaScript 垃圾回收机制

JavaScript 主要使用两种垃圾回收算法:

  1. 标记清除(Mark and Sweep): 这是最常用的算法。GC 定期从根对象(例如 window 对象或全局对象)开始遍历所有可达对象,标记为“存活”,未被标记的对象则视为“垃圾”,进行清除。
  2. 引用计数(Reference Counting): 较老的算法,为每个对象维护一个引用计数,当对象被引用时计数加 1,当引用解除时计数减 1,当计数为 0 时回收。但这种算法容易造成循环引用问题,导致内存泄漏,因此现代浏览器基本不再使用。

常见的内存泄漏场景及避免方法

  1. 意外的全局变量: 在函数内部忘记使用 varletconst 声明变量,会导致变量泄漏到全局作用域,无法被回收。

    function foo() {
      a = "意外的全局变量"; // a 变成了全局变量
    }
    

    避免方法: 始终使用 varletconst 声明变量,开启严格模式 "use strict"; 可以帮助检测此类错误。

  2. 闭包: 闭包会引用外部函数的变量,如果闭包长期存在,会导致被引用的变量无法被回收。

    function outer() {
      let count = 0;
      return function inner() {
        count++;
        return count;
      };
    }
    
    const counter = outer(); // counter 持有 inner 函数的引用,导致 count 无法被回收
    

    避免方法: 谨慎使用闭包,及时解除对闭包的引用,或者在闭包不再需要时将其中的变量设置为 null

  3. DOM 元素引用: 将 DOM 元素的引用保存在 JavaScript 对象中,即使 DOM 元素从页面中移除,由于 JavaScript 对象仍然持有引用,导致内存无法回收。

    let element = document.getElementById("myElement");
    let obj = { el: element }; // obj 持有 element 的引用
    
    element.parentNode.removeChild(element); // DOM 元素被移除,但内存未释放
    

    避免方法: 在 DOM 元素移除后,及时将 JavaScript 对象中对 DOM 元素的引用设置为 null

  4. 定时器和事件监听器: 如果定时器或事件监听器没有被正确清除,它们会一直持有回调函数的引用,导致相关变量无法被回收。

    let intervalId = setInterval(() => {
      // ...
    }, 1000);
    
    // 如果不清除定时器,回调函数和相关变量将一直存在于内存中
    clearInterval(intervalId); // 正确清除定时器
    

    避免方法: 在组件卸载或不再需要时,及时清除定时器和移除事件监听器。

内存分析工具

Chrome DevTools 的 Memory 面板提供了强大的内存分析功能,可以帮助我们检测内存泄漏和分析内存使用情况。常用的功能包括:

  • Heap snapshots(堆快照): 记录某一时刻的内存状态,可以比较不同快照之间的差异,找出内存泄漏的对象。
  • Allocation timeline(分配时间线): 记录内存分配的随时间变化情况,可以观察内存增长趋势。

关于 JavaScript 内存管理的常见面试题


1. 什么是垃圾回收?JavaScript 的垃圾回收机制是如何工作的?

垃圾回收是自动清理不再使用的内存的过程。JavaScript 使用垃圾回收机制来检测哪些对象不再被引用,然后释放这些对象占用的内存空间。

  • JavaScript 的垃圾回收主要依赖标记清除,即垃圾回收器定期从根对象(例如全局对象 windowglobal)开始遍历所有可达对象,并标记为“存活”。遍历结束后,未被标记的对象则被视为“垃圾”,进行清除,释放其占用的内存。。
  • 如果某对象没有被任何活动代码引用,则标记为“不可达”,随后会被垃圾回收器释放。

2. 什么是内存泄漏?JavaScript 中有哪些常见的内存泄漏场景?

内存泄漏是指程序占用的内存空间未被释放,导致内存无法有效利用。 常见的内存泄漏场景包括:

  • 未清理的事件监听器

    事件绑定后未移除。

    let button = document.getElementById('btn');
    button.addEventListener('click', () => console.log('Clicked'));
    button = null; // 此时事件监听器依然存在,内存无法回收
    
  • 未被清理的全局变量

    无意中声明在全局作用域中的变量。

    function foo() {
        leakedVar = 'I am leaked'; // 没有使用 `let` 或 `const`,变量变为全局
    }
    
  • 闭包导致的引用

    闭包保留了对外部变量的引用,导致内存无法释放。

    function outer() {
        let largeArray = new Array(1000);
        return function inner() {
            console.log(largeArray); // largeArray 被保留,无法回收
        };
    }
    const closure = outer();
    

3. JavaScript 中如何手动优化内存管理?

  • 重置无用变量

    将不再使用的对象或变量设置为

    null
    

    使其引用断开,便于垃圾回收。

    let obj = { key: 'value' };
    obj = null; // 可被回收
    
  • 使用弱引用

对可能长时间存在的对象(如缓存)使用

WeakMap

WeakSet

这些对象不会阻止垃圾回收。

      let weakCache = new WeakMap();
      let obj = { key: 'value' };
      weakCache.set(obj, 'cachedValue');
      obj = null; // 可以被回收
  • 移除事件监听器

    在组件销毁或对象不再需要时,及时移除事件监听器。

    button.removeEventListener('click', handleClick);
    

4. 垃圾回收会影响性能吗?如何减少垃圾回收对性能的影响?

垃圾回收会占用 CPU 时间,可能导致性能下降,尤其是在高频执行时。 减少影响的措施包括:

  • 避免创建过多临时对象:减少垃圾回收的频率。
  • 优化数据结构:使用轻量级的数据结构存储信息。
  • 延长对象生命周期:对于重复使用的对象,避免频繁销毁和重建。

5. 什么是栈内存和堆内存?它们的区别是什么?

  • 栈内存:用于存储基本类型的值和函数调用信息(如作用域链、函数参数)。大小固定,分配速度快。
  • 堆内存:用于存储复杂类型(如对象、数组)。大小不固定,分配和回收成本较高。

区别:

特性栈内存堆内存
存储内容原始数据类型引用数据类型
分配方式自动分配手动管理或垃圾回收
性能分配快,访问快分配慢,访问慢

6. WeakMap 和 Map 有什么区别?为什么使用 WeakMap 更有利于内存管理?

  • Map 的键可以是任何值,键和值都被强引用,垃圾回收不会回收。
  • WeakMap 的键必须是对象,键和值被弱引用,如果对象没有其他引用,垃圾回收会自动回收。

使用 WeakMap 有助于内存管理,因为它不会阻止对象被回收,适用于缓存和临时存储。

7. 什么是执行上下文,如何与内存管理相关?

执行上下文是 JavaScript 代码执行时的运行环境,分为全局、函数和块级上下文。 每个上下文都会创建变量对象、作用域链和 this。 与内存管理相关的点包括:

  • 函数执行结束后,其执行上下文会被销毁,局部变量会被回收(除非被闭包引用)。
  • 全局上下文在整个程序生命周期中存在,其变量不会被回收,因此避免全局变量过多。