前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >[译]JS的内存管理及4种常见的内存泄漏

[译]JS的内存管理及4种常见的内存泄漏

作者头像
江米小枣
发布2020-06-15 15:17:48
1.1K0
发布2020-06-15 15:17:48
举报
文章被收录于专栏:云前端

原文:https://blog.sessionstack.com/how-javascript-works-memory-management-how-to-handle-4-common-memory-leaks-3f28b94cfbec

Overview - 概览

在类似C的语言中,存在一些诸如 malloc() 和 free() 的低级操作方法,用来人为的精确分配和释放操作系统内存。

然而JS则是在对象(或字符串等)被创建时自动分配内存,并在其不再被使用时“自动”用垃圾回收机制(gc)释放内存。但这种看起来顺其自然的“自动”释放资源成了混乱之源,并给JS(及其他高级语言)开发者一种错误的印象,那就是他们可以不关心内存管理。这是个大毛病。

为了正确处理(或尽快找到合适的变通方案)时不时由自动内存管理引发的问题(一些bug或者gc的实现局限性等),即便是使用高级语言,开发者也应该理解内存管理(至少是基本的)。

Memory life cycle - 内存生命周期

不管使用什么编程语言,内存生命周期几乎总是相同的:

周期中每一步的基本是这样的:

分配内存—内存被操作系统分配给程序使用。在低级语言(比如C)中,由开发者手动处理;而在高级语言中,开发者是很省心的。

使用内存—使用程序代码中的变量等时,引发了读写操作,从而真正使用了先前分配的内存。

释放内存—当不再需要使用内存时,就是完全释放整个被分配内存空间的时机,内存重新变为可用的。与分配内存一样,该操作只在低级语言中需要手动进行。

可以看这篇帖子快速了解调用栈和内存堆。 https://blog.sessionstack.com/how-does-javascript-actually-work-part-1-b0bacc073cf

What is memory? - 什么是内存?

在直接转入JS内存的话题前,我们主要讨论一下通常内存的含义,并简短说一下它是如何工作的。

在硬件层面,计算机内存由大量触发器组成。每个触发器包含一些晶体管,并用来储存 1 比特位(以下简称位)的数据。不同的触发器由唯一标识符定位以便对其读写。所以从概念上讲,我们可以把整个计算机内存想象成一个可读写的巨大位数组。

作为人类,难以在位层面思考和计算,而是从大的维度上管理数据—将位集合成大一些的组就可以用来表示数字。8位被叫做 1 字节。除了字节,有时还有16位或32位等分组称呼。

内存中存储了很多东西:

. 所有程序使用的变量和其他数据

. 操作系统和程序的所有代码

编译器和操作系统共同管理大部分内存,但最好看一看底层发生了什么。当编译代码时,编译器会检查基本数据类型并提前计算它们需要多少内存。所需的内存数量被以“栈空间”的名义分配给程序,而这种称呼的原因是:当函数被调用时,其内存被置于已存在内存的顶部;当调用结束后,以LIFO(后入先出)的顺序被移除。举例来说,看一下以下声明:

int n; // 4 bytes

int x[4]; // array of 4 elements, each 4 bytes

double m; // 8 bytes

编译器立刻就能算出这部分代码需要的空间

4 + 4 × 4 + 8 = 28 bytes.

这就是当前整数和双精度浮点数的工作方式;而在20年前(16位机器上),典型的整数只用2字节存储,而双精度数用4字节。所以代码不应该依赖于当前基础数据类型的大小。

编译器向栈中申请好一定数量的字节,并把即将和操作系统交互的代码插入其中,以存储变量。

在以上例子中,编译器清楚的制度每个变量所需内存。事实上,每当我们写入变量 n 时,这个变量在内部就被翻译成类似“内存地址4127963”了。

如果试图访问这里的 x[4] , 就会访问关联数据 m。这是因为访问的是数组中一个并不存在的元素—比数组中实际分配的最后一个元素 x[3] 又远了4 个字节,也就有可能结束读写在 m 的某个位上。这几乎可以确定将给后续的程序带来非常不希望发生的后果。

当函数调用其他函数时,每个函数各自有其自己调用的那块栈空间。该空间保存着函数所有本地变量,以及一个用来记住执行位置的程序计数器。当函数结束时,这个内存块再次被置为可用,以供其他用处。

Dynamic allocation - 动态分配

遗憾的是,当我们不知道编译时变量需要多少内存时,事情就没那么简单了。假设我们要做如下的事情:

int n = readInput(); // reads input from the user

...

// create an array with "n" elements

此处,在编译时,编译器并不知道数组需要多少内存,因为这取决于用户的输入。

所以,无法为变量在栈上分配房间了。相应的,程序必须在运行时明确向操作系统申请正确数量的空间。这部分内存从堆空间中指派。关于静态内存和动态内存分配的不同之处总结在下表中:

