JavaScript 工作原理(一)——内存管理与四种常见内存泄漏的处理方法

时间:2022-03-22 20:56:10

几周前,我们新开了一系列文章,旨在深入 JavaScript,探寻其工作原理。我们认为通过了解 JavaScript 的构建方式和其运行规则,我们能写出更好的代码和应用。

第一篇文章重点介绍了引擎、运行时和调用栈的概述。第二篇文章仔细地分析了 Google's V8 JavaScript 引擎的内部部分并且为如何编写更好的 JavaScript 代码提供了一些建议。

这是第三篇文章,我们将会讨论一个由于日常使用的编程语言日益成熟和复杂度提升从而让开发者忽略的话题——内存管理。我们还将提供一些有关如何处理 SessionStack 中 JavaScript 内存泄漏的建议,因为我们需要确保 SessionStack 不会导致内存泄漏,也不会增加我们集成的 Web 应用程序的内存消耗。

概述

像 C 语言,拥有底层原始的内存管理方法,例如:malloc()  free()。这些原始的方法被开发者用来从操作系统中分配内存和释放内存。

然而,JavaScript 当一些东西(objects,strings,etc.)被创建的时候分配内存并且当它们不再被使用的时候“自动”释放它们,这个过程被称为垃圾回收。

释放资源的这种看似“自动”的性质是造成困扰的根源。它给 JavaScript (和其它高级语言)开发者一个错误的印象——他们可以选择不关心内存管理。这是一个很大的错误。

即使是使用高级语言,开发者对内存管理也应该有所了解(至少要有基础的了解)。有时,开发者必须理解自动内存管理会遇到问题(例如:垃圾回收中的错误或者性能问题等),以便能够正确处理它们。(或者是找到适当的解决方法,用最小的代价去解决。)

内存的生命周期

无论你使用那种语言,内存的生命周期基本是都差不多:

JavaScript 工作原理(一)——内存管理与四种常见内存泄漏的处理方法

一下是生命周期中每一步发生了什么的一个概述:

  • Allocate memory —— 操作系统分配内存,允许你的程序使用它。在基础语言中(例如 C ),这是一个开发者自己处理的明确操作。然而,在高级语言中,它已经为你处理了。
  • Use memory —— 现在你就可以使用之前分配好的内存了。当你在代码中使用变量时,   的操作正在发生。
  • Release memory —— 现在该释放你不再需要的内存了,以便它们能够被再次使用。与分配内存的操作一样,这种操作在基础语言中是明确执行的。

要快速了解调用栈和内存堆的概念,你可以阅读我们该主题的第一篇文章。

什么时内存

在直接讨论 JavaScript 中的内存之前,我们先简略地讨论下一般内存是什么以及它的工作原理。

在硬件层面,计算机内存是有大量触发电路组成的。每个触发电路包含了一些晶体管并且能够存储一个位(bit)。单个触发器可通过唯一的标识符寻址,因此我们可以读取并重写它们。因此,在概念上,我们可以将整个计算机内存看作时我们可以读和写的一个由位组成的大数组。

因为作为人类,我们不是很善于在  上进行思考和计算,所以,我们将位组成更大的组,它们可以一起用来表示数字。8位称为1字节(byte)。除了字节之外,还有字(有时是 16 位,有时是 32 位)。

很多东西都存储在内存中:

  1. 所有程序使用的所有变量和其他数据。
  2. 程序的代码,包括操作系统的。

编译器和操作系统共同合作,帮助你管理内存,但我们建议你查看一下引擎盖下的内容。

编译代码时,编译器可以检查原始数据类型,并提前计算出需要多少内存。然后将所需的数量分配给调用堆栈中的程序。这些变量分配的空间称为堆栈空间,因为随着函数被调用,它们的内存被添加到现有存储器的顶部。当它们终止时,它们以 LIFO (last-in,first-out)顺序被移除。例如,考虑以下声明:

int n; // 4 bytes
int x[4]; // array of 4 elements, each 4 bytes
double m; // 8 bytes

