JavaScript 垃圾回收机制

共 24829字,需浏览 50分钟

 ·

2021-11-05 14:50

作者:隐冬

来源:SegmentFault 思否社区

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,设置两个形参obj1obj2,让obj1通过一个属性指向obj2,紧接着再让obj2也通过一个属性去指向obj1。再通过return关键字直接返回一个对象,obj1通过o1进行返回,再设置一个o2让他找到obj2。完成之后在外部调用这个函数,设置一个变量进行接收,obj等于objGroup调用的结果。传两个参数分别是两个对象obj1obj2

function objGroup(obj1, obj2) {
    obj1.next = obj2;
    obj2.prev = obj1;
}

let obj = objGroup({name: 'obj1'}, {name: 'obj2'});

console.log(obj);


运行可以发现得到了一个对象。对象里面分别有obj1obj2,而obj1obj2他们内部又各自通过一个属性指向了彼此。

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


分析一下代码,首先从全局的根出发,是可以找到一个可达的对象obj,他通过一个函数调用之后指向了一个内存空间,他的里面就是上面看到的o1o2。然后在o1o2的里面刚好又通过相应的属性指向了一个obj1空间和obj2空间。obj1obj2之间又通过nextprev做了一个互相的一个引用,所以代码里面所出现的对象都可以从根上来进行查找。不论找起来是多么的麻烦,总之都能够找到,继续往下来再来做一些分析。

如果通过delete语句把obj身上o1的引用以及obj2obj1的引用直接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属性值。再定义一个函数,在函数体内定义几个变量数值num1num2,注意这里是没有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的下边是可以直接找到user1user2user3以及nameList,同时在fn函数里面定义的num1num2由于没有设置关键字,所以同样是被挂载在window对象下的。这时候对这些变量而言他们的引用计数肯定都不是0

接着在函数内直接把num1num2加上关键字的声明,就意味着当前这个num1num2只能在作用域内起效果。所以,一旦函数调用执行结束之后,从外部全局的地方出发就不能找到num1num2了,这个时候num1num2身上的引用计数就会回到0。此时此刻只要是0的情况下,GC就会立即开始工作,将num1num2当做垃圾进行回收。也就是说这个时候函数执行完成以后内部所在的内存空间就会被回收掉。

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


那么紧接着再来看一下其他的比如说user1user2user3以及nameList。由于userList,里面刚好都指向了上述三个对象空间,所以脚本即使执行完一遍以后user1user2user3他里边的空间都还被人引用着。所以此时的引用计数器都不是0,也就不会被当做垃圾进行回收。这就是引用计数算法实现过程中所遵循的基本原理。简单的总结就是靠着当前对象身上的引用计数的数值来判断是否为0,从而决定他是不是一个垃圾对象。

1. 引用计数优缺点

引用计数算法的优点总结出两条。

第一是引用计数规则会在发现垃圾的时候立即进行回收,因为他可以根据当前引用数是否为0来决定对象是不是垃圾。如果是就可以立即进行释放。

第二就是引用计数算法可以最大限度的减少程序的暂停,应用程序在执行的过程当中,必然会对内存进行消耗。当前执行平台的内存肯定是有上限的,所以内存肯定有占满的时候。由于引用计数算法是时刻监控着内存引用值为0的对象,举一个极端的情况就是,当他发现内存即将爆满的时候,引用计数就会立马找到那些数值为0的对象空间对其进行释放。这样就保证了当前内存是不会有占满的时候,也就是所谓的减少程序暂停的说法。

引用计数的缺点同样给出两条说明。

第一个就是引用计数算法没有办法将那些循环引用的对象进行空间回收的。通过代码片段演示一下,什么叫做循环引用的对象。

定义一个普通的函数fn在函数体的内部定义两个变量,对象obj1obj2,让obj1下面有一个name属性然后指向obj2,让obj2有一个属性指向obj1。在函数最后的地方return返回一个普通字符,当然这并没有什么实际的意义只是做一个测试。接着在最外层调用一下函数。

function fn() {
    const obj1 = {};
    const obj2 = {};

    obj1.name = obj2;
    obj2.name = obj1;

    return 'yd is a coder';
}