Differences between statically and dynamically allocated memory

要全面理解动态内存分配如何工作,需要花费更多时间在指针上,可能有点太过背离本篇的主题了。

Allocation in JavaScript - JS中的分配

现在解释一下在JS中的第一步(分配内存)如何工作。与声明变量并赋值的同时,JS自动进行了内存分配—从而在内存分配问题上解放了开发者们。

var n = 374; // 为数字分配内存

var s = 'sessionstack'; // 为字符串分配内存

var o = {

a: 1,

b: null

}; // 为对象和其包含的值分配内存

var a = [

1, null, ‘str'

]; // 为数组和其包含的值分配内存

function f(a) {

return a + 3;

} // 为函数分配内存 (也就是一个可调用的对象)

// 函数表达式也是为对象分配内存

someElement.addEventListener('click', function() {

someElement.style.backgroundColor = 'blue';

}, false);

一些函数调用也按对象分配:

var d = new Date(); // allocates a Date object

var e = document.createElement('div'); // allocates a DOM element

方法会被分配新值或对象:

var s1 = 'sessionstack';

var s2 = s1.substr(0, 3); // s2 是一个新字符串

// 因为字符串是不可变的,

// 所以JS并不分配新的内存,

// 只是存储 [0, 3] 的范围.

var a1 = ['str1', 'str2'];

var a2 = ['str3', 'str4'];

var a3 = a1.concat(a2);

// 由 a1 和 a2 的元素串联成新的 4 个元素的数组

Using memory in JavaScript - 在JS中使用内存

在JS中使用内存,基本上就意味着对其读写。这将发生在读写一个变量、对象属性,或对一个函数传递值等时候。

Release when the memory is not needed anymore - 当不再需要内存时释放它

大部分内存管理问题都发生在这个阶段。

最难办的事就是找出什么时候分配的内存不再有用了。这通常需要开发者决定代码中的哪一块不再需要内存,并释放它。

高级语言包含了垃圾回收器的功能,其职责就是跟踪内存分配和使用,以便找出什么时候相应的内存不再有用,并自动释放它。

遗憾的是,这只是一个粗略估算的过程,因为要知道需要多少内存的问题是不可决定的(无法通过算法解决)。

大部分gc通过收集无法再被访问到的内存来工作,例如所有指向该内存块的变量都离开了其作用域。然而,这只是一组可被收集的内存空间的粗略估计,因为可能存在着某一个变量仍处在其作用域内,但就是永远不再被访问的情况。

Garbage collection - 内存回收器

由于找出某些内存是否“不再被需要”是不可决定的,gc实现了对解决一般问题的一个限制。本章将解释必要的概念,以理解主要的gc算法和其限制。

Memory references - 内存引用

gc算法主要依赖的一个概念就是引用

在内存管理的上下文中,说一个对象引用了另一个的意思,就是指前者直接或间接的访问到了后者。举例来说,一个JavaScript object间接引用了其原型对象,而直接引用了其属性值。

在此上下文中,所谓“对象”的指向就比纯JavaScript object更宽泛了,包括了函数作用域(或全局词法作用域)在内。

词法作用域定义了如何在嵌套的函数中处理变量名称:内部函数包含了父函数的作用域,即便父函数已经return

Reference-counting garbage collection - 引用计数法

这是最简单的一种gc算法。如果一个对象是“零引用”了,就被认为是该回收的。

看下面的代码:

var o1 = {

o2: {

x: 1

}

};

// 创建了 2 个对象

// 'o2' 作为 'o1' 的属性被其引用

// 两者都不能被回收

var o3 = o1;

// 变量 'o3' 引用了 'o1' 指向的对象

o1 = 1;

// 原本被 'o1' 引用的对象只剩下了变量 ‘o3'的引用

var o4 = o3.o2;

// 'o2' 现在有了两个引用

// 作为父对象的属性,以及被变量 ‘o4’ 引用

o3 = '374';

// 原本被 'o1' 引用的对象现在是“零引用”了

// 但由于其 'o2' 属性仍被 'o4' 变量引用,所以不能被释放

o4 = null;

// 原本被 'o1' 引用的对象可以被gc了

Cycles are creating problems - 循环引用带来问题

循环引用会带来问题。在下面的例子中,两个对象被创建并互相引用,这就形成了一个循环引用。当他们都离开了所在函数的作用域后,却因为互相有1次引用,而被引用计数算法认为不能被gc。

function f() {

var o1 = {};

var o2 = {};

o1.p = o2; // o1 references o2

o2.p = o1; // o2 references o1. This creates a cycle.

}

f();

Mark-and-sweep algorithm - 标记清除法

