JS性能优化(内存管理、V8堆栈执行)

内存管理

像C语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()和free()。相反,JavaScript是在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。 释放的过程称为垃圾回收。这个“自动”是混乱的根源,并让JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理。

内存是一个可以进行读写操作单元组成的可使用空间,其生命周期是(JavaScript):

内存的分配:变量的声明内存的使用:变量的使用(对变量进行读写操作)内存的释放:变量使用完毕,或者置空(人为设置为null)

所有编程语言中,第二部分是明确的,对内存空间的使用都是随着读写操作而进行的,但是第一和第三部分在一些底层语言下是明确的,比如C++就需要申请一片内存空间使用,不用时就需要调用API释放内存,而JavaScript在内存的分配和释放都是隐式的:

var a = 123 // 内存分配

a += 3 // 内存使用

a = null // 内存释放

可以看出,JavaScript 在变量声明时就隐式的分配了一块内存存储变量 a ,但是如果 a 长期不使用的话就会被回收掉,进行内存释放,也可以设置为 null ,进行人为释放。

JavaScript中的垃圾回收

JavaScript中的垃圾:

JavaScript的内存管理是自动的对象不再被引用时是垃圾对象不能在根上被访问到时是垃圾

JavaScript中的可达对象

可以访问到的对象就是可达对象(引用、作用域链)可达的标准就是从根出发是否能够被找到JavaScript中的根可以认为是全局变量对象

一般的对象引用:

let obj = { name: 'tom' }

let foo = obj

foo.age = 12

obj = null

console.log(foo) // { name: 'tom', age: 12 }

console.log(obj) // null

以上我们声明了一个对象 obj,内存空间会在栈内存分配一个内存地址 00x001 给 obj 这个变量名称,并在堆内存分配一块空间给 { name: tom } ,00x001 指向堆内存中的这份数据,然后又声明了一个变量 foo ,将 obj 赋值给 foo ,那么内存又会分配一个地址 00x002 同样指向堆内存中的{ name: tom },后续在 foo 上添加属性 age,此时堆内存中的数据就变成了 {name: tom, age: 12},最后我们虽然把 obj 置为 null,但是 foo 对这份数据还存在引用,所以只会标记清除 obj 在栈内存中的内存地址 00X001,不会标记堆内存中的实际数据,这就是对象引用可能导致的内存泄漏。

循环对象引用

const objGroup = (obj1, obj2) => {

obj1.next = obj2

obj2.prev = obj1

return {

o1: obj1,

o2: obj2

}

}

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

console.log(obj)

MacBook-Pro:lagoufed-test xxx$ node ./task05/js垃圾回收.js

{

o1: {

name: 'obj1',

next: { name: 'obj2', prev: [Circular *1] }

},

o2: {

name: 'obj2',

prev: { name: 'obj1', next: [Circular *2] }

}

}

上面打印结果可以看出 Circular 已经标记出了对象的循环引用,可以从下图形象地表示出来:

从根 global variable 查找都可以找到 obj1 和 obj2,那么此时使用 delete 删除 obj 的 o1 和 obj2 的 prev:

此时就无法再从根上访问到 obj,它也就成为了垃圾,等待被回收。

GC算法

GC 就是垃圾回收机制的简写,GC 可以找到内存中的垃圾,进行回收、释放。

GC 中的垃圾分为两类:

程序中不再需要使用的对象程序中不再访问到的对象

GC 其实就是一种机制,在垃圾回收器中完成一定的工作,这个工作就是查找内存中的垃圾,回收垃圾并释放内存空间,而算法就是 GC 在执行垃圾回收过程中遵循的一般规则。

GC 算法大体上可以分为以下几类:

引用计数标记清除标记整理分代回收

1.引用计数

引用计数算法很简单,它实际上是通过在对象头中分配一个空间来保存该对象被引用的次数。如果该对象被其它对象引用,则它的引用计数加一,如果删除对该对象的引用,那么它的引用计数就减一,当该对象的引用计数为0时,那么该对象就会被回收。

引用计数优点:

发现垃圾时立即回收最大限度减少程序暂停:正是引用计数算法能够立即回收引用次数为0的对象,才能保障内存不会被对象长时占用而导致内存爆满,避免内存不足影响程序运行

引用计数缺点:

