v8及其垃圾回收机制

972 阅读16分钟

概览

  • v8:JIT技术、AST、作用域、字节码、
  • v8内存管理
  • 栈:操作系统会自动进行内存分配和内存释放
  • 堆:由JS引擎(如Chrome的V8)手动进行释放
  • 新生代、scavenge算法、from to
  • 老生代、标记清除、标记整理、增量标记、惰性清理、并行并发

1. v8

  • V8引擎是一个JavaScript引擎实现

1.1 语言的分类

1.1.1 解释执行

  • 先将源代码通过解析器转成中间代码,再用解释器执行中间代码,输出结果
  • 启动快,执行慢

1.1.2 编译执行

  • 先将源代码通过解析器转成中间代码,再用编译器把中间代码转成机器码,最后执行机器码,输出结果
  • 启动慢,执行快

1.2 V8执行过程

  • V8采用的是解释和编译两种方式,这种混合使用的方式称为JIT技术
  • 第一步先由解析器生成抽象语法树和相关的作用域
  • 第二步根据AST和作用域生成字节码,字节码是介于AST和机器码的中间代码
  • 然后由解释器直接执行字节码,也可以让编译器把字节码编译成机器码后再执行
  • jsvu可以快速安装V8引擎
  • V8源码编译出来的可执行程序名为d8,d8是V8自己的开发工具shell

image.png

1.2.1 抽象语法树

var a = 1;
var b = 2;
var c = a + b;

  • 抽象语法树的结构
d8 --print-ast 4.js
[generating bytecode for function: ]
--- AST ---
FUNC at 0
. KIND 0
. SUSPEND COUNT 0
. NAME ""
. INFERRED NAME ""
. DECLS
. . VARIABLE (00000278B965EB88) (mode = VAR) "a"
. . VARIABLE (00000278B965EC78) (mode = VAR) "b"
. . VARIABLE (00000278B965EDB0) (mode = VAR) "c"
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 8
. . . INIT at 8
. . . . VAR PROXY unallocated (00000278B965EB88) (mode = VAR) "a"
. . . . LITERAL 1
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 20
. . . INIT at 20
. . . . VAR PROXY unallocated (00000278B965EC78) (mode = VAR) "b"
. . . . LITERAL 2
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 32
. . . INIT at 32
. . . . VAR PROXY unallocated (00000278B965EDB0) (mode = VAR) "c"
. . . . ADD at 34
. . . . . VAR PROXY unallocated (00000278B965EB88) (mode = VAR) "a"
. . . . . VAR PROXY unallocated (00000278B965EC78) (mode = VAR) "b"

1.2.2 作用域

  • 作用域是一个抽象的概念,它描述了一个变量的生命周期,比如哪些变量是在哪里声明的,哪些变量是在哪里使用的
d8 --print-scopes 1.js
Global scope:
global { // (000001D42188BEC0) (0, 38)
  // will be compiled
  // 1 stack slots
  // temporary vars:
  TEMPORARY .result;  // (000001D42188C730) local[0]
  // local vars:
  VAR c;  // (000001D42188C670)
  VAR b;  // (000001D42188C538)
  VAR a;  // (000001D42188C448)
}

1.2.3 字节码

  • 字节码是机器码的抽象表示
  • 源代码直接编译成机器码编译时间太长,体积太大,不适合移动端
  • 编译成字节码编译时间短,体积小
  • bytecodes.h
  • FeedBack Vector slot(反馈向量槽)是一个数组,是用来给优化编译器提供信息的
  • 字节码执行过程
var a = 10;
var b = 20;
var c = a + b;
d8 --print-bytecode  4.js
LdaConstant [0] 从常量池中加载索引0的常量到累加寄存器中
Star r1 把累加器的值保存到目标寄存器中
LdaZero0保存到累加寄存器中
Star r2 把累加器的值0保存到目标寄存器中
Mov <closure>, r3 保存r3寄存器的值
CallRuntime [DeclareGlobals], r1-r3

StackCheck    检查栈是否溢出