那么接下来分析还是一样的道理,函数在执行结束以后,他内部所在的空间肯定需要有涉及到空间回收的情况。比如说obj1obj2,因为在全局的地方其实已经不再去指向他了,所以这个时候他的引用计数应该是为0的。

但是这个时候会有一个问题,在里边会发现,当GC想要去把obj1删除的时候,会发现obj2有一个属性是指向obj1的。换句话讲就是虽然按照之前的规则,全局的作用域下找不到obj1obj2了,但是由于他们两者之间在作用域范围内明显还有着一个互相的指引关系。这种情况下他们身上的引用计数器数值并不是0GC就没有办法将这两个空间进行回收。也就造成了内存空间的浪费,这就是所谓的对象之间的循环引用。这也是引用计数算法所面临到的一个问题。

第二个问题就是引用计数算法所消耗的时间会更大一些,因为当前的引用计数,需要维护一个数值的变化,在这种情况下要时刻的监控着当前对象的引用数值是否需要修改。对象数值的修改需要消耗时间,如果说内存里边有更多的对象需要修改,时间就会显得很大。所以相对于其他的GC算法会觉得引用计数算法的时间开销会更大一些。

6. 标记清除算法

相比引用计数而言标记清除算法的原理更加简单,而且还能解决一些相应的问题。在V8中被大量的使用到。

标记清除算法的核心思想就是将整个垃圾回收操作分成两个阶段,第一个阶段遍历所有对象然后找到活动对象进行标记。活动就像跟之前提到的可达对象是一个道理,第二个阶段仍然会遍历所有的对象,把没有标记的对象进行清除。需要注意的是在第二个阶段当中也会把第一个阶段设置的标记抹掉,便于GC下次能够正常工作。这样一来就可以通过两次遍历行为把当前垃圾空间进行回收,最终再交给相应的空闲列表进行维护,后续的程序代码就可以使用了。

这就是标记清除算法的基本原理,其实就是两个操作,第一是标记,第二是清除。这里举例说明。

首先在全局global声明ABC三个可达对象,找到这三个可达对象之后,会发现他的下边还会有一些子引用,这也就是标记清除算法强大的地方。如果发现他的下边有孩子,甚至孩子下边还有孩子,这个时候他会用递归的方式继续寻找那些可达的对象,比如说DE分别是AC的子引用,也会被标记成可达的。

这里还有两个变量a1b1,他们在函数内的局部作用域,局部作用域执行完成以后这个空间就被回收了。所以从global链条下是找不到a1b1的,这时候GC机制就会认为他是一个垃圾对象,没有给他做标记,最终在GC工作的时候就会把他们回收掉。

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


这就是标记清除所谓的标记阶段和清除阶段,以及这两个阶段分别要做的事情。简单的整理可以分成两个步骤。在第一阶段要找到所有可达对象,如果涉及到引用的层次关系,会递归进行查找。找完以后会将这些可达对象进行标记。标记完成以后进行第二阶段开始做清除,找到那些没有做标记的对象,同时还将第一次所做的标记清除掉。这样就完成了一次垃圾回收,同时还要留意,最终会把回收的空间直接放在一个叫做空闲列表上面。方便后续的程序可以直接在这申请空间使用。

1. 标记清除算法优缺点

相对比引用计数而言标记清除具有一个最大的优点,就是可以解决对象循环引用的回收操作。在写代码的时候可能会在全局定义ABC这样的可达对象,也会有一些函数的局部作用域,比如在函数内定义了a1b1,而且让他们互相引用。

const A = {};

const B;

const C = {};

function fn() {
    const a1 = {};
    const b1 = {};
    a1.value = b1;
    b1.value = a1;
}

fn();


函数的调用在结束之后必然要去释放他们内部的空间,在这种情况下一旦当某一个函数调用结束之后他局部空间中的变量就失去了与全局global作用域上的链接。这个时候a1b1global根下边就没办法访问到了,就是一个不可达的对象。不可达对象在做标记阶段的时候不能够完成标记,在第二个阶段回收的时候就直接进行释放了。

