1.快属性和慢属性:v8是怎样提升对象属性访问速度的?
- 本文我们的主要目标是介绍 V8 内部是如何存储对象的,因为 JavaScript 中的对象是由一组组属性和值组成的,所以最简单的方式是使用一个字典来保存属性和值,但是由于字典是非线性结构,所以如果使用字典,读取效率会大大降低。
- 为了提升查找效率,V8 在对象中添加了两个隐藏属性,排序属性和常规属性,element 属性指向了 elements 对象,在 elements 对象中,会按照顺序存放排序属性。properties 属性则指向了 properties 对象,在 properties 对象中,会按照创建时的顺序保存常规属性。
- 通过引入这两个属性,加速了 V8 查找属性的速度,为了更加进一步提升查找效率,V8 还实现了内置内属性的策略,当常规属性少于一定数量时,V8 就会将这些常规属性直接写进对象中,这样又节省了一个中间步骤。
- 但是如果对象中的属性过多时,或者存在反复添加或者删除属性的操作,那么 V8 就会将线性的存储模式降级为非线性的字典存储模式,这样虽然降低了查找速度,但是却提升了修改对象的属性的速度。 帮助理解
2.函数表达式
- 今天我们主要学习 V8 是如何处理函数表达式的。函数表达式在实际的项目应用中非常广,不过由于函数声明和函数表达式之间非常类似,非常容易引起人们的误解,所以我们先从通过两段容易让人误解的代码,分析了函数声明和函数表达式之间的区别。函数声明的本质是语句,而函数表达式的本质则是表达式。
- 函数声明和变量声明类似,V8 在编译阶段,都会对其执行变量提升的操作,将它们提升到作用域中,在执行阶段,如果使用了某个变量,就可以直接去作用域中去查找。
- 不过 V8 对于提升函数和提升变量的策略是不同的,如果提升了一个变量,那么 V8 在将变量提升到作用域中时,还会为其设置默认值 undefined,如果是函数声明,那么 V8 会在内存中创建该函数对象,并提升整个函数对象
- 函数表达式也是表达式的一种,在编译阶段,V8 并不会将表达式中的函数对象提升到全局作用域中,所以无法在函数表达式之前使用该函数。函数立即表达式是一种特别的表达式,主要用来封装一些变量、函数,可以起到变量隔离和代码隐藏的作用,因此在一些大的开源项目中有广泛的应用
3.原型链:v8是如何实现对象继承的?
- 今天我们的主要目的是介绍清楚 JavaScript 中的继承机制,这涉及到了原型继承机制,虽然基于原型的继承机制本身比较简单,但是在 JavaScript 中,这是通过关键字 new 加上构造函数来体现的。这种方式非常绕,且不符合人的直觉,如果直接上来就介绍 new 加构造函数是怎么工作的,可能会把你给绕晕了
- 于是我先通过每个对象中都有的隐含属性 proto,来介绍了什么是原型和原型链。V8 为每个对象都设置了一个 proto 属性,该属性直接指向了该对象的原型对象,原型对象也有自己的 proto 属性,这些属性串连在一起就成了原型链。
- 不过在 JavaScript 中,并不建议直接使用 proto 属性,主要有两个原因。
- 这是隐藏属性,并不是标准定义的;
- 使用该属性会造成严重的性能问题。
-
所以,在 JavaScript 中,是使用 new 加上构造函数的这种组合来创建对象和实现对象的继承。不过使用这种方式隐含的语义过于隐晦,所以理解起来有点难度。
-
为什么 JavaScript 中要使用这种怪异的方式来创建对象?为了理解这个问题,我们回顾了一段 JavaScript 的历史。由于当前的 Java 非常流行,基于市场推广的考虑,JavaScript 采取了蹭 Java 热度的策略,在语言命名上使用了 Java 字样,在语法形式上也模仿了 Java。事实上通过这些策略,确实为 JavaScript 带来了市场上的成功。不过你依然要记住,JavaScript 和 Java 是完全两种不同的语言。
4.作用域链:v8是如何查找变量的?
- 今天,我们主要解释了一个问题,那就是在一个函数中,如果使用了一个变量,或者调用了另外一个函数,V8 将会怎么去查找该变量或者函数。
- 为了解释清楚这个问题,我们引入了作用域的概念。作用域就是用来存放变量和函数的地方,全局作用域中存放了全局环境中声明的变量和函数,函数作用域中存放了函数中声明的变量和函数。当在某个函数中使用某个变量时,V8 就会去这些作用域中查找相关变量。沿着这些作用域查找的路径,我们就称为作用域链。
- 要了解查找路径,我们需要明白词法作用域,词法作用域是按照代码定义时的位置决定的,而 JavaScript 所采用的作用域机制就是词法作用域,词法作用域称为静态作用域。所以作用域链的路径就是按照词法作用域来实现的。
- 和静态作用域相对的是动态作用域,动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是基于函数定义的位置的
5.类型转换:v8是如何实现1+“2”的?
- 在 JavaScript 中,类型系统是依据 ECMAScript 标准来实现的,所以 V8 会严格根据 ECMAScript 标准来执行。在执行加法过程中,V8 会先通过 ToPrimitive 函数,将对象转换为原生的字符串或者是数字类型,在转换过程中,ToPrimitive 会先调用对象的 valueOf 方法,如果没有 valueOf 方法,则调用 toString 方法,如果 vauleOf 和 toString 两个方法都不返回基本类型值,便会触发一个 TypeError 的错误。
6.运行时环境:运行JavaScript代码的基石
- 宿主环境在启动过程中,会构造堆空间,用来存放一些对象数据,还会构造栈空间,用来存放原生数据。由于堆空间中的数据不是线性存储的,所以堆空间可以存放很多数据,但是读取的速度会比较慢,而栈空间是连续的,所以堆空间中的查找速度非常快,但是要在内存中找到一块连续的区域却显得有点难度,于是所有的程序都限制栈空间的大小,这就是我们经常容易出现栈溢出的一个主要原因。
- 如果在浏览器中,JavaScript 代码会频繁操作 window(this 默认指向 window 对象)、操作 dom 等内容,如果在 node 中,JavaScript 会频繁使用 global(this 默认指向 global 对象)、File API 等内容,这些内容都会在启动过程中准备好,我们把这些内容称之为全局执行上下文。
- 全局执行上下文中和函数的执行上下文生命周期是不同的,函数执行上下文在函数执行结束之后,就会被销毁,而全局执行上下文则和 V8 的生命周期是一致的,所以在实际项目中,如果不经常使用的变量或者数据,最好不要放到全局执行上下文中。
- 另外,宿主环境还需要构造事件循环系统,事件循环系统主要用来处理任务的排队和任务的调度。
7.堆和栈,函数是如何影响内存布局的?
- 因为现代语言都是基于函数的,每个函数在执行过程中,都有自己的生命周期和作用域,当函数执行结束时,其作用域也会被销毁,因此,我们会使用栈这种数据结构来管理函数的调用过程,我们也把管理函数调用过程的栈结构称之为调用栈。
- 因为栈在内存中连续的数据结构,所以在通常情况下,栈都有最大容量限制,这也就意味着,函数的嵌套调用次数过多,就会超出栈的最大使用范围,从而导致栈溢出。
- 为了解决栈溢出的问题,我们可以使用 setTimeout 将要执行的函数放到其他的任务中去执行,也可以使用 Promise 来改变栈的调用方式,这涉及到了事件循环和微任务,我们会在后续课程中再来介绍。
8.延迟解析,v8是如何实现闭包的?
-
今天我们主要介绍了 V8 的惰性解析,所谓惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。
-
利用惰性解析可以加速 JavaScript 代码的启动速度,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间。
-
由于 JavaScript 是一门天生支持闭包的语言,由于闭包会引用当前函数作用域之外的变量,所以当 V8 解析一个函数的时候,还需要判断该函数的内部函数是否引用了当前函数内部声明的变量,如果引用了,那么需要将该变量存放到堆中,即便当前函数执行结束之后,也不会释放该变量。
在编译阶段,v8不会对所有代码进行编译,要不然速度会很慢,严重影响用户体验,所以采用一种“惰性编
译”或者“惰性解析”,也就是说 v8默认不会对函数内部的代码进行编译,只有当函数被执行前,才会进行编译。
而闭包的问题指的是:由于子函数使用到了父函数的变量,导致父函数在执行完成以后,它内部被子函数引用的变量无法
及时在内存中被释放。 而闭包问题产生的根本原因是
javascript中本身的特性:
1. 可以在 JavaScript 函数内部定义新的函数;
2. 内部函数中访问父函数中定义的变量;
3. 因为 JavaScript 中的函数是一等公民,所以函数可以作为另外一个函数的返回值。
既然由于javascript本身的这种特性就会出现闭包的问题,那么我们就要想办法解决闭包问题,那么“预编译“ 或者
“预解析” 就出现了, 预编译具体方案: 在编译阶段,v8不会完全不解析函数,而是预解析函数,简单理解来说,
就是判断一下父函数中是否有被子函数饮用的变量,如果有的话,就需要把这个变量copy一份到 堆内存中,同时子函数本身也是一个对象,
它会被存在堆内存中,这样即使父函数执行完成,内存被释放以后,子函数在执行的时候,依然可以从堆内存中访问copy
过来的变量。
9.隐藏类,如何在对象内存中快速查找对象属性?
- 好了,现在我们知道了 V8 会为每个对象分配一个隐藏类,在执行过程中:
- 如果对象的形状没有发生改变,那么该对象就会一直使用该隐藏类
- 如果对象的形状发生了改变,那么 V8 会重建一个新的隐藏类给该对象。
-
我们当然希望对象中的隐藏类不要随便被改变,因为这样会触发 V8 重构该对象的隐藏类,直接影响到了程序的执行性能。那么在实际工作中,我们应该尽量注意以下几点:
-
一,使用字面量初始化对象时,要保证属性的顺序是一致的。比如先通过字面量 x、y 的顺序创建了一个 point 对象,然后通过字面量 y、x 的顺序创建一个对象 point2,代码如下所示:
let point = {x:100,y:200};
let point2 = {y:100,x:200};
-
虽然创建时的对象属性一样,但是它们初始化的顺序不一样,这也会导致形状不同,所以它们会有不同的隐藏类,所以我们要尽量避免这种情况。
-
二,尽量使用字面量一次性初始化完整对象属性。因为每次为对象添加一个属性时,V8 都会为该对象重新设置隐藏类。
-
三,尽量避免使用 delete 方法。delete 方法会破坏对象的形状,同样会导致 V8 为该对象重新生成新的隐藏类。
-
这节课我们介绍了 V8 中隐藏类的工作机制,我们先分析了 V8 引入隐藏类的动机。因为 JavaScript 是一门动态语言,对象属性在执行过程中是可以被修改的,这就导致了在运行时,V8 无法知道对象的完整形状,那么当查找对象中的属性时,V8 就需要经过一系列复杂的步骤才能获取到对象属性。
-
为了加速查找对象属性的速度,V8 在背后为每个对象提供了一个隐藏类,隐藏类描述了该对象的具体形状。有了隐藏类,V8 就可以根据隐藏类中描述的偏移地址获取对应的属性值,这样就省去了复杂的查找流程。 不过隐藏类是建立在两个假设基础之上的:
-
对象创建好了之后就不会添加新的属性;
-
对象创建好了之后也不会删除属性。
-
一旦对象的形状发生了改变,这意味着 V8 需要为对象重建新的隐藏类,这就会带来效率问题。为了避免一些不必要的性能问题,我们在程序中尽量不要随意改变对象的形状。我在这节课中也给你列举了几个最佳实践的策略。
-
最后,关于隐藏类,我们记住以下几点。在 V8 中,每个对象都有一个隐藏类,隐藏类在 V8 中又被称为 map。
-
在 V8 中,每个对象的第一个属性的指针都指向其 map 地址。
-
map 描述了其对象的内存布局,比如对象都包括了哪些属性,这些数据对应于对象的偏移量是多少?如果添加新的属性,那么需要重新构建隐藏类。
-
如果删除了对象中的某个属性,同样也需要构建隐藏类。
10.消息队列.v8是怎么实现回调函数的?
- 今天我们介绍了 V8 是如何执行回调函数的。回调函数有两种类型:同步回调和异步回调,同步回调函数是在执行函数内部被执行的,而异步回调函数是在执行函数外部被执行的。
- 那么,搞清楚异步回调函数在什么时机被执行就非常关键了。为了理清楚这个问题,我们分析了通用 UI 线程宏观架构。UI 线程提供一个消息队列,并将待执行的事件添加到消息队列中,然后 UI 线程会不断循环地从消息队列中取出事件、执行事件。
- 关于异步回调,这里也有两种不同的类型,其典型代表是 setTimeout 和 XMLHttpRequest。
- setTimeout 的执行流程其实是比较简单的,在 setTimeout 函数内部封装回调消息,并将回调消息添加进消息队列,然后主线程从消息队列中取出回调事件,并执行回调函数
- XMLHttpRequest 稍微复杂一点,因为下载过程需要放到单独的一个线程中去执行,所以执行 XMLHttpRequest.send 的时候,宿主会将实际请求转发给网络线程,然后 send 函数退出,主线程继续执行下面的任务。网络线程在执行下载的过程中,会将一些中间信息和回调函数封装成新的消息,并将其添加进消息队列中,然后主线程从消息队列中取出回调事件,并执行回调函数。
11.v8是如何实现微任务的?
- 这节课我们主要从调用栈、主线程、消息队列这三者关联的角度来分析了微任务。
- 调用栈是一种数据结构,用来管理在主线程上执行的函数的调用关系。主线在执行任务的过程中,如果函数的调用层次过深,可能造成栈溢出的错误,我们可以使用 setTimeout 来解决栈溢出的问题。
- setTimeout 的本质是将同步函数调用改成异步函数调用,这里的异步调用是将回调函数封装成宏任务,并将其添加进消息队列中,然后主线程再按照一定规则循环地从消息队列中读取下一个宏任务。
- 消息队列中事件又被称为宏任务,不过,宏任务的时间颗粒度太粗了,无法胜任一些对精度和实时性要求较高的场景,而微任务可以在实时性和效率之间做有效的权衡。
- 微任务之所以能实现这样的效果,主要取决于微任务的执行时机,微任务其实是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。
- 因为微任务依然是在当前的任务中执行的,所以如果在微任务中循环触发新的微任务,那么将导致消息队列中的其他任务没有机会被执行。
12.v8是如何实现async和await?
- Callback 模式的异步编程模型需要实现大量的回调函数,大量的回调函数会打乱代码的正常逻辑,使得代码变得不线性、不易阅读,这就是我们所说的回调地狱问题
- 使用 Promise 能很好地解决回调地狱的问题,我们可以按照线性的思路来编写代码,这个过程是线性的,非常符合人的直觉。
- 但是这种方式充满了 Promise 的 then() 方法,如果处理流程比较复杂的话,那么整段代码将充斥着大量的 then,语义化不明显,代码不能很好地表示执行流程。
- 我们想要通过线性的方式来编写异步代码,要实现这个理想,最关键的是要能实现函数暂停和恢复执行的功能。而生成器就可以实现函数暂停和恢复,我们可以在生成器中使用同步代码的逻辑来异步代码 (实现该逻辑的核心是协程),但是在生成器之外,我们还需要一个触发器来驱动生成器的执行,因此这依然不是我们最终想要的方案。
- 我们的最终方案就是 async/await,async 是一个可以暂停和恢复执行的函数,我们会在 async 函数内部使用 await 来暂停 async 函数的执行,await 等待的是一个 Promise 对象,如果 Promise 的状态变成 resolve 或者 reject,那么 async 函数会恢复执行。因此,使用 async/await 可以实现以同步的方式编写异步代码这一目标。
13.v8的两个垃圾回收器是如何工作的?
- 今天,我们先分析了什么是垃圾数据,从“GC Roots”对象出发,遍历 GC Root 中的所有对象,如果通过 GC Roots 没有遍历到的对象,则这些对象便是垃圾数据。V8 会有专门的垃圾回收器来回收这些垃圾数据。
- V8 依据代际假说,将堆内存划分为新生代和老生代两个区域,新生代中存放的是生存时间短的对象,老生代中存放生存时间久的对象。为了提升垃圾回收的效率,V8 设置了两个垃圾回收器,主垃圾回收器和副垃圾回收器。主垃圾回收器负责收集老生代中的垃圾数据,副垃圾回收器负责收集新生代中的垃圾数据。
- 副垃圾回收器采用了 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。新的数据都分配在对象区域,等待对象区域快分配满的时候,垃圾回收器便执行垃圾回收操作,之后将存活的对象从对象区域拷贝到空闲区域,并将两个区域互换。主垃圾回收器回收器主要负责老生代中的垃圾数据的回收操作,会经历标记、清除和整理过程。
14.v8是如何优化垃圾回收器执行效率的?
- V8 最开始的垃圾回收器有两个特点,第一个是垃圾回收在主线程上执行,第二个特点是一次执行一个完整的垃圾回收流程。
- 由于这两个原因,很容易造成主线程卡顿,所以 V8 采用了很多优化执行效率的方案
- 第一个方案是并行回收,在执行一个完整的垃圾回收过程中,垃圾回收器会使用多个辅助线程来并行执行垃圾回收。
- 第二个方案是增量式垃圾回收,垃圾回收器将标记工作分解为更小的块,并且穿插在主线程不同的任务之间执行。采用增量垃圾回收时,垃圾回收器没有必要一次执行完整的垃圾回收过程,每次执行的只是整个垃圾回收过程中的一小部分工作。
- 第三个方案是并发回收,回收线程在执行 JavaScript 的过程,辅助线程能够在后台完成的执行垃圾回收的操作
- 主垃圾回收器就综合采用了所有的方案,副垃圾回收器也采用了部分方案。
15.几种常见的内存问题解决策略?
通常有三种内存问题:内存泄漏 (Memory leak)、内存膨胀 (Memory bloat)、频繁垃圾回收。
- 在 JavaScript 中,造成内存泄漏 (Memory leak) 的主要原因,是不再需要 (没有作用) 的内存数据依然被其他对象引用着。所以要避免内存泄漏,我们需要避免引用那些已经没有用途的数据。
- 内存膨胀和内存泄漏有一些差异,内存膨胀主要是由于程序员对内存管理不科学导致的,比如只需要 50M 内存就可以搞定的,有些程序员却花费了 500M 内存。要解决内存膨胀问题,我们需要对项目有着透彻的理解,也要熟悉各种能减少内存占用的技术方案。
- 如果频繁使用大的临时变量,那么就会导致频繁垃圾回收,频繁的垃圾回收操作会让你感觉到页面卡顿,要解决这个问题,我们可以考虑将这些临时变量设置为全局变量。