LdaSmi [10]   加载10到累加寄存器中
StaGlobal [1] 把累加寄存器的值保存到常量池索引1LdaSmi [20]   加载20到累加寄存器中
StaGlobal [2] 把累加寄存器的值保存到常量池索引2LdaGlobal [1] 从常量池加载索引1到累加寄存器
Star r1       把累加器的值10保存到目标计数器中
LdaGlobal [2] 从常量池加载索引2的值20到累加寄存器
Add r1  把r1寄存器的值加到累加寄存器中,累加寄存器值为30
StaGlobal [3] 把累加寄存器的值保存到常量池索引3LdaUndefinedUndefined保存到累加寄存器中
Return        返回累加寄存器中的值

1.2.4 编译器优化

function sum() {
    let a = 1;
    let b = 2;
    return a + b;
}
for (let i = 0; i < 10000; i++) {
    sum();
}
d8 --trace-opt sum.js
[marking 0x02ccc2ba2279 <JSFunction (sfi = 000002CCC2BA2091)> for optimized recompilation, reason: small function, ICs with typeinfo: 4/4 (100%), generic ICs: 0/4 (0%)]
[marking 0x02ccc2ba2339 <JSFunction sum (sfi = 000002CCC2BA2141)> for optimized recompilation, reason: small function, ICs with typeinfo: 1/1 (100%), generic ICs: 0/1 (0%)]
[compiling method 0x02ccc2ba2339 <JSFunction sum (sfi = 000002CCC2BA2141)> using TurboFan]

2. V8内存管理

  • 程序运行需要分配内存
  • V8也会申请内存,申请的内存又会分为堆内存和栈内存

2.1 栈

  • 栈用于存放JS中的基本类型和引用类型指针
  • 栈的空间是连续的,增加删除只需要移动指针,操作速度非常快
  • 栈的空间是有限的,当栈满了,就会抛出一个错误
  • 栈一般是在执行函数时创建的,在函数执行完毕后,栈就会被销毁

2.2 堆

  • 如果不需要连续空间,或者申请的内存较大,可以使用堆
  • 堆主要用于存储JS中的引用类型
const v8 = require('v8');
const heapSpace = v8.getHeapSpaceStatistics();
function format(size) {
    return `${(size / 1024 / 1024).toFixed(2)}M`.padEnd(10, ' ');
}
console.log(`${"空间名称".padEnd(20, ' ')} 空间大小 已用空间大小 可用空间大小 物理空间大小`);
for (let i = 0; i < heapSpace.length; i++) {
    const space = heapSpace[i];
    console.log(`${space.space_name.padEnd(23, ' ')}`,
        `${format(space.space_size)}`,
        `${format(space.space_used_size)}`,
        `${format(space.space_available_size)}`,
        `${format(space.physical_space_size)}`);
}

image.png

2.2.1 堆空间分类

new_large_object_space

2.2.1.1 新生代(new space)
  • 新生代内存用于存放一些生命周期比较短的对象数据
2.2.1.2 老生代(old space)
  • 老生代内存用于存放一些生命周期比较长的对象数据

  • new space的对象进行两个周期的垃圾回收后,如果数据还存在new space中,则将他们存放到old space

  • old space又可以分为两部分,分别是Old pointer space和Old data space

    • Old pointer space 存放GC后surviving的指针对象
    • Old data space 存放GC后surviving的数据对象
  • Old Space使用标记清除和标记整理的方式进行垃圾回收

2.2.1.3 Code space
  • 用于存放JIT已编译的代码
  • 唯一拥有执行权限的内存
2.2.1.4 Large object space
  • 为了避免大对象的拷贝,使用该空间专门存储大对象
  • GC 不会回收这部分内存
2.2.1.5 Map space
  • 存放对象的Map信息,即隐藏类
  • 隐藏类是为了提升对象属性的访问速度的
  • V8 会为每个对象创建一个隐藏类,记录了对象的属性布局,包括所有的属性和偏移量

2.2.2 什么是垃圾

在程序运行过程中肯定会用到一些数据,这些数据会放在堆栈中,但是在程序运行结束后,这些数据就不会再被使用了,那些不再使用的数据就是垃圾

global.a = { name: 'a' };
global.a.b = { name: 'b1' };
global.a.b = { name: 'b2' };

引用计数法(已被淘汰)

早先的一种垃圾回收算法,它把 对象是否不再需要 简化定义为 对象有没有其他对象引用到它,如果没有引用指向该对象(零引用),对象将被垃圾回收机制回收,目前很少使用这种算法了,因为它的问题很多

