“深入理解JVM:垃圾收集器与内存分配策略”的版本间差异
第348行: | 第348行: | ||
== 经典垃圾收集器 == | == 经典垃圾收集器 == | ||
=== Serial 收集器 === | |||
---- | |||
=== ParNew 收集器 === | |||
---- | |||
=== Parallel Scavenge 收集器 === | |||
---- | |||
=== Serial Old 收集器 === | |||
---- | |||
=== Parallel Old 收集器 === | |||
---- | |||
=== CMS 收集器 === | |||
---- | |||
=== Garbage First 收集器 === | |||
---- | |||
== 低延迟垃圾收集器 == | == 低延迟垃圾收集器 == |
2020年10月24日 (六) 20:50的版本
概述
垃圾收集(Garbage Collection,GC),并非Java语言的伴生产物。
垃圾收集需要完成的三件事情:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
对象已死?
引用计数算法(Reference Counting)
引用计数算法:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
- 优点:原理简单,判定效率高;
- 缺点:不能解决对象之间相互循环引用的问题;
/** * 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(); } }
可达性分析算法(Reachability Analysis)
- 当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。
可达性分析算法:通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
Java中可作为“GC Roots”的对象:【全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中】
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象;
- 譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象;
- 譬如Java类的引用类型静态变量。
- 在方法区中常量引用的对象;
- 譬如字符串常量池(String Table)里的引用。
- 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
- Java虚拟机内部的引用;【?】
- 如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized关键字)持有的对象。【???】
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。【?】
再谈引用(强、软、弱、虚)
判定对象是否存活都和“引用”离不开关系。JDK 1.2版之后,Java将引用分为强、软、弱、虚四种:
- 强引用(Strongly Reference):是指在程序代码之中普遍存在的引用赋值,即类似“Objectobj = new Object()”这种引用关系。
- 只要强引用关系还存在,就永远不会回收掉被引用的对象。
- 软引用(Soft Reference):JDK 1.2版之后提供了“SoftReference”类来实现软引用。
- 在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
- 弱引用(Weak Reference):在JDK 1.2版之后提供了“WeakReference”类来实现弱引用。
- 被弱引用关联的对象只能生存到下一次垃圾收集发生为止。
- 虚引用(Phantom Reference):也称为“幽灵引用”或者“幻影引用”,在JDK 1.2版之后提供了“PhantomReference”类来实现虚引用。
- 虚引用的对于对象的生存时间没有影响,也无法通过虚引用来取得一个对象实例。(唯一作用是在对象被收集器回收时收到一个系统通知)
生存还是死亡?(两次标记)
真正宣告一个对象死亡,至少要经历两次标记过程:
- 标记:对象在进行可达性分析后发现没有与GC Roots相连接的引用链,将会被第一次标记;
- 筛选(对标记的对象):筛选的条件是此对象是否有必要执行“finalize()”方法;
- “没有必要执行”:【对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过】直接回收;
- “有必要执行”:将该对象放如“F-Queue”队列中,稍后由“Finalizer”(虚拟机自动建立的低优先级)线程去调用对象的“finalize()”方法;
- 并不承诺一定会等待对象的“finalize()”方法运行结束(防止等待造成回收系统崩溃);
- 标记(对F-Queue中的对象):被标记的对象会被回收;
- finalize()方法中:如果对象重新与引用链上的对象建立了关联【如把自己(this关键字)赋值给某个类变量或者对象的成员变量】,那么在第二次标记时它将被移出“即将回收”的集合;
- finalize()方法的不确定性大:不确定是否一定被调用,不确定何时被调用;所以应尽量避免使用;
一次对象自我拯救的演示:
/**
* 此代码演示了两点:
* 1.对象可以在被GC时自我拯救。
* 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
* @author zzm
*/
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 :(");
}
// 下面这段代码与上面的完全相同,但是这次自救却失败了
// (finalize已被执行过,筛选后直接被回收)
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 :(
回收方法区
方法区的垃圾收集主要回收两部分内容:
- 废弃常量:没有被对象或对象属性引用的常量(与回收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虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
【???】
垃圾收集算法
标记-清除算法(Mark-Sweep)
“半区复制”(Semispace Copying):算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
问题:
- 执行效率不稳定:随对象数量增长而降低;
- 内存空间碎片化:标记、清除之后会产生大量不连续的内存碎片,导致分配较大对象时无法找到足够的连续内存,而不得不提前触发另一次垃圾收集动作。
标记-复制算法(Semispace Copying)
算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,另一块用于。
问题:
- 可用内存缩小为了原来的一半;
“Appel式回收”
Appel式回收:
- 把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。
- 发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。
- (“Appel式回收”:现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代);
- 如:HotSpot虚拟机的Serial、ParNew等新生代收集器。
- HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1;
- 当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(老年代)进行“分配担保”(Handle Promotion);
标记-整理算法(Mark-Compact)
算法:
- 标记:过程仍然与“标记-清除”算法一样,
- 整理:将所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
问题:
- 移动操作必须全程暂停用户应用程序才能进行(“Stop The World”);
分代收集理论(Generational Collection)
- (当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计);
- (除了分代,已有分区收集的理论:即将内存划分为多个区,分别进行内存管理);
分代收集理论建立在两个分代假说之上:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的;
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡;
- 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
由此引出了垃圾收集器的一致的设计原则:“收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储”。
对不同的区域“”
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集;
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。
- 目前只有“CMS”收集器会有单独收集老年代的行为。
- 另外请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指,读者需按上下文区分到底是指老年代的收集还是整堆收集。
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。
- 目前只有“G1”收集器会有这种行为。
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。“Full GC”:;
- 通常能单独发生收集行为的只是新生代,实际上除了CMS收集器,其他都不存在只针对老年代的收集。
关于“跨代引用”:
依据这条假说(跨代引用假说),我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。 此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GCRoots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。
即,新生代维护了“记忆集”(Remembered Set),来标识老年代中存在跨代引用的内存块,用于Minor GC时被加入扫描整理范围。
HotSpot的算法细节实现
根节点枚举
现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发(具体见3.4.6节),但根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上(“Stop The World”),不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足的话,分析结果准确性也就无法保证。 这是导致垃圾收集过程必须停顿所有用户线程的其中一个重要原因,即使是号称停顿时间可控,或者(几乎)不会发生停顿的CMS、G1、ZGC等收集器,枚举根节点时也是必须要停顿的。
目前主流Java虚拟机使用的都是“准确式GC”,所以当用户线程停顿下来之后,其实并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。
- “枚举根节点”:(在“查找GC Root链”之前)找出内存中所有的引用;
- “准确式GC”:对“准确式内存管理”(虚拟机明确知道内存中的某个位置数据的具体类型)进行垃圾回收;
- 在HotSpot中,使用一组称为“OopMap”的数据结构来达到这个目的;
即,“HotSpot使用在 OopMap 来快速准确地完成GC Roots枚举”
安全点(Safepoint)
用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达“安全点”后才能够暂停。
- 安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。
如何产生安全点
只有具有“指令功能复用”的指令才能产生安全点:
- 安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如“方法调用”、“循环跳转”、“异常跳转”等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。
如何运行到安全点
如何在垃圾收集发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:
- 抢先式中断(Preemptive Suspension):不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。
- (现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件)
- 主动式中断(Voluntary Suspension):当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最
近的安全点上主动中断挂起。
- 轮询标志的地方包括:安全点,及“所有创建对象和其他需要在Java堆上分配内存”的地方(为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象)。
安全区域(Safe Region)
- 安全点用于线程执行过程中的GC时刻;
- 安全区域:指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。
- 用于线程不执行(没有分配CPU时间,如:Sleep、Blocked状态)的时候,此时线程无法响应 JVM 的中断请求
记忆集(Remembered Set)与卡表
记忆集(Remembered Set):垃圾收集器在新生代中建立了名为“记忆集”(Remembered Set)的数据结构,用以避免(存在跨代引用时)把整个老年代加进GC Roots扫描范围。
- 所有涉及部分区域收集(Partial GC)行为的垃圾收集器(典型的如G1、ZGC和Shenandoah收集器),都会面临“跨代引用”问题;
- 新生代、老年代之间的跨代引用:老年代中对象或对象属性,引用了新生代对象;
记忆集的实现:卡表(Card Table)
记忆集其实是一种“抽象”的数据结构,通常需要根据不同的记录精度,有不同的数据结构实现。
- 记录精度:
- “字长精度”:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
- “对象精度”:每个记录精确到一个对象,该对象里有字段含有跨代指针。
- “卡精度”:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
卡表(Card Table)就是记忆集的一种具体实现(“卡精度”),它定义了记忆集的记录精度、与堆内存的映射关系等。
- (卡表与记忆集的关系,可以类比于:HashMap与Map)
- 卡表最简单的形式可以只是一个字节数组(HotSpot的形式)
HotSpot中的卡表
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,称为这个元素变脏(Dirty),没有则标识为0;
- 垃圾收集时,根据卡表的元素标识,将对应卡页内存卡加入GC Roots中一并扫描;
写屏障(如何维护卡表状态?)
在HotSpot虚拟机里是通过“写屏障”(Write Barrier)技术维护卡表状态的。
- (先请读者注意将这里提到的“写屏障”,以及后面在低延迟收集器中会提到的“读屏障”与解决并发乱序执行问题中的“内存屏障”区分开来,避免混淆。)
写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的 AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内: 在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier), 在赋值后的则叫作写后屏障(Post-Write Barrier)。 HotSpot虚拟机的许多收集器中都有使用到写屏障,但直至G1收集器出现之前,其他收集器都只用到了写后屏障。
写后屏障更新卡表(简化逻辑):
void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}
“伪共享”(False Sharing)【???】
经典垃圾收集器
Serial 收集器
ParNew 收集器
Parallel Scavenge 收集器
Serial Old 收集器
Parallel Old 收集器
CMS 收集器
Garbage First 收集器
低延迟垃圾收集器
3.6.1 Shenandoah收集器 3.6.2 ZGC收集器
选择合适的垃圾收集器
3.7.1 Epsilon收集器 3.7.2 收集器的权衡 3.7.3 虚拟机及垃圾收集器日志 3.7.4 垃圾收集器参数总结
内存分配与回收策略
大对象优先在Eden分配 3.8.2 大对象直接进入老年代 3.8.3 长期存活的对象将进入老年代 3.8.4 动态对象年龄判定 3.8.5 空间分配担保