2024-04-24 V8浏览器垃圾回收

是用于存储动态分配的内存的区域。当您使用 new 关键字创建对象时,JavaScript 引擎会在堆上分配内存并存储该对象。堆上的内存是 非连续的,这意味着它可能位于内存的不同区域。

堆的特点:

  • 用于存储动态分配的内存
  • 非连续内存分配
  • 由垃圾收集器自动管理内存释放

是用于存储函数调用信息和局部变量的区域。当您调用一个函数时,JavaScript 引擎会在栈上创建一个新的 调用帧。调用帧包含函数的参数、局部变量和指向上一调用帧的指针。栈上的内存是 连续的,这意味着它位于内存的同一区域。

栈的特点:

  • 用于存储函数调用信息和局部变量
  • 连续内存分配
  • 由 JavaScript 引擎自动管理内存分配和释放

堆和栈的区别

特性
用途 存储动态分配的内存 存储函数调用信息和局部变量
内存分配 非连续 连续
内存管理 由垃圾收集器自动管理 由 JavaScript 引擎自动管理

JavaScript数据类型分为基础数据类型和引用数据类型:

  • 基础数据类型:拥有固定的大小,值保存在栈内存里,可以通过值直接访问
  • 引用数据类型:大小不固定(可以加属性),栈内存中存着指针,指向堆内存中的对象空间,通过引用来访问
  • 由于栈内存所存的基础数据类型大小是固定的,所以栈内存的内存都是操作系统自动分配和释放回收的
  • 由于堆内存所存大小不固定,系统无法自动释放回收,所以需要JS引擎来手动释放这些内存

为啥要垃圾回收
在Chrome中,V8被限制了内存的使用(64位约1.4G/1464MB , 32位约0.7G/732MB),为什么要限制呢?

  • 表层原因:V8最初为浏览器而设计,不太可能遇到用大量内存的场景
  • 深层原因:V8的垃圾回收机制的限制(如果清理大量的内存垃圾是很耗时间,这样会引起JavaScript线程暂停执行的时间,那么性能和应用直线下降)

前面说到栈内的内存,操作系统会自动进行内存分配和内存释放,而堆中的内存,由JS引擎(如Chrome的V8)手动进行释放,当我们的代码没有按照正确的写法时,会使得JS引擎的垃圾回收机制无法正确的对内存进行释放(内存泄露),从而使得浏览器占用的内存不断增加,进而导致JavaScript和应用、操作系统性能下降。

V8的垃圾回收算法
1. 分代回收
在JavaScript中,对象存活周期分为两种情况

  • 存活周期很短:经过一次垃圾回收后,就被释放回收掉
  • 存活周期很长:经过多次垃圾回收后,他还存在,赖着不走

那么问题来了,对于存活周期短的,回收掉就算了,但对于存活周期长的,多次回收都回收不掉,明知回收不掉,却还不断地去做回收无用功,那岂不是很消耗性能?

对于这个问题,V8做了分代回收的优化方法,通俗点说就是:V8将堆分为两个空间,一个叫新生代,一个叫老生代,新生代是存放存活周期短对象的地方,老生代是存放存活周期长对象的地方。

新生代通常只有1-8M的容量,而老生代的容量就大很多了。对于这两块区域,V8分别使用了不同的垃圾回收器和不同的回收算法,以便更高效地实施垃圾回收。

  • 副垃圾回收器 + Scavenge算法:主要负责新生代的垃圾回收
  • 主垃圾回收器 + Mark-Sweep && Mark-Compact算法:主要负责老生代的垃圾回收

1.1 新生代
在JavaScript中,任何对象的声明分配到的内存,将会先被放置在新生代中,而因为大部分对象在内存中存活的周期很短,所以需要一个效率非常高的算法。在新生代中,主要使用Scavenge算法进行垃圾回收,Scavenge算法是一个典型的牺牲空间换取时间的复制算法,在占用空间不大的场景上非常适用。

Scavange算法将新生代堆分为两部分,分别叫from-space和to-space,工作方式也很简单,就是将from-space中存活的活动对象复制到to-space中,并将这些对象的内存有序的排列起来,然后将from-space中的非活动对象的内存进行释放,完成之后,将from space 和to space进行互换,这样可以使得新生代中的这两块区域可以重复利用。


image.png

具体步骤为以下4步:

  • 标记活动对象和非活动对象
  • 复制from-space的活动对象到to-space中并进行排序
  • 清除from-space中的非活动对象
  • 将from-space和to-space进行角色互换,以便下一次的Scavenge算法垃圾回收
    那么,垃圾回收器是怎么知道哪些对象是活动对象,哪些是非活动对象呢?

