聊聊V8

92 阅读9分钟

此内容为笔者学习和自我总结,有错误的地方请谅解和指出,笔者定会及时更正。学习的路上,一起互助前行。

1. 什么是V8?

V8 is Google’s open source high-performance JavaScript and WebAssembly engine, written in C++. It is used in Chrome and in Node.js, among others.

从上面的定义我们知道它是js引擎。相比其他语言,处理js要做的事有什么不同?
js语言是动态语言,也就是类型可以在运行的时候发生改变。这就和很多静态语言,与C/C++不一样,它们类型定义后,不能再改变类型。这就意味着,静态语言可以在静态编译时就确定类型。
类型真的这么重要吗?
是的,很重要。我们都知道,程序=数据结构+算法。
而确定类型,基本就能确定变量的数据结构。知道了数据结构,也就清楚如何为这个变量分配内存。

当静态语言在静态编译的时候就知道了类型,那静态编译的时候就可以把变量的查找从按属性匹配查找,直接固定为定位到该属性的内存地址,用偏移量直接代替。动态语言因为不能固定类型,所以需要在运行时去确定。从源头,动态语言的效率比静态语言就要差,那怎么来弥补这个效率差?抓住重点,确定类型,对应属性值偏移量,缩短查找的步骤。那我们来看下V8是怎么做的?

2. V8工作流程

可以分为两个时期:

  1. 没有字节码时期的流程:
    大致流程是:
    源码source code--->抽象语法树ast--(JIT编译)--> 机器码----->优化编译器(根据数据分析器会不断的优化机器码)

    JIT(just in time):运行时编译。上面已经提到,js只能在运行时确定类型,所以要编译的化,要只能在运行时编译。

    看上面的流程,有什么疑问?
    1)时间(效率)问题?
    从ast到机器码一步到位的化,即使编译很快,对于源码很多很多的情况(并且很多代码只是会在某些特定时刻需要执行),也是会延迟编译时间。然后结合对硬件平台和操作系统的判定,生成指定机器码,整个过程还是可能会很长。怎么优化呢?

    2)空间问题?
    我们知道,即使是一小段高级语言代码,编译出机器码之后,都很增大很多。对于直接编译的机器码,即使是存储全局变量,已经当前运行栈的数据,也会很耗内存。除外,js程序开发者也有可能设计一个超级大的函数去执行,也会让基于现状的优化收效甚微。有没有更好的方式?

对与以上的疑问,V8是怎么处理的呢?

惰性编译

惰性编译原理是页面加载时,只编译全局作用域和执行的函数作用域的代码,运行到哪再编译那些代码段。可以理解为类似前端页面组件的懒加载。

惰性加载只是优化过程的一个小方式,并不是本质的解决。这里V8引入字节码。

  1. 引入字节码后的大致流程:
    源码source code---parse-->抽象语法树ast--->基线编译器 Baseline Compiler(Ignition)---->机器码 / Optimizing Compiler-----> 优化的机器码 此外,V8还有优化编译器Optimizing Compiler(TurboFun)会对‘热方法’进行优化编译,生成优化后的机器码。
    除了重新编译(Re-compiler)热方法(hot functions),对于类型变更的情况,V8提供优化回滚(De-optimize),把流程回退到基线编译阶段,V8重新根据新的ast构建代码。
    这里需要注意: Re-compiler,也就是重新编译‘热方法’时,这些’热方法‘的类型是从之前的执行过程获取的,也就是说,这里优化是根据之前的编译后的数据确定类型,然后把类型的查找直接用偏移量代码,保存少的代码,还能快速定位。所以,当类型改变时,重编译后的机器码中的偏移量实际上是对不上的,所以需要重新回归到基线编译。
    所以编写代码时尽量少的在运行时中改变数据类型.

    说了V8的大致流程,下面我们了解下V8在优化细节上还做了哪些?或者说在js运行时不断更改类型的过程中,怎么快速,有效的查找属性,确定和缓存偏移量。