编译器立即会看到这段代码需要:
4 + 4x4 + 8 = 28 bytes。

这是现在整数和双精度的工作原理。大约 20 年前,整数通常为 2 字节,双精度为 4 字节。你的代码应该永远不依赖于此时基本数据类型的大小。

编译器将插入与操作系统交互的代码,以便在堆栈中请求要存储的变量所需的字节数。

在上面的示例中,编译器知道每个变量的精确内存地址。事实上,每当我们写入变量 n 时,在系统内部都会被翻译为“内存地址 4127963” 这样的东西。

注意,如果我们这里尝试访问 x[4] ,我们可能会访问到和 m 相关联的数据。这是因为我们访问的元素在数组中并不存在——这 4 个字节比数组中最后一个元素 x[3] 还要远,可能会读取(或重写) m 的位。这肯定会对程序产生难以理解的不良影响。

JavaScript 工作原理(一)——内存管理与四种常见内存泄漏的处理方法

当函数调用其他函数时,每个函数在调用时都会获得自己的堆栈块。它保存所有的局部变量,还有一个程序计数器,可以记住函数在执行中的位置。当函数执行完成时,其内存块可以再次用于其他目的。

动态分配

不幸的是,当我们在编译一个不知道多少内存的变量时,事情就变的不那么容易了。假设我们要做如下的事情:

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

...

// create an array with "n" elements

这样,在编译的时候,编译器不知道数组需要多少内存,因为它依靠用户提供的输入。

因此,它不能为堆栈上的变量分配空间。相反,我们的程序需要在运行时明确地要求操作系统获得适当的空间量。这个内存时从堆空间分配的。静态和动态内存分配的区别如下表所示:

Static allocation Dynamic allocation
编译时内存大小确定 编译时内存大小不确定
编译阶段执行 运行时执行
分配给栈 分配给堆
FILO 没有特定的顺序

为了充分了解动态内存分配的工作原理,我们需要花更多的时间在指针上,这可能与这篇文章的主题有些偏离了。如果你有兴趣了解的话,可以在评论中通知我们,我们可以在未来的文章中详细介绍指针。

JavaScript 中的分配

现在,我们将要解释 JavaScript 中第一步(分配内存)是如何工作的。

JavaScript 解放了开发者处理内存分配的责任——JavaScript 自己在声明 values 时就做了这件事。

var n = 374; // allocates memory for a number
var s = 'sessionstack'; // allocates memory for a string
var o = {
a: 1,
b: null
}; // allocates memory for an object and its contained values
var a = [1, null, 'str']; // (like object) allocates memory for the
// array and its contained values
function f(a) {
return a + 3;
} // allocates a function (which is a callable object)

// function expressions also allocate an object
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 is a new string
// Since strings are immutable,
// JavaScript may decide to not allocate memory,
// but just store the [0, 3] range.
var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2);
// new array with 4 elements being
// the concatenation of a1 and a2 elements

JavaScript 中使用内存

基本上在 JavaScript 中使用内存的意思就是在内存在进行   

这个操作可能是一个变量值的读取或写入,一个对象属性的读取或写入,甚至时向函数中传递参数。

当内存不再需要时释放它

大多数的内存管理问题发生在这个阶段。

这里最困难的任务就是确定内存何时就不再被需要了。它通常需要开发人员确定程序中哪里不再需要这样的内存,并释放它。

高级语言拥有垃圾回收器,它的职责就是追踪内存分配和使用情况,找到不再被使用的内存,然后自动地释放它。

不幸的是,这个过程只能得到一个近视的值,因为内存是否被需要是不可判定的(不能用算法求解)。

大多数垃圾回收器通过判断内存是否能够被再次访问来工作的,例如:指向它的所有变量都超出了作用域。然而,这只能得到一个近似值。因为在任何位置,存储器位置可能仍然具有指向其范围的变量,但是它可能将永远不会被再次访问了。

垃圾回收

由于事实上发现内存“不再被需要”是不可判定的,因此垃圾收集的通常解决方案都存在局限性。本节将介绍理解主要垃圾收集算法及其局限性的必要概念。

