Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JavaScript 深入系列之内存泄漏与垃圾回收机制 #135

Open
yuanyuanbyte opened this issue May 2, 2022 · 1 comment
Open

JavaScript 深入系列之内存泄漏与垃圾回收机制 #135

yuanyuanbyte opened this issue May 2, 2022 · 1 comment

Comments

@yuanyuanbyte
Copy link
Owner

yuanyuanbyte commented May 2, 2022

本系列的主题是 JavaScript 深入系列,每期讲解一个技术要点。如果你还不了解各系列内容,文末点击查看全部文章,点我跳转到文末

如果觉得本系列不错,欢迎 Star,你的支持是我创作分享的最大动力。

JS 内存泄漏与垃圾回收机制

不管什么程序语言,内存生命周期基本是一致的:

  1. 分配你所需要的内存
  2. 使用分配到的内存(读、写)
  3. 不需要时将其释放\归还

所有语言 第二部分都是明确的,第一和第三部分在 底层语言 中是明确的。但在像 JavaScript 这些高级语言中,大部分都是隐含的。

注意:JavaScript 是一种高级的解释执行的编程语言,是一种属于网络的高级脚本语言。

在 Chrome 浏览器中,V8 被限制了内存的使用(64位约1.4G/1464MB , 32位约0.7G/732MB),限制的主要原因是 V8 最初为浏览器而设计,不太可能遇到用大量内存的场景;更深层原因是 V8 垃圾回收机制的限制:清理大量的内存垃圾很耗时间,这样会引起 JavaScript 线程暂停执行,导致性能和应用直线下降。

JavaScript 是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。 释放的过程称为垃圾回收。

这个“自动”是混乱的根源,并让 JavaScript 开发者错误的感觉他们可以不关心内存管理,进而引发内存泄漏。

一、什么是内存泄漏

程序的运行需要内存。只要程序提出要求,操作系统或者运行时(runtime)就必须供给内存。

对于持续运行的服务进程,必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。

不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak)。

二、常见的内存泄漏

写得不好的JavaScript可能出现难以察觉且有害的内存泄漏问题。

在内存有限的设备上,或者在函数会被调用很多次的情况下,内存泄漏可能是个大问题。JavaScript中的内存泄漏大部分是由不合理的引用导致的。

下面来讲一讲常见的内存泄漏:

1. 意外声明的全局变量

意外声明全局变量是最常见但也最容易修复的内存泄漏问题。下面的代码没有使用任何关键字声明变量:

function setName() {
  name = 'yuanyuan';
}

此时,解释器会把变量 name 当作 window 的属性来创建(相当于 window.name = 'yuanyuan' )。可想而知,在 window 对象上创建的属性,只要 window 本身不被清理就不会消失。这个问题很容易解决,只要在变量声明前头加上 varletconst 关键字即可,这样变量就会在函数执行完毕后离开作用域。

2. 被遗忘的定时器

定时器也可能会悄悄地导致内存泄漏。下面的代码中,定时器的回调通过闭包引用了外部变量:

let name = 'yuanyuan'; 
setInterval(() => {
  console.log(name); 
}, 100);

只要定时器一直运行,回调函数中引用的 name 就会一直占用内存。垃圾回收程序当然知道这一点,因而就不会清理外部变量。

3. 使用不当的闭包

使用 JavaScript 闭包很容易在不知不觉间造成内存泄漏。请看下面的例子:

let outer = function() { 
  let name = 'yuanyuan'; 
  return function() { 
    return name; 
  }; 
};

这会导致分配给 name 的内存被泄漏。以上代码创建了一个内部闭包,只要 outer 函数存在就不能清理 name ,因为闭包一直在引用着它。假如 name 的内容很大(不止是一个小字符串),那可能就是个大问题了。

4. 未清理的 DOM 引用

DOM 元素的生命周期正常情况下取决于是否挂载在 DOM 树上,当元素从 DOM 树上移除时,就可以被销毁回收了。

