JVM学习笔记(五)-JVM的垃圾收集2

回顾

  上一篇博客,学习了垃圾收集前的准备工作,包括对象存活判定算法、垃圾收集算法和JVM对以上算法的实现。
  本篇博客将接着上篇博客的内容,对JVM的垃圾收集相关知识进行进一步学习(由于之前一直用的学习这个词,现在就沿用了,其实这是第三遍复习,通过写博客找不足同时加深印象,后面将会用复习这个词了)。本篇博客首先学习几种垃圾收集器的特性和适用场景,然后会对java对象的内存分配策略和内存回收策略进行学习。

垃圾收集器

  各垃圾收集器的关系如下图所示:上面是新生代垃圾收集器,下面是老年代垃圾收集器,有连线的代表两个垃圾收集器可以组合使用,比如我的默认组合就是parallel scavenge和parallel old组合(可查看我之前的内存溢出博客的GC日志)。
  垃圾收集器是垃圾收集算法的具体实现,没有谁是最好的这种说法,只有说哪种组合适用于哪种场景。

垃圾收集器关系图

Serial收集器

特性:

单线程:只能有一个线程完成垃圾收集工作
stop the world:在进行垃圾收集时必须停止其他java线程
作用于新生代,复制算法

适用场景:

虽然Serial收集器推出得很早,但是现在仍然是client模式下的默认新生代收集器。
优点:它简单高效

Parnew收集器

特性:

Serial收集器的多线程版本,具有Serial的STW特性
作用于新生代,使用复制算法。

适用场景:

由于目前只有Serial和ParNew两款垃圾收集器能够与CMS垃圾收集器配合使用,
所以ParNew仍然是server模式下的首选新生代垃圾收集器。

parrallel scavenge垃圾收集器

特性:

注重吞吐量的可控制性,吞吐量表示CPU执行程序代码的时间占总CPU时间的比例
并行的多线程收集器
作用于新生代,复制算法
两个参数:-XX:MaxGCPauseMills和-XX:GCTimeRatio
自适应调节策略:-XX:+UseAdaptiveSizePolicy

  -XX:MaxGCPauseMills用于调节最大的GC停顿时间,但是这个值是以牺牲新生代空间和吞吐量为代价的,比如新生代空间300M肯定比原先500M所需的GC停顿时间要短(牺牲新生代空间),原先10s中执行一次GC,每次1.5s停顿时间,现在5s执行一次GC,每次1s停顿时间(牺牲吞吐量)。-XX:GCTimeRatio用于直接指定吞吐量,为0~100的整数,如为19时,则吞吐量为1-(1/(1+19))=95%。
  自适应调节策略指的是,设置了-XX:+UseAdaptiveSizePolicy参数后,JVM可以根据当前运行情况自动调节相关参数达到最佳的吞吐量或停顿时间。
适用场景:

注重吞吐量的可控制性,在parallel old推出前地位很尴尬,只能和Serial Old配合,
影响了它的性能。

Serial Old垃圾收集器

特性和使用场景:

Serial收集器的老年代版本
标记-整理算法,作用于老年代
和Serial结合使用,常用语client模式下

Parallel Old垃圾收集器

特性和适用场景:

和parallel scavenge垃圾收集器配合使用

CMS垃圾收集器

  CMS是第一款真正意义上的并发垃圾收集器,以最短GC停顿时间为目标,作用于老年代,采用标记-清除算法。共分为四个步骤:
1.初始标记
2.并发标记
3.重新标记
4.并发清除
初始标记:标记那些与GC ROOTS直接相连的对象
并发标记:这个过程是并发的,进行根搜索(gc root tracing),即是从root开始向下沿着引用链搜索,进行可达性分析。
重新标记:由于并发标记过程是并发的,即在标记的同时用户线程也是在执行的,前面说过GC过程中一定是有一段时间是停顿其他用户线程的,要保证对象的引用关系不能发生变化。重新标记时会停止用户线程,处理那些在并发标记过程中发生变化的对象。
并发清除:这个过程是并发的。在使用标记-清除算法回收需要回收的内存时,用户线程也在继续执行。

特性:

1.较短的GC停顿时间。由于两个比较耗时的GC过程集中在并发标记和并发清除阶段,所以并发使得用户感觉不到STW的存在
2.对CPU资源非常敏感,这是并发过程的共同特点
3.无法处理浮动垃圾:由于并发清除阶段,用户线程也在执行,这时产生的垃圾是无法处理的,如果堆积过多,可能会出现“Concurrent mode failure”,提前出发FULL GC。所以CMS在GC时不能像其他的收集器那样等到老年代几乎填满了才GC,必须预留足够的空间防止这种情况发生。
4.空间碎片问题:由于适用的是标记清除算法,所以会产生空间碎片,有可能提前触发Full GC。CMS提供了一个参数------XX:+UseCompactFullCollection,默认就是开启的,在顶不住将要进行Full GC时会进行一次内存整理。

适用场景:

  注重用户体验的互联网B/S应用,减少系统服务响应时间,低GC停顿时间。