V8怎么让获取js的属性变的更快?

  1. hidden classes 隐藏类
    什么是隐藏类?
    将本来需要通过字符串匹配来查找属性值的算法改进为使用类似C++编译器的偏移位置机制来实现,保存属性值与偏移量对应关系。
    隐藏类将对象划分不同的组,何为相同的组,就是改组内的对象拥有相同的属性名和相同类型的属性值。这些属性名和对应的偏移量保存在同一个隐藏类中,共享该类信息。
    打个比方:

        var a={x:1,y:2};
        var b={x:4,y;5};
    

    以上两个对象的属性名相同,可以公用一个隐藏类,减少了存储空间,此外,通过隐藏类找到对象偏移量,通过偏移量更快速找到属性值。

    
    b={x:4,y:5,z:6}
    

    如上:如果某次我们对b对象新增了属性,V8会认为a和b不在是同一组,会重新为b创建新的隐藏类。所以尽可能不要更改一个对象的属性。

提个问题?那对应一个对象内的属性,是都为一种类型更有效还是为多种类型都无妨呢?

当多次查找相同对象的属性时,每一次都需要都需要查找到隐藏类再定位。有没有方式可以记住之前的查找呢?  
答案是有的,那就是我们的内联缓存。   

2. inline cache 内联缓存
内联缓存的思想是:V8把之前查找的隐藏类和偏移量保存起来,当下次查找时,先比较当前查找对象是不是之前的隐藏类,如果是,直接使用之前缓存的偏移量。从而减少查找表的时间。

V8的内存管理

  1. V8的内存划分
    V8对于内存的划分大抵分为两类。一类是Zone类。一类是堆。
    1)Zone类: 它用来管理一系列小块内存,并且这些小内存的生命周期类似。
    具体方式是:Zone对象先自己申请一块内存,然后再管理分配内存。当需要注意的是,这些分配出去的小块内存不能单独回收,只能一次性全部回收。由于这个特性,Zone对象不适合处理内存需要比较大的过程,因为内部局部内存没法释放,导致内存不够,不利于内存资源管理。
    2)堆
    通常会申请大片内存,管理js的大量数据,以及生成的代码,哈希表等。
    V8将堆分成3部分:
    a.年轻分代。 b.年老分代。 c.大对象保留区。

     a.年轻分代:  
     主要是为新创建的对象分配内存空间。因为年轻分代中的对象比较容易被要求回收,为了回收的方便,采用复制的方式。V8将年轻分代分成两半,一半用来分配,另外一半是在回收的时候负责把还需要保留的对象复制过来。   
     什么时候需要复制?当正在被分配对象的那一半内存区即将满额时。   
     怎么确定哪些对象是需要保留的?通过标志清除法。   
     当重复多次复制后,有一些对象一值存在,意味着这些需要比较久的保存,在年轻分代中重复的复制操作这些对象,造成了不必要的资源浪费和消耗。标记这些对象,并在合适的时机把这些对象转移到一个比较稳定的环境---进入年老分代。  
    
     b.年老分代    
         不像年轻分代那样平凡操作,所以需要更为细致的分类管理。方便更快速的查询。比如代码段区域,数据段区域,Map区域等。   
         年老分代清理的时机一般是在内存空间要不足的情况下,清理掉内存占用少的对象区域,或者操作频率很低的对象。   
     
     c.大对象保留区   
         主要为需要使用较多内存的大对象分配内存。   
    

V8对js数据对象的存储

js对象的实现在V8中包含3个成员。
隐藏类的指针,这是V8为js对象创建的隐藏类。Hidden Classes or Map;
指向该对象包含的属性值。propertiesOrHash;  
指向该对象包含的元素。elements;  

隐藏类上面我们其实已经提到过,这里我们说一下properties。
V8内置了 3 种关联属性的形式:分别是
in-object
fast
slow

  1. in-object:
    表示属性值对应的指针直接保存在对象开头的连续地址内。通常大小有限,只能保留少数几个对象。 可以参照V8Fast properties;

  2. fast 是in-object的互补形式,因为in-object数量有限。

  3. slow slow是和in-object,fast互斥的。也是因为内存资源有限,为了更合理的利用资源,一部分化为为快属性访问,它会占用更多的资源,其他相对的,只能占用少一点资源。
    slow相比fast和in-object更慢,是因为slow型的属性访问无法使用内联缓存技术进行优化,