无法回收循环引用的对象,循环引用的对象的引用次数始终会维持在2时间消耗过长,对象的引用次数和对象一样也是存储在内存中,GC需要不停的去内存中查找引用次数为0的对象,一旦对象非常多时,消耗的时间也会非常多

2.标记清除

标记清除分为标记和清除两步骤,第一次遍历查找那些没有被引用的对象,对其进行标记,第二次遍历就去找到这些被标记的对象进行回收。

以上图示 不是标准 的标记清除,只是形象的说明标记清除是从根递归遍历,查找活动对象,再查找活动对象关联的活动对象,只要碰到不能访问到的对象,就标记为待回收,直到查找结束,将这些待回收对象放到 空闲链表 里,等到需要分配内存时,再从 空闲链表 取出大小合适的内存,释放掉再进行新的分配。

标记清除的优点:

相对于引用计数来看计算简单,引用计数需要维护单独的一片内存空间存储对象的引用次数,对象的引用次数计算相当复杂,难以维护,而标记清除只需要标记那些不活动的对象,根上访问不到的对象,标记结束就清除。

标记清除的缺点:

内存碎片化:因为不活动对象都是分散的,标记清除后放到空闲链表中都是碎片化的,把具有引用关系的对象安排在堆中较远的位置,就会增加访问所需的时间。分配速度:因为分配新内存需要找到空闲链表中最大的那块内存,就需要遍历到最后,耗时较长。

关于标记整理大部分的说法是标记活动对象,对于标记算法的实现不完全一致的,也有的说是标记非活动对象,虽然算法不尽相同,其原理都是一致的。

关于标记清除的扩展阅读:https://www.ituring.com.cn/book/tupubarticle/10955

3.标记整理

因为标记清除收集到的空闲链表都是碎片化的,使用空闲链表中的内存碎片比较耗费时间,那么标记整理正是解决该问题的。标记整理第一步骤和标记清除相似,把活动对象标记出来,第二部是对活动对象进行整理,向一端移动,然后边界外的都是非活动对象,且是地址连续性的内存片区,清理和再利用都非常方便。

4.分代回收

V8引擎:

V8是主流的 JavaScript 执行引擎V8是即时编译的V8内存设限 (win64 <= 1.5G, win32 <= 800M)

V8内存设限是有一定原因的:

最初的设计是专门针对浏览器的,以前的浏览器不存在较为复杂的使用场景,分配的内存足够使用V8的垃圾回收机制限制了可分配内存上限

根据官方说法,对于1.5G的垃圾回收堆内存,V8做一次小的垃圾回收需要50ms,做一次非增量式的垃圾回收在1s以上(小垃圾回收和非增量都是垃圾回收策略,后续后讲到)。这些是垃圾回收引起js执行暂停的时间。在这样的时间花销下,前后端都是没法接受的,因此直接限制堆内存是一个合理的选择。

V8垃圾回收策略

采用分代回收的策略内存分为新生代和老生代不同对象采用不同的算法

V8中的GC算法:

分代回收空间复制标记清除标记整理增量标记

以上是V8垃圾回收的一些基本概念,下面我们具体看一看新生代和老生代的垃圾回收机制:

首先V8内存会分割为新生代和老生代两个区域:

新生代分割为两个大小一样的区块:FROM 和 TO,FROM 用来内存分配,TO 是一片空闲空间,当 GC 执行新生代垃圾回收时,会使用标记整理的算法把 FROM 中的活动对象标记整理出来,然后把这些活动对象全部复制到 TO 中,释放 FROM,最后FORM和TO进行交换,完成一次回收周期。当新生代执行回收时存在 晋升 现象,也就是新生代转到老生代中,条件有二:第一个是当发现 TO 中的活动对象 执行过一个 GC 周期;第二个是 TO 的内存空间将要超过 25%。举例:比如函数局部作用域中的变量在函数执行完毕后就不可访问,立即被回收,如果存在闭包,该变量在第一次回收执行时还会放置到 FROM 中,当执行第二次回收时,发现它已经被回收过,那么就直接晋升到老生代中。老生代中存储的对象多数为全局作用域中的数据,或者闭包产生的常驻对象,它常用的回收机制是标记清除,当有新生代晋升时,就会执行一遍标记整理,整理出连续的内存空间放置新生代晋升过来的存活对象。因为回收执行时会暂停JavaScript的运行,所以才会有了增量标记,就是在JavaScript的执行空闲时执行垃圾回收。老生代中存活对象较多,所以分配的空间较大,一般为win64 1.4G,win32 700M。新生代执行空间复制是空间换时间,空间复杂度大于时间复杂度,所以分配的空间较小,避免了复制难度。

