文章概述

我们都知道,随着软件开发行业的不断发展,性能优化已经是一个不可避免的话题, 那么什么样的行为才能算得上是性能优化呢?

本质上来说任何一种可以提高运行效率,降低运行开销的行为,我们都可以看做是一种优化操作。

这也就意味着,在软件开放行业必然存在着很多值得优化的地方,特别是在前端开发过程中,性能优化我们可以认为是无处不在的。例如请求资源时所用到的网络,以及数据的传输方式,再或者开放过程中所使用到的框架等他们都可以去进行优化。

而本阶段我们要探索的是JavaScript语言本身的优化,具体来说就是从认知内存空间的使用到垃圾回收的方式介绍,从而让我们可以编写出高效的javascript代码。

下面我们就来看一下在性能优化阶段,我们会涉及到哪些内容。

首先第一个部分我们会遇到内存管理,在这里我们首先会说明为什么我们的内存是需要管理的,以及内存管理的基本流程。

同时也会去介绍一些常见GC的算法,让你可以灵活的去应对一些所谓的大场面试。

在这之后还会介绍当前市面上非常流行的V8引擎,会去具体介绍V8引擎当中使用的是什么样的GC算法,在实现当前的垃圾回收。

当了解了内存的管理,以及垃圾回收的一些相关操作之后,我们就会通过谷歌浏览器所提供的一些工具,例如Performance来具体的演示一下,如何对内存进行监控,从而去发现我们当前的代码中是否存在着可以优化的性能空间。

最后去通过一些代码示例来作为演示,让我们可以具体的去完成一些代码层面上的优化操作。

内存管理

随着近些年硬件技术的不断发展,同时高级编程语言当中都自带了GC机制,所以这样一些变化就让我们在不需要特别注意内存空间使用的情况下,也能够正常的去完成相应的功能开发。

那么为什么在这里我们一定要去重提内存管理呢,下面我们就通过一段极简单的代码来进行说明。

首先我们在这样一个地方定义了一个普通的函数fn, 然后我们再函数体内去声明了一个数组,紧接着我们去给数组赋值,需要注意的是我们在赋值的时候刻意选择了一个比较大的数字来作为下标。

这样做的目的呢也就是为了当前这个函数在调用的时候可以像我们的内存尽可能多的申请一片比较大的空间。

function fn() {
    arrlist = []
    arrlist[100000] = 'this is a lg'
}

fn()

然后我们在执行这个函数过程中其实从语法上来说他是不存在任何问题的,不过当我们用相应的一个性能监控工具来在这个脚本执行过程中对他的内存进行监控的时候,我们会发现,那么他的内存变化是持续程线性升高的,那么在这个过程当中没有回落。

那么这代表着什么呢?非常简单,这就是一个内存泄露,至于说他如何泄露呢,我们现在不需要纠结。

我们只是想在这去声明,如果说我们在写代码的时候不够了解内存管理的一个机智。那么从而呢就会让我们去编写出一些不容易察觉到的内存问题性代码。

那么像这种代码多了以后呢那么给我们程序带来的可能就是一些意想不到的bug,所以掌握内存的管理,还是非常有必要的。

因此接下来呢我们就去看一下,什么是内存管理。

从我们当前这样一个词语本身来说呢,内存其实就是由可读写的单元组成,他就标识一片可操作的空间。

而管理呢,在这里我们想刻意强调的就是由人主动去操作这片空间的申请,使用和释放,即使我们借助了一些API,但终归来说我们可以自主的来做这样一个事情。

所以内存管理呢我们就认为是,开发者可以主动的向内存来申请空间,使用空间,并且呢去释放空间。

因此这个流程呢,也就显得非常简单了,一共呢就三步,申请,使用和释放。

那说完了这些以后,我们就回到我们的JavaScript当中,我们来看一下在JavaScript里面,他是如何来完成内存管理的。

其实和其他的语言是一样的,他也是分三步来执行这样一个过程,但是呢由于ECMAScript当中并没有提供相应的操作API。

所以JS预发不能像C或者C++那样,由开发者主动去调用相应的API来完成这样一个空间的管理。

不过即使如此他也不能影响我们去通过js脚本来演示当前在内部一个空间的生命周期是怎样完成的。

所以下边呢我们就回到编辑器当中,来通过一段简单的JS脚本演示一下我们当前这样一个内存空间的生命周期是什么样子的。

那这一块我们就先回到编辑器当中,在这里我们就通过一段简短的代码去把刚才我们所看到的流程在我们的JS当中呢去进行一个实现。

首先我们把这个顺序先简单的写一下,第一个我们要去申请空间,第二个我们要去使用空间,第三个我们要去释放空间。

那在我们JS当中由于我们并没有直接提供相应的API,所以我们只能是在JS执行引擎去遇到变量定义语句的时候自动分配给我们一个相应的空间。

所以这个步骤呢我们就相当于去定义一个变量,比如在这我们采用let定义一个obj,然后我们把它指向一个空对象。

然后紧接着对她的使用呢其实就是一个读写的操作,所以这个时候我们可以直接往我们这个对象里面写入一个具体的数据就可以了。我们写上一个yd。

完成这个操作以后我们最后可以对她进行释放,同样的我们js里面并没有相应的释放API,所以我们在这里可以采用一种间接的方式,比如我们去找到我们的obj然后直接去把他设置为null。

let obj = {}

obj.name = 'yd'

obj = null

那这个时候就相当于是我们按照内存管理的一个流程在js当中去实现了这样一个内存管理。

那这里我们就保存,看一下当前代码其实也没有任何的提示,后期我们会在这样一个性能监控工具当中去看一下这样的一个走势就可以了。

那这里就是关于js当中内存管理相应的一些内容介绍。

JavaScript中的垃圾回收

接下来我们来看一下JavaScript中的垃圾回收,首先我们来看一下在JavaScript当中什么样的内容会被当中是垃圾看待。在我们的后续的GC算法当中,他也会存在的一个垃圾的概念,两者呢其实是完全一样的。所以在这里我们就统一的说明。

首先对于我们前端开发来说呢JavaScript当中的内存管理是自动的。每当我们去创建一个对象、数组或者函数的时候呢,他就会自动的去分配相应的内存空间。

然后后续程序代码在执行的过程中如果通过一些引用关系无法再找到某些对象的时候那么这些个对象就会被看作是垃圾。

那再或者说我们这些对象其实已经存在的,但是由于我们代码当中一些不合适的语法或者说结构性的错误,让我们没有办法再去找到这样的一个对象。那么这种对象也会被称之是垃圾。

那知道了什么是垃圾之后这个JavaScript执行引擎就会出来工作,然后把他们所占据的对象空间进行回收,那这个过程呢就是我们所谓的JavaScript垃圾回收。

说完这个以后呢我们在这里用到了几个小的概念,第一是引用,第二是从根上访问,而这个操作呢在后续的GC里面也会被频繁的使用到。

所以说我们在这再去说一个名词叫可达对象,首先在JavaScript当中可达对象理解起来非常的容易,就是我们能访问到的对象就是可达。

那至于说怎么访问呢,我们可以通过具体的引用也可以在当前的上下文当中去通过作用域链来进行查找。

只要我们能找得到,我们就认为呢是可达的。不过这里边会有一个小的标准限制就是我们一定要是从根上出发找得到才认为他是可达的。

所以说在这我们又要去讨论一下什么是根呢,在JavaScript里面我们可以认为当前的全局变量对象就是我们的根,也就是我们所谓的全局执行上下文。

那说完了这些以后我们简单的总结一下就是JavaScript当中的垃圾回收其实就是找到垃圾,然后让JavaScript的执行引擎来进行一个空间的释放和回收。

那么在这里我们用到了引用和可达对象所以接下来我们就尽可能的去通过代码的方式来看一下,在js中的引用与可达是怎么体现的。

那么说完这些以后我们就先回到代码编辑器当中,在这里我们通过一些代码片段的演示来说明一下我们当前的引用和可达他们的具体操作。

在这我们首先定义一个变量,为了后续我们可以修改值我们采用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还在引用着这样的一个对象空间,所以说他依然是一个可达对象。

那这块就是一个引用的主要说明,顺带呢我们也看到了一个可达。

那么接下来我们再去举一个示例,来说明一下我们当前js当中的可达操作,不过这里面我们需要提前说明一下。

为了方便我们后面的演示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);

我们运行看一下打印出来额是什么, 我们可以发现得到了一个对象

{
    o1: {name: 'obj1', next: {name: 'obj2', prev: [Circular]}},
    o2: {name: 'obj2', next: {name: 'obj1', prev: [Circular]}}
}

那么这个对象里面分别有obj1和obj2,而obj1和obj2他们内部又各自通过一个属性去指向了彼此。

我们来分析一下我们编写的代码,首先从全局的根去出发,我们这块是可以找到一个可达的对象obj,那么他是通过一个函数调用之后指向了一个内存空间,他的里面就是我们上面看到的o1和o2。

然后在o1和o2的里面呢我们刚好又通过相应的属性去指向了一个obj1的空间和obj2的空间。

对于obj1和obj2来说呢,他俩之间又通过next和prev做了一个互相的一个引用,所以现在这样一个过程来说我们代码里面所出现的那些对象其实都可以从根上来进行查找。

不论我们找起来是多么的麻烦,总之来说呢这块我们都能够找得到的,那么继续往下来呢我们再来做一些分析。

如果说我们现在代码里面去做一件事情,现在我们去通过一个delete语句把我们obj身上这样一个o1的引用以及我们obj2对我们当前obj1的引用呢都给他直接delete掉。

那此时此刻就说明了我们现在是没有办法再直接通过什么样的方式来找到obj1这样的一个对象空间的,也就是说他会变成我们直接把所有能找到obj1的路径都给删除了,那么在这里他就会被认为是一个垃圾的操作。

那么最后,我们的js引擎呢就会去找到他,然后对其进行回收。

我们这里说的比较麻烦,那么简单来说就是我们当前在编写代码的时候会存在的一些对象引用的关系,然后我们可以从根的下边呢,来进行查找,按照引用关系我们终究能找到一些对象。

但是如果说我们去找到这些对象的一些路径呢,被破坏掉或者说被回收了,那么这个时候我们是没有办法再找到他,就会把他视作是垃圾,最后我们就可以呢,让垃圾回收机制呢,去把他回收掉。

那,关于这块呢,就是我们当前,js当中的垃圾回收以及引用和对象可达相关的一些介绍内容。

GC算法介绍

在这呢,我们去介绍一下GC算法相关的一些内容。

首先呢我们在说算法之前先把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算法的一个定义。

知道了什么是GC算法之后那么我们就来说一些常见的GC算法名称。在这里面呢我们会去介绍到这样几个GC算法。

第一,引用计数,可以呢通过一个数字来判断当前的这样一个对象是不是一个垃圾,后续呢我们会讲到。

第二,标记清除,可以呢在GC工作的时候去给到那些个活动对象呢添加上一个标记,来判断他是否是一个垃圾。