这是标记清除可以做到的,但是在引用计数里面,函数调用结束同时也没有办法在全局进行访问。可是由于当前判断的标准是引用数字是否为0,在这种情况下,就没有办法释放a1b1空间,这就是标记清除算法的最大优点,当然这是相对于引用计数算法而言的。

同时标记清除算法也会有一些缺点。比如模拟一个内存的存储情况,从根进行查找,在下方有一个可达对象A对象, 左右两侧有一个从跟下无法直接查找的一个区域,BC。这种情况下在进行第二轮清除操作的时候,就会直接将B和C所对应的空间进行回收。然后把释放的空间添加到空闲列表上,后续的程序可以直接从空闲列表上申请相应的一个空间地址,进行使用。在这种情况下就会有一个问题。

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

const A = '四个文字';

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


比如我们认为,任何一个空间都会有两部分组成,一个用来存储空间一些元信息比如他的大小,地址,称之为头。还有一部分是专门用于存放数据的叫做域,BC空间认为B对象有2个字的空间,C对象有1个字的空间。这种情况下,虽然对他进行了回收,加起来好像是释放了3个字的空间,但是由于它们中间被A对象去分割着。所以在释放完成之后其实还是分散的也就是地址不连续。

这点很重要,后续想申请的空间地址大小刚好1.5个字。这种情况下,如果直接找到B释放的空间会发现是多了的,因为还多了0.5个,如果直接去找C释放的空间又发现不够,因为是1个。所以这就带来了标记清除算法中最大的问题,空间的碎片化。

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

这就是标记清除算法优点和缺点,简单的整理一下就是优点是可以解决循环引用不能回收的问题,缺点是说会产生空间碎片化的问题,不能让空间得到最大化的使用。

7. 标记整理算法

V8中标记整理算法会被频繁的使用到,下面来看一下是如何实现的。

首先认为标记整理算法是标记清除的增强操作,他们在第一个阶段是完全一样的,都会去遍历所有的对象,然后将可达活动对象进行标记。第二阶段清除时,标记清除是直接将没有标记的垃圾对象做空间回收,标记整理则会在清除之前先执行整理操作,移动对象的位置,让他们能够在地址上产生连续。

假设回收之前有很多的活动对象和非活动对象,以及一些空闲的空间,当执行标记操作的时候,会把所有的活动对象进行标记,紧接着会进行整理的操作。整理其实就是位置上的改变,会把活动对象先进行移动,在地址上变得连续。紧接着会将活动对象右侧的范围进行整体的回收,这相对标记清除算法来看好处是显而易见的。

因为在内存里不会大批量出现分散的小空间,从而回收到的空间都基本上都是连续的。这在后续的使用过程中,就可以尽可能的最大化利用所释放出来的空间。这个过程就是标记整理算法,会配合着标记清除,在V8引擎中实现频繁的GC操作。

8. 执行时机

首先是引用计数,他的可以及时回收垃圾对象,只要数值0的就会立即让GC找到这片空间进行回收和释放。正是由于这个特点的存在,引用计数可以最大限度的减少程序的卡顿,因为只要这个空间即将被占满的时候,垃圾回收器就会进行工作,将内存进行释放,让内存空间总有一些可用的地方。

标记清除不能立即回收垃圾对象,而且他去清除的时候当前的程序其实是停止工作的。即便第一阶段发现了垃圾,也要等到第二阶段清除的时候才会回收掉。

标记整理也不能立即回收垃圾对象。

9. V8引擎

众所周知V8引擎是目前市面上最主流的JavaScript执行引擎,日常所使用的chrome浏览器以及NodeJavaScript平台都在采用这个引擎去执行JavaScript代码。对于这两个平台来看JavaScript之所以能高效的运转,也正是因为V8的存在。V8的速度之所以快,除了有一套优秀的内存管理机制之外,还有一个特点就是采用及时编译。

之前很多的JavaScript引擎都需要将源代码转成字节码才能执行,而V8可以将源码翻译成直接执行的机器码。所以执行速度是非常快的。

V8还有一个比较大的特点就是他的内存是有上限的,在64位操作系统下,上限是不超过1.5G,在32位的操作系统中数值是不超过800M

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

