likes
comments
collection
share

JavaScript原理--运行原理

作者站长头像
站长
· 阅读数 42

JavaScript 是高级设计语言;在被计算机cpu执行之前,需要通过JS引擎,将JS转换成低级机器语言并执行

JavaScript的语言组成

JavaScript 由于设计的时间太短,很多细节没有考虑得很严谨,整个语言基本上是多个语言的大杂烩;这也导致了会有一些奇怪的JavaScript

  • 基本语法:借鉴了C语言 的基本语法
  • 数据类型 内存管理:借鉴了Java语言 的数据类型和内存管理
  • 函数式编程:借鉴了Scheme语言 将函数提升到最高级的思想
  • 原型继承:借鉴了Self语言 基于原型prototype的继承机制

JIT

js引擎运用了一项技术叫运行时编译 JIT。白话就是:在运行时编译成机器代码。

对应的AOT:在运行前提前生成好机器代码

JS引擎

将js代码编译成机器能够识别的代码

常见的有:

  1. 谷歌V8
  2. 苹果 - javaScriptCore
  3. 火狐 - SpideMonkey
  4. QuickJs
  5. FaceBook - Hermes

编译JS时大致流程

  1. JS源码通过解析器、解析成抽象语法树AST

  2. 通过解释器编译成字节码

    字节码是一种跨平台的表示,能够在不同的平台上运行

  3. 字节码通过编译器、生成不同平台的机器代码(汇编代码)

JavaScript原理--运行原理

在不同js引擎中流程会有一定的差异

V8引擎是什么

简单来说:V8是一个接收JavaScript代码,编译代码然后执行的C++程序,编译后的代码可以在多种操作系统、多种处理器上执行。

V8引擎也就是JavaScript引擎

主要负责

  • 编译和执行JS代码
  • 处理调用栈
  • 内存的分配
  • 垃圾回收

组成

大部分JavaScript引擎在编译执行JS代码都会用到三个组件

  • 解析器: 负责将JS源代码解析成抽象语法树AST

  • 解释器:负责将AST解释成字节码

  • 编译器:负责将字节码编译出更加高效的机器代码

早期的V8

由于早期的V8架构不成熟,导致了一系列的问题

  • 机器码占用大量内存
  • 缺少中间层机器码、无法实现一些优化策略
  • 无法很好的支持和优化新的JS语法新特性

V8设计架构

  • 语法树解析还是基本保持一致

  • 加入解释器ignition,通过解释器生成字节码,此时AST就被清除掉了,释放内存空间

  • 生成的字节码作为基准执行模型、同时字节码直接被解释器执行

    字节码更加简洁、相当于等效的机器码的20-50%

  • 在代码不断运行的过程中、解释器收集到很多用来优化代码的信息,优化信息发送给编译器

    比如:变量类型、哪些函数执行频率较高

    随着JS源码的不断执行,就会有更多的优化机器代码产生

  • 编译器根据优化信息、编译出优化后的机器代码,直接执行优化后的机器代码

  • 优化后的机器代码可能会被逆向还原成字节码

从编译过程和内存管理两个方面来探索 JavaScript 引擎的工作机制

编译过程

编译器的基本工作流程,大体上包括 3 个步骤:解析(Parsing)、转换(Transformation)及代码生成(Code Generation),JavaScript 引擎与之相比大体上也遵循这个过程,可分为解析、解释和优化 3 个步骤。

下面我们就以 V8 引擎为例进行讲解。

解析(解析器)

解析步骤又可以拆分成 2 个小步骤:

  • 词法分析,将 JavaScript 代码解析成一个个的令牌(Token);
  • 语法分析,将令牌组装成一棵抽象的语法树(AST)。

下面是一段简单的代码,声明了一个字符串变量并调用函数console.log进行打印。

    var name = 'web'
    console.log(name)

通过词法分析会对这段代码逐个字符进行解析,生成类似下面结构的令牌(Token),这些令牌类型各不相同,有关键字、标识符、符号、字符串。

    Keyword(var)
    Identifier(name)
    Punctuator(=)
    String('web')
    Identifier(console)
    Punctuator(.)
    Identifier(log)
    Punctuator(()
    Identifier(name)
    Punctuator())

语法分析阶段会用令牌生成类似下面结构的抽象语法树,生成树的过程并不是简单地把所有令牌都添加到树上,而是去除了不必要的符号令牌之后,按照语法规则来生成。

JavaScript原理--运行原理

解释(解释器)

JavaScript 引擎通过解释器 Ignition将 AST 转换成字节码。字节码是对机器码的一个抽象描述,相对于机器码而言,它的代码量更小,从而可以减少内存消耗。

