1 Java 代码时如何运行的
一旦一个程序被转换成 Java 字节码,那么它便可以在不同平台上的虚拟机实现里运行。这也就是我们经常说的“一次编写, 到处运行”, 虚拟机的另一个好处是有一个托管环境,能够代替我们处理一些代码中冗长而容易出错的部分。其中最为广为人知的当属自动内存管理与垃圾回收。
执行Java代码首先需要将class文件加载到Java虚拟机中。加载后的Java类会被存放于方法区(Method Area) 中。实际运行时,虚拟机会执行方法区的代码。
在运行过程中,每当调用进入一个Java方法,Java虚拟机会在当前线程的Java方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且Java虚拟机不要求栈帧在内存空间里连续分布。当退出当前执行的方法时,不管是正常返回还是异常返回,Java虚拟机均会弹出当前线程的栈帧,并将之舍弃。
从硬件角度看,Java字节码无法直接执行。因此,Java虚拟机需要将字节码翻译成机器码。在HotSpot里面,上述翻译有两种形式,第一种是解释执行,即逐条将字节码翻译成机器码。第二种是即时编译(JIT), 即将一个方法中包含的所有字节码编译成机器码后再执行。
前者的优势在于无需等待编译,而后者的优势在于实际运行速度更快。HotSpot 默认采用混合模式,综合了解释执行和即时编译的优点。它会先解释执行字节码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。
2 垃圾回收
2.1 如何判别一个对象是否死亡
- 引用记数法,每个对象添加一个引用计数器,用来统计指向该对象的引用计数。但是引用计数法无法解决循环引用问题,所以没有被使用。
-
可达性分析法,将一系列GC Roots作为初时的存活对象合集,从集合出发,探索所有能被改集合引用到的对象,并将其加入到集合中,我们成为标记(mark), 最终未被探索到的对象是死亡的,可以回收的。 GC Roots就是类加载时(方法区))引用的对象和正在运行(栈)的数据引用的对象主要有如下几种:
- 虚拟机栈中应用的对象;
- 本地方法栈引用的JNI对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量应用的对象
Stop-the-world, 多线程情况下,为了避免漏报或者误报(误报问题不大,顶多晚点回收;漏报就会导致正在使用的内存被回收掉),停止其他非垃圾回收线程的工作,直到垃圾回收完成。
回收死亡对象的内存共有三种方式,分别为:会造成内存碎片的清除,性能开销较大的压缩、以及堆使用效率较低的复制。
3 垃圾回收和内存分配策略
Java程序员把内存控制的权力交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会成为一项异常艰难的工作。
3.1 运行时数据区域
-
程序计数器是一块很小的内存空间,可以看作是当前线程所执行的字节码行号指示器。为了线程切换后能恢复到正确的执行位置,PC是线程私有的。
-
Java虚拟机栈,线程私有,虚拟机栈描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(栈帧表示程序的函数调用记录,栈上保持了N个栈帧的实体,栈帧不仅保存诸如:函数入参、出参、返回地址和上一个栈帧的栈底指针等信息,还保存了函数内部的自动变量)。每一个方法从调用直至执行完成就对应着一个栈帧在虚拟机栈中的入栈到出栈的过程。
-
本地方法栈:为虚拟机使用到的Native方法服务。hotspot直接将本地方法和虚拟机栈合二为一。
- Java堆:存放对象实例和数组。
- 方法区:存储已被虚拟机加载的类信息,常量,静态变量,及时编译器编译后的代码等数据。 还有运行时常量池——存放编译器生成的各种字面量和符号引用。java8 中取消了永久代,所以方法区的数据分成了2部分。元空间存储类的元信息,静态变量和常量池等并入堆中
3.2 内存模型
Java内存模型解决可见性和有序性的问题,导致可见性的原因是缓存,导致有序性的原因是编译优化,最直接的办法是禁用缓存和编译优化,但是这样问题虽然解决了,但是程序的性能可能就堪忧了,合理的方案是按需禁用缓存以及编译优化。从程序员的视角可以理解为,java内存模型规范了JVM如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括volatile, synchronized和final三个关键字,以及8个happens-before规则。
3.2.1 volatile
可见性,禁用CPU缓存,变量的读写不能使用缓存,必须从内存中读取或写入。volatile只能保证可见性,不能保证原子性,对变量的操作和运算需要锁保证原子性。对于flag是比较合适的。
3.2.2 happen-before
操作X happens-before 操作Y,那么X的结果对于Y可见。Happens-Before约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守Happens-Before规则。 倘若在程序开发中,仅靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,幸运的是,在Java内存模型中,还提供了happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before 原则内容如下:
- 程序顺序规则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
- volatile规则, volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变 量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
- 传递性, A先于B ,B先于C 那么A必然先于C
- 锁规则, 同一个锁解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前。也就是说,先进入临界区线程的操作结果对后进入临界区的线程可见。解锁时,虚拟机同样需要强制刷新缓存,使得当前线程的内存对其他线程可见。
- 线程启动规则,主线程A启动子线程B后,子线程B能够看到主线程在启动子线程B前的操作。
- 线程结束规则, 主线程A等待子线程B完成,当子线程B完成后,主线程能够看到子线程的操作(通过Thread.isAlive或者Thread.join判断线程是否中止)。当然所谓看到指的是共享变量的操作。
- 线程中断规则, 线程对其他线程的中断Happens-before被中断线程的代码检测到中断事件的发生(即被中断线程的InterruptedException异常,或者第三个线程通过Thread.interrupted()或者Thread.isInterrupted方法检测线程是否中断)
- 对象终结规则,构造器中的最后一个操作Happens-before析构器的第一个操作。
3.2.3 Java内存模型的底层实现
Java内存模型是通过内存屏障(memory barrier)禁止重排序的。对于即时编译器来说,他会针对前面提到的每一个Happens-before关系,向正在编译的目标方法中插入相应的读读,读写,写读以及写写的内存屏障。
举例来说,对于volatile字段,即使编译器将在volatile字段的读写操作前后各插入一些内存屏障。
3.3 内存分配
大部分的 Java 对象只存活一小段时间,而存活下来的小部分 Java 对象则会存活很长一段时间。
- 新生代(Young Generation):大多数对象在新生代中被创建,其中很多对象的生命周期很短。每次新生代的垃圾回收(又称Minor GC)后只有少量对象存活,所以选用复制算法,只需要少量的复制成本就可以完成回收。
当Eden区满时,进行一次MinorGC, 还存活的对象将被复制到S0区。当S0区满时,此区的存活且不满足“晋升”条件的对象将被复制到S1区。对象每经历一次Minor GC,年龄加1,达到“晋升年龄阈值”后,被放到老年代,这个过程也称为“晋升”。显然,“晋升年龄阈值”的大小直接影响着对象在新生代中的停留时间,在Serial和ParNew GC两种回收器中,“晋升年龄阈值”通过参数MaxTenuringThreshold设定,默认值为15。
- 老年代(Old Generation):在新生代中经历了MaxTenuringThreshold次垃圾回收后仍然存活的对象,就会被放到年老代,该区域中对象存活率高。老年代的垃圾回收(又称Major GC)通常使用“标记-清理”或“标记-整理”算法。整堆包括新生代和老年代的垃圾回收称为Full GC(HotSpot VM里,除了CMS之外,其它能收集老年代的GC都会同时收集整个GC堆,包括新生代)。
- 大对象会直接放到老年代。
- 通过动态判定,同龄对象占用空间的和大于survivor的一半时,进入老年代。
4 Java 虚拟机的监控及诊断工具
4.1 命令行参数
- jps, 打印正在运行的进程的相关信息。 -l打印模块名及包名;-v 打印个java虚拟机的参数;-m打印传递给主类的参数。
-
jstat, 打印目标java进程的性能数据,并且可以设置每隔多长时间打印一次。可以用来判断是否出现内存泄漏,取OU列,获得多组OU的最小值,如果这些值呈上涨趋势,说明该java程序老年代内存在不断上涨,可能存在内存泄漏。
- jmap, 分析堆中的对象。
- jinfo, 查看java进程的参数。
- jstack, 打印目标java继承中各个线程的栈轨迹,以及这些线程所持有的锁。其中一个应用场景就是死锁检测,不仅会打印线程的栈轨迹,线程状态,持有的锁,以及正在请求的锁,还会分析出具体的死锁。
- jcmd, 可以代替除了jstat外的所有功能。
5 虚拟机执行子系统
5.1 类文件结构
类的文件结构就是将class/interface的语法转换为字节码后的存储结构。 主要包括:
- 魔数(CAFEBABE)和Class文件的次版本号和主版本号:高版本的JDK能向下兼容以前版本的Class文件,虚拟机也会拒绝执行超过其版本号的class文件。
- 常量池:字符串和final常量。不同的类型结构不一样(String类型是TLV结构)。类创建的时候,从常量池获取对应符号引用,翻译到对应的内存地址中。
- 访问标志:如public/priave等
- 类索引、父类索引与接口索引集合。
- 字段表集合
- 方法表集合
- 属性表集合。
如果不考虑异常处理的话,Java虚拟机的解释器的执行类似下面的伪代码:
do {
自动计算PC的值+1
根据PC的指示位置,从字节码流中去取出操作码;
if (字节码存在操作数) {
从字节码流中取出操作数
}
执行操作吗所定义的操作
} while(字节码流长度 > 0);
Java虚拟机通过指令集进行各种操作,如加载/存储,运算,类型转换,对象的创建和访问,操作数栈管理指令,控制转移指令,方法调用和返回指令,异常处理指令,同步指令等。
5.2 类加载机制
5.2.1 类初始化的时机
类加载的实际:对于5种情况必须对类进行初始化:
- 遇到new、getstatic、putstatic、invokestatic四条指令。
- 使用java.lang.reflect包的方法对类进行反射调用的时候。
- 初始化一个类的时候,发现其父类还没有进行过初始化,需要先触发其父类的初始化
- 虚拟机启动的时候,初始化包含main()的主类
- 如果java.lang.invoke.MethodHandle实例,最后解析结构的方法句柄对应的类没有初始化,需要对其进行初始化。
类型的加载、链接和初始化过程都是在程序运行期间完成的,这种策略虽然增加了一些性能开销,但是提高了灵活性,如编写一个面向接口的应用程序,可以在运行时再指定其实现的类。
5.2.2 类加载过程
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,准备,解析和初始化,最终形成可以被虚拟机直接使用的Java类型。当然整个生命周期还包括使用和卸载,共7个阶段。
- 加载:通过一个类的全限定名来获取定义此类的二进制字节流,把静态方法存储结构转化为方法区的运行时数据结构,在内存中生成一个代表这个类的java.lang.Class对象作为访问入口。
- 验证:确保Class文件的字节流种包含的信息符合当前虚拟机的要求,不会危害虚拟机的安全。包括文件验证,元数据验证,语义验证,字节码验证,符号引用验证。
- 准备:为类变量(被static修饰的变量)分配内存并设置类变量初始值的阶段。不包括实例变量,实例变量在对象实例化时随对象一起分配。
- 解析:虚拟机将常量池内的符号引用替换为直接引用的过程。
- 初始化:根据程序员的代码对类变量和其他资源进行初始化。
5.2.3 类加载器
“通过一个类的全局限定名来获取描述此类的二进制字节流”的动作放在了Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称作“类加载器”。
任意一个类,都需要有它的类加载器和这个类本身一同确立其在java虚拟机中的唯一性,每一个类加载器都拥有一个独立的类命名空间。 双亲委派模型:如果一个类加载器收到了类加载的请求,它不会自己尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,所有的请求最终都应该传送到顶层的启动类加载器中,只有父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。双亲委派模型解决同一个的类在各个加载器环境中都是同一个类。
6 虚拟机字节码执行引擎
“虚拟机”是一个相对与“物理机” 的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎是自己实现的。
所有Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的过程,输出的是执行结果。
每一个方法调用开始至执行完成的过程,都对应着一个栈帧的入栈和出栈过程。
6.1 栈帧的结构
对于执行引擎来说,在活动的线程中,只有栈顶的栈帧才是有效的。
每个栈帧包括:
- 局部变量表:存放方法参数和方法内部定义的局部变量
- 操作数栈:算术运算是通过操作数栈进行的,调用其他方法的时候是通过操作数栈进行传递的。
- 动态链接: 每次运行期间转换为直接引用
- 方法返回的地址: 正常退出将调用者的PC计数器作为返回值。异常退出通过异常处理器处理
- 附加信息:如调试相关的信息
6.2 方法解析和分派(方法调用)
方法调用并不等同于方法执行,而是调用哪一个方法,不涉及方法内部的具体运行过程。一切方法调用在Class文件李米娜存储的都只是符号引用,而不是实际运行时内存中的入口地址(直接引用)。
6.2.1 解析
目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号转化为直接引用。这要求在方法运行之前就有一个可确定的调用版本,并且这个版本的调用版本在运行期间是不可变的。
6.2.2 静态分派
静态分配发生在编译阶段,因此静态分派实际上不是虚拟机执行的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class StaticDispatch{
static abstract class Human{ }
static class Man extends Human{ }
static class Woman extends Human{ }
public void sayHello(Human guy) {
System. out. println(" hello, guy!");
}
public void sayHello(Man guy){
System. out. println(" hello, gentleman!");
}
public void sayHello(Woman guy)
System. out. println("hello, lady!");
}
public static void main(String[] args){
Human man= new Man();
Human woman= new Woman();
StaticDispatch sr= new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
1
2
hello, guy!
hello, guy!
虚拟机在 重载(overload) 是通过参数的静态类型而不是实际类型作为判断依据。
public class Overload{
public static void sayHello(Object arg) {
System. out. println("hello Object");
}
public static void sayHello(int arg) {
System. out. println("hello int");
}
public static void sayHello(long arg){
System. out. println("hello long");
}
public static void sayHello(char arg) {
System. out. println(" hello char");
}
public static void sayHello(char…… arg) {
System. out. println(" hello char……");
}
public static void main( String[] args){
sayHello('a');
}
}
往往很多情况,重载版本呢并不是唯一的,往往只能选择一个“更加合适”的版本。首先是打印“hello char”, 如果注释掉,打印“hello int”, 依次类推,最后调用可变参。
6.2.3 动态分配
多态(一个变量既可以引用本类型的对象,也可以引用子类的对象)的一个重要特性是覆盖也叫重写(override)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class DynamicDispatch{
static abstract class Human{
protected abstract void sayHello();
}
static class Man extends Human{
@Override protected void sayHello() {
System. out. println(" man say hello");
}
}
static class Woman extends Human{
@Override protected void sayHello(){
System. out. println(" woman say hello");
}
}
public static void main(String[] args) {
Human man= new Man();
Human woman= new Woman();
man. sayHello();
woman. sayHello();
man= new Woman();
man. sayHello();
}
}
man say hello
woman say hello
woman say hello
当前类如果没找到就一直向上找父类,找到就分配,找不到就抛出AbstractMethodError异常。
Java是一门静态多分配,动态但分配的语言。
6.3 基于栈的字节码执行引擎
6.3.1 基于栈的指令集和基于寄存器的指令集
Java编译器输出的指令流是一种基于栈的指令集架构,他们依赖操作数栈进行工作。与之相对的是基于寄存器的指令集架构,如x86的指令集。
基于栈的指令集的最大优点: 可移植,用户程序不会直接使用这些寄存器,由虚拟机决定把访问最频繁的数据(如程序计数器等)放到寄存器中提高性能。
基于栈的指令集的缺点:
- 基于栈的指令集最大的缺点就是执行速度相对来说稍慢一些。栈是存储在内存中,频繁的栈访问也就意味着频繁的内存访问,对于处理器来说,内存始终是执行的速度瓶颈
- 虽然指令集代码紧凑,但是指令数量比寄存器架构多。
6.3.2 基于栈的解释执行过程
public int calculate(){
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
上图展示了执行偏移地址为0的指令的情况,bipush指令的作用是将单字节的整型常量值(-128~127)推入操作数栈顶,后跟一个参数,指明推送的常量值,这里是100。
上图则是执行偏移地址为2的指令,istore_1指令的作用是将操作数栈顶的整型值出栈并存放到第1个局部变量Slot中。后面四条指令(直到偏移为11的指令为止)都是做同样的事情,也就是在对应代码中把变量a、b、c赋值为100、200、300。后面四条指令的图就不重复画了。
执行偏移地址为11的指令,iload_1指令的作用是将局部变量第1个Slot中的整型值复制到操作数栈顶。
执行偏移地址12的指令,iload_2指令的执行过程与iload_1类似,把第2个Slot的整型值入栈。画出这个指令的目的是为了显示执行零iadd指令前操作数栈的情况。
执行偏移地址为13的指令情况,iadd指令的作用是将操作数栈中前两个栈顶元素出栈,做整型加法,然后把结果重新入栈。在iadd指令执行完毕后,栈中原有的100和200出栈,它们相加后的和300重新入栈。
上图为执行偏移地址为14的指令的情况,iload_3指令把存放在第3个局部变量Slot中的300入栈到操作数栈中。这时操作数栈为两个整数300,。
下一条偏移地址为15的指令imul是将操作数栈中前两个栈顶元素出栈,做整型乘法,然后把结果重新入栈,这里和iadd指令执行过程完全类似,所以就不重复画图了。
这只一个概念模型,虚拟机会对执行过程做一些优化来提高性能,实际过程中差距会很大。
上图是最后一条指令也就是偏移地址为16的指令的执行过程,ireturn指令是方法返回指令之一,它将结束方法执行并将操作数栈顶的整型值返回给此方法的调用者。到此为止,该方法执行结束。
上面的执行过程只是一种概念模型,虚拟机最终会对执行过程做出一些优化来提高性能,实际的运作过程不一定完全符合概念模型的描述。不过从这段程序的执行过程也可以看出栈结构指令集的一般运行过程,整个运算过程的中间变量都是以操作数栈的出栈和入栈为信息交换途径。
6.4 案例和实战
再Class文件格式与执行引擎中,用户能影响的事情并不多,class文件以何种格式存储,类型何时加载、如何连接,以及虚拟机如何执行字节码指令等都是由虚拟机直接控制的行为。能通过程序进行操作的,主要是字节码生成与类加载器。
Tomcat: 定了多个类加载器,都是按照经典的双亲委派模型实现的。 OSGI: 加载器之间的关系不再是双亲委派模型的树形结构,而是已经进一步发展成为一种更为复杂、运行时才能确定的网站结构。 动态代理:newProxyInstance()程序进行了验证、优化、缓存、同步、生成字节码、显示类加载等操作。最后调用generateProxyClass()方法完成生成字节码的动作。
7 案例
如果你的Docker环境不支持jdk工具,参考这里
7.1 感觉发生了内存泄漏
隔一段时间通过docker stat查看容器状态,发现export-distro的内存不断增长。
- 启动了telegraph长时间观察内存情况。
- 开启GC日志。通过gceasy.io进行分析。
7.2 内存泄漏
device service采集2天左右,出现out of memory, 通过jvisualVM分析,查看到一个hashMap占用空间越来越大。查看相关代码发现hashMap多线程put操作,然后单线程消费后执行remove操作,导致添加的速度大于删除的速度,最终内存溢出。修改为无锁操作,定时任务触发采集任务阻塞队列,然后通过线程池进行消费。采集后直接将采集数据返回。
7.3 死锁问题
同事说他们的wms跑着跑着偶尔会挂住,有一次叫我帮忙看原因,通过jstack直接打印出来等待的锁,因为2个加锁的地方加锁顺序不一致。将加锁和解锁封装成函数(通过id排序),解决了这个问题。
7.4 高cpu占用
发现开发的服务内存占用到300%左右,这时候就需要查看时哪个线程占用了CPU。
7.4.1 安装htop
alpine镜像中的top和ps都是精简版,不支持查看进程中线程的方式,所以通过安装htop解决这个问题:
1
2
3
docker exec -it ${container-name} sh # 进入docker容器
apk add htop # 安装htop
配置htop显示线程信息,要在htop中启用线程查看,再shell中输入:htop,然后按
docker中如果又简单的方式欢迎通知我,不胜感激。
7.4.2 通过htop查看哪些线程占用的cpu高,并且长时间占用cpu
可以看到104, 59, 58三个线程长时间占用cpu资源。
7.4.3 jstack查看线程栈
将线程ID转换为16进制,因为jstack使用的时16进制的线程ID
/deploy # printf "%x\n" 104
68
/deploy # printf "%x\n" 59
3b
/deploy # printf "%x\n" 58
查看线程对应的栈, 如104号线程的信息:
/deploy # jstack 8 | grep -A 10 0x68
"Read-Queue-0" #70 prio=5 os_prio=0 tid=0x00005642a6570800 nid=0x68 runnable [0x00007f47fba23000]
java.lang.Thread.State: RUNNABLE
at com.mingdutech.cloudplatform.deviecestatus.service.DeviceStatusService.lambda$messageProcess$1(DeviceStatusService.java:189)
at com.mingdutech.cloudplatform.deviecestatus.service.DeviceStatusService$$Lambda$50/340670126.run(Unknown Source)
at java.lang.Thread.run(Thread.java:748)
通过grep的-A参数查看当前行,及后续10行信息。栈的信息已经清晰定位到出问题的位置了。打开大地吗查看:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private void messageProcess() {
Runnable runnable = () -> {
List<DeviceStatusEntity> entities = new LinkedList<>();
int size;
while (true) {
try {
entities.clear();
size = mqttMsgBlockingQueue.drainTo(entities, 512);
if (size <= 0) {
continue;
}
//...
}
}
}
原因竟然是代码改来改去改错了,最开始用的drainTo() + sleep, 然后改成使用take(), 这次又改成了drainTo(), 但是忘记写sleep()了。
- drainTo(): 不阻塞, 从阻塞队列中获取指定数量的元素。
- take(): 阻塞, 从阻塞队列中获取1个元素。
问题解决了,如果有工具,那么定位问题还是比较方便的。
7.4.4 如何跟踪一个偶发的内存溢出
如果服务平时的内存回收都是正常的,偶发溢出。说明程序在某些不经常跑到的路径中发生了内存泄露。 可以让服务带着HeapDumpOnOutOfMemoryError参数运行一段时间。
1
-XX:HeapDumpOnOutOfMemoryError
7.4.5 GC导致的停顿
可以通过PrintGC参数打开GC日志进行分析。
1
2
3
-XX:+PrintGCApplicationStoppedTime \
-XX:+PrintGCDateStamps-Xloggc:gclog.log \
-XX:+PrintReferenceGC