在这第三篇文章中,我们将讨论另一个重要主题——内存管理,这是由于日常使用的编程语言越来越成熟和复杂,开发人员容易忽视这一问题。我们还将提供一些有关如何处理JavaScript中的内存泄漏的技巧,在SessionStack中遵循这些技巧,既能确保SessionStack 不会导致内存泄漏,也不会增加我们集成的Web应用程序的内存消耗。
一、概述
像 C 这样的编程语言,具有低级内存管理原语,如malloc()
和free()
。开发人员使用这些原语显式地对操作系统的内存进行分配和释放。
而JavaScript在创建对象(对象、字符串等)时会为它们分配内存,不再使用对时会“自动”释放内存,这个过程称为垃圾收集。这种看“自动”似释放资源的的特性是造成混乱的根源,因为这给JavaScript(和其他高级语言)开发人员带来一种错觉,以为他们可以不关心内存管理的错误印象,这是想法一个大错误。
即使在使用高级语言时,开发人员也应该了解内存管理(或者至少懂得一些基础知识)。有时候,自动内存管理存在一些问题(例如垃圾收集器中的bug或实现限制等),开发人员必须理解这些问题,以便可以正确地处理它们(或者找到一个适当的解决方案,以最小代价来维护代码)。
二、内存的生命周期
无论使用哪种编程语言,内存的生命周期都是一样的:
这里简单介绍一下内存生命周期中的每一个阶段:
- 分配内存 — 内存是由操作系统分配的,它允许您的程序使用它。在低级语言(例如C语言)中,这是一个开发人员需要自己处理的显式执行的操作。然而,在高级语言中,系统会自动为你分配内在。
- 使用内存 — 这是程序实际使用之前分配的内存,在代码中使用分配的变量时,就会发生读和写操作。
- 释放内存 — 释放所有不再使用的内存,使之成为自由内存,并可以被重利用。与分配内存操作一样,这一操作在低级语言中也是需要显式地执行。
三、内存是什么?
在介绍JavaScript中的内存之前,我们将简要讨论内存是什么以及它是如何工作的。
硬件层面上,计算机内存由大量的触发器组成的。每个触发器包含几个晶体管,能够存储一位,单个触发器都可以通过唯一标识符寻址,因此我们可以读取和覆盖它们。因此,从概念上讲,可以把整个计算机内存看作是一个可以读写的巨大数组。
作为人类,我们并不擅长用比特来思考和计算,所以我们把它们组织成更大的组,这些组一起可以用来表示数字。8位称为1字节。除了字节,还有字(有时是16位,有时是32位)。
很多东西都存储在内存中:
- 程序使用的所有变量和其他数据。
- 程序的代码,包括操作系统的代码。
编译器和操作系统一起为你处理大部分内存管理,但是你还是需要了解一下底层的情况,对内在管理概念会有更深入的了解。
在编译代码时,编译器可以检查基本数据类型,并提前计算它们需要多少内存。然后将所需的大小分配给调用堆栈空间中的程序,分配这些变量的空间称为堆栈空间。因为当调用函数时,它们的内存将被添加到现有内存之上,当它们终止时,它们按照后进先出(LIFO)顺序被移除。例如:
int n; // 4字节
int x [4]; // 4个元素的数组,每个4个字节
double m; // 8个字节
编译器能够立即知道所需的内存:4 + 4×4 + 8 = 28字节。
这段代码展示了整型和双精度浮点型变量所占内存的大小。但是大约20年前,整型变量通常占2个字节,而双精度浮点型变量占4个字节。你的代码不应该依赖于当前基本数据类型的大小。
编译器将插入与操作系统交互的代码,并申请存储变量所需的堆栈字节数。
在上面的例子中,编译器知道每个变量的确切内存地址。事实上,每当我们写入变量 n
时,它就会在内部被转换成类似“内存地址4127963”这样的信息。
注意,如果我们尝试访问 x[4]
,将访问与m关联的数据。这是因为访问数组中一个不存在的元素(它比数组中最后一个实际分配的元素x[3]
多4字节),可能最终读取(或覆盖)一些 m
位。这肯定会对程序的其余部分产生不可预知的结果。
当函数调用其他函数时,每个函数在调用堆栈时获得自己的块。它保存所有的局部变量,但也会有一个程序计数器来记住它在执行过程中的位置。当函数完成时,它的内存块将再次用于其他地方。
四、动态分配
不幸的是,当编译时不知道一个变量需要多少内存时,事情就有点复杂了。假设我们想做如下的操作:
int n = readInput(); // 读取用户输入
...
// 创建一个长度为n的数组
在编译时,编译器不知道数组需要使用多少内存,因为这是由用户提供的值决定的。
因此,它不能为堆栈上的变量分配空间。相反,我们的程序需要在运行时显式地向操作系统请求适当的空间,这个内存是从堆空间分配的。静态内存分配和动态内存分配的区别总结如下表所示:
静态内存分配 | 动态内存分配 |
---|---|
大小必须在编译时知道 | 大小不需要在编译时知道 |
在编译时执行 | 在运行时执行 |
分配给堆栈 | 分配给堆 |
FILO (先进后出) | 没有特定的分配顺序 |
要完全理解动态内存分配是如何工作的,需要在指针上花费更多的时间,这可能与本文的主题有太多的偏离,这里就不太详细介绍指针的相关的知识了。
五、JavaScript中的内存分配
现在将解释第一步:如何在JavaScript中分配内存。
JavaScript为让开发人员免于手动处理内存分配的责任——JavaScript自己进行内存分配同时声明值。
var n = 374; // 为数字分配内存
var s = 'sessionstack'; // 为字符串分配内存
var o = {
a: 1,
b: null
}; // 为对象及其包含的值分配内存
var a = [1, null, 'str']; // (与object类似) 为数组及其包含的值分配内存
function f(a) {
return a + 3;
} // 分配函数(可调用对象)
// 函数表达式也分配一个对象
someElement.addEventListener('click', function() {
someElement.style.backgroundColor = 'blue';
}, false);
某些函数调用也会导致对象的内存分配:
var d = new Date(); // 分配一个日期对象
var e = document.createElement('div'); // 分配DOM对象
方法可以分配新的值或对象:
var s1 = 'sessionstack';
var s2 = s1.substr(0, 3);
// s2是新的字符串,由于字符串是不可变的,JavaScript可能决定不分配内存,而是存储[0, 3]范围
var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2); // 4个元素的新数组,a1和a2元素的连接
六、在JavaScript中使用内存
在JavaScript中使用分配的内存意味着在其中读写,这可以通过读取或写入变量或对象属性的值,或者将参数传递给函数来实现。
七、当内存不再需要时进行释放
大多数的内存管理问题都出现在这个阶段
这里最困难的地方是确定何时不再需要分配的内存,它通常要求开发人员确定程序中哪些地方不再需要内存的并释放它。
高级语言嵌入了一种称为垃圾收集器的机制,它的工作是跟踪内存分配和使用,以便发现任何时候一块不再需要已分配的内在。在这种情况下,它将自动释放这块内存。
不幸的是,这个过程只是进行粗略估计,因为很难知道某块内存是否真的需要 (不能通过算法来解决)。
大多数垃圾收集器通过收集不再被访问的内存来工作,例如,指向它的所有变量都超出了作用域。但是,这是可以收集的内存空间集合的一个不足估计值,因为在内存位置的任何一点上,仍然可能有一个变量在作用域中指向它,但是它将永远不会被再次访问。
八、垃圾收集
由于无法确定某些内存是否真的有用,因此,垃圾收集器想了一个办法来解决这个问题。本节将解释理解主要垃圾收集算法及其局限性。
九、内存引用
垃圾收集算法主要依赖的是引用。
在内存管理上下文中,如果对象具有对另一个对象的访问权(可以是隐式的,也可以是显式的),则称对象引用另一个对象。例如,JavaScript对象具有对其原型(隐式引用)和属性值(显式引用)的引用。
在此上下文中,“对象”的概念被扩展到比常规JavaScript对象更广泛的范围,并且还包含函数范围(或全局词法作用域)。
词法作用域定义了如何在嵌套函数中解析变量名:即使父函数已经返回,内部函数也包含父函数的范围
十、引用计数垃圾收集算法
这是最简单的垃圾收集算法。如果没有指向对象的引用,则认为该对象是“垃圾可回收的”,如下代码:
var o1 = {
o2: {
x: 1
}
};// 创建2个对象,“o2”被“o1”对象引用作为其属性之一,没有垃圾可以收集
var o3 = o1; // o3变量是引用由“o1”指向对象的变量
o1 = 1; // 现在,由于o1为1,所以最初“o1”中的对象由“o3”变量表示
var o4 = o3.o2; // 引用对象的o2属性,这个对象(o2)现在有两个引用,一个作为属性,另一个是o4变量
o3 = '374'; // 最初在o1中的对象现在没有对它的引用,它可以被垃圾收集
// 但是,它的o2属性仍然被o4变量引用,所以它不能被释放
o4 = null; // 最初在o1对象的属性o2属性,没有对它的引用,可以被垃圾回收
十一、循环会产生问题
当涉及到循环时,会有一个限制。在下面的示例中,创建了两个对象,两个对象互相引用,从而创建了一个循环。在函数调用之后,它们将超出作用域,因此它们实际上是无用的,可以被释放。然而,引用计数算法认为,由于每个对象至少被引用一次,所以它们都不能被垃圾收集。
function f() {
var o1 = {};
var o2 = {};
o1.p = o2; // o1 引用 o2
o2.p = o1; // o2 引用 o1,形成循环
}
f();
十二、标记-清除(Mark-and-sweep)算法
该算法能够判断出某个对象是否可以访问,从而知道该对象是否有用,该算法由以下步骤组成:
- 垃圾收集器构建一个“根”列表,用于保存引用的全局变量。在JavaScript中,“window”对象是一个可作为根节点的全局变量。
- 然后,算法检查所有根及其子节点,并将它们标记为活动的(这意味着它们不是垃圾)。任何根不能到达的地方都将被标记为垃圾。
- 最后,垃圾收集器释放所有未标记为活动的内存块,并将该内存返回给操作系统。
这个算法比上一个算法要好,因为“一个对象没有被引用”就意味着这个对象无法访问。
截至2012年,所有现代浏览器都有标记-清除垃圾收集器。过去几年在JavaScript垃圾收集(分代/增量/并发/并行垃圾收集)领域所做的所有改进都是对该算法(标记-清除)的实现改进,而不是对垃圾收集算法本身的改进,也不是它决定对象是否可访问的目标。
在这篇文章中,你可以更详细地阅读到有关跟踪垃圾收集的详细信息,同时还包括了标记-清除算法及其优化。
十三、循环不再是问题
在上面的第一个例子中,在函数调用返回后,这两个对象不再被从全局对象中可访问的对象引用。因此,垃圾收集器将发现它们不可访问。
尽管对象之间存在引用,但它们对于根节点来说是不可达的。
十四、垃圾收集器的反直观行为
尽管垃圾收集器很方便,但它们有一套自己的折衷方案,其中之一就是非决定论,换句话说,垃圾收集器是不可预测的,你无法真正判断何时进行垃圾收集。这意味着在某些情况下,程序使用的内存比实际需要的内存更多。在对速度特别敏感的应用程序中,可能会很明显的感受到短时间的停顿。如果没有分配内存,则大多数垃圾收集器将处于空闲状态。看看以下场景:
- 执行一组相当大的分配。
- 这些元素中的大多数(或全部)被标记为不可访问(假设引用指向一个不再需要的缓存)。
- 不再进一步的分配
在这些场景中,大多数垃圾收集器将不再继续收集。换句话说,即使有不可访问的引用可供收集,收集器也不会声明这些引用。这些并不是严格意义上的泄漏,但仍然会导致比通常更高的内存使用。
十五、内存泄漏是什么?
从本质上说,内存泄漏可以定义为:不再被应用程序所需要的内存,出于某种原因,它不会返回到操作系统或空闲内存池中。
编程语言支持不同的内存管理方式。然而,是否使用某一块内存实际上是一个无法确定的问题。换句话说,只有开发人员才能明确一块内存是否可以返回到操作系统。
某些编程语言为开发人员提供了帮助,另一些则期望开发人员能清楚地了解内存何时不再被使用。维基百科上有一些有关人工和自动内存管理的很不错的文章。
十六、四种常见的内存泄漏
1.全局变量
JavaScript以一种有趣的方式处理未声明的变量: 对于未声明的变量,会在全局范围中创建一个新的变量来对其进行引用。在浏览器中,全局对象是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自己调用,它指向全局对象(window),而不是未定义。
foo();
可以在JavaScript文件的开头通过添加“use strict”来避免这一切,它将开启一个更严格的JavaScript解析模式,以防止意外创建全局变量。
尽管我们讨论的是未知的全局变量,但仍然有很多代码充斥着显式的全局变量。根据定义,这些是不可收集的(除非被指定为空或重新分配)。用于临时存储和处理大量信息的全局变量特别令人担忧。如果你必须使用一个全局变量来存储大量数据,那么请确保将其指定为null,或者在完成后将其重新赋值。
2.被遗忘的定时器和回调
以setInterval
为例,因为它在JavaScript中经常使用。
var serverData = loadData();
setInterval(function() {
var renderer = document.getElementById('renderer');
if(renderer) {
renderer.innerHTML = JSON.stringify(serverData);
}
}, 5000); //每五秒会执行一次
上面的代码片段演示了使用定时器时引用不再需要的节点或数据。
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);
// 现在,当元素超出作用域时,元素和onClick都将被收集,即使在处理循环不好的浏览器中也是如此
如今,现在的浏览器(包括IE和Edge)使用现代的垃圾回收算法,可以立即发现并处理这些循环引用。换句话说,在一个节点删除之前也不是必须要调用removeEventListener。
一些框架或库,比如JQuery,会在处置节点之前自动删除监听器(在使用它们特定的API的时候)。这是由库内部的机制实现的,能够确保不发生内存泄漏,即使在有问题的浏览器下运行也能这样,比如……IE 6。
3. 闭包
闭包是javascript开发的一个关键方面,一个内部函数使用了外部(封闭)函数的变量。由于JavaScript运行的细节,它可能以下面的方式造成内存泄漏:
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
的闭包。
是不是有点困惑了? 重要的是,一旦具有相同父作用域的多个闭包的作用域被创建,则这个作用域就可以被共享。
在这种情况下,为闭包someMethod
而创建的作用域可以被unused
共享的。unused
内部存在一个对originalThing
的引用。即使unused
从未使用过,someMethod
也可以在replaceThing
的作用域之外(例如在全局范围内)通过theThing
来被调用。
由于someMethod
共享了unused
闭包的作用域,那么unused
引用包含的originalThing
会迫使它保持活动状态(两个闭包之间的整个共享作用域)。这阻止了它被收集。
当这段代码重复运行时,可以观察到内存使用在稳定增长,当GC运行后,内存使用也不会变小。从本质上说,在运行过程中创建了一个闭包链表(它的根是以变量theThing
的形式存在),并且每个闭包的作用域都间接引用了了一个大数组,这造成了相当大的内存泄漏。
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() {
// image是body元素的直接子元素
document.body.removeChild(document.getElementById('image'));
// 此时,我们仍然在全局元素对象中有一个对#button的引用。换句话说,button元素仍然在内存中,不能被垃圾收集器收集。
}
在引用 DOM 树中的内部节点或叶节点时,还需要考虑另外一个问题。如果在代码中保留对表单元格的引用(
你可能认为垃圾收集器将释放除该单元格之外的所有内容。然而,事实并非如此,由于单元格是表的一个子节点,而子节点保存对父节点的引用,所以对表单元格的这个引用将使整个表保持在内存中,所以在移除有被引用的节点时候要移除其子节点。
十七、参考文章
原文:https://blog.sessionstack.com/how-javascript-works-memory-management-how-to-handle-4-common-memory-leaks-3f28b94cfbec
相关推荐
评论 抢沙发
觉得文章有用就打赏一下文章作者
支付宝扫一扫打赏

微信扫一扫打赏

最新评论
Nignx主要是后台做负载用,没想到你也这么用心
这个评论虽然不能一针见血,但是喜欢这个文章,一直喜欢这个时间管理法。很好
优秀
你对加密的定义很严谨,在平时及网络各种文章中,通常将 base64 称之为“加密”,上面及文章中提到的“加密”同样是这个意思,并非严格意义的加密。严格讲 base64 是一种编码方式。感谢你的回复。
严格意义上来说 base64 不算是加密(Encryption),而是一种编码形式(Encoding)。对于 UTF-8 这种也可以叫它为 Encoding。 加密(Encryption)是指像 R
Base64: 可逆性。 可以将图片等二进制文件转换为文本文件。 可以把非ASCII字符的数据转换成ASCII字符,避免不可见字符。 MD5: 不可逆性。 任意长度的明文字符串,加密后得到的密文字符
第一个问题BASE64的加密方式和MD5的加密方式在这里 哪种 好用?
正则规则的原理可以多说一些