JS的垃圾回收机制
JS的垃圾回收机制
1.V8引擎如何回收垃圾
*什么是浏览器内核:
-
1.*.1.浏览器内核:
英文是“Rendering Engine”,可大概译为“渲染引擎”,不过我们一般习惯将之称为“浏览器内核”
通常所谓的浏览器内核也就是浏览器所采用的渲染引擎,渲染引擎决定了浏览器如何显示网页的内容以及页面的格式信息。不同的浏览器内核对网页编写语法的解释也有不同,因此同一网页在不同的内核的浏览器里的渲染(显示)效果也可能不同(开发有时候会要求适配)
浏览器内核是多线程,在内核控制下各线程相互配合以保持同步,一个浏览器通常由以下常驻线程组成:
-
GUI 渲染线程
-
JavaScript引擎线程
-
定时触发器线程
-
事件触发线程
-
异步http请求线程
(ps:js的单线程是指单个进程中是单线程的)
1.GUI渲染线程:
- 主要负责页面的渲染,解析HTML、CSS,构建DOM树,布局和绘制等。
- 当界面需要重绘或者由于某种操作引发回流时,将执行该线程。
- 该线程与JS引擎线程互斥,当执行JS引擎线程时,GUI渲染会被挂起,当任务队列空闲时,主线程才会去执行GUI渲染。
2.JS引擎线程:
- 该线程当然是主要负责处理 JavaScript脚本,执行代码。
- 也是主要负责执行准备好待执行的事件,即定时器计数结束,或者异步请求成功并正确返回时,将依次进入任务队列,等待 JS引擎线程的执行。
- 当然,该线程与 GUI渲染线程互斥,当 JS引擎线程执行 JavaScript脚本时间过长,将导致页面渲染的阻塞。
3.定时器触发线程
- 负责执行异步定时器一类的函数的线程,如: setTimeout,setInterval。
- 主线程依次执行代码时,遇到定时器,会将定时器交给该线程处理,当计数完毕后,事件触发线程会将计数完毕后的事件加入到任务队列的尾部,等待JS引擎线程执行。
4.事件触发线程
-
主要负责将准备好的事件交给 JS引擎线程执行。
比如 setTimeout定时器计数结束, ajax等异步请求成功并触发回调函数,或者用户触发点击事件时,该线程会将整装待发的事件依次加入到任务队列的队尾,等待 JS引擎线程的执行。
5.异步http请求线程
- 负责执行异步请求一类的函数的线程,如: Promise,axios,ajax等。
-
-
1.*.2.回顾一下目前市面上常见的一些浏览器他们所使用的内核:
浏览器 内核版本 IE浏览器内核 Trident内核,也是俗称的IE内核; Chrome浏览器内核 统称为Chromium内核或Chrome内核,以前是Webkit内核,现在是Blink内核 Firefox浏览器内核 Gecko内核,俗称Firefox内核 Safari浏览器内核 Webkit内核 Opera浏览器内核 最初是自己的Presto内核,后来是Webkit,现在是Blink内核 360浏览器、猎豹浏览器内核 IE+Chrome双内核 搜狗、遨游、QQ浏览器内核 Trident(兼容模式)+Webkit(高速模式) 百度浏览器、世界之窗内核 IE内核 2345浏览器内核 以前是IE内核,现在也是IE+Chrome双内核
*了解一下谷歌浏览器和node的v8内核
V8 是谷歌开发的高性能 JavaScript 引擎,该引擎使用 C++ 开发。目前主要应用在 Google Chrome 浏览器和 node.js 当中。而Node是基于Chrome浏览器V8引擎所开发的,所以Chrome浏览器支持的API和语法都是适合Node的。
V8 自带的高性能垃圾回收机制,使开发者能够专注于程序开发中,极大的提高开发者的编程效率。但是方便之余,也会出现一些比较棘手的问题:进程内存暴涨,cpu 飙升,性能很差等。这个时候,了解 V8 的内存结构和垃圾回收机制、知道如何进行性能调优就很有必要。
(1.1)V8 引擎内存
-
1.1.1.一个 V8 进程的内存通常由以下几个块构成:
栈内存:
栈内存主要用于存储各种基本类型的变量,包括Boolean、Number、String、Undefined、Null以及对象变量的指针。
这里是代码执行的地方。
堆内存:
堆内存主要负责存放引用数据类型,包括对象、数组、函数等。
这里也是内存分配发生的地方。
- 新生代内存区(new space)
大多数的对象都会被分配在这里,这个区域很小但是垃圾回收比较频繁;
-
老生代内存区(old space)
属于老生代,这里的数据都是由新生代晋升产生的; -
大对象区(large object space)
-
代码区(code space)
-
map&cell 区(map space)
-
1.1.2.栈的代码回收
调用栈:
调用栈是追踪函数执行流的一种机制。当执行环境中调用了多个函数时,通过这种机制,能够帮助我们追踪到哪个函数正在执行,执行的函数体中又调用了哪个函数。
调用栈的规则:
-
每调用一个函数,解释器就会把该函数添加进调用栈并开始执行。
-
正在调用栈中执行的函数还调用了其它函数,那么被调用的函数也将被添加进调用栈,一旦这个函数被调用,便会立即执行。
-
当前函数执行完毕后,解释器将其清出调用栈,继续执行当前执行环境下的剩余的代码。
-
当分配的调用栈空间被占满时,会引发“堆栈溢出”
举例:
function greeting() { // [1] 这里有代码哦~ sayHi(); // [2] 这里也有代码哦~ } function sayHi() { return "Hi!"; } // 调用greeting函数 greeting(); // [3] 这里还有代码哦~ 复制代码
以上代码会按照如下方式运行:
- 忽略前面所有函数,直到
greeting()
函数被调用。 - 把
greeting()
添加进调用栈列表。
调用栈列表 greeting() - 执行
greeting()
函数体中的所有代码。 - 代码执行到
sayHi()
时,该函数被调用 - 把
sayHi()
添加进调用栈列表
调用栈列表 greeting() sayHI() - 执行
sayHi()
函数体中的代码,直到全部执行完毕。 - 返回来继续执行
greeting()
函数体中sayHi()
后面的代码。 - 删除调用栈列表中的
sayHi()
函数。
调用栈列表 greeting() - 当
greeting()
函数体中的代码全部执行完毕,返回到激活greeting()
的代码行,继续执行剩余JS代码。 - 删除调用栈列表中的
greeting()
函数。
调用栈列表 空 一开始,一个空空如也的调用栈,每当有函数被调用都会自动地添加进调用栈,执行完函数体中的代码后,调用栈又会自动地移除这个函数。最后,我们又得到了一个空空如也的调用栈。
我们可以发现由于调用栈如上所示的调用方式,代码块运行完后全部都自动出栈了。
如下的方法可以为你计算出你使用的JavaScript引擎可以支持多深的调用:
var computeMaxCallStackSize = (function() { return function() { var size = 0; function cs() { try { size++; return cs(); } catch(e) { return size + 1; } } return cs(); }; }()); computeMaxCallStackSize();
-
-
1.1.3.新生代&老生代
堆内存负责垃圾回收的是新生代和老生代,也是今天的主角。
- 新生代中的semi space from 和semi space to两个空间是大小一致的。
- 如果一个对象是一个基本类型对象的话他就保存在old data space当中。
- 老生代中的空间是连续的,如果一个对象有指针、引用 或者说指向其他的对象的话大多数 存储在old pointer space区。
- 所有老生代的对象都会由新生代晋升而来。
-
1.1.4.新老生代内存大小与操作系统的位数有关
操作系统位数 新生代 老生代 32bit 32MB 700MB 64bit 64MB 1400MB 最新版的内存为2GB
- 问题:为什么在v8中我们要将内存限制在1.4G呢?
先有js才有node,js的定位是为了浏览器渲染,js/node是异步单线程,并且在早期没有考虑到一种读写大文件的需求,webpack编译大型项目代码时,都会有可能内存空间会占用1.4G,而js发明者最早认为前端代码是一种不持久化的代码,内存占用不会太多,因为js是一种异步单线程的语言,垃圾回收机制虽然是自动运行的,但是他需要运行线程,就会出现冲突,到底是先运行代码还是先运行垃圾回收机制呢,运行代码时垃圾回收机制就不会运行,反过来运行垃圾回收机制时代码就不会运行,垃圾越多回收占用线程时间就会越长,根据V8,如果一次回收1.5G堆内存垃圾,需要用到50ms以上,会造成前端页面显示的卡顿等问题,因此就会把内存做的比较轻,目前也只是扩充到了2G的大小。
前端代码一般涉及的都是轻业务,没有必要设计过大的内存空间。不过如果需要随时都可以扩充。
补充:java、js这种语言我们是没有办法人为去控制垃圾回收的时机的,一般都是在垃圾存储到了一定的阈值时开始回收。
(1.2)堆内存的垃圾回收机制
-
1.2.1.如何判断回收内容
如何确定哪些内存需要回收,哪些内存不需要回收,这是垃圾回收期需要解决的最基本问题。我们可以这样假定,一个对象为活对象当且仅当它被一个根对象 或 另一个活对象指向。根对象永远是活对象,它是被浏览器或V8所引用的对象。
根对象:
1.被局部变量所指向的对象也属于根对象,因为它们所在的作用域对象被视为根对象。
2.全局对象(Node中为global,浏览器中为window)是根对象。
3.浏览器中的DOM元素也属于根对象。
-
1.2.2.如何识别指针和数据
垃圾回收器需要面临一个问题,它需要判断哪些是数据,哪些是指针。由于很多垃圾回收算法会将对象在内存中移动(紧凑,减少内存碎片),所以经常需要进行指针的改写:
目前主要有三种方法来识别指针:
-
保守法:将所有堆上格式与指针相类似的字都认为是指针,那么有些数据就会被误认为是指针。于是某些实际是数字的假指针,会背误认为指向活跃对象,导致内存泄露(假指针指向的对象可能是死对象,但依旧有指针指向——这个假指针指向它)。
-
编译器提示法:如果是静态语言,编译器能够告诉我们每个类当中指针的具体位置,而一旦我们知道对象是哪个类实例化得到的,就能知道对象中所有指针。这是JVM实现垃圾回收的方式,但这种方式并不适合JS这样的动态语言
-
标记指针法:这种方法需要在每个字末位预留一位来标记这个字段是指针还是数据。这种方法需要编译器支持,但实现简单,而且性能不错。V8采用的是这种方式。V8将所有数据以32bit字宽来存储,其中最低一位保持为0,而指针的最低两位为01
-
-
1.2.3.V8回收策略
自动垃圾回收算法(gc)的演变过程中出现了很多算法,但是由于不同对象的生存周期不同,没有一种算法适用于所有的情况。所以V8采用了一种分代回收的策略,将内存分为两个生代:新生代和老生代。
新生代的对象为存活时间较短的对象,老生代中的对象为存活时间较长或常驻内存的对象。分别对新生代和老生代使用 不同 的垃圾回收算法来提升垃圾回收的效率。对象起初都会被分配到新生代,当新生代中的对象满足某些条件(后面会有介绍)时,会被移动到老生代(晋升)。
-
1.2.4.新生代算法
新生代中的对象一般存活时间较短,使用 Scavenge GC 算法。在Scavenge的具体实现中,主要是采用一种复制的方式的方法–cheney算法。
在新生代空间中,内存空间分为两部分,分别为 From 空间和 To 空间。在这两个空间中,必定有一个空间是使用的,另一个空间是空闲的。新分配的对象会被放入 From 空间中,当 From 空间被占满时,新生代 GC 就会启动了。算法会检查 From 空间中存活的对象并复制到 To 空间中,如果有失活的对象就会销毁。当复制完成后将 Fro
m 空间和 To 空间互换,这样 GC 就结束了。下图中存活对象为a