JavaScript内存管理和优化
作者:聚划算前端开发专家 韩璟(花名:业勤)
在JavaScript中,每当我们创建一个对象,都会占用内存,不再使用时,浏览器会自动释放。这种自动化的内存的管理的方式,大大降低了开发对于js内存管理的成本,但也造成了开发人员的JavaScript的内存管理忽视。然而现在,各种单页应用的诞生,各种不同无线终端少的可怜内存分配,交互的复杂性以及流畅性,以及nodejs应用的崛起,又使得JavaScript的内存管理变得重要起来。
基本概念篇在ECMAScript标准中,没有规定任何的内存管理的接口,这使得开发者没有任何的能力来操作内存。但并不代表我们可以不关心内存。
怎样才是一个好的内存管理?简单来说就是做到以下两点-
使得js的内存的使用保持在较低水平。如果浏览的内存使用过高,往往容易导致浏览器的crash。内存泄漏,导致内存无法被浏览器回收,也是引起内存使用过高的重要原因。
- 不频繁的触发浏览器的GC(内存垃圾回收)操作。因为浏览器的单线程的,GC操作执行时,会阻碍浏览器的正常程序的执行。所以,频繁触发GC时,往往会影响页面的流畅度,尤其在动画的过程中,卡顿感还是会比较明显的。
内存的生命周期大致分为三个步骤
-
分配需要的内存
JavaScript 在定义变量或者创建一个变量的时候,就完成了内存分配。
例如:// 给数值变量分配内存 var n = 123; // 给字符串分配内存 var s = "azerty"; // 给对象及其包含的值分配内存 var o = { a: 1, b: null }; // 给数组及其包含的值分配内存(就像对象一样) var a = [1, null, "abra"]; // 给函数(可调用的对象)分配内存 function f(a){ return a + 2; } var d = new Date(); // 分配一个 Date 对象 var e = document.createElement('div'); // 分配一个 DOM 元素
-
使用分配到的内存(读、写)
其实就是对值的读写操作,不多说了
-
不需要时将其释放\归还
如果一个内存不在被需要了,就需要把内存释放掉,以便内存的再次使用。这里比较难的问题就是“如何判断内存是否不再被需要”。垃圾回收算法主要依赖于引用(reference)的概念,来判断内存是否被需要。在内存管理的环境中,一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。
垃圾收集算法中,IE 6, 7采用的是
引用计数垃圾收集算法
。该算法把“对象是否不再需要”简化定义为“对象有没有其他对象引用到它”。如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收。该算法有一个弊端就是“循环引用”,是导致内存泄漏的重要原因。而从2012年起,所有的现代浏览器都换成了
标记-清除算法
,这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。是否可获得的判断标准就是这个对象是否被root引用(包含直接引用和间接引用),如果不被引用到,就被收回。这样就很好的避免了循环引用的问题。
var a = new A(); //创建A的实例
var b = new B(); //创建B的实例
a.link = b;
b.link = a;
a = null;
b = null;
/*
上面的例子中 A ,B的实例形成循环引用, 最后把a ,b设为null。
在引用计数垃圾收集算法中,A ,B的实例相互引用,各自的引用数不为0,所以不会被收回。
而在标记-清除算法中,由于a, b设为null,A ,B的实例都不会被root也就是window对象引用到,会被收回。
*/
调试篇
查看浏览器的JavaScript的内存使用情况,一般有三种方法:
-
通过浏览器的任务管理器。这个可以了解各个页面的内存的使用总量,发现内存是否占用过高。
-
chrome的dev-tools里面的timeline。timeline的好处是可以看到随着时间的变化,看到内存的使用的情况。通过timeline,我们很容易了解到GC操作和内存的分配,从而发现内存是否泄漏和GC是否频繁的问题。
- dev-tools里面的profiles。内存快照的优点是详细的展示了某一时刻的内存的使用情况,包括:什么类型的数据占用了多大的内存,以及变量之间的引用关系。通过这些,我们就可以找到内存使用的问题所在,找到解决内存问题的方法。
注意在使用dev-tools调试的时候,最好使用隐身模式,或者禁用浏览器插件扩展程序等,因为这些插件的运行也会被dev-tools统计到,从而影响你的分析结果,增加你分析的难度。
通过浏览器的任务管理器参看内存使用情况,比较简单,我们就不多说了。从timeline开始讲起。
timeline- 先看个简单的例子,了解下timeline的基本使用
var x = []
var grow = function(){
x.push(new Array(1000000))
}
setInterval(function(){
grow()
},1000)
以上代码的timeline的情况如下:
由上图我们可以看到,在第一个红框里,很明显随着时间的增加内存在不断的增高,而第二个红框里,表示的是在这段时间里调用了哪些函数,以及每个函数的耗时。通过这个我们就可以定位到在哪些函数里面我们创建了对象,并且占用了内存,找到内存不断增高的原因。
- 再来看个GC的例子。
var run = function(){
var d = [];
for(var i = 0; i<100000; i++){
d.push(Math.random())
}
}
setInterval(function(){
run()
},100);
从图上可以看到,每隔一段时间,内存的使用就会大幅的下降,那就说明浏览器进行了GC操作。上面的代码中,每100ms执行一次run函数,每次执行我们创建一个d数组,浏览器并不是每次执行完,立刻回收d数组,而是等5次后执行一次GC操作。并且也可以注意到,GC操作也是花费时间的,minor GC和 major GC一共花费了8.9ms(5.8+3.1ms),所以这也是我们需要避免频繁的GC操作的原因。避免的方式,就是在频繁执行的方法里(如动画),尽可能的减少创建对象,尽可能的使用缓存对象来代替创建新对象。
仔细的人可能会问,啥是minor GC
和 major GC
?有什么区别?这个涉及到GC的实现和工作原理,详细的解释请看第三章,在上图中我们看到箭头标记的其实是major GC。而minor GC一般发生在给对象分配空间的时候,也就是说,在上图中内存的增长的时候,可能就触发了minor GC,请注意是可能。
有图有真相
minor GC
(这个图显示minor GC是0.0ms, 感觉是精度问题舍掉了,并不是没发生)
major GC
通过上面的两个例子,我们大致了解了timeline调试内存的使用方式和一些常见内存的问题,下面来了解另一个调试工具profiles(快照)。
profilesprofiles里我们主要用下面两个方式来分析内存的问题。
这两种分析方式的界面,操作和分析的方式基本是一致的。
Take Heap Snapshot
可以截取某一个时刻的,内存快照,用于分析这个时刻的内存使用情况,找出那些东西占用了比较大的内存。Record Allocation Timeline
可以记录每个时刻分配了哪些内存,和内存被回收的情况,主要用来分析内存泄漏的问题。
先了解下 Take Heap Snapshot
function Class1(name){
this.name = name
}
var x1 = new Class1("i am x1")
x1.x = new Array(1000000).join('x')
生成的快照如下
上图中我们选择的视图方式是summary
,所以在第一个红框内,是按照构造函数名分类显示对象。另外,还有几个选项分别是:
-
Summary - 通过构造函数名分类显示对象;
-
Comparison - 显示两个快照间对象的差异;
-
Containment - 从根节点开始,按照包含关系来显示对象;
- statistics - 统计各个不同类型的对象占用内存的比例;
在这块区域我们可以看到每个对象的情况:
- Distance - 表示该root引用该对象的最短距离
- Object Count - 表示数量
- shallow Size - 表示该对象对应构造函数生成的对象直接占用的内存数,不包含引用的对象占用的内存。
- Retained Size - 表示该对象和该对象的间接引用对象占用的内存的总数。
shallow Size
和Retained Size
这两个概念很重要,通常来说,只有Arrays和Stings会有比较大的shallow size。正如上面的图看到的 x1这个对象shallow size比较小,而因为包含一个比较大的string,所以Retained Size比较大。
上图中第二个红框,表示的选中的对象被root引用的关系链。例如,我们选中的是x这个字符串。第二个红框显示的内容就是,x 被一个Class1类型的对象引用,这个Class1的对象是x1, 然后x1又被window所引用。
通过上面的例子我们对快照的使用有了大致的了解,接下来我们看几个有趣的例子来进一步了解快照的使用。
- example1
var div1 = document.createElement('div')
var div2 = document.createElement('div')
document.body.appendChild(div1);
这回我们需要将summary
切换成contaiment
视图方式,在这个视图下,都是从根节开始,树型结构来组织对象,这里我们看到一个有趣的根节点Detached Dom tree
,顾名思义,就是没有插入到dom树中,但仍然被js引用的dom。就如我们创建的div2(由于div1已经插入到body中,所以不在这里),并且选中时高亮成了红色(这里应该是有bug,前几个版本的chrome,在未选中的时候,高亮也是红色)。通过这个,我们很容易查找出这些特殊dom,往往这些dom都是无用,也经常是造成内存泄漏的原因。
- example2
var text1 = 'im a test'
var text2 = 'im a test'
var text3 = 'im a test'
var text4 = 'im a test'
生成的快照如下
这个例子有趣的地方是,我们在代码中声明了四个String都是“im a test”,但是在实际的内存中,只有一个“im a test”,四个变量都指向了同一片内存。说明,在v8引擎中,创建sting的时候,会先判断有没有相同的字符串,如果有就复用,没有的话才会新建一个字符串。
总结下,在Take Heap Snapshot
中,我们可以了解到详细的、全局的内存使用情况,也有各种视图,来帮助我们更好的分析内存中的问题。但现实还是很残酷,真实的页面往往上万甚至几十万个对象,要发现问题真的很难。还是需要更多的仔细,耐心,经验。
接着来了解下 Record Allocation Timeline
,这个真的是分析内存泄漏的利器了。
还是来一段代码
var arr = [];
var add = function(){
arr.push(new Array(1000))
}
var del = function(){
arr[arr.length-1] = null;
}
setInterval(function(){
add();
},1000);
setInterval(function(){
del();
},2000);
profiles中选择Record Allocation Timeline
, 然后就可以看到下面的样子
这个和Take Heap Snapshot
不同的是多了一个时间轴,还有蓝色和灰色的柱子。这个柱子表示的在那个时刻,浏览器分配的内存,高度表示分配的大小。蓝色是指未被回收的内存,灰色是指分配了,但被回收了的内存。这样,看到蓝色的柱子,就说明你可能存在内存泄漏了。
这时,你可以选中一个时间段,像下图这样
这时就会显示,这段时间内,哪些对象创建了但是没有回收了的,如上图看到的是arr[113]没有被回收。
最后来一个实际开发中比较容易内存泄漏的例子,大家可以用上面的方法试试,哪里内存泄漏了,如何解决,这里就不多说了。
var wrap = document.querySelector('#wrap')
var createDOM = function(){
var a = new Array(100000);
var div = document.createElement(div);
div.a = a
div.resize = function(){
console.info(div.a)
}
window.addEventListener('resize', div.resize)
wrap.innerHTML = "";
wrap.appendChild(div);
}
setInterval(function(){
createDOM();
},1000);
V8’s GC engine
这一节,我们来了解下V8的GC引擎是到底是如何工作的。
在V8中,会把内存分为两个部分:新生区( young generation )和年老区(old generation),新生区的内存空间较小,用于创建和分配新的内存,年老区的空间较大,用于存放存活时间较长的对象。
大部分的对象,会在新生区里面被回收,这个时候发生的GC就是上面讲到的minor GC
,而不被回收的对象就会被移动到年老区。在年老区进行的GC操作,就是major GC
.
在新生区内,内存还会分为两个区,其中一个是active区,所有的内存分配都发生在active区,当active区满了以后,这个时候就会触发minor GC
,把活的对象移动到另一个半区,并且这些对象最终会移动到年老区。而其余的对象就会被清除,内存进行回收。minor GC
相对于major GC
,触发的更加频繁,并且处理速度也比较快。因为一是内存空间小,相对涉及的对象数也比较少。二是分成了两个半区,避免了内存整理压缩等操作。
下面了解下年老区的处理。在年老区里,当内存使用超出限制时,就会触发major GC
,回收算法就是使用的我们说的标记-清除(mark-sweep)算法,通常来说,标记整个年老区需要很长的时间,这里V8做了优化,支持增量标记,这样就可以把整个标记过程,分成多个小步。在清除完垃圾以后,以避免清除后年老区产生的内存碎片,还要进行内存压缩的操作,这个操作也比较耗时。所以可以看出,major GC
确实要比minor GC
多花很多的时间。
来张图 回顾下整个的GC流程
我们知道浏览器的最快的渲染频率是60fps,也就是是说每帧的时间间隔是16.6ms,如果浏览器在16.6ms内完成了所有的操作(包括js的运行和渲染),剩下的时间就是空闲时间,在这些空闲时间里面,就会执行GC操作,以便可以把GC操作的影响降到最低。
这里面chrome很多的优化,以保证GC时间不超过空闲时间。例如,
通过内存分配的速度,预测新生区什么时候会满,从而决定什么时候进行minor GC;
年老区进行增量标记,可以使标记操作分散在多段空闲时间里;
年老区里并不是每次GC操作都进行内存整理的工作,同时也会考虑空闲的大小是否足够
以上我们可以看出,浏览器对于GC再不断的优化,并且对于内存的管理,也提供给了我们很多的工具。这也说明了内存管理是越来的越重要了。总结下内存相关的东西,以便可以解决项目里内存的问题。然而,每当页面crash的时候,内心还是捉急和懵逼的。
共同学习,写下你的评论
评论加载中...
作者其他优质文章