内存引用

垃圾回收算法依靠的主要概念就是引用

在内存管理的上下文中,如果前者具有对后者的访问权限(可以是隐式的或者显式的),则一个对象被成为引用另一个对象。例如:JavaScript 对象具有对其原型(隐式引用)及其属性值(显式引用)的引用。

在上下文中,“对象”的定义扩展到比常规 JavaScript 对象更广泛的东西,并且还包含了函数的作用域(或者全局的词法作用域)。

词法作用域定义了如何在嵌套函数中解析变量名称:即使父函数已返回,内部函数也可以包含父函数的范围。

引用计数——垃圾回收

这是最简单的垃圾回收算法。如果一个对象指向它的引用数为 0,那么它就应该被“垃圾回收”了。

看一下下面的代码:

var o1 = {
o2: {
x: 1
}
};
// 2 objects are created.
// 'o2' is referenced by 'o1' object as one of its properties.
// None can be garbage-collected

var o3 = o1; // the 'o3' variable is the second thing that
// has a reference to the object pointed by 'o1'.

o1 = 1; // now, the object that was originally in 'o1' has a
// single reference, embodied by the 'o3' variable

var o4 = o3.o2; // reference to 'o2' property of the object.
// This object has now 2 references: one as
// a property.
// The other as the 'o4' variable

o3 = '374'; // The object that was originally in 'o1' has now zero
// references to it.
// It can be garbage-collected.
// However, what was its 'o2' property is still
// referenced by the 'o4' variable, so it cannot be
// freed.

o4 = null; // what was the 'o2' property of the object originally in
// 'o1' has zero references to it.
// It can be garbage collected.

循环依赖造成的问题

出现循环依赖就会产生限制。在以下的示例中,将创建两个对象并引用彼此,从而创建了一个循环。在函数调用之后,它们离开了作用域,因此它们实际上已经无用了,可以被释放了。然而,引用计数算法认为,由于两个对象中的每一个至少被引用了一次,所以也不能被垃圾回收。

function f() {
var o1 = {};
var o2 = {};
o1.p = o2; // o1 references o2
o2.p = o1; // o2 references o1. This creates a cycle.
}

f();

JavaScript 工作原理(一)——内存管理与四种常见内存泄漏的处理方法

标记扫描算法

为了确定一个对象是否被需要,这个算法会确定对象是否可以访问。

该算法由以下步骤组成:

  1. 垃圾回收器构建“roots”列表。Roots 通常是代码中保留引用的全局变量。在 JavaScript 中,“window” 对象可以作为 root 全局变量示例。
  2. 所有的 roots 被检查并标记为 active(即不是垃圾)。所有的 children 也被递归检查。从 root 能够到达的一切都不被认为是垃圾。
  3. 所有为被标记为 active 的内存可以被认为是垃圾了。收集器限制可以释放这些内存并将其返回到操作系统。

JavaScript 工作原理(一)——内存管理与四种常见内存泄漏的处理方法

这个算法优于前一个,因为“一个对象零引用”会让这个对象不是可达的。反过来就不一定对了,因为存在循环引用。

截至 2012 年,所有现代浏览器都配备了 mark-and-sweep 机制的垃圾回收器。过去几年,JavaScript 垃圾回收(代数、增量、并行、并行垃圾收集)领域的所有改进都是对该算法(mark-and-sweep)的实现进行改进,但并没有对垃圾回收算法本身进行改进,其目标是确定一个对象是否可达。

在本文中,你可以阅读到更多关于垃圾回收的详细信息,当然也包含对其的优化。

循环引用不再是问题

在上面的第一个例子中,在函数调用后,两个对象不再被从全局对象可访问的东西所引用。因此,垃圾回收器将发现它们是不可达的。

JavaScript 工作原理(一)——内存管理与四种常见内存泄漏的处理方法

尽管两个对象还是存在引用,但是他们从 root 出发已经是不可达的了。

反垃圾收集器的直接行为

