副标题:内存管理 —— 闭包导致的内存泄漏
背景
闭包是 JavaScript 中最重要的特性之一,但也极其容易出现内存泄漏的情况。就这个情况,我打算,通过一个例子来说明闭包的复杂性和隐匿性(不容易被发现),来加深对闭包的理解,从而尽早地发现内存泄漏。
例子
- 首先下面的代码只是例子,无需纠结为什么要这样写,为什么要那样写。
- 其次这是最精简的代码,实际项目中会有各种干扰,难度系数更高。
对于下面的代码,挑战两个问题:
- 页面打开后,点击很多次,是否会内存泄漏?
- 如果有内存泄漏,那么是哪部分代码导致的?
class TestData {
static count = 0;
constructor() {
this.dataList = new Array(5000).fill(0).map(() => Math.random().toString());
this.count = TestData.count;
}
}
let globalData = null;
document.onclick = function handleClick() {
const originGlobalData = globalData;
function unused() {
if (!originGlobalData) return;
// 做一些事情......
}
TestData.count += 1; // 点击次数
globalData = {
data: new TestData(),
someMethod: function() {
// 做一些事情
// 但是没有使用到 originThing 常量
}
}
}
上面的问题,如果都答对了,那么请离开,不要浪费时间看此文章;如果不确定,那么说明对闭包的理解可能还需要加强:
点击看答案
答案:1)有内存泄漏;2)下面代码共同导致的内存泄漏: const originGlobalData = globalData;
function unused() {
if (!originGlobalData) return;
// 做一些事情......
}
someMethod: function() {
// 做一些事情
// 但是没有使用到 originThing 常量
}
接下来,我们将:
- 先了解一波内存泄漏排查的方法
- 再讲解上面的代码为什么会导致内存泄漏/为什么不会内存泄漏,怎样做才会内存泄漏。
排查方法
queryObjects
作用:传递构造函数,返回其构造函数生成的实例对象,未被 GC 回收掉的实例列表。此 API,仅在 DevTools 控制台上下文中可使用。
class TestData {
constructor() {
this.name = 'GWJ';
this.dataList = new Array(600).fill(90);
}
}
let mockData = new TestData();
// 在控制台中运行
queryObjects(TestData);
说明 TestData 这个构造函数,有 1 个实例对象没有被 GC 回收。
此时要将控制台的打印全部清空[^注释1],然后在控制台输入 mockData = null;
此时 TestData 唯一的实例对象被 GC 回收。再 queryObjects(TestData); 一遍试试:
DevTools 性能监视器工具
以如下代码为例子,打开 DevTools 中的性能监视器
选项卡,来观察 JS 生成的内存大小:
class TestData {
constructor() {
this.name = Math.random().toString();
this.dataList = new Array(5000).fill(0).map(() => Math.random().toString());
}
}
let globalDataList = [];
document.onclick = () => {
const mockData = new TestData();
globalDataList.push(mockData);
};
多次点击,可以看到 JS 堆内存呈明显的上升趋势,而且不会下降;稍后下降时是对 globalDataList
进行了释放。
DevTools 性能工具
此工具,更为细致、也更为强大,本文不做重点讲解,后期会进行讲解。此工具可以查看 JS 堆内存暴增的时间段,然后查看这段时间段内,渲染器主线程的都干了什么,从而发现是那些函数导致的,最后再进入此函数内部断点调试。
console 是个坑
当一个引用对象以非字符串的方式,输出到控制台,那么就意味着控制台会引用着该对象,直到控制台清空或者控制台关闭为止,GC才会回收这部分内存。下面来举一些例子:
-
console.log、console.error、console.dir ..... console 对象下的大部分打印方法都有这个现象。比如:
class TestData { constructor() { this.name = 'GWJ'; this.dataList = new Array(600).fill(90); } } let mockData = new TestData(); console.log(mockData); mockData = null; // mockData 输出到了控制台,先不要清空控制台的输出 // queryObjects(TestData) 返回能实例对象。
-
上面的 queryObjects() 也有类似的现象,因为将实例对象直接输出到了控制台,控制台会对该对象保持引用。比如:
class TestData { constructor() { this.name = 'GWJ'; this.dataList = new Array(600).fill(90); } } let mockData = new TestData(); // 在控制台中运行 queryObjects(TestData); // 第一步:queryObjects 将会输出 TestData 到控制台 // 第二步:不清空控制台,执行代码:mockData = null; // 第三步:再执行 queryObjects(TestData); // 仍然能打印出 TestData 的实例对象,并没有被 GC 回收。
-
只有对象以字符串序列化的方式输出到控制台,控制台才不会保持引用。比如:
class TestData { constructor() { this.name = 'GWJ'; this.dataList = new Array(600).fill(90); } } let mockData = new TestData(); console.log(JSON.stringify(mockData)); mockData = null; // mockData 输出到了控制台,但是是字符串格式的,也不用清空控制台。 // queryObjects(TestData) 返回空
讲解`例子`
先说结论:下面代码,点击 N 多次,会造成很严重的内存泄漏。
class TestData {
static count = 0;
constructor() {
this.dataList = new Array(5000).fill(0).map(() => Math.random().toString());
this.count = TestData.count;
}
}
let globalData = null;
document.onclick = function handleClick() {
const originGlobalData = globalData;
function unused() {
if (!originGlobalData) return;
// 做一些事情......
}
TestData.count += 1; // 点击次数
globalData = {
data: new TestData(),
someMethod: function() {
// 做一些事情
// 但是没有使用到 originThing 常量
}
}
}
一起捋一下
表面上看,触发 N 次页面 click 事件,虽然多次对globalData 全局变量赋值,但最后只有一个被保留。实际上,复杂就复杂在这里,比如我第二次点击后,那么会覆盖第一次的 globalData 数据,..... 以此类推,最后一次的 globalData 覆盖上一次的 globalData,那按照一般思路,上一次的 globalData 应该会被 GC 回收掉,但实际情况是上一次的 globalData 仍然不会被GC 回收,这就是内存泄漏。
那么我们就动手看看,到底是什么导致的内存泄漏: 应用刚才讲过的排查方法来找出,是否有内存泄漏。
- 打开「性能监视器工具」。发现点击页面的过程中,JS堆内存一直在增长,说明有新分配的内存。但是一直是增长,说明上一次的事件处理函数中,分配的内存没有被GC回收,疑点出现了 !
- 定位到了 handleClick 函数中,而且也知道是 new TestData() 的实例没有被销毁,那么我们通过 queryObjects 看看,到底有没有被 GC 回收。
结果非常的出乎意料!我不禁再次自言自语起来:“上一次产生的实例,明明被本次产生的实例覆盖,为什么还没被 GC 回收?”
先抑制一下心中的好奇(说给我自己听的,哈哈哈)后面再详细的说明。把代码简化一下,简化成:handleClick 事件处理函数中,对一个全局变量赋值;除此之外,不包含其他干扰代码。
不包含干扰的代码:于是我又点击了 N 次。「性能监视器」增长一段,然后又下降;「queryObjects(TestData)」只有一个实例对象。
class TestData {
static count = 0;
constructor() {
this.dataList = new Array(5000).fill(0).map(() => Math.random().toString());
this.count = TestData.count;
}
}
let globalData = null;
document.onclick = function handleClick() {
TestData.count += 1; // 点击次数
globalData = {
data: new TestData(),
}
}
原来是你
我们似乎是知道了哪些代码导致了上一次生成的实例无法被 GC 回收。下图红框所选的代码,就可能导致内存泄漏。
闭包到底是什么?
在 JavaScript 中,闭包指的是一个函数与其它变量之间的组合。具体来说,一个闭包就是一个函数以及其创建时所能访问到的所有变量的集合。闭包使得函数可以“记住”它被创建时的环境,即使在函数在其被定义的作用域之外执行时,这些变量仍然可以被访问。 想了解更多,请查看`参考`部分
以下仅仅是我个人的理解:闭包(Closure)是特殊的对象,用来存储子代函数访问父函数的变量。这样说可能有些抽象。我将分几个例子,循序渐进地深入理解闭包:
简单
function father() {
const data = { name: 'GWJ' };
const uname = 'GWJ';
return function son() {
console.log('data: ', data);
};
}
const resFn = father();
在控制台,打印一下 resFn 函数,可以看到,resFn 函数对象有个特殊的 [[Scopes]] 属性:
[[Scopes]] 此属性只有在控制台可以看到,代码中是无法访问到的。[[Scopes]] 是函数的作用域链列表,在函数声明时被创建。
接下来,结合下图我们详细看看[[Scopes]]属性,都包含了什么?我们发现,该属性值是一个数组;数组的第一个元素是一个 名为闭包(Closure)的对象,对象内部拥有 father 函数执行时创建的 data 变量和对应的值。
-
当函数执行时,例如 father 函数,其内部的局部变量将会被创建,比如 data 变量,而这些变量称之为
AO活跃对象
,这个对象很重要,与闭包(Closure)对象关联很大。 -
当 father 函数执行,son 函数被创建。前面说过函数创建时,会生成[[Scopes]]属性,该属性是个数组,会把当前函数的父函数的 [[Scopes]] 元素、以及父函数执行生成的 AO对象,依次压入自身的[[Scopes]]数组中。
在这个例子中就是 father 函数执行,son 函数被创建,那么 father.[[Scopes]]、father AO对象,将依次被压入 son.[[Scopes]] 数组中。
-
全局函数创建时,比如会被压入 Global:全局对象,即window; 全局 Script:全局let、const
函数在查找变量时,其实就是沿着 AO 和 [[Scopes]]查找。father 函数执行时,AO创建完毕后,JavaScript v8 引擎会深度扫描 father 函数的所有子函数,看是否对 father 函数创建的 AO 对象成员有所访问(father 函数的局部变量),若有则从 AO 中把该变量标记一下,表示 father 函数执行完后,不销毁此变量。
随后, son 函数被返回赋值给一个全局变量resFn
,至此 son.[[Scopes]] = [ fatherAO 标记, Script, Global ],又因为全局变量 resFn === son,那么 son.[[Scopres]] 属性即被引用着,会阻止 GC 回收 fatherAO标记。于是 father 函数中的成员变量 data 所指向的对象被保留。fatherAO 标记,被称之为闭包(Closure)。
这点是不是动态语言的强大性?
进阶
把上一个例子稍微改一下:
function father() {
const data = { name: 'GWJ' };
const uname = 'GWJ';
// 注意这段,是搅屎棍
(function () {
data, uname;
console.log('hhh');
})();
return function son() {
console.log('啥也不引用');
};
}
const resFn = father();
看下图,我们返回的 son 函数,并没有访问 data、uname 这些局部变量。但最终这两个局部变量还是被压入 son.[[Scopes]] 数组中。
不用说,都知道是立即执行函数的原因,因为我们说过:
father 函数执行,JavaScript v8 引擎会深度扫描 father 函数的所有子函数,是否对 father 函数创建的 AO 对象成员有所访问(father 函数的局部变量),若有则从 AO 把该变量标记一下,表示 father 函数执行完后,不销毁此变量。
那么,data、name 两个 father 的AO成员,将被标记,之后变为闭包(Closure)对象。
讲解`例子`
有了以上的铺垫,想要弄清楚文章开头的例子,是很容易的。
class TestData {
static count = 0;
constructor() {
this.dataList = new Array(5000).fill(0).map(() => Math.random().toString());
this.count = TestData.count;
}
}
let globalData = null;
document.onclick = function handleClick() {
const originGlobalData = globalData;
function unused() {
if (!originGlobalData) return;
// 做一些事情......
}
TestData.count += 1; // 点击次数
globalData = {
data: new TestData(),
someMethod: function someMethod() {
// 做一些事情
// 但是没有使用到 originThing
}
};
}
下面将以用户的操作视角进行剖析:
1、第一次点击,handleClick 函数执行时:
- 创建 AO对象(只有 originGlobalData 一个成员),此时 AO.originGlobalData = null
- 接下来 chrome v8 引擎深度扫描,发现 unused 函数访问了 originGlobalData 局部变量,开始标记。
- 最后创建 someMethod 函数,其Scopes 属性的变化
someMethod.[[Scopes]].push( handleClick[[Scopes]], handleClick.AO标记, ) // 即: someMethod.[[Scopes]].push( handleClick[[Scopes]], { originGlobalData: null } )
- 最后,globalData.someMethod[[Scopes]] 中有闭包对象,即 { originGlobalData: null }
2、第二次点击,handleClick 函数执行中:
- 创建 AO对象(只有 originGlobalData 一个成员),此时 AO.originGlobalData = 第一次 globalData 指向的对象
- 接下来 chrome v8 引擎深度扫描,发现 unused 函数访问了 originGlobalData 局部变量,开始标记。
- 最后创建 someMethod 函数
someMethod.[[Scopes]].push( handleClick[[Scopes]], handleClick.AO标记, ) // 即: someMethod.[[Scopes]].push( handleClick[[Scopes]], { originGlobalData: 第一次 globalData 指向的对象 } )
- 结尾处,globalData.someMethod[[Scopes]] 中有闭包对象,即 { originGlobalData: null }
已经不需要再往下写了,旧的 globalData 指向的对象,之所以不被 GC 回收,是因为在闭包中。
这是一个很经典的例子,让闭包形成了链条。
解决内存泄漏
所以,我们发现,绿色部分简直是搅屎棍,someMethod 函数什么都没引用,却导致产生了闭包,真冤枉啊。
改进(一):
改进(二):
document.onclick = function handleClick() {
{
const originGlobalData = globalData;
if (!originGlobalData) return;
// 做一些事情......
}
// 这段函数立即调用和上面的代码其实是等效的。
// 或
(function() {
var originGlobalData = globalData;
if (!originGlobalData) return;
// 做一些事情......
})();
TestData.count += 1; // 点击次数
globalData = {
data: new TestData(),
someMethod: function() {
// 做一些事情
// 但是没有使用到 originThing 常量
}
}
}
参考
0、闭包的概念
1、闭包引起的内存泄漏的例子
2、词法作用域和作用域链:
3、Chrome v8 内存管理机制
4、DevTools perform 工具的使用
5、queryObjects API
感谢
感谢您的认真阅读!如果您有任何问题、建议或想法,都可以畅所欲言,我将尽快回复您。