likes
comments
collection
share

JVM调优知识及实践

作者站长头像
站长
· 阅读数 1

内存溢出和内存泄露怎么排查?

内存溢出

概念

内存溢出: 申请的内存大于系统能提供的内存。

溢出原因

  1. 本地直接内存溢出:本地直接内存设的太小导致溢出。设置直接内存最大值-XX:MaxDirectMemorySize,若不指定则默认与Java堆最大值一致。

  2. 虚拟机栈和本地方法栈溢出:如果虚拟机的栈内存允许动态扩展,并且方法递归层数太深时,导致扩展栈容量时无法申请到足够内存。

  3. 方法区溢出:运行时生成大量动态类时会内存溢出。

CGlib动态代理:CGlib动态代理产生大量类填满了整个方法区(方法区存常量池、类信息、方法信息),直到溢出。CGlib动态代理是在内存中构建子类对象实现对目标对象功能扩展,如果enhancer.setUseCache(false);,即关闭用户缓存,那么每次创建代理对象都是一个新的实例,创建过多就会导致方法区溢出。注意JDK动态代理不会导致方法区溢出。

  1. 堆溢出
  • 死循环创建过多对象;
  • 集合类中有对对象的引用,使用完后未清空,使得JVM不能回收;
  • 内存中加载的数据量过于庞大,如一次从数据库取出的数据集太大、第三方接口接口传输的大对象、接收的MQ消息太大;
  • Tomcat参数设置不当导致OOM:Tomcat会给每个线程创建两个默认4M大小的缓冲区,高并发情况下会导致缓冲区创建过多,导致OOM。

OOM的排查和解决

使用JDK自带的命令行调优工具 ,判断是否有OOM:

  • 使用jps命令查看当前Java进程;
  • 使用jstat命令多次统计GC,比较GC时长占运行时长的比例;
  • 如果比例超过20%,就代表堆压力已经很大了;如果比例超过98%,说明这段时期内几乎一直在GC,堆里几乎没有可用空间,随时都可能抛出 OOM 异常。

使用VisualVM工具分析dump文件,定位OOM的原因:

  • 生成堆dump文件
      1. jmap -dump命令手动生成
      1. JVM参数设置,内存溢出后自动生成dump文件,设置路径;-XX:+HeapDumpOnOutOfMemoryError、-XX:HeapDumpPath
  • VisualVM解析dump文件:
      1. 定位大对象:对象会按内存大小排序,查看内存占用最大的对象;
      1. 查看这个对象被谁引用:点击对象的线程信息,看大对象被哪个线程调用。
      1. 定位具体代码: 看线程的方法调用链和堆栈信息,查看大对象所属类和第几行,定位到具体代码,解决问题。

内存泄露

当应用内存随时间持续升高而无明显下降,大概率存在内存泄露。

概念

内存泄漏: 不再使用的对象仍然被引用,导致GC无法回收;

内存泄露的9种情况

  1. 静态容器里的对象:静态集合类的生命周期与 JVM 程序一致,容器里的对象引用也将一直被引用得不到GC;Java里不准静态方法引用非静态方法也是防止内存泄漏。

  2. 外部类跟随内部类被引用:内部类持有外部类,这个内部类对象被长期引用了,即使那个外部类实例对象不再被使用,但由于内部类持有外部类的实例对象,这个外部类对象将不会被垃圾回收,这也会造成内存泄漏。

  3. 数据库、网络、IO等连接忘记关闭:在对数据库进行操作的过程中,首先需要建立与数据库的连接,当不再使用时,需要调用 close 方法来释放与数据库的连接。如果对 Connection、Statement 或 ResultSet 不显性地关闭,将会造成大量的对象无法被回收,从而引起内存泄漏。

  4. 缓存引用忘删除:一旦你把对象引用放入到缓存中,他就很容易遗忘,缓存忘了删除,将导致引用一直存在。

  5. 使用线程池时,ThreadLocal忘记remove():使用线程池的时候,ThreadLocal 需要在使用完线程中的线程变量手动 remove(),否则会内存泄漏。因为线程执行完后没有销毁而是被线程池回收,导致ThreadLocal中的对象不能被自动垃圾回收。

内存泄漏的排查和解决