最后看一下增量标记示意图:

performance

比较客观的从多方面表示浏览器应用的性能:

首先点击左上角的 ● 开启录制,然后在应用中进行一系列的操作,然后点击终止,等待一段分析时间后就可以看到操作过程中的性能监控。

内存问题和监控方法

内存问题的外在表现

界面加载缓慢或者持续性暂停(内存泄漏)页面持续性出现糟糕的交互体现(内存满溢)页面性能随着时间的延长变得越来越差(频繁性垃圾回收)

内存问题的界定标准

内存泄漏:内存使用持续升高内存满溢:在多数设备上都存在性能问题频繁的垃圾回收:通过内存变化图进行分析

监控内存的几种方法

浏览器任务管理器TimeLine 时序图分析堆快照查找分离 DOM判断是否存在频繁的垃圾回收

1.浏览器任务管理器

浏览器的任务管理器可以查看当前脚本的实时内存占用情况:

内存占用空间是指原生内存,指当前页面DOM节点所占用的空间,JavaScript使用的内存就是当前JavaScript堆内存,包含活动对象和非活动对象,实际大小就是当前活动对象占用的内存大小。

当点击按钮时,JavaScript的实际使用内存就会增长。

2.TImeLine 记录内存

TimeLine 时序图能够记录下内存消耗的走势:

当我们点击按钮时会向DOM中插入大量的节点,消耗非常多的内存,数组push一个非常长的字符串也会消耗大量内存,所以有三块内存非常活跃的地区(红框中),内存走势陡然拔高,然后趋于平稳,此时垃圾回收机制运行,内存大量释放,走势降低,完整的记录了我们操作界面时的内存使用情况。

3.堆快照查找分离DOM

什么是分离 DOM:

界面元素存活在DOM树上垃圾对象时的DOM节点分离状态的DOM节点

堆快照是JavaScript 堆内存的一个快照,能够记录当前内存的使用情况,我们可以通过查看快照检查是否存在分离DOM导致内存浪费:

上面的代码是创建一个列表,但是这个列表不添加到页面DOM中具体呈现,而是赋值给一个变量,这就形成了分离DOM,可以在堆快照中查找到这个分离DOM:

点击按钮就可以对当前JavaScript堆内存使用生成一张快照:

当我们点击 add 按钮创建列表时,将出现分离DOM(上图),我们可以根据这个来分析代码,查看哪里有使用过后没有清除的分离DOM,将其设置为 null,清除内存,比如点击 clear 按钮就会将这个 HTMLUListElement 清除掉。

4.频繁的GC回收

GC执行时应用程序是处于暂停状态的,平凡的GC操作会导致应用程序假死,在用户层面上就是交互卡顿,响应不流畅,体验较差。

那么怎么来确认频繁的GC操作呢:

通过 TimeLine 查看走势图是否存在平凡的上升下降的过程,如果存在就说明GC操作频繁,此时就可以查看具体在哪个时间点出现频繁的GC操作,回溯到界面上进行漏洞修复。 通过浏览器任务管理器查看标签界面使用的内存是否存在持续增多或减少的情况,如果存在就说明存在频繁的GC操作。

V8引擎工作流程

扫描器 scanner 扫描 JavaScript 代码,进行 词法分析,生成 token 解析器 Parser 进行 语法分析,生成 AST 树(JavaScript语法树,形同于 DOM 树) 解析器分为 预解析 和 全量解析 :

预解析对当前声明未执行的函数只进行外部解析,不解析函数内部具体实现,跳过了未被使用的代码;不生成AST,不创建无变量引用和声明的scopes;依据规范抛出特定错误;解析速度更快。全量解析对当前立即执行的函数进行全量解析,生成 AST,构建具体的 scope 信息,变量引用和声明等,抛出所有解析时出现的语法错误。 未执行的代码,在执行时还会进行一次全量解析,也就是说预解析过得代码,在执行时会进行一次全量解析,会解析两遍。 解释器 Ignition 将 AST 编译为 字节码 编译器 TurboFan 将 字节码 编译成 机器码

详细了解请查看:https://juejin.cn/post/6844904021451505677