虽然垃圾收集器很方便,但他们也有自己的一系列决策。其中一个是非确定论。换句话说,GCs 是不可预测的。你不能真正的知道回收是什么时候执行的。这意味着在某些情况下,程序使用的内存要比实际需要的还多。在另外一些情况下,如果程序特别敏感,那么一些短暂的暂停会显得特别明显。虽然不确定性意味着回收执行的时间不能被确定,但是大多数 GCs 的实现是共享模式——在分配内存期间执行回收遍历。如果没有分配执行,大多数 GCs 保持空闲状态。考虑一下情况:

  1. 相当大的一组分配被执行。
  2. 大多数元素(或全部)被标记为不可达(假设我们将指向不再需要的缓存的引用置空)。
  3. 不再进行分配。

在这种情况下,大多数 GC 将不会再运行任何进一步的收集。换句话说,即使有不可达的引用变量可以被收集,但是收集器并没有被声明。这些不是严格的内存泄漏,但仍然会导致使用内存比通常的内存要高。

什么是内存泄漏

实质上,内存泄漏可以被定义为应用程序不再需要的内存,但由于某种原因,内存不会返回到操作系统或可用内存池中。

编程语言支持多种管理内存的方法。然而,某块内存是否被使用实际上是一个不确定的问题。换句话说,只有开发人员可以清楚一块内存是否可以释放到操作系统又或者不该被释放。

某些编程语言提供了一些特性,帮助开发者处理这些事情。另外一些语言期望开发者能够完全自己去明确地控制内存。*有关于手动和自动内存管理的好文章。

四种常见的 JavaScript 内存泄漏

1:Global variables

JavaScript 以有趣的方式处理未声明的变量:对未声明的变量的引用在全局对象内创建一个新变量。在浏览器中,全局对象就是 window。换种说法:

function foo(arg) {
bar = "some text";
}

等价于:

function foo(arg) {
window.bar = "some text";
}

如果 bar 被假定为仅仅在函数 foo 的作用域范围内持有对变量的引用,但是你却忘记了使用 var 来声明它,那么就会创建一个意外的全局变量。

在这个例子中,泄漏一个简单的字符串不会有太大的伤害,但肯定会变得更糟的。

可以通过另一种方式创建意外的全局变量:

function foo() {
this.var1 = "potential accidental global";
}
// Foo called on its own, this points to the global object (window)
// rather than being undefined.
foo();

为了防止这些错误的发生,可以在 JavaScript 文件开头添加 “use strict”,使用严格模式。这样在严格模式下解析 JavaScript 可以防止意外的全局变量。

即使我们讨论了如何预防意外全局变量的产生,但是仍然会有很多代码用显示的方式去使用全局变量。这些全局变量是无法进行垃圾回收的(除非将它们赋值为 null 或重新进行分配)。特别是用来临时存储和处理大量信息的全局变量非常值得关注。如果你必须使用全局变量来存储大量数据,那么,请确保在使用完之后,对其赋值为 null 或者重新分配。

2:被忘记的 Timers 或者 callbacks

在 JavaScript 中使用 setInterval 非常常见。

大多数库都会提供观察者或者其它工具来处理回调函数,在他们自己的实例变为不可达时,会让回调函数也变为不可达的。对于 setInterval,下面这样的代码是非常常见的:

var serverData = loadData();
setInterval(function() {
var renderer = document.getElementById('renderer');
if(renderer) {
renderer.innerHTML = JSON.stringify(serverData);
}
}, 5000); //This will be executed every ~5 seconds.

这个例子阐述着 timers 可能发生的情况:计时器会引用不再需要的节点或数据。

renderer 可能在将来会被移除,使得 interval 内的整个块都不再被需要。但是,interval handler 因为 interval 的存活,所以无法被回收(需要停止 interval,才能回收)。如果 interval handler 无法被回收,则它的依赖也不能被回收。这意味着 serverData——可能存储了大量数据,也不能被回收。在观察者模式下,重要的是在他们不再被需要的时候显式地去删除它们(或者让相关对象变为不可达)。

