1. 概述
随着软件开发行业的不断发展,性能优化已经是一个不可避免的话题,那什么样的行为才能算得上是性能优化呢?
本质上任何一种可以提高运行效率,降低运行开销的行为,都可以看做是一种优化操作。
这也就意味着,在软件开放行业必然存在着很多值得优化的地方,特别是在前端开发过程中,性能优化可以认为是无处不在的。例如请求资源时所用到的网络,以及数据的传输方式,再或者开发过程中所使用到的框架等都可以进行优化。
本章探索的是JavaScript
语言本身的优化,是从认知内存空间的使用到垃圾回收的方式,从而可以编写出高效的JavaScript
代码。
2. 内存管理
随着近些年硬件技术的不断发展,高级编程语言中都自带了GC
机制,让开发者在不需要特别注意内存空间使用的情况下,也能够正常的去完成相应的功能开发。为什么还要重提内存管理呢,下面就通过一段极简单的代码来进行说明。
首先定义一个普通的函数fn
,然后在函数体内声明一个数组,接着给数组赋值,需要注意的是在赋值的时候刻意选择了一个比较大的数字来作为下标。这样做的目的就是为了当前函数在调用的时候可以向内存尽可能多的申请一片比较大的空间。
function fn() {
arrlist = [];
arrlist[100000] = 'this is a lg';
}
fn()
在执行这个函数的过程中从语法上是不存在任何问题的,不过用相应的性能监控工具对内存进行监控的时候会发现,内存变化是持续程线性升高的,并且在这个过程当中没有回落。这代表着内存泄露。如果在写代码的时候不够了解内存管理的机制就会编写出一些不容易察觉到的内存问题型代码。
这种代码多了以后程序带来的可能就是一些意想不到的bug
,所以掌握内存的管理是非常有必要的。因此接下来就去看一下,什么是内存管理。
从这个词语本身来说,内存其实就是由可读写的单元组成,他标识一片可操作的空间。而管理在这里刻意强调的是由人主动去操作这片空间的申请、使用和释放,即使借助了一些API
,但终归可以自主的来做这个事。所以内存管理就认为是,开发者可以主动的向内存申请空间,使用空间,并且释放空间。因此这个流程就显得非常简单了,一共三步,申请,使用和释放。
回到JavaScript
中,其实和其他的语言一样,JavaScript
中也是分三步来执行这个过程,但是由于ECMAScript
中并没有提供相应的操作API
。所以JavaScript
不能像C
或者C++
那样,由开发者主动调用相应的API来完成内存空间的管理。
不过即使如此也不能影响我们通过JavaScript
脚本来演示一个空间的生命周期是怎样完成的。过程很简单首先要去申请空间,第二个使用空间,第三个释放空间。
在JavaScript
中并没有直接提供相应的API
,所以只能在JavaScript
执行引擎遇到变量定义语句的时候自动分配一个相应的空间。这里先定义一个变量obj
,然后把它指向一个空对象。对它的使用其实就是一个读写的操作,直接往这个对象里面写入一个具体的数据就可以了比如写上一个yd
。最后可以对它进行释放,同样的JavaScript
里面并没有相应的释放API
,所以这里可以采用一种间接的方式,比如直接把他设置为null
。
let obj = {
}
obj.name = 'yd'
obj = null
这个时候就相当于按照内存管理的一个流程在JavaScript
当中实现了内存管理。后期在这样性能监控工具当中看一下内存走势就可以了。
3. 垃圾回收
首先在JavaScript
中什么样的内容会被当中是垃圾看待。在后续的GC
算法当中,也会存在的垃圾的概念,两者其实是完全一样的。所以在这里统一说明。
JavaScript
中的内存管理是自动的。每创建一个对象、数组或者函数的时候,就会自动的分配相应的内存空间。等到后续程序代码在执行的过程中如果通过一些引用关系无法再找到某些对象的时候那么这些对象就会被看作是垃圾。再或者说这些对象其实是已经存在的,但是由于代码中一些不合适的语法或者说结构性的错误,没有办法再去找到这些对象,那么这种对象也会被称之是垃圾。
发现垃圾之后JavaScript
执行引擎就会出来工作,把垃圾所占据的对象空间进行回收,这个过程就是所谓的垃圾回收。在这里用到了几个小的概念,第一是引用,第二是从根上访问,这个操作在后续的GC
里面也会被频繁的提到。
在这里再提一个名词叫可达对象,首先在JavaScript
中可达对象理解起来非常的容易,就是能访问到的对象。至于访问,可以是通过具体的引用也可以在当前的上下文中通过作用域链。只要能找得到,就认为是可达的。不过这里边会有一个小的标准限制就是一定要是从根上出发找得到才认为是可达的。所以又要去讨论一下什么是根,在JavaScript
里面可以认为当前的全局变量对象就是根,也就是所谓的全局执行上下文。
简单总结一下就是JavaScript
中的垃圾回收其实就是找到垃圾,然后让JavaScript
的执行引擎来进行一个空间的释放和回收。
这里用到了引用和可达对象,接下来就尽可能的通过代码的方式来看一下在JavaScript
中的引用与可达是怎么体现的。
首先定义一个变量,为了后续可以修改值采用let
关键字定一个obj
让他指向一个对象,为了方便描述给他起一个名字叫xiaoming
。
let obj = {
name: 'xiaoming'}
写完这行代码以后其实就相当于是这个空间被当前的obj
对象引用了,这里就出现了引用。站在全局执行上下文下obj
是可以从根上来被找到的,也就是说这个obj
是一个可达的,这也就间接地意味着当前xiaoming
的对象空间是可达的。
接着再重新再去定义一个变量,比如ali
让他等于obj
,可以认为小明的空间又多了一次引用。这里存在着一个引用数值变化的,这个概念在后续的引用计数算法中是会用到的。
let obj = {
name: 'xiaoming'}
let ali = obj
再来做一个事情,直接找到obj
然后把它重新赋值为null
。这个操作做完之后就可以思考一下了。本身小明这对象空间是有两个引用的。随着null
赋值代码的执行,obj
到小明空间的引用就相当于是被切断了。现在小明对象是否还是可达呢?必然是的。因为ali
还在引用着这样的一个对象空间,所以说他依然是一个可达对象。
这就是一个引用的主要说明,顺带也看到了一个可达。
接下来再举一个示例,说明一下当前JavaScript
中的可达操作,不过这里面需要提前说明一下。
为了方便后面GC
中的标记清除算法,所以这个实例会稍微麻烦一些。
首先定义一个函数名字叫objGroup
,设置两个形参obj1
和obj2
,让obj1
通过一个属性指向obj2
,紧接着再让obj2
也通过一个属性去指向obj1
。再通过return关键字直接返回一个对象,obj1
通过o1
进行返回,再设置一个o2
让他找到obj2
。完成之后在外部调用这个函数,设置一个变量进行接收,obj
等于objGroup
调用的结果。传两个参数分别是两个对象obj1
和obj2
。
function objGroup(obj1, obj2) {
obj1.next = obj2;
obj2.prev = obj1;
}
let obj = objGroup({
name: 'obj1'}, {
name: 'obj2'});
console.log(obj);
运行可以发现得到了一个对象。对象里面分别有obj1
和obj2
,而obj1
和obj2
他们内部又各自通过一个属性指向了彼此。
{
o1: {
name: 'obj1', next: {
name: 'obj2', prev: [Circular]}},
o2: {
name: 'obj2', next: {
name: 'obj1', next: [Circular]}}
}
分析一下代码,首先从全局的根出发,是可以找到一个可达的对象obj
,他通过一个函数调用之后指向了一个内存空间,他的里面就是上面看到的o1
和o2
。然后在o1
和o2
的里面刚好又通过相应的属性指向了一个obj1
空间和obj2
空间。obj1
和obj2
之间又通过next
和prev
做了一个互相的一个引用,所以代码里面所出现的对象都可以从根上来进行查找。不论找起来是多么的麻烦,总之都能够找到,继续往下来再来做一些分析。
如果通过delete
语句把obj
身上o1
的引用以及obj2
对obj1
的引用直接delete
掉。此时此刻就说明了现在是没有办法直接通过什么样的方式来找到obj1
对象空间,那么在这里他就会被认为是一个垃圾的操作。最后JavaScript
引擎会去找到他,然后对其进行回收。
这里说的比较麻烦,简单来说就是当前在编写代码的时候会存在的一些对象引用的关系,可以从根的下边进行查找,按照引用关系终究能找到一些对象。但是如果找到这些对象路径被破坏掉或者说被回收了,那么这个时候是没有办法再找到他,就会把他视作是垃圾,最后就可以让垃圾回收机制把他回收掉。
4. GC算法介绍
GC
可以理解为垃圾回收机制的简写,GC
工作的时候可以找到内存当中的一些垃圾对象,然后对空间进行释放还可以进行回收,方便后续的代码继续使用这部分内存空间。至于什么样的东西在GC
里边可以被当做垃圾看待,在这里给出两种小的标准。
第一种从程序需求的角度来考虑,如果说某一个数据在使用完成之后上下文里边不再需要去用到他了就可以把他当做是垃圾来看待。
例如下面代码中的name
,当函数调用完成以后已经不再需要使用name
了,因此从需求的角度考虑,他应该被当做垃圾进行回收。至于到底有没有被回收现在先不做讨论。
function func() {
name = 'yd';
return `${
name} is a coder`
}
func()
第二种情况是当前程序运行过程中,变量能否被引用到的角度去考虑,例如下方代码依然是在函数内部放置一个name
,不过这次加上了一个声明变量的关键字。有了这个关键字以后,当函数调用结束后,在外部的空间中就不能再访问到这个name
了。所以找不到他的时候,其实也可以算作是一种垃圾。
function func() {
const name = 'yd';
return `${
name} is a coder`
}
func()
说完了GC
再来说一下GC
算法。我们已经知道GC
其实就是一种机制,它里面的垃圾回收器可以完成具体的回收工作,而工作的内容本质就是查找垃圾释放空间并且回收空间。在这个过程中就会有几个行为:查找空间,释放空间,回收空间。这样一系列的过程里面必然有不同的方式,GC
的算法可以理解为垃圾回收器在工作过程中所遵循的一些规则,好比一些数学计算公式。
常见的GC
算法有引用计数,可以通过一个数字来判断当前的这个对象是不是一个垃圾。标记清除,可以在GC
工作的时候给那些活动对象添加标记,以此判断它是否是垃圾。标记整理,与标记清除很类似,只不过在后续回收过程中,可以做出一些不一样的事情。分代回收,V8
中用到的回收机制。
5. 引用计数算法
引用计数算法的核心思想是在内部通过引用计数器来维护当前对象的引用数,从而判断该对象的引用数值是否为0
来决定他是不是一个垃圾对象。当这个数值为0
的时候GC
就开始工作,将其所在的对象空间进行回收和释放。
引用计数器的存在导致了引用计数在执行效率上可能与其它的GC
算法有所差别。
引用的数值发生改变是指某一个对象的引用关系发生改变的时候,这时引用计数器会主动的修改当前这个对象所对应的引用数值。例如代码里有一个对象空间,有一个变量名指向他,这个时候数值+1
,如果又多了一个对象还指向他那他再+1
,如果是减小的情况就-1
。当引用数字为0
的时候,GC
就会立即工作,将当前的对象空间进行回收。
通过简单的代码来说明一下引用关系发生改变的情况。首先定义几个简单的user变量,把他作为一个普通的对象,再定义一个数组变量,在数组的里存放几个对象中的age
属性值。再定义一个函数,在函数体内定义几个变量数值num1
和num2
,注意这里是没有const
的。在外层调用函数。
const user1 = {
age: 11};
const user2 = {
age: 22};
const user3 = {
age: 33};
const nameList = [user1.age, user2.age, user3.age,];
function fn() {
num1 = 1;
num2 = 2;
}
fn();
首先从全局的角度考虑会发现window
的下边是可以直接找到user1
,user2
,user3
以及nameList
,同时在fn
函数里面定义的num1
和num2
由于没有设置关键字,所以同样是被挂载在window
对象下的。这时候对这些变量而言他们的引用计数肯定都不是0
。
接着在函数内直接把num1
和num2
加上关键字的声明,就意味着当前这个num1
和num2
只能在作用域内起效果。所以,一旦函数调用执行结束之后,从外部全局的地方出发就不能找到num1
和num2
了,这个时候num1
和num2
身上的引用计数就会回到0
。此时此刻只要是0
的情况下,GC
就会立即开始工作,将num1
和num2
当做垃圾进行回收。也就是说这个时候函数执行完成以后内部所在的内存空间就会被回收掉。
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
。由于userList
,里面刚好都指向了上述三个对象空间,所以脚本即使执行完一遍以后user1
,user2
,user3
他里边的空间都还被人引用着。所以此时的引用计数器都不是0
,也就不会被当做垃圾进行回收。这就是引用计数算法实现过程中所遵循的基本原理。简单的总结就是靠着当前对象身上的引用计数的数值来判断是否为0
,从而决定他是不是一个垃圾对象。
1. 引用计数优缺点
引用计数算法的优点总结出两条。
第一是引用计数规则会在发现垃圾的时候立即进行回收,因为他可以根据当前引用数是否为0
来决定对象是不是垃圾。如果是就可以立即进行释放。
第二就是引用计数算法可以最大限度的减少程序的暂停,应用程序在执行的过程当中,必然会对内存进行消耗。当前执行平台的内存肯定是有上限的,所以内存肯定有占满的时候。由于引用计数算法是时刻监控着内存引用值为0
的对象,举一个极端的情况就是,当他发现内存即将爆满的时候,引用计数就会立马找到那些数值为0
的对象空间对其进行释放。这样就保证了当前内存是不会有占满的时候,也就是所谓的减少程序暂停的说法。
引用计数的缺点同样给出两条说明。
第一个就是引用计数算法没有办法将那些循环引用的对象进行空间回收的。通过代码片段演示一下,什么叫做循环引用的对象。
定义一个普通的函数fn
在函数体的内部定义两个变量,对象obj1
和obj2
,让obj1
下面有一个name
属性然后指向obj2
,让obj2
有一个属性指向obj1
。在函数最后的地方return
返回一个普通字符,当然这并没有什么实际的意义只是做一个测试。接着在最外层调用一下函数。
function fn() {
const obj1 = {
};
const obj2 = {
};
obj1.name = obj2;
obj2.name = obj1;
return 'yd is a coder';
}
那么接下来分析还是一样的道理,函数在执行结束以后,他内部所在的空间肯定需要有涉及到空间回收的情况。比如说obj1
和obj2
,因为在全局的地方其实已经不再去指向他了,所以这个时候他的引用计数应该是为0
的。
但是这个时候会有一个问题,在里边会发现,当GC
想要去把obj1
删除的时候,会发现obj2
有一个属性是指向obj1
的。换句话讲就是虽然按照之前的规则,全局的作用域下找不到obj1
和obj2
了,但是由于他们两者之间在作用域范围内明显还有着一个互相的指引关系。这种情况下他们身上的引用计数器数值并不是0
,GC
就没有办法将这两个空间进行回收。也就造成了内存空间的浪费,这就是所谓的对象之间的循环引用。这也是引用计数算法所面临到的一个问题。
第二个问题就是引用计数算法所消耗的时间会更大一些,因为当前的引用计数,需要维护一个数值的变化,在这种情况下要时刻的监控着当前对象的引用数值是否需要修改。对象数值的修改需要消耗时间,如果说内存里边有更多的对象需要修改,时间就会显得很大。所以相对于其他的GC
算法会觉得引用计数算法的时间开销会更大一些。
6. 标记清除算法
相比引用计数而言标记清除算法的原理更加简单,而且还能解决一些相应的问题。在V8
中被大量的使用到。
标记清除算法的核心思想就是将整个垃圾回收操作分成两个阶段,第一个阶段遍历所有对象然后找到活动对象进行标记。活动就像跟之前提到的可达对象是一个道理,第二个阶段仍然会遍历所有的对象,把没有标记的对象进行清除。需要注意的是在第二个阶段当中也会把第一个阶段设置的标记抹掉,便于GC
下次能够正常工作。这样一来就可以通过两次遍历行为把当前垃圾空间进行回收,最终再交给相应的空闲列表进行维护,后续的程序代码就可以使用了。
这就是标记清除算法的基本原理,其实就是两个操作,第一是标记,第二是清除。这里举例说明。
首先在全局global
声明A
,B
,C
三个可达对象,找到这三个可达对象之后,会发现他的下边还会有一些子引用,这也就是标记清除算法强大的地方。如果发现他的下边有孩子,甚至孩子下边还有孩子,这个时候他会用递归的方式继续寻找那些可达的对象,比如说D
,E
分别是A
和C
的子引用,也会被标记成可达的。
这里还有两个变量a1
和b1
,他们在函数内的局部作用域,局部作用域执行完成以后这个空间就被回收了。所以从global
链条下是找不到a1
和b1
的,这时候GC
机制就会认为他是一个垃圾对象,没有给他做标记,最终在GC
工作的时候就会把他们回收掉。
const A = {
};
function fn1() {
const D = 1;
A.