G1垃圾收集器

  G1收集器的名字就说明了一切“garbage first”,表示优先收集。

特性:

1.并行与并发:G1收集器收集过程中既有并行也有并发
2.分代收集:G1收集器仍然保留了老年代和新生代的概念,只不过不再是物理隔离的。它将堆内存分割成一个个的region,其中一部分表示老年代,另一部分表示新生代,且有可能这些region位置都是交错的。
3.空间整合:相比CMS使用标记清除算法,G1使用的是标记整理算法。在垃圾收集时,从局部的两个region来看,使用的是复制算法。这样就不会想CMS那样产生大量的空间碎片。
4.可预测的停顿时间:G1不会进行堆的全区域回收,而是按计划回收。它会按照回收所获得的内存和回收需要的停顿时间,维护一个回收region的优先级列表,根据允许的收集时间,选择最有价值的region进行回收(所以叫做garbage-first),这样能够保证满足在某段时间内停顿时间不会超过设置的数值。

技术瓶颈:

  G1的设计思路很早就提出来了,但是一直没有设计成功,是因为将堆分为很多个region,但是垃圾收集时建立引用关系并不是只在一个region中进行,并不能保证一个region中的对象不会引用其他region中的对象,这样就需要进行全堆扫描。事实上,这个问题在其他收集器中也存在,但是没有这么突出,因为其他收集器也不能保证新生代和老年代中的对象之间没有引用关系。

解决办法:

  为每个region提供一个remember set的数据结构,每次发现reference类型的数据进行写数据时,产生一个write barrier暂停线程,检查被引用的对象是否处于不同的region之中,如果是,就讲引用关系记录到被引用对象的remember set中。

记录引用关系流程图

垃圾收集过程:

1.初始标记
2.并发标记
3.最终标记
4.筛选回收
初始标记:停止用户线程,记录和gc roots直接关联的对象,并且修改TAMS(Next Top at Mark Start),让下一阶段用户线程在正确可用的region中创建对象。
并发标记:并发进行,进行可达性分析,同时用户线程也能正常进行
最终标记:停止用户线程,但是GC可以并行执行,在并发标记阶段,发生引用关系变化的信息会记录到remember set log中,在最终标记阶段将log中的信息合并到remember set中。
筛选回收:根据允许的收集时间,从优先列表中选取最有回收价值的region进行回收。

G1收集器回收过程

内存分配和回收策略

1.对象优先在Eden区分配

  堆内存的新生代又被分为Eden区、from survivor区和to survivor区。新建对象会优先在Eden区分配,Eden区空间不够了,就会触发一次Minor GC。这个没什么好说的,直接对照书上做了实验

package gctest;

