JVM GC原理及调优的基本思路

本文涉及的产品
日志服务 SLS,月写入数据量 50GB 1个月
简介: 若观察到Tomcat进程CPU使用率较高,并在GC日志中发现GC次数比较频繁、GC停顿时间长,说明需优化GC。CMS和G1是时下使用率比较高的两款垃圾收集器,从Java 9开始,G1是默认垃圾收集器。

若观察到Tomcat进程CPU使用率较高,并在GC日志中发现GC次数比较频繁、GC停顿时间长,说明需优化GC。

CMS和G1是时下使用率比较高的两款垃圾收集器,从Java 9开始,G1是默认垃圾收集器。

CMS vs G1

CMS收集器

将Java堆分为新生代(Young)或老年代(Old),因为研究表明,超过90%的对象在第一次GC时就被回收掉,仅少数对象会存活较长。

image.png

CMS还将新生代内存空间分为幸存者空间(Survivor)和伊甸园空间(Eden):

  • 新的对象始终在Eden空间上创建
  • 一旦一个对象在一次垃圾收集后还幸存,就会被移动到幸存者空间


当一个对象在多次垃圾收集之后还存活时,它会移动到年老代。这样做的目的是在年轻代和年老代采用不同的收集算法,以达到较高的收集效率,比如在年轻代采用复制-整理算法,在年老代采用标记-清理算法。

G1收集器

与CMS相比,G1收集器有两大特点:


  • G1可以并发完成大部分GC的工作,这期间不会“Stop-The-World”
  • G1使用非连续空间,这使G1能够有效地处理非常大的堆。此外,G1可以同时收集年轻代和年老代。G1将堆分成许多(通常几百个)小区域。这些区域固定大小(默认大约2M)。每个区域都分配给一个空间。

image.png

U表示“未分配”区域。G1将堆拆分成小的区域:可以做局部垃圾回收,而无需每次都回收整个区域,这样回收的停顿时间会比较短。

收集过程

  • 将所有存活的对象将从收集的区域复制到未分配的区域,比如收集的区域是Eden空间,把Eden中的存活对象复制到未分配区域,这个未分配区域就成了Survivor空间。理想情况下,如果一个区域全是垃圾(意味着一个存活的对象都没有),则可以直接将该区域声明为“未分配”
  • 为了优化收集时间,G1总是优先选择垃圾最多的区域,从而最大限度地减少后续分配和释放堆空间所需的工作量。这也是G1收集器名字的由来——Garbage-First。

GC调优原则

GC有代价,因此根本原则是每次GC都回收尽可能多的对象。

针对CMS和G1有相应策略。

CMS收集器

最重要的是合理地设置年轻代和年老代大小。

  • 年轻代太小,会导致频繁Minor GC,并且很有可能存活期短的对象也不能被回收,GC的效率就不高
  • 年老代太小,容纳不下从年轻代过来的新对象,会频繁触发单线程Full GC,导致较长时间的GC暂停,影响Web应用的响应时间。

G1收集器

不推荐直接设置年轻代大小,和CMS不不同,因为G1会根据算法动态决定年轻代和年老代大小。

因此对于G1,最关心Java堆总大小(-Xmx)。

-XX:MaxGCPauseMillis = n

限制最大GC暂停时间,以尽量不影响请求的响应时间。G1将根据先前收集信息及检测到的垃圾量,估计它可以立即收集的最大区域数量,从而尽量保证GC时间不会超出这个限制。因此G1更“智能”,使用更简单。

内存调优实战

下面我通过一个例子实战一下Java堆设置得过小,导致频繁的GC,我们将通过GC日志分析工具来观察GC活动并定位问题。

1.首先我们建立一个Spring Boot程序,作为我们的调优对象

@RestController
public class GcTestController {
    private Queue<Greeting> objCache =  new ConcurrentLinkedDeque<>();
    @RequestMapping("/greeting")
    public Greeting greeting() {
        Greeting greeting = new Greeting("Hello World!");
        if (objCache.size() >= 200000) {
            objCache.clear();
        } else {
            objCache.add(greeting);
        }
        return greeting;
    }
}
@Data
@AllArgsConstructor
class Greeting {
   private String message;
}

就是创建了一个对象池,当对象池中的对象数到达200000时才清空一次,用来模拟年老代对象。

命令启动测试程序:

