浏览器JavaScript执行原理分析

JavaScript执行原理

JavaScript 的执行流程

  • 可以在定义之前使用变量或者函数的原因:变量提升是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值,这个默认值就是 undefined
showName()//函数 showName 被执行
console.log(myname)//undefined
var myname = '时间'
function showName() {
    
    
    console.log('函数 showName 被执行');
}

JavaScript 代码在执行之前需要被 JavaScript 引擎编译,编译完成之后,才会进入执行阶段

1. 编译阶段

image-20230405225212462

输入一段代码,经过编译后,会生成两部分内容:执行上下文(Execution context)和可执行代码

执行上下文是 JavaScript 执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间用到的诸如 this、变量、对象以及函数等。

2. 执行阶段

JavaScript 引擎开始执行“可执行代码”,按照顺序一行一行地执行

一段代码如果定义了两个相同名字的函数,那么最终生效的是最后一个函数编译时后者会覆盖前者

执行上下文

当一段代码被执行时,JavaScript 引擎先会对其进行编译,并创建执行上下文。

哪些情况下代码才算是“一段”代码,才会在执行之前就进行编译并创建执行上下文。一般说来,有这么三种情况:

  • 当 JavaScript 执行全局代码的时候,会编译全局代码并创建全局执行上下文,而且在整个页面的生存周期内,全局执行上下文只有一份
  • 当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁
  • 当使用 eval 函数的时候,eval 的代码也会被编译,并创建执行上下文

变量环境

在执行上下文中存在一个变量环境的对象(Viriable Environment),该对象中保存了变量提升的内容

调用栈

  • 每调用一个函数,JavaScript 引擎会为其创建执行上下文,并把该执行上下文压入调用栈,然后 JavaScript 引擎开始执行函数代码。
  • 如果在一个函数 A 中调用了另外一个函数 B,那么 JavaScript 引擎会为 B 函数创建执行上下文,并将 B 函数的执行上下文压入栈顶。
  • 当前函数执行完毕后,JavaScript 引擎会将该函数的执行上下文弹出栈。
  • 当分配的调用栈空间被占满时,会引发“堆栈溢出”问题。
var a = 2
function add(b,c){
    
    
  return b+c
}
function addAll(b,c){
    
    
var d = 10
result = add(b,c)
return  a+result+d
}
addAll(3,6)

image-20230405231204711

image-20230405231245179

image-20230405231315563

image-20230405231327145

image-20230405231335737

image-20230405231342962

作用域

作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

在 ES6 之前,ES 的作用域只有两种:全局作用域和函数作用域。

  • 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
  • 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。

ES6 之前是不支持块级作用域的

变量提升所带来的问题

变量容易在不被察觉的情况下被覆盖掉

//全局上下文中 myname=" 极客时间 ",function showName()
var myname = " 极客时间 "
function showName(){
    
    
  console.log(myname);
  if(1){
    
    
     //变量提升
   var myname = " 极客邦 "
  }
  console.log(myname);
}
//执行函数,创建函数上下文,函数中var myname 变量提升导致函数上下文中存在myname=undefined,如果没有变量提升,函数上下文中没有myname变量,则会去全局上下文中获取变量
showName()
//undefined
// 极客邦 

image-20230405233137041

本应销毁的变量没有被销毁

function foo(){
    
    
    // 由变量提升而导致的,在创建执行上下文阶段,变量 i 就已经被提升了,所以当 for 循环结束之后,变量 i 并没有被销毁。
  for (var i = 0; i < 7; i++) {
    
    
  }
  console.log(i); 
}
foo()

ES6 是如何解决变量提升带来的缺陷

ES6 引入了 let 和 const 关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域

使用 let 关键字声明的变量是可以被改变的,而使用 const 声明的变量其值是不可以被改变的。两者都可以生成块级作用域

function letTest() {
    
    
  let x = 1;
  if (true) {
    
    
    let x = 2;  // 不同的变量
    console.log(x);  // 2
  }
  console.log(x);  // 1
}

因为 let 关键字是支持块级作用域的,所以在编译阶段,JavaScript 引擎并不会把 if 块中通过 let 声明的变量存放到变量环境中,这也就意味着在 if 块通过 let 声明的关键字,并不会提升到全函数可见。所以在 if 块之内打印出来的值是 2,跳出语块之后,打印出来的值就是 1 了。这种就非常符合我们的编程习惯了:作用块内声明的变量不影响块外面的变量

JavaScript 是如何支持块级作用域的

在同一段代码中,ES6 是如何做到既要支持变量提升的特性,又要支持块级作用域

function foo(){
    
    
    var a = 1
    let b = 2
    {
    
    
      let b = 3
      var c = 4
      let d = 5
      console.log(a)
      console.log(b)
    }
    console.log(b) 
    console.log(c)
    console.log(d)
}   
foo()

第一步是编译并创建执行上下文

image-20230405235210912

  • 函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。
  • 通过 let 声明的变量,在编译阶段会被存放到**词法环境(Lexical Environment)**中。
  • 在函数的作用域内部,通过 let 声明的变量并没有被存放到词法环境中。

第二步继续执行代码,当执行到代码块里面时,变量环境中 a 的值已经被设置成了 1,词法环境中 b 的值已经被设置成了 2,这时候函数的执行上下文就如下图所示:

image-20230405235512956

当进入函数的作用域块时,作用域块中通过 let 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,比如在作用域外面声明了变量 b,在该作用域块内部也声明了变量 b,当执行到作用域内部时,它们都是独立的存在。

  • 在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。需要注意下,我这里所讲的变量是指通过 let 或者 const 声明的变量。

当执行到作用域块中的console.log(a)这行代码时,就需要在词法环境和变量环境中查找变量 a 的值了,具体查找方式是:沿着词法环境的栈顶向下查询,如果在词法环境中的某个块中查找到了,就直接返回给 JavaScript 引擎,如果没有查找到,那么继续在变量环境中查找。

image-20230405235901688

当作用域块执行结束之后,其内部定义的变量就会从词法环境的栈顶弹出,最终执行上下文如下图所示:

image-20230405235939923

词法作用域

词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

image-20230406001407041

词法作用域就是根据代码的位置来决定的,其中 main 函数包含了 bar 函数,bar 函数中包含了 foo 函数,因为 JavaScript 作用域链是由词法作用域决定的,所以整个词法作用域链的顺序是:foo 函数作用域—>bar 函数作用域—>main 函数作用域—> 全局作用域。

作用域链

  • 在 JavaScript 执行过程中,其作用域链是由词法作用域决定的

  • 词法作用域是代码阶段就决定好的,和函数是怎么调用的没有关系

function bar() {
    
    
    console.log(myName)
}
function foo() {
    
    
    var myName = " 极客邦 "
    bar()
}
var myName = " 极客时间 "
foo()//极客时间

image-20230406000712516

在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为outer

当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量,
比如上面那段代码在查找 myName 变量时,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。

bar 函数和 foo 函数的 outer 都是指向全局上下文的,这也就意味着如果在 bar 函数或者 foo 函数中使用了外部变量,那么 JavaScript 引擎会去全局执行上下文中查找。我们把这个查找的链条就称为作用域链

块级作用域中的变量查找

function bar() {
    
    
    var myName = " 极客世界 "
    let test1 = 100
    if (1) {
    
    
        let myName = "Chrome 浏览器 "
        console.log(test)
    }
}
function foo() {
    
    
    var myName = " 极客邦 "
    let test = 2
    {
    
    
        let test = 3
        bar()
    }
}
var myName = " 极客时间 "
let myAge = 10
let test = 1
foo()

image-20230406002103082

闭包

function foo() {
    
    
    var myName = " 极客时间 "
    let test1 = 1
    const test2 = 2
    var innerBar = {
    
    
        getName:function(){
    
    
            console.log(test1)
            return myName
        },
        setName:function(newName){
    
    
            myName = newName
        }
    }
    return innerBar
}
var bar = foo()
bar.setName(" 极客邦 ")
bar.getName()
console.log(bar.getName())

当执行到 foo 函数内部的return innerBar这行代码时调用栈的情况

image-20230406002412783

  • innerBar 是一个对象,包含了 getName 和 setName 的两个方法
  • 并且这两个方法内部都使用了 myName 和 test1 两个变量

根据词法作用域的规则,内部函数 getName 和 setName 总是可以访问它们的外部函数 foo 中的变量,所以当 innerBar 对象返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 getName 和 setName 函数依然可以使用 foo 函数中的变量 myName 和 test1。所以当 foo 函数执行完成之后,其整个调用栈的状态如下图所示:

image-20230406002532008

foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 setName 和 getName 方法中使用了 foo 函数内部的变量 myName 和 test1,所以这两个变量依然保存在内存中。这像极了 setName 和 getName 方法背的一个专属背包,无论在哪里调用了 setName 和 getName 方法,它们都会背着这个 foo 函数的专属背包。这个背包称为 foo 函数的闭包

在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。

当执行到 bar.setName 方法中的myName = "极客邦"这句代码时,JavaScript 引擎会沿着“当前执行上下文–>foo 函数闭包–> 全局执行上下文”的顺序来查找 myName 变量

image-20230406002923421

image-20230406003122056

产生闭包的核心有两步:第一步是需要预扫描内部函数;第二步是把内部函数引用的外部变量保存到堆中

this 机制

var bar = {
    
    
    myName:"time.geekbang.com",
    printName: function () {
    
    
        console.log(myName)
    }    
}
function foo() {
    
    
    let myName = " 极客时间 "
    return bar.printName
}
let myName = " 极客邦 "
let _printName = foo()
_printName()//极客邦
bar.printName()//极客邦

在 printName 函数里面使用的变量 myName 是属于全局作用域下面的,所以最终打印出来的值都是“极客邦”。这是因为 JavaScript 语言的作用域链是由词法作用域决定的,而词法作用域是由代码结构来确定的。

按照常理来说,调用bar.printName方法时,该方法内部的变量 myName 应该使用 bar 对象中的,因为它们是一个整体,大多数面向对象语言都是这样设计的

在对象内部的方法中使用对象内部的属性是一个非常普遍的需求。但是 JavaScript 的作用域机制并不支持这一点,基于这个需求,JavaScript 又搞出来另外一套this 机制

JavaScript 中的 this 是什么

image-20230406224236490

this 是和执行上下文绑定的,也就是说每个执行上下文中都有一个 this。

执行上下文主要分为三种——全局执行上下文、函数执行上下文和 eval 执行上下文,所以对应的 this 也只有这三种——全局执行上下文中的 this、函数中的 this 和 eval 中的 this。

全局执行上下文中的 this

在控制台中输入console.log(this)来打印出来全局执行上下文中的 this,最终输出的是 window 对象。所以可以得出这样一个结论:全局执行上下文中的 this 是指向 window 对象的这也是 this 和作用域链的唯一交点,作用域链的最底端包含了 window 对象,全局执行上下文中的 this 也是指向 window 对象。

函数执行上下文中的 this

function foo(){
    
    
  console.log(this)
}
foo()//window 

在默认情况下调用一个函数,其执行上下文中的 this 也是指向 window 对象的。

通过函数的 call 方法设置执行上下文中的 this 值

除了 call 方法,你还可以使用bindapply方法来设置函数执行上下文中的 this,它们在使用上还是有一些区别的

可以通过函数的call方法来设置函数执行上下文的 this 指向,比如下面这段代码,并没有直接调用 foo 函数,而是调用了 foo 的 call 方法,并将 bar 对象作为 call 方法的参数

let bar = {
    
    
  myName : " 极客邦 ",
  test1 : 1
}
function foo(){
    
    
  this.myName = " 极客时间 "
}

foo.call(bar)
console.log(bar)
console.log(myName)

执行这段代码,然后观察输出结果,发现 foo 函数内部的 this 已经指向了 bar 对象,因为通过打印 bar 对象,可以看出 bar 的 myName 属性已经由“极客邦”变为“极客时间”了,同时在全局执行上下文中打印 myName,JavaScript 引擎提示该变量未定义。

通过对象调用方法设置
var myObj = {
    
    
  name : " 极客时间 ", 
  showThis: function(){
    
    
    console.log(this)
  }
}
myObj.showThis()

定义了一个 myObj 对象,该对象是由一个 name 属性和一个 showThis 方法组成的,然后再通过 myObj 对象来调用 showThis 方法。执行这段代码,最终输出的 this 值是指向 myObj 的。

使用对象来调用其内部的一个方法,该方法的 this 是指向对象本身的

也可以认为 JavaScript 引擎在执行myObject.showThis()时,将其转化为了:

myObj.showThis.call(myObj)

稍微改变下调用方式,把 showThis 赋给一个全局对象,然后再调用该对象,this 又指向了全局 window 对象

var myObj = {
    
    
  name : " 极客时间 ",
  showThis: function(){
    
    
    this.name = " 极客邦 "
    console.log(this)
  }
}
var foo = myObj.showThis
foo()
  • 在全局环境中调用一个函数,函数内部的 this 指向的是全局变量 window。
  • 通过一个对象来调用其内部的一个方法,该方法的执行上下文中的 this 指向对象本身。
通过构造函数中设置
function CreateObj(){
    
    
  this.name = " 极客时间 "
}
var myObj = new CreateObj()

new 关键字会进行如下的操作:

  • 创建一个空的简单 JavaScript 对象(即 {});
  • 为步骤 1 新创建的对象添加属性 __proto__,将该属性链接至构造函数的原型对象;
  • 将步骤 1 新创建的对象作为 this 的上下文;
  • 如果该函数没有返回对象,则返回 this

当执行 new CreateObj() 的时候,JavaScript 引擎做了如下四件事:

  • 一个继承自 CreateObj.prototype 的新对象tempObj 被创建。
  • 使用指定的参数调用构造函数 CreateObj,并将 this 绑定到新创建的对象
    • 先调用 CreateObj.call 方法,并将 tempObj 作为 call 方法的参数,这样当 CreateObj 的执行上下文创建时,它的 this 就指向了 tempObj 对象;
    • 然后执行 CreateObj 函数,此时的 CreateObj 函数执行上下文中的 this 指向了 tempObj 对象;
  • 由构造函数返回的对象就是 new 表达式的结果。如果构造函数没有显式返回一个对象,则使用步骤 1 创建的tempObj 对象

this 的设计缺陷以及应对方案

嵌套函数中的 this 不会从外层函数中继承

var myObj = {
    
    
  name : " 极客时间 ", 
  showThis: function(){
    
    
    console.log(this)//函数 showThis 中的 this 指向的是 myObj 对象
    function bar(){
    
    
        console.log(this)//函数 bar 中的 this 指向的是全局 window 对象
    }
    bar()
  }
}
myObj.showThis()

通过一个小技巧来解决这个问题,在 showThis 函数中声明一个变量 self 用来保存 this,然后在 bar 函数中使用 self,这个方法的的本质是把 this 体系转换为了作用域的体系

var myObj = {
    
    
  name : " 极客时间 ", 
  showThis: function(){
    
    
    console.log(this)
    var self = this
    function bar(){
    
    
      self.name = " 极客邦 "
    }
    bar()
  }
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)

也可以使用 ES6 中的箭头函数来解决这个问题,ES6 中的箭头函数并不会创建其自身的执行上下文,所以箭头函数中的 this 取决于它的外部函数。

var myObj = {
    
    
  name : " 极客时间 ", 
  showThis: function(){
    
    
    console.log(this)
    var bar = ()=>{
    
    
      this.name = " 极客邦 "
      console.log(this)
    }
    bar()
  }
}
myObj.showThis()
console.log(myObj.name)
console.log(window.name)

this 没有作用域的限制,这点和变量不一样,所以嵌套函数不会从调用它的函数中继承 this,这样会造成很多不符合直觉的代码。要解决这个问题,你可以有两种思路:

  • 第一种是把 this 保存为一个 self 变量,再利用变量的作用域机制传递给嵌套函数。
  • 第二种是继续使用 this,但是要把嵌套函数改为箭头函数,因为箭头函数没有自己的执行上下文,所以它会继承调用函数中的 this。

普通函数中的 this 默认指向全局对象 window

在默认情况下调用一个函数,其执行上下文中的 this 是默认指向全局对象 window 的。

在实际工作中,我们并不希望函数执行上下文中的 this 默认指向全局对象,因为这样会打破数据的边界,造成一些误操作。如果要让函数执行上下文中的 this 指向某个对象,最好的方式是通过 call 方法来显示调用。

JavaScript 是什么类型的语言

  • 在使用之前就需要确认其变量数据类型的称为静态语言
  • 相反地,把在运行过程中需要检查数据类型的语言称为动态语言
  • 支持隐式类型转换的语言称为弱类型语言
  • 不支持隐式类型转换的语言称为强类型语言

JavaScript 是一种弱类型的、动态的语言

JavaScript 的数据类型

想要查看一个变量到底是什么类型,可以使用“typeof”运算符

var bar
console.log(typeof bar)  //undefined
bar = 12 
console.log(typeof bar) //number
bar = " 极客时间 "
console.log(typeof bar)//string
bar = true
console.log(typeof bar) //boolean
bar = null
console.log(typeof bar) //object
bar = {
    
    name:" 极客时间 "}
console.log(typeof bar) //object

image-20230407213627277

  • 使用 typeof 检测 Null 类型时,返回的是 Object。
  • Object 类型比较特殊,它是由上述 7 种类型组成的一个包含了 key-value 对的数据类型。
  • 前面的 7 种数据类型称为原始类型,把最后一个对象类型称为引用类型

内存空间

image-20230407213811682

在 JavaScript 的执行过程中, 主要有三种类型内存空间,分别是代码空间、栈空间堆空间

栈空间和堆空间

原始类型的数据值都是直接保存在“栈”中的,引用类型的值是存放在“堆”中的

function foo(){
    
    
    var a = " 极客时间 "
    var b = a
    var c = {
    
    name:" 极客时间 "}
    var d = c
}
foo()

image-20230407214350350

image-20230407214218850

image-20230407214326608

JavaScript 中的垃圾回收

调用栈中的数据是如何回收的

function foo(){
    
    
    var a = 1
    var b = {
    
    name:" 极客邦 "}
    function showName(){
    
    
      var c = " 极客时间 "
      var d = {
    
    name:" 极客时间 "}
    }
    showName()
}
foo()

image-20230407220434206

执行到 showName 函数时,那么 JavaScript 引擎会创建 showName 函数的执行上下文,并将 showName 函数的执行上下文压入到调用栈中,最终执行到 showName 函数时,其调用栈就如上图所示。与此同时,还有一个记录当前执行状态的指针(称为 ESP),指向调用栈中 showName 函数的执行上下文,表示当前正在执行 showName 函数。

接着,当 showName 函数执行完成之后,函数执行流程就进入了 foo 函数,那这时就需要销毁 showName 函数的执行上下文了。ESP 这时候就帮上忙了,JavaScript 会将 ESP 下移到 foo 函数的执行上下文,这个下移操作就是销毁 showName 函数执行上下文的过程

image-20230407220604077

当一个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文

堆中的数据是如何回收的

image-20230407220726966

要回收堆中的垃圾数据,就需要用到 JavaScript 中的垃圾回收器了

代际假说(The Generational Hypothesis)

垃圾回收领域中一个重要的术语,后续垃圾回收的策略都是建立在该假说的基础之上的

代际假说有以下两个特点:

  • 第一个是大部分对象在内存中存在的时间很短,简单来说,就是很多对象一经分配内存,很快就变得不可访问;
  • 第二个是不死的对象,会活得更久。

JavaScript 引擎 V8 实现垃圾回收(与java及其相似)

在 V8 中会把堆分为新生代老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象

新生区通常只支持 1~8M 的容量,而老生区支持的容量就大很多了。对于这两块区域,V8 分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收。

  • 副垃圾回收器,主要负责新生代的垃圾回收。
  • 主垃圾回收器,主要负责老生代的垃圾回收。
垃圾回收器的工作流程

不论什么类型的垃圾回收器,它们都有一套共同的执行流程

  • 第一步是标记空间中活动对象和非活动对象。
  • 第二步是回收非活动对象所占据的内存。
  • 第三步是做内存整理。一般来说,频繁回收对象后,内存中就会存在大量不连续空间,我们把这些不连续的内存空间称为内存碎片。当内存中出现了大量的内存碎片之后,如果需要分配较大连续内存的时候,就有可能出现内存不足的情况。所以最后一步需要整理这些内存碎片,但这步其实是可选的,因为有的垃圾回收器不会产生内存碎片

image-20230407221144597

副垃圾回收器

副垃圾回收器主要负责新生区的垃圾回收。而通常情况下,大多数小的对象都会被分配到新生区,所以说这个区域虽然不大,但是垃圾回收还是比较频繁的。

新生代中用Scavenge 算法来处理。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域

新加入的对象都会存放到对象区域,当对象区域快被写满时,就需要执行一次垃圾清理操作。

在垃圾回收过程中,首先要对对象区域中的垃圾做标记;标记完成之后,就进入垃圾清理阶段,副垃圾回收器会把这些存活的对象复制到空闲区域中,同时它还会把这些对象有序地排列起来,所以这个复制过程,也就相当于完成了内存整理操作,复制后空闲区域就没有内存碎片了。

完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域。这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去

由于新生代中采用的 Scavenge 算法,所以每次执行清理操作时,都需要将存活的对象从对象区域复制到空闲区域。但复制操作需要时间成本,如果新生区空间设置得太大了,那么每次清理的时间就会过久,所以为了执行效率,一般新生区的空间会被设置得比较小

新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript 引擎采用了对象晋升策略,也就是经过两次垃圾回收依然还存活的对象,会被移动到老生区中。

主垃圾回收器

主垃圾回收器主要负责老生区中的垃圾回收。除了新生区中晋升的对象,一些大的对象会直接被分配到老生区。因此老生区中的对象有两个特点,一个是对象占用空间大,另一个是对象存活时间长。

由于老生区的对象比较大,若要在老生区中使用 Scavenge 算法进行垃圾回收,复制这些大的对象将会花费比较多的时间,从而导致回收执行效率不高,同时还会浪费一半的空间。因而,主垃圾回收器是采用**标记 - 清除(Mark-Sweep)**的算法进行垃圾回收的。

标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据

image-20230407221551899

接下来就是垃圾的清除过程

image-20230407221624865

不过对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了另外一种算法——标记 - 整理(Mark-Compact),这个标记过程仍然与标记 - 清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

image-20230407221732340

全停顿(Stop-The-World)

V8 是使用副垃圾回收器和主垃圾回收器处理垃圾回收的,不过由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)

image-20230407221821794

在 V8 新生代的垃圾回收中,因其空间较小,且存活对象较少,所以全停顿的影响不大,但老生代就不一样了。如果在执行垃圾回收的过程中,占用主线程时间过久,就像上面图片展示的那样,花费了 200 毫秒,在这 200 毫秒内,主线程是不能做其他事情的。比如页面正在执行一个 JavaScript 动画,因为垃圾回收器在工作,就会导致这个动画在这 200 毫秒内无法执行的,这将会造成页面的卡顿现象。

为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段完成,我们把这个算法称为增量标记(Incremental Marking)算法。如下图所示:

image-20230407221918344

V8 的工作原理

编译器和解释器

编译型语言在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了

而由解释型语言编写的程序,在每次运行时都需要通过解释器对程序进行动态解释和执行。比如 Python、JavaScript 等都属于解释型语言。

image-20230407222240373

V8 是如何执行一段 JavaScript 代码的

image-20230407222504311将源代码转换为抽象语法树,并生成执行上下文

var myName = " 极客时间 "
function foo(){
    
    
  return 23;
}
myName = "geektime"
foo()

生成的 AST 结构如下:

image-20230407223617967

编译器或者解释器后续的工作都需要依赖于 AST,而不是源代码

AST 是非常重要的一种数据结构

Babel 的工作原理就是先将 ES6 源码转换为 AST,然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代码。

第一阶段是分词(tokenize),又称为词法分析,其作用是将一行行的源码拆解成一个个 token。所谓token,指的是语法上不可能再分的、最小的单个字符或字符串。

image-20230407224224779

第二阶段是解析(parse),又称为语法分析,其作用是将上一步生成的 token 数据,根据语法规则转为 AST。如果源码符合语法规则,这一步就会顺利完成。但如果源码存在语法错误,这一步就会终止,并抛出一个“语法错误”。

有了 AST 后,那接下来 V8 就会生成该段代码的执行上下文。

有了 AST 和执行上下文后,那接下来的第二步,解释器 Ignition 就登场了,它会根据 AST 生成字节码,并解释执行字节码。

一开始 V8 并没有字节码,而是直接将 AST 转换为机器码,由于执行机器码的效率是非常高效的,所以这种方式在发布后的一段时间内运行效果是非常好的。但是随着 Chrome 在手机上的广泛普及,特别是运行在 512M 内存的手机上,内存占用问题也暴露出来了,因为 V8 需要消耗大量的内存来存放转换后的机器码。为了解决内存占用问题,V8 团队大幅重构了引擎架构,引入字节码,并且抛弃了之前的编译器,最终花了将进四年的时间,实现了现在的这套架构。

字节码就是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。

image-20230407230212540

机器码所占用的空间远远超过了字节码,所以使用字节码可以减少系统的内存使用。

如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。在执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。

字节码配合解释器和编译器的技术,比如 Java 和 Python 的虚拟机也都是基于这种技术实现的,称为即时编译(JIT)

“语法错误”。

有了 AST 后,那接下来 V8 就会生成该段代码的执行上下文。

有了 AST 和执行上下文后,那接下来的第二步,解释器 Ignition 就登场了,它会根据 AST 生成字节码,并解释执行字节码。

一开始 V8 并没有字节码,而是直接将 AST 转换为机器码,由于执行机器码的效率是非常高效的,所以这种方式在发布后的一段时间内运行效果是非常好的。但是随着 Chrome 在手机上的广泛普及,特别是运行在 512M 内存的手机上,内存占用问题也暴露出来了,因为 V8 需要消耗大量的内存来存放转换后的机器码。为了解决内存占用问题,V8 团队大幅重构了引擎架构,引入字节码,并且抛弃了之前的编译器,最终花了将进四年的时间,实现了现在的这套架构。

字节码就是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。

[外链图片转存中…(img-bWRQAvjQ-1682523193368)]

机器码所占用的空间远远超过了字节码,所以使用字节码可以减少系统的内存使用。

如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。在执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。

字节码配合解释器和编译器的技术,比如 Java 和 Python 的虚拟机也都是基于这种技术实现的,称为即时编译(JIT)

image-20230408000306073

猜你喜欢

转载自blog.csdn.net/weixin_46488959/article/details/130396808