第三,标记整理,与标记清除呢其实很类似,只不过呢在我们后续回收过程中,他可以去做出一些不一样的事情,具体呢,我们后续会说。

最后一个,分代回收,将来在V8当中呢我们会用到这样一个回收机制。

那这块就是关于我们GC算法所给出的一些内容介绍。

引用计数算法实现原理

接下来我们去看一下关于引用计数算法实现原理相关的一些内容介绍。

首先针对于引用计数算法来说,他的核心思想其实呢就是在内部去通过一个引用计数器来维护当前对象的引用数,从而呢去判断该对象的引用数值呢是否为0,来决定他是不是一个垃圾对象。

当这个数值呢为0的时候,那么GC呢就开始工作,将其所在的一个对象空间呢进行回收和释放再使用。

那么在这里呢,我们提到了一个名词。叫引用计数器,关于他呢我们需要有一个小小的印象。

因为相对于其他的GC算法来说,也正是由于引用计数器的存在那么导致了引用计数呢在执行效率上可能与其它的GC算法呢有所差别。

那么这个说完以后呢我们还需要再思考一下,我们引用的这样一个数值什么时候会发生改变呢?所以在这里呢,他给出的一个规则是这样的。

当,某一个对象他的引用关系去发生改变的时候,那么引用计数器呢就会主动的去修改当前这个对象所对应的引用数值。

那什么叫做引用关系发生改变呢?例如说我们的代码里面现在有一个对象空间,目前来说呢有一个变量名指向他,那么这个时候就把数值+1,那么如果说在这个时候又多了一个对象还指向他那么我们就把他再+1,那么如果是减小的情况下呢我们就-1就行了。

当我们发现这样一个引用数字为0的时候,那么GC呢就会立即工作,然后将当前的对象空间呢进行回收。

那么说完这样的一块原理之后呢,我们再通过一些简单的代码来说明一下这个引用关系发生改变的一种情况。

首先在这里我们定义几个简单的user变量,我们采用的是const关键字,然后我们去设置一下,把他呢作为一个普通的对象,那这里边呢,我们采用的是数值写起来呢比较方便。

完成这些操作以后呢我们再去定义一个变量,然后这块呢我们给到的是一个数组,然后在数组的里边呢我们去存放一下上述几个对象当中的age属性值, 所里这里边我们就变成了user1.age, user1.age, user1.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呢他只能在,这样的一个作用域内去起效果。

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();

所以,一旦当我们的函数调用执行结束之后,那我们从外部全局的地方去出发就不能够再找到num1和num2了,那么这个时候呢,num1和num2他们身上的一个引用计数呢就会回到0。

此时此刻呢, 只要是0的情况下,GC呢就会立即开始工作,将num1和num2呢当做垃圾去进行一个对象回收。也就是说这个时候函数执行完成以后他们内部所在的一个内存空间就会被回收掉。

那么紧接着我们再来看一下其他的,比如说user1,user2,user3以及呢nameList。

由于在这个地方呢,我们的userList,他的里面呢刚好都指向了我们上述三个对象空间,所以,当我们脚本即使执行完一遍以后他回头一看user1,user2,user3他们里边的空间呢其实都还被人引用着。

所以此时的引用计数器呢就不是0,那么这个时候就不会被当做垃圾呢去进行回收。

那这块呢就是关于我们的引用计数这样一个算法在实现过程中他所遵循的一些基本原理。

简单的总结一下呢其实就是靠着我们当前对象身上的一个引用计数的数值来判断是否为0,从而呢来决定他是不是一个垃圾对象,那这地方我们就说完了。

引用计数优缺点

在这里呢我们来看一下关于引用计数算法的优缺点介绍。

首先呢我们来看一下引用计数算法的优点,这里我们总结出两条,第一就是引用计数这样一个规则呢会在发现垃圾的时候立即进行回收,因为他可以根据当前这样一个引用数是否为0来决定这个对象呢是不是一个垃圾。如果她找到了,那么这个时候呢就可以立即进行释放。

第二呢就是引用计数算法可以最大限度的去减少程序的一个暂停,这句话的意思是怎么解释呢?

我们可以这样取简单的描述一下,我们的应用程序在执行的过程当中,必然呢会对内存进行消耗。而我们当前的执行平台他的内存肯定是有上限的,所以内存肯定有占满的时候。

不过由于引用计数算法呢他是时刻监控着内存一些引用值为0的对象,所以我们就可以认为,举一个极端的情况就是,当他发现这个内存即将爆满的时候,那么引用计数呢就会立马去找到那些个数值为0的对象空间然后对其呢进行释放。

所以这样呢就保证了我们当前这样一个内存呢是不会有占满的时候,这也就是所谓的减少我们程序暂停的一个说法。

然后我们去看一下引用计数的一些缺点,这里同样呢我们是给出了两条说明。

第一个呢就是引用计数算法呢是没有办法将那些循环引用的对象进行空间回收的,这个呢我们会以具体的代码来演示一下。

第二个呢就是引用计数算法呢他所消耗的时间呢会更大一些,这块是为什么呢?

因为我们当前的引用计数,他需要去维护一个数值的变化,所以在这种情况下他要时刻的去监控着当前对象的一个引用数值是否呢需要修改。

那么本身来说我们这个对象他的一个数值的修改就需要消耗时间,那如果说我们这个内存里边有更多的对象需要修改,那么这个时间呢就会显得更大。

所以说这块呢是相对于其他的GC算法来说,我们会觉得引用计数算法他的一个时间开销会更大一些。

那么这块就死关于引用计数优缺点的一个简单说明,那么接下来我们就去回到代码当中,利用一个小的代码片段来演示一下,什么叫做循环引用的对象。

那这里我们去定义一个普通的函数,我们直接呢还是使用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算法呢他的原理实现更加简单,而且还能解决一些相应的问题。

那么在后续的V8当中他会被大量的使用到,所以在这呢我们就先去看一下标记清除算法的实现原理。

对于标记清除算法来说,他的核心思想就是,将整个垃圾回收操作呢,分成两个阶段,第一个阶段呢他会去遍历所有的对象然后找到这些活动对象,然后进行标记的操作。

那这里的活动就像呢就跟我们之前所提到的可达对象是一个道理,第一个操作完成以后,接下来呢会进入到第二个阶段,在第二个阶段里呢仍然是会去遍历所有的对象,然后把那些个身上没有标记的对象呢进行清除。

同时需要注意的就是,在第二个阶段当中,他也会把第一个阶段所设置的标记呢给抹掉,便于我们GC呢,下次还能够去正常的工作。

那这样一来的话呢,他就可以去通过两次的遍历行为把我们当前这样一个垃圾空间去进行回收,然后最终呢再交给我们相应的这样一个空闲列表进行维护,后续我们的程序代码呢就可以实现使用了。

所以,这就是我们当前标记清除算法的一个基本原理,其实呢就是两个操作,第一就是标记,第二就是做清除。

那么为了方便这样一个理解,我们这里进行一个举例说明。

首先我们先简单的去声明一下,在全局global这个地方我们可以去找到A,B,C这样的三个可达对象,那么找到这三个可达对象之后,我们会发现他的下边还会有一些子引用,所以这就是标记清除算法强大的地方。

如果我们发现他的下边会有孩子,甚至于说孩子下边还有孩子,那么这个时候他会去用递归的方式继续呢去寻找那些可达的对象,比如说D,E分别是A和C的子引用,也会被进行一个可达的标记。

那么这个时候我们还有两个变量a1和b1,他们是在函数内部的局部作用域,而我们当前这样一个局部作用域执行完成以后呢这样一个空间就被回收了。

const A = {};

function fn1() {
    const D = 1;
    A.D = D;
}

fn1();

const B;

const C = {};

function fn2() {
    const E = 2;
    A.E = E;
}

fn2();

function fn3() {
    const a1 = 3;
    const b1 = 4;
}

fn3();

所以从我们当前global这样一个链条下呢,我们是找不到a1和b1的,那么这个时候我们的GC机制呢,就会认为他是一个垃圾对象,没有去给他做标记,那么最终呢,在我们GC去工作的时候就会找到a1和b1然后直接把他们回收掉。

那这块就是标记清除所谓的标记阶段和清除阶段,要做的事情。简单的整理一下就是分成两个步骤。

在第一个阶段当中,我们要去找到所有可达对象,如果说在这里呢涉及到了我们这样的一个引用的一个层次关系,那么他会递归的进行查找,就像我们的global找A再找D这样的一个过程。

那么找完以后他会将这些可达对象呢都进行标记。那么标记完成以后呢会进行第二个阶段,然后呢开始去做清除,找到那些没有去做标记的对象,同时呢还会将我们之前第一次呢所做的标记呢,也给他清除掉。

那这样我们就完成了一次垃圾的回收,同事呢我们还要留意,最终呢他还会去把回收的空间呢直接放在我们当前的一个叫做空闲列表上面。方便我们后续的程序呢可以直接在这呢去申请空间使用。

那这块就是关于标记清除算法的实现原理。

标记清除算法优缺点

在这里呢我们来看一下关于标记清除算法优缺点的介绍。

那么作为一个GC算法的出现,他依然是做不到十全十美的,所以跟我们的引用计数一样,他也存在着自己的一些优势和缺点的地方。

首先,我们来看一下相对于引用计数来说标记清除具有一个最大的优点,就是他可以去解决我们之前对象循环引用的一个回收操作。

简单说明一下,我们在写代码的时候可能会直接在全局的地方定义A,B,C这样的一个可达对象,但是呢我们也会去有一些,函数的一个局部作用域,比如我们当前在一个函数内定义了a1和b1,而且让他们呢互相引用。

const A = {};

function fn1() {
    const D = 1;
    A.D = D;
}

fn1();

const B;

const C = {};

function fn2() {
    const E = 2;
    A.E = E;
}

fn2();

function fn3() {
    const a1 = 3;
    const b1 = 4;
}

fn3();

那么对于这种函数的调用呢在结束之后必然呢要去释放他们内部的空间,所以在这种情况下,一旦当某一个函数调用结束之后呢,他局部空间中的变量就失去了与我们当前全局global在作用域上的一个链接。

所以这个时候a1和b1呢,在我们全局的global根下边就没有办法再访问到了,所以这个时候呢,他就是一个不可达的对象。

那么不可达对象在做标记阶段的时候就不能够完成标记,那么接下来在第二个阶段,我们当前去回收的时候,就直接找到这些没有标记的对象,把他们内部的空间进行释放。

这是标记清除呢可以做到的事情,但是在我们的引用计数里面,虽然我们当前这个函数调用结束以后,他呢也没有办法在全局的地方呢去进行访问。

可是呢,由于我们当前判断的标准是引用数字是否为0,所以在这种情况下,他就没有办法去释放a1和b1的空间,这就是我们当前标记清除算法的一个最大优点。就是相对于我们当前的引用计数算法来说的。

那同时呢我们的标记清除算法也会有他自己的一些缺点。这个地方我们简单举例来进行说明。

比如我们模拟一个内存的存储情况,我们当前从根呢去进行查找,在下方呢他有一个直接的可达对象,我们认为这是A对象, 然后紧接着他左右两侧呢有一个从跟下无法直接查找的一个区域,例如左侧我们称之为B,右侧呢我们称之为C。