该算法靠判断对象是否可达,来决定对象是否是需要的。

算法由以下步骤组成:

  • 垃圾回收器会创建一个列表,用来保存根元素,通常指的是代码中引用到的全局变量。在JS中,’window’ 对象通常被作为一个根元素。
  • 所有根元素被监视,并被标记为活跃的(也就是不作为垃圾)。所有子元素也被递归的如此处理。从根元素可达的每个元素都不被当成垃圾。
  • 直到一块内存中所有的东西都不是活跃的了,就可以被认为都是垃圾了。回收器可以释放这块内存并将其返还给OS。

标记清除法的运行示意图

这个算法比引用计数法更好的地方在于:“零引用”会导致这个对象不可到达;而相反的情况并不像我们在循环引用中看到的那样无法正确处理。

自从2012年起,所有现代浏览器都包含了一个标记清除法的垃圾回收器,虽然没有改进算法本身或其判断对象是否可达的目标,但过去一年在JS垃圾回收领域关于标记清除法取得的所有进步(分代回收、增量回收、并发回收、并行回收)都包含在其中了。

可以在这篇文章中阅读追踪垃圾回收算法及其优化的更多细节。 https://en.wikipedia.org/wiki/Tracing_garbage_collection

Cycles are not a problem anymore - 循环引用不再是个问题

在上面的第一个例子中,当函数调用结束,两个对象将不再被任何从跟对象可达的东西引用。

因此,它们将被垃圾回收器认定是不可达的。

尽管两个对象相互引用,但根元素无法找到它们。

Counter intuitive behavior of Garbage Collectors - 垃圾回收器中违反直觉的行为

尽管GC很方便,但也带来一些取舍权衡。其中一点是其不可预知性。换句话说,GC是没准儿的,无法真正的说清回收什么时候进行。这意味着有时程序使用了超过其实际需要的内存;另一些情况下,应用可能会假死。

尽管不可预知性意味着无法确定回收的执行时机,但大部分GC的实现都共享了在分配过程中才执行回收的通用模式。如果没有执行分配,大部分GC也会保持空闲。

考虑以下场景:

. 很大一组分配操作被执行。

. 其中的大部分元素(或全部)被标记为不可达(假设我们对不再需要用的一个缓存设为null)。

. 没有后续的分配再被执行

在这个场景下,大部分GC不会再运行回收操作。也就是说,尽管有不可达的引用可被回收,但回收器并不工作。并不算严格的泄漏,但仍然导致内存实用高于正常。

What are memory leaks? - 何为内存泄漏

本质上来说,内存泄漏可以定义为:不再被应用需要的内存,由于某种原因,无法返还给操作系统或空闲内存池。

内存泄漏是不好的...对吧?

编程语言喜欢用不同的方式管理内存。但是,一块内存是否被使用确实是个无解的问题。换句话说,只有开发者能弄清一块内存是否能被返还给操作系统。

某些编程语言提供了帮助开发者达到此目的的特性。其他一些期望当一块内存不被使用时,开发者完全明示。

The four types of common JavaScript leaks - 四种常见的JS内存泄漏

1: Global variables - 全局变量

JS用一种很逗的方式处理未声明的变量:对一个未声明变量的引用将在 global 对象中创建一个新变量;在浏览器中就是在 window 对象中创建。换句话说:

function foo(arg) {

bar = "some text";

}

等价于:

function foo(arg) {

window.bar = "some text";

}

如果 bar 应该是所在 foo 函数作用域中的变量,而你忘了用 var 声明它,那就会创建一个期望外的全局变量。

在这个例子中,泄漏的只是一个无害的简单字符串,但实际情况肯定会更糟糕的。

另一种意外创建全局变量的途径是通过 ‘this’ :

function foo() {

this.var1 = "potential accidental global";

}

foo();

//直接执行了构造函数,this指向了window

JS文件开头添加 'use strict'; 可以防止出现这种错误。这将允许用一种严格模式来处理JS,以防意外创建全局变量。

这里学习更多这种JS执行的模式。https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Strict_mode

尽管我们谈论了未知的全局变量,其实代码中也大量存在明确定义的全局变量。它们被定义为不可回收的(除非赋值为null或重新赋值)。特别是用全局变量暂存数据或处理大量的数据,也是值得注意的—如果非要这么做,记得在使用后对其赋值为 null 或重新指定。

2: Timers or callbacks that are forgotten - 被遗忘的定时器或回调函数

在JS中使用 setInterval 稀松平常。

大多数库,如果提供了观察者之类的功能,都会有回调函数;当这些库工具本身的实例变为不可达后,要注意使其引用的回调函数也应不可达。对于 setInterval 来说,下面这种代码却很常见:

var serverData = loadData();

