我们都知道,随着软件行业的不断发展,性能优化是一个不可避免的话题,那么什么的行为才能算得上是性能优化呢,本质上来说任何一种可以提高运行效率,减少运行开销的行为都可以看作是优化操作。这就意味着软件开发中必然存在着很多值得优化的地方。
在前端开发的过程中,性能优化也更是无处不在,例如请求资源时用到的网络,以及数据的传输方式,开发框架等,都可以去进行优化。
接下来学习JavaScript本身的优化,具体来说就是从认识内存空间的使用,到垃圾回收方式的介绍,从而编写出高效的JavaScript代码。
我们会遇到的内容:
随着计算机硬件技术的不断发展,同时高级编程语言也都自带了GC机制。所以让我们在不需要注意内存空间使用的情况下,也能够完成相应的功能开发。
那为什么还需要重提内存管理呢?
fn函数中有一个空数组,在赋值的时候刻意选择了一个比较大的数字作为下标,目的时为了当前函数调用的时候可以向内存尽可能的去申请一片比较大的空间。
function fn(){ let arr = []; arr[100000] = 999 }; fn()
上面代码语法上是不存在问题的,但是当我们用性能监测工具在脚本执行过程中进行监控的时候,会发现它的内存变化是这样的:如图中的蓝色线条一样,持续升高,过程中并没有回落,这就是内存泄漏。
所以说我们在写代码的时候对内存管理的机制不够了解的话,就容易写出一些不容易查询到的造成内存泄漏的代码。这些代码多了以后就会给程序造成意想不到的问题。所以掌握内存管理是非常有必要的。
和其他语言一样,JavaScript也是分三步来执行这个过程:申请空间、使用空间、释放空间。
但是ECMAScript中并没有提供相应的API,所以JavaScript开发者不能主动调用相应的API来完成相应的内存管理。
但我们依然可以通过JS脚本来演示当前在内部一个空间的生命周期是如何完成的:
//申请----JavaScript执行引擎在遇到变量定义语句的时候会自动分配给我们相应的空间 let obj = {}; //使用----读写操作 obj.name = 'wjp'; //释放----JavaScript也没有相应的释放API,但可以通过间接的方式,比如把变量设置为null obj = null;
然后可以在性能监测工具当中看一下这样的走势。
知道了什么是垃圾之后,JavaScript执行引擎就会出来工作,把它们所占据的内存空间进行回收。这个过程就是我们所说的JavaScript垃圾回收
所以JavaScript当中的垃圾回收就是:找到垃圾,然后通过JavaScript执行引擎来进行空间的释放回收。
//当前对象空间叫做 '小明空间',并且小明空间被obj对象所引用。 //且在全局执行上下文中,当前obj对象可以从根上被找到,所以obj是可达的。所以小明对象空间也是可达的。 let obj = {name:'小明'}; //让ali变量等于obj变量,意味着小明空间又多了一次引用,存在引用数值变化。 let ali = obj; //obj 到小明空间的引用被断掉了,那小明对象空间是否还是可达呢? //必然是可达鸭,因为ali还在引用着小明对象空间 obj = null;
function objGroup(obj1,obj2){ obj1.next = obj2; obj2.prev = obj1; return { o1:obj1, o2:obj2 } } let obj = objGroup( {name:'obj1'},{name:'obj2'} ); console.log(obj);
首先从全局的根(作用域)出发,找到可达对象obj;它里面是o1和o2,又指向了obj1和obj2的对象空间;
obj1和obj2又通过next和prev属性互相引用,所以代码里的对象都可以从根上来进行查找,如图:
那现在,做一件事情,通过delete语句把obj中o1的引用和obj2对obj1的应用都delete掉:
delete obj.o1; delete obj.o2.prev;
此时此刻就再也没有办法去找到obj1这个对象空间了。它就变成了垃圾,JavaScript执行引擎就会找到它进行回收。
如图:
当前在编写代码时会存在对象引用的关系,然后可以从根的下边进行查找,按照这样一些链条终归能找到一些对象,但如果找到这些对象的路径被破坏掉,我们就没有办法找到它,就会把它视作垃圾,最后垃圾回收机制会把它回收掉。
GC就是垃圾回收机制的简写,它可以找到内存中的垃圾对象,并释放,回收空间。
**核心思想:**设置引用计数器,维护当前对象的引用数值,通过数值是否为零。从而判断是不是垃圾对象。当数值为零时,GC就开始工作,将其所在的对象空间进行回收释放再使用。
当某个对象空间的引用关系发生改变时,引用计数器就会自动去修改当前对象所对应的引用数值。
什么叫引用关系发生改变呢?比如代码里现在有一个对象空间,有一个变量名指向它,那么这个时候数值加1,又多一个变量还指向它,那么数值再加1,如果减少,减1。当引用数值为零时,GC立即工作,将当前对象空间进行回收。
const user1 = {age:11}; const user2 = {age:22}; const user3 = {age:33}; const nameList = [user1.age , user2.age , user3.age]; function fn(){ const num1 = 1; const num2 = 2; } fn();
上面代码,从user1、user2、user3、nameList的引用计数肯定都不是零。
fn执行后,由于num1和num2只能在函数作用域内被访问到,从外部全局作用域出发就不能够再找到num1和num2了,这个时候num1和num2身上的引用计数就会回到零,GC立即开始工作,将它们当作垃圾进行回收。
总结一下:靠着当前对象身上的引用计数数值来判断是否为零,从而决定是否是垃圾对象。
**优点:**发现垃圾立即回收,最大限度减少程序暂停
缺点:
function fn(){ const obj1 = {}; const obj2 = {}; obj1.name = obj2; obj2.name = obj1; } fn(); //fn函数执行完后,它内部所在的空间要涉及到空间回收的情况,比如obj1和obj2,因为全局作用域已经不可能访问到它了,但这时候的问题是,当GC去回收obj1的时候,发现obj2的属性指向obj1。
上面代码,按照之前的规则,我们在全局作用域找不到obj1和obj2了,但是由于obj1和obj2两者之间在函数作用域内明显还存在互相的指引关系,所以它们当前引用计数器的数值永远是不为零的,这个时候引用计数算法下的GC就没有办法将这两个对象空间进行回收了。从而造成内存空间的浪费,这就是所谓的对象之间的循环引用。这也是计数算法所面临的问题。
核心思想:标记和清除两个阶段
如下图:
在全局作用域,可以找到A、B、C三个可达对象并标记 ,找到这三个可达对象后发现它们的下边会有一些子引用,标记清除算法会用递归的方式继续去寻找那些可达的对象,D和E也会被找到并标记;
a1和b1放在右侧,可以比作放在一个局部作用域,而局部作用域执行完毕后就会进行空间回收。所以global链条下是找不到a1和b1的,GC机制就会认为它是垃圾对象,不会给它做标记,然后GC工作时就会找到a1和b1把它们回收。
简单再整理一下,就是 分成两个步骤:
相比引用计数算法,标记清除算法的原理实现更加简单,还能解决一些相应的问题,在后续的V8当中,会被大量使用到。
**相对于引用计数算法,它可以解决之前对象循环引用的回收操作。**上图的a1和b1在在局部作用域完成后就失去了和全局作用域global的连接,它们是不可达的对象 ,不可达对象在标记阶段就不能完成标记。第二个阶段找到它们是没有标记的对象,然后清除它们,完成释放。
和标记清除算法一样,这个算法在V8当中也会被频繁使用。
实现原理:
回收前内存对象摆放位置:包含活动对象、非活动对象、空闲的空间 ,执行标记阶段时会将活动对象进行标记,然后进行整理的操作;
整理后看到就是位置上的改变,它会将活动对象进行移动,在地址上变成一个连续的位置。然后将活动对象右侧的范围进行整体回收;
回收后,相对于标记清除算法来说,好处就是在内存里面就不会出现大批量的分散小空间。而回收到的空间基本上是连续的。在后续使用过程中去申请新的空间时候就会最大化利用当前所释放出来的空间。
这就是标记整理算法,它会配合标记清除算法在V8引擎中实现频繁的GC操作。