public class EdenSpaceTest {
	private static final int _1MB = 1024*1024;
	public static void main(String[] args) {
		byte[] allocation1,allocation2,allocation3,allocation4;
		allocation1 = new byte[2*_1MB];
		allocation2 = new byte[2*_1MB];
		allocation3 = new byte[2*_1MB];
		allocation4 = new byte[4*_1MB];  //会有一次Minor GC
	}

}
[GC (Allocation Failure) [PSYoungGen: 6972K->1000K(9216K)] 6972K->3323K(19456K), 0.0025347 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 9216K, used 5336K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 52% used [0x00000000ff600000,0x00000000ffa3c0d0,0x00000000ffe00000)
  from space 1024K, 97% used [0x00000000ffe00000,0x00000000ffefa020,0x00000000fff00000)
  to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
 ParOldGen       total 10240K, used 6419K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 62% used [0x00000000fec00000,0x00000000ff244c70,0x00000000ff600000)
 Metaspace       used 4732K, capacity 4930K, committed 5248K, reserved 1056768K
  class space    used 510K, capacity 561K, committed 640K, reserved 1048576K

  虽然GC日志和书上有差别,但是可以看到内存分布还是差不多的,其中5M分配到了Eden区,1M分配到了from survivor区,还有6M分到了老年代。GC是因为在分配allocation4时,Eden区的空间不够了,所以触发了一次minor GC。这里要注意的是minor GC并没有发挥作用,因为to survivor并不能存下这么多数据。所以原本存在Eden区和from survivor区的内容通过分配担保转移到老年代,后来的allocation4则分配到了新生代。

2.大对象直接进入老年代

这个思想本身是可以理解的,对象太大,不适合在新生代使用复制算法,而且你程序员就不该写这么大的对象出来!但是,对于“比如很长的字符串和数组”这句话,我却犯糊涂了——字符串不是存在字符串常量池的吗????

public class Test{
	public String str1 = "ABC";//假设ABC是个很大的字符串
	public String str2 = new String("ABC");
}

  以前看视频的时候,老师总是说str1指向常量池中的“ABC”,str2指向堆中的对象。但是大对象真的指的是str2吗?我的理解应该str2指向的对象不会进入老年代,而是String底层的数组char[] value进入老年代,因为str2指向的对象本身申请的内存空间并不大,它的对象内存布局中的实例数据部分不会存放“ABC”,而是存放一个指向value数组的引用,value数组是一个大对象存在老年代中。注意,这里甚至str1、str2都不是代表对象,只是对象的引用而已,它们在Test类加载的时候放在常量池中,在Test对象创建后属于字段,放在实例数据中。

3.存活时间久的进入老年代

  Eden区对象在第一次Minor GC后存活下来,如果能够被survivor区容纳的话,就会进入survivor区,并将年龄设置为1,然后每躲过一次Minor GC,它的年龄就加1,通过设置参数-XX:MaxTenuringThreshold=15设置最大年龄为15即进入老年代。
实验,参数设置:

参数设置,年龄阈值为1
package gctest;

public class MaxTenuringThresholdTest {
	
	private static final int _1MB = 1024*1024;
	
	public static void main(String[] args) {
		byte[] allocation1,allocation2,allocation3,allocation4;
		allocation1 = new byte[_1MB/4];
		allocation2 = new byte[4*_1MB];
		allocation3 = new byte[4*_1MB];
		allocation3 = null;
		//System.gc();
		allocation3 = new byte[4*_1MB];
		
	}

}
[GC (Allocation Failure) [PSYoungGen: 5101K->1016K(9216K)] 13293K->9698K(19456K), 0.0019624 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 9216K, used 5194K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 51% used [0x00000000ff600000,0x00000000ffa14930,0x00000000ffe00000)
  from space 1024K, 99% used [0x00000000ffe00000,0x00000000ffefe020,0x00000000fff00000)
  to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
 ParOldGen       total 10240K, used 8682K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 84% used [0x00000000fec00000,0x00000000ff47aad0,0x00000000ff600000)
 Metaspace       used 4732K, capacity 4930K, committed 5248K, reserved 1056768K
  class space    used 510K, capacity 561K, committed 640K, reserved 1048576K

  可以看到java8又作妖了。。。新生代差不多5M,老年代8M,也就是说。。没有进行回收。
  考虑到默认使用的是parallel scavenge和parallel old的组合,我更换了一个Parnew试试

使用par new 收集器
[GC (Allocation Failure) [ParNew: 5182K->1022K(9216K), 0.0021816 secs] 5182K->1459K(19456K), 0.0022260 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [ParNew: 5118K->0K(9216K), 0.0027293 secs] 5555K->5565K(19456K), 0.0027510 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [ParNew: 4096K->0K(9216K), 0.0003104 secs] 9661K->5565K(19456K), 0.0003256 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 par new generation   total 9216K, used 4178K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  51% used [0x00000000fec00000, 0x00000000ff014930, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
  to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
 tenured generation   total 10240K, used 5565K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  54% used [0x00000000ff600000, 0x00000000ffb6f498, 0x00000000ffb6f600, 0x0000000100000000)
 Metaspace       used 4730K, capacity 4930K, committed 5248K, reserved 1056768K
  class space    used 510K, capacity 561K, committed 640K, reserved 1048576K
Java HotSpot(TM) 64-Bit Server VM warning: Using the ParNew young collector with the Serial old collector is deprecated and will likely be removed in a future release

  可以看到,很明显的GC策略差距,Par New居然用了三次GC,from space确实没有了,最后也只回收了4M没有用到的对象空间。这是比较符合书上示例的结果。然而,还需要注意最后一句话,在eclipse里是标红的,“Java HotSpot™ 64-Bit Server VM warning: Using the ParNew young collector with the Serial old collector is deprecated and will likely be removed in a future release”,说明以后要渐渐的废除Par New+Serial Old组合的使用了!!
后面的实验就不做了,就是把年龄阈值改一下。

4.动态判断对象年龄

  这一条讲的是对象并不是一定严格按照MaxTenuringThreshold来决定是否进入老年代的,当某个年龄的对象占survivor区一半以上时,就将大于等于该年龄的对象移入老年代。

5.空间分配担保策略

前提知识:
  新生代分为Eden区和from survivor、to survivor区。当发生minor GC时,为了内存利用率,使用复制算法时,只使用其中一个survivor区域作为备用区。因此首选策略是将存活对象移入to survivor区中。
分配担保:
  当to survivor区不足以装下存活对象时,如果老年代的最大可用连续空间M大于目前存活的对象需要的空间N,则这些对象直接晋升老年代。如果不是,则检查HandlePromotionFailure是否打开,即是否允许担保失败。(JDK1.6以后默认开启),若允许,则查看M是否大于最近平均晋升老年代的对象所占空间W,若大于,则冒险进行一次Minor GC,将对象晋升到老年代。由于N可能远大于W,所以这次可能会担保失败,触发Full GC。
个人理解:
  晋升的对象在survivor放不下,总不能丢掉吧,总得放入老年代的,然而,JVM有侥幸心理,Full GC能少一点是一点,所以用分配担保机制,(我保证不会出问题,求求你就让我Minor GC吧,别Full GC啦!)就是为了避免频繁Full GC,即使担保失败,最后还是不得不进行Full GC,这样的尝试也是值得的!

分配担保流程图

总结

  本篇博客学习了垃圾收集器,内存分配与回收策略等知识。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值