第一V8本身就是为了浏览器制造的,所以现有的内存大小足够使用了。再有V8内部所实现的垃圾回收机制也决定了他采用这样一个设置是非常合理的。因为官方做过一个测试,当垃圾内存达到1.5G的时候,V8去采用增量标记的算法进行垃圾回收只需要消耗50ms,采用非增量标记的形式回收则需要1s。从用户体验的角度来说1s已经算是很长的时间了,所以就以1.5G为界了。

1. 垃圾回收策略

在程序的使用过程中会用到很多的数据,数据又可以分为原始的数据和对象类型的数据。基础的原始数据都是由程序的语言自身来进行控制的。所以这里所提到的回收主要还是指的是存活在堆区里的对象数据,因此这个过程是离不开内存操作的。

V8采用的是分代回收的思想,把内存空间按照一定的规则分成两类,新生代存储区和老生代存储区。有了分类后,就会针对不同代采用最高效的GC算法,从而对不同的对象进行回收操作。这也就意味着V8回收会使用到很多的GC算法。

首先,分代回收算法肯定是要用到的,因为他必须要做分代。紧接着会用到空间的复制算法。除此以外还会用到标记清除和标记整理。最后为了去提高效率,又用到了标记增量。

2. 回收新生代对象

首先是要说明一下V8内部的内存分配。因为他是基于分代的垃圾回收思想,所以在V8内部是把内存空间分成了两个部分,可以理解成一个存储区域被分成了左右两个区域。左侧的空间是专门用来存放新生代对象,右侧专门存放老生代对象。新生代对象空间是有一定设置的,在64位操作系统中大小是32M,在32位的操作系统中是16M

新生代对象其实指的就是存活时间较短的。比如说当前代码内有个局部的作用域,作用域中的变量在执行完成过后就要被回收,在其他地方比如全局也有一个变量,而全局的变量肯定要等到程序退出之后才会被回收。所以相对来说新生代就指的是那些存活时间比较短的那样一些变量对象。

针对新生代对象回收所采用到的算法主要是复制算法和标记整理算法,首先会将左侧一部分小空间也分成两个部分,叫做FromTo,而且这两个部分的大小是相等的,将From空间称为使用状态,To空间叫做空闲状态。有了这样两个空间之后代码执行的时候如果需要申请空间首先会将所有的变量对象都分配至From空间。也就是说在这个过程中To是空闲的,一旦From空间应用到一定的程度之后,就要触发GC操作。这个时候就会采用标记整理对From空间进行标记,找到活动对象,然后使用整理操作把他们的位置变得连续,便于后续不会产生碎片化空间。

做完这些操作以后,将活动对象拷贝至To空间,也就意味着From空间中的活动对象有了一个备份,这时候就可以考虑回收了。回收也非常简单,只需要把From空间完全释放就可以了,这个过程也就完成了新生代对象的回收操作。

总结一下就是新生代对象的存储区域被一分为二,而且是两个等大的,在这两个等大的空间中,起名FromTo,当前使用的是From,所有的对象声明都会放在这个空间内。触发GC机制的时候会把活动对象全部找到进行整理,拷贝到To空间中。拷贝完成以后我们让FromTo进行空间交换(也就是名字的交换),原来的To就变成了From,原来的From就变成了To。这样就算完成了空间的释放和回收。

接下来针对过程的细节进行说明。首先在这个过程中肯定会想到的是,如果在拷贝时发现某一个变量对象所指的空间,在当前的老生代对象里面也会出现。这个时候就会出现一个所谓的叫晋升的操作,就是将新生代的对象,移动至老生代进行存储。

至于什么时候触发晋升操作一般有两个判断标准,第一个是如果新生代中的某些对象经过一轮GC之后他还活着。这个时候就可以把他拷贝至老年代存储区,进行存储。除此之外如果当前拷贝的过程中,发现To空间的使用率超过了25%,这个时候也需要将这一次的活动对象都移动至老生代中存放。

为什么要选择25%呢?其实也很容易想得通,因为将来进行回收操作的时候,最终是要把From空间和To空间进行交换的。也就是说以前的To会变成From,而以前的From要变成To,这就意味着To如果使用率达到了80%,最终变成活动对象的存储空间后,新的对象好像存不进去了。简单的说明就是To空间的使用率如果超过了一定的限制,将来变成使用状态时,新进来的对象空间好像不那么够用,所以会有这样的限制。

