目录
一、为什么我们要去了解垃圾收集和内存分配
二、对象已死?
1. 引用计数算法
2. 可达性分析算法
3. 再谈引用
4. 生存还是死亡
5. 回收方法区
三、垃圾收集算法
1. 简介
2. 分代收集理论
2.1. 弱分代/强分代假说
2.2. 前面两代假说的缺陷
3. 标记-清除算法(Mark-Sweep)
4. 标记-复制算法(Semispace Copying)
5. 标记-整理算法(Mark-Compact)
四、HotSpot的算法细节实现
1. 简介
2. 根节点枚举
3. 安全点
4. 安全区域
5. 记忆集与卡表
6. 写屏障
7. 并发的可达性分析
五、资源变现
一、为什么我们要去了解垃圾收集和内存分配
说起垃圾收集(Garbage Collection,下文简称GC),有不少人把这项技术当作Java语言的伴生产
物。事实上,垃圾收集的历史远远比Java久远,在1960年诞生于麻省理工学院的Lisp是第一门开始使 用内
存动态分配和垃圾收集技术的语言。当Lisp还在胚胎时期时,其作者John McCarthy就思考过垃圾 收集需
要完成的三件事情:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
经过半个世纪的发展,今天的内存动态分配与内存回收技术已经相当成熟,一切看起来都进入 了“自动
化”时代,那为什么我们还要去了解垃圾收集和内存分配?答案很简单:当需要排查各种内存溢出、内存泄漏问
题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施进行必要的监控
和调节。
把时间从大半个世纪以前拨回到现在,舞台也回到我们熟悉的Java语言。如Java内存运行时区域的各个部
分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的内存运行时区域栈
帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。
每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些
优化,但在基于概念模 型的讨论里,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具
备确定性, 在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着
回收了。
而Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,
一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建
哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管
理,本文后续讨论中的“内存”分配与回收也仅仅特指这一部分内 存。
二、对象已死?
在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这
些对象之中哪些还“存活”着,哪些已经“死去”(“死去”即不可能再被任何途径使用的对象)了。
1. 引用计数算法
很多教科书判断对象是否存活的算法是这样的:在对象中添加一个引用计数器,每当有一个地方引用它时,
计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定
效率也很高,在大多数情况下它都是一个不错的算法。
引用计数算法的缺陷(循环引用):对象objA和objB都有字段instance,赋值令objA.instance=objB及
objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但是它们
因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。
/**
* testGC()方法执行后,objA和objB会不会被GC呢?
*
* @author zzm
*/
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
/**
* 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否有回收过
*/
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
// 假设在这行发生GC,objA和objB是否能被回收?
System.gc();
}
}
运行结果:
[Full GC (System) [Tenured: 0K->210K(10240K), 0.0149142 secs] 4603K->210K(19456K), [Perm : 2999K->2999K(21248K)], 0.0150007 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]
Heap
def new generation total 9216K, used 82K [0x00000000055e0000, 0x0000000005fe0000, 0x0000000005fe0000)
Eden space 8192K, 1% used [0x00000000055e0000, 0x00000000055f4850, 0x0000000005de0000)
from space 1024K, 0% used [0x0000000005de0000, 0x0000000005de0000, 0x0000000005ee0000)
to space 1024K, 0% used [0x0000000005ee0000, 0x0000000005ee0000, 0x0000000005fe0000)
tenured generation total 10240K, used 210K [0x0000000005fe0000, 0x00000000069e0000, 0x00000000069e0000)
the space 10240K, 2% used [0x0000000005fe0000, 0x0000000006014a18, 0x0000000006014c00, 0x00000000069e0000)
compacting perm gen total 21248K, used 3016K [0x00000000069e0000, 0x0000000007ea0000, 0x000000000bde0000)
the space 21248K, 14% used [0x00000000069e0000, 0x0000000006cd2398, 0x0000000006cd2400, 0x0000000007ea0000)
No shared spaces configured.
从运行结果中可以清楚看到内存回收日志中包含“4603K->210K”,意味着虚拟机并没有因为这两 个对象互相引
用就放弃回收它们,这也从侧面说明了Java虚拟机并不是通过引用计数算法来判断对象 是否存活的。
2. 可达性分析算法
当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是 通过可达性
分析、(Reachability Analysis)算法来判定对象是否存活的。
这个算法的基本思路就是通过 一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引
用关系向下搜索,搜索过 程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没
有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用
的。
如图3-1所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的, 因此
它们将会被判定为可回收的对象。
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部
变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
- 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
- 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,
- 一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized关键字)持有的对象。
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不 同,还可以
有其他对象“临时性”地加入,共同构成完整GC Roots集合。
譬如后文将会提到的分代收集和局部回收(Partial GC),如果只针对Java堆中某一块区域发起垃圾收集时
(如最典型的只针对新生 代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内
存区域都是不 可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引
用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确 性。
目前最新的几款垃圾收集器[1]无一例外都具备了局部回收的特征,为了避免GC Roots包含过多对 象而过度
膨胀,它们在实现上也做出了各种优化处理。关于这些概念、优化技巧以及各种不同收集器 实现等内容,都将在
本章后续内容中一一介绍
[1]如OpenJDK中的G1、Shenandoah、ZGC以及Azul的PGC、C4这些收集器。
3. 再谈引用
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对
象是否存活都和“引用”离不开关系。
在JDK 1.2版之前,Java里面的引用是很传统的定义:如果reference类型的数据中存储的数值代表的是另外
一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。
在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Re-ference)、软引用
(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4 种,这4种引用强度依次
逐渐减弱,具体查看JavaSE篇
强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new
Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对
象。
软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内 存溢出异常
前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异
常。在JDK 1.2版之后提供了SoftReference类来实现软引用。
弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下
一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只 被弱引用关联的对象。
在JDK 1.2版之后提供了WeakReference类来实现弱引用。
虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的 存在,
完全不会对其生存时间构成影=响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目
的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供 了PhantomReference类来实
现虚引用。
4. 生存还是死亡
即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶
段,要真正宣告一个对象死亡,至少要经历两次标记过程:
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次
筛选,筛选的条件是此对象是否有必要执行finalize()方法。
假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视
为“没有必要执行”。
如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之
中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。
这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。
这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导致F-
Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。
finalize()方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的
标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自
己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;
如果对象这时候还没有逃脱,那基本上它就真的要被回收了。
关于对象死亡时finalize()方法的描述,并不推荐使用这个方法来拯救对象。
相反,建议尽量避免使用它,因为它并不能等同于C和C++语言中的析构函数,而是Java刚诞生时为了使传统
C、C++程序员更容易接受Java所做出的一项妥协。
它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如官方明确声明为不推荐使用的语法。
finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及时,所以,建议我们不要使用
finalize()这个方法!
/**
* 此代码演示了两点:
* <p>
* 1.对象可以在被GC时自我拯救。
* <p>
* 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
*
* @author W哥
*/
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes, i am still alive :)");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws Throwable {
SAVE_HOOK = new FinalizeEscapeGC();
//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
// 下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :(");
}
}
}
运行结果:
finalize method executed!
yes, i am still alive :)
no, i am dead :(
从代码清单的运行结果可以看到,SAVE_HOOK对象的finalize()方法确实被垃圾收集器触发 过,并且在被收
集前成功逃脱了。
另外一个值得注意的地方就是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成 功,一次失败
了。
这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的
finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。
还有一点需要特别说明,上面关于对象死亡时finalize()方法的描述可能带点悲情的艺术加工,笔者并不鼓励
大家使用这个方法来拯救对象。
相反,笔者建议大家尽量避免使用它,因为它并不能等同 于C和C++语言中的析构函数,而是Java刚诞生时
为了使传统C、C++程序员更容易接受Java所做出的一 项妥协。它的运行代价高昂,不确定性大,无法保证各个
对象的调用顺序,如今已被官方明确声明为 不推荐使用的语法。有些教材中描述它适合做“关闭外部资源”之类
的清理性工作,这完全是对finalize()方法用途的一种自我安慰。
finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、 更及时,所以笔者建议大家完全可
以忘掉Java语言里面的这个方法。
5. 回收方法区
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型
回收废弃常量与回收Java堆中的对象非常类似。
举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符
串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其
他地方引用这个字面量。
如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。
常量池中其他类(接口)、方法、字段的符号引用也与此类似。
判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻
了。
需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重
加载等,否则通常是很难达成的。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没
有引用了就必然会回收。
关于是否要对类型进行回收,HotSpot虚拟机提供了Xnoclassgc参数进行控制,还可以使用-verbose:class以
及-XX:+TraceClass-Loading、-XX:+TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class
和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要
FastDebug版的虚拟机支持。
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,
通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
三、垃圾收集算法
1. 简介
垃圾收集算法的实现涉及大量的程序细节,且各个平台的虚拟机操作内存的方法都有差异,所以,我们暂不过多
讨论算法实现!
从如何判定对象消亡的角度出发:垃圾收集算法可以划分为“引用计数式垃圾收集”(Reference Counting
GC)和“追踪式垃圾收集”(Tracing GC)两大类,这两类也常被称作“直接垃圾收集”和“间接垃圾收集”。
2. 分代收集理论
2.1. 弱分代/强分代假说
当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,
分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则
它建立在两个分代假说之上:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:
- 收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)
分配到不同的区域之中存储。
显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一
起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到
大量的空间;
如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,
这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
- 在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域,因而才有
了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;
也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法,
因而发展出了“标记-复制算法”“标记-清除算法”“标记-整理算法”等针对性的垃圾收集算法。
2.2. 前面两代假说的缺陷
现在让我们来看看前面两代假说的缺陷
- 把分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代(Young
Generation)和老年代(Old Generation)两个区域。
- 顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步
晋升到老年代中存放。
- 分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之
间会存在跨代引用。
- 假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代
所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对
象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可行,但
无疑会为内存回收带来很大的性能负担。
为了解决这个问题,就需要对分代收集理论添加第三条经验法则:
- 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
这其实是可根据前两条假说逻辑推理得出的隐含推论:
- 存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。
举个例子:
- 如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以
存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。
- 依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个
对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,
Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。
- 此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GCRoots进行扫描。
- 虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一
些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
注意:刚才我们已经提到了“Minor GC”,后续文中还会出现其他针对不同分代的类似名词,为避免读者产生混
淆,在这里统一定义:部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年
代的行为。
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种
行为。
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
3. 标记-清除算法(Mark-Sweep)
最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法。
算法原理:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过
来,标记存活的对象,统一回收所有未被标记的对象。
标记过程就是对象是否属于垃圾的判定过程。
之所以说它是最基础的收集算法,是因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改
进而得到的。
它的主要缺点有两个:
- 第一个是执行效率不稳定,如果Java堆中包含大量对 象,而且其中大部分是需要被回收的,这时必须进行大
量标记和清除的动作,导致标记和清除两个过 程的执行效率都随对象数量增长而降低;
- 第二个是内存空间的碎片化问题,标记、清除之后会产生大 量不连续的内存碎片,空间碎片太多可能会导致
当以后在程序运行过程中需要分配较大对象时找不到到足够的连续内存而不得不提前触发另一次垃圾收集动
作。
“标记-清除”算法示意图 如图 1.2
4. 标记-复制算法(Semispace Copying)
标记-复制算法简称为复制算法。
为了解决标记-清除算法面对大量可回收对象时执行效率低的问题而存在,它将可用内存按容量划分为大小相
等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再
把已使用过的内存空间一次清理掉。
如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收
的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不
用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
这样实现简单,运行高效,不过其缺陷也显而易见,这种复制回收算法的代价是将可用内存缩小为了原来的
一半,空间浪费未免太多了一 点。
标记-复制算法示意图
如图 1.3
5. 标记-整理算法(Mark-Compact)
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。
更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对
象都100%存 活的极端情况,所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,有针对性的“标记-整 理”(Mark-Compact)算法,其中的标记过程仍然
与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一
端移动,然后直接清理掉边界以外的内存。
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的。
“标记-整理”算法示意图 如图 1.4
四、HotSpot的算法细节实现
1. 简介
以上,从理论原理上介绍了常见的对象存活判定算法和垃圾收集算法,Java虚拟机实现这些算法时,必须对算法
的执行效率有严格的考量,才能保证虚拟机高效运行。这部分内容主要是 为了稍后介绍各款垃圾收集器时做前置
知识铺垫。
2. 根节点枚举
固定可作为GC Roots的节点主要在全局性的引用(如常量或类静态属性)与执行上下文(如栈帧中的本地变
量表)中,尽管目标明确,但查找要做到高效很难。
现在java应用越来越庞大,光方法区的大小就常有数百上千兆,里面的类、常量等更是恒河沙数,逐个检查以
这里为起源的引用肯定得消耗不少时间。
同时迄今为止,所有收集器在根节点枚举这一步时都是必须暂停用户线程的。
根节点枚举必须在一个保障一致性的快照中进行。
一致性的意思是整个枚举期间执行子系统看起来就像被冻结在某一个时间点上,不会出现分析过程中,根节
点集合的对象引用关系还在不断的变化的情况,若这点不能满足,分析结果准确性也就无法保
证。
由于目前主流java虚拟机使用的都是准确式垃圾收集(准确式内存管理:虚拟机可以知道内存中某个位置的
数据具体是什么类型),所以当用户线程停顿时,不需要一个不漏的检查完所有执行上下文和全局的引用位置,
虚拟机应当是有办法直接得到哪些地方存放着对象的引用。
在HotSpot的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。
类加载完成时,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会
在特定的位置记录下栈里和寄存器里哪些位置时引用。这样收集器扫描时就可以直接知道这些信息,并不需要真
正一个不漏从方法区等GC Roots考试查找。
类加载完成时,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会
在特定的位置记录下栈里和寄存器里哪些位置时引用。这样收集器扫描时就可以直接知道这些信息,并不需要真
正一个不漏从方法区等GC Roots考试查找。
3. 安全点
HotSpot没有为每一条指令都生成OopMap,上面提到的“类加载完成时,HotSpot就会把对象内什么偏移量
上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置时引
用”中提到的特定的位置记录了这些信息,这些位置被称为安全点(Safepoint)。
因此,用户程序执行时并非在任意位置都能停下来进行垃圾收集,强制要求必须执行到安全点后才能暂停。
所以,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太频繁以至于过分增大运行时的内存负
荷。
安全点位置的选取标准:是否具有让程序长时间执行的特征。因为每条指令执行的时间都非常短暂,程
序不太可能因为指令流长度太长这样的原因而长时间执行,长时间执行的最明显特征就是指令序列的复用,例如
方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。
如何让所有线程都跑到最近的安全点停顿下来:
- 抢先式中断:
抢先式中断不需要线程的执行代码主动去配合。
在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就
恢复这条线程执行,让它一会儿再重新中断,直到跑到安全点上。
现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。
- 主动式中断:
设置一个标志位,线程执行时会不停的主动轮询这个标志,一旦发现中断标志位为真自己在最近的安全点上
主动中断挂起。
轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这
是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。
由于轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot 使用内存保护陷阱的方式,把轮询操
作精简至只有一条汇编指令的程度。
4. 安全区域
程序“不执行”时(没有分配处理器时间,如sleep和blocked状态),线程无法响应虚拟机的中断请求,不
能走到安全点主动挂起,虚拟机也不可能等线程重新被激活。所以引入安全区域。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生改变。因此,在这个区域中任意地方开始
垃圾收集都是安全的。可以看作扩展延伸了的安全点。
进入安全区域的代码,会标识自己已经进入安全区域,虚拟机发起垃圾收集时不用去管这些线程。当线
程要离开安全区域时,他要检查虚拟机是否已经完成了根节点枚举(或垃圾收集过程中其他需要暂停用户线程的
阶段),完成,那线程就当作没事发生,继续执行。否则一直等待直到收到可以离开安全区域的信号。
5. 记忆集与卡表
在分代收集中,为了解决对象跨代引用所带来的问题,在新生代中建立记忆集的数据结构,用以避免把整个
老年代加进GC Roots扫描范围。事实上所有部分区域收集行为的垃圾收集器都会有跨代引用问题。
记忆集是一种记录从非收集区域指向收集区域的指针集合的抽象数据结构。不考虑效率和成本,最简单的实
现用非收集区域中所有含跨代引用的对象数组来实现这个数据结构。
在垃圾收集场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向收集区域的指针就
行,不需要了解跨代指针的全部细节。
设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了
一些可供选择(当然也可以选择这个范围以外的)的记录精度:
- 字节精度:每个记录精确到机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。(用一种称为“卡表”的方式去实现记忆集,是常用记忆集实现方式之一。)
记忆集其实是一种“抽象 ” 的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为
的具体实现。
卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。
卡表最简单的形式可以只是一个字节数组,下面这行代码是HotSpot默认的卡表标记逻辑:
CARD_TABLE[this address >> 9] = 0;
字节数组CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称
作“ 卡页 ” ( Card Page )。
一般来说,卡页大小都是以 2 的 N 次幂的字节数,通过上面代码可以看出HotSpot 中使用的卡页是 2 的 9
次幂,即 512 字节(地址右移 9 位,相当于用地址除以 512 )。那如果卡表标识内存区域的起始地址是0x0000
的话,数组 CARD_TABLE 的第 0 、 1 、 2 号元素,分别对应了地址范围为0x0000 ~ 0x01FF 、 0x0200 ~
0x03FF 、 0x0400 ~ 0x05FF 的卡页内存块,如图1.5。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就
将对应卡表的数组元素的值标识为1,称为这个元素变脏( Dirty ),没有则标识为 0 。在垃圾收集发生时,只要
筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots 中一并扫描。
6. 写屏障
何时变脏:有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上
应该发生在引用类型字段赋值的那一刻。
如何变脏:即如何更新维护卡表:
HotSpot通过写屏障(Writer Barrier)技术维护卡表。
写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个
环形(Around)通知,供程序执行额外的动作,也就是说赋值前后都在写屏障的覆盖范围内。在赋值前的部分的
写屏障叫做写前屏障(Pre-Write Barrier),赋值后则叫写后屏障(Post-Write Barrier)。
G1之前只用到写后屏障。
写后屏障更新卡表,如下图。
void opp_field_store(opp* field, opp new_value){
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}
除了写屏障开销外(相较于扫描整个老年代的代价低),卡表在高并发下面临着“伪共享”问题。中央处理
器的缓存系统是以缓存行为单位存储,多线程修改独立变量,这些变量恰好共享同一个缓存行,就会彼此影响
(写回,无效化或同步)而导致性能降低。
解决伪共享办法:不采用无条件的写屏障,先检查卡表标记,只有卡表元素未被标记过时才将其标记变脏,
即卡表更新逻辑变为:
if(CARD_TABLE[this address >> 9] != 0)
CARD_TABLE[this address >> 9] = 0;
在JDK 7 之后, HotSpot 虚拟机增加了一个新的参数 -XX : +UseCondCardMark ,用来决定是否开启卡
表更新的条件判断。
开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运
行情况来进行测试权衡。
7. 并发的可达性分析
在根节点枚举这个步骤中,GC ROOTS相比起整个java堆中全部的对象已经减少了很多,且在各种优化技巧
(如OopMap)的加持下,它带来的停顿已经非常短暂且相对固定。可从GC Roots继续往下遍历对象图,这一步
骤的停顿时间必定与java堆容量成正比关系:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产
生的停顿时间的更长。
要知道包含“ 标记 ” 阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段会随着堆变大而等比例
增加停顿时间,其影响就会波及几乎所有的垃圾收集器,同理可知,如果能够削减这部分停顿时间的话,那收益
也将会是系统性的。
首先了解一下为什么在一个能保障一致性的快照下才能进行对象图的遍历?我们使用三色标记辅助推导:
- 白色:对象尚未被垃圾收集器访问到。在可达性分析刚刚开始阶段,所有阶段对象都是白色,分析结束阶
段,仍为白色,即代表不可达。
- 黑色:对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色对象代表已经扫描过,它
是安全存活的,如有其他对象引用指向黑色对象,无须重新扫描。黑色对象不可能直接(不经过灰色对象)
指向某个白色对象。
- 灰色:对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
可达性分析的扫描过程,可以看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程。
用户线程冻结不会有任何问题。
但用户线程并发,收集器在标记时,用户线程在修改引用,会导致两种结果:一种是把原本消亡的对象错误标记
为存活,即产生浮动垃圾,下次收集即可,可以容忍。另一种是把原本存活的对象标记为已消亡,这就很致命
了,程序肯定会因此发生错误,下面演示这样的致命错误是怎样产生的,如图 1.6。
“对象消失”问题:原本应该是黑色的对象被误标为白色。
“对象消失”问题产生的条件(需要同时满足):
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
解决“对象消失”问题:增量更新 和 原始快照。
- 增量更新:破坏第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录
下来,等并发扫描结束后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。简化理解为:黑
色对象一旦新插入了指向白色对象的引用之后,他就变回灰色对象了。
- 原始快照:破坏第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下
来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。简化理解为:无
论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。
在 HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS 是基于增量更新来做并
发标记的,G1、Shenandoah 则是用原始快照来实现。
五、资源变现
MD文档笔记、PDF文档笔记、网盘资料更新等