下面代码是从示例代码生成的字节码中截取的一段。它的语法已经非常接近汇编语言了,有很多操作符,比如 StackCheck、Star、Return。考虑这些操作符过于底层,涉及处理器的累加器及寄存器操作,已经超出前端范围,这里就不详细介绍了。

    [generated bytecode for function: log (0x1e680d83fc59 <SharedFunctionInfo log>)]
    Parameter count 1
    Register count 6
    Frame size 48
     9646 E> 0x376a94a60ea6 @    0 : a7                StackCheck 
             ......
             0x376a94a60ec9 @   35 : 26 f6             Star r5
     9683 E> 0x376a94a60ecb @   37 : 5a f9 02 f7 f6 06 CallProperty2 r2, <this>, r4, r5, [6]
             0x376a94a60ed1 @   43 : 0d                LdaUndefined 
     9729 S> 0x376a94a60ed2 @   44 : ab                Return 
    Constant pool (size = 3)
    Handler Table (size = 0)
    Source Position Table (size = 24)
优化(编译器)

解释器在得到 AST 之后,会按需进行解释和执行,也就是说如果某个函数没有被调用,则不会去解释执行它。 在这个过程中解释器会将一些重复可优化的操作(比如类型判断)收集起来生成分析数据,然后将生成的字节码和分析数据传给编译器 TurboFan,编译器会依据分析数据来生成高度优化的机器码。

优化后的机器码的作用和缓存很类似,当解释器再次遇到相同的内容时,就可以直接执行优化后的机器码。当然优化后的代码有时可能会无法运行(比如函数参数类型改变),那么会再次反优化为字节码交给解释器。

整个过程如下面流程图所示:

JavaScript原理--运行原理

内存管理

JavaScript 引擎的内存空间分为堆(Heap)和栈(Stack)。堆和栈是两种不同的数据结构,堆是具有树结构的数组,栈也是数组,但是遵循“先进后出”规则。

栈是一个临时存储空间,主要存储局部变量和函数调用(对于全局表达式会创建匿名函数并调用)。 对于基本数据类型(String、Undefined、Null、Boolean、Number、BigInt、Symbol)的局部变量,会直接在栈中创建,而对象数据类型局部变量会存储在堆中,栈中只存储它的引用地址,也就是我们常说的浅拷贝。全局变量以及闭包变量也是只存储引用地址。

总而言之栈中存储的数据都是轻量的。

对于函数,解释器创建了“调用栈”(Call Stack)来记录函数的调用流程。每调用一个函数,解释器就会把该函数添加进调用栈,解释器会为被添加进的函数创建一个栈帧 (Stack Frame,这个栈帧用来保存函数的局部变量以及执行语句)并立即执行。如果正在执行的函数还调用了其它函数,那么新函数也将会被添加进调用栈并执行。一旦这个函数执行结束,对应的栈帧也会被立即销毁。 查看调用栈的方式有 2 种:

  • 调用函数 console.trace() 打印到控制台;
  • 利用浏览器开发者工具进行断点调试。

示例

    // 下面的代码是一个计算斐波那契数列的函数,分别通过调用 console.trace() 函数以及断点的方式得到了它的调用栈信息。
    function fib(n) {
      if (n < 3) return 1
      console.trace();
      return fib(n-1) + fib(n-2)
    }
    fib(4)

示例效果图

JavaScript原理--运行原理

虽然栈很轻量,只会在使用时创建,使用结束时销毁,但它并不是可以无限增长的。当分配的调用栈空间被占满时,就会引发“栈溢出”错误。 下面是一个递归函数导致的栈溢出报错代码片段:

    (function recursive() {
      recursive()
    })()

栈溢出错误

JavaScript原理--运行原理

所以我们在编写递归函数的时候一定要注意函数执行边界,也就是退出递归的条件。

堆空间存储的数据比较复杂,大致可以划分为下面 5 个区域:

  • 代码区(Code Space)、
  • Map 区(Map Space)、
  • 大对象区(Large Object Space)、
  • 新生代(New Space)、
  • 老生代(Old Space)。

重点讨论新生代和老生代的内存回收算法。

新生代

大多数的对象最开始都会被分配在新生代,该存储空间相对较小,只有几十 MB,分为两个空间:from 空间和 to 空间。 程序中声明的对象首先会被分配到from 空间,当进行垃圾回收时,会先将 from空间中存活的的对象(存活对象可以理解为被引用的对象)复制到to空间进行保存,对未存活的对象空间进行回收。当复制完成后,from 空间和to 空间进行调换,to空间会变为新的 from空间,原来的 from空间则变为to空间,这种算法称之为 Scavenge

新生代的内存回收频率很高、速度也很快,但空间利用率较低,因为让一半的内存空间处于“闲置”状态。

下图:Scanvage 回收过程

JavaScript原理--运行原理

老生代