使用JDK自带的工具:

  • 每隔一段较长的时间通过jstat命令采样多组 OU(老年代内存量) 的最小值; 如果这些最小值在上涨,说明无法回收对象在不断增加,可能是内存泄漏导致的。
  • jmap查询应用堆内存占用较高的对象
jmap -histo:live <target_pid> | head -n 101
  • VisualVM监视诊断内存泄漏:
    • 1.生成堆转储文件:jmap -dump 命令生成dump文件
    • 2.可疑点:在VisualVM中打开dump文件进行分析,对比分析两次快照间新增的对象和引用链或者排查内存占用较高的对象,找到内存泄漏可疑点。
    • 3.可疑线程:可疑点查看详情(Details),找到可疑线程。
    • 4.定位代码:查看线程调用栈(See stacktrace),找到问题代码的具体位置。

注: VisualVM也提供了内存泄漏检测工具。在“内存”选项卡中,选择“Leak Suspects”子标签页,VisualVM会尝试自动检测是否存在内存泄漏迹象。

CPU占用高怎么排查?

原因

CPU利用率过高,大量线程并发执行任务导致CPU飙升。例如锁等待(例如CAS不断自旋)、多线程都陷入死循环、Redis被攻击、网站被攻击、文件IO、网络IO。

定位步骤

  • 定位进程ID:通过top命令查看当前服务CPU使用最高的进程,获取到对应的pid(进程ID)
  • 定位线程ID:使用top -Hp pid,显示指定进程下面的线程信息,找到消耗CPU最高的线程id
  • 线程ID转十六进制:转十六进制是因为下一步jstack打印的线程快照(线程正在执行方法的堆栈集合)里线程id是十六进制。
  • 定位代码:使用jstack pid | grep tid(十六进制),打印线程快照,找到线程执行的代码。一般如果有死锁的话就会显示线程互相占用情况。
  • 解决问题:优化代码、增加系统资源(增多服务器、增大内存)。

JVM GC 有哪些核心监控指标?合理范围应该是多少?

核心指标:

  1. jvm.gc.time: 这个指标通常指的是JVM自从启动以来所有垃圾回收(包括年轻代GC Minor GC和老年代GC Major GC/Full GC)所花费的总时间。它是各个GC事件累积的时间总和。
  2. jvm.gc.meantime: 这个指标可能表示每次垃圾回收平均花费的时间,也就是总的垃圾回收时间除以垃圾回收的次数。不过,“meantime”并非标准JMX监控属性名称,而是可能出现在某些监控工具中用来表示平均GC时间的概念。
  3. jvm.fullgc.count: 这个指标具体记录了从JVM启动以来,发生Full GC(全局垃圾回收,包括清理年轻代和老年代的所有对象)的次数。Full GC通常是代价最高的垃圾回收类型,因为它会导致所有的Java线程停止(Stop-The-World)。
  4. jvm.fullgc.time: 此指标统计的是JVM自启动以来所有Full GC事件的累计执行时间。每一次完整的全局垃圾回收事件所耗费的时间都会累加到这个值中。

合理范围:

每个服务对性能指标的要求是不同的,没有固定数值。以下是比较合理的指标范围:

  • 每分钟的GC耗时在1s以内,500ms以内尤佳

  • 每次YGC耗时在100ms以内,50ms以内尤佳

  • FGC最多几小时1次,1天不到1次尤佳

  • 每次FGC耗时在1s以内,500ms以内尤佳

有进行过JVM调优吗?

1. 如果使用合理的 JVM 参数配置,在大多数情况应该是不需要调优的。

  • -XX:NewRatio=2,年轻代:老年代=1:2
  • -XX:SurvivorRatio=8,eden:survivor=8:1
  • 堆内存设置为物理内存的3/4左右

一小部分场景,可能需要用到 JVM 调优。比如因为代码bug原因或者使用工具不当可能会引起较为频繁的minor gc和full gc;或者是程序对于性能要求比较高,要求gc暂停时间要特别短。

2. 调优的步骤

2.1 监控发现问题

通过监控工具例如Prometheus+Grafana,监控服务器有没有以下情况,有的话需要调优:

  • GC频繁
  • CPU负载过高
  • OOM
  • 内存泄露
  • 死锁
  • 程序响应时间较长

2.2 工具分析定位问题

2.2.1 需要用到一些命令来查看JVM 内存指标JVM GC指标来去分析。

