当前位置: 首页 > Web前端 > HTML

《图解 Google V8》CompilationPipeline——学习笔记(二)

时间:2023-03-28 12:41:39 HTML

本篇是《图解 Google V8》/共三篇的第二部分:学习编译流水线最大的收获有两点:V8如何提高JavaScript执行速度和早期缓存machinecode,然后重构缓存字节码在JavaScript中访问属性时,V8做了哪些优化来隐藏类内联缓存,特别是第二点,让我看到了使用TypeScript的好处,动态语言和静态语言的问题09|运行时环境:运行JavaScript代码的基石运行时环境包括:堆空间和栈空间、全局执行上下文、全局作用域、内置内置函数、宿主环境提供的扩展函数和对象,以及消息循环系统宿主浏览器为V8提供了基本的消息循环系统、全局变量和WebAPI。V8的核心是实现ECMAScript标准,如:Object,Function,String,同时还提供垃圾回收,协程等构造数据存储空间:堆空间和栈空间在Chrome中,只要一个渲染进程打开后,渲染进程会初始化V8,同时初始化堆空间和栈空间。栈是内存中一块连续的空间,采用“先进后出”的策略。在函数调用过程中,上下文相关的内容会入栈,比如native类型、引用对象的地址、函数的执行状态、this的值等都会入栈.当一个函数的执行结束时,函数的执行上下文就会被销毁。堆空间是一个树状的存储结构,用于存储对象类型的离散数据,如函数、数组等。在浏览器中,还有全局执行上下文和全局作用域执行上下文,如window、document。部分,变量环境,词法环境和this关键字。全局执行上下文在V8的生命周期内不会被销毁,它会一直保存在堆中。在ES6中,多个执行上下文可以存在于同一个全局执行上下文中。范围:varx=5;{让y=2;constz=3;}构建事件循环系统V8需要一个主线程来执行JavaScript并进行垃圾回收。V8寄生在宿主环境中,V8执行的代码是在宿主的主线程上执行的。如果主线程正在执行任务,此时有新任务来,将新任务放入消息队列,等待当前任务执行完毕,然后从消息队列中发送消息从队列中取出,待任务执行完毕,重复上述过程。10|机器码:CPU执行的二进制机器码到底是怎么来的?将汇编语言转换为机器语言的过程称为“汇编”;反之,将机器语言转换为汇编语言的过程称为“反汇编”,在程序执行之前,需要将程序加载到内存中(内存中存储的每一个二进制代码加载到内存后,CPU就可以取一个从内存中取出指令,解析指令,最后执行指令。取指令、解析指令、执行指令这三个过程称为一个CPU时钟周期。CPU中有一个PC寄存器,存放着指令的地址要执行的指令从内存中取出指令的地址PC寄存器中的指令取出后,系统需要做两件事:更新下一条指令的地址到PC寄存器,分析指令,识别不同类型指令的数量,以及各种获取操作数的方法,因为CPU访问内存的速度很慢,所以需要通用寄存器来存储数据在CPU中(通用寄存器容量小,读写速度快,内存容量大,读写速度慢。)通用寄存器通常用来存放数据或一块数据在内存中的地址。我们称这个地址为指针ebp寄存器通常用来存放栈帧指针esp寄存器用来存放栈顶指针PC寄存器用来存放下一条要执行的指令常用的指令类型:加载指令:复制内容从内存中指定长度的内容到通用寄存器,并覆盖寄存器中原来的内容存储指令:与加载型指令相反,作用是将寄存器中的内容复制到内存中的某个位置,并覆盖内存中该位置原来的内容Update指令:作用是将两个寄存器的内容复制到ALU中Jump指令:从指令本身中提取一个字,这个字是下一条指令的地址11|被执行,将word复制到PC寄存器中,并覆盖PC寄存器中原来的值堆和栈:函数调用如何影响内存布局?函数有两个主要特点:可调用和作用域机制所以:函数的调用者的生命周期比被调用者的生命周期长(回退),被调用者的生命周期先结束(first-out)从函数资源分配和回收的角度来看,被调用函数的资源分配晚于调用函数(后向),被调用函数的资源释放早于调用函数(先出)。栈的状态从add恢复到上次执行main函数的状态,这个过程叫做恢复场景functionmain(){add();}functionadd(num1,num2){returnnum1+num2;}如何还原main函数的执行场景:esp寄存器中保存一个值,一直指向当前栈top指针告诉你往ebp寄存器的什么地方添加新元素,保存当前栈的起始位置函数(也叫栈帧指针),告诉CPU移动到这个地址栈帧:每个栈帧对应一个未完成的函数,函数的返回地址和局部变量存放在栈帧中。12|惰性解析:V8是如何实现闭包的?在编译阶段,V8不会编译所有的代码,采用一种“惰性编译”或“惰性解析”的方式,也就是说V8默认不会编译函数内部的代码,只会在函数执行之前,它会编译。闭包的问题是指:由于子函数使用了父函数的变量,所以在父函数执行完毕后,其内部的子函数引用的变量不能及时释放到内存中。闭包问题的根源在于JavaScript的固有特性:可以在函数内部定义新的函数。内部函数可以访问父函数的变量。函数是一等公民,所以函数可以作为返回值。由于JavaScript有这个特性会有闭包问题,所以需要解决闭包问题。“Precompilation”或者“preparse”对于预编译有具体的解决方案:在编译阶段,V8会对函数进行预解析,判断函数中的语法是否正确子函数是否引用了父函数中的变量,如果所以,把这个变量复制到堆中,子函数本身也是一个对象,也会放到堆中。父函数执行后,内存会被删除。执行释放子函数时,复制的变量仍然可以从堆内存中访问13|字节码(一):为什么V8要重新引入字节码?在V8中,字节码有两个作用:解释器可以直接执行字节码优化编译器可以将字节码编译成机器码,然后执行机器码“执行字节码”会牺牲代码的执行速度,所以使用了两个编译器来直接将JavaScript代码编译为机器代码:基线编译器:将JavaScript代码编译为未优化的机器代码热点代码(频繁执行的代码)针对更高效的机器代码进行了优化执行JavaScript:将JavaScript代码转换为抽象语法树(AST)基线编译器将AST编译成未优化的机器码,然后V8在优化后的机器码执行未优化的机器码时,将一些热点代码优化成更高效的机器码,然后再执行优化后的机器码。如果优化后的机器码不满足当前代码的执行,V8会去优化运行问题1.机器码缓存V8执行一段JavaScript代码,编译时间和执行时间几乎一致。如果JavaScript没变,每次都编译这段代码会浪费CPU资源,所以V8引入了机器码缓存。:将源代码编译成机器码后,放入内存(内存缓存)中,下次执行这段代码时,先去内存中查找这段代码的机器码是否存在,如果存在,则执行这段代码机器码将编译好的机器码保存到硬盘,关闭浏览器,下次重新打开,可以直接使用编译好的机器码,时间缩短20%到40%。这是一种用空间换取时间的策略,在移动端很流行吃内存2.惰性编译V8采用惰性编译,只编译全局执行上下文的代码。因为在ES6之前,没有块级作用域,为了实现模块间隔离会使用立即执行的功能,会产生大量的闭包,闭包模块中的代码不会被缓存,所以只有顶层代码不完善,所以V8进行了一次大的重构。目前的V8bytecode+Interpreter+Compiler5K源代码JavaScript->40K字节码->10M机器码字节码的大小比机器码小很多,浏览器可以缓存所有的字节码,而不仅仅是全局执行的字节码context优点:减少内存提高代码启动速度降低代码复杂度缺点:降低执行效率解释器的作用是将源代码转换成字节码V8的解释器是:lgnition;V8的编译器有:TurboFan如何降低代码复杂度机器代码在不同的CPU中是不同的。将AST直接转换成不同的机器码需要基线编译器和优化编译器编写大量适应各个CPU的代码。先把AST转成字节码,再把字节码转成机器码,因为字节码(排除平台差异)类似于CPU执行机器码的过程,所以字节码转成机器码会容易很多14|字节代码(2):解释器如何解释并执行字节码?生成字节码functionadd(x,y){varz=x+y;返回z;}console.log(add(1,2));生成AST[为函数生成字节码:add]---AST---FUNCat12KIND0LITERALID1SUSPENDCOUNT0NAME"add"PARAMSVAR(0x7fa7bf8048e8)(mode=VAR,assigned=false)"x"VAR(0x7fa7bf804990)(mode=VAR,assigned=false)"y"DECLSVARIABLE(0x7fa7bf8048e8)(mode=VAR,assigned=false)"x"VARIABLE(0x7fa7bf804990)(mode=VAR,assigned=false)"y"VARIABLE(0x7fa7bf804a38)(mode=VAR,assigned=false)"z"BLOCKNOCOMPLETIONSat-1EXPRESSIONSTATEMENTat31INITat31VARPROXYlocal[0](0x7fa7bf804a38)(mode=VAR,assigned=false)"z"ADDat32VAR代理参数[0](0x7fa7bf8048e8)(模式=VAR,分配=假)“x”VAR代理参数[1](0x7fa7bf804990)(模式=VAR,分配=假)“y”返回37VAR代理本地[0](0x7fa7bf804a38)(mode=VAR,assigned=false)"z"可视化AST并将函数拆分为4部分usedVariabledeclarationnode(DECLS):出现了3个变量:x,y,z,你会发现x和y的地址和PARAMS里面的一样,说明是同一条数据Expressionnode:有ADD节点parameter[0]和VARPROXYparameter[1]下的VARPROXYRETURN节点:指向z的值,这里在生成AST的同时是local[0],同时生成th的作用域eaddfunctionGlobalscope:functionadd(x,y){//(0x7f9ed7849468)(12,47)//将被编译//1个栈槽//localvars:VARy;//(0x7f9ed7849790)参数[1],从未分配VARz;//(0x7f9ed7849838)local[0],从未分配VARx;//(0x7f9ed78496e8)parameter[0],neverassigned}在解析阶段,普通变量默认值为undefined,函数声明指向实际的函数对象;在执行阶段,变量会指向栈和堆对应的数据AST作为自身字节码生成器(BytecodeGenerator)的输入,它是lgnition的一部分,以函数为单位生成字节码【为函数生成的字节码:add(0x079e0824fdc1)]Parametercount3Registercount2Framesize160x79e0824ff7a@0:a7StackCheck0x79e0824ff7b@1:2502Ldara10x79e0824ff7d@3:340300Adda0,[0]0x79e0824ff80@6:26fbStarr00x79e0824ff82@8:0c02LdaSmi[2]0x79e0824ff84@10:26faStarr10x79e0824ff86@12:25fbLdarr00x79e0824ff88@14:abReturnConstantpool(size=Source0)HandlerTable(size=Source0)HandlerTable(size=Source0)参数表3代表显示的参数x和y,隐含参数this的最终字节码StackCheckLdara1Adda0,[0]Starr0LdaSmi[2]Starr1Ldarr0Return理解字节码有两种解释器:State-based使用栈保存函数参数、中间操作结果、基于寄存器的变量(Register-based))支持寄存器指令操作,使用寄存器保存参数,中间计算结果Stack-basedinterpreters:Java虚拟机,.Net虚拟机,早期的V8虚拟机;优点:在处理函数调用、解决递归问题、切换上下文时简单快速目前的V8采用了基于寄存器的设计Ladra1指令:将a1寄存器中的值加载到累加器中Starr0指令:保存累加器中的值进入r0寄存器添加a0,[0]指令:从a0寄存器加载值并与累加器中的值相加,然后将结果放入累加器[0]:成为反馈向量槽(feedbackvectorslot),目的是给优化编译器(TurboFan)提供优化信息,它是一个数组,解释器在反馈向量槽LadSmi[2]指令中保存了解释执行过程中一些数据类型的解析信息:将小整数(Smi)2装入累加器返回指令:结束当前函数执行,将控制权交还给调用者,返回值为累加器Sta中的值ckCheck指令:检查堆栈是否已经达到溢出上限15|隐藏类:如何在内存中快速查找对象属性?为了提高对象属性的访问速度,引入隐藏类加速操作,引入内联缓存不知道start里面有没有x,也不知道x相对于start的偏移量是多少。简单的说,V8不知道start对象的具体状态,所以JavaScript在查询start.x的时候,过程很慢。静态语言,比如C++在声明一个对象之前,需要先定义对象的结构(行)。它会在执行前被编译。编译时,该行是固定的,这意味着在执行过程中,对象的配置不能改变。所以当C++查询start.x时,编译器在编译时,会直接将x相对于start对象的地址写入汇编指令,查询时直接读取x的地址,没有查找链接。为了做到这一点,V8做了两个Assumption:创建对象后不会再添加新的对象创建属性对象后,不会删除该属性。然后V8为每个对象创建一个隐藏类,记录对象包含的基本信息的所有属性。每个属性相对于对象的偏移量在V8中,隐藏类称为map,即每个对象都有一个map属性,指向内存中的隐藏类。有了map之后,在访问start.x时,V8会先查询start.map中x相对于start点对象的偏移量,然后将点对象的地址与偏移量相加得到value的地址内存中的x属性。如果两个对象的形状相同,V8会为它们复用同一个隐藏类:减少隐藏类的创建次数,也间接加快了代码的执行速度,减少了隐藏类的存储空间。两个对象的形状相同,必须满足:属性名称相同,属性顺序相同,属性类型相同,属性个数相等。如果动态变化,V8会重建一个新的隐藏类参考:使用V8深入理解JavaScript对象存储策略16|答:V8是如何通过内联缓存提高函数执行效率的?functionloadX(o){returno.x;}varo={x:1,y:3};varo1={x:3,y:6};对于(vari=0;i<90000;i++){loadX(o);loadX(o1);}V8获取o.x的过程:找到对象o的隐藏类,然后通过隐藏类找到x属性的偏移量,然后根据偏移量获取属性值。在这段代码中,o.x会被重复执行,搜索过程也会被重复执行,那么V8有没有做这个优化呢?内联缓存(InlineCache,简称IC)V8在函数执行过程中会观察到函数中的一些调用点(CallSite)。关键数据(中间数据),然后缓存起来,下次函数执行时,V8可以使用这些中间数据,省去了再次获取这些数据的过程,IC会为每个函数维护一个反馈向量(FeedBackVector),反馈向量记录了函数执行过程中的一些关键中间数据。反馈向量是一个表结构,有很多项,每一项称为一个槽(Slot)functionloadX(o){o.y=4;returno.x;}V8在执行这个函数的时候,判断o.y=4和returno.x是调用点(CallSite),因为他们使用了对象和属性,那么V8会在反馈向量中为每个调用点分配一个调用点loadX函数槽。槽包括:槽的索引(slotindex)、槽的类型(type)、槽的状态(state)、隐藏类的地址属性的偏移量(map)函数loadX(o){returno.x;}loadX({x:1});//BytecodeStackCheck//检查LdaNamedProperty是否溢出a0,[0],[0]//取出参数a0的第一个属性值,将属性值放入accumulatorMediumreturn//返回accumulator中的属性LdaNameProperty,一共有三个参数:a0是loadX的第一个参数,第一个[0]表示取对象a0的第一个属性值,第二个[0]是相关的tothefeedbackvector,表示将LdaNameProperty操作的中间数据写入反馈向量,其中0表示第一个slotmap:缓存的隐藏类的地址ooffset:缓存属性的偏移量xtype:缓存操作类型,这里是LOAD类型。在反馈向量中,我们将这种通过o.x访问对象属性值的操作称为LOAD类型。函数foo(){}函数loadX(o){o.y=4;富();returno.x;}loadX({x:1,y:4});//BytecodeStackCheck//下面两行是o.y=4,STOREtypeLdaSmi[4]StaNamedPropertya0,[0],[0]//下面三行调用foo函数,CALLLdaGlobal[1],[2]Starr0CallUndefinedReceiver0r0,[4]//下面一行是o.xLdaNamedPropertya0,[2],[6]返回多态和超状态函数loadX(o){returno.x;}//o和o1有不同的行形状varo={x:1,y:3};varo1={x:3,y:6,z:4};for(vari=0;i<90000;i++){loadX(o);loadX(o1);}第一次执行loadX时,V8会将隐藏类记录到反馈向量中,同时记录x的偏移量。第二次执行loadX时,V8首先取出反馈向量中的隐藏类,与o1的隐藏类进行比较。如果不是同一个隐藏类,则不能使用反馈。缓存在向量中的偏移量。一个槽只有1个隐藏类,称为单态。一个槽有2到4个隐藏类,称为多态。一个插槽中有4个以上的隐藏类。隐藏类称为磁态。如果一个slot中存在多态或者superstate,执行效率会比单态低(多比较过程)。参考:多态内联缓存PIC《图解 Google V8》学习笔记系列《图解 Google V8》设计inV8Thought-学习笔记(一)《图解 Google V8》事件循环与垃圾回收-学习笔记(三)