GC垃圾回收
- 了解什么是垃圾回收
- 掌握垃圾会回收的常见算法
- 学习串行、并行、并发、G1垃圾收集器
- 学习GC日志的可视化查看
1.什么是垃圾回收?
程序的运行必然需要申请内存资源,无效的对象资源如果不及时处理就会一直占有内存资源,最终将导致内存溢出,所以对内存资源的管理是非常重要了。
1.1.C/C++语言的垃圾回收
在C/C++语言中,没有自动垃圾回收机制,是通过new关键字申请内存资源,通过delete关键字释放内存资源。如果,程序员在某些位置没有写delete进行释放,那么申请的对象将一直占用内存资源,最终可能会导致内存溢出。
1.2.Java语言的垃圾回收
为了让程序员更专注于代码的实现,而不用过多的考虑内存释放的问题,所以,在Java语言中,有了自动的垃圾回收机制,也就是我们熟悉的GC。有了垃圾回收机制后,程序员只需要关心内存的申请即可,内存的释放由系统自动识别完成。换句话说,自动的垃圾回收的算法就会变得非常重要了,如果因为算法的不合理,导致内存资源一直没有释放,同样也可能会导致内存溢出的。当然,除了Java语言、C#、Python等语言也都有自动的垃圾回收机制。
2.垃圾回收的常见算法
自动化的管理内存资源,垃圾回收机制必须要有一套算法来进行计算,哪些是有效的对象,哪些是无效的对象,对于无效的对象就要进行回收处理。常见的垃圾回收算法有:引用计数法、标记清除法、标记压缩法、复制算法、分代算法等。
2.1.引用计数法
引用计数是历史最悠久的一种算法,最早George E. Collins在1960的时候首次提出,50年后的今天,该算法依然被很多编程语言使用。
- 原理
假设有一个对象A,任何一个对象对A的引用,那么对象A的引用计数器+1,当引用失败时,对象A的引用计数器就-1,如果对象A的计数器的值为0,就说明对象A没有引用了,可以被回收。
- 优缺点
优点:
实时性较高,无需等到内存不够的时候,才开始回收,运行时根据对象的计数器是否为0,就可以直接回收。在垃圾回收过程中,应用程序无需挂起。如果申请内存时,内存不足,则立刻报out of memery 错误。局部更新对象的计数器时,只是影响到该对象,不会扫描全部对象。
缺点:
每次对象被引用时,都需要去更新计数器,有一点时间开销。浪费CPU资源,即使内存够用,仍然在运行时进行计数器的统计。无法解决循环引用问题。(最大的缺点)
什么是循环引用?
public class A {
public static void main(String[] args) {
TestA a = new TestA();
TestB b = new TestB();
a.b = b;
b.a = a;
a = null;
b = null;
}
}
class TestA {
public TestB b;
}
class TestB {
public TestA a;
}
虽然a和b都为null,但是由于a和b存在循环引用,这样a和b永远都不会被回收。
2.2.标记清除法
标记清除算法,是将垃圾回收分为2个阶段,分别是标记和清除。
标记:从根节点开始标记引用的对象。
清除:未被标记引用的对象就是垃圾对象,可以被清理。
- 原理
这张图代表的是程序运行期间所有对象的状态,它们的标志位全部是0(也就是未标记,以下默认0就是未标记,1为已标记),假设这会儿有效内存空间耗尽了,JVM将会停止应用程序的运行并开启GC线程,然后开始进行标记工作,按照根搜索算法,标记完以后,对象的状态如下图。
可以看到,按照根搜索算法,所有从root对象可达的对象就被标记为了存活的对象,此时已经完成了第一阶段标记。接下来,就要执行第二阶段清除了,那么清除完以后,剩下的对象以及对象的状态如下图所示
可以看到,没有被标记的对象将会回收清除掉,而被标记的对象将会留下,并且会将标记位重新归0。接下来就不用说了,唤醒停止的程序线程,让程序继续运行即可。
- 优缺点
优点:可以看到,标记清除算法解决了引用计数算法中的循环引用的问题,没有从root节点引用的对象都会被回收。
缺点:效率较低,标记和清除两个动作都需要遍历所有的对象,并且在GC时,需要停止应用程序,对于交互性要求比较高的应用而言这个体验是非常差的。通过标记清除算法清理出来的内存,碎片化较为严重,因为被回收的对象可能存在于内存的各个角落,所以清理出来的内存是不连贯的。
2.3.标记压缩算法
标记压缩算法是在标记清除算法的基础之上,做了优化改进的算法。和标记清除算法一样,也是从根节点开始,对对象的引用进行标记,在清理阶段,并不是简单的清理未标记的对象,而是将存活的对象压缩到内存的一端,然后清理边界以外的垃圾,从而解决了碎片化的问题。
- 原理
- 优缺点
优缺点同标记清除算法,解决了标记清除算法的碎片化的问题,同时,标记压缩算法多了一步,对象移动内存位置的步骤,其效率也有有一定的影响。
2.4.复制算法
复制算法的核心就是,将原有的内存空间一分为二,每次只用其中的一块,在垃圾回收时,将正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,然后再重新交换两个内存的角色,完成垃圾的回收。如果内存中的垃圾对象较多,需要复制的对象就较少,这种情况下适合使用该方式并且效率比较高,反之,则不适合
2.4.1.JVM中年轻代内存空间
-
在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区域是空的。
-
GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。
优缺点:
优点:在垃圾对象多的情况下,效率较高,清理后,内存无碎片
缺点:在垃圾对象少的情况下,不适用,如:老年代内存,分配的2块内存空间,在同一个时刻,只能使用一半,内存使用率较低
2.5.分代算法
前面介绍了多种回收算法,每一种算法都有自己的优点也有缺点,谁都不能替代谁,所以根据垃圾回收对象的特点进行选择,才是明智的选择。分代算法其实就是这样的,根据回收对象的特点进行选择
在jvm中,年轻代适合使用复制算法,老年代适合使用标记清除或标记压缩算法。
3.垃圾收集器以及内存分配
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现,在jvm中,实现了多种垃圾收集器,包括:串行垃圾收集器、并行垃圾收集器、CMS(并发)垃圾收集器、G1垃圾收集器
虽然我们对各个收集器进行比较,但并非为了挑选出一个最好的收集器。因为直到现在为止还没有 最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合 自己的垃圾收集器。试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么 我们的Java虚拟机就不会实现那么多不同的垃圾收集器了
3.1.串行垃圾收集器(Serial)
Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。大家看名字就知道这个收集器是一个单线程收集器了。它的 “单线程” 的意义不仅仅意味着它只会使用一条垃圾收集线程去完成垃 圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程( “Stop The World” ),直到它收集结束 ,Serial一般和Serial Old搭配在一起,Serial对年轻代收集,Serial Old对老年代收集。
新生代采用复制算法,老年代采用标记-整理算法
虚拟机的设计者们当然知道Stop The World带来的不良用户体验,所以在后续的垃圾收集器设计中停顿时间在不断缩短(仍然还有停顿,寻找最优秀的垃圾收集器的过程仍然在继续)。 但是Serial收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效(与其他收集器的单线程相比)。Serial收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。 Serial主要用于对年轻代进行垃圾收集,Serial Old收集器是对老年代进行收集,Serial Old同样是一个单线程收集器,它主要有两大用 途:一种用途是在JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用
Serial垃圾回收器常用参数设置
- -XX:+UseSerialGC : 指定年轻代和老年代都使用串行垃圾收集器
- -XX:+PrintGCDetails:打印垃圾回收的详细信息
案例测试:
public class TestGC {
public static void main(String[] args) throws Exception {
List<Object> list = new ArrayList<Object>();
while (true) {
int sleep = new Random().nextInt(100); //100以内的随机数
if (System.currentTimeMillis() % 2 == 0) {
list.clear();
} else {
for (int i = 0; i < 10000; i++) {
Properties properties = new Properties();
properties.put("key_" + i, "value_" + System.currentTimeMillis() + i);
list.add(properties);
}
}
Thread.sleep(sleep);
}
}
}
为了测试GC,将堆的初始和最大内存都设置为16M
-XX:+UseSerialGC -XX:+PrintGCDetails -Xms16m -Xmx16m
3.2.并行垃圾收集器
并行垃圾收集器在串行垃圾收集器的基础之上做了改进,将单线程改为了多线程进行垃圾回收,这样可以缩短垃圾回收的时间。(这里是指,并行能力较强的机器)当然了,并行垃圾收集器在收集的过程中也会暂停应用程序,这个和串行垃圾回收器是一样的,只是并行执行,速度更快些,暂停的时间更短一些。
1.ParNew收集器(-XX:+UseParNewGC)
ParNew收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为 (控制参数、收集算法、回收策略等等)和Serial收集器完全一样。默认的收集线程数跟cpu核数相同,当然也可以用参数(-XX:ParallelGCThreads)指定收集线程数,但是一般不推荐修改。
ParNew收集器一般和Cms收集器或者Serial Old收集器搭配使用,ParNew收集器主要作用是对年轻代进行垃圾收集,采用的是复制算法,老年代Cms收集器或者Serial Old收集器采用的是标记-整理算法
参数配置:
-XX:+UseParNewGC -XX:+PrintGCDetails -Xms16m -Xmx16m
打印出的信息
[GC (Allocation Failure) [ParNew: 4416K->512K(4928K), 0.0032106 secs] 4416K- >1988K(15872K), 0.0032697 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
2.Parallel Scavenge垃圾收集器
Parallel Scavenge收集器工作机制和ParNewGC收集器一样,Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)。所谓吞吐量就是CPU中用于运行用户代码的时间与CPU总消耗时间的比值。 Parallel Scavenge收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解的话,可以选择把内存管理优化交给虚拟机去完成也是一个不错的选择
Parallel Scavenge收集器主要作用在年轻代上,采用复制算法,老年代采用ParallelOld收集器,采用标记整理算法
相关参数如下:
- -XX:+UseParallelGC:年轻代使用ParallelGC垃圾回收器
- -XX:+UseParallelOldGC:老年代使用ParallelOldGC垃圾回收器。
- -XX:MaxGCPauseMillis:设置最大的垃圾收集时的停顿时间,单位为毫秒,需要注意的时,ParallelGC为了达到设置的停顿时间,可能会调整堆大小或其他的参数,如果堆的大小设置的较小,就会导致GC工作变得很频繁,反而可能会影响到性能。使用需谨慎。
- -XX:GCTimeRatio:设置垃圾回收时间占程序运行时间的百分比,公式为1/(1+n)。它的值为0~100之间的数字,默认值为1,也就是垃圾回收时间不能超过1%
- -XX:UseAdaptiveSizePolicy:自适应GC模式,垃圾回收器将自动调整年轻代、老年代等参数,达到吞吐量、堆大小、停顿时间之间的平衡。一般用于手动调整参数比较困难的场景,让收集器自动进行调整
参数设置:
-XX:+UseParallelGC -XX:+UseParallelOldGC -XX:MaxGCPauseMillis=100 -XX:+PrintGCDetails -Xms16m -Xmx16m
日志打印:
[GC (Allocation Failure) [PSYoungGen: 4096K->480K(4608K)] 4096K->1840K(15872K), 0.0034307 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] [Full GC (Ergonomics) [PSYoungGen: 505K->0K(4608K)] [ParOldGen: 10332K->10751K(11264K)] 10837K->10751K(15872K), [Metaspace: 3491K->3491K(1056768K)], 0.0793622 secs] [Times: user=0.13 sys=0.00, real=0.08 secs]
以上信息可以看出,年轻代和老年代都使用了ParallelGC垃圾回收器
3.3.CMS垃圾收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。一般用在老年代,它非常符合在注重用户体验的应用上使用,它是HotSpot虚拟机第一款真正意义上的并发收集器, 它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
从名字中的Mark Sweep这两个词可以看出,CMS收集器是一种 “标记-清除”算法实现的,它 的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤
- 初始标记: 暂停所有的其他线程,并记录下gc roots直接能引用的对象,速度很快 ;
- 并发标记: 同时开启GC和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以GC线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。
- 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记 产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
- 并发清理: 开启用户线程,同时GC线程开始对未标记的区域做清扫
cms垃圾收集的缺点:
- 对CPU资源敏感(会和服务抢资源)
- 无法处理浮动垃圾(在并发清理阶段又产生垃圾,这种浮动垃圾只能等到下一次gc再清理 了)
- 它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生,当然 通过参数-XX:+UseCMSCompactAtFullCollection 可以让jvm在执行完标记清除后再做整理
- 执行过程中的不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,特别是在并发标记和并发清理阶段会出现,一边回收,系统一边运行,也许没回收完就再次触发full gc,也就是"concurrent mode failure",此时会进入stop the world,只能用serial old垃圾收集器来回收
CMS收集器相关参数:
- -XX:+UseConcMarkSweepGC:启用cms
- -XX:ConcGCThreads:并发的GC线程数
- -XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片)
- -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次
- -XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比)
- -XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(- XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整
- -XX:+CMSScavengeBeforeRemark:在CMS GC前启动一次minor gc,目的在于减少老年代对年轻代的引用,降低CMS GC的标记阶段时的开销
设置启动参数:
-XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -Xms16m -Xmx16m
运行日志:
[GC (Allocation Failure) [ParNew: 4926K->512K(4928K), 0.0041843 secs] 9424K- >6736K(15872K), 0.0042168 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
#第一步,初始标记 [GC (CMS Initial Mark) [1 CMS-initial-mark: 6224K(10944K)] 6824K(15872K), 0.0004209 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
#第二步,并发标记 [CMS-concurrent-mark-start] [CMS-concurrent-mark: 0.002/0.002 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
#第三步,预处理 [CMS-concurrent-preclean-start] [CMS-concurrent-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
#第四步,重新标记 [GC (CMS Final Remark) [YG occupancy: 1657 K (4928 K)][Rescan (parallel) , 0.0005811 secs][weak refs processing, 0.0000136 secs][class unloading, 0.0003671 secs][scrub symbol table, 0.0006813 secs][scrub string table, 0.0001216 secs][1 CMS-remark: 6224K(10944K)] 7881K(15872K), 0.0018324 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
#第五步,并发清理 [CMS-concurrent-sweep-start] [CMS-concurrent-sweep: 0.004/0.004 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
#第六步,重置 [CMS-concurrent-reset-start]
[CMS-concurrent-reset: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
3.4 亿级流量电商系统如何优化JVM参数设置(ParNew+CMS)
对于8G内存,我们一般是分配4G内存给JVM,正常的JVM参数配置如下:
‐Xms3072M ‐Xmx3072M ‐Xmn1536M ‐Xss1M ‐XX:PermSize=256M ‐XX:MaxPermSize=256M ‐XX:SurvivorRatio=8
- SurvivorRatio:年轻代中Eden区与Survivor区的大小比值,缺省默认值为8
- ‐Xms :JVM初始内存大小,建议与-Xmx一致
- ‐Xmx:JVM最大堆内存大小
- -Xmn:年轻代的大小,使用G1收集器是不建议设置该值
- -Xss:每个线程的堆栈大小
- MaxPermSize:永久代的大小
系统按每秒生成60MB的速度来生成对象,大概运行20秒就会撑满eden区,会出发minor gc(YoungGc),大概会有95%以上对象成为垃圾被回收,可能最后一两秒生成的对象还被引用着,我们暂估为100MB左右,那么这100M会被挪到S0区,回忆下动态对象年龄判断原则,这100MB对象同龄而且总和大于S0区的50%,那么这些对象都会被挪到老年代,到了老年代不到一秒又变成了垃圾对象,很明显,survivor区大小设置有点小,我们分析下系统业务就知道,明显大部分对象都是短生存周期的,根本不应该频繁进入老年代,也没必要给老年代维持过大的内存空间,得让对象尽量留在新生代里。 于是我们可以更新下JVM参数设置
这样就降低了因为对象动态年龄判断原则导致的对象频繁进入老年代的问题,其实很多优化无非就 是让短期存活的对象尽量都留在survivor里,不要进入老年代,这样在minor gc的时候这些对象 都会被回收,不会进到老年代从而导致full gc
对于对象年龄应该为多少才移动到老年代比较合适,本例中一次minor gc要间隔二三十秒,大多数对象一般在几秒内就会变为垃圾,完全可以将默认的15岁改小一点,比如改为5,那么意味着对象要经过5次minor gc才会进入老年代,整个时间也有一两分钟了,如果对象这么长时间都没被回收,完全可以认为这些对象是会存活的比较长的对象,可以移动到老年代,而不是继续一直占用 survivor区空间
对于多大的对象直接进入老年代(参数-XX:PretenureSizeThreshold),这个一般可以结合你自己系统 看下有没有什么大对象生成,预估下大对象的大小,一般来说设置为1M就差不多了,很少有超过 1M的大对象,这些对象一般就是你系统初始化分配的缓存对象,比如大的缓存List,Map之类的对象
可以适当调整JVM参数如下:
‐Xms3072M ‐Xmx3072M ‐Xmn2048M ‐Xss1M ‐XX:PermSize=256M ‐XX:MaxPermSize=256M ‐XX:SurvivorRatio=8
‐XX:MaxTenuringThreshold=5 ‐XX:PretenureSizeThreshold=1M ‐XX:+UseParNewGC ‐XX:+UseConcMarkSweepGC
对于老年代CMS的参数如何设置我们可以思考下,首先我们想下当前这个系统有哪些对象可能会 长期存活躲过5次以上minor gc最终进入老年代。无非就是那些Spring容器里的Bean,线程池对象,一些初始化缓存数据对象等,这些加起来充其 量也就几十MB。
还有就是某次minor gc完了之后还有超过200M的对象存活,那么就会直接进入老年代,比如突然某一秒瞬间要处理五六百单,那么每秒生成的对象可能有一百多M,再加上整个系统可能压力剧增,一个订单要好几秒才能处理完,下一秒可能又有很多订单过来
我们可以估算下大概每隔五六分钟出现一次这样的情况,那么大概半小时到一小时之间就可能因为老年代满了触发一次Full GC,其实在半小时后发生full gc,这时候已经过了抢购的最 高峰期,后续可能几小时才做一次FullGC。 对于碎片整理,因为都是1小时或几小时才做一次FullGC,是可以每做完一次就开始碎片整理。 综上,只要年轻代参数设置合理,老年代CMS的参数设置基本都可以用默认值,如下所示
‐Xms3072M ‐Xmx3072M ‐Xmn2048M ‐Xss1M ‐XX:PermSize=256M ‐XX:MaxPermSize=256M ‐XX:SurvivorRatio=8
‐XX:MaxTenuringThreshold=5 ‐XX:PretenureSizeThreshold=1M ‐XX:+UseParNewGC ‐XX:+UseConcMarkSweepGC
‐XX:CMSInitiatingOccupancyFaction=92 ‐XX:+UseCMSCompactAtFullCollection ‐XX:CMSFullGCsBeforeCompaction=0
- CMSInitiatingOccupancyFaction:老年代内存使用率一旦超过92就会执行CMS GC
- XX:CMSFullGCsBeforeCompaction:设置在执行多少次Full GC后对内存空间进行压缩整理
3.5.G1垃圾收集器(重点)
G1(Garbage-First)垃圾收集器是在jdk1.7中正式使用的全新的垃圾收集器,oracle官方计划在jdk9中将G1变成默认的垃圾收集器,以替代CMS。以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。
G1将Java堆划分为多个大小相等的独立区域(Region),JVM最多可以有2048个Region。 一般Region大小等于堆大小除以2048,比如堆大小为4096M,则Region大小为2M,当然也可以 用参数"-XX:G1HeapRegionSize"手动指定Region大小,但是推荐默认的计算方式。 G1保留了年轻代和老年代的概念,但不再是物理隔阂了,它们都是(可以不连续)Region的集合
默认年轻代对堆内存的占比是5%,如果堆大小为4096M,那么年轻代占据200MB左右的内存, 对应大概是100个Region,可以通过“-XX:G1NewSizePercent”设置新生代初始占比,在系统运行中,JVM会不停的给年轻代增加更多的Region,但是最多新生代的占比不会超过60%,可以 通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和Survivor对应的region也跟之前 一样,默认8:1:1,假设年轻代现在有1000个region,eden区对应800个,s0对应100个,s1对应100个。
一个Region可能之前是年轻代,如果Region进行了垃圾回收,之后可能又会变成老年代,也就是说Region的区域功能可能会动态变化。
G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样,唯一不同的是对大对象的处理,G1有专门分配大对象的Region叫Humongous区,而不是让大对象直接进入老年代的 Region中。在G1中,大对象的判定规则就是一个大对象超过了一个Region大小的50%,比如按照上面算的,每个Region是2M,只要一个大对象超过了1M,就会被放入Humongous中,而且 一个大对象如果太大,可能会横跨多个Region来存放。
Humongous区专门存放短期巨型对象,不用直接进老年代,可以节约老年代的空间,避免因为老 年代空间不够的GC开销。 Full GC的时候除了收集年轻代和老年代之外,也会将Humongous区一并回收。
G1收集器一次GC的运作过程大致分为以下几个步骤:
- 初始标记(initial mark,STW):暂停所有的其他线程,并记录下gc roots直接能引用 的对象,速度很快 ;
- 并发标记(Concurrent Marking):同CMS的并发标记
- 最终标记(Remark,STW):同CMS的重新标记
- 筛选回收(Cleanup,STW):筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间(可以用JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划,比如说老年代此时有1000个Region都满了,但是因为根据预期停顿时间,本 次垃圾回收可能只能停顿200毫秒,那么通过之前回收成本计算得知,可能回收其中800个 Region刚好需要200ms,那么就只会回收800个Region,尽量把GC导致的停顿时间控制在我们指定的范围内。这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。不管是年轻代或是老年代,回收算法主要用的是复制算法,将一个region中的存活对象复制到另一个 region中,这种不会像CMS那样回收完因为有很多内存碎片还需要整理一次,G1采用复制 算法回收几乎不会有太多内存碎片
G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字Garbage-First的由来),比如一个Region花200ms能回收10M垃 圾,另外一个Region花50ms能回收20M垃圾,在回收时间有限情况下,G1当然会优先选择后面 这个Region回收。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集 器在有限时间内可以尽可能高的收集效率。
1.G1重要进化特征
并行与并发:G1能充分利用CPU、多核环境下的硬件优势,使用多个CPU(CPU或者 CPU核心)来缩短Stop-The-World停顿时间。部分其他收集器原本需要停顿Java线程来执行GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行
分代收集:虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但是还是保留了分代的概念。
空间整合:与CMS的“标记–清理”算法不同,G1从整体来看是基于“标记整理”算法 实现的收集器;从局部上来看是基于“复制”算法实现的。
可预测的停顿:这是G1相对于CMS的另一个大优势,降低停顿时间是G1 和 CMS 共同 的关注点,但G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段(通过参数"-XX:MaxGCPauseMillis"指定)内完成垃圾收集
2.G1收集器参数设置
- -XX:+UseG1GC:使用G1收集器
- -XX:ParallelGCThreads:指定GC工作的线程数量
- -XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为 2048个分区
- -XX:MaxGCPauseMillis:目标暂停时间(默认200ms)
- -XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%)
- -XX:G1MaxNewSizePercent:新生代内存最大空间
- -XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄 1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对 象都放入老年代
- -XX:MaxTenuringThreshold:最大年龄阈值(默认15)
- -XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC),比如我们之前说的堆默认有2048个region,如果有接近 1000个region都是老年代的region,则可能就要触发MixedGC
- -XX:G1HeapWastePercent(默认5%): gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他 Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的 Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收就结束了
- -XX:G1MixedGCLiveThresholdPercent(默认85%) region中的存活对象低于这个值时才会回收该region,如果超过这个值,存活对象过多,回收的的意义不大
- -XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至 于单次停顿时间过长
记一次调优经历,高并发下订单的场景
java -Xms64G -Xmx64G -Xss1M -XX:+UseG1GC -XX:MaxGCPauseMllis=100 -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=512M -jar xxx- eureka-server.jar
3.G1垃圾收集分类
YoungGC
YoungGC并不是说现有的Eden区放满了就会马上触发,而且G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值,那么增加年轻代 的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC
MixedGC
MixedGC不是FullGC,老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercen)设定的值则触发,值默认是45%,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC
Full GC 停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的
4.G1垃圾收集器优化建议
假设参数 -XX:MaxGCPauseMills 设置的值很大,导致系统运行很久,年轻代可能都占用了堆内存的60%了,此时才触发年轻代gc。
那么存活下来的对象可能就会很多,此时就会导致Survivor区域放不下那么多的对象,就会进 入老年代中。
或者是你年轻代gc过后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor区域的50%,也会快速导致一些对象进入老年代中。
所以这里核心还是在于调节 -XX:MaxGCPauseMills 这个参数的值,在保证他的年轻代gc别太频繁的同时,还得考虑每次gc过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发mixed gc
5.每秒几十万并发的系统如何优化JVM
Kafka类似的支撑高并发消息系统大家肯定不陌生,对于kafka来说,每秒处理几万甚至几十万消息时很正常的,一般来说部署kafka需要用大内存机器(比如64G),也就是说可以给年轻代分配个三四十G的内存用来支撑高并发处理,这里就涉及到一个问题了,我们以前常说的对于eden区的young gc是很快的,这种情况下它的执行还会很快吗?很显然,不可能,因为内存太大,处理还是要花不少时间的,假设三四十G内存回收可能最快也要十几秒钟,那么意味着整个系统每运行十几秒钟就会因为young gc卡顿几秒钟没法处理新消息,显然是不行的。那么对于这种情况如何优化了,我们可以使用G1收集器,设置 -XX:MaxGCPauseMills 为50ms,假设50ms能够回收三到四个G内存,然后50ms的卡顿其实完全能够接受,用户几乎无感知,那么整个系统就可以在卡顿几乎无感知的情况下一边处理业务一边收集垃圾。
G1天生就适合这种大内存机器的JVM运行,可以比较完美的解决大内存垃圾回收时间过长的问题。
4.如何选择垃圾收集器
- 优先调整堆的大小让服务器自己来选择
- 如果内存小于100M,使用串行收集器
- 如果是单核,并且没有停顿时间的要求,串行或JVM自己选择
- 如果允许停顿时间超过1秒,选择并行或者JVM自己选
- 如果响应时间最重要,并且不能超过1秒,使用并发收集器
5.可视化GC日志分析工具
5.1、GC日志输出参数
前面通过-XX:+PrintGCDetails可以对GC日志进行打印,我们就可以在控制台查看,这样虽然可以查看GC的信息,但是并不直观,可以借助于第三方的GC日志分析工具进行查看。
在日志打印输出涉及到的参数如下:
-XX:+PrintGC 输出GC日志
-XX:+PrintGCDetails 输出GC的详细日志
-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息 -Xloggc:../logs/gc.log 日志文件的输出路径
测试:
-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xmx256m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:F://test//gc.log
运行后就可以在F盘下生成gc.log文件。
如下:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NZucAgSt-1675327221691)(assets/image-20211104202951144.png)]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bvgaoMKW-1675327221696)(assets/image-20211104203009247.png)]
5.2 GC Easy 可视化工具
GC Easy是一款在线的可视化工具,易用、功能强大,网站:
http://gceasy.io/
Key Performance Indicators(关键性能指标)
Throughput表示的是吞吐量
Latency表示响应时间
Avg Pause GC Time 平均GC时间
Max Pause GC TIme 最大GC时间
Key Performance Indicators 给我们展示了GC吞吐量(应用程序线程用时占程序总用时的比例,越高越好),每次GC的平均耗时(建议控制在50ms以下),GC最长耗时,每个时间段的GC次数及占比信息。
通过Key Performance Indicators显示的信息里面,我们需要关注下面几个问题:
吞吐量,应用花在非GC上的时间百分比(引用花在生产任务上的百分比)。所以吞吐量越高越好。
每次GC的平均耗时。越小越好,建议50ms以下。
GC最长耗时。越小越好。如果你的应用是一个后台程序,并且任何请求不超过10秒,那么GC最长耗时就不能超过10秒
Interactive Graphs(交互圈)
Interactive Graphs展示了
Heap after GC:GC之后堆的使用情况
Heap before GC:GC之前堆的使用情况
GC Duration:GC持续时间
Reclaimed Bytes:GC回收掉的垃圾对象的内存大小
Young Gen:年轻代堆的使用情况
Old Gen:老年代堆的使用情况
Meta Space:元空间的使用情况
A & P:每次GC的时候堆内存分配和晋升情况。其中红色的线表示每次GC的时候年轻代里面有多少内存(对象)晋升到了老年代。
第一部分是Heap after GC,GC后堆的内存图,堆是用来存储对象的,从图中可以看出,随着GC的进行,垃圾回收器把对象都回收掉了,因此堆的大小逐渐增大
第二部分是Heap before GC,这是GC前堆的使用率,可以看出随着程序的运行,堆使用率越来越高,堆被对象占用的内存越来越大
第三部分是GC Duration Time,就是GC持续时间。一个GC事件的发生具有多个阶段,而不同的垃圾回收器又有不同的阶段,这里展示不作细分。这些阶段(例如并发标记,并发清除等)与程序线程一起并发运行,此时不会暂停程序线程。但是某些阶段(例如初始标记,清除等)会暂停整个应用程序,所以此图标描述的仅暂停阶段所花费的时间
第四部分表示的是GC回收掉的垃圾对象的内存大小
第五部分表示的是Young Gen,年轻代的内存分配情况。对象都是朝生夕死,年轻代存放的就是刚刚产生的对象,每进行一次GC,都会GC掉很多垃圾对象,剩下的就是右GC Root关联的对象,这些对象会年龄会逐渐增加,达到了一定阈值就会晋升为老年代的对象。可以看到before GC表示的图线随着时间的进行逐渐增大,也就是年轻代中对象越来越多,而GC事件发生后,年轻代中对象就会减少,也就是after GC图线表示的内存变化趋势
第六部分是Old Gen,表示的是老年代的内存分配情况。细心的读者会发现,为啥一开始before GC的内存大小比after GC的内存分配要少呢?这里得先知道老年代存放的都是年龄大的对象,意思就是经过了多次GC都没有被GC掉的对象,就会晋升为老年代的对象。所以这就解释了为啥after GC内存要比before GC内存要大,因为每次GC过后,都会有年轻代的对象晋升为老年代对象
第七部分是每次GC的时候堆内存分配和晋升情况。其中红色的线表示每次GC的时候年轻代里面有多少内存(对象)晋升到了老年代
GC Statistics(GC统计信息)
GC Statistics显示一些GC的统计信息。每种GC总共回收了多少内存、总共用了多长时间、平均时间、以及每种GC的单独统计信息啥的