简单总结一下就是当前内存一分为二,一部分用来存储新生代对象,至于什么是新生代对象可以认为他的存活时间相对较短。然后可以去采用标记整理的算法,对From空间进行活动对象的标记和整理操作,接着把他们拷贝To空间。最后再置换一下两个空间的状态,那此时也就完成了空间的释放操作。

3. 回收老生代对象

老生代对象存放在内存空间的右侧,在V8中同样是有内存大小的限制,在64位操作系统中大小是1.4G, 在32位操作系统中是700M

老生代对象指的是存活时间较长的对象,例如之前所提到的在全局对象中存放的一些变量,或者是一些闭包里面放置的变量有可能也会存活很长的时间。针对老生代垃圾回收主要采用的是标记清除,标记整理和增量标记三个算法。

使用时主要采用的是标记清除算法完成垃圾空间的释放和回收,标记清除算法主要是找到老生代存储区域中的所有活动对象进行标记,然后直接释放掉那些垃圾数据空间就可以了。显而易见这个地方会存在一些空间碎片化的问题,不过虽然有这样的问题但是V8的底层主要使用的还是标记清除的算法。因为相对空间碎片来说他的提升速度是非常明显的。

在什么情况下会使用到标记整理算法呢?当需要把新生代里的内容向老生代中移动的时候,而且这个时间节点上老生代存储区域的空间又不足以存放新生代存储区移过来的对象。这种情况下就会触发标记整理,把之前的一些锁片空间进行整理回收,让程序有更多的空间可以使用。最后还会采用增量标记的方式对回收的效率进行提升。

这里来对比一下新老生代垃圾回收。

新生代的垃圾回收更像是在用空间换时间,因为他采用的是复制算法,这也就意味着每时每刻他的内部都会有一个空闲空间的存在。但是由于新生代存储区本身的空间很小,所以分出来的空间更小,这部分的空间浪费相比带来的时间上的一个提升当然是微不足道的。

在老生代对象回收过程中为什么不去采用这种一分二位的做法呢?因为老生代存储空间是比较大的,如果一分为二就有几百兆的空间浪费,太奢侈了。第二就是老生代存储区域中所存放的对象数据比较多,所以在赋值的过程中消耗的时间也就非常多,因此老生代的垃圾回收是不适合使用复制算法来实现的。

至于之前所提到的增量标记算法是如何优化垃圾回收操作的呢?首先分成两个部分,一个是程序执行,另一个是垃圾回收。

首先明确垃圾回收进行工作的时候是会阻塞当前JavaScript程序执行的,也就是会出现一个空档期,例如程序执行完成之后会停下来执行垃圾回收操作。所谓的标记增量简单来讲就是将整段的垃圾回收操作拆分成多个小步骤,组分片完成整个回收,替代之前一口气做完的垃圾回收操作。

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

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

程序首先运行的时候是不需要进行垃圾回收的,一旦当他触发了垃圾回收之后,无论采用的是何种算法,都会进行遍历和标记操作,这里针对的是老生代存储区域,所以存在遍历操作。在遍历的过程中需要做标记,标记之前也提到过可以不一口气做完,因为存在直接可达和间接可达操作,也就是说如果在做的时候,第一步先找到第一层的可达对象。然后就可以停下来,让程序再去执行一会。如果说程序执行了一会以后,再继续让GC机做第二步的标记操作,比如下面还有一些子元素也是可达的,那就继续做标记。标记一轮之后再让GC停下来,继续回到程序执行,也就是交替的去做标记和程序执行。

最后标记操作完成以后再去完成垃圾回收,这段时间程序就要停下来,等到垃圾回收操作完成才会继续执行。虽然这样看起来程序停顿了很多次,但是整个V8最大的垃圾回收也就是当内存达到1.5G的时候,采用非增量标记的形式进行垃圾回收时间也不超过1s,所以这里程序的间断是合理的。而且这样一来最大限度的把以前很长的一段停顿时间直接拆分成了更小段,针对用户体验会显得更加流程一些。