那这种情况下在进行第二轮清除操作的时候,他就会直接将我们当前的这样一个B和C所对应的空间呢,进行回收。

function fn() {
    const B = '两个';
}
fn();

const A = '四个文字';

function fn2() {
    const C = '一个';
}
fn2();

然后再把这样一个释放的空间呢,去添加到我们的空闲列表之上,然后紧接着我们后续的程序呢就可以直接进来再从空闲列表上呢去申请相应的一个空间地址,进行使用。

不过,在这种情况下呢就会有一个问题,例如说,在这呢我们举例说明一下。

比如我们当前认为,任何一个空间呢都会有两个部分组成,一个呢是存储这个空间的一些元信息的比如他的一个大小,比如他的一个地址,我们称之为头。

再或者呢还有一部分是专门用于存放数据的,我们叫做域,那么这样说以后呢,我们B,C这样一个空间呢我们认为B对象呢有2个字的空间,而C对象呢有1个字的空间。

那么在这种情况下,虽然我们对他进行了回收,加起来呢好像是释放了3个字的空间,但是由于它们中间呢被我们这样一个A对象去分割着。所以在释放完成之后他们其实还是分散的,也就是地址不连续。

这点呢很重要,地址不连续,所以在这种情况下,如果后续我们想去申请一片空间,而刚好巧了,这次我们想申请的空间地址大小呢刚好是1.5个字。

那么这种情况下,如果说我们去直接找到B所释放的空间,我们会发现呢,他是多了的,因为还多了0.5个,但是如果呢我们直接去找C所释放的空间我们又发现呢她又不够,因为他是1个。

所以这个时候呢我们就造成了一个当前标记清除算法中最大的问题,叫空间的碎片化,那这里我们去简单的解释一下。

所谓的空间碎片化,就是由于我们当前所回收的这个样一个垃圾对象在地址上他本身是,不连续的,由于这种不连续,从而造成了我们在回收之后他们分散在各个角落,那后续我们要想去使用的时候,如果刚好巧了新的生成空间刚好与他们的大小匹配,那么这时候就能直接用。一旦是多了或是少了,我们就不太适合使用了。

所以这就是我们标记清除算法中的一个缺点,那么我们称之为叫做空间碎片化。

那这块呢就是关于我们标记清除算法优点和缺点的介绍,简单的整理一下就是,第一:优点呢相对于引用计数来说,我们可以去解决循环引用不能回收的问题,那么缺点呢就是相对于我们之前的一个垃圾回收来说呢他会产生一个空间碎片化的问题,不能让我们的空间呢得到最大化的使用。

那这块呢我们就介绍完了。

标记整理算法实现原理

在这里呢,我们来介绍一下标记整理算法的实现原理,和我们之前的标记清除一样,在V8当中呢,这个算法也会被频繁的使用到,那么下面我们就来看一下,标记整理算法是如何实现的。

首先,我们会认为,标记整理算法,其实就是标记清除的一个增强操作,因为他们在第一个阶段的标记工作是完全一样的,都会去遍历所有的对象,然后将当前的可达活动对象呢进行标记,只不过呢是在清除阶段,我们的标记清除是直接呢将没有标记的垃圾对象呢做空间的回收。

但是,标记整理呢会在清除之前先去执行一个整理操作,移动对象的位置,去让他们呢能够在地址上产生连续。

那这块呢为了去理解这样一个过程,我们进行一个简单的说明。

假设我们回收之前我们有很多的活动对象和非活动对象,以及一些空闲的空间,那么当他去执行我们当前标记操作的时候,会把我们所有的活动对象呢来进行标记,那么紧接着他就会进行一个整理的操作。

那么整理的是什么呢,其实就是一个位置上的改变,他会把我们当前的活动对象呢,先进性移动,然后在地址上变成连续的一个位置。

那么紧接着他就会去将当前活动对象右侧的一个范围呢去进行整体的回收。回收完成以后我们就会得到这样的一种情况,那这种情况去相对我们之前的标记清除算法来说,他的好处就会显而易见。

因为我们现在呢在内存里边就不会大批量去出现那些分散的小空间,而回收到的空间呢都基本上是连续的。

那么在后续的使用过程中,如果我们要想去申请的时候,就可以尽可能的去最大化利用我们当前内存当中所释放出来的空间。

那这个过程也就是我们的标记整理算法,那么跟我们之前所提到的一样,他会配合着我们的标记清除,在我们的V8引擎当中呢。去配合着实现频繁的GC操作。

那这里就是标记整理算法的一个基本原理介绍。

常见GC算法总结

在这里呢,我们来看一些关于常见GC算法的内容总结,首先在之前呢我们已经是见到过这样的几种算法。

第一呢,就是引用计数,他的核心思想呢就是在内部去通过一个引用计数器呢来维护每个对象呢都存在的一个引用数值,通过这个数值呢是否为0,来判断这个对象呢是否是一个垃圾对象。从而呢去回收他的一个垃圾空间,从而呢,让垃圾回收器对当前的一个空间呢进行回收释放。

第二种呢就是标记清除,那么这种算法呢,分两个阶段呢来进行,首先他会去遍历所有的对象,然后去给当前的活动对象进行标记,然后紧接着就会去把那些没有标记的对象呢去清除掉,从而去释放当前这些垃圾对象所占用的空间。

第三种呢就是标记整理,那么他的做法和其实和我们的标记清除呢类似,只不过呢在清除上有一些前置的操作,需要先去整理一下当前的地址空间。

那么接下来呢我们再分别去看一下,这些算法所各自具有的一些优缺点。

首先我们来看一下引用计数,对于他来讲他的一个优点就是可以及时回收我们的垃圾对象,因为只要这个数值为0的情况下,他就会立即去让我们的GC呢找到这样的一片空间呢进行回收和释放。

所以也正是由于这样的一个特点的存在,那么我们引用计数的另外一个优点就是,可以最大限度的去减少程序的一个卡顿,因为只要这个空间即将被占满的时候,我们的垃圾回收器呢就会进行工作,然后呢将这个内存呢去进行释放,让我们的内存空间呢总是有一些可用的地方。

那再者呢引用计数也会有一些缺点,例如他无法去回收存在着循环引用的对象,因为这样的情况意味着当前对象空间的一个引用数字呢永远是不为0的。也就是不能触发我们当前的垃圾回收操作。

除此以外他还有一个缺点就是对资源的消耗是比较大的,因为他这块呢有一个引用计数器,然后每次呢还都要去修改当前这个引用数,而这个对象空间的引用数呢有可能是很大也有可能是很小,总之呢频繁的操作会有一些资源上的开销。

因此我们认为呢他的速度就不一定是那么快了,那这块就是关于引用计数的一个基本介绍。

那么接下来我们再去看一下标记清除的优缺点,对于标记清除来说呢,他的一个好处相对于引用计数来讲就是可以回收循环引用的对象空间,那这块呢是引用计数所做不到的。

除此以外呢我们这个标记清除还有他自己的一些缺点,比如说,他呢,由于当前算法决定了他不能够去把自己所有的这样一个空间去最大化利用。所以很容易呢就产生了这种碎片化的操作。

因此这块呢是标记清除的一个小缺点,那除此以外对于标记清除来说呢,他也不能够去立即回收垃圾对象,也就是说即使在遍历的过程中他发现了,这样一个对象是不可达的,但是呢他也要等着最后呢才去清除。而且,他去清除的时候当前的程序呢其实是停止工作的,所以这也是标记清除相对来说的一个缺点。

那么最后我们再来看一下标记整理,那么他其实跟我们的标记清除其实非常类似,所以他一个优点就是可以解决我们之前标记清除的一个空间碎片化,因为他会有一个整理地址的操作在里边然后才能去做一个清除的操作。

同样,标记整理呢和我们之前的标记清除是一样的,也不能够去立即回收我们的垃圾对象,所以相对于引用计数来说呢这块也相对是一个缺点。

那么这里呢我们就完成了常见GC算法的一个整理操作,具体描述了他们的核心原理以及呢他们各自的一个优缺点,那这块呢我们就说完了。

认识V8引擎

在这里我们先对引擎去做一些介绍和说明。

众所周知引擎是目前市面上最主流的,JavaScript执行引擎,我们日常所使用的chrome浏览器以及目前的NodeJavaScript平台呢都在采用这样的一个引擎去执行我们的JavaScript代码。

那么对于这两个平台来说,JavaScript之所以能在他们的上边去高效的运转,那么也正是因为这样一个幕后英雄的存在。

那这里呢就提到了JavaScript的一个高效的去转转,那么这也是我们V8的一个最大卖点, 那么这个速度之所以快,除了他的背后有一套优秀的内存管理机制之外呢,其实V8还有一个特点,就是采用及时编译。

那么之前很多的JavaScript引擎呢都需要将我们源代码先去转成我们的字节码,然后呢才能去执行,而对于V8来说呢就可以直接将源码呢给翻译成我们当前可以直接执行的机器码。

所以这个时候的速度呢是非常快的,那么接下来对于V8来讲,还有一个比较大的特点就是V8他的内存呢是设有上限的,那么这块我们先简单的说明一下。

对于V8来说呢他的内存空间去设置了一个数值,那么在64位的操作系统下,这个上限呢是不超过1.5G的,然后对于我们32位的操作系统来说呢,这个数值是不超过800M的。

那么为什么V8要采用这样的一个做法呢,原因基本上来说呢可以从两方面来进行总结。

第一呢V8本身呢就是为了浏览器而去制造的,所以,现有的这个一个内存大小对于网页应用来说呢,是足够使用了。

再有呢,V8内部所去实现的一个垃圾回收机制呢也决定了他采用这样的一个设置是非常合理的。

因为官方呢去做过这样的一个测试,当我们的垃圾内存呢去达到1.5个G的时候如果V8去采用增量标记的算法进行垃圾回收只需要消耗50ms。而如果采用非增量标记的形式去回收呢则需要1s。

那么偶从用户体验的角度来说呢,1s其实呢已经算是很长的时间了,所以在这里呢他就以1.5G为界了,那么对于V8内部的内存呢进行了一个上限的设置。

那这块呢就是针对于V8呢先做的一些简单的介绍,简单的总结一下就是,第一我们知道他是当前一个主流的JavaScript执行引擎,第二呢他的速度很快,因为他采用的是即时编译,第三呢,他的内部内存是有上限的,在64位操作系统下呢一般是不超过1.5G, 在32位的操作系统下呢是不超过800M的。

V8垃圾回收策略

在这里呢我们对于V8垃圾回收的策略呢进行一些介绍和说明,在这之前呢我们先做一些前置的描述。

我们都知道,在程序的使用过程中,那么我们会用到很多的数据,而这些数据呢我们又可以分为原始的数据和对象类型的数据。

那么对于这些基础的原始数据来说呢,他都是由程序的语言自身来进行控制的。所以在这里我们所提到的回收,那主要呢还是指的是当前存货在我们堆区里的对象数据。