这就要不得不提一个东西了——可达性。什么是可达性呢?就是从初始的根对象(window或者global)的指针开始,向下搜索子节点,子节点被搜索到了,说明该子节点的引用对象可达,并为其进行标记,然后接着递归搜索,直到所有子节点被遍历结束。那么没有被遍历到节点,也就没有被标记,也就会被当成没有被任何地方引用,就可以证明这是一个需要被释放内存的对象,可以被垃圾回收器回收。

新生代中的对象什么时候变成老生代的对象?

在新生代中,还进一步进行了细分。分为nursery子代和intermediate子代两个区域,一个对象第一次分配内存时会被分配到新生代中的nursery子代,如果经过下一次垃圾回收这个对象还存在新生代中,这时候我们将此对象移动到intermediate子代,在经过下一次垃圾回收,如果这个对象还在新生代中,副垃圾回收器会将该对象移动到老生代中,这个移动的过程被称为晋升。

1.2 老生代
新生代空间的对象,身经百战之后,留下来的老对象,成功晋升到了老生代空间里,由于这些对象都是经过多次回收过程但是没有被回收走的,都是一群生命力顽强,存活率高的对象,所以老生代里,回收算法不宜使用Scavenge算法,为啥呢,有以下原因:

  • Scavenge算法是复制算法,反复复制这些存活率高的对象,没什么意义,效率极低。
  • Scavenge算法是以空间换时间的算法,老生代是内存很大的空间,如果使用Scavenge算法,空间资源非常浪费,得不偿失啊。

所以老生代里使用了Mark-Sweep算法(标记清理)和Mark-Compact算法(标记整理)。

Mark-Sweep(标记清理)

Mark-Sweep分为两个阶段,标记和清理阶段,之前的Scavenge算法也有标记和清理,但是Mark-Sweep算法跟Scavenge算法的区别是,后者需要复制后再清理,前者不需要,Mark-Sweep直接标记活动对象和非活动对象之后,就直接执行清理了。

  • 标记阶段:对老生代对象进行第一次扫描,对活动对象进行标记
  • 清理阶段:对老生代对象进行第二次扫描,清除未标记的对象,即非活动对象


    image.png

Mark-Compact(标记整理)
Mark-Sweep算法执行垃圾回收之后,留下了很多零零散散的空位,这有什么坏处呢?如果此时进来了一个大对象,需要对此对象分配一个大内存,先从零零散散的空位中找位置,找了一圈,发现没有适合自己大小的空位,只好拼在了最后,这个寻找空位的过程是耗性能的,这也是Mark-Sweep算法的一个缺点。

这个时候Mark-Compact算法出现了,他是Mark-Sweep算法的加强版,在Mark-Sweep算法的基础上,加上了整理阶段,每次清理完非活动对象,就会把剩下的活动对象,整理到内存的一侧,整理完成后,直接回收掉边界上的内存。


image.png

全停顿(Stop-The-World)
说完V8的分代回收,咱们来聊聊一个问题。JS代码的运行要用到JS引擎,垃圾回收也要用到JS引擎,那如果这两者同时进行了,发生冲突了咋办呢?答案是,垃圾回收优先于代码执行,会先停止代码的执行,等到垃圾回收完毕,再执行JS代码。这个过程,称为全停顿。

由于新生代空间小,并且存活对象少,再配合Scavenge算法,停顿时间较短。但是老生代就不一样了,某些情况活动对象比较多的时候,停顿时间就会较长,使得页面出现了卡顿现象。

3. Orinoco优化
orinoco为V8的垃圾回收器的项目代号,为了提升用户体验,解决全停顿问题,它提出了增量标记、懒性清理、并发、并行的优化方法。

3.1 增量标记(Incremental marking)
咱们前面不断强调了先标记,后清除,而增量标记就是在标记这个阶段进行了优化。我举个生动的例子:路上有很多垃圾,害得路人都走不了路,需要清洁工打扫干净才能走。前几天路上的垃圾都比较少,所以路人们都等到清洁工全部清理干净才通过,但是后几天垃圾越来越多,清洁工清理的太久了,路人就等不及了,跟清洁工说:“你打扫一段,我就走一段,这样效率高”。

大家把上面例子里,清洁工清理垃圾的过程——标记过程,路人——JS代码,一一对应就懂了。当垃圾少量时不会做增量标记优化,但是当垃圾达到一定数量时,增量标记就会开启:标记一点,JS代码运行一段,从而提高效率。


image.png

3.2 惰性清理(Lazy sweeping)
上面说了,增量标记只是针对标记阶段,而惰性清理就是针对清除阶段了。在增量标记之后,要进行清理非活动对象的时候,垃圾回收器发现了其实就算是不清理,剩余的空间也足以让JS代码跑起来,所以就延迟了清理,让JS代码先执行,或者只清理部分垃圾,而不清理全部。这个优化就叫做惰性清理。