但如果某个 DOM 元素在 JS 中也持有它的引用,想要彻底删除这个元素,就需要把两个引用都清除,这样才能正常回收它。

// 在对象中引用 DOM
var elements = {
  btn: document.getElementById('btn'),
}
function doSomeThing() {
  elements.btn.click()
}

function removeBtn() {
  // 移除 DOM 树中的 btn
  document.body.removeChild(document.getElementById('button'))
  // 但是此时全局变量 elements 还是保留了对 btn 的引用, btn 还是存在于内存中,不能被 GC 回收
}

虽然别的地方删除了,但是对象中还存在对 dom 的引用。

解决方法是删除 DOM 节点时,也要释放 JS 对节点的引用:

elements.btn = null;

三、垃圾回收机制

有些语言(比如 C 语言)必须手动释放内存,程序员负责内存管理。

char * buffer;
buffer = (char*) malloc(42);

// Do something with buffer

free(buffer);

上面是 C 语言代码,malloc方法用来申请内存,使用完毕之后,必须自己用free方法释放内存。

这很麻烦,所以大多数语言提供自动内存管理,减轻程序员的负担,这被称为"垃圾回收机制"(garbage collector)。

JavaScript 是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。在C和C++等语言中,跟踪内存使用对开发者来说是个很大的负担,也是很多问题的来源。JavaScript为开发者卸下了这个负担,通过自动内存管理实现内存分配和闲置资源回收。基本思路很简单:确定哪个变量不会再使用,然后释放它占用的内存。这个过程是周期性的,即垃圾回收程序每隔一定时间(或者说在代码执行过程中某个预定的收集时间)就会自动运行。垃圾回收过程是一个近似且不完美的方案,因为某块内存是否还有用,属于“不可判定的”问题,意味着靠算法是解决不了的。

我们以函数中局部变量的正常生命周期为例。函数中的局部变量会在函数执行时存在。此时,栈(或堆)内存会分配空间以保存相应的值。函数在内部使用了变量,然后退出。此时,就不再需要那个局部变量了,它占用的内存可以释放,供后面使用。这种情况下显然不再需要局部变量了,但并不是所有时候都会这么明显。垃圾回收程序必须跟踪记录哪个变量还会使用,以及哪个变量不会再使用,以便回收内存。如何标记未使用的变量也许有不同的实现方式。不过,在浏览器的发展史上,用到过两种主要的标记策略:标记清理和引用计数。

1. 标记清理

JavaScript 最常用的垃圾回收策略是标记清理(mark-and-sweep)。当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在于上下文中的标记。而不在上下文中的变量,比如在函数外部声明的全局变量,逻辑上讲,永远不应该释放它们的内存,因为只要上下文中的代码在运行,就有可能用到它们。当变量离开上下文时,也会被加上离开上下文的标记。

给变量加标记的方式有很多种。比如,当变量进入上下文时,反转某一位;或者可以维护“在上下文中”和“不在上下文中”两个变量列表,可以把变量从一个列表转移到另一个列表。标记过程的实现并不重要,关键是策略。

垃圾回收程序运行的时候,会标记内存中存储的所有变量(记住,标记方法有很多种)。然后,它会将所有在上下文中的变量,以及被在上下文中的变量引用的变量的标记去掉。在此之后再被加上标记的变量就是待删除的了,原因是任何在上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存。

到了2008年,IE、Firefox、Opera、Chrome 和 Safari 都在自己的 JavaScript 实现中采用标记清理(或其变体),只是在运行垃圾回收的频率上有所差异。

下面在介绍引用计数时会通过代码来更深入的了解和对比两种策略。

2. 引用计数

另一种没那么常用的垃圾回收策略是引用计数(referencecounting)。其思路是对每个值都记录它被引用的次数。声明变量并给它赋一个引用值时,这个值的引用数为1。如果同一个值又被赋给另一个变量,那么引用数加1。类似地,如果保存对该值引用的变量被其他值给覆盖了,那么引用数减1。当一个值的引用数为0时,就说明没办法再访问到这个值了,因此可以安全地收回其内存了。垃圾回收程序下次运行的时候就会释放引用数为0的值的内存。

