先说一下,HotSpot是目前应用最广泛的一种Java虚拟机。说起垃圾回收,首先我们需要复习一下JVM的内存分配。
一、内存分区
Java虚拟机把内存划分为若干个不同的数据区,如下图所示:
接下来将会详细介绍这些分区。
1.1 程序计数器( Program Counter Register)
- 线程私有
程序计数器 是一块较小的内存空间, 它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令, 分支、 循环、 跳转、 异常处理、 线程恢复等基础功能都需要依赖这个计数器来完成。
为了线程切换后能恢复到正确的执行位置,保证上下文切换的正确性, 每条线程都需要有一个独立的程序计数器, 各条线程之间计数器互不影响, 独立存储, 我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个Java方法, 这个计数器记录的是正在执行的虚拟机字节码指令的地址; 如果正在执行的是Native方法, 这个计数器值则为空( Undefined) 。 此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
1.2 Java虚拟机栈( Java Virtual Machine Stacks)
- 线程私有
- 数据结构为栈帧
它的生命周期与线程相同。
每个Java方法在执行的同时都会创建一个栈帧用于存局部变量表、 操作数栈、 动态链接、 方法出口等信息。 每一个方法从调用到执行完成的过程, 就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
局部变量表存放了
- 编译期可知的各种基本数据类型( boolean、 byte、 char、 short、 int、float、 long、 double)
- 对象引用( reference类型, 它不等同于对象本身, 可能是一个指向对象起始地址的引用指针, 也可能是指向一个代表对象的句柄或其他与此对象相关的位置) 和returnAddress类型( 指向了一条字节码指令的地址) 。
- 在方法运行期间不会改变局部变量表的大小。
1.3 本地方法栈( Native Method Stack)
它与虚拟机栈非常相似的, 区别:
- 虚拟机栈为虚拟机执行Java方法( 也就是字节码) 服务
- 而本地方法栈则为虚拟机使用到的Native方法服务。
1.4 Java堆( Java Heap)
- 线程共享
此内存区域的唯一目的就是存放对象实例。
Java堆是垃圾收集器管理的主要区域,Java堆中还可以细分为: 新生代和老年代; 再细致一点的有Eden空间、 From Survivor空间、 To Survivor空间等。
补充——栈内存:
Java Stack内存用于执行线程。
每当调用方法时,都会在磁盘存储中创建一个新块,以容纳该方法的本地原始值并引用该方法中的其他对象。
存放基本类型的变量,对象的引用和方法调用,遵循先入后出的原则。
- Java中的代码是在函数体中执行的,每个函数主体都会被放在栈内存中,比如main函数。假如main函数里调用了其他的函数,比如add(),那么在栈里面的的存储就是最底层是main,mian上面是add。栈的运行时后入先出的,所以会执行时会先销毁add,再销毁main。
栈的优势是,栈内存与堆内存相比是非常小的,存取速度比堆要快,仅次于寄存器,栈数据可以共享。但缺点是,存在栈中的数据大小与生存期必须是确定的,缺乏灵活性。栈中主要存放一些基本类型的变量(int, short, long, byte, float, double, boolean, char)和对象句柄。栈有一个很重要的特殊性,就是存在栈中的数据可以共享。
1.5 方法区( Method Area)
- 线程共享
方法区存储已被虚拟机加载的类信息、 常量、 静态变量、 即时编译器编译后的代码等数据。永久代就存放在方法区。
垃圾收集行为在这个区域是比较少出现的, 但并非数据进入了方法区就如永久代的名字一样“永久”存在了。 这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。
1.6 运行时常量池
运行时常量池( Runtime Constant Pool) 是方法区的一部分。 Class文件中除了有类的版本、 字段、 方法、 接口等描述信息外, 还有一项信息是常量池( Constant Pool Table) , 用于存放编译期生成的各种字面量和符号引用, 这部分内容将在类加载后进入方法区的运行时常量池中存放。
1.7 总结
主要分两大块,一块是被线程所共享的,另外一块是线程所独享的区域。
- 被线程所共享的区域,主要有两大块,一块是堆内存,另一块是方法区
- 被线程所独享的区域主要有三大块,一块是栈内存,第二块叫本地方法栈,第三块叫程序计数器。
其中,在堆内存内部,又会分为两块区域,也就是我们经常听到的,叫做新生代和老年代,新生代又会被进行再划分,划分成一般是三个或者四个区域:
- 第一个是Eden,英文翻译过来是伊甸园,它是干嘛的呢?我们只要是在创建对象的过程中,创建一个,那个这个对象就会扔到Eden这块区域中,只要是新创建的,那么就会扔到Eden中,让他们在那里过无忧无虑的生活,当然了,垃圾回收器也是最喜欢光顾Eden的,那么,一旦被垃圾回收所定位了,那么它就不再在Eden中了,那么,可能就被杀掉了,如果没被杀掉,就被放到Survivor即存活区中。
- Survivor即存活区中那么就相当于,我有诺亚方舟的船票,那么我就进到了存活区里面,如果没有,就被杀掉了,还有一种就是,我多次在存活区里面,那么可能,我一直可能会被,地位比较高,在这里面上升了,升到哪里去了呢?老了,最后也没退休,就进入到Tenured Gen这一块区域中。
- Tenured Gen这一块区域是去养老去了,基本上,我们的垃圾回收比较少关注Tenured Gen这一块区域,这是新生代三块内存区域的划分。
在后续的垃圾收集算法我会继续提到上述概念。
二、什么时候GC?
垃圾收集GC需要完成的三件事情:
- 什么时候GC?
- 谁是垃圾?
- 如何回收?
在JVM中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫描那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行回收。
大多数情况下, 对象在新生代Eden区中分配。 当Eden区没有足够空间进行分配时, 虚拟机将发起一次 GC。
三、谁是垃圾?垃圾判断算法
GC 主要处理的是对象的回收,什么时候会触发一个对象的回收的呢?
- 对象没有引用
- 作用域发生未捕获异常
- 程序在作用域正常执行完毕
- 程序执行了System.exit()
- 程序发生意外终止(被杀进程等)
如何决定哪些对象是垃圾?涉及到以下算法:
3.1 引用计数法
在JDK1.2之前,使用的是引用计数器算法。通过引用计数来判断一个对象是否可以被回收。如果一个对象没有任何引用与之关联,则说明该对象基本不太可能在其他地方被用到,那么这个对象就可被回收了。
这种方式的特点是实现简单,而且效率较高,但是它无法解决循环引用的问题,如下面所示:
public class Main {
public static void main(String[] args) {
MyObject object1 = new MyObject();
MyObject object2 = new MyObject();
object1.object = object2;
object2.object = object1;
object1 = null;
object2 = null;
}
}
class MyObject{
public Object object = null;
}
object1 和 object2 赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数都不为0,那么垃圾收集器就永远不会回收它们。所以主流的JVM没有使用这种算法。
为了解决这个问题,在Java中采取了 可达性分析法。
3.2 可达性分析法
基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点,根据引用关系向下搜索, 搜索过程所走过的路径称为“引用链”(Reference Chain) , 如果没有任何引用链相连,则证明此对象是不可能再被使用的。
如图所示, 对象object 5、 object 6、 object 7虽然互有关联, 但是它们到GC Roots是不可达的,因此它们将会被判定为可回收的对象。
可作为GC Roots的对象包括下面几种:
- 虚拟机栈( 栈帧中的本地变量表) 中引用的对象。他们引用到的对象也就不会被回收。
public class Rumenz{
public static void main(String[] args) {
Rumenz a = new Rumenz();
a = null;
}
}
a是栈帧中的本地变量,a就是GC Root,由于a=null,a与new Rumenz()对象断开了链接,所以对象会被回收。
- 方法区中类静态属性引用的对象。
public class Rumenz{
public static Rumenz=r;
public static void main(String[] args){
Rumenz a=new Rumenz();
a.r=new Rumenz();
a=null;
}
}
- 方法区中常量引用的对象。
public class Rumenz{
public static final Rumenz r=new Rumenz();
public static void main(String[] args){
Rumenz a=new Rumenz();
a=null;
}
}
常量r引用的对象不会因为a引用的对象的回收而被回收。
- 本地方法栈中JNI( 即一般说的Native方法) 引用的对象
非死不可?
但是!!!!!!!
即使不可达的对象,也并非是“非死不可”。如果这个对象包含了finalize函数,性质就不一样了。怎么不一样了呢?
java虚拟机在进行垃圾回收的时候,一看到这个对象类含有finalize函数,就把对象交给一个单独的线程处理,这个线程有一个执行队列,依次执行各个对象的finalize方法,并使用一个链表,把这些包含了finalize的对象串起来。
执行完finalize方法后,GC会再次判断这个对象是否可达,如果还是不可达,那么这个对象就真的拜拜了,回收!!。finalize( ) 方法是对象逃脱死亡命运的最后一次机会。被判定为不可达的对象要成为可回收对象必须至少经历两次判断过程。从下面代码我们可以看到一个对象的finalize( ) 被执行, 但是它仍然可以存活。
/**
*此代码演示了两点:
*1.对象可以在被GC时自我拯救。
*2.这种自救的机会只有一次, 因为一个对象的finalize( ) 方法最多只会被系统自动调用一次
*/
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 mehtod executed! ") ;
FinalizeEscapeGC.SAVE_HOOK=this;//之后给这个空引用塞一个对象
}
public static void main(String[]args) throws Throwable{
SAVE_HOOK=new FinalizeEscapeGC() ;
//对象第一次成功拯救自己
SAVE_HOOK=null;
System.gc();
//因为finalize方法优先级很低, 所以暂停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() ;
//因为finalize方法优先级很低, 所以暂停0.5秒以等待它
Thread.sleep(500) ;
if( SAVE_HOOK! =null) {
SAVE_HOOK.isAlive() ;
}else{
System.out.println("no,i am dead:")
}
}
}
强弱引用与可达性
在 Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到JVM 也不会回收。因此强引用是造成Java 内存泄漏的主要原因之一。
弱引用
弱引用需要用WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。
弱引用简单来说就是将对象留在内存的能力不是那么强的引用。使用WeakReference,垃圾回收器会帮你来决定引用的对象何时回收并且将对象从内存移除。创建弱引用如下
四、怎么回收?垃圾收集算法
4.1 标记-清除 算法
算法分为“标记”和“清除”两个阶段:
- 标记出所有需要回收的对象;
- 回收被标记的对象。
它的主要不足有两个:
- 效率问题, 标记和清除效率都不高;
- 空间问题, 标记清除之后会产生大量不连续的内存碎片,无法找到足够的连续内存会提前触发另一次垃圾收集动作。
执行过程如图所示:
4.2 复制算法
为了解决效率问题, 一种称为“复制”( Copying) 的收集算法出现了。
它将可用内存划分为大小相等的两块, 每次只使用其中的一块。 当这一块的内存用完了, 就将还存活着的对象复制到另外一块上面, 然后再把已使用过的内存空间一次清理掉。 这样使得每次都是对整个半区进行内存回收, 内存分配时也就不用考虑内存碎片等复杂情况, 只要移动堆顶指针, 按顺序分配内存即可, 实现简单, 运行高效。
具体流程如图所示:
这个是堆内存,在这里,复制算法把它们分成了两块,
用的时候只用一块,在这里面用,在这里面去创建。
创建完之后,进行一次垃圾回收,比如说,把我们标记的这些都给回收掉了。
回收掉了之后怎么办呢?它不是像我们原来的那块区域一样把它直接干掉了剩下一对不连续的空间,它是把没有回收掉的扔到另外一块区域里面去,并且给它们排列好,那么这块内存就是连续的了,
然后把上面所没有用到的,就是说,复制到下面完了,而且把这些都给清除掉了。
这块内存区域就给清空了,然后接着再来分配空间,就在新的这块区域里面进行分配就行了
这就是所谓的复制算法,那么,这个算法呢,我们可以看到,效率问题可以解决了,但是又引入了一个新的问题,就是,内存区域同时用的只有一半,这样就会造成内存区域极大的浪费。(现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代, IBM公司曾有一项专门研究——新生代中的对象有98%熬不过第一轮收集。 因此并不需要按照1∶ 1的比例来划分新生代的内存空间)
4.3 改进!!!复制算法
Andrew Appel提出了一种更优化的半区复制分代策略, 现在称为“Appel式回收”。
- Appel式回收把新生代分为一块较大的Eden空间和两块较小的Survivor空间, 每次分配内存只使用Eden和其中一块Survivor。
- 发生垃圾搜集时, 将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上, 然后直接清理掉Eden和已用过的那块Survivor空间。
- HotSpot虚拟机默认Eden和Survivor的大小比例是8∶ 1, 也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%) , 只有一个Survivor空间, 即10%的新生代是会被“浪费”的。
- 当然, 98%的对象可被回收仅仅是“普通场景”下测得的数据, 任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活, 因此, 当Survivor空间不足以容纳一次Minor GC之后存活的对象时, 就需要依赖其他内存区域(实际上大多就是老年代) 进行分配担保(Handle Promotion)。
详细流程:
我们认为整个的堆内存分成了三块区域:
- 最大的这块区域就是伊甸园,所有创建的新对象都会往伊甸园中扔。
- 第二块叫Survivor,第三块区域同样也称之为Survivor。
我们再分出来一块
我们认为暂且是四块区域,我们看到实际上Survivor有两块区域,其实在Survivor,Eden,Tenured Gen这三块区域中:
- Eden这块区域一般是占到百分之八十以上
- Survivor这两块区域分别占百分之十
- 我们暂且不管、不用Tenured Gen。
后面,我们如果不够的时候,我们会去找Tenured Gen要内存,但是,一般情况下,我们暂时先不用Tenured Gen。
为什么会有两块Survivor区域呢?过程是这样的,首先是创建对象的时候,就扔到Eden中了,Eden中就会扔很多的对象,
如果这里面一旦满了的话,就会去使用任意一个Survivor
这个Survivor其实就类似于这两块区域
那么,接下来经过一次垃圾回收
- 这个过程会把eden区域的大部分对象都回收掉,我们认为,存活的对象一般是占到百分之十左右,那么,我们就把存活的对象移到这一块区域中来。
然后这一部分全部清除掉
然后,这一部分同样也清除掉
然后,下一次再进行对象创建的过程中,我们就把对象接着往Eden中分
存活的对象还在这里面
如果说
这里面分的内存达到一定限度了,或者说内存报警了,等等一些情况下,那么,垃圾回收又开始工作了,那么就会把底下存活的对象接着往右上角这里面去放
并且会检测这里面的对象是否依然存活,那么,如果多数存活,那么,可能就进入到这一块区域了
当然这个不一定,我们后面再去说。然后,接着把这里的存活对象
和这里的存活对象
诺到这里来
这样就完成了垃圾回收,我们发现,这样来进行使用的过程中,我们的内存并不会浪费太多,仅仅是浪费了百分之十,这个是可以接受的,这样既提高了效率,而且空间浪费也并不是特别大。
但是!!!并不一定这里
百分之十是存活的,如果,它有百分之二十存活,那么,挪到Survivor中,Survivor中是装不下的,装不下怎么办?你总不能直接报内存溢出,这是不可行的,这个时候就需要一个内存的担保,就是所谓的分配担保,其实这个就跟银行贷款是一样的,如果信誉好,有很高的情况下都能按时偿还,那么,银行在进行下一次的贷款的时候,可能找一个担保人就可以了,如果不能按时还款,银行就从担保人那里扣钱就可以了,内存分配担保也是一样的,如果这一块区域
已经不够了,放不下了,那么就扔到老年代里面去。
4.4 标记-整理算法(老年代算法)
复制收集算法在对象存活率较高时就要进行较多的复制操作, 效率将会变低。 更关键的是, 如果不想浪费50%的空间, 就需要有额外的空间进行分配担保, 以应对被使用的内存中所有对象都100%存活的极端情况, 所以在老年代一般不能直接选用这种算法。
根据老年代的特点, 有人提出了另外一种“标记-整理”( Mark-Compact) 算法, 标记过程仍然与“标记-清除”算法一样, 但后续步骤不是直接对可回收对象进行清理, 而是让所有存活的对象都向一端移动, 然后直接清理掉端边界以外的内存, “标记-整理”算法的示意图如图所示。
如果移动存活对象, 尤其是在老年代这种每次回收都有大量对象存活区域, 移动存活对象并更新引用是一种极为负重的操作, 而且这种对象移动操作必须全程暂停用户应用程序才能进行。
4.5 总结
但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。
- 譬如通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间, 能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的) 。 但是内存的访问是用户程序最频繁的操作, 甚至都没有之一, 假如在这个环节上增加了额外的负担, 势必会直接影响应用程序的吞吐量。
基于以上两点, 不论是否移动对象都存在弊端:
- 移动则内存回收时会更复杂;
- 不移动则内存分配时会更复杂。
从垃圾收集的停顿时间来看, 不移动对象停顿时间会更短, 甚至可以不需要停顿, 但是从整个程序的吞吐量来看, 移动对象会更划算。此语境中, 吞吐量的实质是赋值器(Mutator, 可以理解为使用垃圾收集的用户程序, 本书为便于理解, 多数地方用“用户程序”或“用户线程”代替) 与收集器的效率总和。 即使不移动对象会使得收集器的效率提升一些, 但因内存分配和访问相比垃圾收集频率要高得多, 这部分的耗时增加, 总吞吐量仍然是下降的。
另外, 还有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担, 让虚拟机平时多数时间都采用标记-清除算法, 暂时容忍内存碎片的存在, 直到内存空间的碎片化程度已经大到影响对象分配时, 再采用标记-整理算法收集一次, 以获得规整的内存空间。
- 基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。
五、垃圾收集器
如果说收集算法是内存回收的方法论, 那垃圾收集器就是内存回收的实践者。 下面讲到的垃圾收集器都会涉及一些垃圾回收的算法,会用到上一章节讲到的知识。
5.1 Serial收集器
是新生代的垃圾收集器。
Serial收集器是最基础、 历史最悠久的收集器。这个收集器是一个单线程工作的收集器, 但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作, 更重要的是强调在它进行垃圾收集时, 必须暂停其他所有工作线程, 直到它收集结束。即 “Stop The World” ,举个例子:电脑每运行1小时就会暂停响应1分钟。。。其运行流程示意图如下:
虽然听起来很辣鸡,但是迄今为止, 它依然是HotSpot虚拟机运行在客户端模式下的默认新生代收集器, 因为简单而高效(与其他收集器的单线程相比)
Serial收集器由于没有线程交互的开销, 专心做垃圾收集自然可以获得最高的单线程收集效率。 在用户桌面的应用场景以及近年来流行的部分微服务应用中, 分配给虚拟机管理的内存一般来说并不会特别大, 收集几十兆甚至一两百兆的新生代(仅仅是指新生代使用的内存, 桌面应用甚少超过这个容量) , 垃圾收集的停顿时间完全可以控制在十几、 几十毫秒, 最多一百多毫秒以内, 只要不是频繁发生收集, 这点停顿时间对许多用户来说是完全可以接受的。 所以,Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。
5.2 ParNew收集器
是新生代的垃圾收集器。
ParNew收集器实质上是Serial收集器的多线程并行版本, 除了同时使用多条线程进行垃圾收集之外, 其余的行为包括Serial收集器可用的所有控制参数(例如: -XX: SurvivorRatio、 -XX:PretenureSizeThreshold、 -XX: HandlePromotionFailure等) 、 收集算法、 Stop The World、 对象分配规
则、 回收策略等都与Serial收集器完全一致, 在实现上这两种收集器也共用了相当多的代码。 ParNew收集器的工作过程如图3-8所示。
ParNew收集器除了支持多线程并行收集之外, 其他与Serial收集器相比并没有太多创新之处, 但它却是不少运行在服务端模式下的HotSpot虚拟机。其中一个重要的原因是: 除了Serial收集器外, 目前只有它能与CMS收集器配合工作。
- CMS收集器(作为老年代的收集器)
这款收集器是HotSpot虚拟机中第一款真正意义上支持并发的垃圾收集器, 它首次实现了让垃圾收集线程与用户线程(基本上) 同时工作。
可以说直到CMS的出现才巩固了ParNew的地位, 但成也萧何败也萧何, 随着垃圾收集器技术的不断改进,ParNew和CMS从此只能互相搭配使用, 再也没有其他收集器能够和它们配合了。 读者也可以理解为从此以后, ParNew合并入CMS, 成为它专门处理新生代的组成部分。 ParNew可以说是HotSpot虚拟机中第一款退出历史舞台的垃圾收集器。
5.3 Parallel Scavenge收集器
Parallel Scavenge收集器也是一款新生代收集器, 它同样是基于标记-复制算法实现的收集器, 也是能够并行收集的多线程收集器……Parallel Scavenge的诸多特性从表面上看和ParNew非常相似, 那它有什么特别之处呢?
Parallel Scavenge收集器的特点是它的关注点与其他收集器不同:
- CMS等收集器的关注点是尽可能地缩短停顿时间。
- 而Parallel Scavenge收集器的目标则是达到一个可控制(我想短就短,想长就长)的吞吐量(Throughput) 。 所谓吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值,即:
如果虚拟机完成某个任务, 用户代码加上垃圾收集总共耗费了100分钟, 其中垃圾收集花掉1分钟, 那吞吐量就是99%。 那吞吐量的高低有什么区别呢?
- 停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序, 良好的响应速度能提升用户体验;
- 而高吞吐量则可以最高效率地利用处理器资源, 尽快完成程序的运算任务, 主要适合在后台运算而不需要太多交互的分析任务(说白了吞吐量为100%,那么就是一直在执行代码了)。
Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量, 分别是控制最大垃圾收集停顿时间的-XX: MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX: GCTimeRatio参数。(细节我就不讲了,太细了…)
由于与吞吐量关系密切, Parallel Scavenge收集器也经常被称作“吞吐量优先收集器”。
5.4 Serial Old收集器
Serial Old是Serial收集器的老年代版本, 它同样是一个单线程收集器, 使用标记-整理算法。 这个收集器的主要意义也是供客户端模式下的HotSpot虚拟机使用。 Serial Old收集器的工作过程如图3-9所示。
5.5 Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本, 支持多线程并发收集, 基于标记-整理算法实现。 这个收集器是直到JDK 6时才开始提供的, 在此之前, 新生代的Parallel Scavenge收集器一直处于相当尴尬的状态, 原因是如果新生代选择了Parallel Scavenge收集器, 老年代除了Serial Old(PS MarkSweep) 收集器以外别无选择, 其他表现良好的老年代收集器, 如CMS无法与它配合工作。 由于老年代Serial Old收集器在服务端应用性能上的“拖累”, 使用Parallel Scavenge收集器也未必能在整体上获得吞吐量最大化的效果。 同样, 由于单线程的老年代收集中无法充分利用服务器多处理器的并行处理能力, 在老年代内存空间很大而且硬件规格比较高级的运行环境中, 这种组合的总吞吐量甚至不一定比ParNew加CMS的组合来得优秀。
直到Parallel Old收集器出现后, “吞吐量优先”收集器终于有了比较名副其实的搭配组合, 在注重吞吐量或者处理器资源较为稀缺的场合, 都可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。 Parallel Old收集器的工作过程如图3-10所示。
5.6 CMS收集器
CMS(Concurrent Mark Sweep) 收集器是一种以获取最短回收停顿时间为目标的收集器。 目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上, 这类应用通常都会较为关注服务的响应速度, 希望系统停顿时间尽可能短, 以给用户带来良好的交互体验。 CMS收集器就非常符合这类应用的需求。
从名字(包含“Mark Sweep”) 上就可以看出CMS收集器是基于标记-清除算法(就是最简单的那个GC算法…)实现的, 它的运作过程分为四个步骤, 包括:
- 初始标记(CMS initial mark):初始标记仅仅只是标记一下GCRoots能直接关联到的对象, 速度很快;
- 并发标记(CMS concurrent mark):并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程, 这个过程耗时较长但是不需要停顿用户线程, 可以与垃圾收集线程一起并发运行;
- 重新标记(CMS remark):重新标记阶段则是为了修正并发标记期间, 因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录, 这个阶段的停顿时间通常会比初始标记阶段稍长一些, 但也远比并发标记阶段的时间短;
- 并发清除(CMS concurrent sweep):最后是并发清除阶段, 清理删除掉标记阶段判断的已经死亡的对象, 由于不需要移动存活对象, 所以这个阶段也是可以与用户线程同时并发的。
其中初始标记1、 重新标记2 这两个步骤仍然需要“Stop The World”。
由于在整个过程中耗时最长的并发标记和并发清除阶段中, 垃圾收集器线程都可以与用户线程一起工作, 所以从总体上来说, CMS收集器的内存回收过程是与用户线程一起并发执行的。 通过图3-11可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的阶段。
CMS是一款优秀的收集器, 它最主要的优点在名字上已经体现出来: 并发收集、 低停顿, 一些官方公开文档里面也称之为“并发低停顿收集器”(Concurrent Low Pause Collector) 。 CMS收集器是HotSpot虚拟机追求低停顿的第一次成功尝试, 但是它还远达不到完美的程度, 至少有以下三个明显的缺点:
一、CMS收集器对处理器资源非常敏感。 事实上, 面向并发设计的程序都对处理器资源比较敏感。 在并发阶段, 它虽然不会导致用户线程停顿, 但却会因为占用了一部分线程(或者说处理器的计算能力) 而导致应用程序变慢, 降低总吞吐量。 CMS默认启动的回收线程数是(处理器核心数量
+3) /4, 也就是说, 如果处理器核心数在四个或以上, 并发回收时垃圾收集线程只占用不超过25%的处理器运算资源, 并且会随着处理器核心数量的增加而下降。 但是当处理器核心数量不足四个时,CMS对用户程序的影响就可能变得很大。 如果应用本来的处理器负载就很高, 还要分出一半的运算能
力去执行收集器线程, 就可能导致用户程序的执行速度忽然大幅降低。
为了缓解这种情况, 虚拟机提供了一种称为“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)的CMS收集器变种,所做的事情和以前单核处理器年代PC机操作系统靠抢占式多任务来模拟多核并行多任务的思想一样,是在并发标记、清理的时候让收集器线程、 用户线程交替运行, 尽量减少垃圾收集线程的独占资源的时间, 这样整个垃圾收集的过程会更长,但对用户程序的影响就会显得较少一些, 直观感受是速度变慢的时间更多了, 但速度下降幅度就没有那么明显。实践证明增量式的CMS收集器效果很一般, 从JDK 7开始, i-CMS模式已经被声明为“deprecated”,即已过时不再提倡用户使用, 到JDK 9发布后iCMS模式被完全废弃。
二、 由于CMS收集器无法处理“浮动垃圾”(Floating Garbage) , 有可能出现“Con-current ModeFailure”失败进而导致另一次完全“Stop The World”的Full GC的产生。 在CMS的并发标记和并发清理阶段, 用户线程是还在继续运行的, 程序在运行自然就还会伴随有新的垃圾对象不断产生, 但这一部分
垃圾对象是出现在标记过程结束以后, CMS无法在当次收集中处理掉它们, 只好留待下一次垃圾收集时再清理掉。 这一部分垃圾就称为“浮动垃圾”。 同样也是由于在垃圾收集阶段用户线程还需要持续运行, 那就还需要预留足够内存空间提供给用户线程使用, 因此CMS收集器不能像其他收集器那样等待
到老年代几乎完全被填满了再进行收集, 必须预留一部分空间供并发收集时的程序运作使用。 在JDK5的默认设置下, CMS收集器当老年代使用了68%的空间后就会被激活, 这是一个偏保守的设置, 如果在实际应用中老年代增长并不是太快, 可以适当调高参数-XX: CMSInitiatingOccu-pancyFraction的值来提高CMS的触发百分比, 降低内存回收频率, 获取更好的性能。 到了JDK 6时, CMS收集器的启动阈值就已经默认提升至92%。 但这又会更容易面临另一种风险: 要是CMS运行期间预留的内存无法满足程序分配新对象的需要, 就会出现一次“并发失败”(Concurrent Mode Failure) , 这时候虚拟机将不得不启动后备预案: 冻结用户线程的执行, 临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。 所以参数-XX: CMSInitiatingOccupancyFraction设置得太高将会很容易导致大量的并发失败产生, 性能反而降低, 用户应在生产环境中根据实际应用情况来权衡设置。
三、还有最后一个缺点——内存碎片。在本节的开头曾提到, CMS是一款基于“标记-清除”算法实现的收集器, 如果读者对前面这部分介绍还有印象的话, 就可能想到这意味着收集结束时会有大量空间碎片产生。 空间碎片过多时, 将会给大对象分配带来很大麻烦, 往往会出现老年代还有很多剩余空间, 但就是无法找到足够大的连续空间来分配当前对象, 而不得不提前触发一次Full GC的情况。
为了解决这个问题,CMS收集器提供了一个-XX: +UseCMS-CompactAtFullCollection开关参数(默认是开启的,此参数从JDK 9开始废弃) , 用于在CMS收集器不得不进行Full GC时开启内存碎片的合并整理过程,由于这个内存整理必须移动存活对象, (在Shenandoah和ZGC出现前) 是无法并发的。 这样空间碎片问题是解决了,但停顿时间又会变长, 因此虚拟机设计者们还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction(此参数从JDK9开始废弃) , 这个参数的作用是要求CMS收集器在执行过若干次(数量由参数值决定) 不整理空间的Full GC之后, 下一次进入FullGC前会先进行碎片整理(默认值为0, 表 示每次进入Full GC时都进行碎片整理) 。
六、内存回收策略
6.1 对象优先在Eden分配
大多数情况下, 对象在新生代Eden区中分配。 当Eden区没有足够空间进行分配时, 虚拟机将发起一次Minor GC。
6.2 大对象直接进入老年代
大对象直接进入老年代大对象就是指需要大量连续内存空间的Java对象, 最典型的大对象便是那种很长的字符串, 或者元素数量很庞大的数组, 本节例子中的byte[]数组就是典型的大对象。 在Java虚拟机中要避免大对象的原因是, 在分配空间时, 它容易导致内存明明还有不少空间时就提前触发垃圾收集。
6.3 长期存活的对象将进入老年代
HotSpot虚拟机中多数收集器都采用了分代收集来管理堆内存, 那内存回收时就必须能决策哪些存活对象应当放在新生代, 哪些存活对象放在老年代中。 为做到这点, 虚拟机给每个对象定义了一个对象年龄(Age) 计数器, 存储在对象头中 。
对象通常Eden区里诞生, 如果经过第一次Minor GC后仍然存活, 并且能被Survivor容纳的话, 该对象会被移动到Survivor空间中, 并且将其对象年龄设为1岁。 对象在Survivor区中每熬过一次Minor GC, 年龄就增加1岁, 当它的年龄增加到一定程度(默认为15) , 就会被晋升到老年代中。 对象晋升老年代的年龄阈值, 可以通过参数-XX:MaxTenuringThreshold设置。
6.4 动态对象年龄判定
HotSpot虚拟机并不是永远要求对象的年龄必须达到-XX: MaxTenuringThreshold才能晋升老年代, 如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半, 年龄大于或等于该年龄的对象就可以直接进入老年代, 无须等到要求的年龄。
6.5 空间分配担保
在发生Minor GC之前, 虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间, 如果是, 那这一次Minor GC可以确保是安全的。 如果不成立, 则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure) ; 如果允许, 那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小, 如果大于, 将尝试进行一次Minor GC, 尽管这次Minor GC是有风险的; 如果小于, 或者-XX:HandlePromotionFailure设置不允许冒险, 那这时就要改为进行一次Full GC。
- 从年轻代空间(包括 Eden 和 Survivor 区域)回收内存被称为 Minor GC;
- 对老年代GC称为Major GC;
- 而FullGC是对整个堆来说的;
前面提到过, 新生代使用复制收集算法, 但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份, 因此当出现大量对象在Minor GC后仍然存活的情况——最极端的情况就是内存回收后新生代中所有对象都存活, 需要老年代进行分配担保, 把Survivor无法容纳的对象直接送入老年代, 这与生活中贷款担保类似。 老年代要进行这样的担保, 前提是老年代本身还有容纳这些对象的剩余空间, 但一共有多少对象会在这次回收中活下来在实际完成内存回收之前是无法明确知道的, 所以只能取之前每一次回收晋升到老年代对象容量的平均大小作为经验值, 与老年代的剩余空间进行比较, 决定是否进行Full GC来让老年代腾出更多空间。
取历史平均值来比较其实仍然是一种赌概率的解决办法, 也就是说假如某次Minor GC存活后的对象突增, 远远高于历史平均值的话, 依然会导致担保失败。 如果出现了担保失败, 那就只好老老实实地重新发起一次Full GC, 这样停顿时间就很长了。 虽然担保失败时绕的圈子是最大的, 但通常情况下
都还是会将-XX: HandlePromotionFailure开关打开, 避免Full GC过于频繁。