上一篇:深入理解Java虚拟机—Java虚拟机内存
下一篇:深入理解Java虚拟机—垃圾收集器
今天学的内容是垃圾收集器,在章节开头是这样描述的:
Java与c++之间有一堵有内存动态分配和垃圾收集技术所围成的高墙,墙外人想进去,墙里面的人却想出来
一. 概述
Java 语言的一大特点就是可以进行自动垃圾回收处理,而无需开发人员过于关注系统资源,例如内存资源的释放情况。自动垃圾收集虽然大大减轻了开发人员的工作量,但是也增加了软件系统的负担。
拥有垃圾收集器可以说是 Java 语言与 C++语言的一项显著区别。在 C++语言中,程序员必须小心谨慎地处理每一项内存分配,且内存使用完后必须手工释放曾经占用的内存空间。当内存释放不够完全时,即存在分配但永不释放的内存块,就会引起内存泄漏,严重时甚至导致程序瘫痪。
其中程序计数器,虚拟机栈,本地方法栈,随着线程生,随着线程灭,每一个帧栈中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会有即时编译器进行一些优化,但在基础概念模型的讨论里,大体可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性这几个区域内就不需要考虑如何回收的问题,当方法结束或者线程结束时,内存就跟着回收了。
Java堆和方法区这两个区域,有很显著的不确定性,
二. 对象已死?
1. 引用计数算法
给每个对象附加一个计数器,每当有一个地方引用此对象。计数器+1;每当有一个地方不再引用此对象,计数器-1;在任意时刻,只要对象引用计数器值为0,任务此对象已经“死亡”。(没有“死亡”的对象一定不会被垃圾回收回收)
这种方法实现简单,效率高,但是它很难解决对象的循环引用问题:
private static final int _1MB = 1024 *1024;
private Object instance = null;
public static void testGC(){
Test objectA = new Test();
Test objectB = new Test();
objectA.instance = objectB;
objectB.instance = objectA;
objectA = null;
objectB = null;
// 假如这里发生GC,objectA和objectB会不会被回收
}
优点:实现简单,判定效率较高,大多数情况下都是一个不错的算法(phthon、C++智能指针都采用引用计数器算法判断无用对象)
缺点:无法处理对象循环引用问题,所以Java不采用引用计数算法。
2. 可达性分析算法
通过一系列称之为 “GC Roots” 的对象作为起点,然后向下搜索,如果当前对象到GC Roots有路可走(GC Roots到当前对象可达),认为此对象还存活;如果当前对象到GC Roots没有引用链,认为此对象不可用。
什么对象可作GC Roots
- 虚拟机栈中(局部变量表)引用的对象
——我们知道,每个方法执行的时候,jvm都会创建一个相应的栈帧(栈帧中包括操作数栈、局部变量表、运行时常量池的引用),栈帧中包含这在方法内部使用的所有对象的引用(当然还有其他的基本类型数据),当方法执行完后,该栈帧会从虚拟机栈中出栈,这样一来,临时创建的对象的引用也就不存在了,或者说没有任何gc roots指向这些临时对象,这些对象在下一次GC时便会被回收掉 - 本地方法栈中引用的对象
- 方法区中类静态变量引用的对象
——也就是使用了static关键字。静态属性是该类型(class)的属性,不单独属于任何实例,因此该属性自然会作为gc roots。只要这个class存在,该引用指向的对象也会一直存在。 - 方法区中常量引用的对象
——就是使用了static final关键字,由于这种引用初始化之后不会修改,所以方法区常量池里的引用的对象也应该作为GC Roots
3. 再谈引用
至此,我们已经了解了平时生成的大部分对象是如何被JVM标记为可回收的。但是在Java中,还存在一些其它的情况,这就要从引用讲起了。我们平时使用的Java对象通常认为只有两种状态,一种是被引用了,在程序中还在使用,另一种是没有被引用,可以被JVM回收。但实际上,Java中的引用一共有四种,它们分别是强引用、软引用、弱引用和虚引用,下面我们来分别介绍。
- 强引用(Strong) :就是我们平时使用的方式 Object a = new Object ();强引用的对象是不会被回收的。
- 软引用(Soft) :在jvm要内存溢出(OOM)时,会回收软引用的对象,释放更多内存。
- 弱引用(Weak) :在下次GC时,弱引用的对象是一定会被回收的
- 虚引用(Phantom) :对对象的存在时间没有任何影响,也无法引用对象实例,唯一的作用就是在该对象被回收时收到一个系统通知
4.生存还是死亡
在根搜索算法中不可达的对象,也并非是“非死不可”的,这时候他们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
- 如果对象在进行可达性分析后发现没有与GC Roots 相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize() 方法。当对象没有覆盖finalize() 方法,或者finalize() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,此时的对象才是真正"死"的对象。
- 如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个叫做F-Queue的队列之中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它(这里所说的执行指的是虚拟机会触发finalize()方法)。finalize()方法是对象逃脱死亡的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记,如果对象在finalize()中成功拯救自己(只需要重新与引用链上的任何一个对象建立起关联关系即可),那在第二次标记时它将会被移除出"即将回收"的集合;如果对象这时候还是没有逃脱,那基本上它就是真的被回收了。
public class Test {
public static Test test;
public void isAlive() {
System.out.println("I am alive :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
Test.test = this;
}
public static void main(String[] args)throws Exception {
//第一次自救
test = new Test();
test = null;
System.gc();
//finalize优先级低,暂停0.5秒在执行
Thread.sleep(500);
if (test != null) {
test.isAlive();
}else {
System.out.println("no,I am dead :(");
}
//第二次自救 自救失败
// 下面代码与上面完全一致,但是此次自救失败
test = null;
System.gc();
Thread.sleep(500);
if (test != null) {
test.isAlive();
}else {
System.out.println("no,I am dead :(");
}
}
}
从上面代码示例我们发现,finalize方法确实被JVM触发,并且对象在被收集前成功逃脱。
但是从结果上我们发现,两个完全一样的代码片段,结果是一次逃脱成功,一次失败。这是因为,任何一个对象的finalize()方法都只会被系统自动调用一次,如果相同的对象在逃脱一次后又面临一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败。
5. 回收方法区
方法区(永久代)的垃圾回收主要收集两部分内容:废弃常量和无用类。
一个类要被回收准确的说应该是卸载,必须同时满足以下三个条件:
- 堆中不存在该类的任何实例
- 加载该类的classloader已经被回收
- 该类的java.lang.Class对象没有在任何地方被引用,也就是说无法通过 - 反射再带访问该类的信息
JVM可以对同时满足上述3个条件的无用类进行回收,也仅仅是“可以”而不是必然。在大量使用反射、动态代理等场景都需要JVM具备类卸载的功能来防止永久代的溢出。
三. 垃圾收集算法
1. 分代收集理论
当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
-
弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的
-
强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域——因而才有了“Minor GC(新生代)”“Major GC(老年代)”“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。
把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代(Young Generation)和老年代(Old Generation)两个区域:新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
对象之间会存在跨代引用,新生代中对象被老年对象所引用,GC Roots中增加老年代,遍历整个老年代对内存回收带来性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则: -
跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC(新生代收集)时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
2. 标记-清除算法
最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
它的主要缺点有两个:
- 执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低
- 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
3. 标记-复制算法
为了解决内存碎片化的问题,我们于是提出了标记-复制算法。我们将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当使用的这块空间用完了,就将存活对象复制到另一块,再把已使用过的内存空间一次清理掉。主要用于进行新生代。虽然增加了复制的开销,但是因为大部分对象都是可回收的,所以还是可以接受的。
缺点:因为只使用一半空间,所以空间利用率不高
不过我们的HotSpot虚拟机通过这种策略设计了新生代的内存布局,将新生代的内存分成了一个Eden区和两个Survivor区来比配这种标记-复制算法。每次分配内存只使用 Eden 和其中一块 Survivor。垃圾收集时将 Eden 和 Survivor 中仍然存活的对象一次性复制到另一块 Survivor 上,然后直接清理掉 Eden 和已用过的那块 Survivor。HotSpot 默认Eden 和 Survivor 的大小比例是 8:1,即每次新生代中可用空间为整个新生代的 90%。
为什么要有Survivor区
先不去想为什么有两个Survivor区,第一个问题是,设置Survivor区的意义在哪里
?
如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代
。老年代很快被填满,触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)。老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。你也许会问,执行时间长有什么坏处?频发的Full GC消耗的时间是非常可观的,这一点会影响大型程序的执行和响应速度,更不要说某些连接会因为超时发生连接错误了。
好,那我们来想想在没有Survivor的情况下,有没有什么解决办法,可以避免上述情况:
方案 | 优点 | 缺点 |
---|---|---|
增加老年代空间 | 更多存活对象才能填满老年代。降低Full GC频率 | 随着老年代空间加大,一旦发生Full GC,执行所需要的时间更长 |
减少老年代空间 | Full GC所需时间减少 | 老年代很快被存活对象填满,Full GC频率增加 |
我们可以得到第一条结论:Survivor的存在意义,就是 减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代
为什么要设置两个Survivor区
设置两个Survivor区最大的好处就是解决了碎片化,下面我们来分析一下。
为什么一个Survivor区不行?
导致内存碎片化
第一部分中,我们知道了必须设置Survivor区。假设现在只有一个survivor区,我们来模拟一下流程:
刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。
我绘制了一幅图来表明这个过程。其中色块代表对象,白色框分别代表Eden区(大)和Survivor区(小)。Eden区理所当然大一些,否则新建对象很快就导致Eden区满,进而触发Minor GC,有悖于初衷。
碎片化带来的风险是极大的,严重影响Java程序的性能。堆空间被散布的对象占据不连续的内存,最直接的结果就是,堆中没有足够大的连续内存空间,接下去如果程序需要给一个内存需求很大的对象分配内存。。。画面太美不敢看。。。这就好比我们爬山的时候,背包里所有东西紧挨着放,最后就可能省出一块完整的空间放相机。如果每件行李之间隔一点空隙乱放,很可能最后就要一路把相机挂在脖子上了。
那么,顺理成章的,应该建立两块Survivor区,刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space S1(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生
)。S0和Eden被清空,然后下一轮S0与S1交换角色,如此循环往复。如果对象的复制次数达到16次,该对象就会被送到老年代中
。下图中每部分的意义和上一张图一样,就不加注释了。
上述机制最大的好处就是,整个过程中,永远有一个survivor space是空的,另一个非空的survivor space无碎片
。
那么,Survivor为什么不分更多块呢?比方说分成三个、四个、五个?显然,如果Survivor区再细分下去,每一块的空间就会比较小,很容易导致Survivor区满,因此,我认为两块Survivor区是经过权衡之后的最佳方案。
4. 标记整理算法
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。如果不想浪费我们不使用的那段空间,就需要有额外空间分配担保,应对被使用内存中所有对象都存活的极端情况,所以老年代一般不使用此算法。
所以针对老年代的特性,我们提出了标记-整理算法。我们首先会让所有存活的对象都向内存空间一段移动,然后然后清理掉边界以外的内存
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。但是如果移动对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行 。但是不移动的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。
所以说,无论是使用标记-清除还是标记-整理对老年代来说都是有弊端的。但是综合起来,从整个程序的吞吐量来看,移动对象会更划算。(HotSpot虚拟机里面关注吞吐量的ParallelScavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的,也是基于这两种弊端设计的。)
不过,我们还有一种混合使用方式,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。
5. 三色标记法
关于垃圾回收算法,基本就是那么几种:标记-清除、标记-复制、标记-整理。在此基础上可以增加分代(新生代/老年代),每代采取不同的回收算法,以提高整体的分配和回收效率。
无论使用哪种算法,标记总是必要的一步。这是理算当然的,你不先找到垃圾,怎么进行回收?
垃圾回收器的工作流程大体如下:
- 标记出哪些对象是存活的,哪些是垃圾(可回收);
- 进行回收(清除/复制/整理),如果有移动过对象(复制/整理),还需要更新引用。
1.1 基本算法
要找出存活对象,根据可达性分析,从GC Roots开始进行遍历访问,可达的则为存活对象:
我们把遍历对象图过程中遇到的对象,按“是否访问过”这个条件标记成以下三种颜色:
- 白色: 尚未访问过。
- 黑色: 本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。
- 灰色: 本对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完。全部访问后,会转换为黑色
假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:
- 初始时,所有对象都在 【白色集合】中;
- 将GC Roots 直接引用到的对象 挪到 【灰色集合】中;
- 从灰色集合中获取对象:
3.1 将本对象 引用到的 其他对象 全部挪到 【灰色集合】中;
3.2. 将本对象 挪到 【黑色集合】里面。 - 重复步骤3,直至【灰色集合】为空时结束。
- 结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收
如果标记结束后对象仍为白色,意味着已经“找不到”该对象在哪了,不可能会再被重新引用
当Stop The World (以下简称 STW)时,对象间的引用 是不会发生变化的,可以轻松完成标记。
而当需要支持并发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生。
1.2 多标-浮动垃圾
假设已经遍历到E(变为灰色了),此时应用执行了 objD.fieldE = null
:
此刻之后,对象E/F/G是“应该”被回收的。然而因为E已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮GC不会回收这部分内存。
这部分本应该回收 但是 没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。
另外,针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。
1.3 漏标-读写屏障
假设GC线程已经遍历到E(变为灰色了),此时应用线程先执行了:
var G = objE.fieldG;
objE.fieldG = null; // 灰色E 断开引用 白色G
objD.fieldG = G; // 黑色D 引用 白色G
此时切回GC线程继续跑,因为E已经没有对G的引用了,所以不会将G放到灰色集合;尽管因为D重新引用了G,但因为D已经是黑色了,不会再重新做遍历处理。
最终导致的结果是:G会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的
不难分析,漏标只有同时满足以下两个条件时才会发生:
条件一:灰色对象 断开了 白色对象的引用;即灰色对象 原来成员变量的引用 发生了变化。
条件二:黑色对象 重新引用了 该白色对象;即黑色对象 成员变量增加了 新的引用。
从代码的角度看:
var G = objE.fieldG; // 1.读
objE.fieldG = null; // 2.写
objD.fieldG = G; // 3.写
- 读取 对象E的成员变量fieldG的引用值,即对象G;
- 对象E 往其成员变量fieldG,写入 null值。
- 对象D 往其成员变量fieldG,写入 对象G ;
我们只要在上面这三步中的任意一步中做一些“手脚”,将对象G记录起来,然后作为灰色对象再进行遍历即可。比如放到一个特定的集合,等初始的GC Roots遍历完(并发标记),该集合的对象 遍历即可(重新标记)
重新标记是需要STW的,因为应用程序一直在跑的话,该集合可能会一直增加新的对象,导致永远都跑不完。当然,并发标记期间也可以将该集合中的大部分先跑了,从而缩短重新标记STW的时间,这个是优化问题了。
写屏障用于拦截第二和第三步;而读屏障则是拦截第一步。
它们的拦截的目的很简单:就是在读写前后,将对象G给记录下来
2.1 写屏障(Store Barrier)
给某个对象的成员变量赋值时,其底层代码大概长这样:
/**
* @param field 某对象的成员变量,如 D.fieldG
* @param new_value 新值,如 null
*/
void oop_field_store(oop* field, oop new_value) {
*field = new_value; // 赋值操作
}
所谓的写屏障,其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念)
void oop_field_store(oop* field, oop new_value) {
pre_write_barrier(field); // 写屏障-写前操作
*field = new_value;
post_write_barrier(field, value); // 写屏障-写后操作
}
2.2 写屏障 + SATB
当对象E的成员变量的引用发生变化时(objE.fieldG = null;),我们可以利用写屏障,将E原来成员变量的引用对象G记录下来
void pre_write_barrier(oop* field) {
oop old_value = *field; // 获取旧值
remark_set.add(old_value); // 记录 原来的引用对象
}
【当原来成员变量的引用发生变化之前,记录下原来的引用对象】
这种做法的思路是:尝试保留开始时的对象图,即原始快照(Snapshot At The Beginning,SATB),当某个时刻 的GC Roots确定后,当时的对象图就已经确定了
比如 当时 D是引用着G的,那后续的标记也应该是按照这个时刻的对象图走(D引用着G)。如果期间发生变化,则可以记录起来,保证标记依然按照原本的视图来。
值得一提的是,扫描所有GC Roots 这个操作(即初始标记)通常是需要STW的,否则有可能永远都扫不完,因为并发期间可能增加新的GC Roots。
SATB破坏了条件一:【灰色对象 断开了 白色对象的引用】,从而保证了不会漏标
一点小优化:如果不是处于垃圾回收的并发标记阶段,或者已经被标记过了,其实是没必要再记录了,所以可以加个简单的判断:
void pre_write_barrier(oop* field) {
// 处于GC并发标记阶段 且 该对象没有被标记(访问)过
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
oop old_value = *field; // 获取旧值
remark_set.add(old_value); // 记录 原来的引用对象
}
}
2.3 写屏障 + 增量更新
当对象D的成员变量的引用发生变化时(objD.fieldG = G;),我们可以利用写屏障,将D新的成员变量引用对象G记录下来:
void post_write_barrier(oop* field, oop new_value) {
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
remark_set.add(new_value); // 记录新引用的对象
}
}
【当有新引用插入进来时,记录下新的引用对象】
这种做法的思路是:不要求保留原始快照,而是针对新增的引用,将其记录下来等待遍历,即增量更新(Incremental Update)
增量更新破坏了条件二:【黑色对象 重新引用了 该白色对象】,从而保证了不会漏标
2.4 读屏障(Load Barrier)
oop oop_field_load(oop* field) {
pre_load_barrier(field); // 读屏障-读取前操作
return *field;
}
读屏障是直接针对第一步:var G = objE.fieldG
;,当读取成员变量时,一律记录下来:
void pre_load_barrier(oop* field, oop old_value) {
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
oop old_value = *field;
remark_set.add(old_value); // 记录读取到的对象
}
}
这种做法是保守的,但也是安全的。因为条件二中【黑色对象 重新引用了 该白色对象】,重新引用的前提是:得获取到该白色对象,此时已经读屏障就发挥作用了
2.4 三色标记法与现代垃圾回收器
现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。
对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:
- CMS:写屏障 + 增量更新
- G1:写屏障 + SATB
- ZGC:读屏障
工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并发执行等。功能之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。
值得注意的是,CMS中使用的增量更新,在重新标记阶段,除了需要遍历 写屏障的记录,还需要重新扫描遍历GC Roots(当然标记过的无需再遍历了),这是由于CMS对于astore_x等指令不添加写屏障的原因,具体可参考这里。
四. HotSpot 的算法细节实现
- 为保证一致性,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的
- 现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行
- 虚拟机直接得到哪些地方存在着对象:使用一组称为OopMap的数据结构,一旦类加载动作完成的时候,HotSpot就会把 对象内 什么偏移量上 是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等GCRoots开始查找
- 安全点(safe point):HotSpot不会为每条指令都生成OopMap,只在“特定的位置”记录这些信息,这些位置被称为安全点(Safepoint)
- 安全点的位置(方法调用、循环跳转、异常跳转)
- 循环的末尾
- 方法临返回前 / 调用方法的call指令后
- 可能抛异常的位置
- 抢先式中断:系统定期中断/恢复用户线程,直到其运行到安全点时被中断。(基本不用
- 主动式中断:由线程主动中断,需要代码配合。在安全点处设置一个标志位,线程执行过程中不断轮询,一旦为真即中断线程
- 安全点的位置(方法调用、循环跳转、异常跳转)
- 安全区域(Safe Region):主动式中断要求线程在运行过程中不断轮询标志位,以达到进入垃圾回收状态的目的。但当线程处于sleep/blocked状态时,系统不可能等待线程苏醒,于是引入安全区域
- 安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点
- 记忆集(Remembered Set)
- 为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集的数据结构,用以避免把整个老年代加进GC Roots扫描范围
- 跨代引用问题:老年代中存在对新生代的引用,由跨代引用假说,不可能在对新生代GC时扫描整个老年代。若此时针对新生代单独进行GC(如Minor GC),则老年代中引用指向的新生代对象有可能被回收,导致老年代中出现悬空指针,造成GC的结果不确定甚至程序崩溃。反之亦然
- 解决办法:将老年代中存在跨代引用的对象加入GC Roots
- 记忆集的作用:记录哪些对象存在跨代引用,即记录哪些对象需要加入GC Roots
- 记忆集的记录精度
- 记忆集用于记录哪些对象存在跨代引用,但不可能将这些对象的指针都记录下来
- 在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节
- 例:
- 字长精度
- 对象精度
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针
- 卡表:目前最常用的一种记忆集实现形式
- 卡表最简单的形式可以只是一个字节数组,而HotSpot虚拟机确实也是这样做的
- 字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数
- 一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0