4. V8垃圾回收总结

首先要知道V8引擎是当前主流的JavaScript执行引擎,在V8的内部内存是设置上限的,这么做的原因是第一他本身是为浏览器而设置的,所以在web应用中这样的内存大小是足够使用的。第二就是由他内部的垃圾回收机制来决定的,如果把内存设置大一些这个时候回收时间最多可能就超过了用户的感知,所以这里就设置了上限数值。

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

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

10. Performance工具介绍

GC工作目的就是为了让内存空间在程序运行的过程中,出现良性的循环使用。所谓良性循环的基础其实就是要求开发者在写代码的时候能够对内存空间进行合理的分配。但是由于ECMAScript中并没有给程序员提供相应的操作内存空间的API,所以是否合理好像也不知道,因为他都是由GC自动完成的。

如果想判断整个过程内存使用是否合理,必须想办法能够时刻关注到内存的变化。所以就有了这样一款工具可以提供给开发者更多的监控方式,在程序运行过程中帮助开发者完成对内存空间的监控。

通过使用Performance可以对程序运行过程内存的变化实时的监控。这样就可以在程序的内存出现问题的时候直接想办法定位到出现问题的代码快。下面来看一下Performance工具的基本使用步骤。

首先打开浏览器,在地址栏输入网址。输入完地址之后不建议立即进行访问,因为想把最初的渲染过程记录下来,所以只是打开界面输入网址即可。紧接着打开开发人员工具面板(F12),选择性能选项。开启录制功能,开启之后就可以访问目标网址了。在这个页面上进行一些操作,过一段时间后停止录制。

就可以得到一个报告,在报告当中就可以分析跟内存相关的信息了。录制后会有一些图表的展示,信息也非常的多,看起来比较麻烦。这里主要关注与内存相关的信息,有一个内存的选项(Memory)。默认情况下如果没有勾选需要将它勾选。页面上可以看到一个蓝色的线条。属于整个过程中我内存所发生的变化,可以根据时序,来看有问题的地方。如果某个地方有问题可以具体观察,比如有升有降就是没问题的。

1. 内存问题的体现

当程序的内存出现问题的时候,具体会表现出什么样的形式。

首先第一条,界面如果出现了延迟加载或者说经常性的暂停,首先限定一下网络环境肯定是正常的,所以出现这种情况一般都会去判定内存是有问题的,而且与GC存在着频繁的垃圾回收操作是相关的。也就是代码中肯定存在瞬间让内存爆炸的代码。这样的代码是不合适的需要去进行定位。

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

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

这就是关于应用程序在执行过程中如果遇到了内存出现问题的情况,具体的体现可以结合Performance进行内存分析操作,从而定位到有问题的代码,修改之后让应用程序在执行的过程中显得更加流畅。

2. 监控内存的几种方式

内存出现的问题一般归纳为三种:内存泄露,内存膨胀,频繁的垃圾回收。当这些内容出现的时候,该以什么样的标准来进行界定呢?

内存泄露其实就是内存持续升高,这个很好判断,当前已经有很多种方式可以获取到应用程序执行过程中内存的走势图。如果发现内存一直持续升高的,整个过程没有下降的节点,这也就意味着程序代码中是存在内存泄露的。这个时候应该去代码里面定位相应的模块。

内存膨胀相对的模糊,内存膨胀的本意指的是应用程序本身,为了达到最优的效果,需要很大的内存空间,在这个过程中也许是由于当前设备本身的硬件不支持,才造成了使用过程中出现了一些性能上的差异。想要判定是程序问题还是设备问题,应该多做一些测试。这个时候可以找到那些深受用户喜爱的设备,在他们上面运行应用程序,如果整个过程中所有的设备都表现出了很糟糕的性能体验。这就说明程序本身是有问题的,而不是设备有问题。这种情况就需要回到代码里面,定位到内存出现问题的地方。

具体有哪些方式来监控内存的变化,主要还是采用浏览器所提供的一些工具。

