垃圾的产生
在程序运行过程中,我们需要定义的变量,函数等,这些都是需要占用内存空间的,这些内存是浏览器自动为我们分配的。代码执行过程中,会出现某些创建出来的对象,永远不会再被使用了,那么这个对象就是垃圾,浏览器就需要对该变量进行回收,释放内存空间。
垃圾回收的策略
引用计数
记录每个变量被引用的次数(有多少个变量引用它,那么被引用次数就为多少),当次数变成0时,说明该变量没有引用指向它。也就是说,该变量已经成为一个dead Object死对象了。这时候就需要回收该变量,释放内存空间。
引用计数的优点:当引用计数为0的那一刻,垃圾回收器GC(Garbage Collection)就能立刻将该对象回收。
引用计数的缺点:当出现循环引用时(例如:a的某个属性指向b,b的某个属性又指向a),那么这个时候a和b都不会被回收。除非显示的将某一个指向null。所以目前基本上没有浏览器使用该方法进行垃圾回收了。
let a = {
name: 'a'
}
let b = {
name = 'b'
}
// 让变量a的b属性指向变量b,让变量b的a属性指向变量a,那么a和b也就形成了循环引用。
// 引用计数对于循环引用是没有办法主动将其清除的
a.b = b
b.a = a
// 如果想要回收循环引用的变量,可以手动将某个变量置为null
a = null
标记清除
标记是找到可达对象的过程。GC从一组已知的对象指针开始,称为根集。根集包括堆栈和全局对象。只要从根集触发能被到达的对象,就将该对象标记为可达。GC跟踪根集对象的每个指针,并递归执行此过程,知道找到并标记运行时可到达的每个对象。然后清除掉没有被标记的对象。
标记清除的优点:实现打标记的过程比较简单,而且可以解决循环引用的过程。
标记清除的缺点:因为清除的对象绝大多数情况下不是连续的,也就是说会导致回收的内存是一片一片的,出现内存碎片。
V8的垃圾回收
V8的垃圾回收器叫做Oilpan,Olipan是使用C++进行编写的。
垃圾回收器的基本任务
- 识别活对象和死对象(不会再被用到的对象)
- 回收/重用死对象占用的内存。
- 压缩/碎片整理内存(可选)。
Major GC(Full Mark-Compact)
Major GC在整个堆中收集垃圾。Major GC(在js线程上运行)发生在三个阶段:marking标记,sweeping清除,compacting压缩。
Marking标记阶段
弄清楚可以收集哪些对象是GC的重要组成部分。垃圾回收器(GC)通过使用可达性作为活跃度的代理来做到这一点。这意味着必须保留当前在运行时内可访问的任何对象,并且可以收集任何不可达对象。标记是找到可达对象的过程。GC从一组已知的对象指针开始,称为根集。根集包括堆栈和全局对象。只要从根集对象出发能被到达的对象,就将该对象标记为可达。GC跟踪根集对象中的每个指针,并递归地继续此过程,直到找到并标记运行时可到达的每个对象。
// 从根集对象可以找到a和b这两个对象,所以这两个对象会被标记为可达
let a = {
name: 'a'
}
let b = {
name: 'b'
}
// 将a设置为null,也就是说从根集对象上的找到的a是null,原来a指向的对象{name: 'a'}就会被标记为不可达,等待被回收。
a = null
Sweeping清除阶段
清除是将死对象留下的内存间隙添加到称为空闲列表(free-list) 的数据结构的过程。标记完成后,GC会找到无法访问的对象留下的连续间隙,并将它们添加到适当的空闲列表(free-list)中。free-list由内存块的大小分隔,以便快速查找。当我们在将来需要分配内存时,只需查看free-list并找到适当大小的内存块。
Compacting压缩
Major GC会根据内存碎片的程度(fragmentation heuristic) ,选择性的evacuate(撤离)/compact(压缩) 一些页面(V8将堆内存划分为固定大小的块,称为页面)。可以认为压缩有点像旧PC上的硬盘碎片整理。将幸存的对象复制到当前未被压缩的其他页面中(根据这个没被压缩过的页面的空闲列表) 。这样就可以利用死对象留下的分散的内存。
GC复制幸存对象的一个潜在缺点是,当分配了大量的长寿命对象时,GC会为复制这些对象付出高昂的代价。这也是为什么不会压缩所有页面,只会选择压缩一些高度碎片化的页面。对于不是高度碎片化的页面,只会对其进行清理,不会复制幸存对象。
Generational layout世代布局
V8中的堆被分成不同的区域,称为代(generations)。分为新生代(Young Generation) 和老生代(Old Generation)。新生代又会分为Nursery和Intermediate(中生代)。对象一开始会被分配到nursery中,如果在下一次垃圾回收中,该对象存活下来,那么该对象会被移动到intermediate中。如果在另一次垃圾回收中继续存活下来,那么该对象就会被移动到老生代中。
在垃圾回收中有一个重要的术语世代假设(The Generational Hypothesis)。这说明了大多数对象不会进入老生代。换句话说,从GC的角度来看,大多数对象在分配内存后,几乎立即变得不可到达。这不仅适用于V8或者JavaScript,也适用与大多数动态语言。 GC是一种压缩/移动(compacting/moving)GC。这意味着GC会复制在垃圾回收中幸存下来的对象。这似乎违反了复制对象的开销很大。当根据世代假设,只有很小一部分对象能够在垃圾回收中生存下来。通过仅移动幸存的对象,其他所有分配都成为隐式垃圾(implicit garbage)。
Minor GC(Scavenger)
V8中有两个垃圾回收器。Major GC从整个堆中收集垃圾。Minor GC在新生代中收集垃圾。Major GC可以有效地从整个堆中收集垃圾,但是世代假设告诉我们,新分配的对象很可能需要垃圾回收。
V8为新生代采用了半空间设计。这意味着新生代的一半空间总是空的。这个一开始为空的区域称为To-Space,我们从中复制的区域称为From-Space。在最坏的情况下,GC需要复制每一个对象。
对于清扫(scavenging),V8有一组额外的根,即从旧到新的引用,指的是新生代的对象。V8通过写屏障(write barriers)来维护旧到新的引用列表。当栈stack和global全局结合时,不需要追踪整个老生代,就可以知道新生代中的每一个引用。
疏散步骤(The evacuation step) 将所有幸存的对象转移到一个连续的内存块中(To-space)。这样做的好处是完成了对碎片的清除。然后将From-Space变成To-Space,To-Space变成From-Space。一旦GC完成后,分配的新的地址,会从From-Spce的下一个空闲地址。
仅仅依靠上面的策略,新生代的空间很快就会被耗尽。前面提到了在第二次GC中幸存下来的对象会被疏散到老生代中,而不是To-Space。
清扫(scavenging)的最后一步是更新引用已被移动的原始对象的指针。每个被复制的对象都会留下一个转发地址,用来更新原始指针以指向新的位置。
在清扫中,Minor GC实际上是在做三个步骤标记-疏散-指针更新,都是交错进行的,而不是在不同阶段的。
Orinoco
什么是Orinoco?
Orinoco是GC项目的代号,利用最新和最伟大的并行(parallel),增量(incremental)和并发(concurrent) 技术进行垃圾回收,以释放主线程。
Parallel
并行(Parallel)是指主线程和辅助线程在同一时间做大致相等的工作。这仍然是一种stop-the-world(执行GC时,会阻塞js代码的执行) 的方法,但是现在总的暂停时间被参与的线程数量所除(再加上一些同步的开销)。
Incremental
增量是指主线程间歇性地做少量的工作。在增量暂停中不做整个GC,只做GC所需总工作的一小段。这是比较困难的,因为JavaScript在每个增量工作段之间运行,这意味着堆的状态已经改变,可能会使之前的增量工作无效。这并没有减少主线程所花费的时间(事实上通常会略微增加),它只是将时间分散开来。这仍然是解决GC占用主线程过多时间导致卡顿的好技术。
Concurrent
并发是指主线程不断地执行JavaScript,而辅助线程则完全在后台做GC工作。这是三项技术中最困难的一种。JavaScript堆上的任何东西都可能随时改变,导致之前所做的工作失效。除此之外,现在还有读/写竞争需要担心。 因为辅助线程和主线程同时读取或修改相同的对象。并发的好处就是主线程可完全自由的执行JavaScript,尽管和辅助线程的一些同步会有少量开销。
State Of GC in V8(V8中GC的状态)
Scavenging
目前,V8使用并行清扫(Parallel scavenging),在新生代中把工作分配给辅助线程。每个线程都会收到一些指针,线程跟随这些指针,急切(eagerly)地将任何幸存的对象疏散到To-Space。当试图疏散一个对象时,清扫任务必须通过原子读/写/比较和交换操作进行同步。另一个线程也可能通过不同的路径找到了相同的对象,也试图移动它。无论哪一个线程成功地移动了该对象,都会会去更新指针,留下一个转发指针,以便其他到达该对象的其他线程可以更新它们。
Major GC
V8中的MajorGC是从并发标记开始的。当堆接近一个动态计算的极限时,并发标记任务就开始了。每个线程都被赋予了一些要跟踪的指针,他们在跟踪所有被发现的对象的引用时,会标记他们发现的每个对象。当JavaScript在主线程上执行时,并发标记完全在后台发生。写屏障被用来跟踪JavaScript在线程并发标记时创建的对象之间的新引用
当并发标记完成后,或者达到了动态分配的极限,主线程会执行快速标记的最终步骤。主线程的暂停在这个阶段开始。这代表了主要GC的总暂停时间。主线程再次扫描根部,以确保所有活着的对象都被标记,然后与一些线程一起,开始并行压缩和指针更新。并非所有在旧空间的页面都有资格被压缩--那些没有被压缩的页面将使用前面提到的自由列表进行清扫。主线程在暂停期间启动并发的清扫任务。这些任务与并行压缩任务和主线程本身同时运行--即使JavaScript在主线程上运行,它们也可以继续。