第三篇文章我们将会讨论另一个非常核心,非常重要,但是又被很多程序员忽略的话题。之所以被忽略,是因为越来越成熟复杂的编程语言集成了相关功能,这就是内存管理。我们仍然会提供一些防止内存泄漏的方法。
简介
像C这样的语言,只提供了很原始的内存管理方式,例如malloc() 和 free() 。程序员们使用这些原始的方法分配和释放内存。
然而, 当对象,字符串等内容被创建时,JavaScript会自动分配内存并且通过垃圾回收进程“自动”释放内存。从表面上看来,想JS这样的高级语言,原生就支持自动释放资源,这样程序员就不用太关注内存管理。其实这是一个巨大的错误。
就算我们使用高级语言来工作,程序员们也应该理解内存管理,哪怕是一些基础概念。有的时候,一些问题的原因就来自于内存管理,比如一些bug或者垃圾回收机制的限制。只有理解内存管理,我们才能够更加高效的处理这些问题。
内存生命周期
不管你使用哪种语言,内存的生命周期都是一样的:
这面来讲述一下内存生命周期这几步都干了什么:
- 内存分配(Allocate Memory) -- 操作系统分配一些内存空间,以便于你的程序使用它们。在低级的编程语言当中,例如C,需要开发者通过一个明确的操作来实现内存分配。在高级语言当中,好吧,大部分的工作已经帮你搞定了。
- 使用内存(Use Memory)-- 之前分配好的内存空间被程序使用的过程。比如对你在程序中定义好的变量进行读和写操作。
- 释放内存(Release Memory)-- 释放所有不需要的内存空间,使其可以被再分配。和内存分配一样,在低级语言中,需要我们使用明确的命令实现。
你可以通过阅读第一篇文章来快速的回顾一下调用栈和内存堆。
什么是内存?
在直接进入JS内存知识之前,我们简单的聊一聊一般的内存是怎样工作的。
在硬件级别,内存是由大量的触发器组成的。每个触发器包含了几个晶体管,并且能保存1比特(bit)。每个触发器可以通过唯一的标识寻址,因此我们读取和复写他们。这样,从概念上我们可以认为,整个的计算机内存就是一个巨大数量的bit组成的数组,并且我们可以读写他们。
尽管身为人类,我们不可能和计算机一样去处理比特算法,但是我们把他们组织成可以表示数字的组, 8个比特成为一个字节,字节之后,就是单词(16或者32比特表示)。
太多的东西被存储在内存当中:
- 被程序所使用的所有变量和数据
- 程序的代码,包括操作系统的
编译器和操作系统很好的结合,已经为你管理大部分的内存,但是我们推荐你继续理解底层的一些知识。
当你编译你的代码的时候,编译器会检查原始数据的类型,提前算好需要多少内存。 这总数将会决定给应用程序分配多少调用栈空间。 变量被分配的空间称之为栈空间,因为当方法被调用的时候,他们的内存会被放在所有的内存的最顶端,当方法结束时,他们将会从内存中移除(LIFO)。比如下面代码:
int n; // 4 bytes
int x[4]; // array of 4 elements, each 4 bytes
double m; // 8 bytes
编译器将会立即看出来需要28个字节: 4 + 4 × 4 + 8 = 28 bytes.
这是现在整型和双浮点数的大小。在20年前,整形由2个字节表示,而双浮点由4个字节。现在的代码永远也不必遵循这个大小的限制了
编译器将会插入一段代码用来和操作系统通信,去获取变量所需栈的大小(字节数)。
如同上面的例子,编译器完全知道每个变量的内存地址,不管什么时候,我们想给变量“n”赋值,系统内部会把他穿换成想“内存地址 4127963”一样的东西。
注意,如果我们想要访问"x[4]", 我们就会访问给m分配的数据。 因为我们试图访问数组中不存在的元素,因为访问的4字节已经超出最后给x分配元素的位置,因此我们有可能最终读取或者写入了m变量的一些bit位。 这应该是我们程序里不想要的结果。
当一个function调用另外一个function的时候,每一个会获取自己的调用栈。然后会把所有的变量放在那里,并且程序计数器(Program Counter)也会记住它在哪里执行。当function执行结束之后,这块内存空间就又可以在将来被使用。
动态分配
不幸的是,如果我们不知道编译时一个变量到底使用多少内存,一切会变得会没有那么简单。比如我们想做下面的事情:
int n = readInput(); // reads input from the user
...
// create an array with "n" elements
在编译的时候,编译器并不知道这个数组需要多少内存,因为他是依据用户提供的值来决定的。
因此,系统不能给变量在栈上分配空间。因此我们的程序需要在运行时想操作系统询问总大小、 然后内存通过堆空间来被注册而不是栈。 具体静态分配和动态分配的区别如下:
动态内存分配与静态内存分配差异
为了完全的理解动态内存分配的工作机制,我们需要花更多的时间在指针上面,这个话题有点偏离本文的重点。如果你感兴趣的话,我们将会在接下来的文章里详细讨论一下。
JavaScript的分配机制
现在我们来解释一下JavaScript工作的第一步(分配内存)。
JavaScript已经帮助开发者减轻了对内存分配的工作职责,就在你定义变量的时候,JS会自己处理分配。
var n = 374; // allocates memory for a number
var s = 'sessionstack'; // allocates memory for a stringvar 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);
一些function调用之后的结果被分配到了对象中:
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中使用内存
基础意义上的使用内存是指读写。
比如读写值或者对象的属性,或者给fucntion传参。
释放不在被使用的内存
大多数的内存管理问题都会出现在这一小节。
最难的任务是确定什么时候应该去释放内存。这经常需要开发者在程序里手动释放。
高级语言集成了垃圾回收机制,来找出不再被使用的内存,并自动释放内存。
但是,这个机制只是大概完成了内存释放,因为毕竟有些内存很难在数学算法层面上来判断是否应该被释放。
大部分的垃圾回收机制是通过收集哪些不再可能被访问的内存,比如所有能访问它的变量都在作用域外。这就是能够被确认可以回收的内存空间,但这也是为什么垃圾回收机制只能近似的完成内存释放的原因,因为尽管在同一作用域一直有一个变量指向它,也有可能在程序逻辑中,这个变量是不再被访问的。
垃圾回收
由于在寻找可被释放内存方面的缺陷,垃圾回收机制实行了一些限制措施来坚决这些问题。下一段将会解释一些关于理解垃圾回收算法,和他们去解决这些问题的一些概念性知识。
内存引用
垃圾回收算法的核心理念依赖于内存引用。
在内存的上下文管理中,对象引用存在隐式的,或显式的。比如,JS Object对原型的引用,和对属性的引用。对象的原型就是隐式的引用,而属性则是显式的引用。
文中提到的对象是泛指一切包含函数作用域和全局词法作用域的对象,JS包含其中。
词法作用域定义了由变量名称定义的fucntions, 被调用的function作用域是其调用着的作用域。
基于“引用计数”的垃圾回收机制
这是一个极简的垃圾回收算法:如果该对象没有被任何其他对象引用,那么就会被回收。
var o1 = { o2: { x: 1 } };
// 2 objects are created.
// 'o2' is referenced by 'o1' object as one of its properties.
// None can be garbage-collectedvar 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' variableo3 = '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.
闭环带来的麻烦
当这种算法遇到一种闭环引用,那么它就会有局限性。比如下面例子,两个对象的属性都都指向了对方,也就是两个对象互相建立了引用,这样就形成了闭环。当f的调用结束后,这两个变量将会被抛出作用域,因此他们占用的内存应该被清理。可是,引用计数算法认为这两个对象至少被引用了一次,所以垃圾回收机制并不会释放他们。
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)
这种算法通过判断对象是否可以被“到达 或者 放的到”来决定是否可以被释放。
标记清除算法做了三步操作:
- 根元素:一般来讲,根元素是一组被引用的全局变量,类似于JS中比如window对象,或者NodeJS中的global对象。垃圾回收机制会建立一整套完整的根元素。
- 这种算法将会检查所有的根元素和他们的子节点,并标记为活跃的(并不是垃圾)。 任何通过根元素不能访问到的对象都被标记为垃圾。
- 最后将标记为垃圾的所有内存片段释放给操作系统。
标记-清理算法
相比这个对象是否有被引用来讲,对象是否能够被“到达”要更好一点。
自从2012年起,现代浏览器技术全部装载了标记清除方式的垃圾回收机制。这些年针对JS垃圾回收做出的升级,使得新的垃圾回收算法的效果突显出来。但是算法本身并没有改变,包括是如何决定对象是否能够被到达。
在这篇文章中,你可以了解更多关于垃圾回收的详细内容。
闭环的问题迎刃而解
在第一个例子当中,当function调用结束后,生成的两个对象不在可以通过任何的全局对象来访问到。因此,他将会被垃圾回收机制标记为垃圾。
就算两个对象都被对方引用,但是通过任何的根对象都访问不到它们。
垃圾回收的缺陷
尽管垃圾回收带来了很大的方便,它甚至可以自己来权衡操作。但是也存在很多不确定性。换句话说,垃圾回收是不稳定的。你很难说出来它什么时候运行。有的时候,应用程序使用的内存远远超过了它的需要,还有些应用程序会出现短暂的未响应。大多数垃圾回收机制的运行基于一些分配操作。如果没有内存分配操作被执行,那么垃圾回收将会处于空闲状态。 比如下面这个场景:
- 一个巨大的空间被分配
- 这个空间中大多数元素是不可达到的,应该被清理的。
- 之后没有任何内存分配动作
在这个场景中,垃圾回收将不再会被执行。也就是说,尽管存在不可被访问到的元素,但是垃圾回收并不会处理它们。这不一定导致内存泄露,但是内存的使用仍然高于正常情况。
什么是内存泄漏
内存泄露是指在程序中,某些内存不在被程序使用,但是有没有正确的释放给操作系统或空闲内存池的状态。
每种编程语言都有自己管理内存的不同方法,但是对于某些内存是否应该被释放仍然是一件不可确定的事情,只有程序员是唯一的明白人,也只有程序员才明确的知道什么时候去释放内存。
有些语言会帮助开发者管理内存,还有些语言鼓励开发者自己去管理。可以阅读手动内存管理和自动内存管理来获取更多信息。
四中常见的内存泄漏
1.全局变量
JS来处理未被声明的变量的方式令人费解:当一个未被声明的变量被赋值的时候,一个新的全局变量的属性将会被声明,在浏览器中全局变量就是window:
function foo(arg) { bar = "some text"; }
等价于
function foo(arg) { window.bar = "some text"; }
实际上bar只被方法foo使用了,但是一个多余的全局变量被创建。
所以在coding的时候最好使用`use strict` 或者 ESLint来帮助自己检查这种错误。
2.被遗忘的定时器和回调函数
在日常编写代码当中,我们经常使用setInterval,而serInterval内部经常使用外部作用域的变量。因此当定时器内部方法不在被使用的时候,一定要记得清理掉。
除此之外,我们会给dom绑定很多的操作事件,当我们不需要这个dom或者不需要这些事件的时候,也要记得给dom解绑事件,当然在现代浏览器当中,你可以通过移除这些dom来获得相同的效果。
3.闭包
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。 并且,originThing 是 theThing的一个索引。originalThing被unused这个闭包函数调用。因此theThing被相同作用域下的不同闭包函数调用。
尽管unused 永远不被使用,但是外部作用域的theThing仍然可以访问someMethod,因此someMethod和unused分享了相同的作用域,那么为了保持调用时闭包可以访问父级作用域的数据,所有的变量都不可以回收。
这样也会造成内存泄露。
脱离DOM索引
有些开发者将DOM节点存放到数据结构当中, 比如我们想快速的更新几行表中的数据。如果你存储每行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的内存不能被释放,但是单元格所占的内存可以释放。 那你就太天真了,因为dom树的结构原因,其子节点会被父节点引用,更严重的是,因为子节点也会将父节点作为自己的引用,所以一个对单元格的引用将会导致整张表都会一直保留在内存当中,然而整张表的引用也有其父元素,因此一切不需要的父节点以及子元素都会永久保留在内存当中, 这就尴尬了。