浏览器所带的任务管理器,可以直接以数值的方式将当前应用程序在执行过程中内存的变化体现出来。第二个是借助于Timeline时序图,直接把应用程序执行过程中所有内存的走势以时间点的方式呈现出来,有了这张图就可以很容易的做判断了。再有浏览器中还会有一个叫做堆快照的功能,可以很有针对性的查找界面对象中是否存在一些分离的DOM,因为分离DOM的存在也就是一种内存上的泄露。

至于怎样判断界面是否存在着频繁的垃圾回收,这就需要借助于不同的工具来获取当前内存的走势图,然后进行一个时间段的分析,从而得出判断。

3. 任务管理器监控内存

一个web应用在执行的过程中,如果想要观察他内部的一个内存变化,是可以有多种方式的,这里通过一段简单的demo来演示一下,可以借助浏览器中自带的任务管理器监控脚本运行时内存的变化。

在界面中放置一个元素,添加一个点击事件,事件触发的时候创建一个长度非常长的一个数组。这样就会产生内存空间上的消耗。

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


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

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

第一列内存表示的是原生内存,也就是当前界面会有很多DOM节点,这个内存指的就是DOM节点所占据的内存,如果这个数值在持续的增大,就说明界面中在不断的创建DOM元素。

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

以这个界面为例,可以发现小括号的值一直是个稳定的数字没有发生变化,也就意味着当前页面是没有内存增长的。此时可以再去触发一下click事件(点击按钮),多点几次,完成以后就发现小括号里面的数值变大了。

通过这样的过程就可以借助当前的浏览器任务管理器来监控脚本运行时整个内存的变化。如果当前JavaScript内存列小括号里面的数值一直增大那就意味着内存是有问题的,当然这个工具是没有办法定位的,他只能发现问题,无法定位问题。

4. TimeLine记录内容

在之前已经可以使用浏览器自带的任务管理器对脚本执行中内存的变化去进行监控,但是在使用的过程中可以发现,这样的操作更多的是用于判断当前脚本的内存是否存在问题。如果想要定位问题具体和什么样的脚本有关,任务管理器就不是那么好用了。

这里再介绍一个通过时间线记录内存变化的方式来演示一下怎样更精确的定位到内存的问题跟哪一块代码相关,或者在什么时间节点上发生的。

首先放置一个DOM节点,添加点击事件,在事件中创建大量的DOM节点来模拟内存消耗,再通过数组的方式配合着其他的方法形成一个非常长的字符串,模拟大量的内存消耗。

<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按钮,稍等几秒后,点击停止按钮。完成以后就生成了一个图表,密密麻麻的东西看起来可能会有些头疼,只关注下想要看到的信息就可以了。

内存如果没有勾选的话是不会监控内存变化的,需要先勾选内存,勾选之后页面上就出现了内存的走势曲线图。里面会包含很多信息,给出来了几中颜色的解释。蓝色的是JavaScript堆,红色表示当前的文档,绿色是DOM节点,棕色是监听器,紫色是CPU内存。

为了便于观察可以只保留JavaScript堆,其他的取消勾选隐藏掉。可以看到这个脚本运行过程中到目前为止他的JavaScript堆的情况走势。当前这个工具叫时序图,也就是在第一栏,以毫秒为单位,记录了整个页面从空白到渲染结束到最终停状态,这个过程中整个界面的变化。如果愿意,可以点进去看一下当前的界面形态,如果只是关注内存,只看内存的曲线图就可以了。

当这个页面最开始打开的时候其实很长一段时间都是平稳的状态,没有太多的内存消耗。原因在根本没有点击add。然后紧接着在某一个时间点上突然之间内存就上去了,上去之后是一段平稳的状态,这是因为点击了add之后这里的内存肯定是瞬间暴涨的,然后紧接着暴涨之后我们任何操作,所以这时候肯定是平稳。

然后紧接着平稳之后又下降了,这就是之前所提到的,浏览器本身也是具有垃圾回收机制的,当的脚本运行稳定之后,GC可能在某个时间点上就开始工作了,会发现有一些对象是非活动的,就开始进行回收,所以一段平稳之后就降下去了。降下去之后又会有一些小的浮动,属于正常的活动开销。后来又有几次连续的点击,这个连续的点击行为可能又造成内存的飙升,然后不操作之后又往下降。