堆栈处理

V8 js的执行环境ECS 执行环境栈(ECStack -> execution context stack)EC 执行上下文VO 当前上下文变量对象(包含VO(G))VO(G) 全局变量对象AO私有变量对象GO 全局对象

ECS执行环境栈

一开始执行js代码时,就会在内存中开辟一块栈内存用来专门执行JavaScript代码,它存放所有当前的执行上下文EC,它遵循先进后出的原则,这里的后出指的是在垃圾回收时的释放顺序。

EC执行上下文

当前代码执行的作用域,实质上就是执行环境栈中的一块栈内存,用来存放当前执行的代码,根据全局变量和函数又分为 全局执行上下文 和 函数私有执行上下文:

全局执行上下文:在执行全局js代码之前,就会在ECS中压入EC(G),EC(G)只会存在一个,在页面关闭时就会释放掉,在页面刷新时会先释放所有EC,然后再创建全新的EC(G)。

函数私有执行上下文:函数在执行时会创建其自身的私有执行上下文,有多少函数执行就创建多少个EC(funcxxx),目的是为了保护局部作用域中的变量不受外部影响,它在函数执行完毕会释放,并且 每次函数调用都会创建权限的函数执行上下文。如果某个函数被其他对象引用,那么这个函数执行上下文就会继续存在,直到引用结束才会释放。

VO

存储当前处于活跃状态的变量对象

GO

存储当前处于活跃状态的全局变量对象。

AO

函数自己的作用域中活跃的局部变量对象。

我们所认知的全局对象有两个:window和global,前者隶属于web应用,后者隶属于nodejs应用。它在初始化时就存储了一些全局方法,比如setTimeout、setInterval、process等,而每当我们使用 var 进行变量声明时,就会 创建 VO 和 VO(G) 的映射关系,我们能够通过全局对象引用它,这里不包含let和const创建的变量。

下面通过画图看一个简单的堆栈处理:

引用类型堆栈处理

基本数据类型的数据直接存储在栈中,而引用类型的数据具体值存储于堆(heap)中,栈中存放变量指针,指针对应于变量名,指向堆内存中的具体值:

函数堆栈处理

函数作为引用类型的数据,其函数名类似于变量名,对应于存储在栈内存中的指针,函数体作为一个字符串存储于堆内存中,栈内存中的指针指向堆内存中的函数体。

函数因为有其自身的作用域,所以函数的执行上下文是私有的,称之为 EC(xxx),内部变量称之为私有变量对象 AO(xxx)。

函数在执行时才进入执行上下文调用栈,执行完毕退出 ECS,等待 GC 回收。

闭包的堆栈处理

闭包是一种保护局部变量的机制,函数内部对局部变量产生引用关系时,闭包就产生了。存在闭包的函数在函数调用完毕时不会弹出调用栈,只有当这层引用关系结束才会弹出调用栈,等待GC回收(引用内部变量的外部存在被回收)。

闭包和垃圾回收

函数形参被外部引用形成闭包,形参作为函数私有变量对象会一直存在于函数私有执行上下文中,直至这层引用关系结束(人为置空)才会被释放。

在大量使用闭包时一定要记得在使用完毕后进行手动释放,否则会造成大量的内存占用,导致内存满溢。

分析一下回收情况:

EC(G) 使用的内存会在浏览器页签关闭或者刷新当前页签时进行释放,并生成全新的EC(G)(整个ECS都会清空重置)。fn 等于 foo 的返回值,此时 foo 被引用,0x000 无法释放,FOO1 无法退出执行栈foo(6) 执行完毕生成一个临时的匿名函数 0x002,暂时缓存,FOO2 退出执行栈foo(6)(7) 执行完毕后再无引用,临时的匿名函数 0x002 释放,AY 退出执行栈fn1 和 fn2 在调用完毕后再无引用,都会退出执行栈

由此可见还未被回收的只有EC(G)、 fn 、创建 fn 的 FOO1、foo,其中EC(G)在页面关闭或刷新时会释放回收,而 foo 因为被 fn 引用,无法回收,此时我们就需要手动进行回收:fn = null,清空 fn,使 FOO1 退出执行栈;foo = null,清空 foo。

底层原理:垃圾回收算法是如何设计的?

精彩文章

评论可见,请评论后查看内容,谢谢!!!评论后请刷新页面。