引用计数最早由Netscape Navigator 3.0采用,但很快就遇到了严重的问题:循环引用。所谓循环引用,就是对象A有一个指针指向对象B,而对象B也引用了对象A。比如:

function problem() { 
	let objectA = new Object(); 
	let objectB = new Object(); 
	
	objectA.someOtherObject = objectB; 
	objectB.anotherObject = objectA; 
}

在这个例子中, objectAobjectB 通过各自的属性相互引用,意味着它们的引用数都是2。在标记清理策略下,这不是问题,因为在函数结束后,这两个对象都不在作用域中。而在引用计数策略下, objectAobjectB 在函数结束后还会存在,因为它们的引用数永远不会变成0。如果函数被多次调用,则会导致大量内存永远不会被释放。为此,Netscape在4.0版放弃了引用计数,转而采用标记清理。事实上,引用计数策略的问题还不止于此。

在IE8及更早版本的IE中,并非所有对象都是原生JavaScript对象。BOM和DOM中的对象是C++实现的组件对象模型(COM,Component Object Model)对象,而COM对象使用引用计数实现垃圾回收。因此,即使这些版本IE的JavaScript引擎使用标记清理,JavaScript存取的COM对象依旧使用引用计数。换句话说,只要涉及COM对象,就无法避开循环引用问题。下面这个简单的例子展示了涉及COM对象的循环引用问题:

let element = document.getElementById("some_element"); 
let myObject = new Object();
myObject.element = element; 
element.someObject = myObject;

这个例子在一个DOM对象( element )和一个原生JavaScript对象( myObject )之间制造了循环引用。 myObject 变量有一个名为 element 的属性指向DOM对象 element ,而 element 对象有一个 someObject 属性指回 myObject 对象。由于存在循环引用,因此DOM元素的内存永远不会被回收,即使它已经被从页面上删除了
也是如此。

为避免类似的循环引用问题,应该在确保不使用的情况下切断原生JavaScript对象与DOM元素之间的连接。比如,通过以下代码可以清除前面的例子中建立的循环引用:

myObject.element = null; 
element.someObject = null;

把变量设置为 null 实际上会切断变量与其之前引用值之间的关系。当下次垃圾回收程序运行时,这些值就会被删除,内存也会被回收。

为了补救这一点,IE9把BOM和DOM对象都改成了JavaScript对象,这同时也避免了由于存在两套垃圾回收算法而导致的问题,还消除了常见的内存泄漏现象。

回过头可以发现,使用标记清理策略的话,循环引用就不再是问题了。

在上面的示例中,函数调用返回之后,两个对象从全局对象出发无法获取。因此,他们将会被垃圾回收器回收。第二个示例同样,一旦 div 和其事件处理无法从根获取到,他们将会被垃圾回收器回收。

四、内存管理方案

JavaScript变量可以保存两种类型的值:原始值和引用值。原始值是 6 种原始数据类型: UndefinedNullBooleanNumberStringSymbol

原始值和引用值有以下特点:

  • 原始值大小固定,因此保存在栈内存上。
  • 从一个变量到另一个变量复制原始值会创建该值的第二个副本。
  • 引用值是对象,存储在堆内存上。
  • 包含引用值的变量实际上只包含指向相应对象的一个指针,而不是对象本身。
  • 从一个变量到另一个变量复制引用值只会复制指针,因此结果是两个变量都指向同一个对象。
  • typeof 操作符可以确定值的原始类型,而 instanceof 操作符用于确保值的引用类型。

任何变量(不管包含的是原始值还是引用值)都存在于某个执行上下文中(也称为作用域)。这个上下文(作用域)决定了变量的生命周期,以及它们可以访问代码的哪些部分。

