引言
JavaScript 可以高效地运行在浏览器和 Nodejs 这两大宿主环境中,是因为背后有强大的 V8 引擎在为其保驾护航,甚至成就了 Chrome 在浏览器中的霸主地位。
正是因为有 V8 引擎的存在才会有前端今天的繁荣生态
感谢V8 🙇
V8 引擎为了追求极致的性能和更好的用户体验,为我们做了太多太多。
V8 引擎努力降低整体的内存占用和提升到更高的运行性能。
我们来看一下 V8 引擎中的垃圾回收机制
引用法
概念
判断一个对象的引用数,引用数为 0 就回收,引用数大于 0 就不回收
1// 在堆中创建一个对象,foo是这个对象的引用2let foo = { name: "foo" };3// 把引用地址改为null, { name: "foo" }对象在堆中没有被栈中引用,那么这块堆中的内存就会被回收4foo = null;
缺陷
如果对象之间有相互引用关系,并且引用关系一直存在,那么就不会被回收,从而造成内存泄漏
1function fn() {2 const obj1 = {};3 const obj2 = {};4 obj1.a = obj2;5 obj2.a = obj1;6}7fn();
标记法
标记法就是,将可达的对象标记起来,不可达的对象当成垃圾回收。
就是从初始的根对象(window 或者 global)的指针开始,向下搜索子节点,子节点被搜索到了,说明该子节点的引用对象可达,并为其进行标记,然后接着递归搜索,直到所有子节点被遍历结束。那么没有被遍历到节点,也就没有被标记,也就会被当成没有被任何地方引用,就可以证明这是一个需要被释放内存的对象,可以被垃圾回收器回收。
JavaScript 内存管理
1、分配给使用者所需的内存
2、使用者拿到这些内存,并使用内存
3、使用者不需要这些内存了,释放并归还给系统
1var name = "john";2var obj = { name: "john" };
name 和 obj 是使用者
基础数据类型:拥有固定的大小,值保存在栈内存里,可以通过值直接访问
引用数据类型:大小不固定(可以加属性),栈内存中存着指针,指向堆内存中的对象空间,通过引用来访问
由于栈内存所存的基础数据类型大小是固定的,所以栈内存的内存都是操作系统自动分配和释放回收的
由于堆内存所存大小不固定,系统无法自动释放回收,所以需要 JS 引擎来手动释放这些内存
分代回收
在 JavaScript 中,对象存活周期分为两种情况
- 存活周期很短:经过一次垃圾回收后,就被释放回收掉
- 存活周期很长:经过多次垃圾回收后,他还存在,赖着不走
V8 将堆分为两个空间,一个叫新生代,一个叫老生代,新生代是存放存活周期短对象的地方,老生代是存放存活周期长对象的地方
新生代通常只有 1-8M 的容量,而老生代的容量就大很多了。对于这两块区域,V8 分别使用了不同的垃圾回收器和不同的回收算法,以便更高效地实施垃圾回收
- 副垃圾回收器 + Scavenge 算法:主要负责新生代的垃圾回收
- 主垃圾回收器 + Mark-Sweep && Mark-Compact 算法:主要负责老生代的垃圾回收
新生代
在 JavaScript 中,任何对象的声明分配到的内存,将会先被放置在新生代中,而因为大部分对象在内存中存活的周期很短,所以需要一个效率非常高的算法。
在新生代中,主要使用 Scavenge(清道夫)算法进行垃圾回收,Scavenge 算法是一个典型的牺牲空间换取时间的复制算法,在占用空间不大的场景上非常适用。
Scavange 算法将新生代堆分为两部分,分别叫 from-space 和 to-space,工作方式也很简单,就是将 from-space 中存活的活动对象复制到 to-space 中,并将这些对象的内存有序的排列起来,然后将 from-space 中的非活动对象的内存进行释放,完成之后,将 from space 和 to space 进行互换,这样可以使得新生代中的这两块区域可以重复利用。
- 1、标记活动对象和非活动对象
- 2、复制 from-space 的活动对象到 to-space 中并进行排序
- 3、清除 from-space 中的非活动对象
- 4、将 from-space 和 to-space 进行角色互换,以便下一次的 Scavenge 算法垃圾回收
通过可达性进行活动对象的判断
在新生代中,还进一步进行了细分。 分为 nursery(托儿所小班) 子代和 intermediate(托儿所中班) 子代两个区域,一个对象第一次分配内存时会被分配到新生代中的 nursery 子代,如果经过下一次垃圾回收这个对象还存在新生代中,这时候我们将此对象移动到 intermediate 子代,在经过下一次垃圾回收,如果这个对象还在新生代中,副垃圾回收器会将该对象移动到老生代中,这个移动的过程被称为晋升
很形象,顽固的老油条我来单独处理你,别在托儿所上了,让小学老师来收拾你 🤣 😎
老生代
新生代空间的对象,身经百战之后,留下来的老对象,成功晋升到了老生代空间里,由于这些对象都是经过多次回收过程但是没有被回收走的,都是一群生命力顽强,存活率高的对象,所以老生代里,回收算法不宜使用 Scavenge 算法,为啥呢,有以下原因:
Scavenge 算法是复制算法,反复复制这些存活率高的对象,没什么意义,效率极低 Scavenge 算法是以空间换时间的算法,老生代是内存很大的空间,如果使用 Scavenge 算法,空间资源非常浪费,得不偿失啊。。
所以老生代里使用了 Mark-Sweep 算法(标记清理)和 Mark-Compact 算法(标记整理)
套路和之前一样 Mark-Sweep 算法(标记清理)
- 标记阶段:对老生代对象进行第一次扫描,对活动对象进行标记
- 清理阶段:对老生代对象进行第二次扫描,清除未标记的对象,即非活动对象
Mark-Compact 算法(标记整理) 把内存空间整理一下,就像文件夹按网格排序一样
全停顿(Stop-The-World)
说完V8的分代回收,咱们来聊聊一个问题。JS代码的运行要用到JS引擎,垃圾回收也要用到JS引擎,那如果这两者同时进行了,发生冲突了咋办呢?答案是,垃圾回收优先于代码执行,会先停止代码的执行,等到垃圾回收完毕,再执行JS代码。这个过程,称为全停顿 由于新生代空间小,并且存活对象少,再配合Scavenge算法,停顿时间较短。但是老生代就不一样了,某些情况活动对象比较多的时候,停顿时间就会较长,使得页面出现了卡顿现象。
Orinoco(奥里诺科)优化
orinoco为V8的垃圾回收器的项目代号,为了提升用户体验,解决全停顿问题,它提出了增量标记、懒性清理、并发、并行的优化方法。
增量标记(Incremental marking)
咱们前面不断强调了先标记,后清除,而增量标记就是在标记这个阶段进行了优化。我举个生动的例子:路上有很多垃圾,害得路人都走不了路,需要清洁工打扫干净才能走。前几天路上的垃圾都比较少,所以路人们都等到清洁工全部清理干净才通过,但是后几天垃圾越来越多,清洁工清理的太久了,路人就等不及了,跟清洁工说:“你打扫一段,我就走一段,这样效率高”。 大家把上面例子里,清洁工清理垃圾的过程——标记过程,路人——JS代码,一一对应就懂了。当垃圾少量时不会做增量标记优化,但是当垃圾达到一定数量时,增量标记就会开启:标记一点,JS代码运行一段,从而提高效率
非常像react fiber架构,任务纤维化
惰性清理(Lazy sweeping)
上面说了,增量标记只是针对标记阶段,而惰性清理就是针对清除阶段了。在增量标记之后,要进行清理非活动对象的时候,垃圾回收器发现了其实就算是不清理,剩余的空间也足以让JS代码跑起来,所以就延迟了清理,让JS代码先执行,或者只清理部分垃圾,而不清理全部。这个优化就叫做惰性清理 整理标记和惰性清理的出现,大大改善了全停顿现象。但是问题也来了:增量标记是标记一点,JS运行一段,那如果你前脚刚标记一个对象为活动对象,后脚JS代码就把此对象设置为非活动对象,或者反过来,前脚没有标记一个对象为活动对象,后脚JS代码就把此对象设置为活动对象。总结起来就是:标记和代码执行的穿插,有可能造成对象引用改变,标记错误现象。这就需要使用写屏障技术来记录这些引用关系的变化
并发(Concurrent)
并发式GC允许在在垃圾回收的同时不需要将主线程挂起,两者可以同时进行,只有在个别时候需要短暂停下来让垃圾回收器做一些特殊的操作。但是这种方式也要面对增量回收的问题,就是在垃圾回收过程中,由于JavaScript代码在执行,堆中的对象的引用关系随时可能会变化,所以也要进行写屏障操作。
并行
并行式GC允许主线程和辅助线程同时执行同样的GC工作,这样可以让辅助线程来分担主线程的GC工作,使得垃圾回收所耗费的时间等于总时间除以参与的线程数量(加上一些同步开销)。
总结
我们在写react时有一句俗语,“遇事不决加一层”, 同样,在性能优化方面,总是绕不开 “遇事不决加一个线程” ,并发、协程的解决问题, 纤维化任务等。
程序世界果然好多套路都是相通的!