新生代中多次回收仍然存活的对象会被转移到空间较大的老生代。因为老生代空间较大,如果回收方式仍然采用Scanvage算法来频繁复制对象,那性能开销就太大了。 所以老生代采用的是另一种“标记清除”(Mark-Sweep)的方式来回收未存活的对象空间。

这种方式主要分为标记清除两个阶段。标记阶段会遍历堆中所有对象,并对存活的对象进行标记;清除阶段则是对未标记对象的空间进行回收。

下图:标记清除回收过程

JavaScript原理--运行原理 由于标记清除不会对内存一分为二,所以不会浪费空间。但是进行过标记清除之后的内存空间会产生很多不连续的碎片空间,这种不连续的碎片空间中,在遇到较大对象时可能会由于空间不足而导致无法存储的情况。 为了解决内存碎片的问题,提高对内存的利用,还需要使用到标记整理(Mark-Compact) 算法。

标记整理算法相对于标记清除算法在回收阶段进行了改进,标记整理对待未标记的对象并不是立即进行回收,而是将存活的对象移动到一边,然后再清理。当然这种移动对象的操作相对而言是比较耗时的,所以执行速度上,比标记清除要慢。

下图:标记整理回收过程

JavaScript原理--运行原理

知识支撑

尾调用

递归调用由于调用次数较多,同时每层函数调用都需要保存栈帧,所以通常是比较消耗内存的操作。对递归的优化一般有两个思路,减少递归次数和使用尾调用。 尾调用(Tail Call)是指函数的最后一步返回另一个函数的调用。

例如下面的代码中,函数 a() 返回了函数 b() 的调用。

    function a(x){
      return b(x);
    }

    // 像下面的示例中,返回缓存的函数调用结果,或者返回多个函数调用都不属于“尾调用”。

    function a(x){
      let c = b(x);
      return c;
    }
    function a(x){
      return b(x) + c(x);
    }
    function a() {
      b(x)
    }

尾调用由于是在 return 语句中,并且是函数的最后一步操作,所以局部变量等信息不需要再用到,从而可以立即释放节省内存空间。 下面的示例代码通过递归实现了求斐波那契额数列第 n 个数的功能。函数 fibTail() 相对于函数 fib() 就同时使用了尾调用以及减少调用次数两种优化方式。

    function fib(n) {
      if (n < 3) return 1
      return fib(n-1) + fib(n-2)
    }
    function fibTail(n, a = 0, b = 1) {
      if (n === 0) return a
      return fibTail(n - 1, b, a + b)
    }

但是由于尾调用也存在一些隐患,比如错误信息丢失、不方便调试,所以浏览器以及 Node.js 环境默认并没有支持这种优化方式。

奇葩的JavaScript