整理标记和惰性清理的出现,大大改善了全停顿现象。但是问题也来了:增量标记是标记一点,JS运行一段,那如果你前脚刚标记一个对象为活动对象,后脚JS代码就把此对象设置为非活动对象,或者反过来,前脚没有标记一个对象为活动对象,后脚JS代码就把此对象设置为活动对象。总结起来就是:标记和代码执行的穿插,有可能造成对象引用改变,标记错误现象。这就需要使用写屏障技术来记录这些引用关系的变化。

3.3 并发(Concurrent)
并发式GC允许在在垃圾回收的同时不需要将主线程挂起,两者可以同时进行,只有在个别时候需要短暂停下来让垃圾回收器做一些特殊的操作。但是这种方式也要面对增量回收的问题,就是在垃圾回收过程中,由于JavaScript代码在执行,堆中的对象的引用关系随时可能会变化,所以也要进行写屏障操作。

image.png

3.4 并行
并行式GC允许主线程和辅助线程同时执行同样的GC工作,这样可以让辅助线程来分担主线程的GC工作,使得垃圾回收所耗费的时间等于总时间除以参与的线程数量(加上一些同步开销)。

image.png

V8当前的垃圾回收机制
2011年,V8应用了增量标记机制。直至2018年,Chrome64和Node.js V10启动并发标记(Concurrent),同时在并发的基础上添加并行(Parallel)技术,使得垃圾回收时间大幅度缩短。

副垃圾回收器
V8在新生代垃圾回收中,使用并行(parallel)机制,在整理排序阶段,也就是将活动对象从from-to复制到space-to的时候,启用多个辅助线程,并行的进行整理。由于多个线程竞争一个新生代的堆的内存资源,可能出现有某个活动对象被多个线程进行复制操作的问题,为了解决这个问题,V8在第一个线程对活动对象进行复制并且复制完成后,都必须去维护复制这个活动对象后的指针转发地址,以便于其他协助线程可以找到该活动对象后可以判断该活动对象是否已被复制。(如上图)

主垃圾回收器
V8在老生代垃圾回收中,如果堆中的内存大小超过某个阈值之后,会启用并发(Concurrent)标记任务。每个辅助线程都会去追踪每个标记到的对象的指针以及对这个对象的引用,而在JavaScript代码执行时候,并发标记也在后台的辅助进程中进行,当堆中的某个对象指针被JavaScript代码修改的时候,写入屏障(write barriers)技术会在辅助线程在进行并发标记的时候进行追踪。

当并发标记完成或者动态分配的内存到达极限的时候,主线程会执行最终的快速标记步骤,这个时候主线程会挂起,主线程会再一次的扫描根集以确保所有的对象都完成了标记,由于辅助线程已经标记过活动对象,主线程的本次扫描只是进行check操作,确认完成之后,某些辅助线程会进行清理内存操作,某些辅助进程会进行内存整理操作,由于都是并发的,并不会影响主线程JavaScript代码的执行。


image.png

题1: V8 浏览器中如何判断一个对象是否可以被回收?
V8 浏览器使用 可达性分析 来判断一个对象是否可以被回收。从根节点(例如全局变量或 DOM 节点)开始遍历,如果一个对象可以被根节点或其他可达对象引用,则该对象是可达的,不会被回收;如果一个对象没有任何引用,则该对象是不可达的,会被回收。

题2:什么是 V8 浏览器内存泄漏?常见的 V8 浏览器内存泄漏场景有哪些?
应用程序分配了内存,但不再使用该内存,导致内存无法被回收,最终导致浏览器占用过多的内存空间。内存泄漏会降低浏览器性能,甚至导致浏览器崩溃。

  • 未释放 DOM 节点: 如果 JavaScript 代码创建了 DOM 节点,但没有正确释放这些节点,则会导致内存泄漏。(正确释放 DOM 节点: 在 DOM 节点不再需要时,使用 removeChild() 方法将其从 DOM 树中移除)
  • 未释放事件监听器: 如果 JavaScript 代码注册了事件监听器,但没有正确注销这些监听器,则会导致内存泄漏。(在不再需要事件监听器时,使用 removeEventListener() 方法将其注销)
  • 未释放闭包: 如果 JavaScript 代码创建了闭包,但这些闭包不再需要,则会导致内存泄漏。(尽量避免使用闭包,如果必须使用闭包,请确保闭包中的变量不会被意外保留)
  • 循环引用: 如果两个或多个对象相互引用,但没有任何外部引用,则会导致内存泄漏(如果发现存在循环引用,可以使用弱引用或代理来打破循环)

排查:Chrome DevTools 的内存分析器可以查看堆内存的快照,并分析每个对象的占用空间和引用关系。在“分配器”视图中,找到泄漏的 DOM 节点。点击泄漏的 DOM 节点,查看它的引用关系。根据引用关系,找到导致内存泄漏的代码,并进行修复。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,406评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,732评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,711评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,380评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,432评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,301评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,145评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,008评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,443评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,649评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,795评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,501评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,119评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,731评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,865评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,899评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,724评论 2 354