优点

  • 引用计数算法的优点我们对比标记清除来看就会清晰很多,首先引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾,而标记清除算法需要每隔一段时间进行一次,那在应用程序(JS脚本)运行过程中线程就必须要暂停去执行一段时间的 GC
  • 标记清除算法需要遍历堆里的活动以及非活动对象来清除,而引用计数则只需要在引用时计数就可以了 缺点
  • 首先它需要一个计数器,而此计数器需要占很大的位置,因为我们也不知道被引用数量的上限,还有就是无法解决循环引用无法回收的问题,这也是最严重的

2.2.3 新生代的垃圾回收

  • 新生代内存有两个区域,分别是对象区域(from) 和 空闲区域(to)
  • 新生代内存使用Scavenger算法来管理内存
    • 广度优先遍历 From-Space 中的对象,从根对象出发,广度优先遍历所有能到达的对象,把存活的对象复制到 To-Space
    • 遍历完成后,清空 From-Space
    • From-Space 和 To-Space 角色互换
  • 复制后的对象在 To-Space 中占用的内存空间是连续的,不会出现碎片问题
  • 这种垃圾回收方式快速而又高效,但是会造成空间浪费
  • 新生代的 GC 比较频繁
  • 新生代的对象转移到老生代称为晋升Promote,判断晋升的情况有两种
    • 经过一次 GC 还存活的对象
    • 对象复制到 To-Space 时,To-Space 的空间达到一定的限制
global.a={};
global.b = {e:{}}
global.c = {f: {},g:{h:{}}} 
global.d = {};
global.d=null;

2.2.4 老生代的垃圾回收

老生代里的对象有些是从新生代晋升过来的,有些是比较大的对象直接分配到老生代里的,所以老生代的对象空间大,活的长

如果使用Scavenge算法,浪费一半空间不说,复制如此大块的内存消耗时间将会相当长。所以Scavenge算法显然不适合

V8在老生代中的垃圾回收策略采用 Mark-Sweep(标记清除)Mark-Compact(标记整理) 相结合

2.2.4.1 Mark-Sweep(标记清除)

标记清除分为标记和清除两个阶段

在标记阶段需要遍历堆中的所有对象,并标记那些活着的对象,然后进入清除阶段。在清除阶段,只清除没有被标记的对象

V8采取的是黑色和白色来标记数据,垃圾收集之前,会把所有的数据设置为白色,用来标记所有的尚未标记的对象,然后会从GC根出发,以 深度优先 的方式把所有的能访问到的数据都标记为黑色,遍历结束后黑色的就是活的数据,白色的就是可以清理的垃圾数据

由于标记清除只清除死亡对象,而死亡对象在老生代中占用的比例很小,所以效率较高

标记清除有一个问题就是进行一次标记清楚后,内存空间往往是不连续的,会出现很多的内存碎片。

如果后续需要分配一个需要内存空间较多的对象时,如果所有的内存碎片都不够用,就会出现内存溢出的问题

2.2.4.2 Mark-Compact(标记整理)

标记整理正是为了解决标记清除所带来的内存碎片的问题

标记整理在标记清除的基础进行修改,将其的清除阶段变为紧缩极端

在整理的过程中,将活着的对象向内存区的一端移动,移动完成后直接清理掉边界外的内存

紧缩过程涉及对象的移动,所以效率并不是太好,但是能保证不会生成内存碎片,一般10次标记清理会伴随一次标记整理

2.2.5 优化

在执行垃圾回收算法期间,JS脚本需要暂停,这种叫Stop the world(全停顿)

如果回收时间过长,会引起卡顿

image.png

性能优化

  • 把大任务拆分小任务,分步执行,类似fiber
  • 将一些任务放在后台执行,不占用主线程
2.2.5.1 增量标记

老生代因为对象又大又多,所以垃圾回收的时间更长,采用增量标记的方式进行优化

增量标记就是把标记工作分成多个阶段,每个阶段都只标记一部分对象,和主线程的执行穿插进行

为了支持增量标记,V8必须可以支持垃圾回收的暂停和恢复,所以采用了黑白灰三色标记法

image.png

  • 黑色表示这个节点被 GC 根引用到了,而且该节点的子节点都已经标记完成了
  • 灰色表示这个节点被 GC 根引用到了,但子节点还没被垃圾回收器标记处理,也表明目前正在处理这个节点
  • 白色表示此节点还没未被垃圾回收器发现,如果在本轮遍历结束时还是白色,那么这块数据就会被收回