JavaScript原理--运行原理

    ('b'+'a'+ + 'a'+'a'+ +'').toLowerCase() //"banana0"

    /*
    一元正号的优先级高于加法
      MDN查了一下JavaScript运算符优先级;一元正号的优先级高于加法,所以我们这样用括号会让这个代码更加清晰`'b'+'a'+ (+ 'a') +'a'+ (+'')`
    一元正号的运算规则
      官方文档:一元正号运算符位于其操作数前面,计算其操作数的数值,如果操作数不是一个数值,会尝试将其转换成一个数值。 
      尽管一元负号也能转换非数值类型,但是一元正号是转换其他对象到数值的最快方法,也是最推荐的做法,因为它不会对数值执行任何多余操作。它可以将字符串转换成整数  和浮点数形式,也可以转换非字符串值true,false和null。小数和十六进制格式字符串也可以转换成数值。负数形式字符串也可以转换成数值(对于十六进制不适用)。   如果它不能解析一个值,则计算结果为 NaN。`(+ 'a') to NaN ; (+'') to 0`
    最终在调用toLowerCase函数转成小写,就变成了banana0
    */
    0 == '0' //true  值相等

    0 == [] //true
    /*
    首先获取[]的原始值,即空字符串“”,然后进行 0 与 “” 之间的比较。“” to 0 , 故0==0
    */

    '0' == [] //false
    /*
    首先获取[]的原始值,即空字符串“”,然后进行 '0' 与 “” 之间的比较。
    */

    [] == false //true
    /*
    首先获取[]的原始值,即空字符串“”,因为操作数之一是Boolean, 所以“” to 0 , false to 0; 故 0 == 0
    */

    0 == false //true

    "" == false //true  包括多空格

    undefined == false //true

    NaN == false //true

    null == false //true

    /*
    双等号 == 比较不同类型的值时,会尝试先转换,再比较其内容
    一个操作数,另一个是数字或者字符串,尝试将字符串转为数字值。
    操作数之一是Boolean,会转换为number
    如果其中一个操作数为 `null` 或 `undefined`,另一个操作数也必须为 `null` 或 `undefined` 以返回 `true`。否则返回 `false`
    如果任何一个操作数是 `NaN`,返回 `false`;所以,`NaN` 永远不等于 `NaN`。
    */
    typeof NaN //number

    /*
    `NaN`是*全局对象*的一个属性。换句话说,它是全局作用域中的一个变量
    常量`NaN`明确地为“数字”类型;用于描述一系列值,这些位的位排列使得规范的规则无法有意义地解释这些值;`NaN`不应被理解为口语短语“不是数字”的含义,它们是数字,但它们是“非数字”数字。
    */
    9999999999999999 //100000000000
    /*
      大整数的精度丢失和浮点数本质上是一样的,尾数位最大是52位,因此 JS 中能精准表示的最大整数是 Math.pow(2, 53),十进制即 9007199254740992。
    */

    0.1+0.2==0.3 //false

    /*
    看似有穷的数字, 在计算机的二进制表示里却是无穷的,由于存储位数限制因此存在“舍去”,精度丢失就发生了
      进制转换
        js 在做数字计算的时候,0.1 和 0.2 都会被转成二进制后无限循环 ,但是 js 采用的 IEEE 754 二进制浮点运算,尾数最大可以存储 53 位有效数字,于是大于     53 位后面的会全部截掉,将导致精度丢失。
          双精度存储(double precision),占用 64 bit。(1位用来表示符号位,11位用来表示指数,52位表示尾数)
      对阶运算
        由于指数位数不相同,运算时需要对阶运算,阶小的尾数要根据阶差来右移(`0舍1入`),尾数位移时可能会发生数丢失的情况,影响精度。
    */
    Math.max() //-Infinity  负无穷

    Math.min() //Infinity  正无穷
    /*
    max、min函数返回作为输入参数的最大数字,如果没有参数,则返回 Infinity/ -Infinity。
    全局属性 **`Infinity`** 是一个数值,表示无穷大
    */
    [] + {} //"【object object】"
    /*
    首先这两个都是对象类型

    第一个[]执行valueOf后,因为数组的valueOf会返回数组本身,因此这个返回值还不是基本类型值,因此会执行toString,所以[]这时候变成了""空字符串

    第二个{}执行valueOf后,因为对象的valueOf会返回对象本身,因此这个返回值还不是基本类型值,因此会执行toString,而对象的toString会变成[object Object]

     "" + [object Object]
    */

    {} + [] // 0
    /*
    按照之前的套路来说,应该是将两个对象类型都执行toPrimitive,然后进行+运算符

    但是这里有个坑,也就是js引擎会将{}这个解析为代码块,因此会变成{};+[]

    所以此时只会执行 +[] ,而一元运算符+会对值进行toNumber操作,因此[]的toNumber为0

    在括号内,{}并不会被解析为代码块,所以({} + []) => "[object Object]"
    */

    true + true + true===3 //true
    /*
    这是二元+运算符,会对左右两只进行hint值为default的toPrimitive调用,因此true先执行valueOf,因为是Boolean类型,所以返回的是true本身,而此时已经是基本类型值了,直接返回true这个值

    这时进行完toPrimitive调用后,判断两边值是否有string类型,此时两边的值都是true,为Boolean类型

    所以对两值进行 加法运算, 这时true会执行toNumber,true变为1,因此1+1+1 === 3
    */
    true - true //0

    9 + "1" //91

    91 - "1" //90

    (! + [] + [] + ![]).length//9 "truefalse"
    /*
    !优先级高于+,所以执行顺序会变成(!( + [] ) + [] + ![])

    因此先看第一个!(+[]),此时先执行()内部的+[],之前也说了,一元运算符+会对值进行toNumber,而[]的toNumber为0

    所以现在为!0,而取反运算符,会对值进行toBoolean操作,0的toBoolean为false,然后判断false是否为true,如果不为true,返回true,所以现在第一个表达式算出来后为true

    接着看第二个!的执行,![],[]进行toBoolean操作,转为true,再进行!true,所以此时为false;所以现在成了 true + [] + false

    而此时又是二元+运算符了,对两边的值进行toPrimitive,先分析true + [],true返回true,而[]执行valueOf后返回本身,所以又执行toString后,[]的toString为"“空字符串,所以执行完toPrimitive后,为 true + ”",接着判断两边值是否有string类型,确实有一个string类型,那么进行拼接操作,因此true+[]的结果为"true"

    接着进行"true" + false, 同上面一样,进行完toPrimitive后,判断string类型,恰好"true"为string类型,进行拼接操作,所以变成了"truefalse".length
    */

最后一句 学习心得!若有不正,还望斧正。

转载自:https://juejin.cn/post/7249375708919840805
评论
请登录