JVM学习笔记(二):垃圾收集
程序计数器、 虚拟机栈、 本地方法栈3个区域随线程而生,随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。 每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。 而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存。
自动垃圾回收机制就是寻找Java堆中的对象,并对对象进行分类判别,寻找出正在使用的对象和已经不会使用的对象,然后把那些不会使用的对象从堆上清除。
一、哪些对象需要回收
1. 引用计数算法
引用计数算法:给对象中添加一个引用计数器,每当有一个对象引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
但主流Java虚拟机中并没有采用引用计数算法,原因:它很难解决对象之间相互循环引用的问题。
例:
/** * 添加参数:-XX:+PrintGC * testGC()方法执行后,objA和objB会不会被GC呢? * */ 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(); } public static void main(String[] args) { ReferenceCountingGC referenceCountingGC = new ReferenceCountingGC(); ReferenceCountingGC.testGC(); } }
objA和ojbB都被设置成了null,在GC时,应回收这样的对象,因为这两个对象已经不可能再被访问(对象已经为null)。但如果按照引用计数算法来看,虽然这两个对象都被设置成了null,但它们还在互相引用,所以各自的计数器都还不是0,所以不能被回收。
运行结果:
可以看出虚拟机并没有因为这两个对象互相引用就不回收它们,说明虚拟机不是通过引用计数算法判断对象是否存活的。
2. 可达性分析算法
Java虚拟机主要是通过可达性分析来判定对象是否存活的。
这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达时,则证明此对象是不可用的。
如图中,对象object4、object5、object6虽然互相有关联,但他们到GC Roots是不可达的,所以它们将会被判定为可回收的对象。
在Java语言中,可作为GC Roots的对象包括下面几种:
(1). 虚拟机栈(栈帧中的本地变量表)中引用的对象。
(2). 方法区中类静态属性引用的对象。
(3). 方法区中常量引用的对象。
(4). 本地方法栈中JNI(即一般说的Native方法)引用的对象。
这里关于引用:
JDK1.2之后,引用分为:强引用、软引用、弱引用、虚引用,四种引用强度逐渐减弱。
3. 两次标记过程
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程。
第一次标记
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,则该对象被第一次标记并进行一次筛选,筛选条件为是否有必要执行该对象的finalize()方法。
(1). 若对象没有覆盖finalize()方法,或该finalize()方法是否已经被虚拟机执行过了,虚拟机将这两种情况都视为“不必要执行该对象的finalize()方法”。即该对象将会被回收。
(2). 对象有必要执行finalize()方法
第二次标记
若对象覆盖了finalize()方法,并且该finalize()方法还没有被执行过,则说明该对象有必要执行finalize()方法。 (注:Finalize()方法是对象脱逃死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模标记。)
那么,这个对象会被放置在一个叫F-Queue的队列中,并在稍后由虚拟机自动建立的、优先级低的Finalizer线程去执行这个finalize()方法。这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺等待该线程执行结束。即虚拟机只负责建立线程,其他的事情交给此线程去处理。
如果对象在finalize()方法中拯救了自己,即关联上了GC Roots引用链,如把this关键字赋值给其他变量。那么在第二次标记的时候它将从“即将回收”的集合中移除,如果对象还是没有拯救自己,那基本上它就真的被回收了。
总结:
需要回收的对象:
从Roots搜索不到,而且经过第一次标记后,没必要执行finalize()方法的对象将被回收;执行finalize()方法后,仍没与Roots建立关联的对象,将会被回收。
对象自我拯救的栗子:
/** * 1. 对象在被GC时,可以自我拯救 * 2. 这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次 */ public class FinalizeEscapeGC { public String name; public static FinalizeEscapeGC SAVE_HOOK = null; public FinalizeEscapeGC(String name){ this.name = name; } public void isAlive() { System.out.println("yes, i am still alive :)"); } @Override protected void finalize() throws Throwable { super.finalize(); System.out.println("finalize mehtod executed!"); System.out.println("this: " + this); FinalizeEscapeGC.SAVE_HOOK = this; } @Override public String toString(){ return name; } public static void main(String[] args) throws Throwable{ // TODO Auto-generated method stub SAVE_HOOK = new FinalizeEscapeGC("first"); System.out.println("SAVE_HOOK: " + SAVE_HOOK); //对象第一次成功拯救自己 SAVE_HOOK = null; //SAVE_HOOK被设置成了null,因此,在GC时,应该被回收 System.out.println("SAVE_HOOK: " + SAVE_HOOK); //SAVE_HOOK = null,虽然占有内存,但值为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 :("); } } }
运行结果:
可以看出SAVE_HOOK对象的finalize()方法确实被GC收集器触发过,并且在收集前成功逃脱了。
另外,任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败。
此外,一个堆对象的this(放在局部变量表中的第一项)引用会永远存在,在方法体内可以将this引用赋值给其他变量,这样堆中对象就可以被其他变量所引用,即不会被回收。
二、对象怎么被回收
(一). 垃圾收集算法
1. 标记-清除算法
标记-清除算法是最基础的收集算法,如同它的名字样,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象(标记过程就是上面所述的对象标记过程)。
主要缺点:
(1). 效率问题:标记和清除两个过程的效率都不高;
(2). 空间问题:标记清除后会产生大量不连续的内存碎片, 内存碎片太多可能会导致以后程序运行过程中在需要分配较大对象时,无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。
标记-清除算法的执行过程:
2. 复制算法
复制算法是为了解决效率问题而出现的。它将可用的内存分为大小相等的两块,每次只用其中一块,当这一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的内存空间一次性清理掉。
主要缺点:内存缩小为了原来的一半,算法代价太高
复制算法的执行过程:
应用于虚拟机:
现在的商用虚拟机中新生代都采用复制算法来回收。新生代的内存被划分为一块较大的Eden空间和两块较小的Survivor空间(分别叫from和to),每次使用Eden和其中一块Survivor。每次回收时,将Eden和Survivor中还存活着的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden区和Survivor区的比例为8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%,只有10%的内存会被“浪费”。
当然,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖老年代进行分配担保。
虚拟机给每个对象定义了一个对象年龄计数器。一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,并且能够被Survivor容纳的话,将会被移到Survivor区,并把对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度(默认是15岁),就会被晋升到年老代中。
具体而言,在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”;而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。
经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。
参考:聊聊JVM的年轻代
3. 标记-整理算法
标记-整理算法过程与标记-清除算法一样,不过不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。
标记-整理算法的执行过程:
应用于虚拟机:
虚拟机中老年代采用标记-整理算法。(若采用复制算法,就需要浪费50%的空间,如果不想浪费空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况。)
4. 分代收集算法
现代商用虚拟机基本都采用分代收集算法来进行垃圾回收。这种算法并没有什么新思想,只是根据对象存活周期的不同将内存划分为几块(一般Java堆分成新生代和老年代),然后根据各个年代的特点采用最适当的收集算法。
在新生代中,每次垃圾收集时,都有大批对象死去,只有少量存活,采用复制算法(复制成本低);
在老年代中,对象存活率高,没有额外空间对它进行分配担保,采用标记-清除算法或标记-整理算法。
(二). 垃圾收集器
垃圾收集器是垃圾收集算法的具体实现。
不同虚拟机所提供的垃圾收集器可能会有很大差别,这里讨论的是HotSpot,HotSpot虚拟机所包含的所有收集器如图:
上图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,那说明它们可以搭配使用。虚拟机所处的区域,表示它是属于新生代收集器还是老年代收集器。
1. Serial收集器
Serial收集器是最基本、发展历史最久的收集器,是一个采用复制算法的单线程的新生代收集器。
特点:
(1). 针对新生代;
(2). 采用复制算法;
(3). 单线程。 它的“单线程”意义:
a. 它只会使用一个CPU或一条线程去完成垃圾收集工作
b. 它进行垃圾收集时必须暂停其他线程的所有工作,直到它收集结束(STW,Stop The World)。
应用场景:
Serial收集器是虚拟机运行在Client模式下的默认新生代收集器
优点:简单高效。对于单个CPU环境而言,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
Serial/Serial Old收集器运行示意图:
2. ParNew收集器
ParNew收集器其实就是Serial收集器的多线程版本,在可用控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样。是一个采用复制算法的并行多线程收集器,是一个新生代收集器。
特点:
(1). 与Serial收集器相同的特点:
a. 针对新生代;
b. 采用复制算法;
c. STW
(2). 主要特点:
多线程。使用多线程进行垃圾收集。
应用场景:
它是Server模式下的虚拟机首选的新生代收集器。但在单个CPU环境中,不会比Serail收集器有更好的效果,因为存在线程交互的开销。
ParNew/Serial Old收集器运行示意图:
3. Parallel Scavenge收集器
Parallel Scavenge收集器也是一个采用复制算法的并行多线程收集器,是一个新生代收集器。Parallel Scavenge收集器因为与吞吐量关系密切,也称为吞吐量收集器。
特点:
(1). 与ParNew收集器相同的特点:
a. 新生代收集器;
b. 采用复制算法;
c. 多线程收集;
(2). 主要特点:
Parallel Scavenge收集器的目标是达一个可控制的吞吐量,而CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间。
所谓吞吐量的意思就是CPU用于运行用户代码时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总运行100分钟,垃圾收集1分钟,那吞吐量就是99%。
应用场景:
高吞吐量为目标,即减少垃圾收集时间,让用户代码获得更长的运行时间。
当应用程序运行在具有多个CPU上,对暂停时间没有特别高的要求时,即程序主要在后台进行计算,而不需要与用户进行太多交互。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序
4. Serial Old收集器
Serial Old收集器是Serial收集器的老年代版本,它是一个单线程收集器,使用“标记-整理算法”。
特点:
特点
(1). 针对老年代;
(2). 采用"标记-整理"算法(还有压缩,Mark-Sweep-Compact);
(3). 单线程。
应用场景:
(1). 用于给Client模式下的虚拟机使用;
(2). 在Server模式下使用有两大用途:
a. 在JDK1.5及之前,与Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配);
b. 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用
5. Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。在Parallel Old收集器出现后,“吞吐量优先收集器”终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge收集器+Parallel Old收集器的组合。
特点:
(1). 针对老年代;
(2). 采用"标记-整理"算法;
(3). 多线程收集;
(4). 吞吐量可控。
应用场景:
在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge收集器+Parallel Old收集器的组合。
Parallel Scavenge/Parallel Old收集器运行示意图:
6. CMS收集器
CMS收集器,是以获取最短回收停顿时间为目标的收集器。使用标记-清除算法。
特点:
(1). 针对老年代;
(2). 采用"标记-清除"算法(产生内存碎片);
(3). 以获取最短回收停顿时间为目标;
(4). 并发收集、低停顿,因此也被称为并发低停顿收集器。
应用场景:
与用户交互较多的场景;注重服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验,如常见WEB、B/S系统的服务端上。
主要缺点:
(1). 对CPU资源非常敏感;
(2). 无法处理浮动垃圾;
(3). 由于采用的标记 - 清除算法,会产生大量的内存碎片,不利于大对象的分配,可能会提前触发一次Full GC。
CMS收集器运行示意图:
7. G1收集器
G1是目前技术发展的最前沿成果之一,HotSpot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器。
特点:
(1). 并行和并发。能充分利用多CPU、多核环境下的硬件优势,使用并行来缩短Stop The World停顿时间;也可以并发让垃圾收集与用户线程同时进行。
(2). 分代收集。独立管理整个GC堆(新生代和老年代),能够采用不同的方式处理不同时期的对象。
(3). 空间整合。从整体看,是基于标记-整理算法;从局部(两个Region间)看,是基于复制算法;不会产生内存空间碎片。
(4). 可预测的停顿。G1除了追求低停顿处,还能建立可预测的停顿时间模型,可以明确指定M毫秒时间片内,垃圾收集消耗的时间不超过N毫秒。
应用场景:
面向服务端应用,针对具有大内存、多处理器的机器;最主要的应用是为需要低GC延迟,并具有大堆的应用程序提供解决方案;
G1收集器运行示意图:
三、对象什么时候被回收
1. 何时发生Minor GC
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
2. 何时发生Full GC
(1). System.gc()方法被调用
(2). 老年代空间在新生代对象转入,或在老年代创建大对象大数组,内存空间不足时,执行Full GC。
当执行Full GC后空间仍然不足,报错:java.lang.OutOfMemoryError: Java heap space
(3). perm/metaspace 空间不足
JVM规范中运行时数据区域中的方法区,在HotSpot虚拟机中又被习惯称为永生代或者永生区,
Permanet Generation中存放的为一些class的信息、常量、静态变量等数据,当系统中要加载的类、反射的类和调用的方法较多时,
当Full GC后空间仍然不足,报错:java.lang.OutOfMemoryError: PermGen space
(4). CMS GC时出现promotion failed和concurrent mode failure
promotion failed是在进行Minor GC时,survivor space放不下, 对象只能放入老年代,而此时老年代也放不下造成的;
concurrent mode failure是在执行CMS GC的过程中同时有对象要放入老年代,而此时老年代空间不足造成的
(5). 判断当前新生代的对象是否能够全部顺利的晋升到老年代,如果不能,就提早触发一次老年代的收集
参考:对象何时进入老年代、何时发生full gc
几个概念:
1. 并发垃圾收集和并行垃圾收集的区别
(1). 并行:指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
(2). 并发:指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上。
2. Minor GC和Full GC的区别
(1). 新生代GC(Minor GC):指发生在新生代的垃圾收集动作。因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
(2). 老年代GC(Major GC / Full GC):指发生在老年代的GC。出现了Full GC,经常会伴随至少一次的Minor GC(但并非绝对的,Parallel Sacvenge收集器的收集策略里就有直接进行Full GC的设置)。Full GC速度一般比Minor GC慢10倍以上。
3. Client模式和Server模式的区别
部分商用虚拟机中,Java程序最初是通过解释器对进行解释执行的,当虚拟机发现某个方法或代码块运行特别频繁的时,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器叫做即时编译器(Just In Time Compiler,即JIT编译器)。
JIT编译器并不是虚拟机必需的部分,Java虚拟机规范并没有要求要有JIT编译器的存在,更没有限定或指导JIT编译器应该如何去实现。但是,JIT编译器性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键指标之一。
解释器和编译器其实和编译器各有优势:
(1). 当程序需要迅速启动和执行的时候,解释器可以先发挥作用,省去编译的时间,立即执行
(1). 在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率
HotSpot虚拟机中内置了两个JIT编译器,分别称为Client Complier和Server Complier(简称C1编译器和C2编译器)。HotSpot默认采用的是解释器和一个编辑器配合的方式进行工作。HotSpot在启动的时候会根据自身版本以及宿主机器的硬件性能自动选择运行模式,比如会检测宿主机器是否为服务器、比如J2SE会检测主机是否有至少2个CPU和至少2GB的内存。
(1). 如果是,则虚拟机会以Server模式运行,该模式与C2编译器共同运行,更注重编译的质量,启动速度慢,但是运行效率高,适合用在服务器环境下,针对生产环境进行了优化
(2). 如果不是,则虚拟机会以Client模式运行,该模式与C1编译器共同运行,更注重编译的速度,启动速度快,更适合用在客户端的版本下,针对GUI进行了优化
查看虚拟机是运行在Client模式下还是Server模式下:
主要来自:
《深入理解java虚拟机 JVM高级特性与最佳实践》