引入了灰色标记后,就可以通过判断有没有灰色节点来判断标记是否完成了,如果有灰色节点,下次恢复的应该从灰色节点继续执行

2.2.5.2 Write-barrier(写屏障)

一旦有黑色对象引用白色对象,该机制会强制将引用的白色对象改为灰色,从而保证下一次增量 GC 标记阶段可以正确标记,这个机制也被称作 强三色不变性

global.a = { name: 'a' };
global.a.b = { name: 'b1' };
//执行标记工作
global.a.b = { name: 'b2' };
//继续执行标记工作
2.2.5.3 Lazy Sweeping(惰性清理)

增量标记完成后,惰性清理就开始了。当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理过程稍微延迟一下,让 JavaScript 脚本代码先执行,也无需一次性清理完所有非活动对象内存,可以按需逐一进行清理直到所有的非活动对象内存都清理完毕,后面再接着执行增量标记

2.2.5.4 Parallel(并行执行)

新生代的垃圾回收采取并行策略提升垃圾回收速度,它会开启多个辅助线程来执行新生代的垃圾回收工作

并行执行需要的时间等于所有的辅助线程时间的总和加上管理的时间

并行执行的时候也是全停顿的状态,主线程不能进行任何操作,只能等待辅助线程的完成

这个主要应用于新生代的垃圾回收

image.png

2.2.5.5 concurrent(并发回收)

其实增量标记和惰性清理并没有减少暂停的总时间

并发回收就是主线程在执行过程中,辅助线程可以在后台完成垃圾回收工作

标记操作全都由辅助线程完,清理操作由主线程和辅助线程配合完成

image.png

辅助线程在执行垃圾回收的时候,主线程也可以自由执行而不会被挂起,这是并发的优点,但同样也是并发回收实现的难点,因为它需要考虑主线程在执行 JavaScript 时,堆中的对象引用关系随时都有可能发生变化,这时辅助线程之前做的一些标记或者正在进行的标记就会要有所改变,所以它需要额外实现一些读写锁机制来控制这一点

3.内存泄露

3.1 什么是内存泄露

当不再用到的对象内存没有及时被回收时,我们叫它内存泄漏

3.2 不合理的闭包

一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起这样的组合就是闭包(closure)

也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来

词法(lexical)一词指的是,词法作用域根据源代码中声明变量的位置来确定该变量在何处可用。嵌套函数可访问声明于它们外部作用域的变量

function Person(){}
function fn(){
    let name = 'yy';
    let age = 18;
    let arr = new Array();
    for(let i=0;i<10000;i++){
        arr.push(new Person());
    }
    return function(){
        console.log('hello',arr,name);
    }
}
let hello = fn();
debugger
hello();

3.3 隐式全局变量

全局变量通常不会被回收,所以要避免额外的全局变量

使用完毕后经重置值为null

function Person(){}
function fn(){
    p1 = new Person();
    this.p2 = new Person();
}
fn();
p1=null;
p2=null;

3.4 分离的DOM

当在界面中移除DOM节点时,还要移除相应的节点引用

var container = document.getElementById('container');
var title = document.getElementById('title');
document.body.removeChild(container);
//Detached  HTMLParagraphElement
container=null;
title=null;

3.5 定时器

  • setInterval(fn,delay) cleanInterval(id)
  • setTimeout(fn,delay) cleanTimeout(id)
  • setImmediate(fn) cleanImmediate(id)
  • requestAnimationFrame(fn) cleanRequestAnimationFrame(id)
function Person(){}
function fn(){
    var p1 = new Person();
    var id = setInterval(()=>{
        p1.age = 20;
    },1000);
    clearInterval(id);
}
fn();

3.6 事件监听器

监听函数如果不及时移除,会导致内存泄漏

var data = new Array(100000);
class App extends React.Component{
    componentDidMount(){
        document.addEventListener('click',this.handleClick);
    }
    handleClick = ()=>{
        console.log('click',data);
    }
    componentWillUnmount(){
        document.removeEventListener('click',this.handleClick);
    }
}

3.7 Map、Set对象

Map 或 Set 存储对象时如果不主动清除也会造成内存不自动回收

可以采用 WeakMap,WeakSet 对象同样用来保存键值对,对于键是弱引用(WeakMap 只对于键是弱引用),且必须为一个对象,而值可以是任意的对象或者原始值,由于是对于对象的弱引用,不会阻止垃圾回收