因此,这个过程呢我们是离不开内存操作的,而我们当前也知道了在V8当中呢他对内存是做了上限的,所以这样的话,我们就想知道他是怎么样在这种情况下来对我们垃圾进行回收的。

那么这里呢我们就来具体的看一下,首先,对于V8来说呢他采用的是分代回收的一个思想,具体来说如何实现呢。

主要就是把我们当前的内存空间呢去按照一定的规则分成两类,一个呢就叫做新生代存储区,还有一个呢叫老生代存储区。

至于说如何去划分呢我们在后续会去介绍到,那有了这样一个分类之后,接下来他就会去针对于不同代然后呢去采用最高效的一种GC算法,从而呢去对不同的对象进行一个回收的操作。

那这里我们把这个过程简单的说明一下,首先在这里呢我们有一个V8的内存空间,然后他会去一分为二,一个呢用于存储新生代对象,一个呢用于存储老生代对象。

那紧接着我们再去针对于不同对象里边所存放的这样一些数据去采用具体的GC算法,所以这也就意味着,对于我们的V8回收来说呢,他会去使用到更多的GC算法。

因此在这里呢我们就先提前去做一些,整理和说明。

首先,对于我们的V8来说,分代回收这样的一个算法他肯定是要用到的,因为他必须要去做分代。

那紧接着呢他会去用到空间的复制算法,具体来说如何去做呢,我们后续进行介绍。

除此以外他还会用到我们之前所提到的标记清除和标记整理。

最后呢,为了去提高效率,所以在这里他们又用到了标记增量。

那么这个部分呢,就是关于V8当中的垃圾回收策略是如何执行的,我们在这呢做了一个描述,简单的整理一下就是,我们要记住V8的内存呢是有上限的,基于这样的一个条件,我们需要采用分代回收的思路,然后不同代的对象呢我们就再去采用更适合的一个GC算法, 从而呢去实现V8的一个搞笑垃圾回收的一个操作,那这里呢我们就说完了。

V8如何回收新生代对象

在这里我们来看一下V8当中是如何完成新生代存储区当中的垃圾回收操作。

首先在这个地方我们还是要去看一下V8内部的一个内存分配。因为他是基于分代的垃圾回收思想,所以在我们V8的内部是把内存空间分成了两个部分,我们内部的一个存储区域被分成了左右两个区域。

那么左侧的这样一个空间呢就是专门用来存放我们的新生代对象,而右侧呢就是专门用于存放我们的老生代对象。

那么在这里呢我们当前只是关注一下,新生代存储区的垃圾回收操作。所以在这我们先做一些文字上的说明。

跟刚才讲到的一样,我们当前的V8内部是把空间分成了两部分,而左侧呢专门去用于存储新生代对象,这样的一个空间呢是有一定设置的,那么在我们的64位操作系统当中他的大小呢是32M, 在32位的操作系统当中呢,就是16M。

那么有了这样一个空间之后,那么在他里边我们就可以存放相应的新生代对象,而这里的新生代对象呢,其实指的就是存活时间较短的,那么如何来界定存活时间较短呢?

举个例子,比如说我们在当前的这样一段代码内,有个局部的作用域,那么这个局部作用域当中的变量呢,在执行完成过后就肯定要去回收,而我们在其他的地方比如全局的地方呢也可能会有一个变量,而全局下方的这个变量呢他肯定要等到我们的程序退出之后才会被回收。

所以相对来说我们的新生代就指的是那些存活时间比较短的那样一些变量对象。

那么说完这块以后我们就具体的来看一下,在我们当前的V8当中是如何完成新生代对象回收的。

那么这块呢我们过程当中所采用到的算法呢主要就是复制算法和标记整理的算法操作。

首先他会去将我们当前左侧一部分小空间呢也去分成两个部分,叫做From和To,而且这两个部分的大小呢是相等的。

其中我们会去将From空间呢称之为叫做使用状态,然后To空间呢叫做空闲状态。

那么有了这样两个空间之后呢代码再执行的时候如果需要去申请空间来进行使用,首先呢他会将所有的变量对象呢都分配至From空间。

也就是说在这个过程当中,其实To呢是空闲着没有使用的,那么一旦当我们的From空间应用到一定的程度之后,那么就要去触发我们的GC操作。

所以这个时候呢他就会采用标记整理的操作,来对我们From空间进行一个活动对象的标记,那么找到这样一些活动对象之后,那么他继续呢去使用整理的操作,再去把他们的位置呢变得连续,也便于后续呢不会产生碎片化的空间。

那么昨晚这些操作以后,他会将这样的活动对象拷贝至我们的To空间,那么拷贝完成以后呢,就意味着我们之前From空间当中的活动对象就有了一个备份,所以这个时候呢,我们就可以考虑去做回收操作了。

至于说如何去回收呢,非常简单,我们只需要去把From的空间进行一个完全的释放就可以了,因为form里的对象在我们的To里边都有所体现。

所以这个时候我们吧From直接给他释放掉,不存在任何问题,那么这个过程也就完成了我们新生代对象的一个回收操作。

在这我们简答的总结一下就是,新生代对象的存储区域其实也被一分二位,而且是两个等大小的,在这两个等大小的空间中,我们起名From和To,那么当前我们使用的呢是From,所有的对象声明都会放在这样一个空间内。

然后触发GC机制的时候,我们就会去把这样的一个活动对象全部找到,然后进行整理,然后拷贝到我们的To空间当中。

拷贝完成以后我们再让这个From和To去进行空间的一个交换(也就是名字的交换),那么原来的To就变成了From,而原来的From就变成了To。

那这样一来我们就算是完成了这样一个空间的释放和回收操作。

那么接下来在这里边呢我们还要针对于这样的一个过程它里边的一些细节呢进行说明。

首先呢,在这个过程中我们肯定会想到的一个现象就是,如果我们在拷贝时,发现某一个变量对象所指用的空间呢,他在我们当前的老生代对象里面也会出现。

这个时候呢我们就会出现一个所谓的叫晋升的操作,那这里的晋升呢,其实指的就是,将新生代的一个对象,移动至我们的老生代进行存储。

那么至于说,什么时候我们能够去,触发这样的一个晋升操作呢?

在这我们一般呢是有两个判断标准的,第一个就是呢,如果我们新生代当中的某些对象经过一轮GC之后呢,他还活着。所以这个时候我们就可以呢把他拷贝至我们的老年代存储区,进行一个存储操作。

除此之外呢,如果说我们当前在拷贝的一个过程中,发现呢这个To空间的使用率超过了25%,那么这个时候我们也需要将这一次的活动对象呢,都移动至老生代中进行存放。

那这个时候我们为什么要选择这样一个25%呢?其实也很容易想得通,因为我们再将来进行回收操作的时候,最终是要把From空间和我们的To空间呢进行一个交换。

也就是说以前的To会变成From,而以前的From呢要变成To。那么这就意味着,我们的To如果使用率达到了80%,那么最终他变成活动对象的一个存储空间后,那么新的对象好像就存不进去了。

这点呢可能有些绕,再简单的说明下就是To空间的使用率如果超过了一定的限制,那么将来他变成使用状态时,那么我们新进来的这样一些对象空间,那么好像就不是那么够用了,所以在这里呢我们会有这样一个限制操作。

那么这一块呢,我们就说完了,在V8当中是如何完成新生代存储区的一个垃圾回收的,简单总结一下就是,当前我们的内存一分为二一部分用来存储新生代对象,至于说什么是新生代对象呢,我们就可以认为他的存活时间呢,相对较短。

然后我们当前就可以去采用标记整理的算法,去对我们当前的From空间进行一个活动对象的标记和整理操作,然后接着去把他们呢拷贝至我们当前的To空间。

最后呢再置换一下两个空间的状态,那此时呢我们也就完成了一个空间的释放操作,那这些就是关于,V8当中如何针对于新生代对象进行一个垃圾回收的操作。

V8如何回收老生代对象

在这里呢我们来看一下,V8是如何回收老生代对象区域的一些垃圾的,那么这里我们首先呢还是对老生代这样一个空间进行一些说明。

首先,老生代对象呢是存放在内存空间的右侧,针对于老生代区域呢,在V8当中他同样是有一个内存大小的限制,在64位操作系统当中呢这个大小是1.4G, 在32位操作系统当中是700M。

那么有了这样一个大小之后,我们就可以在里面去存放具体的数据了。

老生代对象指的就是当前存活时间较长的对象,例如我们之前所提到的在全局对象下所存放的一些变量。再或者就是一些闭包里面所放置的一些变量数据,有可能也会存活很长的时间。

我们接下来看一下老生代是如何完成垃圾回收的,针对于老生代垃圾回收主要采用的是标记清除,标记整理和增量标记三个算法。

具体使用时他主要采用的是标记清除算法去完成对应的垃圾空间的释放和回收,之前我们已经说过标记清除算法的工作原理,这里我们就不再具体说明。主要就是找到我们老生代存储区域当中的所有活动对象进行标记,然后直接释放掉那些垃圾数据空间,就可以了。

显而易见呢这个地方他就会存在一些空间碎片化上的一些问题,不过虽然有这样一些问题存在,但是在V8的底层主要使用的还是标记清除的算法。因为相对于那些空间碎片来说他的提升速度是非常明显的。

在什么情况下我们会去使用到标记整理算法呢?如果说我们发现,当他需要把我们新生代区域里面的内容向老生代中移动的时候,而且这个时间节点上老生代存储区域的空间又不足以存放我们新生代存储区所移过来的这样一些对象。在这种情况下就会去触发标记整理,把之前的一些锁片空间进行整理回收,这样就让我们有更多的空间可以使用。

那么最后呢,他还会采用增量标记方式对我们当前这样一个回收的效率呢进行提升,这里后面我们会介绍到。

那么现在我们已经知道了V8对于老生代对象是如何处理垃圾回收的,这里我们再来对比一下新老生代垃圾回收。

我们新生代的垃圾回收更像是在用空间换时间,因为他采用的是一个复制算法,那么这样也就意味着每时每刻他的内部都会有一个空闲空间的存在。

但是由于我们新生代存储区他本身的空间就很小,所以分出来的空间就更小,所以这一部分的空间浪费对于他所带来的时间上的一个提升是微不足道的。

在我们老生代对象回收过程中为什么不去采用这种一分二位的做法呢?因为老生代存储空间是比较大的,如果一分为二就有几百兆的空间浪费,这太奢侈了。

第二呢就是我们老生代存储区域中所存放的对象数据比较多,所以在赋值的过程中消耗的时间也就非常对,由此我们就知道老生代的垃圾回收是不适合使用复制算法来实现的。

这里我们对细节的描述也就说完了,那么接下来我们之前所提到的通过增量标记的一个算法去优化垃圾回收操作,这里如何理解呢?

这里我们来说明一下,首先我们分成两个部分,一个是程序的执行,另一个是垃圾回收。

这里我们首先明确垃圾回收进行工作的时候,是会阻塞当前JavaScript程序执行的,所以会出现一个空档期。例如程序支撑完成之后会停下来去执行垃圾回收操作。