java -Xmx32m -Xss256k -verbosegc -Xlog:gc*,gc+ref=debug,gc+heap=debug,gc+age=trace:file=gc-%p-%t.log:tags,uptime,time,level:filecount=2,filesize=100m -jar target/demo-0.0.1-SNAPSHOT.jar

我给程序设置的堆的大小为32MB,目的是能让我们看到Full GC。除此之外,我还打开了verbosegc日志,请注意这里我使用的版本是Java 12,默认的垃圾收集器是G1。


  • 使用JMeter压测工具向程序发送测试请求,访问的路径是/greeting。
  • 使用GCViewer工具打开GC日志

image.png

上部的蓝线表示已使用堆大小,周期上下震荡,这是对象池要扩展到200000才会清空。

绿线表示新生代GC活动,当堆使用率上去了,会触发频繁GC活动。

竖线表示Full GC,伴随着Full GC,蓝线会下降,这说明Full GC收集了老年代中的对象。

竖线表示Full GC,伴随着Full GC,蓝线会下降,这说明Full GC收集了老年代中的对象。


综上,Java堆大小不够:

  • GC活动频繁
    年轻代GC(绿色线)和年老代GC(黑色线)都比较密集。这说明内存空间不够,也就是Java堆的大小不够。
  • Java的堆中对象在GC之后能够被回收
    说明不是内存泄漏。


GCViewer还发现累计GC暂停时间有55.57秒:

image.png

因此我们的解决方案是调大Java堆的大小,像下面这样:

java -Xmx2048m -Xss256k -verbosegc -Xlog:gc*,gc+ref=debug,gc+heap=debug,gc+age=trace:file=gc-%p-%t.log:tags,uptime,time,level:filecount=2,filesize=100m -jar target/demo-0.0.1-SNAPSHOT.jar

生成的新的GC log分析图如下:

image.png

你可以看到,没有发生Full GC,并且年轻代GC也没有那么频繁了,并且累计GC暂停时间只有3.05秒。

image.png

总结

CMS来说,我们要合理设置年轻代和年老代的大小。你可能会问该如何确定它们的大小呢?这是一个迭代的过程,可以先采用JVM的默认值,然后通过压测分析GC日志。


如果我们看年轻代的内存使用率处在高位,导致频繁的Minor GC,而频繁GC的效率又不高,说明对象没那么快能被回收,这时年轻代可以适当调大一点。


如果我们看年老代的内存使用率处在高位,导致频繁的Full GC,这样分两种情况:如果每次Full GC后年老代的内存占用率没有下来,可以怀疑是内存泄漏;如果Full GC后年老代的内存占用率下来了,说明不是内存泄漏,我们要考虑调大年老代。


对于G1收集器来说,我们可以适当调大Java堆,因为G1收集器采用了局部区域收集策略,单次垃圾收集的时间可控,可以管理较大的Java堆。

若年轻代和年老代都设置很大,会咋样?

设置过大,回收频率会降低,导致单次回收时间过长,因为需要回收的对象更多,导致GC stop the world时间过长,引起GC停顿时间过长,导致请求无法及时处理

年轻代设置过大

  • 生命周期长的对象会长时间停留在年轻代,在S0和S1来回复制,增加复制开销
  • 年轻代太大会增加YGC每次停顿的时间,不过通过根节点遍历,OopMap,old scan等优化手段这一部分的开销其实比较少
  • 浪费内存

老年代设置过大

  • 降低FGC频率,有些堆外内存比如直接内存,需要靠FGC辅佐回收的,就会无法释放。万一剩余的堆外内存不够程序也会宕机
  • 单次FGC时间变长,如果在夜深人静的时候主动触发FGC内啥影响,如果白天业务繁忙的时候就凉凉
  • 增加YGC时间,old scan阶段会扫描老年代,而且这个阶段耗时在YGC总比重很大