function Person(){}
let obj = new Person();
let set = new Set([obj]);
let map = new Map([[obj,'yy']]);
obj = null; // 对象还存在set、map中
function Person(){}
let obj = new Person();
let set = new WeakSet([obj]);
let map = new WeakMap([[obj,'yy']]);
obj = null; // 对象被从内存中删除了

3.8 console

浏览器保存了我们输出对象的信息数据引用

未清理的 console 如果输出了对象也会造成内存泄漏

function Person(){}
function fn(){
    let name = 'yy';
    let age = 18;
    let arr = new Array();
    for(let i=0;i<10000;i++){
        arr.push(new Person());
    }
    return function(){
        console.log('hello',arr);
    }
}
let hello = fn();
hello();

4.内存泄漏排查

4.1 发现内存泄漏

<body>
  <div id="container">0</div>
  <button id="click">click</button>
  <script>
      var rows = [];
      function Person() { }
      function getColumns() {
          var columns = new Array(10000).fill('0');
          for (let i = 0; i < columns.length; i++) {
              columns[i] = new Person();
          }
          return function () {
              return columns;
          }
      }
      click.addEventListener("click", function () {
          rows.push(getColumns());
          container.innerHTML = rows.length;
      });
  </script>
</body>

4.2 定位内存泄漏

4.2.1 录制监控

  • 刷新录制页面加载
  • 监控堆、文档 、节点、监听器、CPU
  • 手工GC

image.png

4.2.2 内存快照

字段含义
摘要按构造函数进行分组,捕获对象和其使用内存的情况
对比对比某个操作前后的内存快照区别
控制查看堆的具体内容,可以用来查看对象结构
统计信息统计视图
4.2.2.1 摘要
字段含义
构造函数显示所有的构造函数,点击每一个构造函数可以查看由该构造函数创建的所有对象
距离显示通过最短的节点路径到根节点的距离,引用层级
浅层大小显示对象所占内存,不包含内部引用的其他对象所占的内存
保留的大小显示对象所占的总内存,包含内部引用的其他对象所占的内存
4.2.2.2 对比
字段含义
新对象数新建对象数
已删除项回收对象数
增量新建对象数减去回收的对象数

5.性能优化

5.1 少用全局变量

  • 全局执行上下文会一直存在于上下文执行栈中,不会销毁,容易内存泄露
  • 查找变量的链条比较长,比较消耗性能
  • 容易引起命名冲突
  • 确定需要使用的全局就是可以局部缓存
var a = 'a';
function one() {
    return function two() {
        return function three() {
            let b = a;
            for (let i = 0; i < 100000; i++) {
                console.log(b);
            }
        }
    }
}
one()()();

5.2 通过原型新增方法

var Person = function () {
    this.getName = function () {
        console.log('person');
    }
}
let p1 = new Person();
var Person = function () {

}
Person.prototype.getName = function () {
    console.log('person');
}
let p1 = new Person();

5.3 尽量创建对象一次搞定

V8会为每个对象分配一个隐藏类,如果对象结构发生改变就会重建隐藏类,结构相同的对象会共用隐藏类

隐藏类描述了对象的结构和属性偏移地址,可以加速查找属性的时间

优化指南

  • 创建对象尽量保持属性顺序一致
  • 尽量不要动态添加和删除属性
d8 --allow-natives-syntax main.js 
let p1 = {name:'zhangsan',age:10}
let p2 = {age:10,name:'zhangsan'}

let p = {};
%DebugPrint(point);
p.name = 'wangwu';
%DebugPrint(point);
p.age = 10;
%DebugPrint(point);

5.4 尽量保持参数结构稳定

V8中的内联缓存会监听函数执行,记录中间数据,参数结构不同会让优化失效

function read(obj){
 console.log(obj.toString());
}
for(let i=0;i<1000;i++){
    read(i)
}

function read(obj){
 console.log(obj.toString());
}
for(let i=0;i<1000;i++){
    read(i%2===0?i:''+i)
}
function read(obj){
 console.log(obj.toString());
}
function read2(obj){
 console.log(obj.toString());
}
for(let i=0;i<1000;i++){
    read(i%2===0?i:''+i)
    i%2===0?read(i):read2(''+i)
}