所谓的标记增量简单的来说就是将整段的垃圾回收操作拆分成多个小步,组合着去完成整个回收,去替代之前的一口气做完的垃圾回收操作。

这样做的好处主要是实现垃圾回收与程序执行交替完成,这样做带来的时间消耗会更加的合理一些。避免像以前那样程序执行的时候不能做垃圾回收,程序做垃圾回收的时候不能继续运行程序。

简单的举个例子,说明一下增量标记的实现原理。

程序首先运行的时候是不需要进行垃圾回收的,一旦当他触发了垃圾回收之后,那么这里无论我们采用的是何种算法,都会进行遍历和标记的操作,这里要明确,针对的是老生代存储区域,所以是存在遍历操作的。

在遍历的过程中需要做标记,而这个标记之前也提到过,可以不一口气做完,为什么呢?因为他存在一个直接可达和间接可达操作,也就是说如果我们在做的时候,第一步先找到第一层的可达对象。那么我们就可以停下来了,让程序再去执行一会。

如果说程序执行了一会以后,在继续让GC机制去做他的二步的一个标记操作,比如下面还有一些子元素也是可达的,那就继续去把他做一个标记。

标记一轮之后再让GC停下来,继续回到程序执行,也就是交替的去做标记和程序执行。

最后标记操作完成以后,再去完成垃圾回收,这段时间程序就要停下来不执行等到垃圾回收操作完成,程序继续执行。

虽然这样看起来当前的程序停顿了很多次,但是我们要明白,整个V8最大的一个垃圾回收当内存达到1.5G的时候,采用非增量标记的形式去进行垃圾回收,时间也不超过1s,所以这里程序的一个间断是合理的。

而且这样一来他就最大限度的把以前很长的一段停顿时间直接拆分成了更小端,这对于用户来说就会显得更加流程一些。

到底为止就说完了V8当中是如何完成对于老生代存储区中的垃圾回收操作的。

简单整理一下就是有三个算法,编辑清除,标记整理和增量标记。增量标记的方式去可以提高回收效率。

V8垃圾回收总结

这里我们对之前所提到的关于V8引擎的垃圾回收操作进行一些整理和总结。

首先要知道V8引擎其实是当前主流的一个JavaScript执行引擎,然后在V8的内部他的内存是设置上限的,这么做的原因是因为第一他本身是为浏览器而设置的,所以针对于web应用来说,这样的内存大小是足够使用的。

第二就是由他内部的垃圾回收机制来决定的,如果我们再去把内存设置大一些那这个时候他的回收时间最多可能就超过了用户的感知,所以这里就设置了一个上限数值。

V8采用的是分代回收的思想,将内存分成了新生代和老生代。关于新生代和老生代空间和存储的数据类型是不同的。对于新生代,如果在64位操作系统下大小空间是32M, 如果在32位的系统下就是16M。

V8对于不同代对象采用的是不同的GC算法来完成垃圾回收操作,具体来说就是针对新生代采用复制算法和标记整理算法,针对于老生代对象主要采用标记清除,标记整理和增量标记这样三个算法。

这里我们做了一个简单的梳理,里面还有一些细节性的问题,比如说为什么不在老生代当中去做一些复制操作,比如说什么时候将新生代当中的对象去复制到老生代。具体的内容可以在前面讲解的内容中找到答案,这里我们就说完了。

Performance工具介绍

我们知道,关于GC的工作目的来说,其实就是为了让内存空间在程序运行的过程中,出现一个良性的循环使用。

所谓良性循环的基础其实就是要求开发者在写代码的时候能够对内存空间进行合理的分配。

但是由于ECMAScript当中并没有给程序员提供相应的操作内存空间的API,所以是否合理我们好像也不知道,因为他都是由GC来完成这个操作的。

所以在这里我们如果想去判断整个过程内存使用是否合理,就必须想办法能够时刻关注到当前内存的一个变化。

所以当前就有了这样一款工具可以提供给我们更多的监控方式,来在程序运行过程中帮助我们去完成对内存空间的一个监控操作。

那么这块我们简单总结一下就是通过使用Performance, 我们可以对当前程序运行过程中,内存的变化得到一个实时的监控。

有了这样一个操作之后,我们就可以在程序的内存出现一些问题的时候直接去想办法定位到当前出现问题的这样一个代码快。

那下面我们来看一下Performance工具的基本使用步骤。

首先来说非常简单,我们第一步就是先打开浏览器,在地址栏输入网址。

我们建议使用chrome浏览器,因为本身Performance就是chrome浏览器提供的工具。当然我们其他浏览器也可以有这样的操作,后面具体我们再去说明。

输入完地址之后我们不建议立即进行访问,因为我们想把最初的渲染过程记录下来,所以我们只是打开界面输入网址即可。

然后紧接着我们打开开发人员工具面板(F12),选择性能选项。

进入到性能选项之后,我们需要开启录制功能,开启之后,我们就可以访问目标网址。

然后接着我们充当用户角色,在这个页面上进行一些操作,过一段时间后停止录制。

从而我们就可以得到一个报告,在报告当中就可以去分析那些跟我们当前这样一个内存相关的一些信息了。

有了这样一个步骤之后,我们可以在浏览器中亲自试验一下。

停止录制之后,就会帮我生成当前这个过程中所对应的一些信息,然后在这里面会有一些图表的展示,然后信息也非常的多,所以看起来是比较的麻烦。

不过之前我们也强调了,在这里面呢我们只是关注与内存相关的信息,所以在这里我们看一下会有一个内存的选项(Memory)。默认情况下如果没有勾选我们需要将它勾选。如果勾选了内存我们在页面上就可以看到一个蓝色的线条。

他就属于整个过程当中我们内存所发生的变化,在这里我们也可以根据上面的时序,来看一下有问题的地方。

如果我们当前某个地方有问题的话,可以具体观察,比如有升有降那么就是没问题的,那么这里呢我们就算是对Performance基本做了一个介绍。

为什么我们要去选择这样一个工具,因为我们想要在写代码的过程中去时刻的监控到我们程序运行时整个内存的一个变化,从而呢去发现一些内存的问题,辅助我们在代码当中做一些优化,从而提高我们代码的执行效率。

内存问题的体现

这里我们来看一下,当我们应用程序在执行的过程中,如果内存出现了问题,那么他具体会在我们当前的界面上如何做出展示,那这里我们就可以更好的配合着我们当前Performance工具去进行问题的定位,所以在这里我们就依据相应的性能模型,给定了一些判定的标准。

下面我们就来看一下,当我们的程序的内存出现问题的时候,他具体会表现出什么样的形式。

首先第一条,我们的界面如果出现了延迟加载或者说经常性的暂停,那这里我们首先限定一下网络环境肯定是正常的,所以出现这种情况呢我们一般都会去判定内存是有问题的,而且呢与我们当前这样一个GC存在着频繁的垃圾回收操作是相关的。

具体来说我们如何来利用Performance工具进行观察呢,我们后续会去进行具体的演示。那这里呢我们就大致知道了,如果界面出现了延迟或者经常性的暂停,他的底层可能就伴随着频繁性的垃圾回收,也就是我们的代码中肯定存在瞬间让内存爆炸的代码。那这样的代码一定是不合适的,所以我们需要去进行定位。

第二个就是当我们的界面出现了持续性的糟糕性能表现,也就是说我们在使用过程中,他一直都会让我们觉得不是特别的好用,那这种情况他的底层我们一般会认为存在着内存膨胀。而所谓的内存膨胀指的就是,当前界面为了达到最佳的使用速度,可能会去申请一定的内存空间,但是这个内存空间的大小,远超过了当前设备本身所能提供的大小,所以这个时候就会感知到一段持续性的糟糕性能的体验,同样的我们这块肯定是假设当前网络环境是正常的。

最后,当我们使用一些界面的时候,如果我们感知到这个界面的使用流畅度,随着时间的加长越来越慢,或者说越来越差,那么这个过程呢就伴随着我们内存的泄露,因为在这种情况下我们刚开始的时候是没有问题的,由于我们某些代码的出现,他可能随着时间的一个增长让我们的内存空间越来越少,这也就是所谓的内存泄漏,因此,出现这种情况的时候我们的界面就会随着我们使用时间的增长表现出性能越来越差的一个现象。

那这块呢就是关于我们当前应用程序在执行过程中如果遇到了内存出现问题的情况,他具体在界面上可以呈现给用户的一些信息,那这里我们看到了以后就要去结合我们的Performance来进行一个内存的分析操作,从而去定位到那些有问题的代码,进行修改之后让我们当前的应用程序在执行的过程中会显得更加流畅一些。

监控内存的几种方式

这里我们来看一下几种常见的内存监控方式。

之前我们已经看到了,当我们的内存出现问题的时候我们可以一般归纳为三种情况,第一是内存泄露,第二是内存膨胀,第三就是频繁的垃圾回收。

那么当这些内容出现的时候,我们又该以什么样的标准来进行界定呢,下面我们就去给出一些简单的说明,那这里我们就来看一下这些界定的标准。

第一我们来看一下对于内存泄露来说呢,他其实呢就是内存的一个持续升高,这个呢反而很好判断,因为我们当前已经有很多种方式去获取到我们当前应用程序,执行过程中内存的走势图。

如果我们在这个图上去发现,内存是一直持续升高的,但是整个过程没有下降的这样一个节点,那这也就意味着我们程序代码当中是存在着内存泄露的。所以这个时候我们就应该去代码里面定位一下这样的模块了。

第二个呢是内存膨胀,这个呢就相对的模糊,内存膨胀的本意呢他指的就是当前应用程序本身,为了去达到一个最优的效果,他需要一个很大的内存空间,所以在这个过程当中也许是由于当前设备本身的硬件不支持,那么才造成了我们在使用过程中出现了一些性能上的差异。

所以说如果我们想要去判定, 他当前是程序的问题还是说是我们设备的问题,我们就应该去多做一些测试。

所以说这个时候我们就可以找到那些深受用户喜爱的设备,然后再他们上面都去运行我们当前这样一个应用程序,如果说整个过程中,那么在所有的设备上当前的应用都表现出了很糟糕的一个性能体验。那这就说明我们的程序本身是有问题的,而不是我们的设备有问题。

所以在这种情况下呢,我们就需要去回到我们代码里面,定位到我们当前内存出现问题的地方了。那这款就是我们关于我们如何去界定内存是否存在问题的两个容易说出的标准。

那第三个呢就是我们当前这样一个应用在执行的过程中,如果出现了一个频繁的垃圾回收,那么我们该怎样去界定呢?

这块我们后续就会通过一个内存的变化图来进行分析,因为我们通过界面是没有办法感知到的。

那么现在我们就知道了,如何去界定我们当前的内存是否是达到了一个标准,从来来确定他到底是有没有问题。

那么接下来我们就来看一看具体我们是有哪些方式,来监控我们内存的变化。那这里呢我们主要还是采用浏览器所提供的一些工具。

那么第一个我们所给出的就是浏览器所带的一个叫做任务管理器,这样的一个工具,他可以直接以数值的方式将我们当前应用程序在执行过程中内存的一个变化体现出来。