相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
目录
相关文章
|
1天前
|
缓存 算法 Java
JVM实战—4.JVM垃圾回收器的原理和调优
本文详细探讨了JVM垃圾回收机制,包括新生代ParNew和老年代CMS垃圾回收器的工作原理与优化方法。内容涵盖ParNew的多线程特性、默认线程数设置及适用场景,CMS的四个阶段(初始标记、并发标记、重新标记、并发清理)及其性能分析,以及如何通过合理分配内存区域、调整参数(如-XX:SurvivorRatio、-XX:MaxTenuringThreshold等)来优化垃圾回收。此外,还结合电商大促案例,分析了系统高峰期的内存使用模型,并总结了YGC和FGC的触发条件与优化策略。最后,针对常见问题进行了汇总解答,强调了基于系统运行模型进行JVM参数调优的重要性。
JVM实战—4.JVM垃圾回收器的原理和调优
|
3天前
|
消息中间件 Java 应用服务中间件
JVM实战—1.Java代码的运行原理
本文介绍了Java代码的运行机制、JVM类加载机制、JVM内存区域及其作用、垃圾回收机制,并汇总了一些常见问题。
JVM实战—1.Java代码的运行原理
|
1天前
|
消息中间件 算法 Java
JVM实战—5.G1垃圾回收器的原理和调优
本文详细解析了G1垃圾回收器的工作原理及其优化方法。首先介绍了G1通过将堆内存划分为多个Region实现分代回收,有效减少停顿时间,并可通过参数设置控制GC停顿时长。接着分析了G1相较于传统GC的优势,如停顿时间可控、大对象不进入老年代等。还探讨了如何合理设置G1参数以优化性能,包括调整新生代与老年代比例、控制GC频率及避免Full GC。最后结合实际案例说明了G1在大内存场景和对延迟敏感业务中的应用价值,同时解答了关于内存碎片、Region划分对性能影响等问题。
|
3月前
|
算法 网络协议 Java
【JVM】——GC垃圾回收机制(图解通俗易懂)
GC垃圾回收,标识出垃圾(计数机制、可达性分析)内存释放机制(标记清除、复制算法、标记整理、分代回收)
|
3月前
|
Rust 安全 Java
JVM原理与实现——Synchronized关键字
在多线程Java程序中,`Synchronized`关键字用于确保线程安全。本文深入探讨其工作原理,通过分析字节码`monitorenter`和`monitorexit`,解释JVM如何实现同步机制。文章展示了`Synchronized`方法的编译结果,并详细解析了轻量锁和重度锁的实现过程,包括Mark Word的状态变化及CAS操作的应用。最后简要介绍了`ObjectMonitor::enter()`函数在获取重度锁时的作用。
JVM原理与实现——Synchronized关键字
|
3月前
|
监控 架构师 Java
Java虚拟机调优的艺术:从入门到精通####
本文作为一篇深入浅出的技术指南,旨在为Java开发者揭示JVM调优的神秘面纱,通过剖析其背后的原理、分享实战经验与最佳实践,引领读者踏上从调优新手到高手的进阶之路。不同于传统的摘要概述,本文将以一场虚拟的对话形式,模拟一位经验丰富的架构师向初学者传授JVM调优的心法,激发学习兴趣,同时概括性地介绍文章将探讨的核心议题——性能监控、垃圾回收优化、内存管理及常见问题解决策略。 ####
|
4月前
|
监控 Java 编译器
Java虚拟机调优指南####
本文深入探讨了Java虚拟机(JVM)调优的精髓,从内存管理、垃圾回收到性能监控等多个维度出发,为开发者提供了一系列实用的调优策略。通过优化配置与参数调整,旨在帮助读者提升Java应用的运行效率和稳定性,确保其在高并发、大数据量场景下依然能够保持高效运作。 ####
65 1
|
5天前
|
存储 缓存 算法
JVM简介—1.Java内存区域
本文详细介绍了Java虚拟机运行时数据区的各个方面,包括其定义、类型(如程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区和直接内存)及其作用。文中还探讨了各版本内存区域的变化、直接内存的使用、从线程角度分析Java内存区域、堆与栈的区别、对象创建步骤、对象内存布局及访问定位,并通过实例说明了常见内存溢出问题的原因和表现形式。这些内容帮助开发者深入理解Java内存管理机制,优化应用程序性能并解决潜在的内存问题。
JVM简介—1.Java内存区域
|
2月前
|
存储 设计模式 监控
快速定位并优化CPU 与 JVM 内存性能瓶颈
本文介绍了 Java 应用常见的 CPU & JVM 内存热点原因及优化思路。
654 166
|
2天前
|
消息中间件 Java 应用服务中间件
JVM实战—2.JVM内存设置与对象分配流转
本文详细介绍了JVM内存管理的相关知识,包括:JVM内存划分原理、对象分配与流转、线上系统JVM内存设置、JVM参数优化、问题汇总。
JVM实战—2.JVM内存设置与对象分配流转