深入理解JavaScript内存泄漏:原理、机制与常见场景

作者:青柠时间:2026-10-24 11:01:01浏览:537

本文深入解析JavaScript内存泄漏原理,介绍V8引擎采用的标记清除算法与分代回收策略。详细剖析意外全局变量、滥用闭包、遗忘定时器及DOM节点未释放等4种常见内存泄漏场景,并提供对应的代码示例与排查建议,帮助开发者编写更高效的代码。

1、什么是内存泄漏

在探讨内存泄漏之前,首先需要理解内存的基本概念。随机存取存储器(Random Access Memory,简称RAM)是CPU直接交换数据的内部存储介质。它具有读写速度快、可随时访问的特点,通常用作操作系统或正在运行的程序的临时数据存储介质。

什么是内存泄漏?

内存泄漏是指程序已经不再需要某块内存,但未能及时将其释放,导致该内存长期占用系统资源的现象。

在理解内存泄漏之前,我们需要了解内存管理的不同模式。在C语言等底层语言中,内存的分配与释放需要开发者手动完成,通常通过malloc和free等函数进行内存管理。


<pre class="custom">`char * buffer;
// 使用malloc申请内存空间
buffer = (char*) malloc (66);
// do something ...
// 不需要时, 释放掉内存空间引用
free(buffer);

上述代码展示了C语言中内存的申请与释放过程。然而,在JavaScript中,开发者无需手动管理内存。Chrome浏览器中的V8引擎提供了自动的内存分配与回收机制,即垃圾回收机制(Garbage Collection, GC)。尽管无需手动管理,但这并不意味着编写代码时可以忽视内存问题。由于V8的垃圾回收机制遵循特定规则,了解这些规则有助于避免写出导致内存泄漏的低效代码。

首先,我们来了解JavaScript垃圾回收机制中常见的两种方法:

  • 引用计数算法
  • 标记清除算法

引用计数法

早期IE浏览器使用的是引用计数算法。这种方法无法有效解决循环引用导致的垃圾回收问题,容易引发内存泄漏。

那么,什么是引用计数算法?又什么是循环引用问题呢?

引用计数是指,每当一个变量被引用时,垃圾回收机制(GC)会给该变量的引用计数加一;当引用减少时,计数减一。如果计数变为零,在下一次垃圾回收时,该变量所占用的内存就会被释放。


<pre class="custom">`let obj = {}; // obj计数为0
let a = obj; // obj计数为1
let b = obj; // obj计数为2
let a = null; // obj计数为1
let b = null; // Obj计数为0, 下次垃圾回收将obj内存释放

上述代码演示了引用计数法的垃圾回收机制。然而,当存在循环引用时,这种机制将失效。


<pre class="custom">`let obj = {};
let obj2 = {};
obj.o = obj2; // obj2计数为1
obj2.o = obj; // obj计数为1

这就是循环引用。在这种情况下,垃圾回收机制不会释放obj和obj2的内存,导致变量常驻内存,从而引发内存泄漏。

说完引用计数法,我们再来看看目前主流浏览器所采用的垃圾回收算法——标记清除法。

自2012年起,所有现代浏览器都使用了标记清除垃圾回收算法。对JavaScript垃圾回收算法的所有改进都是基于标记-清除算法的,并未改变其核心逻辑及对“对象是否不再需要”的简化定义。

标记清除法

该算法假定存在一个根对象(在JavaScript中,根是全局对象)。垃圾回收器会定期从根开始,查找所有从根引用的对象,然后继续查找这些对象所引用的对象。

在介绍标记清除法之前,补充一个知识点,即栈和堆的概念,请看下面的示例。


<pre class="custom">`let obj = {};
let obj2 = {};

obj = null;
obj2 = null;

我们知道,在JavaScript中,除了八大基本类型(截至目前共八种)外,其余均为对象类型。在JS中,对象类型属于引用类型,其内容实体存储在堆中,如下图所示:

1、什么是内存泄漏

当我们重新赋值obj和obj1时,内存结构会发生变化。

1、什么是内存泄漏

当堆内存中的对象不再被任何变量引用时,它们虽然仍占用内存,但已无实际用途。此时,垃圾回收机制介入销毁这些空间。V8引擎的垃圾回收机制不仅会销毁堆内存中无人引用的空间,还会对堆内存进行碎片整理。V8的GC工作过程如下动图所示:

1、什么是内存泄漏

V8的GC大致可以分为以下几个步骤:

第一步,通过GC Root标记空间中活动对象和非活动对象。目前V8采用的是可访问性算法,从GC Root出发遍历所有对象。通过GC Root可以遍历到的标记为可访问的,称为活动对象,必须保留在内存中;GC Root无法遍历到的标记为不可访问的,称为非活动对象,这些不可访问的对象将被GC清理。

第二步,回收非活动对象所占据的内存。即在完成所有标记后,统一清理内存中所有被标记为可回收的对象。