JVM 内存指标

  • 查看当前 JVM 堆内存参数配置是否合理

  • 查看堆中对象的统计信息

  • 查看堆存储快照,分析内存的占用情况

  • 查看堆各区域的内存增长是否正常

  • 查看是哪个区域导致的GC

  • 查看GC后能否正常回收到内存

常见的命令:

// 查看当前的 JVM 参数配置
ps -ef | grep java
// 查看 Java 进程的配置信息,包括系统属性和JVM命令行标志
jinfo pid
// 输出 Java 进程当前的 gc 情况
jstat -gc pid
// 输出 Java 堆详细信息
jmap -heap pid
// 显示堆中对象的统计信息
jmap -histo:live pid
// 生成 Java 堆存储快照dump文件
jmap -F -dump:format=b,file=dumpFile.phrof pid

JVM GC指标

  • 查看每分钟GC时间是否正常

  • 查看每分钟YGC次数是否正常

  • 查看FGC次数是否正常

  • 查看单次FGC时间是否正常

  • 查看单次GC各阶段详细耗时,找到耗时严重的阶段

  • 查看对象的动态晋升年龄是否正常

JVM 的 GC指标一般是从 GC 日志里面查看,默认的 GC 日志可能比较少,我们可以添加以下参数,来丰富我们的GC日志输出,方便我们定位问题。

// 打印GC的详细信息
-XX:+PrintGCDetails
// 打印GC的时间戳
-XX:+PrintGCDateStamps
// 在GC前后打印堆信息
-XX:+PrintHeapAtGC
// 打印Survivor区中各个年龄段的对象的分布信息
-XX:+PrintTenuringDistribution
// JVM启动时输出所有参数值,方便查看参数是否被覆盖
-XX:+PrintFlagsFinal
// 打印GC时应用程序的停止时间
-XX:+PrintGCApplicationStoppedTime
// 打印在GC期间处理引用对象的时间(仅在PrintGCDetails时启用)
-XX:+PrintReferenceGC

2.2.2 VisualVM分析堆转储文件

VisualVM是一款由Oracle开发的免费、强大的多合一Java性能分析工具,它集成了多个JDK命令行工具的功能,为用户提供了一个直观的图形化界面,用于监控、分析和调试Java应用程序的性能。 VisualVM 可以分析 heap dump 文件。在进行内存分析时,只要获得了反映当前设备内存映像的 hprof 文件,通过 VisualVM 打开就可以直观地看到当前的内存信息。一般说来,这些内存信息包含:

  • 所有的对象信息,包括对象实例、成员变量、存储于栈中的基本类型值和存储于堆中的其他对象的引用值。
  • 所有的类信息,包括 classloader、类名称、父类、静态变量等。
  • GCRoot 到所有的这些对象的引用路径。
  • 线程信息,包括线程的调用栈及此线程的线程局部变量(TLS)。

使用方式:

  • 安装并启动VisualVM
  • jmap -dump 命令生成dump文件
  • 在VisualVM中打开dump文件进行分析

2.3 性能调优解决问题

2.3.1 JVM调优参数解决

调优JVM参数主要关注停顿时间和吞吐量,两者不可兼得,提高吞吐量会拉长停顿时间。

JVM常用调优参数汇总

//调整内存大小
-XX:MetaspaceSize=128m(元空间默认大小)
-XX:MaxMetaspaceSize=128m(元空间最大大小)
-Xms1024m(堆最大大小)
-Xmx1024m(堆默认大小)
-Xmn256m(新生代大小)
-Xss256k(栈最大深度大小)
 
//调整内存比例
 //伊甸园:幸存区
