JavaScript内存管理和内存泄漏

本文深入解析JavaScript的内存管理机制,涵盖栈与堆空间、垃圾回收策略(引用计数与可达性分析)、代际假说、GC执行时机,列举常见内存泄漏场景,并提供排查和解决内存泄漏的方法。掌握这些技巧,提升编码效率,避免性能问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

简介

像C语言这样的底层语言,一般是由应用程序手动分配内存和释放内存的。而JavaScript是由引擎自动完成内存管理的。虽然JavaScript会自动释放内存,但如果编码不当,也会造成内存泄漏问题。文章分析了JavaScript的内存管理机制,同时,总结了几种典型的内存泄漏的场景,最后,介绍了排查内存泄漏的相关实践。希望能够帮助我们在编码中尽量避免内存泄漏的问题,即使有问题了也可以快速定位。

程序执行

内存是用来存储程序运行过程中的数据
程序的执行是一系列函数调用的过程。函数可以嵌套调用,当子函数执行完毕,其占用的内存资源也应该得到释放,所以用栈结构来存储上下文信息(包括基本类型变量)。
如果把所有数据都放入栈中,将会影响上下文切换的效率。所有还需要另外的空间来存储比较大的数据(引用类型变量),即堆空间

内存空间

根据程序运行的需要,JavaScript引擎把内存空间划分如下:

  • 代码空间
  • 栈空间: 程序执行上下文信息(包括基本类型数据)。
  • 堆空间: 引用类型数据。

栈空间

栈空间是一种先进后出的结构。当调用函数时,把执行上下文压入栈,函数内定义的基本类型变量也会被存在栈中。函数执行完毕后,再把数据弹出栈
采用静态分配方式,即在编译时就能确定变量占用内存大小。

在这里插入图片描述

堆空间

内存分配

当创建一个引用类型变量时,引擎会在堆空间分配内存,并通过一个栈变量索引到该堆地址
采用动态分配方式,即在运行时确定变量占用内存大小。

在这里插入图片描述

垃圾回收

回收对象

垃圾回收策略有一个重要的环节,如何确定哪些内存是可回收的

通常有两种方法:引用计数法访问可达法

  • 引用计数法:当一个对象被引用次数为零时,就认为该对象是可被回收的。但存在循环引用的问题。
  • 访问可达法:从根对象(window/global)出发,递归遍历子对象,把不可访问到的对象认为是可被回收的。

循环引用:

在这里插入图片描述

访问可达法:

在这里插入图片描述

代际假说

JavaScript引擎的垃圾回收机制是基于"代际假说"的:即大部分对象的生命周期比较短,在一个回收周期内可被回收。
基于"代际假说",把堆内存分为"新生代""老生代"。新对象被放入"新生代",经过两轮垃圾回收依然存活的对象被移动到"老生代"。

"新生代"和"老生代"的垃圾回收策略有所不同:"新生代"使用Minor GC,"老生代"使用Major GC

Major GC

主回收器的工作过程包括: marking、sweeping、defragmenting三个阶段。

  • marking: 完成标记。把不可访问到的对象,标记为非活动对象(垃圾)。
  • sweeping: 回收垃圾。记录非活动对象的地址信息,下次有新对象需要分配内存时可以直接使用。
  • defragment: 整理内存碎片。经过多次的内存回收后,原本连续的内存块会被分割得比较零散。这时候把所有对象复制到连续的内存片段,可以提高内存利用效率。

Minor GC

“新生代"又分为"对象区""空闲区":新对象放入"对象区”;当对象区快写满了,进行垃圾回收,把活动对象复制到"空闲区";然后交换"对象区"和"空闲区"的角色。

副回收器的工作过程包括: marking、copying、 exchange role。

  • marking:标记阶段。同Major GC。
  • copying:回收垃圾。把活动对象从"对象区"复制到"空闲区",此时的"对象区"空间被当做垃圾回收。
  • exchange role:交换"对象区"和"空闲区"的角色。即"空闲区"变成"对象区",可以继续分配给新对象。

Major GC VS Minor GC

相同点:

  • marking阶段是相同的。
  • 当复制对象到新的内存时,需要更新引用地址,保证该对象能够被正常访问。

不同点:

  • sweeping阶段是不同的:
    Minor GC是复制活动对象到空闲区,留下来的非活动对象会被当作垃圾回收。由于新生代的对象一般占内存小且存活周期短,所以复制并不会带来太大的开销。
    Major GC不是每次都复制,而是把非活动对象的地址信息存起来,下次可以直接分配给新对象。当内存碎片过多时再进行复制。

GC执行时机

GC是运行在主线程的,会阻塞JS脚本的执行,被称为全停顿(stop-the-world)。

在这里插入图片描述

针对全停顿问题,常见的有以下两种方案:

  • 增量(increment)执行

把GC任务拆分成多个小任务,碎片化执行。虽然没有提高GC工作效率,但可以给JS脚本更高优先级的响应,避免页面卡顿。

在这里插入图片描述

  • 并发(concurrent)执行

GC运行在另外的辅助线程,不再占用主线程。理论上避免了全停顿问题。

在这里插入图片描述

内存泄漏

少量的内存泄露是不容易被察觉的,用户一刷新页面,问题也就被隐藏了。当页面交互越多,用户停留时间越长,特别是SPA程序,就更容易暴露内存泄露问题: 导致可用内存减少,GC工作频率和时长增加,主线程被占用,最终可能导致页面卡顿甚至程序崩溃。

