栈内存和堆内存的优缺点
在JS中,基本数据类型变量大小固定,并且操作简单容易,所以把它们放入栈中存储。
引用类型变量大小不固定,所以把它们分配给堆中,让他们申请空间的时候自己确定大小,这样把它们分开存储能够使得程序运行起来占用的内存最小。
栈内存由于它的特点,所以它的系统效率较高。
堆内存需要分配空间和地址,还要把地址存到栈中,所以效率低于栈。
浏览器垃圾回收机制
内存生命周期
js中内存生命周期分3个步骤
1、内存分配:当我们声明变量、函数、对象时,系统会自动为他们分配内存
2、内存使用:即读写内存,也就是使用变量、函数等
3、内存回收:使用完毕,由垃圾回收机制自动回收不再使用的内存
js内存分配
为了不让程序员费心分配内存,JavaScript 在定义变量时就自动完成了内存分配。
1 | var n = 123; // 给数值变量分配内存 |
有些函数调用结果是分配对象内存
1 | var d = new Date(); // 分配一个 Date 对象 |
JS 的内存使用
使用值的过程实际上是对分配内存进行读取与写入的操作。 读取与写入可能是写入一个变量或者一个对象的属性值,甚至传递函数的参数。
1 | var a = 10; // 分配内存 |
JS 的内存回收
JS 有自动垃圾回收机制,那么这个自动垃圾回收机制的原理是什么呢?其实很简单,就是找出那些不再继续使用的值,然后释放其占用的内存。
大多数内存管理的问题都在这个阶段。在这里最艰难的任务是找到不再需要使用的变量。
不再需要使用的变量也就是生命周期结束的变量,是局部变量,局部变量只在函数的执行过程中存在,当函数运行结束,没有其他引用(闭包),那么该变量会被标记回收。
全局变量的生命周期直至浏览器卸载页面才会结束,也就是说全局变量不会被当成垃圾回收。
因为自动垃圾回收机制的存在,开发人员可以不关心也不注意内存释放的有关问题,但对无用内存的释放这件事是客观存在的。不幸的是,即使不考虑垃圾回收对性能的影响,目前最新的垃圾回收算法,也无法智能回收所有的极端情况。
接下来我们来探究一下 JS 垃圾回收的机制。
垃圾回收
垃圾回收主要依赖于引用概念,在内存管理的环境中,一个对象如果有访问另一个对象(显式或者隐式),叫做一个对象对另一个对象的引用。
例如,一个js对象具有对其原型的引用(隐式引用)和对他属性的引用(显式引用)。
在这里,“对象”的概念不仅特指 JavaScript 对象,还包括函数作用域(或者全局词法作用域)。
引用计数垃圾收集
这是最初级的垃圾回收算法。
引用计数算法定义“内存不再使用”的标准很简单,就是看一个对象是否有指向它的引用。 如果没有其他对象指向它了,说明该对象已经不再需了。
1 | var o = { |
由上面可以看出,引用计数算法是个简单有效的算法。但它却存在一个致命的问题:循环引用。
如果两个对象相互引用,尽管他们已不再使用,垃圾回收不会进行回收,导致内存泄露。
来看一个循环引用的例子:
1 | function f(){ |
上面我们申明了一个函数 f ,其中包含两个相互引用的对象。
在调用函数结束后,对象 o1 和 o2 实际上已离开函数范围,因此不再需要了。
但根据引用计数的原则,他们之间的相互引用依然存在,因此这部分内存不会被回收,内存泄露不可避免了。
再来看一个实际的例子:
1 | var div = document.createElement("div"); |
上面这种JS写法再普通不过了,创建一个DOM元素并绑定一个点击事件。
此时变量 div 有事件处理函数的引用,同时事件处理函数也有div的引用!(div变量可在函数内被访问)。
一个循序引用出现了,按上面所讲的算法,该部分内存无可避免的泄露了。
为了解决循环引用造成的问题,现代浏览器通过使用标记清除算法来实现垃圾回收。
标记清除算法
标记清除算法将“不再使用的对象”定义为“无法达到的对象”。
简单来说,就是从根部(在JS中就是全局对象)出发定时扫描内存中的对象。
凡是能从根部到达的对象,都是还需要使用的。
那些无法由根部出发触及到的对象被标记为不再使用,稍后进行回收。
从这个概念可以看出,无法触及的对象包含了没有引用的对象这个概念(没有任何引用的对象也是无法触及的对象)。
但反之未必成立。
工作流程:
垃圾收集器会在运行的时候会给存储在内存中的所有变量都加上标记。
从根部出发将能触及到的对象的标记清除。
那些还存在标记的变量被视为准备删除的变量。
最后垃圾收集器会执行最后一步内存清除的工作,销毁那些带标记的值并回收它们所占用的内存空间。
内存泄漏
什么是内存泄漏
程序的运行需要内存。只要程序提出要求,操作系统或者运行时(runtime)就必须供给内存。
对于持续运行的服务进程(daemon),必须及时释放不再用到的内存。
否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。
本质上讲,内存泄漏就是由于疏忽或错误造成程序未能释放那些已经不再使用的内存,造成内存的浪费。
内存泄漏的识别方法
经验法则是,如果连续五次垃圾回收之后,内存占用一次比一次大,就有内存泄漏。
这就要求实时查看内存的占用情况。
在 Chrome 浏览器中,我们可以这样查看内存占用情况
打开开发者工具,选择 Performance 面板
在顶部勾选 Memory
点击左上角的 record 按钮
在页面上进行各种操作,模拟用户的使用情况
一段时间后,点击对话框的 stop 按钮,面板上就会显示这段时间的内存占用情况
我们有两种方式来判定当前是否有内存泄漏:
多次快照后,比较每次快照中内存的占用情况,如果呈上升趋势,那么可以认为存在内存泄漏
某次快照后,看当前内存占用的趋势图,如果走势不平稳,呈上升趋势,那么可以认为存在内存泄漏
在服务器环境中使用 Node 提供的 process.memoryUsage 方法查看内存情况
1 | console.log(process.memoryUsage()); |
process.memoryUsage返回一个对象,包含了 Node 进程的内存占用信息。
该对象包含四个字段,单位是字节,含义如下:
rss(resident set size):所有内存占用,包括指令区和堆栈。
heapTotal:”堆”占用的内存,包括用到的和没用到的。
heapUsed:用到的堆的部分。
external: V8 引擎内部的 C++ 对象占用的内存。
判断内存泄漏,以heapUsed字段为准。
常见的内存泄露案例
1、意外创建的全局变量
1 | function foo() { |
在这个例子中,意外的创建了两个全局变量 bar1 和 bar2
2、被遗忘的定时器和回调函数
在很多库中, 如果使用了观察者模式, 都会提供回调方法, 来调用一些回调函数。 要记得回收这些回调函数。举一个 setInterval的例子:
1 | var serverData = loadData(); |
这段代码,每次调用 replaceThing 时,theThing 获得了包含一个巨大的数组和一个对于新闭包 someMethod 的对象。
同时 unused 是一个引用了 originalThing 的闭包。
这个范例的关键在于,闭包之间是共享作用域的,尽管 unused 可能一直没有被调用,但是 someMethod 可能会被调用,就会导致无法对其内存进行回收。
当这段代码被反复执行时,内存会持续增长。
4、DOM引用
很多时候, 我们对 Dom 的操作, 会把 Dom 的引用保存在一个数组或者 Map 中。
1 | var elements = { |
上述案例中,即使我们对于 image 元素进行了移除,但是仍然有对 image 元素的引用,依然无法对齐进行内存回收。
另外需要注意的一个点是,对于一个 Dom 树的叶子节点的引用。
举个例子: 如果我们引用了一个表格中的td元素,一旦在 Dom 中删除了整个表格,我们直观的觉得内存回收应该回收除了被引用的 td 外的其他元素。
但是事实上,这个 td 元素是整个表格的一个子元素,并保留对于其父元素的引用。
这就会导致对于整个表格,都无法进行内存回收。所以我们要小心处理对于 Dom 元素的引用。
如何避免内存泄漏
记住一个原则:不用的东西,及时归还。
减少不必要的全局变量,使用严格模式避免意外创建全局变量。
在你使用完数据后,及时解除引用(闭包中的变量,dom引用,定时器清除)。
组织好你的逻辑,避免死循环等造成浏览器卡顿,崩溃的问题。