第三步,进行内存整理。一般来说,频繁回收对象后,内存中会存在大量不连续空间,这些不连续的内存空间称为内存碎片。

受代际假说的影响,V8引擎采用两个垃圾回收器:主垃圾回收器(Major GC)和副垃圾回收器(Minor GC,即Scavenger)。你可能会问,什么是代际假说?

第一个假说是大部分对象都是“朝生夕死”的,即大部分对象在内存中存活时间很短。例如函数内部声明的变量或块级作用域中的变量,当函数或代码块执行结束时,作用域中定义的变量就会被销毁。因此,这类对象一经分配内存,很快就变得不可访问。

第二个假说是不死的对象会活得更久,例如全局的window、DOM、Web API等对象。

这两个回收器的作用如下:

  • 主垃圾回收器(Major GC),主要负责老生代的垃圾回收。
  • 副垃圾回收器(Minor GC,Scavenger),主要负责新生代的垃圾回收。

这里又会引出新生代内存和老生代内存的概念,将堆内存分成两块区域。

新生代的内存区域一般较小,但垃圾回收频率较高;而老生代内存区的特点是对象占用空间相对较大,对象存活时间较长,垃圾回收频率也较低。

另外补充一点,垃圾回收时是会阻塞进程的。

2、常见的内存泄漏情况

了解垃圾回收和内存泄漏的概念后,我们来看一些常见的内存泄漏场景:

1. 意外的全局变量

前文提到,有些对象是常驻内存的,视为“不死对象”,如window对象。它是浏览器中JavaScript的顶级对象,其存在贯穿整个JavaScript生命周期。如果不小心将庞大且无用的变量挂载到window对象上,将会造成内存泄漏。当然,这是一个较低级的错误。


<pre class="custom">`function test() {
  // 漏掉了声明, 将会自动挂载到window对象下
  str = '';
  for (let i = 0; i < 100000; i++) {
   str += 'xx';
  }
  return str;
}
// test执行结束后, str应该就没用了, 但是它常驻在了内存中
test();

2. 滥用闭包

此处顺便了解一下闭包的概念。网上关于闭包的说明往往较为抽象,我个人理解的闭包是:

函数及其可操作的其他作用域变量的词法环境称为闭包。

当然,如果我是个杠精,可能会问with语法是否也算闭包?按照定义,with不是函数,所以不属于闭包。

MDN中对闭包的描述:

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在JavaScript中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

闭包是静态作用域(又称词法作用域)语言独有的功能。


<pre class="custom">`function fn() {
  const x = 'xx';
  return function() {
    return x;
 }
}
const getX = fn();
console.log(getX());

如上面的例子就是一个闭包,fn执行结束后内部变量并没有销毁,我们在全局作用域下可以通过getX访问到fn函数作用域内的变量x。

如果不好理解,可以看下上面提到的静态作用域(又称词法作用域)。看到静态作用域,你可能会问,有没有动态作用域呢?确实有,bash脚本采用的就是动态作用域,而JavaScript采用的是静态作用域,闭包是静态作用域采用的功能。

看两个例子。


<pre class="custom">`const x = 123;
function fn() {
  console.log(x);
}
function fn2() {
  const x = 345;
  fn();
}
fn2(); // 结果是123

静态作用域:fn中输出的x所处的作用域是在定义时确定的。

再看个动态作用域的例子。


<pre class="custom">`# test.sh
value="global";
function fn() {
 echo $value;
}
function fn2() {
 local value="local";
 fn;
}
fn2; # 结果是local

动态作用域:fn中输出的x所处的作用域是在调用时确定的。

还有一点,只有滥用闭包才能叫内存泄漏。因为根据定义,只有我们用不到了,而且没有被销毁的内存才叫内存泄漏。闭包中的值是我们用到的,所以不应该叫做内存泄漏。


<pre class="custom">`function generateRandomMath() {
 let x = Math.random();
  return function() {
    return x;
  }
}

如上面这个例子就是滥用闭包了,就该叫做内存泄漏。

3. 被遗忘的定时器

这个没什么好说的,就是设置了定时器请记住在不要的时候使用clearInterval或者clearTImeout给他关一下。

4. DOM相关

给某个dom节点绑定了很多事件,使用过程中dom节点被移除但是被释放内存

我们来看个例子。


<pre class="custom">`<button class="remove">remove bbb</button>
<div class="box">bbb</div>
<script> const box = document.querySelector('.box');

  document.querySelector('.remove').addEventListener('click', () => {
    document.body.removeChild(box);
    console.log(box);
  }) </script>

移除了box元素后,box仍然占用内存,这也是内存泄漏,因为box用不到了但是没有释放内存。

总结

到此这篇关于javascript内存泄漏的文章就介绍到这了,更多相关javascript内存泄漏内容请搜索本站以前的文章或继续浏览下面的相关文章希望大家以后多多支持本站!

作者最新文章