典型场景

在了解内存管理机制之后,我们知道当一个变量是可访问的/被引用的,那么GC将不会回收内存。所以开发中应该做到:变量不用之后立刻解除引用。下面介绍几种典型的内存泄漏场景,大家在开发中可以规避。

不必要的全局变量

  • 未使用关键字声明的变量

在这里插入图片描述

// 非严格模式下,memoryLeak会被挂在全局上
function testGlobalValue() {
	memoryLeak = new MemoryLeak();
}

解决方法:

  • 开启严格模式或者lint工具检查 未声明的变量。
  • 尽量避免使用全局变量存储大量的数据。

不恰当的闭包(closure)

闭包是JavaScript的一大特性:当一个对象被内部函数引用了,就会形成闭包。即使外部函数已经执行完毕,内部函数依然可以访问到该对象。由于闭包对象是存储在堆空间的,如果内部函数被引用了,闭包对象将不会被释放。

举个栗子:

let globalArray = [];
function testClosure() {
    function wrap() {
        let dateArray = new Array(5 * 1000).map(() => {
            return new Date();
        });
        return function inner() {
            return dateArray;
        };
    }
    globalArray.push(wrap());
}

由于内部函数inner()引用了dateArray,形成闭包。并且inner()最终被全局对象globalArray引用,导致dateArray无法被释放。如果这不是预期行为,那就是内存泄漏了。

解决方法:

尽量避免在闭包中使用大量的数据。

detached DOM/EventListener

<body>
  <div id="node"></div>
  <script>
    function onclick() {}
    let node = document.getElementById("detachedNode");
    node.addEventListener("click", onclick);
    //node.removeEventListener("click", onclick); // 删除节点后,未移除的事件监听也会造成内存泄漏。
    node.parentNode.removeChild(node); // 虽然从dom树移除了节点,但该节点还被JavaScript变量(node)引用着,所以导致dom节点无法被回收。
  </script>
</body>

解决方法:

  • 在操作完DOM之后,及时清除对DOM节点的引用,即赋值null。
  • 在释放DOM节点之前,记得移除节点上的事件监听。

未移除的事件监听

添加事件监听会占用内存,所以在事件处理完成后立刻移除事件监听。

在这里插入图片描述

function testUnremoveEventListener() {
    document.addEventListener("mousedown", onMousedown);
}

function onMousedown() {
    document.addEventListener("mousemove", onMousemove);
    document.addEventListener("mouseup", onMouseup);
}
function onMousemove() {
    console.log("mousemove");
}
function onMouseup() {
    console.log("mouseup");
    // 事件处理完后应该立刻释放 事件监听
    //   document.removeEventListener("mousedown", onMousedown);
    //   document.removeEventListener("mousemove", onMousemove);
    //   document.removeEventListener("mouseup", onMouseup);
}

类似的,还有setTimeout和setInterval函数。

注意: setInterval运行期间,入参对象始终被定时器引用。setTimeout在触发回调之后,入参会被解除引用。

function testTimer() {
    let couter = new MemoryLeak();
    // 在定时器被clearInterval之前,couter对象不会被释放。
    window.timeoutID = setInterval(onTimeout, 1 * 1000, couter);
}
function onTimeout(couter) {
    console.log("timeout");
    couter.value++;
}

排查方法

思路

目前是没有工具可以自动检查出内存泄漏的,但是,内存泄漏有个明显的特征:当执行某个操作之后,占用内存会变大。所以排查问题的思路就是逐一排查可能存在内存泄漏的页面组件,观察内存使用情况

在浏览器开发环境中,有两个辅助工具:timelineheap snapshot

  • timeline直观地展示了内存分配随时间的变化情况。
  • heap snapshot记录了当前内存中所有对象占用内存的大小。

过程

具体排查过程如下:

  1. 通过timeline排查出页面中可能存在内存泄漏的组件。
  2. 审查对应模块的代码,结合内存泄漏的典型场景,找出可能存在问题的代码(业务数据/listener/detached dom)。
  3. 记录操作前后的heap snapshot,通过对比可疑对象的内存大小变化,进一步锁定可疑对象。
  4. 找出可能存在问题的代码进行修复,重复上述操作进行验证。

这里以上面的detached DOM/EventListener为例做分析:

  • 第一步,操作页面组件,观察timeline。我们观察到执行动作后,timeline出现了蓝线且一直未消失。

在这里插入图片描述

  • 第二步,选中蓝线附近区域,查看下面的内存对象列表。观察Shallow Size大的对象是否为应用程序数据对象。由于例子中的数据量不大,所以这里很难看出问题。

  • 第三步,审查对应模块的代码。发现是DOM操作和事件监听相关的,可能是detached DOM和Listener造成。所以尝试搜索detached对象,果然出现了detached DOM。
    在这里插入图片描述

  • 第四步,记录操作前后的heap snapshot,进行对比。观察到存在未释放的DOM节点和事件监听。

在这里插入图片描述

  • 第五步,找出可能存在问题的代码进行修复,然后再通过timeline和heap snapshot进行验证。

参考资料

MDN内存管理

图文并茂讲清楚 JavaScript 内存管理

浅谈V8垃圾回收机制

浅谈JS内存机制

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值