执行上下文可以总结如下:

  • 执行上下文分全局上下文、函数上下文和块级上下文。
  • 代码执行流每进入一个新上下文,都会创建一个作用域链,用于搜索变量和函数。
  • 函数或块的局部上下文不仅可以访问自己作用域内的变量,而且也可以访问任何包含上下文乃至全局上下文中的变量。
  • 全局上下文只能访问全局上下文中的变量和函数,不能直接访问局部上下文中的任何数据。
  • 变量的执行上下文用于确定什么时候释放内存。

JavaScript是使用垃圾回收的编程语言,开发者不需要操心内存分配和回收。

JavaScript的垃圾回收程序可以总结如下:

  • 离开作用域的值会被自动标记为可回收,然后在垃圾回收期间被删除。
  • 主流的垃圾回收算法是标记清理,即先给当前不使用的值加上标记,再回来回收它们的内存。
  • 引用计数是另一种垃圾回收策略,需要记录值被引用了多少次。
  • JavaScript 引擎不再使用这种算法,但某些旧版本的IE仍然会受这种算法的影响,原因是JavaScript会访问非原生JavaScript对象(如DOM元素)。
  • 引用计数在代码中存在循环引用时会出现问题。
  • 解除变量的引用不仅可以消除循环引用,而且对垃圾回收也有帮助。为促进内存回收,全局对象、全局对象的属性和循环引用都应该在不需要时解除引用。

在使用垃圾回收的编程环境中,开发者通常无须关心内存管理。不过,JavaScript运行在一个内存管理与垃圾回收都很特殊的环境。分配给浏览器的内存通常比分配给桌面软件的要少很多,分配给移动浏览器的就更少了。这更多出于安全考虑而不是别的,就是为了避免运行大量JavaScript的网页耗尽系统内存而导致操作系统崩溃。这个内存限制不仅影响变量分配,也影响调用栈以及能够同时在一个线程中执行的语句数量。

● 解除引用

将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据。如果数据不再必要,那么把它设置为 null ,从而释放其引用。这也可以叫作解除引用。这个建议最适合全局变量和全局对象的属性。局部变量在超出作用域后会被自动解除引用,如下面的例子所示:

function createPerson(name) {
    let localPerson = new Object();
    localPerson.name = name;
    return localPerson;
}
let globalPerson = createPerson("Nicholas"); // 解除globalPerson对值的引用
globalPerson = null;

在上面的代码中,变量 globalPerson 保存着createPerson() 函数调用返回的值。在 createPerson() 内部, localPerson 创建了一个对象并给它添加了一个 name 属性。然后, localPerson 作为函数值被返回,并被赋值给globalPersonlocalPersoncreatePerson() 执行完成超出上下文后会自动被解除引用,不需要显式处理。但globalPerson 是一个全局变量,应该在不再需要时手动解除其引用,最后一行就是这么做的。

不过要注意,解除对一个值的引用并不会自动导致相关内存被回收。解除引用的关键在于确保相关的值已经不在上下文里了,因此它在下次垃圾回收时会被回收。

● 通过 constlet 声明提升性能

ES6增加这两个关键字不仅有助于改善代码风格,而且同样有助于改进垃圾回收的过程。因为 constlet 都以块(而非函数)为作用域,所以相比于使用 var ,使用这两个新关键字可能会更早地让垃圾回收程序介入,尽早回收应该回收的内存。在块作用域比函数作用域更早终止的情况下,这就有可能发生。

参考

查看原文

查看全部文章

博文系列目录

  • JavaScript 深入系列
  • JavaScript 专题系列
  • JavaScript 基础系列
  • 网络系列
  • 浏览器系列
  • Webpack 系列
  • Vue 系列
  • 性能优化与网络安全系列
  • HTML 应知应会系列
  • CSS 应知应会系列

交流

各系列文章汇总:https://github.com/yuanyuanbyte/Blog

我是圆圆,一名深耕于前端开发的攻城狮。

weixin

@xiaomoumou
Copy link

你好,像这种比较抽象的概念性知识,你通常是怎么学习的?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants