JS 垃圾回收和运行机制

117 阅读6分钟

1.V8

编译器有 SpiderMonkey、JavascriptCore、V8 V8 是 Google 开源的 JavaScript 引擎,被广泛应用于各种 JavaScript 执行环境,比如 Chrome 浏览器、Node.js、Electron 以及 Deno 等。

1.1 V8的执行流程

image.png

  • 解析器:将Javascript源码通过解析器转化为AST语法树🌲
  • 解释器:将AST通过解释器翻译为字节码,一边解释一遍执行
  • 优化编译器:在解释执行的过程中,解释器会记特定代码片段的运行次数,如果运行次数超过了某个阈值,那么该段代码会被标记为热代码(hot code)并且将运行信息反馈给优化编译器,优化编译器根据反馈信息优化编译字节码最终生成优化后的机器码,这样当该段代码再次执行时,解释器就直接使用优化后的机器码执行,不用再次解释,从而提高代码的运行效率。

1.2 解析器(Parser)

解析器的过程可以分为两个部分:词法分析和语法分析

  • 词法分析:将字符流转化为tokens。字符流就是我们编写的一行行代码,token是指语法上不能再分割的最小单位。
  • 语法分析:根据语法规则将tokens组成一个嵌套层级的抽象语法树(AST),在这个过程中如果源码不符合规范,解析过程就会终止,并抛出语法错误。

举个例子:var a = 1; 对于这个代码

首先词法分析阶段会将这个字符流转化为5个token

[
    {
        "type": "Keyword",
        "value": "var"
    },
    {
        "type": "Identifier",
        "value": "a"
    },
    {
        "type": "Punctuator",
        "value": "="
    },
    {
        "type": "Numeric",
        "value": "1"
    },
    {
        "type": "Punctuator",
        "value": ";"
    }
]

然后通过语法分析通过tokens生成AST。(可以通过astexplorer.net/ 这个网站查看)

{
  "type": "Program",
  "start": 0,
  "end": 10,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 10,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 4,
          "end": 9,
          "id": {
            "type": "Identifier",
            "start": 4,
            "end": 5,
            "name": "a"
          },
          "init": {
            "type": "Literal",
            "start": 8,
            "end": 9,
            "value": 1,
            "raw": "1"
          }
        }
      ],
      "kind": "var"
    }
  ],
  "sourceType": "module"
}

注意: 如果对于所有的js源码都要完全经过解析才能执行,那必然面临3个问题:

  1. 代码执行时间变长
  2. 内存消耗增加
  3. 占用磁盘空间

所以现在的引擎都会进行延迟解析。举个例子 ·11·1·1·11···qaqqqq1`wq2frdeswaq

function foo(a,b){
    var res = a + b
    return res
}

var a = 1;
var c = 2;
foo(1, 2)

V8解析器会从上往下解析代码,当V8解析到foo的时候发现它不是立即执行,那么只会解析函数声明,并不会解析函数内部的代码。只有当执行到 foo(1,2)的时候才会去执行函数内部的代码

2. 垃圾回收

2.1 引用计数法

跟踪记录每一个变量值被使用的次数。

当一个变量被一个引用类型的值赋值后,那么这个值的引用次数就 +1 ,如果这个变量被其他的值赋值后,那么上次的值的引用次数就-1。

举个例子:var a = {x: 1},那么{x:1}的引用次数就为1.然后又执行了a = {x:2},那么{x:1}的次数-1就变成了0。它就被立即回收

优点:可以被立即回收。

缺点:需要一个计数器,要占用很大位置。还有就是无法解决循环引用无法回收的问题。

2.2 标记清除

执行过程:

  1. 垃圾收集器会在运行时给所有的变量都加上一个标记,假设内存中的所有对象都是垃圾,标记为0
  2. 然后从根对象开始遍历,把不是垃圾的节点改成1
  3. 清理所有的标记为0的垃圾,回收内存空间
  4. 把所有的内存中的对象标记修改为0,等待下一次回收

优点:只有一个优点就是实现比较简单,无非就是标记或者不标记。

缺点:清除内存之后,剩余的内存位置是不变的,就导致内存空间不连续,出现内存碎片,如果清理之后,再去重新分配对象内存空间则需要遍历所有的内存块。

2.3 优化方案:分代式垃圾回收机制

针对垃圾回收的缺点,V8 通过分代式的方法去管理内存。会分为新生代老生代

截屏2023-08-20 16.12.10.png 新生代:当有新的对象时,会将其分配到使用区,如果使用区快满的时候,开始垃圾清除对于仍然保留在内存中的对象将其复制到空闲区,然后将使用区和空闲区互换。

如果一个对象在多次复制的过程中一直保留,则会直接将其移动到老生代中进行管理。

另外,如果一个对象复制到空闲区时,如果空闲区的占用空间超过25%,那么这个对象会直接移入老生代中。

老生代:中的垃圾回收会通过标记整理的方法进行处理。

问题:JS是单线程语言,在执行垃圾回收的时候,会阻塞JS的执行。

V8的优化方式就是并行回收,就是通过开启辅助线程进行垃圾回收,主线程则用来执行JS。

但是这样依然会阻塞。

继续优化:切片标记:将一次GC标记的过程分成很多小步,每执行一小步就继续执行JS,这样最大程度的保证JS的执行,也不会有较长时间的阻塞

截屏2023-08-20 16.39.51.png

3.运行机制

3.1 浏览器的进程

浏览器有五大进程:主进程、插件进程、渲染进程、网络进程、GPU进程

  • 主进程:负责界面显示、用户交互、子进程管理
  • 插件进程:负责插件进程,当插件崩溃时不会影响浏览器页面
  • 渲染进程:将HTML、css、js转化为用户可以与之交互的页面。我们通常所说的JS引擎和排版内核都是运行在这个进程中的
  • 网络进程:负责网络资源的加载
  • GPU进程:负责3D效果的渲染

3.2 浏览器的事件循环

JS是单线程的语言。但是它为什么是单线程呢?

假如它是多线程的。一个线程在某个DOM节点上添加了内容,而另一个线程又删除了节点。那这时该以哪个线程为准,所以为了避免复杂性,JS就只能是单线程的。

单线程也就意味着所有的任务都需要排队,前一个任务结束才会执行下一个任务。

在实际的执行过程中,就必须要按照顺序一步一步执行吗?即使中间的某一步可能会花费很长的时间。比如网络请求数据

答案当然是否定的。任务可以分为两种: 同步任务异步任务