第二个呢就是我们可以借助于Timeline这样一个时序图,直接把我们当前应用程序执行过程中所有内存的走势以时间点的方式呈现出来,所以有了这张图以后我们就可以很容易的做判断了。这也是很好的监控方式。

再有我们的浏览器中还会有一个叫做堆快照的功能,利用它我们就可以很有针对性的查找我们当前界面对象中是否存在一些分离的DOM, 因为分离DOM的存在也就是一种内存上的泄露。

那么最后至于说我们该去怎样判断这个界面是否存在着频繁的垃圾回收,这就需要我们去借助于不同的工具来获取当前内存的一个走势图,然后进行一个时间段的分析,从而去得出一个判断,那这里就是关于我们当前内存在监控方面我们所要给出的一些说明。

具体来说就两个小点,第一我们要知道自己评判的标准是什么,第二我们要知道如何来对这样的内存进行一个持续性的监控。

任务管理器监控内存

在这之前呢我们都已经知道了,一个web应用在执行的过程中,如果想要观察他内部的一个内存变化,是可以有多种方式的,例如我们这里就通过一段简单的demo来演示一下,我们可以怎样借助浏览器当中自带的任务管理器去监控这个脚本运行时内存的变化。

首先我们来做一个简单的说明,将来我们要去运行文件的平台是浏览器,所以我们编写的是html文件,我们这里在代码当中模拟这样一个内存的变化。

首先我们在界面当中放置一个元素,然后给他添加一个点击事件,当这个事件触发的时候我们就去创建一个长度非常长的一个数组。

那这样呢就会产生一个内存空间上的消耗,我们这里来具体实现一下。

<body>
    <button id="btn">add</button>
    <script>
        const oBtn = document.getElementById('btn');
        oBtn.onclick = function() {
            let arrList = new Array(1000000)
        }
    </script>
</body>

完成之后我们打开浏览器运行,在右上角的“更多”中找到“更多工具”找到“任务管理器” 打开。

这个时候我们就可以在任务管理器中去定位到我们当前正在执行的脚本,默认情况下是没有javascript内存列的,所以如果需要的时候可以直接右击找到javascript内存,展示出来。

接下来我们来看一下如何去分析,首先这里我们最关注的就是内存和js内存这样的两列,那么这两例都叫内存有什么区别。

针对于第一列的内存来说呢,他其实表示的是原生内存,也就是当前界面会有很多DOM节点,而这个内存指的就是DOM节点所占据的内存,如果说这个数值在持续的增大,那就说明我们的界面中在不断的去创建DOM元素,这是第一列内存具有的意思。

那js内存表示的就是js的堆,在这列当中我们需要关注的其实是小括号里面的值,他表示的是我们界面当中所有可达对象正在使用的内存大小,如果说这个数值一直在增大,那就意味着我们当前的界面中要么在创建新对象,要么就是当前现有对象在不断的增长。

比如说以我们写的这个界面为例,我们可以发现当前小括号的值一直是个稳定的数字没有发生变化,那也就意味着我们当前页面是没有内存增长的。

那此时我们可以在这去触发一下click事件(点击按钮),我们这里多点几次,完成以后我们就发现了小括号里面的数值就变大了。

所以通过这样的一个过程我们就可以借助于当前的浏览器任务管理器来监控一下我们脚本运行时整个内存的变化。

那么我们能得出的结论是,如果说我们当前javascript内存列小括号里面的数值一直增大那就意味着我们内存是有问题的。具体来说是什么样的问题,我们当前这个工具是没有办法定位的,他只能发现问题,无法定位问题。

TimeLine记录内容

在之前我们已经可以去使用我们浏览器当中自带的任务管理器来对当前脚本执行中内存的变化去进行监控,但是在使用的过程中我们也可以发现,当前这样的一个操作其实更多的是用于判断当前脚本的内存是否存在问题。

如果我们想要定位这个问题呢,具体和什么样的脚本有关,那么任务管理器就不是那么好用了。所以在这里我们再介绍一个通过时间线记录内存变化的方式来演示一下我们可以怎样更精确的定位到我们当前内存的问题跟哪一块代码是相关的,或者说在什么时间节点上发生的。

那这里我们就通过脚本编写完成。

首先我们放置一个DOM节点,然后添加一个点击事件,首先我们在事件中创建大量的DOM节点来模拟我们内存的消耗,第二我们觉得DOM节点可能不够,我们通过属数组的方式配合着其他的方法形成一个非常非常长的字符串,也去模拟当前大量的内存消耗。

那有了这两个消耗之后我们再回到浏览器当中去看一下,如何使用工具来记录整个过程内存的变化。

那这里我们就开始编写脚本。

因为我们想要利用数组的方式来创建很长的字符串,所以我们先创建一个数组,初始化的时候是空的。

紧接着我们在事件中通过循环创建大量的DOM元素。并且在循环结束之后,我们在数组中添加1000000个x字符串。

<body>
    <button id="btn">add</button>
    <script>
        const oBtn = document.getElementById('btn');

        const arrList = [];

        function test () {
            for (let i = 0; i < 100000; i++) {
                document.body.appendChild(document.createElement('p'))
            }
            arrList.push(new Array(1000000).join('x'))
        }
        oBtn.onclick = test;
    </script>
</body>

到此为止我们的代码就写完了,我们去浏览器当中看一下。

我们先打开浏览器的控制台工具,这里面我们选择性能面板,默认这里是没有运行的,也就是没有记录,我们需要先点击计时操作。

点完以后我们就开始录制了,我们点击几次add按钮,稍等几秒后,点击停止按钮。

完成以后就生成了一个图表,这里密密麻麻的东西我们看起来可能会有些头疼,所以在这里我们关注下我们想要看到的信息就可以了。

见到这样的页面不要慌张,我们之前已经说过,我们这里关注的是内存的变化,所以只要关注内存就好,其他的暂时可以不必理会。我们只要想办法把内存信息提取出来就好。

首先我们先看一下,内存如果没有勾选的话是不会监控内存变化的,我们需要先勾选内存,勾选之后页面上就出现了内存的走势曲线图。

具体来说里面会包含很多信息,他给出来了击中颜色的解释。

当他是蓝色的时候就是js堆,红色就表示当前的文档,绿色就是DOM节点,棕色是监听器,紫色是CPU内存。

这里为了便于观察我们先只保留JS堆,其他的取消勾选,隐藏掉。这样我们就可以很直观的,看到这个脚本运行过程中到目前为止他的一个JS堆的情况走势。

除此以外我们当前这个工具叫时序图,也就是在第一栏,这里是以毫秒为单位,记录了整个页面从空白到渲染结束到最终停状态,这个过程中整个界面的变化。

如果愿意,你可以点进去看一下当前的界面形态,而我们现在关注的只是内存,所以这里我们只看内存的曲线图就可以了。

那么这时候通过这样图表我们简单来分析一下,当这个页面最开始打开的时候其实很长一段时间都是平稳的状态,没有太多的内存消耗。原因在于我们根本没有点击add。

然后紧接着在某一个时间点上突然之间内存就上去了,上去之后是一段平稳的状态,这是因为我们点击了add之后这里的内存肯定是瞬间暴涨的,然后紧接着暴涨之后我们没有任何操作,所以这时候肯定是平稳。

然后紧接着平稳之后又下降了,这就是我们之前所提到的,浏览器本身也是具有垃圾回收机制的,所以当我们的脚本运行稳定之后,那么GC可能在某个时间点上就开始工作了,然后他会发现有一些对象是非活动的,那么他就开始去进行回收,所以一段平稳之后我们这里就降下去了。

降下去之后又会有一些小的浮动,这个属于我们正常的活动开销。

后来我们又有几次连续的点击,这个连续的点击行为呢可能又去造成我们当前内存的飙升,然后不操作之后他又去往下降。

所以通过这样一张内存走势图,我们可以得出的结论就是,当前我们这个脚本里面其实内存还是非常稳定的,因为整个过程呢就是有涨还会有降,而涨呢就是我们去申请内存,降呢就是用完之后我们的GC在正常的去回收我们的内存。

那这里呢就是我们通过浏览器里面的Performance工具他里面有一个timeline的这样一个小的选项,如何去监控我们当前整个内存的变化。

那一旦说我们看到当前这个内存的走势是直线向上走,也就意味着他只有增长而没有回收的操作,那这里必然就存在着内存的一个消耗,所以更有可能是内存泄漏。

我们可以通过上面的时序图去定位问题,当我们发现某一个节点上有问题的时候,那我们可以直接在这里面去定位到那个时间节点,我们可以在时序图上进行拖动查看每一个时间节点上的内存消耗。

然后你还可以看到界面上的一个变化,就可以配合着定位到是哪一块产生了这样一个内存的问题。

所以这里边相对我们的任务管理器来说可能更好用的地方,不但可以帮我们来看一下当前内存是否有问题,而且还可以帮助我们定位这个问题到底在哪个时候发生的,然后再配合当前的界面展示让我们知道我们做了什么样的操作才出现了这样一个问题,从而间接地让我们可以回到代码当中定位这样有问题的一个代码块。

这里就是关于如何利用timeline来记录当前内存的走势,从而监控整个内存的过程。

堆快照查找分离DOM

在这我们去看一下如何去利用浏览器当中所提供的堆快照的功能在我们脚本运行时,进行一些内存的监控操作。

这里我们先来简单的说明一下堆快照功能工作的原理,首先他就是相当于找到我们当前的js堆,然后对她进行一个照片的留存。

有了照片以后我们就可以看到它里面的所有信息,这也就是我们如何去监控的一个由来。

堆快照在使用的时候非常的有用,因为他更像是针对分离DOM的查找行为。

我们都知道在界面上我们看到的很多元素其实都是DOM节点,而这些DOM节点本应该存在于一颗存活的DOM树上的。

不过对DOM节点会有几种形态,一种形态我们称之为垃圾对象,还有一种我们叫分离DOM。

简单的说就是如果这个节点从我们当前的DOM树上进行了脱离,而且在JS代码当中也没有再引用的DOM节点,那么他其实就成为了一个垃圾。

如果说当前的DOM节点只是从DOM树上脱离了,但是在js代码中还有人在饮用者他,我们把这种DOM称为分离DOM。这种分离DOM在界面上是看不见的,但是在内存中是占据着空间的。

所以在这种情况下就是一种内存泄露,因此我们可以通过这样一个堆快照的功能去把他们从这里面都找出来,那么只要能找得到,我们就可以去回到代码里面,针对于这些代码进行一些清除就可以了。从而让我们当前的内存得到一些释放,让我们的脚本在执行的时候也会变得更加迅速。

那说完这些以后我们就去通过脚本把他们实现一下,在这里我们采用和之前一样的思路。

在html里面我们存放一个btn按钮,然后给btn一个点击事件,当我们点击按钮的时候,我们通过js语句去模拟相应的内存变化,比如这次我们要去做的事情就是创建DOM节点。