setInterval(function() {

var renderer = document.getElementById('renderer');

if(renderer) {

renderer.innerHTML = JSON.stringify(serverData);

}

}, 5000); //大约每5秒执行一次

这个例子演示了定时器会发生什么:定时器引用了不再需要的节点或数据。

在未来的某个时刻,由 renderer 代表的对象可能会被移除,使得整个定时处理函数块变为无用的。但因为定时器始终有效,处理函数又不会被回收(需要停止定时器才行)。这也意味着,那个看起来个头也不小的 serverData,同样也不会被回收。

而对于观察者的场景,重要的是移除那些不再有用的明确引用(或相关的对象)。

之前,这对某些无法很好的管理循环引用(见上文)的浏览器(IE6咯)非常关键。当今,即使没有明确删除监听器,大部分浏览器都能在观察对象不可达时回收处理函数;但在对象被去除之前,明确移除这些观察者,始终是个好习惯。

var element = document.getElementById('launch-button');

var counter = 0;

function onClick(event) {

counter++;

element.innerHtml = 'text ' + counter;

}

element.addEventListener('click', onClick);

//做些什么

element.removeEventListener('click', onClick);

element.parentNode.removeChild(element);

//现在,当元素离开作用域

//即便是老旧浏览器,也能正确回收元素和处理函数了

当前,现代浏览器(包括IE和Microsoft Edge)都使用了可以检测这些循环引用并能正确处理之的现代垃圾回收器算法。也可以说,在使得节点不可达之前,不再有必要严格的调用 removeEventListener 了。

诸如 jQuery 等框架和库在去除节点之前做了移除监听工作(当调用其特定API时)。这种库内部的处理同时确保了没有泄露发生,即便是运行在问题频发的浏览器时。。。嗯,说的就是IE6。

3: Closures - 闭包

JS开发中很重要的一方面就是闭包:一个有权访问所包含于的外层函数中变量的内部函数。归因于JS运行时的实现细节,在如下方式中可能导致内存泄漏:

var theThing = null;

var replaceThing = function () {

var originalThing = theThing;

var unused = function () {

if (originalThing) //对originalThing的引用

console.log("hi");

};

theThing = {

longStr: new Array(1000000).join('*'),

someMethod: function () {

console.log("message");

}

};

};

setInterval(replaceThing, 1000);

这段代码做了一件事:每次调用 replaceThing 时,theThing 获得一个包含了一个巨大数组和一个新闭包(someMethod)的新对象。同时,变量unused则指向一个引用了originalThing(其实就是前一次调用 replaceThing 时指定的theThing)的闭包。已经懵了,哈?关键之处在于:一旦同一个父作用域中的闭包们的作用域被创建了,则其作用域是共享的。

在本例中,someMethod和unused共享了作用域;而unused引用了originalThing。尽管unused从来没被调用,但通过theThing,someMethod可能会在replaceThing外层作用域(例如全局的某处)被调用。并且因为someMethod和unused共享了闭包作用域,unused因为有对originalThing的引用,从而迫使其保持活跃状态(被两个闭包共享的整个作用域)。这也就阻止了其被回收。

当这段代码被重复运行时,可以观察到内存占用持续增长,并且在GC运行时不会变小。本质上是,创建了一个闭包的链表(以变量 theThing 为根),其中每个闭包作用域间接引用一个巨大的数组,从而导致一个超出容量的泄漏。

该问题的更多描述见 Meteor 团队的这篇文章。 https://blog.meteor.com/an-interesting-kind-of-javascript-memory-leak-8b47d2e7f156

4: Out of DOM references - 脱离DOM的引用

有时把DOM节点储存在数据结构里是有用的。假设要一次性更新表格的多行内容,那么把每个DOM行的引用保存在一个字典或数组中是合理的;这样做的结果是,同一个DOM元素会在DOM数和JS数据中

各有一个引用。如果未来某个时刻要删除这些行,就得使两种引用都不可达才行。

var elements = {

button: document.getElementById('button'),

image: document.getElementById('image')

};

function doStuff() {

elements.image.src = 'http://example.com/image_name.png';

}

function removeImage() {

// img元素的父元素是body document.body.removeChild(document.getElementById('image'));

// 此时, 全局对象 elements 中仍引用着 #button

// 换句话说,GC无法回收 button 元素

}

另外需要额外考虑的是对一个DOM树的内部节点或叶子节点的引用。比方说JS代码引用了表格中某个单元格(一个td标签);一旦决定从DOM中删除整个表格,却保留了之前对那个单元格的引用的话,是不会想当然的回收除了那个td之外的其他东西的。实际上,因为单元格作为表格的子元素而持有对父元素的引用,所以JS中对单元格的引用导致了整个表格留在内存中。当保留对DOM元素的引用时,要格外注意这点。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2017-09-29,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 云前端 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档