JavaScript如何工作:引擎 编译器 作用域
从本文开始 我将深入研究JavaScript及其实际工作原理 从源头剖解 由于学校上课等原因限制 尽量保持每周两篇速度更新(作者是一名刚刚大三的学生 就读于东北大学管理学院 曾在滴滴出行 必示科技等互联网公司任职 喜欢JavaScript Go等语言 对前后端 大数据以及机器学习有一点点见解 欢迎各位与我一起分享成长)
概述
平时我们只管写代码 然后通过浏览器或者Node执行 并未深层次探究 大多数人都听过引擎 例如V8 也知道JavaScript是一门单线程语言 这篇文章简述代码执行过程 后续对每一步进行深入探究
运转过程
JavaScript代码的整个执行过程需要引擎、作用域、编译器的合作完成
- 编译器
负责词法分析 语法分析 代码生成供引擎执行 - 作用域
收集标识符 维护查询的一套规则 - 引擎
程序的编译执行
下面我们通过一个简单的例子了解整个过程
demo
var a=2
- 编译器进行词法分析语法分析 分解成词法单元 将词法单元解析成抽象语法树
- 编译器将变量
a存入当前作用域 首先询问作用域当前是否有变量a如果有就忽略该声明 否则声明变量 命名为a为引擎生成运行时的代码 - 引擎执行过程取询问作用域当前是否有变量
a如果有 取出来赋值为2 如果没有 则作用域取外层作用域寻找 最终找到则赋值2 倘若全局作用域没有找到该变量 非严格模式下作用域帮我们创建一个变量a并赋值为2 严格模式下则报错
可以看一下我画的简略图
引擎
JavaScript引擎是执行JavaScript代码的解释器 其可以实现为标准解释器或者以某种形式将JavaScript编译为字节码的即时编译器
JavaScript引擎较为流行的例子是Google的V8引擎 在chrome和Node.js中使用
V8引擎主要有两个主要部分组成:
- memory Heap(内存堆) ---内存分配地址的地方
- Call Stack(调用堆栈)---代码执行的地方
需要注意一点 我们平时使用的一些浏览器的API(比如
setTimeout)并非引擎提供 这些由浏览器提供API称为Web API
V8引擎
V8引擎由谷歌构建 且开源 使用C++编写 其初始目的为了提高web浏览器中JavaScript执行的性能 为了获得速度 V8将JavaScript代码转换为机器码 并非使用解释器 (JavaScript是一门编译语言)一步到位 不生成字节码或者任何中间代码
前期阶段
V8在5.9版本之前使用两个编译器
full-codegen一个简单且非常快的编译器 产生简单和较慢的机器码(对应前文编译器作用)Crankshaft一个复杂(Just-In-Time)的编译器 生成优化的代码
V8内部多线程
- 主线程执行我们的操作:获取代码 执行代码
- 专门的线程用于编译 主线程可以在其优化代码的同时执行代码
Profiler线程告诉我们运行花费时间 让Crankshaft优化- 一些线程进行垃圾回收(处理垃圾收集器)
- 当我们第一次执行JavaScript代码时 V8利用
full-codegen编译器 将解析的JavaScript编译成机器码不再转换 快递执行机器代码(V8没有中间字节码 不需要解释器) - 一段时间后分析线程收集足够的数据判断优化方法
Crankshaft从其他线程开始优化 将JavaScript抽象语法树转换为称作Hydrogen的高级静态单分配表示 并进行优化Hydrogen图
隐藏类
JavaScript并没有真正意义的继承(基于原型)即没有克隆过程
大多数JavaScript解释器使用类似字典的结构存储对象属性值在内存中的位置 但这种结构在JavaScript中检查属性的值计算成本较高(JavaScript是一门动态语言 我们可以在实例化后在对象中添加属性)
静态语言(例如Go)属性值可以作为连续缓冲区存储在存储器中 (便于查找 您可以仔细想想)每个缓冲区之间有固定偏移量 可以根据属性类型确定偏移的长度 但是JavaScript运行时可以随便更改属性类型 这种方式不行
故V8使用了隐藏类 隐藏类与其他语言中工作方式类似 不过它们在运行时创建 下面看个例子
function X(a, b) {
this.a = a
this.b = b
}
let x = new X (1, 2)
- 当
new X(1,2)调用 开始将创建一个C0的隐藏类 该类不带有任何属性 (尚未定义属性 ) this.a = a语句执行 引擎生成C1的过渡隐藏类 它基于C0其描述了找到属性a的存储器的位置(有点指针的意思)即记录属性的偏移量 它的存在是为了在多个对象间能够共享隐藏类- 这是
a存储在偏移0位置 即当存储器中x对象视为连续缓冲区时 第一偏移对应属性a - 当我们添加属性时 引擎使用类转换更新
C1即this.b = b语句执行 隐藏类应从a对应的C1切换到C2(允许以相同方式创建的对象之间共享隐藏类 若两个对象共享一个隐藏类且同一属性添加到它们 确保两者都接受到相同的新隐藏类以及携带的所有代码)
不同初始化顺序的对象 隐藏类的使用情况也是不同的 故开发中尽量保持属性初始化的顺序 这样隐藏类即可共享 思想下面的代码执行有什么不同效果
function Person(name, age) {
this.name = name;
this.age = age;
}
var laowang = new Person("隔壁老王", 32);
var laozhang = new Person("专家老张", 20);
laowang.email = "aaa@qq.com";
laowang.job = "无业游民";
laozhang.job = "程序员";
laozhang.email = "laaaaa@qq.com";
----------------------------------------
function Person(name, age) {
this.name = name;
this.age = age;
}
var laowang = new Person("隔壁老王", 32);
var laozhang = new Person("专家老张", 20);
laowang.job = "无业游民";
laozhang.job = "程序员";
laozhang.email = "laaaaa@qq.com";
laowang.email = "aaa@qq.com";
内联代码及内联缓存
前面我们说过优化 第一个优化是提前内联尽可能多的代码 内联是用被调用函数的主体替换调用点的过程 看图就懂
仅仅依靠隐藏类还不够 引擎执行过程还需查找隐藏类 故V8加入内联缓存技术优化运行时查找对象及其属性的过程 核心原理即在运行过程中 收集类型信息 从而可以在后续运行中利用这些信息进行预判(有点机器学习的感觉 哈哈 有个大胆的想法 )
V8维护在最近方法调用中作为参数传递的对象类型的缓存 并使用这些信息预测将来作为参数传递的对象类型 这样即可使用从以前的查找到对象的隐藏类的存储信息
无论何时在特定对象调用方法 V8引擎必须执行对该对象的隐藏类的查找 确定访问特定属性的偏移量 当同一个隐藏类的两次方法都调用之后 V8就会省略对隐藏类的查找 直接简单的将该属性的偏移量添加到对象指针本身 那么对于该方法的下一次调用 V8将假定隐藏类没有改变 使用从以前的查找存储的偏移量直接跳转到特定属性的内存地址
这也解释了为什么相同类型的对象共享隐藏类 如果你创建两个相同类型和不同隐藏类的对象 V8将无法使用内联缓存 因为即使这两个对象属于同一类型 它们对应的隐藏类为其属性分配不同的偏移量
肯定有人想问 如果类型在程序执行中发生变化怎么办????
对于这种情况 内联缓存会在直接调用之前验证类型 这些验证类型的代码叫做**前导代码**
var arr= [2,4,6,8];
arr,foreach((item) => console.log(item.toString()))
上面的代码 arrr[0]在第一次toString方法时发起一次动态查询 记录查询结果 当后续调用toString方法时 引擎根据上次的记录直接获知调用点 不再进行动态查询操作 但是问题来了
var arr=[2,'4',6,'8']
arr.foreach(item => console.log(item.toString()))
可以看到 对象类型经常发生变化 这就会导致缓存失效 为了防止这种情况 V8采用了polymorphic inline cache(PIC)(多态内联缓存)技术 它不仅只存储上一次查询结果 而是会缓存所有多态调用点中的查询结果 并把这个结果存储在一个特别产生的桩函数 这个桩函数实际上会在调用点位置上代替原有的调用点
调用栈
JavaScript是一门单线程语言 这就意味着只能有一个调用栈 它一次只能做一件事
调用栈记录我们程序执行的位置 如果我们运行到一个函数 它就会被推入栈 当从这个函数返回时 它会从栈顶弹出
举个栗子
function a (x,y) {
return x + y
}
function b(x) {
var c=a(x,x)
console.log(c)
}
b(1)
- 函数
b推入栈中 - 函数
a推入栈中 此时调用栈有函数a和b两个 - 函数
a执行完毕了 推出执行栈console.log(c)推入栈中 console.log(c)执行完毕推出栈- 函数
b执行完毕了 推出栈 此时栈为空
每一次进入调用栈的称为调用帧
某些时候我们调用栈堆中的函数调用数量超过调用栈堆的实际大小 浏览器会采取行动 抛出异常
深入编译
一旦Hydrogen图被优化 Crankshaft将其降低到称为Lithium的低级表示 其实现都是特定架构 寄存器分配大多发生在这个级别
最后Lithium被编译成机器码 然后就是OSR:on-stack-replacement(堆栈替换) 我们开始编译和优化一个方法之前 我们可能会运行堆栈替换 V8不只是缓慢执行堆栈替换 并再次开始优化 而是转换我们拥有的所有上下文 (堆栈、寄存器) 以便在执行过程中切换到优化版本 具体就不太深入了 (其实是我看不懂)
垃圾收回
标记清除
当变量进入环境 (例如在函数中声明一个变量)时 将这个变量标记为进入环境 从逻辑上讲 永远不能释放进入环境的变量所占用的内存 只要进入相应的环境 就可能会用到它们 当变量离开环境时 标记为离开环境
可以使用任何方式来标记变量 比如 可以通过翻转某个特殊的位来记录一个变量何时进入环境 或者使用一个“进入环境的”变量列表及一个“离开环境的”变量列表来跟踪哪个变量发生了变化 如何标记变量并不重要 关键在于采取什么策略
- 垃圾收集器在运行的时候会给存储在内存中的所有变量加上标记
- 然后 它会去掉运行环境中的变量以及被环境变量引用的变量的标记
- 此后 如果依然有标记的变量就视为准备删除的变量 原因是运行环境中已经无法访问这些变量
- 最后 垃圾收集器完成内存清除工作 销毁那些带标记的值并回收它们所占用的内存空间
引用计数
引用计数的垃圾收集策略不太常见。含义是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是1。
如果同一个值又被赋给另一个变量,则该值的引用次数加1。相反,如果包含对这个值引用的变量改变了引用对象,则该值引用次数减1。 当这个值的引用次数变成0时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来。 这样,当垃圾收集器下次再运行时,它就会释放那些引用次数为0的值所占用的内存。
V8垃圾回收策略
V8采用了一种代回收的策略 将内存分为两个生代: 新生代和老生代 新生代的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。分别对新生代和老生代使用不同的垃圾回收算法来提升垃圾回收的效率。对象起初都会被分配到新生代,当新生代中的对象满足某些条件(后面会有介绍)时,会被移动到老生代(晋升)
新生代
大多数都得对象被分配这里 这个区域很小 但是垃圾回收频繁 分配内存很容易 我们只需要保存一个指向内存区的指针 不断根据新对象的大小进行递增即可 当该指针到达了新生代内存区的末尾 就会有有一次清除(仅为新生代)
回收算法
新生代使用Scavenge算法进行回收。在Scavenge算法的实现中,主要采用了Cheney算法
Cheney算法算法是一种采用复制的方式实现的垃圾回收算法。它将内存一分为二,每一部分空间称为semispace。在这两个semispace中,一个处于使用状态,另一个处于闲置状态。处于使用状态的semispace空间称为From空间,处于闲置状态的空间称为To空间,当我们分配对象时,先是在From空间中进行分配。当开始进行垃圾回收算法时,会检查From空间中的存活对象,这些存活对象将会被复制到To空间中(复制完成后会进行紧缩),而非活跃对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。也就是说,在垃圾回收的过程中,就是通过将存活对象在两个semispace之间进行复制。可以很容易看出来,使用Cheney算法时,总有一半的内存是空的。但是由于新生代很小,所以浪费的内存空间并不大。而且由于新生代中的对象绝大部分都是非活跃对象,需要复制的活跃对象比例很小,所以其时间效率十分理想。复制的过程采用的是BFS(广度优先遍历)的思想,从根对象出发,广度优先遍历所有能到达的对象
具体的执行过程大致是这样:
首先将From空间中所有能从根对象到达的对象复制到To区,然后维护两个To区的指针scanPtr和allocationPtr,分别指向即将扫描的活跃对象和即将为新对象分配内存的地方,开始循环。循环的每一轮会查找当前scanPtr所指向的对象,确定对象内部的每个指针指向哪里。如果指向老生代我们就不必考虑它了。如果指向From区,我们就需要把这个所指向的对象从From区复制到To区,具体复制的位置就是allocationPtr所指向的位置。复制完成后将scanPtr所指对象内的指针修改为新复制对象存放的地址,并移动allocationPtr。如果一个对象内部的所有指针都被处理完,scanPtr就会向前移动,进入下一个循环。若scanPtr和allocationPtr相遇,则说明所有的对象都已被复制完,From区剩下的都可以被视为垃圾,可以进行清理了
写屏障
如果新生代中的一个对象只有一个指向它的指针 而这个指针在老生代中 我们如何判断这个新生代的对象是否存活? 为了解决这个问题 我们需要建立一个列表用来记录所有老生代对象指向新生代对象的情况 每当有老生代对象指向新生代对象的时候 我们记录下来
对象的晋升
当一个对象经过多次新生代的清理依旧幸存,这说明它的生存周期较长,也就会被移动到老生代,这称为对象的晋升。具体移动的标准有两种:
- 对象从From空间复制到To空间时,会检查它的内存地址来判断这个对象是否已经经历过一个新生代的清理,如果是,则复制到老生代中,否则复制到To空间中
- 对象从From空间复制到To空间时,如果To空间已经被使用了超过25%,那么这个对象直接被复制到老生代
老生代
老生代所保存的对象大多数是生命周期很长的甚至是常驻内存的对象 而且老生代占用的内存较多
回收算法
老生代占用内存较多 如果使用
Scavenge算法 浪费一半空间 而且耗时较长 故采用其他算法
- Mark-Sweep(标记清除)
标记清除分为标记和清除两个阶段。在标记阶段需要遍历堆中的所有对象,并标记那些活着的对象,然后进入清除阶段。在清除阶段总,只清除没有被标记的对象。由于标记清除只清除死亡对象,而死亡对象在老生代中占用的比例很小,所以效率较高
标记清除有一个问题就是进行一次标记清楚后,内存空间往往是不连续的,会出现很多的内存碎片。如果后续需要分配一个需要内存空间较多的对象时,如果所有的内存碎片都不够用,将会使得V8无法完成这次分配,提前触发垃圾回收。 - Mark-Compact(标记整理)
标记整理正是为了解决标记清除所带来的内存碎片的问题。标记整理在标记清除的基础进行修改,将其的清除阶段变为紧缩极端。在整理的过程中,将活着的对象向内存区的一段移动,移动完成后直接清理掉边界外的内存。紧缩过程涉及对象的移动,所以效率并不是太好,但是能保证不会生成内存碎片
其他
V8后续引入了增量式整理 以及并行标记和并行清理 通过并行利用多核CPU来提升垃圾回收的性能
总结
这篇文章大概前前后后写了两天 有些地方借鉴了其他文章 有些地方篇幅太长 引用了其他地方 不管如何 只想提高我们的水平 有任何问题可以联系 感谢各位!!!