为了看到更多类型的分离DOM,我们采用ul包裹li的DOM节点创建。我们先在函数中创建,ul节点,然后我们使用循环的方式创建多个li, 将li放在ul里面。

创建之后我们不需要放在页面上,所以也就不插入body中,为了让我们代码引用到这个DOM, 我们使用一个变量tmpEle来指向ul。

<body>
    <button id="btn">add</button>
    <script>
        const oBtn = document.getElementById('btn');

        var tmpEle;

        function fn () {
            var ul = document.createElement('ul');
            for (var i = 0; i < 10; i++) {
                var li = document.createElement('li');
                ul.appendChild(li);
            }
            tmpEle = ul;
        }

        oBtn.addEventListener('click', fn);

    </script>
</body>

代码我们就写完了,简单说明就是我们创建了ul和li节点,但是并没有将他们放在页面中,只是通过js变量引用了这个节点,这就是分离DOM。

我们在浏览器运行一下这个脚本,然后来看如何利用工具来监控这样一个内存变化。我们打开浏览器调试工具,选中内存面板。进入以后我们可以发现这里有一个堆快照的选项。

我们这里做两个行为的测试,第一个呢就是在我们没有点击按钮的情况下,直接去获取当前的快照,这个时候就很迅速的拍了一张照片。

在这个快照里面就是我们当前所动对象的一个具体展示。这里有一个筛选的操作,我们直接检索deta关键字,我们可以发现是没有内容的。

我们回到界面中去做另外一个操作,对当前按钮进行点击,点完以后我们可以再去拍摄一张快照(点击左侧的配置文件文字,出现拍照界面),这次我们还是做和之前一样的操作,检索deta。

这次我们就会发现,快照2里面搜索到了,很明显这几个就是我们自己在代码中所创建的哪几个DOM节点,我们并没有添加到界面中,但是他的确存在于我们的堆中。

这其实就是一种空间上的浪费,因此我们现在就通过堆快照的功能来找到了我们这个脚本里面所存在的问题,也就是存在着所谓的分离DOM。针对这样的问题我们在代码中对使用过后的DOM节点进行清空就可以了。

function fn () {
    var ul = document.createElement('ul');
    for (var i = 0; i < 10; i++) {
        var li = document.createElement('li');
        ul.appendChild(li);
    }
    tmpEle = ul;
    // 清空DOM
    ul = null;
}

在这里我们简单的总结就是,我们可以利用浏览器当中提供的一个叫做堆快照的功能,然后去把我们当前的堆进行拍照,拍照过后我们要找一下这里面是否存在所谓的分离DOM。

因为分离DOM在页面中不体现,在内存中的确存在,所以这个时候他是一种内存的浪费,那么我们要做的就是定位到我们代码里面那些个分离DOM所在的位置,然后去想办法把他给清除掉。

判断是否存在频繁GC

这里我们来说一下如何去确定我们当前web应用在执行过程中是否存在着频繁的垃圾回收,因为我们都已经知道,当GC去工作的时候我们当前的应用程序是停止的。

所以我们当前的GC频繁的工作,而且时间过长,那么这时候对于我们web应用来说就很不友好,因为他会处于一个假死的状态,那么对于用户来说就会感觉到这样一个应用是卡顿的。

所以这个时候我们就要去想办法来确定当前的应用在执行时,是否存在频繁的垃圾回收。

那这里我们给出两种方式,第一种就是我们可以通过timeline时序图的走势来判断一下,我们可以在性能工具面板中对当前的内存走势进行监控。

如果我们发现他那个蓝色的走势条频繁的上升下降。那也就意味着他在频繁的进行垃圾回收。那出现了这样的情况之后我们就必须去定位到相应的时间节点,然后看一下我们具体做了什么样的操作,才造成这样现象的产生,接着我们再代码中进行处理就可以了。

任务管理器在做判断的时候就会显得更加简单一些,因为他就是一个数值的变化,正常来说当我们的界面渲染完成之后,如果没有其他额外的操作,那么无论是我们DOM节点内存,还是我们JavaScript内存来说,他都是一个不变化的数值,或者变化很小。

如果我们这里存在频繁的GC操作时,这个数值的变化就是瞬间增大,瞬间减小,这样的一个节奏,所以我们看到这样的过程也意味着代码存在频繁的垃圾回收操作。

频繁的垃圾回收操作表象上带来的影响是让用户觉得应用在使用的时候会非常卡顿,从内部来说就是当前代码当中存在对内存操作不当的行为让GC不断的去工作,来回收释放相应的空间。

这里就是我们讲到的如何通过内存监控来确定当前应用在执行过程中是否存在着频繁的垃圾回收,当我们确定之后就可以依据相应的一些信息去代码当中进行定位问题,让我们的代码在执行时更加快捷。

Performance总结

在这里我们来看一下Performance工具的总结。

在使用他之前我们首先对Performance工具做了一个介绍,我们知道他是谷歌浏览器所提供的一个性能工具。

那么在这之后我们演示了一下该如何在当前的应用执行过程中去应用Performance工具,至于具体的流程我们就不再赘述了。

然后通过Performance的使用我们可以对内存进行一个适当的监控,然后我们也说了一下内存所能够带来的一些问题,以及如果我们应用程序在执行过程中出现了内存问题之后他会表现成什么样子。

遇到这些展示之后我们可以去把他归结于几类小内存问题,比如内存泄露,内存膨胀,以及频繁的GC操作。

说完这些以后我们就具体的采用了几个小工具对我们程序执行过程中内存的变化进行监控,例如Performance当中的时序图可以记录当前程序执行时内存的走势。

通过蓝色的线条就可以监控到当前内存是如何变化的,从而去定位到当前有问题的时间节点所做的操作,再继续定位代码当中的内容。

除此之外我们还去演示了当前浏览器当中的任务管理器是如何来监控内存变化的,具体来说就是两个数值,一个就是当前DOM节点所占用的内存空间变化,还有就是JS当中的数值变化。

我们在监控的时候如果发现这样的两个数值是持续增加,那就意味着当前代码是不断的有内存申请的,这里我们就要考虑一下是否存在有问题的地方。

在这之后我们又去看到了如何通过堆快照的功能查找代码当中是否存在分离DOM,因为我们都知道,分离的DOM必然存在着内存泄露的现象,如果这样的问题越来越多那么程序就会随着使用时间的增长表现出性能越来越糟糕的情况。

这里呢就是关于Performance工具所涉及到的一些小的总结。

代码优化介绍

这里我们来看一下关于代码优化的内容介绍。

首先我们要去考虑一下该如何去精准测试我们js代码的性能,那这个精准的测试本质上来说我们做的事情就是,经过大量的数据采集,然后去执行样本的一个数学统计的分析,从而去得出一个比对的结果来,证明什么样的脚本他的执行效率更高。

而这样的一个过程对于我们的编码者来说呢显得就有些麻烦,因为我们可能更多的只是关注该如何使用脚本去实现某一个功能,而不是去做大量的数学统计。

所以在这个地方我们去采用一个基于Benchmark.js的一个perf的网站,进行一个在线的js脚本的性能测试。

那么接下来我们就来看一下,这样的测试该如何去执行,也就是哦们的jsperf这样一个网站该如何去使用。

后续我们会具体演示这样一个过程,在这我们先以文字的形式列一下这样一个执行步骤。

Jsperf使用流程

  1. 使用GitHub账号登录

  2. 填写个人信息(非必须)

  3. 填写详细的测试用例信息(title, slug)

这里我们比较关注的是当天测试用例的title和slug,他会用它去生成一个短连接,用于我们去在其他的地方访问,这样的一个测试用例。

  1. 填写准备代码(DOM操作时经常使用)

  2. 填写必要的setup和teardown代码

setup可以理解为是当前要做的一个前值准备工作,比如说我们要使用手机就要先打开手机,而teardown就是所有代码执行完之后要做的一个销毁的操作,比如我们在时候数据库的时候,链接完之后用完了这个过程我们应该把当前的链接资源释放掉。从而让我们的内存得到一个空间的释放。

  1. 填写测试代码片段

这里我们可以填一个片段也可以填多个片段,取决于我们到底想要测试几个片段,有了这些操作以后我们就可以直接在浏览器当中去运行我们当前的脚本。

最终当我们这样一个脚本在这样一个网站上执行完成之后就会给出数据上的提现,通过数据我们就可以得出,什么样的类型的js脚本会具有更高的执行效率。

我们可以在浏览器当中测试使用一下。

首先我们打开jsperf(www.jsperf.com)网站,使用登录完成之后,我们进入到填写个人信息界面,这里是非必填的,我们向下看,可以发现Test case details栏目。

这里有两个是必填的,一个是title,一个是slug,这里我们需要注意的是当前这个slug必须是唯一的,因为他会去生成一个空间,利于我们去访问自己的测试用例。

往下看有个叫做Preparation code html栏目,这里就是准备代码,也就是我们需要用到一些DOM操作或者说我们需要引入一些第三方的资源库的时候,可以在这个里边提那些代码。

在下边就是setup和teardown的填写区域,这里我们可以在后续的测试中有所应用,现在我们先放在这里。

再往下就是当前代码片段的填写,这里有几个是必填的,第一个是测试标题,紧接着就是我们要测试的代码片段,我们可以直接把代码贴在这里面,然后这里有多个片段,还可以自己添加。

准备好这些内容之后,我们可以直接保存,保存之后我们会跳转到新的界面。

慎用全局变量

这里我们来看一下关于慎用全局变量,相关的内容介绍,首先我们先从字面意思做一个解释,关于慎用全局变量我们就认为是在程序执行过程中如果针对于某些数据需要进行存储,那么我们可以尽可能的把他放置在局部作用域当中,变成一个局部变量。

至于说我们为什么要这样做?我们从以下的几点进行分析,当我们在一个全局的范围内定义完变量之后,那么他其实是存在于全局的执行上下文之中,那么这个上下文也就是我们在后续程序查找数据过程中所有作用域链的最顶端。

那如果我们按照这种层级向上查找的一个过程来说,下面某些局部作用域没有找到的变量最终都会查找到顶端的全局作用域。

所以在这种情况下我们查找的一个时间消耗是非常大的,这样一来也就降低了我们当前代码的执行效率,除此以外我们再当前全局上下文当中去定义的变量他一直是存活于我们的上下文执行栈。

而这个上下文执行栈是直到我们当前程序退出之后才会消失的,所以这对于我们当前的GC工作来说是非常不利的,因为只要GC发现这样的变量处于存活状态我们就不会把他当做垃圾对象来进行回收。

因此这样的做法也会降低当前程序运行过程中对于内存的一个使用,除此以外,如果说我们再某个局部作用域当中去定义了一个同名的全局变量,这个时候就有可能造成当前全局变量的命名污染,或者将当前全局的数据进行了遮蔽。

总归来说我们再使用全局变量的时候就需要考虑更多的事情,否则就会给我们带啦一些意想不到的情况。

那在这里我们主要讨论的是针对一个具体的场景去判断一下我们在使用全局变量的时候他的执行效率,和我们在使用局部变量的时候他的执行效率进行一个对比。