过去,特别是某些浏览器(IE6)无法管理循环引用。如今,大多数浏览器会在被观察的对象不可达时对 observer handlers 进行回收,即使 listener 没有被显式的移除。但是,明确地删除这些 observers 仍然是一个很好的做法。例如:

var element = document.getElementById('launch-button');
var counter = 0;
function onClick(event) {
counter++;
element.innerHtml = 'text ' + counter;
}
element.addEventListener('click', onClick);
// Do stuff
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// Now when element goes out of scope,
// both element and onClick will be collected even in old browsers // that don't handle cycles well.

如今,现代浏览器(包括 IE 和 Edge)都使用的是现代垃圾回收算法,可以检测这些循环依赖并正确的处理它们。换句话说,让一个节点不可达,可以不必而在调用 removeEventListener。

框架和库,例如 jQuery ,在处理掉节点之前会删除 listeners (使用它们特定的 API)。这些由库的内部进了处理,确保泄漏不会发生。即使是在有问题的浏览器下运行,如。。。。IE6。

3:闭包

JavaScript 开发的一个关键方面就是闭包:一个可以访问外部(封闭)函数变量的内部函数。由于 JavaScript 运行时的实现细节,可以通过以下方式泄漏内存:

var theThing = null;
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing) // a reference to 'originalThing'
console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log("message");
}
};
};
setInterval(replaceThing, 1000);

这个代码片段做了一件事:每次调用 replaceThing 时,theThing 都会获得一个新对象,它包含一个大的数组和一个新的闭包(someMethod)。同时,变量 unused 保留了一个拥有 originalThing 引用的闭包(前一次调用 theThing 赋值给了 originalThing)。已经有点混乱了吗?重要的是,一旦一个作用域被创建为闭包,那么它的父作用域将被共享

在这个例子中,创建闭包 someMethod 的作用域是于 unused 共享的。unused 拥有 originalThing 的引用。尽管 unused 从来都没有使用,但是 someMethod 能够通过 theThing 在 replaceThing 之外的作用域使用(例如全局范围)。并且由于 someMethod 和 unused 共享 闭包范围,unused 的引用将强制保持 originalThing 处于活动状态(两个闭包之间共享整个作用域)。这样防止了垃圾回收。
当这段代码重复执行时,可以观察到内存使用量的稳定增长。当 GC 运行时,也没有变小。实质上,引擎创建了一个闭包的链接列表(root 就是变量 theThing),并且这些闭包的作用域中每一个都有对大数组的间接引用,导致了相当大的内存泄漏,如下图:

JavaScript 工作原理(一)——内存管理与四种常见内存泄漏的处理方法

这个问题由 Meteor 团队发现的,他们有一篇伟大的文章,详细描述了这个问题。

4:DOM 引用

有时候,在数据结构中存储 DOM 结构是有用的。假设要快速更新表中的几行内容。将每行 DOM 的引用存储在字典或数组中可能是有意义的。当这种情况发生时,就会保留同一 DOM 元素的两份引用:一个在 DOM 树种,另一个在字典中。如果将来某个时候你决定要删除这些行,则需要让两个引用都不可达。

var elements = {
button: document.getElementById('button'),
image: document.getElementById('image')
};
function doStuff() {
elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
// The image is a direct child of the body element.
document.body.removeChild(document.getElementById('image'));
// At this point, we still have a reference to #button in the
//global elements object. In other words, the button element is
//still in memory and cannot be collected by the GC.
}

还有一个额外的考虑,当涉及 DOM 树内部或叶子节点的引用时,必须考虑这一点。假设你在 JavaScript 代码中保留了对 table 特定单元格(<td>)的引用。有一天,你决定从 DOM 中删除该 table,但扔保留着对该单元格的引用。直观地来看,可以假设 GC 将收集除了该单元格之外所有的内容。实际上,这不会发生的:该单元格是该 table 的子节点,并且 children 保持着对它们 parents 的引用。也就是说,在 JavaScript 代码中对单元格的引用会导致整个表都保留在内存中的。保留 DOM 元素的引用时,需要仔细考虑。

==============================

原文地址:传送门
作者:Alexander Zlatkov