您好!
欢迎来到京东云开发者社区
登录
首页
博文
课程
大赛
工具
用户中心
开源
首页
博文
课程
大赛
工具
开源
更多
用户中心
开发者社区
>
博文
>
javascript运行机制
分享
打开微信扫码分享
点击前往QQ分享
点击前往微博分享
点击复制链接
javascript运行机制
自猿其说Tech
2022-02-09
IP归属:未知
17440浏览
前端
### 1 背景 随着 JavaScript 变得越来越流行,团队正在其堆栈中的多个级别利用它的支持 - 前端、后端、混合应用程序、嵌入式设备等等,几乎每个人都听说过 V8 引擎这个概念,而且大多数人都知道 JavaScript 是单线程的,或者它使用的是回调队列,事实证明,有很多开发人员每天都在使用 JavaScript,但并不了解幕后发生的事情。 通过了解 JavaScript 的构建块以及它们如何协同工作,对内部结构有越来越深入的了解,以便能够编写更好的、无阻塞的、健壮和高性能的应用程序。 如GitHub 统计数据所示,JavaScript 在 GitHub 中的活动存储库和总推送方面名列前茅。 查看最新的 GitHub 语言统计信息:https://madnight.github.io/githut/#/pull_requests/2021/4 ![](//img1.jcloudcs.com/developer.jdcloud.com/c92489f5-97b7-40f2-9344-d75f8fda609220220209141913.png) ### 2 javascript运行机制 #### 2.1 JavaScript引擎 JavaScript引擎是一个程序或执行JavaScript代码的解释器。JavaScript 引擎可以实现为标准解释器,或以某种形式将 JavaScript 编译为字节码的即时编译器。 JavaScript 引擎的一个流行示例是 Google 的 V8 引擎。V8 最初旨在提高 Web 浏览器中 JavaScript 执行的性能。为了获得速度,V8 将 JavaScript 代码翻译成更高效的机器代码,而不是使用解释器。它通过实现JIT(即时)编译器,在执行时将 JavaScript 代码编译为机器代码,就像许多现代 JavaScript 引擎所做的那样,例如 SpiderMonkey 或 Rhino (Mozilla)。这里的主要区别是 V8 不产生字节码或任何中间代码。这是它的外观的非常简化的视图: ![](//img1.jcloudcs.com/developer.jdcloud.com/c73c3dca-0b78-4f09-85eb-39e65c47ee2e20220209142038.png) 引擎由两个主要组件组成: - 内存堆——这是内存分配发生的地方 - 调用栈——这是你的代码执行时的堆栈帧所在的地方 V8 引擎也在内部使用了几个线程: - 主线程做你所期望的:获取你的代码,编译它然后执行它 - 还有一个单独的编译线程,这样主线程可以在主线程优化代码的同时继续执行 - 一个 Profiler 线程,它将告诉运行时我们在哪些方法上花费了大量时间,以便 Crankshaft 可以优化它们 - 处理垃圾收集器扫描的几个线程 #### 2.2 优化点 当第一次执行 JavaScript 代码时,V8 利用full-codegen直接将解析的 JavaScript 转换为机器代码,无需任何转换。这允许它非常快速地开始执行机器代码。请注意,V8 不以这种方式使用中间字节码表示,从而消除了对解释器的需求。 当您的代码运行一段时间后,分析器线程收集了足够的数据来判断应该优化哪个方法。 接下来,曲轴优化在另一个线程中开始。它将 JavaScript 抽象语法树转换为称为Hydrogen的高级静态单赋值 (SSA) 表示,并尝试优化该 Hydrogen 图。大多数优化都是在这个级别完成的。 ##### 2.2.1 内联优化 第一个优化是提前内联尽可能多的代码。内联是用被调用函数的主体替换调用点(调用函数的代码行)的过程。 ![](//img1.jcloudcs.com/developer.jdcloud.com/9d63396b-4536-4304-97b8-7f8052e0fdff20220209142145.png) ##### 2.2.2 隐藏类优化 大多数 JavaScript 解释器使用类似字典的结构(基于哈希函数)来存储对象属性值在内存中的位置。这种结构使得在 JavaScript 中检索属性的值比在非动态编程语言(如 Java 或 C#)中检索的计算成本更高。在Java中,所有的对象属性在编译前都是由一个固定的对象布局决定的,不能在运行时动态添加或删除(好吧,C#有动态类型这是另一个主题)。因此,属性值(或指向这些属性的指针)可以作为连续缓冲区存储在内存中,每个值之间具有固定的偏移量。可以根据属性类型轻松确定偏移量的长度,而这在 JavaScript 中是不可能的,因为属性类型可以在运行时更改。由于使用字典查找对象属性在内存中的位置效率非常低,V8 使用了一种不同的方法:隐藏类。隐藏类的工作方式类似于 Java 等语言中使用的固定对象布局(类),不同之处在于它们是在运行时创建的 ##### 2.2.3 内联缓存 V8 利用了另一种优化动态类型语言的技术,称为内联缓存。内联缓存依赖于对同一方法的重复调用往往发生在同一类型的对象上的观察。 隐藏类和内联缓存的概念有什么关系呢?每当在特定对象上调用方法时,V8 引擎都必须查找该对象的隐藏类,以确定访问特定属性的偏移量。在对同一个隐藏类两次成功调用同一个方法后,V8 省略了隐藏类查找,只是简单地将属性的偏移量添加到对象指针本身。对于该方法的所有未来调用,V8 引擎假定隐藏类没有改变,并使用先前查找存储的偏移量直接跳转到特定属性的内存地址。这大大提高了执行速度。 内联缓存也是相同类型的对象共享隐藏类如此重要的原因。如果你创建了两个相同类型但隐藏类不同的对象(就像我们在前面的例子中所做的那样),V8 将无法使用内联缓存,因为即使这两个对象的类型相同,它们对应的隐藏类为其属性分配不同的偏移量。 #### 2.3 垃圾收集 对于垃圾收集,V8 使用传统的标记和清除的分代方法来清理老年代。标记阶段应该停止 JavaScript 执行。为了控制GC成本,让执行更稳定,V8使用增量标记:不是遍历整个堆,而是尝试标记每个可能的对象,它只遍历堆的一部分,然后恢复正常执行。下一个 GC 停止将从前一个堆遍历停止的地方继续。这允许在正常执行期间非常短的暂停。如前所述,扫描阶段由单独的线程处理。 #### 2.4 内存管理 JavaScript 在创建事物(对象、字符串等)时分配内存,并在不再使用时“自动”释放它,这个过程称为垃圾收集。释放资源的这种看似“自动”的性质是一个混乱的根源,并给 JavaScript(和其他高级语言)开发人员一种错误的印象,他们可以选择不关心内存管理,这是一个大错误。 ##### 2.4.1 内存生命周期 无论您使用哪种编程语言,内存生命周期几乎总是相同的: ![](//img1.jcloudcs.com/developer.jdcloud.com/7dd53dda-572e-454b-82a7-bf1dc887e62820220209142247.png) - **分配内存——**内存由操作系统分配,允许您的程序使用它。在低级语言(例如 C)中,这是您作为开发人员应该处理的显式操作。但是,在高级语言中,这是为您处理的。 - **使用内存——**这是您的程序实际使用先前分配的内存的时间。当您在代码中使用分配的变量时,就会发生读取和写入操作。 - **释放内存——**现在是释放你不需要的整个内存的时候了,这样它就可以再次空闲和可用了。与分配内存操作一样,这在低级语言中是明确的,大多数内存管理问题都出现在这个阶段。 ##### 2.4.2 四种常见的 JavaScript 泄漏 ###### 1)全局变量 使用this以下方法意外创建全局变量: ```javascript function foo() { this.var1 = "潜在的偶然全局变量"; } foo() ``` 可以通过‘use strict’;在 JavaScript 文件的开头添加来避免这一切,这将开启更严格的 JavaScript 解析模式,从而防止意外创建全局变量,意外的全局变量当然是一个问题,但是,通常情况下,您的代码会被显式全局变量所感染,根据定义,垃圾收集器无法收集这些变量。需要特别注意用于临时存储和处理大量信息的全局变量。如果必须,请使用全局变量来存储数据,但是当您这样做时,请确保将其分配为 null 或在完成后重新分配。 ###### 2)被遗忘的定时器或回调 ```javascript var serverData = loadData(); setInterval(function() { var renderer = document.getElementById('renderer'); if(renderer) { renderer.innerHTML = JSON.stringify(serverData); } }, 5000); //这将每~5秒执行一次。 ``` 上面的片段显示了使用引用不再需要的节点或数据的计时器的后果。 该renderer对象可能会在某个时候被替换或删除,这将使间隔处理程序封装的块变得多余。如果发生这种情况,处理程序及其依赖项都不会被收集,因为需要先停止间隔(请记住,它仍然处于活动状态)。 现代浏览器支持垃圾收集器,可以检测这些循环并适当处理它们,即使这样,一旦对象变得过时就移除观察者是符合最佳实践的,应用程序在较旧的浏览器版本下运行也不会造成内存泄露。 ###### 3)关闭 JavaScript 开发的一个关键方面是闭包:一个可以访问外部(封闭)函数变量的内部函数。由于 JavaScript 运行时的实现细节,可能会通过以下方式泄漏内存: ```javascript var theThing = null; var replaceThing = function () { var originalThing = theThing; var unused = function () { if (originalThing) // a reference to 'originalThing' console.log("hi"); }; theThing = { longStr: new Array(1000000).join('*'), someMethod: function () { console.log("message"); } }; }; setInterval(replaceThing, 1000); ``` 一旦replaceThing被调用,theThing就会得到一个由一个大数组和一个新的闭包 ( someMethod)组成的新对象。然而,originalThing由unused变量(它是theThing先前调用的变量)所持有的闭包引用replaceThing。需要记住的是,一旦为同一个父作用域中的闭包创建了一个闭包作用域,这个作用域就会被共享,这阻止了它的收集,当上面的代码段一遍又一遍地运行时,您可能会看到内存使用量激增。当垃圾收集器运行时,它的大小不会缩小。一个闭包链表被创建(theThing在这种情况下它的根是可变的),每个闭包作用域都带有一个对大数组的间接引用。 ###### 4)超出 DOM 引用 在某些情况下,开发人员将 DOM 节点存储在数据结构中。假设您要快速更新表中多行的内容。如果您在字典或数组中存储对每个 DOM 行的引用,则会有两个对同一 DOM 元素的引用:一个在 DOM 树中,另一个在字典中。 ```javascript var elements = { button: document.getElementById('button'), image: document.getElementById('image') }; function doStuff() { elements.image.src = 'http://example.com/image_name.png'; } function removeImage() { document.body.removeChild(document.getElementById('image')); //button元素仍然在elements对象里面,仍然在内存中不能被GC回收 } ``` 当涉及到对 DOM 树内的内部节点或叶节点的引用时,还必须考虑一个额外的考虑因素。如果您<td>在代码中保留对表格单元格(标记)的引用,并决定从 DOM 中删除表格,但仍保留对该特定单元格的引用,则可能会出现重大内存泄漏。您可能认为垃圾收集器会释放除该单元格之外的所有内容。然而,情况并非如此。由于单元格是表格的子节点,子节点保留对其父项的引用,因此对表格单元格的单个引用会将整个表格保存在内存中。 ### 3 编码实践 #### 3.1 对象属性的顺序 始终以相同的顺序实例化对象属性,以便可以共享隐藏类和随后优化的代码。 但对于JavaScript这种动态语言,变量在运行时可以随时由不同类型的对象赋值,并且对象本身可以随时添加删除成员。访问对象属性需要的信息完全由运行时决定。为了实现按照索引的方式访问成员,V8“悄悄地”给运行中的对象分了类,在这个过程中产生了一种V8内部的数据结构,即隐藏类。隐藏类本身是一个对象。 考虑以下代码: ``` function Point(x, y) { this.x = x; this.y = y; } var p1 = new Point(1, 2);复制代码 ``` 如果new Point(1, 2)被调用,v8引擎就会创建一个引隐藏的类C0,由于Point没有定于任何属性,因此“C0”为空,一旦“this.x = x”被执行,v8引擎就会创建一个名为“C1”的第二个隐藏类,当执行this.y = y,将会创建一个C2的隐藏类,则隐藏类更改为C2。基于“c0”,“c1”描述了可以找到属性X的内存中的位置(相当指针)。在这种情况下,隐藏类则会从C0切换到C1,每次向对象添加新的属性时,旧的隐藏类会通过路径切换到新的隐藏类,引擎允许以相同的方式创建对象来共享隐藏类。如果两个对象共享一个隐藏类的话,并且向两个对象添加相同的属性,转换过程中将确保这两个对象使用相同的隐藏类和附带所有的代码优化。 隐藏类的转换的性能,取决于属性添加的顺序,如果添加顺序的不同,效果则不同,如以下代码: ```javascript function Point(x, y) { this.x = x; this.y = y; } var p1 = new Point(1, 2); p1.a = 5; p1.b = 6; var p2 = new Point(3, 4); p2.b = 7; p2.a = 8;复制代码 ``` 你可能以为P1、p2使用相同的隐藏类和转换,其实不然。对于P1对象而言,隐藏类先a再b,对于p2而言,隐藏类则先b后a,最终会产生不同的隐藏类,增加编译的运算开销,这种情况下,应该以相同的顺序动态的修改对象属性,以便可以复用隐藏类。 ![](//img1.jcloudcs.com/developer.jdcloud.com/674d9a87-c8d1-40a8-8a08-ae42a0e89b3e20220209150346.png) ![](//img1.jcloudcs.com/developer.jdcloud.com/40e197ac-e1da-4602-969f-d694abf1d24220220209150357.png) #### 3.2 动态属性 在实例化后向对象添加属性会强制修改隐藏类,减慢为之前的隐藏类优化了的方法。所以应该在构造函数中指定对象的所有属性。 V8 为了进一步提升JavaScript代码的执行效率,编译器生直接生成更高效的机器码。程序在运行时,V8会采集JavaScript代码运行数据。当V8发现某函数执行频繁(内联函数机制),就将其标记为热点函数。针对热点函数,V8的策略较为乐观,倾向于认为此函数比较稳定,类型已经确定,于是编译器,生成更高效的机器码。后面的运行中,万一遇到类型变化,V8采取将JavaScript函数回退到优化前的编译成机器字节码。如以下代码: ```javascript // 片段 1 var person = { add: function(a, b){ return a + b; } }; person.name = 'li'; // 片段 2 var person = { add: function(a, b){ return a + b;}, name: 'li' }; ``` 以上代码实现的功能相同,都是定义了一个对象,这个对象具有一个属性name和一个方法add()。但使用片段2的方式效率更高。片段1给对象obj添加了一个属性name,这会造成隐藏类的派生。给对象动态地添加和删除属性都会派生新的隐藏类。假如对象的add函数已经被优化,生成了更高效的代码,则因为添加或删除属性,这个改变后的对象无法使用优化后的代码。 #### 3.3 函数方法 重复执行相同方法的代码比只执行一次许多不同方法的代码运行得更快(由于内联缓存)。 ```javascript function add(a, b){ return a + b } for(var i=0; i<10000; ++i){ add(i, i); } add('a', 'b');//千万别这么做!复制代码 ``` 函数内部的参数类型越确定,V8越能够生成优化后的代码。 #### 3.4 数组 避免键不是增量数字的稀疏数组。元素不全的稀疏数组是一个哈希表。访问此类数组中的元素的成本更高。此外,尽量避免预先分配大数组。最好是随着发展而增长。最后,不要删除数组中的元素。它会让键变得稀疏。 JavaScript中的数组并不像我们在C或java等语言中遇到的常规数组,在js中数组并不是起始地址+长度构成的一片连续的地址空间。 javascript中数组其实就是个对象,只不过会自动管理一些"数字"属性和length属性罢了。 说的更直接一点,JavaScript中的数组根本没有索引,因为索引应该是数字,而JavaScript中数组的索引其实是字符串。 Javascript数组下标值的范围为0到2的32次方,对于任意给定的数字下标值,如果不在此范围内,js会将它转换为一个字符串,并将该下标对应的值作为该数组对象的一个属性值而不是数组元素,例如array[-1] = "yes" 其实就相当于给array对象添加了一个名为-1的属性,属性值为yes。 如果该下标值在合法范围内,则无论该下标值是数字还是数字字符串,都一律会被转化为数字使用,即 array["100"] = 0 和 array[100] = 0 执行的是相同的操作。 ```javascript const array = [1, 2, 3, 4.56, 'x']; // 密集数组 array.length; // 5 array[9] = 1; array.length;//10 // 稀疏数组 ``` 对于密集数组,大多数操作可以有效执行。相比之下, 稀疏数组的操作需要对原型链进行额外的检查和昂贵的查找。 #### 3.5 标记值 V8用32位来表示对象和数字。由于它的31位,它使用1个bit来知道它是一个对象(flag = 1)还是一个称为SMI(SMall Integer)的整数(flag = 0)。然后,如果数字值大于31位,V8会将该数字框起来,将其变成双精度值并创建一个新对象以将该数字放入其中。尝试尽可能使用31位有符号数字以避免将昂贵的装箱操作转换为JS对象。 ECMAScript 标准约定number数字需要被当成 64 位双精度浮点数处理,但事实上,一直使用 64 位去存储任何数字实际是非常低效的,所以 JavaScript 引擎并不总会使用 64 位去存储数字,引擎在内部采用其他内存表示方式(如 32 位),只要保证数字外部所有能被监测到的特性对齐 64 位的表现就行。 V8对数字进行了分类,将数字分为了Smi 和 HeapNumber ```javascript //32位平台是 2的30次方 //64位平台是 2的31次方 -Infinity // HeapNumber -(2**30)-1 // HeapNumber -(2**30) // Smi -42 // Smi -0 // HeapNumber 0 // Smi 4.2 // HeapNumber 42 // Smi 2**30-1 // Smi 2**30 // HeapNumber Infinity // HeapNumber NaN // HeapNumber ``` ![](//img1.jcloudcs.com/developer.jdcloud.com/fb943f9f-4e94-4963-be35-a5033a29cf5d20220209150801.png) 如果我们需要频繁更新HeapNumber的值,执行效率会比Smi慢得多。 在这个短暂的循环中,引擎不得不创建 6 个HeapNumber实例,0.1、1.1、2.1、3.1、4.1、5.1,而等到循环结束,其中 5 个实例都会成为垃圾。为了防止这个问题,V8 提供了一种优化方式去原地更新非Smi的值:当一个数字内存区域拥有一个非Smi范围内的数值时,V8 会将这块区域标志为Double区域,并会为其分配一个用 64 位浮点表示的MutableHeapNumber实例。此后当你再次更新这块区域,V8 就不再需要创建一个新的HeapNumber实例,而可以直接在MutableNumber实例中进行更新了。 ------------ ###### 自猿其说Tech-京东物流技术发展部 ###### 作者:王晓波
原创文章,需联系作者,授权转载
上一篇:浅谈电子围栏的算法思想及技术实现
下一篇:SpringBoot整合SwaggerUI实现在线 API文档
相关文章
前端十年回顾 | 漫画前端的前世今生
Taro小程序跨端开发入门实战
【技术干货】企业级扫描平台EOS关于JS扫描落地与实践!
自猿其说Tech
文章数
426
阅读量
2149964
作者其他文章
01
深入JDK中的Optional
本文将从Optional所解决的问题开始,逐层解剖,由浅入深,文中会出现Optioanl方法之间的对比,实践,误用情况分析,优缺点等。与大家一起,对这项Java8中的新特性,进行理解和深入。
01
Taro小程序跨端开发入门实战
为了让小程序开发更简单,更高效,我们采用 Taro 作为首选框架,我们将使用 Taro 的实践经验整理了出来,主要内容围绕着什么是 Taro,为什么用 Taro,以及 Taro 如何使用(正确使用的姿势),还有 Taro 背后的一些设计思想来进行展开,让大家能够对 Taro 有个完整的认识。
01
Flutter For Web实践
Flutter For Web 已经发布一年多时间,它的发布意味着我们可以真正地使用一套代码、一套资源部署整个大前端系统(包括:iOS、Android、Web)。渠道研发组经过一段时间的探索,使用Flutter For Web技术开发了移动端可视化编程平台—Flutter乐高,在这里希望和大家分享下使用Flutter For Web实践过程和踩坑实践
01
配运基础数据缓存瘦身实践
在基础数据的常规能力当中,数据的存取是最基础也是最重要的能力,为了整体提高数据的读取能力,缓存技术在基础数据的场景中得到了广泛的使用,下面会重点展示一下配运组近期针对数据缓存做的瘦身实践。
自猿其说Tech
文章数
426
阅读量
2149964
作者其他文章
01
深入JDK中的Optional
01
Taro小程序跨端开发入门实战
01
Flutter For Web实践
01
配运基础数据缓存瘦身实践
添加企业微信
获取1V1专业服务
扫码关注
京东云开发者公众号