首先我们要明确要实现的效果是一样的,只不过一个是通过全局变量来进行存储,另外一个通过局部变量来进行存储,最后我们借助jsperf来观察一下两者执行的性能差异。

这里我们通过一段对比代码来完成。

首先这里我们定义两个全局变量,一个是i一个是str,str我们做一个基础赋值,给个空的字符串。

接下来我们通过一个for循环来生成一个很长的字符串,这个字符串我们建议小一些,不然机器可能会出现问题。在循环内部我们拼接上字符串的长度。这是我们采用全局作用域的方式来实现我们要做的事情。

var i, str = '';
(function() {
    for (i = 0; i < 1000; i++) {
        str += i;
    }
})()

另外一个和第一个类似,不过这里我们使用的是局部变量i和str

(function() {
    let str = '';
    for (let i = 0; i < 1000; i++) {
        str += i;
    }
})()

那这个时候我们就相当是用两份不同的代码去完成了一个相同的效果。接下来我们就把他们放在jsperf当中,将他们大量执行,从而得出谁的执行效率更高。

在jsperf中分别贴入这两段代码,然后运行当前的脚本,等待一会他会把我们的脚本进行一个大量的执行,然后给出一个最终的性能结果。

执行完成以后可以发现,我们采用全局变量和采用局部变量之间的差距是非常大的,所以这里我们就验证了,当我们去完成一个相同的时间结果过程中,如果我们采用全局变量来完成的话他的效率是远低于局部变量的。

这也就验证了我们应该慎用全局变量,从而提高js的执行效率。

缓存全局变量

这里我们来看一下,如何通过缓存全局变量的方式,让我们的js代码在执行的时候会有更高的执行性能。

关于所谓的缓存全局变量,他其实值得就是在我们程序执行过程中,个别全局变量的使用是无法避免的,例如当我们想要查找DOM元素的时候,这种情况下我们就必须使用到document,而document就是一个全局变量。

所以在在这种情况下我们就可以选择将这种需要大量使用的全局变量放置在某一个局部作用域中。从而达到一种缓存效果。

下面我们就来实现一下,当我们查找DOM元素时,使用缓存和不使用缓存得到的一个性能差异到底有多大。

为了避免元素单一,我们可以在input标签之间穿插一些p标签。

<body>
    <input value="btn" id="btn1" />
    <input value="btn" id="btn2" />
    <input value="btn" id="btn3" />
    <input value="btn" id="btn4" />
    <p>111</p>
    <input value="btn" id="btn5" />
    <input value="btn" id="btn6" />
    <p>222</p>
    <input value="btn" id="btn7" />
    <input value="btn" id="btn8" />
    <p>333</p>
    <input value="btn" id="btn9" />
    <input value="btn" id="btn10" />
    <script>
        function getBtn() {
            let oBtn1 = document.getElementById('btn1');
            let oBtn3 = document.getElementById('btn3');
            let oBtn5 = document.getElementById('btn5');
            let oBtn7 = document.getElementById('btn7');
            let oBtn9 = document.getElementById('btn9');
        }

        function getBtn2() {
            let obj = document;
            let oBtn1 = obj.getElementById('btn1');
            let oBtn3 = obj.getElementById('btn3');
            let oBtn5 = obj.getElementById('btn5');
            let oBtn7 = obj.getElementById('btn7');
            let oBtn9 = obj.getElementById('btn9');
        }
    </script>
</body>

这里我们回到jsperf里面分别执行这两个获取函数,在html里面贴入body中的html,不包含body,然后再js里面分别放入两个函数然后开始测试。

结果可以发现,使用缓存的效果比没使用缓存会有一些优势。

通过原型对象添加附加方法

接下来我们看一下js中可以如何使用原型链的方式提升我们的性能。首先呢我们都知道,在js中存在三种概念,第一个就是构造函数,第二就是原型对象,第三就是我们的实例对象。

而在这个过程中我们的实例对象和构造函数都是可以指向原型对象的,所以在这个过程中如果某个构造函数的内部具有成员方法,让我们后续的实例对象都需要频繁的去进行调用,那这里就可以直接把他添加在原型对象上。而不需要放在构造函数内部。

这样两种不同的实现方式在性能上也会有所差异,所以在这个地方我们还是通过代码的方式来演示一下。

var fn1 = function() {
    this.foo = function() {
        console.log(11111);
    }
}

let f1 = new fn1();

var fn2 = function() {}
fn2.prototype.foo = function() {
    console.log(11111);
}

let f2 = new fn2();

我们在jsperf中对比发现,通过构造函数添加的方法相比于通过原型添加的方法,效率差别还是比较大的。

避开闭包陷阱

这里我们来看一下如何避开闭包陷阱,从而让我们js代码在执行过程中可以具有更高的执行效率。这里我们先来介绍一下闭包。

我们都知道闭包在js中是一个非常强大的语法,在有些情况下可以给我们的使用带来非常大的便捷,不过我们也要知道,闭包如果使用不当的情况下很容易造成内存泄漏。这里为了表达的就是,不要为了闭包而闭包。

接下来我们去对比一下使用闭包和使用函数重用的方式实现相同的功能谁的执行效率更高。

首先我们这里定义一个函数,他接收另外一个函数作为参数,从而将来他在调用的时候可以让另外函数内部的代码得到执行,这个过程我们分别采用两种方式实现,使用闭包和不使用闭包。

function test(func) {
    console.log(func());
}

function test2() {
    var name = 'yd';
    return name;
}

test(function() { // 使用闭包
    var name = 'yd';
    return name;
});

test(test2) // 不使用闭包

我们将test和test2放在setup中,因为他们是我们每轮都需要使用的部分,我们没必要在每轮测试时都去重新定义。

可以发现使用闭包效率低于使用函数的方式,所以我们这里建议,如果非不要,不建议使用闭包。

避免属性访问方法使用

这里我们看一下如何通过避免属性访问方法的使用,去提高我们js在执行过程中的一些性能。

关于属性访问方法,我们知道这是跟面向对象语法相关的,为了更好的实现这个封装性,所以在更多的时候我们可能会将一些对象的成员属性和方法放在一个函数内部,然后我们对外部暴露一个方法对这些属性进行增删改查。

但是这个特性在我们的js中并不是那么的适用,因为在js里面是不需要属性的访问方法的,所有的属性他其实在外部都是可见的,而且在我们使用属性访问方法的时候他就相当是增加了一层重定义,对于当前的访问控制来说,没有太多的意义。所以在这里我们就不推荐属性访问方法的使用。

function Person() {
    this.name = 'icoder';
    this.age = 18;
    this.getAge = function() {
        return this.age;
    }
}
const p = new Person();
const a = p.getAge();

function Person() {
    this.name = 'icoder';
    this.age = 18;
    this.getAge = function() {
        return this.age;
    }
}
const p = new Person();
const b = p.age;

通过对比发现通过属性访问方法访问属性比直接访问属性效率要低很多。

For循环优化

这里我们来看一下关于for循环的优化,对于js编码来说,通常会遇到一组数据结构,这个时候我们就可以采用for循环对其进行遍历,而在使用for循环遍历的过程中呢,我们同样可以从小的细节方面进行优化。

不要直接使用数组的属性,最好缓存下来。

var arrList = [];
arrList[10000] = 'icoder';
for (var i = 0; i < arrList.length; i++) {
    console.log(arrList[i])
}

for (var i = arrList.length; i; i--) { // 缓存数组长度
    console.log(arrList[i])
}

选择最优的循环方法

这里我们要介绍forEach,for, for … in, 这里我们要做的就是当我们遍历一组相同的对象时,这三种方式他们在实现上谁的效率更高一些。

var arrList = new Array(1, 2, 3, 4, 5)

arrList.forEach(function(item) {
    console.log(item);
})

for (var i = arrList.length; i; i--) { // 缓存数组长度
    console.log(arrList[i])
}

for (var i in arrList) { // 缓存数组长度
    console.log(arrList[i])
}

比对发现, foreach效率最高,for…in…效率最低。

文档碎片优化节点添加

这里我们来看一下关于节点添加的优化操作, 针对于我们web开发来说,DOM节点的操作是非常频繁的,针对DOM的交互操作也是非常消耗性能的,特别是创建新的节点,将它添加至界面中时,这个过程一般都会伴随着回流和重绘。这两个操作对性能的消耗又是比较大的。

这里我们就来看一下针对节点添加可以怎样优化,从而去提高代码的执行效率。我们这里采用优化和不采用优化的行为进行对比。

// 不使用优化
for (var i = 0; i < 10; i++) {
    var oP = document.createElement('p');
    oP.innerHTML = i;
    document.body.appendChild(oP);
}

// 使用优化
const fragEle = document.createDocumentFragment();
for (var i = 0; i < 10; i++) {
    var oP = document.createElement('p');
    oP.innerHTML = i;
    fragEle.appendChild(oP);
}
document.body.appendChild(fragEle);

采用文档碎片添加速度要快于直接append的方式的。

克隆优化节点操作

这里我们通过克隆的方式优化节点操作,从而让我们js代码在执行的时候具有更高的使用性能。

针对于我们当前的DOM节点操作,我们可以认为有新增这样一个节点,第二我们新增完成之后呢后续可能还会有很多属性和方法的添加,我们这里主要以节点的新增去配合克隆完成一个优化的操作。

例如我们想向界面当中添加一个p标签,当页面渲染完成之后其实页面中应该存在很多p标签,其中有一个p标签和我们想要创建的p标签他们在属性和外观的样式上和我们当前的p应该有很多相似的地方,所以我们的克隆就是当我们要去新增节点的时候,可以先去找到一个与他类似的已经存在的节点,把他克隆一下然后再去把克隆好的节点直接添加到界面当中。

这样优化的内容是本身已经具有的样式和属性就不需要后续再执行添加了,这就是所谓的优化。

<body>
    <p id="box1">old</p>
    <script>
        // 创建方式
        for (var i = 0; i < 10; i++) {
            var oP = document.createElement('p');
            oP.innerHTML = i;
            document.body.appendChild(oP);
        }
        // 克隆方式
        var oldP = document.getElementById('box1');
        for (var i = 0; i < 10; i++) {
            var newP = oldp.cloneNode(false);
            newP.innerHTML = i;
            document.body.appendChild(newP);
        }
    </script>
</body>

测试后可以发现,克隆的方式要快于创建的方式。

直接量替换newObject

在这里我们来看一下通过直接量去天幻Object的操作,对我们当前js代码进行一个优化,从而去提高他的执行性能。

这个操作相对来说比较简单,我们直接在代码中进行测试和编写。

当我们定义对象和数组的时候,我们有两种不同的形式,比如可以使用new的方式获取相应的数据,但是也可以直接采用字面量,这里我们以数组为例来进行一个测试。

var a = [1, 2, 3];

var a1 = new Array(3)
a1[0] = 1;
a1[1] = 2;
a1[2] = 3;

转载须知

如转载必须标明文章出处文章名称文章作者,格式如下:

转自:【致前端 - https://madaozhijian.com】 JavaScript性能优化  "隐冬"