前言
这篇文章主要是围绕 JavaScript 的内存管理和垃圾回收机制开展,在阅读这篇文章之前,我们可以先思考一下,下面的几个问题:
- 什么是内存?什么是内存管理?
- 垃圾是怎么产生的?
- 为什么要进行垃圾回收?
- JavaScript是如何进行垃圾回收的?
- Chrome浏览器是如何进行垃圾回收的?
一、内存管理
JavaScript
的内存管理主要有两个方面:
- 垃圾回收
- 内存泄露
1.1 内存管理的简介
像C语言这样的底层语言一般都有底层的内存管理接口,比如malloc()
和free()
。相反,JavaScript 是在创建变量(对象、字符串等)时自动进行了分配内存,并且在不使用它们时"自动"释放内存。这种方式叫做自动管理内存(Automatic Memory Management)。
释放的过程称为垃圾回收(Garbage Collection)。
"自动"释放内存的自动是混乱的根源,并让 开发者 错误的感觉他们可以不关心内存管理
1.2 内存生命周期
内存生命周期大致如下:
- 分配内存
在我们创建变量或函数的时候,JavaScript引擎会为我们分配一些内存空间来存放该变量的内容 - 使用内存(读/写)
读取和写入内存无非就是变量读取和写入 - 不再使用时释放内存
使用完毕后,释放已分配的内存
所有语言的内存生命周期第二步是明确的。第一和第三步在底层语言中是明确的,单是在像JavaScript
这些高级语言中,大部分都是隐含的
1.3 JavaScript的内存分配
JavaScript 是一种脚本语言,它的内存分配是通过 JavaScript 引擎来管理的。在JavaScript中内存分配是通过垃圾回收机制来实现的。
内存分配是指在创建变量、函数或者其它任何内容的时候,JS引擎会自动为我们分配内存,并且在不需要的时候释放内存。
JavaScript 使开发人员无需处理内存分配的工作,JavaScript
自己完成这个工作,同时声明值。
另外,需要注意的是:
- 原始值都是不可变的,所以修改的时候实际上是创建了一个新的值
- 内存地址是尽量分配在一起的,但是不是必须、肯定在一块的
js
// 为对象分配堆内存
const person = {
name: 'John',
age: 24,
};
// 数组也是对象,所以分配的也是堆内存
const hobbies = ['hiking', 'reading'];
let name = 'John'; // 为字符串分配栈内存
const age = 24; // 为数字分配栈内存
name = 'John Doe'; // 为字符串分配新的栈内存
const firstName = name.slice(0,4); // 为字符串分配新的栈内存
内存分配的主要方法有两种:堆内存分配和栈内存分配
1.3.1 堆内存分配(Heap Allocation)
堆内存是 JavaScript用来存储对象 和函数 的区域。JavaScript 引擎不会为这些对象分配一个固定大小的内存,将根据具体的需要来分配对应的内存空间。这种内存分配的方式又叫动态内存分配
堆内存分配的
- 优点:可以 Dynamically 分配内存,不需要事先知道内存的大小。
- 缺点:是内存的分配和释放需要额外的 overhead(额外消耗),可能会导致性能问题。
1.3.2 栈内存分配(Stack Allocation)
栈内存是 JavaScript 用来存放静态数据的一种数据结构。静态数据指的是 JavaScript 引擎在编译时期就能确定其大小的数据。在 JavaScript 中,它包括原始的值(strings, numbers, booleans, undefined, symbol, 和 null)和指向对象和函数引用。
由于引擎知道了数据的大小不会再改变了,那么在分配内存的时候,就会给它分配一个 固定大小 的空间。
在程序执行前分配内存的过程,就叫做 静态内存分配。
因为引擎为这些值分配的是固定大小的内存,所以这些值的大小肯定是有个上限的,而这个上限取决于具体的浏览器。
栈内存分配的:
- 优点:速度快,不需要额外的 Overhead(额外消耗)。
- 缺点:需要事先知道函数的大小。
与C/C++不同,JavaScript中并没有严格意义上区分栈内存与堆内存。因此我们可以简单粗暴的理解为JavaScript的所有数据都保存在堆内存中。但是在某些场景,我们仍然需要基于堆栈数据结构的思维来实现一些功能,比如JavaScript的执行上下文。执行上下文的执行顺序借用了栈数据结构的存取方式。因此理解栈数据结构的原理与特点十分重要。
1.4 JavaScript的使用内存
在JavaScript中使用分配的内存基本上意味着在其中读取和写入。
这可以通过读取或写入变量或对象属性的值,甚至将参数传递给函数来完成。
1.5 JavaScript的释放内存
JavaScript 语言是高级语言,已经在解释器中嵌入了"垃圾回收器",它的主要工作是跟踪内存的分配和使用,,以便当分配的内存不再使用时,自动释放它。
不幸的是,这个过程只是进行粗略估计,因为知道是否需要一些一块内存的一般问题是不可判定 (不能通过算法来解决)。
大多数垃圾收集器通过收集不再被访问的内存来工作,例如,指向它的所有变量都超出了作用域。但是,这是可以收集的内存空间集合的一个不足估计值,因为在内存位置的任何一点上,仍然可能有一个变量在作用域中指向它,但是它将永远不会被再次访问。
二、垃圾回收机制
如上文所述自动寻找是否一些内存"不再需要"的问题是无法判定的。因此,垃圾回收实现只能有限制的解决一般问题。本节将解释必要的概念,了解主要的垃圾回收算法和它们的局限性。
另外,我们还需要知道:JavaScript的V8引擎限制了内存的使用,因此不同操作系统的内存大小会不一样。
还需要注意的是,垃圾回收,不是立即被回收,而是等着被回收
V8引擎最初设计是作为浏览器的引擎,并未考虑占据过多的空间,随着web2技术工程化的发展,占据了越来越多的内存空间。又由于被V8的回收机制所限制,这样就引起可JS执行的线程被挂起,会影响当前执行的页面应用性能
2.1 什么是垃圾回收
什么是垃圾回收呢?顾名思义,主要就是两点:垃圾和回收
然后基于这两点有个 what/how/when,基本就把事儿讲明白了。可以问问自己:
- 垃圾
- 什么是垃圾?垃圾其实都是指已经没用的内存区域
- 如何找打垃圾 ?流的两类垃圾回收算法有两种,分别是追踪式垃圾回收算法 和引用计数法
- 何时回收垃圾?垃圾收集器会定期(周期性)找出那些不再继续使用的变量
- 回收
- 什么是回收? 回收就是指让这些区域可以被新的有用数据覆盖
- 怎么回收? 基本就是清扫(Sweep) 和整理(Compact) 这两种策略
- 何时回收? 找完了就清理,惰性清理(增量标记完成后)
2.2 内存引用
垃圾收集算法主要依赖的是引用。
在内存管理上下文中,如果对象具有对另一个对象的访问权(隐式/显示的),则称对象引用另一个对象。
在这种情况下,"对象"的概念不仅特种 JavaScript 对象,还包括函数作用域(或者全局词法作用域)
作用域和执行上下文可以看看我下面两篇文章:
【JavaScript】【作用域】词法作用域和动态作用域 - 掘金 (juejin.cn)
【JavaScript】【作用域】执行上下文和执行栈 - 掘金 (juejin.cn)
2.3 垃圾回收算法
主流的两类垃圾回收算法:
- 追踪式垃圾回收算法
- 引用计数法(Reference counting)
2.3.1 引用计数(Reference counting)垃圾收集算法
这是最初级的垃圾收集算法。此算法把"对象是否不再需要"简化定义为"对象有没有其他对象引用它"。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收
MDN的栗子:
js
var o = {
a: {
b: 2,
},
};
// 两个对象被创建,一个作为另一个的属性被引用,另一个被分配给变量 o
// 很显然,没有一个可以被垃圾收集
var o2 = o; // o2 变量是第二个对"这个对象"的引用
o = 1; // 现在,"这个对象"只有一个 o2 变量的引用了,"这个对象"的原始引用 o 已经没有
var oa = o2.a; // 引用"这个对象"的 a 属性
// 现在,"这个对象"有两个引用了,一个是 o2,一个是 oa
o2 = "yo"; // 虽然最初的对象现在已经是零引用了,可以被垃圾回收了
// 但是它的属性 a 的对象还在被 oa 引用,所以还不能回收
oa = null; // a 属性的那个对象现在也是零引用了
// 它可以被垃圾回收了
- 限制(缺点): 无法处理循环引用的事例
举个栗子:
js
function f() {
var o = {};
var o2 = {};
o.a = o2; // o 引用 o2
o2.a = o; // o2 引用 o
return "azerty";
}
f();
o 和 o2 这两个对象在被创建,并互相引用,形成了一个循环。它们被调用之后会离开函数作用域,所以它们已经没有用了,可以被回收了。然而,引用技术算法考虑到它们互相都有至少一次引用,所以它们不会被回收。
我们可以打印 o 和 o2的结果看看:可以发现 o 和 o2 两者之间就是无限的循环引用
2.3.2 标记-清除(Mark-and-sweep)算法
这个算法把"对象是否不再需要"简化定义为"对象是否可以再访问"。
该算法大致过程如下:
- 垃圾收集器设定了一个叫做根(root)的对象。在JavaScript中,根就是全局对象
window
,在NodeJs中根就是global
对象 - 垃圾收集器定期从根开始,算法检查所有根及其所有子节点,并将它们标记为活动的(表示它们还是在引用)。任何根不能到达的地方都将被标记为垃圾
- 最后,垃圾收集器释放所有未标记为活动的内存块,并将该内存返回给操作系统
运行中标记和清除算法的可视化动图
该算法是对先前算法的一种改进,因为"有零引用的对象"总是不可访问的,但是相反却不一定,正如我们在循环中看到的那样。
缺点:
- 收集过程中必须暂停整个系统。不允许更改工作集 => 可能导致程序定期(通常是不可预测的)"冻结",从而使某些实时和时间紧迫的应用程序变得不可能
- 必须检查整个工作内存,其中大部分都要检查两次 => 可能导致分页内存出现问题
截止2012年,所有现代浏览器都有标记-清除垃圾收集器。并且过去几年在JavaScript垃圾收集(分代/增量/并发/并行垃圾收集)领域所做的所有改进都是对标记-清除算法的改进,而不是对垃圾收集算法本身的改进,也没有简化"一个对象不再需要"
三、堆栈溢出和内存泄漏
3.1 堆栈溢出(Stack Overflow)
3.1.1 什么是堆栈溢出
堆栈溢出是一种内存错误 ,指的是程序试图往已经满的堆栈中添加数据,导致数据覆盖了其他内存区域或者程序崩溃的情况。
3.1.2 堆栈溢出的原因
主要原因如下:
-
程序中递归深度过渡
举个例子:
jsfunction isEven (num) { if (num === 0) { return true; } if (num === 1) { return false; } return isEven(Math.abs(num) - 2); } console.log(isEven(10)); // true console.log(isEven(1000000)); // Outputs: Uncaught RangeError: Maximum call stack size exceeded
溢出原因:每次执行代码时,都会分配一定尺寸的站空间(windows系统中为1M)。每次方法调用时都会在栈里储存一定信息(如参数、局部变量、返回值等等)。这些信息再少也会占用一定空间,成千上万个此类空间累计起来,自然就超过线程的栈空间
-
使用了过多的本地变量
3.1.3 堆栈溢出的解决方案
可以采取的解决办法如下:
- 优化算法和数据结构:通过减少递归深度、缩小计算规模等方式来降低函数调用时的堆栈空间消耗(使用闭包等方案)
- 增加堆栈大小:在编译器或者操作系统级别增加堆栈空间大小
- 使用动态内存分配代替本地变量:将本地变量改为指向动态分配的内存块的指针,从而减少对堆栈空间的需求
- 审查代码,减少不必要的函数调用等方式来避免
- ....
3.2 内存泄漏
3.2.1 什么是内存泄漏
内存泄漏是指申请的内存,在执行完后没有及时的清理或者销毁,占用空闲内存。内存泄漏过多的话,就会导致后面的程序申请不到内存,因此内存泄漏会导致内部内存溢出
3.2.1 出现内存泄漏的场景
-
全局变量过多
在JavaScript中,全局变量会一直存于内存中,直到页面关闭。如果代码中存在大量未使用的全局变量,就会导致内存泄漏。因此,应该尽量避免声明不必要的全局变量
-
闭包引用
闭包是指函数能访问其词法作用域外部的变量。如果在闭包引用了外部的对象而这个闭包又没有被正确释放,就会导致内存泄漏。
因为闭包会持有对外部变量的引用,导致这些变量无法被垃圾回收器回收
-
没有被清除的计时器
因为定时器有对回调函数的引用,如果定时器不被清除,回调函数中引用的对象也不会被释放
-
事件监听未移除
当在DOM元素上注册了事件监听(如click事件),但在元素被移除之前没有显式地移除事件监听器,就会导致内存泄漏。
因为事件监听器会持有对回调函数的引用,如果监听器不被移除,回调函数中引用的对象也不会被释放
-
循环引用
当两个或多个对象相互引用,并且它们之间没有正确的接触引用时,就会导致循环引用。
因为在这种情况下,垃圾回收器无法判断哪些对象可以释放,从而导致内存泄漏
-
为正确释放资源
JavaScript中还存在其他类型的资源泄漏,如未关闭的网络连接、未释放的DOM元素等。这些资源的泄漏同样会导致内存泄漏
3.2.3 内存泄漏会引发的问题
- 轻则影响应用性能,表现为迟缓卡顿
- 重则导致页面崩溃,表现为页面无法正常使用
3.3 如何定位内存泄漏
-
借用辅助工具。DevTools
内存泄漏定位和分析一般需要辅助工具,比如 Chrome DevTools。开发者可以通过 DevTools 记录页面活动概况,生成可视化分析结果,从时间轴中直观了解内存泄漏情况;利用 DevTools 获取若干次内存快照,检查内存堆栈变化;以及使用 Chrome 任务管理器,实时监控内存的使用情况。
DevTools会将这段时间内的页面行为活动进行记录和分析
-
使用 Chrome DevTools 定位内存泄漏
-
使用Performance面板,大致流程如下:
- 打开准备分析的页面和DevTools的Performance面板
- 勾选 memory 并开始录制
- 在模拟用户操作一段时间后结束录制
- 通过生成的结果可以直观查看到内存时间线,了解内存随时间的占用变化,如果内存占用曲线呈阶梯状一直上升,则可能存在内存泄漏。
- 按需选取时间线中的区域片段,检查对应时间段内的活动类型和时间占用,作为排查和定位内存泄漏的辅助办法。
-
使用Memory大致流程如下:
打开准备分析的页面和DevTools的Memory面板,按需生成快照。每个快照的内容是快照时刻,进行一次垃圾回收后,应用中所有可达的对象
当开发者明确知道与内存泄漏关联的用户交互步骤时,可以生成多次内存快照进行对比,排查出泄漏的对象:在做用户交互操作之前,进行一次正常内存堆栈信息的快照;在做用户交互操作中或操作结束时,进行内存快照。使用 Comparison 视图或使用 filter 按需查看快照之间的差异。
-
-
使用NodeJS中的内存泄漏定位
如果需要定位 Node.js 中的内存泄漏,启动 Node.js 时带上 --inspect 参数,以便利用 Chrome DevTools 工具生成 Memory 快照数据。如图所示,启动 Node.js 服务后,打开 Chrome DevTools,会有 Node 标识,点击可以打开 Node 专用 DevTools。
除此之外,也可以借助第三方包 heapdump 生成快照文件,导入至 Chrome DevTools 中的 Memory 进行快照对比。
启动 Node.js 时带上 --expose-gc 参数以便调用
global.gc()
方法触发垃圾回收。借助process.memoryUsage().heapUsed
检查内存大小,作为内存泄漏的辅助判断。jsconst heapdump = require("heapdump"); const capture = function () { global.gc(); heapdump.writeSnapshot("./HZFE_HEAPSNAPSHOT/" + Date.now() + ".heapsnapshot"); console.log("heapUsed:", process.memoryUsage().heapUsed); }; capture(); /* 可能有内存泄漏的代码片段 start */ // code /* 可能有内存泄漏的代码片段 end */ capture();
-
-
代码自查。适合代码量较小的时候,并且可以基于以下基本原则:
-
是否滥用全局变量,没有手动回收
-
是否没有正确销毁定时器、闭包
-
是否没有正确监听事件和销毁事件
-