-XX:SurvivorRatio=8(伊甸园:幸存区=8:2
 //新生代和老年代的占比
 -XX:NewRatio=4  //表示新生代:老年代 = 1:4 即老年代占整个堆的4/5;默认值=2
 
//修改垃圾回收器
//设置Serial垃圾收集器(新生代)
//-XX:+UseSerialGC
 //设置PS+PO,新生代使用功能Parallel Scavenge 老年代将会使用Parallel Old收集器
//-XX:+UseParallelOldGC
 //CMS垃圾收集器(老年代)
//-XX:+UseConcMarkSweepGC
 //设置G1垃圾收集器
-XX:+UseG1GC
 
//GC停顿时间,垃圾收集器会尝试用各种手段达到这个时间
 -XX:MaxGCPauseMillis
 
 //进入老年代最小的GC年龄,年轻代对象转换为老年代对象最小年龄值,JDK8默认值15,JDK9默认值7
 -XX:InitialTenuringThreshold=7
 //新生代可容纳的最大对象,大于则直接会分配到老年代,0代表没有限制。
  -XX:PretenureSizeThreshold=1000000
 
 //使用多少比例的老年代后开始CMS收集,默认是68%,如果频繁发生SerialOld卡顿,应该调小
 -XX:CMSInitiatingOccupancyFraction 
 //G1混合垃圾回收周期中要包括的旧区域设置占用率阈值。默认占用率为 65%
 -XX:G1MixedGCLiveThresholdPercent=65
 
 //Heap Dump(堆转储)文件
 //当发生OutOfMemoryError错误时,自动生成堆转储文件。
-XX:+HeapDumpOnOutOfMemoryError 
 //错误输出地址
-XX:HeapDumpPath=/Users/a123/IdeaProjects/java-test/logs/dump.hprof
 
 //GC日志
-XX:+PrintGCDetails(打印详细GC日志)
-XX:+PrintGCTimeStamps:打印GC时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps:打印GC时间戳(以日期格式)
-Xlog:gc:(打印gc日志地址)

减少停顿时间:MaxGCPauseMillis

STW:Stop The World,暂停其他所有工作线程直到收集结束。垃圾收集器做垃圾回收中断应用执行的时间。

可以通过-XX:MaxGCPauseMillis参数进行设置,以毫秒为单位,至少大于1。

//GC停顿时间,垃圾收集器会尝试用各种手段达到这个时间 -XX:MaxGCPauseMillis=10 G1回收器默认200ms停顿时长。

提高吞吐量:GCTimeRatio

吞吐量=运行时长/(运行时长+GC时长)。

通过-XX:GCTimeRatio=n参数可以设置吞吐量,99代表吞吐量为99%, 一般吞吐量不能低于95%。

示例:

-XX:GCTimeRatio=99 吞吐量太高会拉长停顿时间,造成用户体验下降。

调整堆内存大小

根据程序运行时老年代存活对象大小(记为x)进行调整,整个堆内存大小设置为X的3~4倍。年轻代占堆内存的3/8。

-Xms:初始堆内存大小。默认:物理内存小于192MB时,默认为物理内存的1/2;物理内存大192MB且小于128GB时,默认为物理内存的1/4;物理内存大于等于128GB时,都为32GB。 -Xmx:最大堆内存大小,建议保持和初始堆内存大小一样。因为从初始堆到最大堆的过程会有一定的性能开销,而且现在内存不是稀缺资源。 -Xmn:年轻代大小。JDK官方建议年轻代占整个堆大小空间的3/8左右。 示例:

//调整内存大小
-XX:MetaspaceSize=128m(元空间默认大小)
-XX:MaxMetaspaceSize=128m(元空间最大大小)
-Xms1024m(堆最大大小)
-Xmx1024m(堆默认大小)
-Xmn256m(新生代大小)
-Xss256k(栈最大深度大小)

调整堆内存比例

调整伊甸园区和幸存区比例、新生代和老年代比例。

Young GC频繁时,我们可以提高新生代在堆内存中的比例、提高伊甸园区在新生代的比例,令新生代不那么快被填满。

默认情况,伊甸园区:S0:S1=8:1:1,新生代:老年代=1:2。

示例:

//调整内存比例
 //伊甸园:幸存区
-XX:SurvivorRatio=8(伊甸园:幸存区=8:2
 //新生代和老年代的占比
-XX:NewRatio=4  //表示新生代:老年代 = 1:4 即老年代占整个堆的4/5;默认值=2

调整升老年代年龄

JDK8的Young GC默认把15岁的对象移动到老年代。JDK9默认值改为7。

当Full GC频繁时,我们提高升老年龄,让年轻代的对象多在年轻代待一会,从而降低Full GC频率。

 //进入老年代最小的GC年龄,年轻代对象转换为老年代对象最小年龄值,JDK8默认值15,JDK9默认值7
 -XX:InitialTenuringThreshold=7

调整大对象阈值

Young GC时大对象会不顾年龄直接移动到老年代。当Full GC频繁时,我们关闭或提高大对象阈值,让老年代更迟填满。

默认是0,即大对象不会直接在YGC时移到老年代。

  //新生代可容纳的最大对象,大于则直接会分配到老年代,0代表没有限制。
  -XX:PretenureSizeThreshold=1000000

调整GC的触发条件

  • CMS调整老年代触发回收比例 CMS的并发标记和并发清除阶段是用户线程和回收线程并发执行,如果老年代满了再回收会导致用户线程被强制暂停。所以我们修改回收条件为老年代的60%,保证回收时预留足够空间放新对象。CMS默认是老年代68%时触发回收机制。
 //使用多少比例的老年代后开始CMS收集,默认是68%,如果频繁发生SerialOld卡顿,应该调小
 -XX:CMSInitiatingOccupancyFraction=68
  • G1调整存活阈值 超过存活阈值的Region,其内对象会被混合回收到老年代。G1回收时也要预留空间给新对象。存活阈值默认85%,即当一个内存区块中存活对象所占比例超过 85% 时,这些对象就会通过 Mixed GC 内存整理并晋升至老年代内存区域。
//G1混合垃圾回收周期中要包括的旧区域设置占用率阈值。默认占用率为 65%
 -XX:G1MixedGCLiveThresholdPercent=65

2.3.2 选择合适的垃圾回收器解决

JVM调优最实用、最有效的方式是升级垃圾回收器,根据CPU核数,升级当前版本支持的最新回收器。

CPU单核,那么毫无疑问Serial 垃圾收集器是你唯一的选择。

CPU多核,关注吞吐量 ,那么选择Parallel Scavenge+Parallel Old组合(JDK8默认)。

CPU多核,关注用户停顿时间,JDK版本1.6或者1.7,那么选择ParNew+CMS,吞吐量降低但是低停顿。

CPU多核,关注用户停顿时间,JDK1.8及以上,JVM可用内存6G以上,那么选择G1。

示例:

//设置Serial垃圾收集器(新生代)
-XX:+UseSerialGC

//设置PS+PO,新生代使用功能Parallel Scavenge 老年代将会使用Parallel Old收集器
-XX:+UseParallelOldGC

//CMS垃圾收集器(老年代)
-XX:+UseConcMarkSweepGC

//设置G1垃圾收集器
-XX:+UseG1GC

3. 调优的例子

监控发现问题 上午8点是我们的业务高峰,一到高峰的时候,用户感觉到明显卡顿,监控工具(例如Prometheus和Grafana)发现TP99(99%请求在多少ms内完成)时长明显变高,有明显的的毛刺;内存使用率也不稳定,会周期性增大再降低,于是怀疑是GC导致。

命令行分析问题 通过jstat -gc观察服务器的GC情况,发现Young GC频率提高成原来的10倍,Full GC频率提高成原来的四倍。正常YGC 10min一次,FGC 10h一次。异常YGC 1min一次,FGC 3h一次;

所以主要问题是Young GC频繁,进而导致Full GC频繁。Full GC频繁会触发STW,导致TP99耗时上升。

解决方案

  • 排查内存泄漏、大对象、BUG。
  • 增大堆内存:服务器加8G内存条,同时提高初始堆内存、最大堆内存。-Xms、-Xmx。
  • 提高新生代比例:新生代和老年代默认比例是1:2。-XX:NewRatio=由4改为默认的2。
  • 降低升老年龄:让存活对象更快进入老年代。-XX:InitialTenuringThreshold=15(JDK8默认)改成7(JDK9默认)。
  • 设置大对象阈值:让大于1M的大对象直接进入老年代。-XX:PretenureSizeThreshold=0(默认)改为1000000(单位是字节)。
  • 垃圾回收器升级为G1:因为是JDK8,所以直接由默认的Parallel Scavenge+Parallel Old组合,升级为低延时的G1回收器。如果是JDK7版本,不支持G1,可以修改成ParNew+CMS或Parallel Scavenge+CMS,以降低吞吐量为代价降低停顿时间,-XX:CMSInitiatingOccupancyFraction。
  • 降低G1的存活阈值:超过存活阈值的Region,其内对象会被混合回收到老年代。降低存活阈值,更早进入老年代。-XX:G1MixedGCLiveThresholdPercent=90设为默认的85。

调优效果 调优后我们重新进行了一次压测,发现TP99耗时较之前降低60%。FullGC耗时降低80%,YoungGC次数减少30%。TP99耗时基本持平,完全符合预期。