chrome v8 的 地址:github.com/v8/v8
1、V8 演进历史:
2008 年图:
讲解:
V8 发布了第一个版本,性能远超同时期竞争对手(如 SpiderMonkey、JavaScriptCore)
当时 V8 架构比较激进,直接将 JavaScript 代码编译为机器码并执行,所以执行速度很快,但在该架构中,V8 只有 Codegen 一个编译器,对代码的优化很有限。
2010 年图:
讲解:
V8 发布了 Crankshaft 编译器,JavaScript 函数通常会先被 Full-Codegen 编译,如果后续该函数被多次执行,那么就用 Crankshaft 再重新编译,生成更优化的代码,之后使用优化后的代码执行,进一步提升性能。
2015 年图:
讲解Crankshaft 对代码的优化有限,所以 V8 中加入了 TurboFan。
V8 依旧是直接将源码编译为机器码的架构,这种架构存在的核心问题:内存消耗特别大。尤其是在移动设备上,通过 Full-Codegen 编译出的机器码几乎占整个 Chrome 浏览器的三分之一,这样为代码运行时留下的内存就更少了。
2016 年图:
讲解: V8 加入了 Ignition 解释器,重新引入字节码,旨在减少内存使用
2017 年图:
讲解:V8 正式发布全新编译 pipeline,即用 Ignition 和 TurboFan 的组合,来编译执行代码,从 V8 5.9 版开始,早期的 Full-Codegen 和 Crankshaft 编译器不再用来执行 JavaScript。
最核心的是三个模块:
解析器(Parser)
解释器(Ignition)
优化编译器(TurboFan)
V8 怎么执行 JavaScript 代码的?
JS作为脚本语言,通常是边解释边执行的。V8的执行流程也是如此。但不同的地方是,V8 执行 JavaScript 源码时,首先解析器会把源码解析为抽象语法树(Abstract Syntax Tree),解释器(Ignotion)再将 AST 翻译为字节码,一边解释一边执行。
在此过程中,解释器会记特定代码片段的运行次数,如果代码运行次数超过某个阈值,那么该段代码就被标记为热代码(hot code)(webpack的热更新技术底层也是采用了这样的机制),并将运行信息反馈给优化编译器(TurboFan)。
优化编译器根据反馈信息,优化并编译字节码,最终生成优化后的机器码,这样当该段代码再次执行时,解释器就直接使用优化机器码执行,不用再次解释,大大提高了代码运行效率。
这种在运行时编译代码的技术也被称为 JIT(即时编译),通过JIT可以极大提升 JavaScript 代码的执行性能。
The Just-in-Time(JIT)paradigm(即时编译)
一般来说,为了使你的代码能够执行,编程语言需要被转化为机器代码。对于如何以及何时发生这种转换,有几种方法。
最常见的转换代码的方法是进行超前编译。它的工作原理:在编译阶段,代码在程序执行之前就被转化为机器代码了。许多编程语言都采用这种方法,如C++、Java 和其他语言。
在表格的另一边,是解释型:每一行代码都将在运行时执行。这种方法通常被动态类型语言(如 JavaScript 和 Python)采用,因为在执行之前不能知道确切的类型。
因为提前编译可以一起评估所有代码,它可以提供更好的优化并最终生成更高性能的代码。另一方面,解释型语言更容易实现,但它通常比提前编译慢。
为了更快、更有效地为动态语言转换代码,创建了一种称为即时(JIT)编译的新方法。它最好地结合了解释和编译。
在使用解释(interpretation)作为基础方法的同时,V8 可以检测到比其他函数更频繁使用的函数,并使用以前执行的类型信息对其进行编译。
然而,类型有可能会发生变化。我们需要对已编译的代码进行去优化,转而返回到解释法(之后,我们可以在得到新的类型反馈后重新编译函数)。
让我们更详细地探讨一下 JIT 编译的每个部分。
V8 怎么解析 JavaScript 代码的?
扫描器(scanner)和解析器(parser)
扫描器接收 JS 文件并将其转换为已知的标记列表。在 keywords.txt文件中有一个所有 JS 标记的列表
解析器识别它并创建一个 抽象语法树(AST):源代码的树状表示。树上的每个节点都表示代码中出现的一个结构
Interpreter(解释器)
V8 使用一个叫做 Ignition 的解释器。最初,它接受一个抽象的语法树并生成字节码。
字节码指令也有元数据,如源行位置,以便将来进行调试。一般来说,字节码指令与 JS 的抽象内容相匹配。
async字节码github.com/v8/v8/blob/…
图:
词法分析:
V8 先将源码转换成 V8 能理解的格式。把源码解析为一个抽象语法树(AST),这个过程称为解析(Parsing)由 V8 的 Parser 模块实现。解释器会把 AST 编译为字节码,一边解释一边执行。
整个解析过程可分为两部分。
**词法分析:**将字符流转换为 tokens,字符流就是我们编写的一行行代码,token 是指语法上不能再分割的最小单位,可能是单个字符,也可能是字符串,图中的 Scanner 就是 V8 的词法分析器。
**语法分析:**根据语法规则,将 tokens 组成一个有嵌套层级的抽象语法结构树,这个树就是 AST,在此过程中,如果源码不符合语法规范,解析过程就会终止,并抛出语法错误。图中的 Parser 和 Pre-Parser 都是 V8 的语法分析器。
在astexplorer.net/ 中观察源码通过Parser转换后的 AST 的结构。
function foo() {
let bar = 1;
return bar;
}
LdaSmi #1 // write 1 to accumulator
Star r0 // read to r0 (bar) from accumulator
Ldar r0 // write from r0 (bar) to accumulator
Return // returns accumulator
这段代码将产生以下树状结构:
你可以通过执行前序遍历(根、左、右)来执行这段代码:
- 定义
foo函数。 - 声明
bar变量。 - 将
1分配给bar。 - 从函数中返回
bar。
你还会看到VariableProxy--一个将抽象变量连接到内存中某个地方的元素。解决VariableProxy的过程被称为 范围分析(Scope Analysis)。
在我们的例子中,这个过程的结果是所有 VariableProxy 都指向同一个 bar 变量。
为什么JavaScript 不能完全解析后执行?
如果JS代码在执行前都要完全经过解析才能执行,会有以下问题。
- 代码执行时间变长:一次性解析所有代码,必然会增加代码的运行时间。
- 消耗更多内存:解析完的 AST,以及根据 AST 编译后的字节码都会存放在内存中,必然会占用更多内存空间。
- 占用磁盘空间:编译后的代码会缓存在磁盘上,占用磁盘空间。
所以,现在主流 JavaScript 引擎都实现了延迟解析(Lazy Parsing)。
延迟解析?
在解析过程中,对于不是立即执行的函数,只进行预解析(Pre Parser),只有当函数调用时,才对函数进行全量解析。
Pre-Parser 解析器(预解析),验证函数语法是否有效、解析函数声明、确定函数作用域,不生成 AST。
**解释器(Ignition)如何将 AST翻译为字节码并执行?
**
V8 为了解决内存占用问题引入了字节码。消耗巨大的内存空间。
V8 的字节码是对机器码的抽象,语法与汇编有些类似,你可以把 V8 字节码看作一个个指令,这些指令组合到一起实现我们编写的功能,V8 定义了几百个字节码,你可以在 V8 解释器的头文件中查看所有字节码
github.com/v8/v8/blob/…
Ignition 解释器在执行字节码时,主要使用通用寄存器和累加寄存器(accumulator register),函数参数和局部变量都保存在通用寄存器中,累加器(accumulator)的东西--一个可以存储/读取数值的地方,****累加寄存器用于保存中间结果。。
累加器避免了推送和弹出堆栈顶部的需要。它也是许多字节代码的隐含参数,通常保存操作的结果。隐式地返回累加器。
Execution(执行)
生成后,Ignition 将使用一个以字节码为关键的处理程序表来解释这些指令。对于每个字节码,Ignition 可以查找相应的处理程序函数,并使用提供的参数执行它们。
在内存中表示 JavaScript 对象。在一个天真的方法中,我们可以为每个对象创建一个字典,并将其链接到内存中。
我们通常有很多具有相同结构的对象,所以存储大量重复的字典是没有效率的。为了解决这个问题,V8 使用 Object Shapes(或内部映射 Maps internally)和内存中的值向量将对象的结构与值本身分开。
可以看到每个对象都可以有一个指向 object shape 的链接,对于每个属性名称,V8 可以在内存中找到一个值的偏移。
Object shapes 本质上是链接列表。因此,如果你写 c.x,V8 会去到列表的头部,在那里找到 y,移动到连接的 shape,最后它得到 x 并从中读取偏移。然后它将进入内存向量并返回其中的第一个元素。
可以想象,在一个大的网络应用中,你会看到大量的 shapes。同时,在链接列表中搜索需要线性时间,使得属性查找成为一个一个非常耗费时间的操作。
为了解决 V8 中的这个问题,你可以使用在线缓存 Inline Cache(IC)。它记住了在哪里可以找到对象的属性的信息,以减少查找的次数。
你可以把它看作是你代码中的一个监听点:它跟踪一个函数中所有的_CALL_、_STORE_和_LOAD_事件,并记录所有经过的 shapes。
保存 IC 的数据结构被称为反馈向量 Feedback Vector**。**它只是一个数组,用来保存函数的所有 IC。
上图的a,b,c分别对应 a0,a1,a2。
Ignition 解释器执行代码时,先把参数分别加载到 a0、a1、a2 寄存器上( accumulator 表示累加寄存器)逐行执行字节码。
- 初始化:
Ldar a2:Ldar 表示将寄存器的值加载到累加器中,accumulator 的值为 150。
**SubSmi [100], [0]:**SubSmi [100] 表示将累加寄存器的值减少 100,这时 accumulator 的值就变为了 50,[0] 反馈向量 (FeedBack Vector) 的索引,反馈向量记录了函数在执行过程中的一些关键的中间数据。
**Star r0:**表示把累加器中的值保存到寄存器 r0 中,这时 r0 的值就变为了 50。
**Ldar a1:**表示将寄存器 a1 的值加载到累加寄存器中,这时 accumulator 的值变为了 2。
**Mul r0, [2]:**Mul r0 表示将累加寄存器的值与 r0 寄存器的值相乘,并把结果再次放入累加寄存器,其中 [2] 同样是反馈向量,执行完毕后,accumulator 的值就变为了 100。
**Add a0, [1]:**Add a0 表示将累加寄存器的值与 a0 寄存器的值相加,并将结果再次放入累加寄存器,这时 accumulator 的值就变为了 105。
**Return:**表示结束当前函数的执行,并返回累加寄存器中的值,函数执行结果是 105。
Ignition 解释器在执行字节码时,依旧需要将字节码转换为机器码,因为 CPU 只能识别机器码。
虽然多了一层字节码的转换,看起来效率低了,但相比于机器码,基于字节码可以更方便进行性能优化。V8也的确做了很多性能优化工作,其中最主要的就是使用 Turbofan 编译器编译热点代码,这些性能优化,使得如今基于字节码架构的性能远超当年直接编译机器码架构的性能。
Compiler(编译器)
Ignition 只能让我们走到这里。如果一个函数调用频繁,它将在编译器中被优化,Turbofan,以使其更快。
Turbofan 从 Ignition 中获取字节码和函数的类型反馈(Feedback Vector),在此基础上应用一系列的缩减,并产生机器码。
正如我们之前看到的,类型反馈并不能保证它在未来不发生变化。
例如,Turbofan 基于一些加法总是加整数的假设来优化代码。
但如果它收到的是一个字符串,会发生什么?这个过程被称为去优化, 我们扔掉优化的代码,回到解释的代码,恢复执行,并更新类型反馈。
优化编译器 (TurboFan) 具体是怎么工作?
Ignition 解释器在解释执行的过程中,会标记重复执行的热点代码。这些被标记的代码会被 Turbofan 编译器编译生成效率更高的机器码。
V8 为了提升 JavaScript 的执行性能,在优化编译方面做了很多工作,其中最主要有内联和逃逸分析两种算法。
内联(inlining):
通过内联,可以降低复杂度、消除冗余代码、合并常量,并且内联技术通常也是逃逸分析的基础
逃逸分析(Escape Analysis):分析对象的生命周期是否仅限于当前函数
前端常用调试技巧:
1、console.log,前端都喜欢用 console.log 调试,先不谈调试效率怎么样,首先 console.log 有个致命的问题:会导致内存泄漏
2、chrome调试,debug等同
console.log的演示:
创建文件。启动静态服务
点击 performance 下的垃圾回收按钮,手动触发一次 GC。
勾选 Memory,然后开始录制,点击 3 次按钮,再执行一次 GC:
内存占用有三次增长,因为我们点击三次按钮的时候会创建 3 次大数组。
但是最后我们手动 GC 之后并没有回落下去,也就是这个大数组没有被回收。
按理来说,代码执行完,那用的内存就要被释放,然后再执行别的代码,结果这段代码执行完之后大数组依然占据着内存,这样别的代码再执行的时候可用内存就少了。
这就是发生了内存泄漏,也就是代码执行完了不释放内存的流氓行为。
有同学说,只是这么一点内存问题不大呀,反正可用内存还很多。
但如果你的代码要跑很长时间,这段代码要执行很多次呢?
每次执行都会占据一部分内存不释放,慢慢的内存就不够用了,甚至会导致程序崩溃。
如果这段代码执行个 9 次,内存占用就增长了 9 个大数组的内存
看看不用 console.log 是什么样的:
分配了三次内存,但是 GC 后又会落下去了。
这才是没有内存泄漏的好代码。
那为啥 console.log 会导致内存泄漏呢?
因为控制台打印的对象,你是不是有可能展开看?那如果这个对象在内存中没有了,是不是就看不到了?
所以有这个引用在,浏览器不会把你打印的对象的内存释放掉。
当然,也不只是 console.log 会导致内存泄漏,还有别的 4 种情况:
- 定时器用完了没有清除,那每次执行都会多一个定时器的内存占用,这就是内存泄漏
- 元素从 dom 移除了,但是还有一个变量引用着他,这样的游离的 dom 元素也不会被回收。每执行一次代码,就会多出游离的 dom 元素的内存,这也是内存泄漏
- 闭包引用了某个变量,这个变量不会被回收,如果这样的闭包比较多,那每次执行都会多出这些被引用变量的内存占用。这样引用大对象的闭包多了之后,也会导致内存问题
- 全局变量,这个本来就不会被 GC,要注意全局变量的使用
总之,全局变量、闭包引用的变量、被移除的 dom 依然被引用、定时器用完了没清除、console.log 都会发生代码执行完了,但是还占用着一部分内存的流氓行为,也就是内存泄漏。
其实不用手动 GC,JS 引擎会做 GC。
打开 devtools 才有内存泄漏,不打开就不会呢
接打印字符串,内存也是平稳的。
为什么呢?字符串不也是对象、可以看到详情的吗?
这是因为字符串比较特殊,有个叫做常量池的东西。
是 @91 的地址。
我过了一段时间再录制了一次快照,依然只有一个字符串,地址是 @91。
这就是字符串常量池的作用,同样的字符串只会创建一次,减少了相同字符串的内存占用。
node.js 的 console.log 有没有内存泄漏呢?
内存是稳定的,并不会内存泄漏。
这是因为 node 打印的是序列化以后的对象,并不是对象引用。----时间不够,暂时不写demo了。
log总结:
console.log 在 devtools 打开的时候是有内存泄漏的,因为控制台打印的是对象引用。但是不打开 devtools 是不会有内存泄漏的。
我们通过打印内存占用大小的方式来证明了这一点。
string 因为常量池的存在,同样的字符串只会创建一次。new String 的话才会在堆中创建一个对象,然后指向常量池中的字符串字面量。
此外,nodejs 打印的是序列化以后的对象,所以是没有内存泄漏的。
当你一打开 devtools 网页就崩了,不打开没事,这时候一般就是因为 console.log 导致的内存泄漏了。
老生长谈,前端JS性能优化:
JS 性能优化
JS 是编译型还是解释型语言其实并不固定。首先 JS 需要有引擎才能运行起来,无论是浏览器还是在 Node 中,这是解释型语言的特性。但是在 V8 引擎下,又引入了 TurboFan 编译器,他会在特定的情况下进行优化,将代码编译成执行效率更高的 Machine Code,当然这个编译器并不是 JS 必须需要的,只是为了提高代码执行性能,所以总的来说 JS 更偏向于解释型语言。
那么这一小节的内容主要会针对于 Chrome 的 V8 引擎来讲解。
在这一过程中,JS 代码首先会解析为抽象语法树(AST),然后会通过解释器或者编译器转化为 Bytecode 或者 Machine Code
从上图中我们可以发现,JS 会首先被解析为 AST,解析的过程其实是略慢的。代码越多,解析的过程也就耗费越长,这也是我们需要压缩代码的原因之一。另外一种减少解析时间的方式是预解析,会作用于未执行的函数,这个我们下面再谈。
这里需要注意一点,对于函数来说,应该尽可能避免声明嵌套函数(类也是函数),因为这样会造成函数的重复解析。
然后 Ignition 负责将 AST 转化为 Bytecode,TurboFan 负责编译出优化后的 Machine Code,并且 Machine Code 在执行效率上优于 Bytecode
那么我们就产生了一个疑问,什么情况下代码会编译为 Machine Code?
JS 是一门动态类型的语言,并且还有一大堆的规则。简单的加法运算代码,内部就需要考虑好几种规则,比如数字相加、字符串相加、对象和字符串相加等等。这样的情况也就势必导致了内部要增加很多判断逻辑,降低运行效率。
对于以上代码来说,如果一个函数被多次调用并且参数一直传入 number 类型,那么 V8 就会认为该段代码可以编译为 Machine Code,因为你固定了类型,不需要再执行很多判断逻辑了。
但是如果一旦我们传入的参数类型改变,那么 Machine Code 就会被 DeOptimized 为 Bytecode,这样就有性能上的一个损耗了。所以如果我们希望代码能多的编译为 Machine Code 并且 DeOptimized 的次数减少,就应该尽可能保证传入的类型一致。
优化过的代码执行时间只需要 12ms,但是不优化过的代码执行时间却是前者的二十倍,已经接近 44ms 了。(测量不同代码时间不一定一致)V8 的性能优化到底有多强,只需要我们符合一定的规则书写代码,引擎底层就能帮助我们自动优化代码。
另外,编译器还有个骚操作 Lazy-Compile,当函数没有被执行的时候,会对函数进行一次预解析,直到代码被执行以后才会被解析编译。对于上述代码来说,test 函数需要被预解析一次,然后在调用的时候再被解析编译。但是对于这种函数马上就被调用的情况来说,预解析这个过程其实是多余的,那么有什么办法能够让代码不被预解析呢?
其实很简单,我们只需要给函数套上括号就可以了
但是不可能我们为了性能优化,给所有的函数都去套上括号,并且也不是所有函数都需要这样做。我们可以通过 optimize-js 实现这个功能,这个库会分析一些函数的使用情况,然后给需要的函数添加括号,当然这个库很久没人维护了,如果需要使用的话,还是需要测试过相关内容的。
总结:
- 学习了 V8 架构的演进史:最初的 V8 没有字节码,直接将 JavaScript 源码编译为机器码执行,导致内存占用过高,后来 V8 引入了字节码以及TurboFan
- 学习了V8 执行 JavaScript 的原理,大致分为三个步骤:
- 解析器将 JavaScript 源码解析为 AST,解析过程分为词法分析和语法分析,V8 通过预解析提升解析效率;
- 解释器 Ignition 根据 AST 生成字节码并执行。这个过程中会收集执行反馈信息,交给 TurboFan 进行优化编译;
- TurboFan 根据 Ignition 收集的反馈信息,将字节码编译为优化后的机器码,后续 Ignition 用优化机器码代替字节码执行,进而提升性能。
- 学习了chrome的v8浏览器调试窗口,页面卡顿的分析,前端代码导致页面卡顿的问题
JS引擎编译管道
- 这一切都始于从网络中获取 JavaScript 代码。
- V8 解析源代码并将其转化为抽象语法树(AST)。
- 基于该 AST,Ignition 解释器可以开始做它的事情,并产生字节码。
- 在这一点上,引擎开始运行代码并收集类型反馈。
- 为了使它运行得更快,字节码可以和反馈数据一起被发送到优化编译器。优化编译器在此基础上做出某些假设,然后产生高度优化的机器代码。
- 如果在某些时候,其中一个假设被证明是不正确的,优化编译器就会取消优化,并回到解释器中。