通过这样一张内存走势图,可以得出的结论是,脚本里面内存是非常稳定的,整个过程有涨有降,涨是申请内存,降是用完之后我GC在正常的回收内存。

一旦看到内存的走势是直线向上走,也就意味着他只有增长而没有回收,必然存在着内存消耗,更有可能是内存泄漏。可以通过上面的时序图定位问题,当发现某一个节点上有问题的时候,可以直接在这里面定位到那个时间节点,可以在时序图上进行拖动查看每一个时间节点上的内存消耗。还可以看到界面上的变化,就可以配合着定位到是哪一块产生了这样一个内存的问题。

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

5. 堆快照查找分离DOM

这里简单说明一下堆快照功能工作的原理,首先他相当于找到JavaScript堆,然后对它进行照片的留存。有了照片以后就可以看到它里面的所有信息,这也就是监控的由来。堆快照在使用的时候非常的有用,因为他更像是针对分离DOM的查找行为。

界面上看到的很多元素其实都是DOM节点,而这些DOM节点本应该存在于一颗存活的DOM树上。不过DOM节点会有几种形态,一种是垃圾对象,一种是分离DOM。简单的说就是如果这个节点从DOM树上进行了脱离,而且在JavaScript代码当中没有再引用的DOM节点,他就成为了一个垃圾。如果DOM节点只是从DOM树上脱离了,但是在JavaScript代码中还有引用,就是分离DOM。分离DOM在界面上是看不见的,但是在内存中是占据着空间的。

这种情况就是一种内存泄露,可以通过堆快照的功能把他们找出来,只要能找得到,就可以回到代码里,针对这些代码进行清除从而让内存得到一些释放,脚本在执行的时候也会变得更加迅速。

html里面放入btn按钮,添加点击事件,点击按钮的时候,通过JavaScript语句去模拟相应的内存变化,比如创建DOM节点,为了看到更多类型的分离DOM,采用ul包裹liDOM节点创建。先在函数中创建ul节点,然后使用循环的方式创建多个li放在ul里面,创建之后不需要放在页面上,为了让代码引用到这个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>


简单说明就是创建了ulli节点,但是并没有将他们放在页面中,只是通过JavaScript变量引用了这个节点,这就是分离DOM

打开浏览器调试工具,选中内存面板。进入以后可以发现堆快照的选项。这里做两个行为的测试,第一个是在没有点击按钮的情况下,直接获取当前的快照,在这个快照里面就是当前对象的具体展示,这里有一个筛选的操作,直接检索deta关键字,可以发现没有内容。

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

这次就会发现,快照2里面搜索到了,很明显这几个就是代码中所创建的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所在的位置,然后去想办法把他给清除掉。

6. 判断是否存在频繁GC

这里说一下如何确定当前web应用在执行过程中是否存在着频繁的垃圾回收。当GC去工作的时候应用程序是停止的。所以GC频繁的工作对web应用很不友好,因为会处于死的状态,用户会感觉到卡顿。

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

这里给出两种方式,第一种是可以通过timeline时序图的走势来判断,在性能工具面板中对当前的内存走势进行监控。如果发现蓝色的走势条频繁的上升下降。就意味着在频繁的进行垃圾回收。出现这样的情况之后必须定位到相应的时间节点,然后看一下具体做了什么样的操作,才造成这样现象的产生,接着在代码中进行处理就可以了。

任务管理器在做判断的时候会显得更加简单一些,因为他就是一个数值的变化,正常当界面渲染完成之后,如果没有其他额外的操作,那么无论是DOM节点内存,还是我们JavaScript内存,都是一个不变化的数值,或者变化很小。如果这里存在频繁的GC操作时,这个数值的变化就是瞬间增大,瞬间减小,这样的节奏,所以看到这样的过程也意味着代码存在频繁的垃圾回收操作。

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



点击左下角阅读原文,到 SegmentFault 思否社区 和文章作者展开更多互动和交流,扫描下方”二维码“或在“公众号后台回复“ 入群 ”即可加入我们的技术交流群,收获更多的技术文章~


- END -


浏览 30
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报