深入了解JVM垃圾收集机制
深入了解JVM垃圾收集机制
Java之所以风靡,很大程度上得益于它的垃圾收集机制(Garbage Collection),在大多数时候,Java程序员不再需要像C/C++程序员一样每开辟一块空间都需要去关注他的使用情况以防止内存泄漏,大多数时候垃圾收集器可以帮我们解决内存管理的工作,然而这并不意味着Java不会出现内存泄漏的情况,因此我们有必要去了解垃圾收集机制的原理,当出现内存泄漏的情况可以更快速的进行手动排查。
想要弄清楚垃圾收集器如何工作,我们需要了解一下几个问题:
- 哪些内存需要被回收(如何判断一块内存是否可以回收)
- 何时进行垃圾回收
- 如何进行回收
一、哪些内存需要被回收?
与C/C++中一样,我们需要回收的是那些已经不再使用的内存,那么如何判断一块内存已经不再需要使用?首先需要知到的是垃圾收集行为主要发生在堆中,当然方法区也存在垃圾收集行为,但探讨的重点依然是堆区
1、引用计数法
引用计数法的思想十分简单,为每一个对象添加一个引用计数器,当有某某处引用了该对象时,计数器加一,引用失效时则计数器减一,当引用计数器为0时即意味着这一块内存已不再使用可以回收
引用计数器的缺陷:
乍一看引用计数法似乎是一个简单并且高效的方法,也不会占用过多额外内存,但它存在着一个致命的缺陷——互相引用(循环引用)。例如下面这段代码,GCTest中有一个Object属性,GCTest的实例a的instance引用了b,而b的instance则引用了a,这样即使执行了a = null;和b = null;两个对象内部依然维持着相互引用,垃圾收集器也没有办法收集这两块区域,因此Java的垃圾收集器没有采用这种方法
public class GCTest {
public Object instance;
public static void main(String[] args) {
GCTest a = new GCTest();
GCTest b = new GCTest();
a.instance = b;
b.instance = a;
a = null;
b = null;
}
}
2、可达性分析算法
可达性分析算法又称根搜索方法,其基本思想史使用一些对象作为根节点(GC roots),从GC roots开始向下搜索对象的引用情况,其搜索过的路径称为引用链,若一个对象到所有的GC roots之间都不存在任何引用链,则证明该对象是不可用的。例如下图中,Object1、Object2、Object3与GC roots之间存在引用链,这三个对象均不会被回收,而Object4与Object5之间虽然存在引用,但这两个对象与GC roots之间不存在引用链,因此会被判定为不可用对象
可以作为GC roots的对象:
- 虚拟机栈中的引用对象
- 方法区中的类静态属性引用的对象
- 方法区中的常量引用的对象
- 本地方法栈中JNI(即native方法)引用的对象
3、方法区的回收
在《Java虚拟机规范》中提到过可以不要求方法区实现垃圾收集,也的确存在未实现或未完整实现方法区垃圾回收的收集器,但我们还是有必要了解一下。方法区的垃圾回收性价比是比较低的,其回收的主要内容是废弃的常量和不再使用的类型。
判定一个常量是否废弃,只需要查看是否还有任何字符串对象引用了常量池中的该常量,以及虚拟机中是否有其他地方引用这个字面量,若均没有,且垃圾收集器判断有必要的情况下,该常量就会被回收
判定一个类是否不再使用则较为复杂,需要同时满足以下三个条件:
- 该类所有的实例都已经被回收,堆中不存在该类及其任何派生子类的实例
- 该类的类加载器已被回收,这个条件除非是经过精心设计的可替换类加载器的场景,否则很难达成
- 该类对应的Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
二、如何进行回收——垃圾收集算法
1、标记——清除算法
标记——清除算法是最为简单的垃圾收集算法,分为标记与清除两个阶段。标记阶段会将所有可回收的对象进行标记,标记完成后统一回收所有被标记的对象
缺点:
- 效率低,在标记和清除两个阶段效率都不高
- 易产生内存碎片,在清除过后会存在大量的不连续空间
2、标记——复制算法
其基本思想是:将内存空间划分为大小相等的两个部分,每次只使用其中一个部分,当这一块部分已满时,则将存活的对象复制到另一块区域,并清空原来的内存空间。显然没一次进行垃圾收集时都会通过复制对内存进行整理,完美的解决了标记——清除算法易产生内存碎片的问题
**缺点:**可用内存被缩小为了原先的一半
目前商用Java虚拟机大多都优先采用标记复制算法。事实上新生代(新生代下面会讲)中的大多数对象都存活不过第一轮,因此标记复制算法采用的两个区域并不需要按1:1的内存比例来设计
3、标记——整理算法
复制算法虽然解决了内存碎片的问题,但在对象存活率较高的情况下,每次清理就会进行大量的复制操作从而降低清理效率,并且也会浪费大量的内存空间,因此应运而生了标记——整理算法
标记——整理算法的思想也十分简单,与标记——清楚算法相似的地方是都需要先对对象进行标记,标记出可回收的内存区域,不同点在于,当标记完成后不会对标记的对象进行清理,而是让存活对象向一端移动,所有对象都移动完成后清理掉边界以外的内存
三、何时进行回收
1、触发垃圾回收的条件
- 当应用程序空闲时,即没有应用线程在运行时,GC会被调用。垃圾收集器所在的线程优先级最低,因此当应用忙时,GC线程一般不会被调用,当然内存不足时除外
- 堆内存不足,即无法创建新的对象时,JVM会强制调用GC线程。若一次垃圾回收之后依然无法满足内存分配的要求,JVM会进行第二次GC,若仍无法满足要求,则抛出OutOfMemoryError异常
- 调用System.gc()方法,调用System.gc()方法并不会触发垃圾收集,该方法并不会触发垃圾收集,而是一种提醒或者建议,建议JVM进行一次垃圾收集行为,调用System.gc并不会保证垃圾收集行为一定发生,何时进行垃圾收集行为总是取决于JVM
2、进一步了解引用
无论是引用标记算法还是可达性分析算法,都是围绕引用吧 来进行讨论的,那么我们就有必要更深入的了解一下引用
在jdk1.2之前,引用被定义为:若reference类型的数据中存储的是另一块内存的起始地址,则这一块内存酒杯称为引用。而在jdk1.2之后,引用被详细分为了强引用、软引用、弱引用和虚引用四种以更灵活的对内存进行管理
- 强引用:强引用即我们最常见的类似于“Object o = new Object()"的引用。如果一个对象具有强引用,它就不会被垃圾回收器回收,即使出现内存不足的情况,JVM会抛出OutOfMemoryError异常,而不会去回收具有强引用对象。如果想要中断强引用与对象实例的关联,可以将引用赋值为null
- 软引用:软引用被用来描述那些还有用但非必须的对象,对于软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列入回收范围之内并进行第二次回收。若这次回收依然没有足够的内存才会抛出内存溢出异常。jdk1.2之后使用WeakReference来实现软引用
String str = "hello";
//软引用的创建
SoftReference<String> softReference = new SoftReference<String>(str);
//通过软引用获取对象
String str2 = softReference.get();
- 弱引用:弱引用的强度比软引用更弱一些,同样用来描述非必须的对象,被软引用关联的对象只能生存到下一次垃圾回收发生之前,当垃圾收集器工作时,无论当前内存是否充足,弱引用对象都会被回收。jdk1.2之后使用WeakReference来实现弱引用
String str = "hello";
//软引用的创建
WeakReference<String> weakReference = new WeakReferencee<String>(str);
//通过软引用获取对象
String str2 = weakReference.get();
- 虚引用:虚引用是最弱的一种引用关系,一个对象是否存在虚引用并不影响其生存时间,也无法通过虚引用来获得一个对象实例。虚引用的目的只是希望当这个对象被回收时可以收到一个系统通知。jdk1.2之后使用PhantomReference来实现虚引用
- 引用队列:引用队列用于保存被回收后对象的引用,可以配合软引用、弱引用和虚引用使用,对引用进行监控。(其中虚引用必须和引用队列一起使用,而引用队列对于弱引用与软引用则不是必要的)若引用队列与引用配合使用,当发生垃圾收集行为,被回收掉的对象对应的软引用、弱引用和虚引用就会被加入到引用队列中
例如下面这段代码,我们创建四个String对象,分别对应强应用、软引用、弱引用和虚引用,其中后三者使用引用队列进行创建,然后将str2、str3和str4释放,调用System.gc()尝试进行一次垃圾收集,垃圾收集完成后从引用队列中取出指向的对象已被回收的引用
public class GCTest extends Object {
public static void main(String[] args){
String str1 = new String("hello");
String str2 = new String("hello");
String str3 = new String("hello");
String str4 = new String("hello");
ReferenceQueue<String> queue = new ReferenceQueue<>();
SoftReference<String> softReference = new SoftReference<String>(str2,queue);
WeakReference<String> weakReference = new WeakReference<String>(str3,queue);
PhantomReference<String> phantomReference = new PhantomReference<String>(str4,queue);
str2 = null;
str3 = null;
str4 = null;
System.gc();
Reference reference;
System.out.println(str1);
try {
while ((reference = queue.poll()) != null) {
System.out.println(reference);
}
}catch (Exception e){}
}
}
运行结果如下,字符串1倍强引用维持显然不可能被回收,之后从引用队列中取出了一个弱引用和一个虚引用,显然如上面提到的,弱引用和虚引用不影响对象的生命周期
[GC (System.gc()) 5243K->976K(251392K), 0.0012138 secs]
[Full GC (System.gc()) 976K->763K(251392K), 0.0051999 secs]
hello
java.lang.ref.WeakReference@4554617c
java.lang.ref.PhantomReference@74a14482
3、finalize方法
事实上被判为不可达的对象并不是非死不可的,想要完全判定一个对象死亡,通常至少要经过两个阶段。若一个对象被发现与GC roots之间不存在引用链,则会被第一次标记为不可达并且进行一次筛选,检查该对象是否有必要执行finalize方法。当该对象没有覆盖finalize方法或者其finalize方法已被执行过,则都将被虚拟机视为没有必要执行的情况。
若对象被判定为有必要执行finalize方法,则该对象会被放入一个名为F-Queue的队列中,并在稍后由一条虚拟机自动建立的低优先级线程Finalizer去触发队列中的对象的finalize方法。要注意的是Finalizer线程仅仅会去触发对象的finalize方法,而不会保证finalize方法的执行。
finalize方法时Object类的成员方法,因此所有对象都拥有finalize方法,但finalize方法是一个空方法,若我们希望它被虚拟机调用,则需要覆盖它。
finalize方法可以看做对象逃脱死亡命运的最后一次机会,也可以用来在对象死亡之前进行资源的释放与清理。在第一次标记过后GC会对F-Queue中的对象进行第二次的小规模标记,若对象在finalize方法中与任何引用链上的对象建立了关联,那么它就会暂时被移除需要回收的对象集合中,否则该对象就会被回收。此外要注意的是任何一个对象的finalize方法在其生命周期中只可能被调用一次,若一个对象第二次面临被回收,则其finalize方法不会执行,该对象也会被回收。我们来看以下GCTest类,我们覆写了finalize方法,在finalize方法中将对象与静态属性建立引用
public class GCTest extends Object {
public static GCTest hook = null;
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("执行finalize方法");
hook = this;
}
public void isAlive(){
System.out.println("对象存活");
}
public static void main(String[] args) throws InterruptedException {
GCTest gcTest = new GCTest();
gcTest = null;
System.gc();
Thread.sleep(500);
if (hook != null){
hook.isAlive();
}else{
System.out.println("对象已被回收");
}
hook = null;
System.gc();
if (hook != null){
hook.isAlive();
}else{
System.out.println("对象已被回收");
}
}
}
执行结果如下,第一次回收后,对象被hook关联,而第二次回收后则没有
[GC (System.gc()) 1980K->856K(19968K), 0.0013165 secs]
[Full GC (System.gc()) 856K->757K(19968K), 0.0049851 secs]
执行finalize方法
对象存活
[GC (System.gc()) 1233K->949K(19968K), 0.0009255 secs]
[Full GC (System.gc()) 949K->789K(19968K), 0.0074109 secs]
对象已被回收
最后要注意的是,finalize方法的运行代价较高且不确定性大,虽然可以用来拯救对象或者关闭资源,但它能做到的事情,通常使用try-finally语句都能实现,因此并不推荐使用finalize方法
四、分代收集理论——对象的分配与回收
1、理论前提——几个假说
- 弱分代假说:绝大多数对象都是朝生夕灭的
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消灭
这两个分代假说理论奠定了常用垃圾收集器一致的设计原则:收集器应该将Java堆划分为不同的区域,然后将回收的对象依据其年龄分配到不同的区域之中存储。
这样做的好处在于提高效率:若一个区域中的对象大多都是朝生夕灭的,那么每次垃圾收集过程中只需要关注如何保留少量存活的对象而不是去标记那些大量将要被回收的对象,可以以较低的代价回收到大量的空间;若剩下的都是难以消亡的对象,那么把他们集中放在一起,虚拟机便能使用较低的频率来回收这个区域。
根据上面的理论,Java堆通常至少被划分为新生代(Young Generation)和老年代(Old Generation)两个部分。其中新生代中每次发生垃圾收集行为都会有大批对象死去,每次回收存活的少量的对象则会逐步晋升到老年代
- 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。
跨代引用假说事实上是由前两条假说推理得出的隐含推论,其表明互相引用关系的两个对象,是应该倾向于同时生存或同时消亡的,因此就没有必要在为了少量的跨代引用扫描整个老年代,也不需要浪费空间专门记录每一个对象是否存在跨代引用,只需要在新生代建立一个全局的数据结构——记忆集(Remembered Set),用来将老年代划分为若干个部分,标识出那一块可能存在跨代引用,这样每次收集新生代时只需要将存在跨代引用的块中的老年代对象加入到GC Roots进行扫描。但这种方法的缺点在于在对象引用关系发生改变时需要维护记录数据的正确性
2、新生代与老年代
- 新生代(Young Generation):用来存放新生对象,通常占据堆内存的1/3空间,垃圾收集行为频繁。新生代的垃圾回收采用标记复制算法,新生代被分为一个Eden区域和两个Survivor区域(这两个区域被命名为from和to),他们的大小通常为8:1:1。
- 老年代(Old Generation/Tenured Space):老年代占用堆内存的2/3,存放生命周期较长的对象,垃圾回收相位不频繁,老年代一般使用标记整理算法进行回收
3、垃圾收集行为的分类
- 新生代收集(Minor GC/Young GC):目标只是新生代的收集
- 老年代收集(Major GC/Old GC):目标是老年代的垃圾收集(除了CMS收集器外其他收集器不存在但对收集老年代的行为)
- 混合收集(Mixed GC):目标是收集整个新生代以及部分老年代的垃圾收集(目前只有G1收集器存在这种行为)
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
4、从垃圾收集的角度看对象的生命周期
a、对象的创建
- 对象优先在Eden区分配
大多数情况下,对象在新生代Eden区进行分配,当Eden区没有足够的空间进行分配时,虚拟机将会发起一次Minor GC,若Minor GC后,Eden空间仍然不够,那么对象就会被放入Survivor区域
- 大对象直接进入老年代
大对象指的是需要大量在连续内存空间的Java对象,最典型的大对象即长度很长的字符串,或者元素数量庞大的数组。在写程序时应当尽量避免创建大对象,大对象在分配空间时容易导致内存明明还有较多空间时就提前触发垃圾收集以获取足够的连续空间存放他们,此外在复制时大对象也会导致高额的内存开销
例如下面这段代码,先对虚拟机参数进行设置,-Xms20m -Xmx20m -Xmn10m将Java堆容量设置为20m,禁止自动扩容,且新生代容量为10m,剩余的10m分配给老生代;-XX:SurvivorRatio=8将Eden与Survivor的内存比例设置为8:1;-XX:+PrintGCDetails设置虚拟机打印GC细节。
/*
虚拟机参数:
-Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8 -XX:+PrintGCDetails
*/
public class Test {
public static void main(String[] args) {
byte[] bytes = new byte[6*1024*1024];
}
}
我们尝试创建一个大小为6m的数组,可以从执行结果看到,新生代占用了百分之三十,而老年代占用了百分之六十,显然数组对象被分配到了老年代
Heap
PSYoungGen total 9216K, used 2487K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 30% used [0x00000000ff600000,0x00000000ff86df90,0x00000000ffe00000)
from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
to space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
ParOldGen total 10240K, used 6144K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 60% used [0x00000000fec00000,0x00000000ff200010,0x00000000ff600000)
Metaspace used 3229K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 352K, capacity 388K, committed 512K, reserved 1048576K
b、Minor GC时发生了什么
Minor GC是针对新生代的垃圾收集行为,上面介绍过新生代采用的是标记复制算法,事实上两个Survivor区就是为标记——复制算法准备的。当垃圾收集发生时,垃圾收集器会扫描Eden和Survivor From Space区域,若发现对象存活,则将该对象复制到Survivor ToSpace区域中,若Survivor ToSpace区域没有足够的空间则直接复制到老年代。扫描结束后垃圾收集器将清空Eden和Survivor FormSpace并交换Survivor FromSpace和Survivor ToSpace的角色。因此新生代同一时刻只有两个区域在使用,新生代的实际可用内存空间也为新生代总内存空间的90%
c、长期存活对象的晋升
每一个对象都拥有一个年龄计数器,存储在对象头中,对象通常在Eden区诞生,若经过第一次Minor GC后该对象仍然存活,且能被Survivor容纳,则该对象会被移动到Survivor中,并且其年龄会被设为1。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当对象年龄达到阈值(默认为15),就会被晋升到老年代。对象年龄阈值可以使用-XX:MaxTenuringThreshold设置
事实上,HotSpot虚拟机并不是始终要求对象的年龄达到一个固定的数值才能晋升老年代,若在Survivor中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代
d、何时发生Full GC——空间分配担保
如果我们需要去银行贷款,并且个人的信用很好,大部分情况下都能及时偿还,于是银行借给我们钱的同时,会需要我们有一个担保人保证我们能按时偿还,若不能按时偿还,则可以从担保人的账户进行扣款,以此降低风险。
事实上在垃圾收集时也需要有这么一个担保。在进行Minor GC时若Survivor控件不足以容纳一次Minor GC之后存活的对象,就需要依赖老年代来进行分配担保。
在Minor GC发生前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总和,若大于,则此次的Minor GC行为可以确保是安全的。否则,虚拟机会先查看-XX:HandlePromotionFailure参数设置的值是否允许担保失败,若允许,则会检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,则会尝试进行一次Minor GC,若小于,或-XX:HandlePromotionFailure设置不允许冒险,则进行一次Full GC。
上面的尝试进行一次Minor GC,事实上是有风险的行为,使用老年代进行担保的前提是老年代还有足够的空间,但具体有多少对象存活,这在Minor GC发生之前都是未知的,只能通过以往的平均水平来做推断,决定是否需要进行Full GC,但如果遇到比较糟糕的情况,例如最极端的所有新生代对象都存活,那么只能重新发起一次Full GC,显然这会导致运行效率下降。虽然担保失败代价很大,但通常情况下还是会打开-XX:HandlePromotionFailure开关,避免频繁的Full GC
五、经典的垃圾收集器
HotSpot虚拟机中的垃圾收集器
1、Serial收集器
Serial收集器是最基础、历史最悠久的收集器,在jdk1.3之前是HotSpot虚拟机新生代唯一的收集器。Serial收集器是一个单线程的收集器,它最大的特点是“Stop the world”,即在进行垃圾收集时,必须暂停其他所有工作线程,直到它完成收集工作。它的却带也是显而易见的,由于垃圾收集行为完全是由虚拟机控制的,对于用户和开发者来说完全是不可知不可控的情况,因此对很多后台应用都会带来一定的麻烦。
虽然如此,但Serial收集器依然是如今HotSpot虚拟机在客户端模式下的默认新生代收集器,其最大的优点在于简单高效,对于内存资源受限的环境,Serial收集器是所有收集器里额外内存消耗最小的
2、ParNew收集器
ParNew收集器实际上是Serial收集器的多线程并行版本,同样是一个Stop The World的收集器,除了同时使用多条线程进行垃圾收集之外,其余的行为,包括控制参数、收集算法、对象分配规则、回收策略等都与Serial收集器完全一致
ParNew收集器与Serial相比除了多线程外并没有太多创新之处,但它被广泛运用在运行在服务器模式下的HotSpot虚拟机,其最主要的原因之一就是除了Serial之外,目前只有ParNew可以与CMS收集器配合工作
3、Parallel Scavenge收集器
Parallel Scavenge收集器是一个新生代收集器,其同样基于标记复制算法实现,支持多线程。Parallel Scavenge收集器与其他收集器相比,最大的特点在于,相较于缩短垃圾收集时的停顿时间,它更关注垃圾收集行为是否能达到一个可控制的吞吐量。吞吐量指的是处理器用于运行客户代码的时间与处理器总消耗时间的比值
吞
吐
量
=
运
行
用
户
代
码
时
间
运
行
用
户
代
码
时
间
+
运
行
垃
圾
收
集
的
时
间
吞吐量=\frac{运行用户代码时间}{运行用户代码时间+运行垃圾收集的时间}
吞吐量=运行用户代码时间+运行垃圾收集的时间运行用户代码时间
高吞吐量意味着可以以高效率的利用处理器资源,尽快的完成运算任务,适合在后台运算而不需要太多交互的分析任务。
- 吞吐量参数
Parallel Scavenge收集器提供了两个用于精准控制吞吐量的参数,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMullis参数和直接设置吞吐量大小的-XX:GCTimeRatio参数
-XX:MaxGCPauseMillis参数允许设置一个大于0 的毫秒数,收集器将尽力保证内存回收花费的时间不超过该值,但这并不意味这这个值设置的越小,垃圾回收行为就越快,垃圾收集停顿时间是以牺牲吞吐量和新生代空间为代价换取的,若这个最大值设置的太小了,单次垃圾回收行为也许会变快,但也会导致垃圾回收行为变得频繁,从而降低吞吐量
-XX:GCTimeRatio参数应当是一个(0,100)内的整数,其表示垃圾收集时间占总时间的比率,若我们记该参数为n,那么最大垃圾收集时间占总时间的比率即为 1 1 + n \frac{1}{1+n} 1+n1
由于与吞吐量关系密切,Paralel Scavenge收集器还常常被称作“吞吐量优先收集器”。此外Parallel Scavenge收集器还有一个重要的参数-XX:+UseAdaptiveSizePolicy,显然这是一个和动态内存大小相关的参数,当该参数被打开后,就不再需要人工置顶新生代大小、Eden和Survivor的比例以及晋升老年代对象大小等参数,虚拟机会根据系统当前的运行情况收集性能监控信息,动态调节这些参数以提供最合适的提顿时间或最大吞吐量。这种垃圾收集行为成为自适应调节策略
4、Serial Old收集器
Serial Old是Serial收集器的老年代版本,同样是一个单线程收集器,采用标记整理算法,同样的主要也是为客户端模式下的虚拟机使用
5、Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,采用标记整理算法,支持多线程并发收集
6、CMS收集器
CMS(Concurrent Mark Sweep)收集器是一种以获得最短回收停顿时间为目标的收集器,通常被广泛应用于互联网网站或基于浏览器的B/S系统的服务端,这类应用通常较为关注响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验
- CMS的运行过程
CMS收集器采用标记清除算法,其具体运行过程相较其他收集器较为复杂,包括:初始标记(CMS initial mark)、并发标记(CMS concurrent mark)、重新标记(CMS remark)和并发清除(CMS concurrent sweep)四个步骤。其中初始标记与重新标记两个过程依然是Stop The World。初始标记会标记出于GC Roots直接关联的对象,速度很快;并发标记阶段会从GC Roots直接关联的对象开始遍历整个对象图,这个过程耗时较长,但不需要停顿用户线程;重新标记阶段则将修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录;并发清楚阶段将清理删除掉标记阶段判断的已死的对象,由于仅仅进行清除而不移动可用对象,因此可与用户线程并发执行
- 特点
CMS是一款优秀的收集器,其最主要的优点在于并发收集与低停顿,因此有时也被称为并发低停顿收集器(Concurrent Low Pause Collector),但其也存在一些重要的缺点。
CMS收集器由于其并发特性,对处理器资源较为敏感,在并发阶段,虽然不会导致用户线程的停顿,但也会因为占用了一部分线程而导致应用程序变慢,降低总吞吐量。CMS默认启动的回收线程数是 处 理 器 核 心 数 量 + 3 4 \frac{处理器核心数量+3}{4} 4处理器核心数量+3,那么当处理器核心数量大于等于4时,CMS回收线程仅占用不超过25%的处理器资源,并且随着处理器核心数量的增加而降低,而当处理器核心数量小于4时,CMS就会对用户线程造成较大影响,本就紧张的处理器资源,在启动垃圾收集时要分出一半的运算能力给垃圾收集线程,导致用户线程执行速度突然大幅度降低。
此外CMS收集器无法处理“浮动垃圾”(Floating Garbage),有可能会出现“Con-current Mode Failure”进而导致一次完全Stop The World的Full GC产生。浮动垃圾指在并发清理阶段由还在运行的用户线程产生的垃圾,这些垃圾在本次的GC中将无法清除,只能留到下一次GC。并且由于用户线程不会暂停,在垃圾收集行为期间就需要预留足够的内存空间提供给用户线程,因此CMS收集器并不会等到老年代几乎被填满了才进行收集,可以使用-XX:CMSInitiatingOccupancyFraction参数来控制CMS启动的阈值,在jdk1.5中,默认在老年代使用了68%的情况下**CMS,而在jdk1.6时这个阈值被提高到了92%,但这又带来了并发失败的风险:CMS运行期间预留的内存无法满足程序分配新对象的需要,这时虚拟机将启动后备预案:动检用户线程,临时启用Serial Old收集器来重新进行老年代收集,从而导致更长的停顿时间。因此-XX:CMSInitiatingOccupancyFraction设置的太高会导致大量的并发失败,性能反而降低
最后还有一个缺点,CMS收集器采用的是标记清楚算法,这意味着CMS收集器很容易产生大量内存碎片,当内存碎片过多时会对大对象的分配带来较大麻烦,往往会出现老年代仍有大量剩余空间但无法找到足够的连续空间来进行内存分配,进而提前触发Full GC。要解决这个问题可以使用-XX:+UseCMSCompactAtFullCollection参数(默认开启),该参数会使得CMS在不得不进行Full GC时开启内存碎片的整理过程,这样虽然可以解决碎片问题,但由于需要移动对象无法并发,导致停顿时间边长,因此也可以使用-XX:CMSFullGCsBeforeCompaction参数,该参数指明在CMS收集器在执行若干次不整理空间的Full GC后下一次FullGC开始前会进行素片整理,该参数默认值为0。但这两个参数在jdk9后均被废弃了
7、G1收集器
G1(Garbage First)收集器是一款主要面向服务端引用的垃圾收集器,HotSpot团队最初开发这款收集器的目的是替换CMS收集器,在jdk9之后G1取代了Parallel Scavenge+Parallel Old的组合,称为服务端模式下默认的垃圾收集器。G1收集器与之前介绍的所有收集器最大的不同在于G1是一个面向堆内存任何部分组成的回收集的收集器,其痕量标准不再是新生代与老年嗲,而是哪块内存中存放的垃圾街数量最多,回收收益最大。
G1收集器的回收行为基于Region的堆内存布局进行,虽然它仍然遵循分代收集理论,但其堆内存布局与其他收集器有明显差异,G1收集器不再坚持固定大小及固定数量的分带区域划分,而是把连续的堆划分成多个等大的独立区域——Region,每个Region都可以根据需要扮演新生代、老年代空间。
此外Region中还有一个特殊的Humongous区域用来专门存储大对象,只要大小超过了Region容量的一半即被认定为大对象。Region的大小可以使用-XX:G1HeapRegionSize参数进行设置,取值范围为1MB-32MB,且应为2的整数幂。对于超过整个Region容量的超大对象则被存放在N个连续的Humongous Region总。
此外G1收集器可以建立可预测的停顿时间模型,因为它以Region作为单次回收的最小单元,即每次回收的内存怒空间都是Region大小的整数倍,这样可以有计划的避免在整个Java堆中进行全局的垃圾收集。G1会跟踪各个Region中的垃圾堆积的价值大小,并在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值最大的Region,这样的具有优先级的回收方式保证了G1在有限时间内尽可能更高效的进行收集
- G1的运行过程
G1收集器主要可以分为初始标记(Initial Marking)、并发标记(Concurrent Marking)、最终标记(Final Marking)和筛选回收(Live Data Counting Evacuation)四个步骤。其中初始标记、最终标记和筛选回收过程会暂停用户线程
初始标记阶段仅仅会标记出GCRoots能直接关联到的对象,让下一阶段用户线程并发运行时能够正确的在可用的Region中分配新对象。
并发标记阶段从GCRoots开始进行可达性分析,找出要回收的对象,耗时较长但可以与用户程序并发执行。在扫描完成后还需重新处理在并发时有引用变动的对象
最终标记阶段会对用户线程做一个短暂的暂停,用于处理并发阶段遗留的有变动的对象。
筛选回收阶段负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,并根据用户所期望的停顿时间来制定回收计划,可以*的选择任意多个Region构成回收集,最后在把决定回收的Region中存活的对象复制到空的Region中,再清理掉旧Region中的全部空间
六、常见的GC参数
(并非所有)
收集器的选择
参数 | 描述 |
---|---|
-XX:UseSerialGC | 虚拟机运行在Client模式下的默认值,开启后使用Serial+Serial Old的收集器组合进行内存回收 |
-XX:UserParNewGC | 开启后使用ParNew+Serial Old的收集器组合进行内存回收,在jdk9后被移除 |
-XX:UseCon从MarkSweepGC | 开启后使用ParNew+CMS+Serial Old的收集器组合进行内存回收,其中Serial Old将作为CMS出现并发失败时的后备策略 |
-XX:UseParallelGC | jdk9之前虚拟机运行在Server模式下的默认值,开启后使用Parallel Scavenge+Serial Old的收集器组合进行内存回收 |
-XX:UseParallelOldGC | 开启后使用Parallel Scavenge+Parallel Old的收集器组合 |
-XX:UseG1GC | 使用G1收集器,jdk9后Server模式的默认值 |
新生代与老年代的调整
参数 | 描述 |
---|---|
-XX:SurvivorRatio | 设置新生代中Eden与Survivor空间的容量比值,默认大小为8:1 |
-XX:PretenureSizeThreshold | 设置直接晋升到老年代的对象的大小,超过该大小的对象将直接在老年代分配 |
-XX:MaxTenuringThreshold | 设置晋升到老年代的对象年龄,默认为15 |
-XX:UseAdaptiveSizePolicy | 动态调整堆中各个区域带下以及进入老年代的年龄 |
-XX:HandlePromotionFailure | 是否允许分配担保失败 |
收集器性能相关
参数 | 描述 |
---|---|
GCTimeRatio | GC时间栈总时间的比率,默认为99,即允许1%的GC时间,仅在使用Parallel Scavenge收集器时生效 |
-XX:MaxGCPauseMillis | 设置GC最大停顿时间,仅在使用Parallel Scavenge时生效 |
-XX:CMSInitiatingOccupancyFraction | 设置CMS收集器在老年代被使用多少后触发垃圾收集,默认值为68%,仅再使用CMS时生效 |
-XX:UseCMSCompactAtFullCollection | 设置CMS在完成垃圾收集后是否需要进行一次内存碎片清理,仅再使用CMS时生效,该参数在jdk9后废弃 |
-XX:CMSFullGCsBeforeCompaction | 设置CMS在进行若干次垃圾收集后启动一次内存碎片整理,仅在使用CMS时生效,该参数在jdk9后废弃 |
-XX:G1HeapRegionSize=n | 设置Region大小,并非最终值 |
-XX:MaxGCPauseMillis | 设置G1收集器目标时间,默认值为200ms |