一只阿木木

详解JVM 内存结构与实战调优总结

详解JVM 内存结构与实战调优总结

Image

Image

详解JVM 内存结构与实战调优总结

GC优化案例做个总结:

  • 1在进行GC优化之前,需要确认项目的架构和代码等已经没有优化空间。我们不能指望一个系统架构有缺陷或者代码层次优化没有穷尽的应用,通过GC优化令其性能达到一个质的飞跃。

  • 2其次,虚拟机内部已有很多优化来保证应用的稳定运行,所以不要为了调优而调优,不当的调优可能适得其反。

  • 3最后,GC优化是一个系统而复杂的工作,没有万能的调优策略可以满足所有的性能指标。

GC优化必须建立在我们深入理解各种垃圾回收器的基础上,才能有事半功倍的效果。

一 JVM 内存结构

当代主流虚拟机(Hotspot VM)的垃圾回收都采用“分代回收”的算法。“分代回收”是基于这样一个事实:对象的生命周期不同,所以针对不同生命周期的对象可以采取不同的回收方式,以便提高回收效率。

Hotspot VM将内存划分为不同的物理区,就是“分代”思想的体现。

JVM内存主要由新生代、老年代、永久代构成。

Image

  • ① 新生代(Young Generation):大多数对象在新生代中被创建,其中很多对象的生命周期很短。每次新生代的垃圾回收(又称Minor GC)后只有少量对象存活,所以选用复制算法,只需要少量的复制成本就可以完成回收。

新生代内又分三个区:一个Eden区,两个Survivor区(一般而言),大部分对象在Eden区中生成。当Eden区满时,还存活的对象将被复制到两个Survivor区(中的一个)。当这个Survivor区满时,此区的存活且不满足“晋升”条件的对象将被复制到另外一个Survivor区。对象每经历一次Minor GC,年龄加1,达到“晋升年龄阈值”后,被放到老年代,这个过程也称为“晋升”。显然,“晋升年龄阈值”的大小直接影响着对象在新生代中的停留时间,在Serial和ParNew GC两种回收器中,“晋升年龄阈值”通过参数MaxTenuringThreshold设定,默认值为15。

  • ② 老年代(Old Generation):在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代,该区域中对象存活率高。老年代的垃圾回收(又称Major GC)通常使用“标记-清理”或“标记-整理”算法。整堆包括新生代和老年代的垃圾回收称为Full GC(HotSpot VM里,除了CMS之外,其它能收集老年代的GC都会同时收集整个GC堆,包括新生代)。

  • ③ 永久代(Perm Generation):主要存放元数据,例如Class、Method的元信息,与垃圾回收要回收的Java对象关系不大。相对于新生代和年老代来说,该区域的划分对垃圾回收影响比较小。

常见垃圾回收器

不同的垃圾回收器,适用于不同的场景。常用的垃圾回收器:

串行(Serial)回收器是单线程的一个回收器,简单、易实现、效率高。并行(ParNew)回收器是Serial的多线程版,可以充分的利用CPU资源,减少回收的时间。吞吐量优先(Parallel Scavenge)回收器,侧重于吞吐量的控制。并发标记清除(CMS,Concurrent Mark Sweep)回收器是一种以获取最短回收停顿时间为目标的回收器,该回收器是基于“标记-清除”算法实现的。

GC日志

每一种回收器的日志格式都是由其自身的实现决定的,换而言之,每种回收器的日志格式都可以不一样。但虚拟机设计者为了方便用户阅读,将各个回收器的日志都维持一定的共性。JavaGC日志 中简单介绍了这些共性。

参数基本策略

各分区的大小对GC的性能影响很大。如何将各分区调整到合适的大小,分析活跃数据的大小是很好的切入点。

活跃数据的大小是指,应用程序稳定运行时长期存活对象在堆中占用的空间大小,也就是Full GC后堆中老年代占用空间的大小。可以通过GC日志中Full GC之后老年代数据大小得出,比较准确的方法是在程序稳定后,多次获取GC数据,通过取平均值的方式计算活跃数据的大小。

参数倍数
总大小3-4 倍活跃数据的大小
新生代1-1.5 活跃数据的大小
老年代2-3 倍活跃数据的大小
永久代1.2-1.5 倍Full GC后的永久代空间占用

例如,根据GC日志获得老年代的活跃数据大小为300M,那么各分区大小可以设为:

总堆:1200MB = 300MB × 4* 新生代:450MB = 300MB × 1.5* 老年代:750MB = 1200MB - 450MB*

这部分设置仅仅是堆大小的初始值,后面的优化中,可能会调整这些值,具体情况取决于应用程序的特性和需求。

二 优化步骤

GC优化一般步骤可以概括为:确定目标、优化参数、验收结果。

确定目标 明确应用程序的系统需求是性能优化的基础,系统的需求是指应用程序运行时某方面的要求,譬如:- 高可用,可用性达到几个9。- 低延迟,请求必须多少毫秒内完成响应。- 高吞吐,每秒完成多少次事务。

明确系统需求之所以重要,是因为上述性能指标间可能冲突。比如通常情况下,缩小延迟的代价是降低吞吐量或者消耗更多的内存或者两者同时发生。

由于笔者所在团队主要关注高可用和低延迟两项指标,所以接下来分析,如何量化GC时间和频率对于响应时间和可用性的影响。通过这个量化指标,可以计算出当前GC情况对服务的影响,也能评估出GC优化后对响应时间的收益,这两点对于低延迟服务很重要。

举例:假设单位时间T内发生一次持续25ms的GC,接口平均响应时间为50ms,且请求均匀到达,根据下图所示:

Image那么有(50ms+25ms)/T比例的请求会受GC影响,其中GC前的50ms内到达的请求都会增加25ms,GC期间的25ms内到达的请求,会增加0-25ms不等,如果时间T内发生N次GC,受GC影响请求占比=(接口响应时间+GC时间)×N/T 。可见无论降低单次GC时间还是降低GC次数N都可以有效减少GC对响应时间的影响。

优化

通过收集GC信息,结合系统需求,确定优化方案,例如选用合适的GC回收器、重新设置内存比例、调整JVM参数等。

进行调整后,将不同的优化方案分别应用到多台机器上,然后比较这些机器上GC的性能差异,有针对性的做出选择,再通过不断的试验和观察,找到最合适的参数。

验收优化结果

将修改应用到所有服务器,判断优化结果是否符合预期,总结相关经验。

接下来,我们通过三个案例来实践以上的优化流程和基本原则(本文中三个案例使用的垃圾回收器均为ParNew+CMS,CMS失败时Serial Old替补)。

三 JVM调试工具

通过案例,我们知道要有一个更系统的调优方案,首先就要系统得了解调优工具。

3.1 jstat

jstat命令可以查看堆内存各部分的使用量,以及加载类的数量。命令的格式如下:

jstat [-命令选项] [vmid] [间隔时间/毫秒] [查询次数]

查看class加载统计

F:\t>jstat -class 12076Loaded  Bytes  Unloaded  Bytes     Time
5962 10814.2 0 0.0 3.75

查看编译统计

F:\t>jstat -compiler 12076Compiled Failed Invalid   Time   FailedType FailedMethod   3115      0       0     3.43          0

垃圾回收统计

F:\t>jstat -gc 12076S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU
CCSC CCSU YGC YGCT FGC FGCT GCT3584.0 6656.0 3412.1 0.0 180224.0 89915.4 61440.0 5332.1 27904.0 26267.3 3840.0 3420.8 6 0.036 1 0.026 0.062#也可以指定打印的间隔和次数,每1秒中打印一次,共打印5次F:\t>jstat -gc 12076 1000 5S0C S1C S0U S1U EC EU OC OU MC MU
CCSC CCSU YGC YGCT FGC FGCT GCT3584.0 6656.0 3412.1 0.0 180224.0 89915.4 61440.0 5332.1 27904.0 26267.3 3840.0 3420.8 6 0.036 1 0.026 0.0623584.0 6656.0 3412.1 0.0 180224.0 89915.4 61440.0 5332.1 27904.0 26267.3 3840.0 3420.8 6 0.036 1 0.026 0.0623584.0 6656.0 3412.1 0.0 180224.0 89915.4 61440.0 5332.1 27904.0 26267.3 3840.0 3420.8 6 0.036 1 0.026 0.0623584.0 6656.0 3412.1 0.0 180224.0 89915.4 61440.0 5332.1 27904.0 26267.3 3840.0 3420.8 6 0.036 1 0.026 0.0623584.0 6656.0 3412.1 0.0 180224.0 89915.4 61440.0 5332.1 27904.0 26267.3 3840.0 3420.8 6 0.036 1 0.026 0.062

3.2 Jmap的使用以及内存溢出分析

前面通过jstat可以对jvm堆的内存进行统计分析,而jmap可以获取到更加详细的内容,如:内存使用情况的汇总、对内存溢出的定位与分析。

查看内存使用情况

[root@node01 ~]# jmap -heap 6219Attaching to process ID 6219, please wait... 
Debugger attached successfully.
Server compiler detected.
JVM version is 25.141-b15
using thread-local object allocation.
Parallel GC with 2 thread(s)
Heap Configuration: #堆内存配置信息MinHeapFreeRatio = 0MaxHeapFreeRatio = 100MaxHeapSize = 488636416 (466.0MB)
NewSize = 10485760 (10.0MB)
MaxNewSize = 162529280 (155.0MB)
OldSize = 20971520 (20.0MB)
NewRatio = 2SurvivorRatio = 8MetaspaceSize = 21807104 (20.796875MB)
CompressedClassSpaceSize = 1073741824 (1024.0MB)
MaxMetaspaceSize = 17592186044415 MB
G1HeapRegionSize = 0 (0.0MB)
Heap Usage: # 堆内存的使用情况PS Young Generation #年轻代Eden Space:
capacity = 123731968 (118.0MB)
used = 1384736 (1.320587158203125MB)
free = 122347232 (116.67941284179688MB)1.1191416594941737% used
From Space:
capacity = 9437184 (9.0MB)
used = 0 (0.0MB)
free = 9437184 (9.0MB)0.0% used
To Space:
capacity = 9437184 (9.0MB)
used = 0 (0.0MB)
free = 9437184 (9.0MB)0.0% used
PS Old Generation #年老代capacity = 28311552 (27.0MB)
used = 13698672 (13.064071655273438MB)
free = 14612880 (13.935928344726562MB)48.38545057508681% used13648 interned Strings occupying 1866368 bytes.

查看内存中对象数量及大小

#查看所有对象,包括活跃以及非活跃的jmap -histo <pid> | more#查看活跃对象 jmap -histo:live <pid> | more
[root@node01 ~]# jmap -histo:live 6219 | morenum #instances #bytes class name----------------------------------------------1: 37437 7914608 [C
2: 34916 837984 java.lang.String
3: 884 654848 [B
4: 17188 550016 java.util.HashMap$Node
5: 3674 424968 java.lang.Class
6: 6322 395512 [Ljava.lang.Object;
7: 3738 328944 java.lang.reflect.Method
8: 1028 208048 [Ljava.util.HashMap$Node;
9: 2247 144264 [I
10: 4305 137760 java.util.concurrent.ConcurrentHashMap$Node
11: 1270 109080 [Ljava.lang.String;
12: 64 84128 [Ljava.util.concurrent.ConcurrentHashMap$Node;
13: 1714 82272 java.util.HashMap
14: 3285 70072 [Ljava.lang.Class;
15: 2888 69312 java.util.ArrayList
16: 3983 63728 java.lang.Object
17: 1271 61008 org.apache.tomcat.util.digester.CallMethodRule
18: 1518 60720 java.util.LinkedHashMap$Entry
19: 1671 53472 com.sun.org.apache.xerces.internal.xni.QName
20: 88 50880 [Ljava.util.WeakHashMap$Entry;
21: 618 49440 java.lang.reflect.Constructor
22: 1545 49440 java.util.Hashtable$Entry
23: 1027 41080 java.util.TreeMap$Entry
24: 846 40608 org.apache.tomcat.util.modeler.AttributeInfo
25: 142 38032 [S
26: 946 37840 java.lang.ref.SoftReference
27: 226 36816 [[C。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。#对象说明B byte
C char
D double
F float
I int
J long
Z boolean
[ 数组,如[I表示int[]
[L+类名 其他对象

将内存使用情况dump到文件中

#用法:jmap -dump:format=b,file=dumpFileName <pid>#示例jmap -dump:format=b,file=/tmp/dump.dat 6219

Image

可以看到已经在/tmp下生成了dump.dat的文件。

通过jhat对dump文件进行分析

在上一小节中,我们将jvm的内存dump到文件中,这个文件是一个二进制的文件,不方便查看,这时我们可以借助于jhat工具进行查看。

#用法:jhat -port <port> <file>#示例:[root@node01 tmp]# jhat -port 9999 /tmp/dump.dat 
Reading from /tmp/dump.dat...
Dump file created Mon Sep 10 01:04:21 CST 2018
Snapshot read, resolving...
Resolving 204094 objects...
Chasing references, expect 40 dots........................................Eliminating duplicate references........................................Snapshot resolved.
Started HTTP server on port 9999Server is ready.

打开浏览器进行访问, 在最后由OQL查询功能.

3.3 Jmp使用以及内存溢出分析

使用MAT对内存溢出的定位与分析

内存溢出在实际的生产环境中经常会遇到,比如,不断地将数据写入到一个集合中,出现了死循环,读取超大的文件等等,都可能会造成内存溢出。

如果出现了内存溢出,首先我们需要定位到发生内存溢出的环节,并且进行分析,是正常还是非正常情况,如果是正常的需求,就应该考虑加大内存的设置,如果是非正常需求,那么就要对代码进行修改,修复这个bug。首先,我们得先学会如何定位问题,然后再进行分析。如何定位问题呢,我们需要借助于jmap与MAT工具进行定位分析。

接下来,我们模拟内存溢出的场景。

模拟内存溢出

编写代码,向List集合中添加100万个字符串,每个字符串由1000个UUID组成。如果程序能够正常执行,最后打印ok。

public class TestJvmOutOfMemory {public static void main(String[] args) { 
List<Object> list = new ArrayList<>(); for (int i = 0; i < 10000000; i++) { String str = ""; for (int j = 0; j < 1000; j++) { str += UUID.randomUUID().toString();
}
list.add(str);
}
System.out.println("ok");
}
}

为了演示效果,我们将设置执行的参数

#参数如下:-Xms8m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid5348.hprof ...
Heap dump file created [8137186 bytes in 0.032 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3332)
at
java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
at java.lang.StringBuilder.append(StringBuilder.java:136)
at cn.itcast.jvm.TestJvmOutOfMemory.main(TestJvmOutOfMemory.java:14)
Process finished with exit code 1

可以看到,当发生内存溢出时,会dump文件到java_pid5348.hprof。导入到MA T工具中进行分析,

Image

可以看到,有91.03%的内存由Object[]数组占有,所以比较可疑。分析:这个可疑是正确的,因为已经有超过90%的内存都被它占有,这是非常有可能出现内存溢出的。

Image可以看到集合中存储了大量的uuid字符串

3.4 Jsatck的使用

有些时候我们需要查看下jvm中的线程执行情况,比如,发现服务器的CPU的负载突然增高了、出现了死锁、死循环等,我们该如何分析呢?

由于程序是正常运行的,没有任何的输出,从日志方面也看不出什么问题,所以就需要看下jvm的内部线程的执行情况,然后再进行分析查找出原因。

这个时候,就需要借助于jstack命令了,jstack的作用是将正在运行的jvm的线程情况进行快照,并且打印出来。

#用法:jstack <pid>[root@node01 bin]# jstack 2203

3.5 VisualVM工具的使用

VisualVM,能够监控线程,内存情况,查看方法的CPU时间和内存中的对 象,已被GC的对象,反向查看分配的堆栈(如100个String对象分别由哪几个对象分配出来的)。

VisualVM使用简单,几乎0配置,功能还是比较丰富的,几乎囊括了其它JDK自带命令的所有功能。

内存信息 线程信息 Dump堆(本地进程 Dump线程(本地进程) 打开堆Dump。堆Dump可以用jmap来生成 打开线程Dump 生成应用快照(包含内存信息、线程信息等等) 性能分析。CPU分析(各个方法调用时间,检查哪些方法耗时多),内存分析(各类对象占用的内存,检查哪些类占用内存多) ......

启动

在jdk的安装目录的bin目录下,找到jvisualvm.exe,双击打开即可。 Image

查看 CPU、内存、类、线程运行信息
参看线程信息
也可以点击右上角Dump按钮,将线程的信息导出,其实就是执行的jstack命令。
监控远程JVM

VisualJVM不仅是可以监控本地jvm进程,还可以监控远程的jvm进程,需要借助于JMX技术实现。

什么是JMX

JMX(Java Management Extensions,即Java管理扩展)是一个为应用程序、设备、系统等植入管理功能的框架。JMX可以跨越一系列异构操作系统平台、系统体系结构和网络传输协议,灵活地开发无缝集成的系统、网络和服务管理应用。

监控Tomcat

想要监控远程的tomcat,就需要在远程的tomcat进行对JMX配置,

3.6 可视化GC日志分析工具

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

运行后就可以在E盘下生成gc.log文件。

使用GC Easy

它是一款在线的可视化工具,易用、功能强大,网站:http://gceasy.io/

3.7 jconsole

Jconsole(Java Monitoring and Management Console)是从 java5 开始,在 JD K中自带的 java 监控和管理控制台,用于对 JVM 中内存,线程和类等的监控,是一个基于 JMX(java management extensions)的 GUI 性能监测工具。

jconsole 使用 jvm 的扩展机制获取并展示虚拟机中运行的应用程序的性能和资源消耗等信息。

概览:包括堆内存使用情况、线程、类、CPU 使用情况四项信息的曲线图。

Image

线程:相当于可视化的 jstack 命令,同时也可以点击 “检测死锁” 来检查线程之间是否有死锁的情况

Image

四 CPU 占用过高排查实战

  • 1、先通过 top 命令找到消耗 cpu 很高的进程 id

假设id 是 2022, top 命令是我们在 Linux 下最常用的命令之一,它可以实时显示正在执行进程的 CPU 使用率、内存使用率以及系统负载等信息。其中上半部分显示的是系统的统计信息,下半部分显示的是进程的使用率统计信息。

  • 2、执行 top -p 2022 单独监控该进程

  • 3、在第 2 步的监控界面输入 H,获取当前进程下的所有线程信息

  • 4、找到消耗 cpu 特别高的线程编号,假设是 2734(要等待一阵)

  • 5、执行 jstack 123456 对当前的进程做 dump,输出所有的线程信息

  • 6、 将第 4 步得到的线程编号 11354 转成 16 进制是 0x7b

  • 7、 根据第 6 步得到的 0x7b 在第 5 步的线程信息里面去找对应线程内容

  • 8、 解读线程信息,定位具体代码位置

发现找是 VM 的线程占用过高,我们发现我开启的参数中,有垃圾回收的日志显示,所以我们要换一个思路,可能是我们的业务线程没问题,而是垃圾回收的导致的。

jstat(代码中有打印 GC 参数,生产上可以使用这个 jstat –gc 来统计,达到类似的效果):是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT 编译等运行数据,在没有 GUI图形界面,只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的首选工具。

假设需要每 250 毫秒查询一次进程 13616 垃圾收集状况,一共查询 10 次,那命令应当是:jstat-gc 13616 250010。 Image

使用这个大量的 FullGC 了,并且还抛出了 OUT Of Memory。

Image

怎么办?OOM 了. 我们可以看到,这个里面 CPU 占用过高是什么导致的?

是业务线程吗?不是的,这个是 GC 线程占用过高导致的。JVM 在疯狂的进行垃圾回收,JVM 中默认的垃圾回收器是多线程的,所以多线程在疯狂回收,导致 CPU 占用过高。

内存占用过高思路

用于生成堆转储快照(一般称为 heapdump 或 dump 文件)。jmap 的作用并不仅仅是为了获取 dump 文件,它还可以查询 finalize 执行队列、Java 堆和永久代的详细信息,如空间使用率、当前用的是哪种收集器等。和 jinfo 命令一样,jmap 有不少功能在Windows 平台下都是受限的,除了生成 dump 文件的-dump 选项和用于查看每个类的实例;

空间占用统计的-histo 选项在所有操作系统都提供之外。

Image

把 JVM 中的对象全部打印出来, 但是这样太多了,那么我们选择前 20 的对象展示出来,jmap –histo 1196 | head -20

Image

Image

定位问题的关键,就是这条命令。很多个 88 万个对象。

问题总结(找到问题) 一般来说,前面这几行,就可以看出,到底是哪些对象占用了内存。

这些对象回收不掉吗?是的,这些对象回收不掉,这些对象回收不掉,导致了 FullGC,里面还有 OutOfMemory。

Image

任务数多于线程数,那么任务会进入阻塞队列,就是一个队列,你进去,排队,有机会了,你就上来跑。

但是,因为代码中任务数一直多于线程数,所以每 0.1S,就会有 50 个任务进入阻塞对象,50 个任务底下有对象,至少对象送进去了,但是没执行。所以导致对象一直都在,同时还回收不了。

为什么回收不了?因为Executor 是一个 GCroots。

Image

所以堆中,就会有对象 80 万个,阻塞队列中 80 万个任务,futureTask。并且这些对象还回收不了。

小结

在 JVM 出现性能问题的时候。(表现上是 CPU100%,内存一直占用)

1、 如果 CPU 的 100%,要从两个角度出发,一个有可能是业务线程疯狂运行,比如说想很多死循环。还有一种可能性,就是 GC 线程在疯狂的回收,因为 JVM 中垃圾回收器主流也是多线程的,所以很容易导致 CPU 的 100%

2、 在遇到内存溢出的问题的时候,一般情况下我们要查看系统中哪些对象占用得比较多。在实际的业务代码中,找到对应的对象,分析对应的类,找到为什么这些对象不能回收的原因,就是可达性分析算法,JVM 的内存区域,还有垃圾回收器的基础。

四 由亿级系统引起的思考:亿级流量电商系统 JVM 调优

Image

根据淘宝的一个官方的数据分析,每天点击量在亿级的系统,用户一次浏览点击 20~40 次之间,推测出每日活跃用户(日活用户)在 500 万左右。

同时结合淘宝的一个点击数据,可以发现,能够付费的也就是橙色的部分(cart)的用户,比例只有 10%左右。

90%的用户仅仅是浏览,那么我们可以通过图片缓存、Redis 缓存等技术,我们可以把 90%的用户解决掉。

10%的付费用户,大概算出来是每日成交 50 万单左右。

Image

GC 预估

如果是普通业务,一般处理时间比较平缓,大概在 3,4 个小时处理,算出来每秒只有几十单,这个一般的应用可以处理过来(不需要 JVM 预估调优)另外电商系统中有大促场景(秒杀、限时抢购等),一般这种业务是几种在几分钟。我们算出来大约每秒 2000 单左右的数据,承受大促场景的使用 4 台服务器(使用负载均衡)。每台订单服务器也就是大概 500 单/秒。

我们测试发现,每个订单处理过程中会占据 0.2MB 大小的空间(什么订单信息、优惠券、支付信息等等),那么一台服务器每秒产生 100M 的内存空间,这些对象基本上 1 秒后都会变成垃圾对象。

Image

加入我们设置堆的空间最大值为 3 个 G,我们按照默认情况下的设置,新生代 1/3 的堆空间,老年代 2/3 的堆空间。Eden:S0:S1=8:1:1,我们推测出,old 区=2G,Eden 区=800M,S0=S1=100M。

根据对象的分配原则(对象优先在 Eden 区进行分配),由此可得,8 秒左右 Eden 区空间满了。

每 8 秒触发一个 MinorGC(新生代垃圾回收),这次 MinorGC 时,JVM 要 STW,但是这个时候有 100M 的对象是不能回收的(线程暂停,对象需要 1 秒后都会变成垃圾对象),那么就会有 100M 的对象在本次不能被回收(只有下次才能被回收掉)。

所以经过本次垃圾回收后。本次存活的 100M 对象会进入 S0 区,但是由于另外一个 JVM 对象分配原则(如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄)。

所以这样的对象本质上不会进去 Survivor 区,而是进入老年代。

Image

所以我们推算,大概每个 8 秒会有 100M 的对象进入老年代。大概 20*8=160 秒,也就是 2 分 40 秒左右 old 区就会满掉,就会触发一次 FullGC,一般来说,这次 FullGC 是可以避免的,同时由于 FullGC 不单单回收老年代+新生代,还要回收元空间,这些 FullGC 的时间可能会比较长(老年代回收的朝生夕死的对象,使用标记清除/标记整理算法决定了效率并不高,同时元空间也要回收一次,进一步加大 GC 时间)。

所以问题的根本就是做到如何避免没有必要的 FullGC。

GC 预估调优

我们在项目中加入 VM 参数:

-Xms3072M -Xmx3072M -Xmn2048M -XX:SurvivorRatio=7-Xss256K -XX:MetaspaceSize= 128M -XX:MaxMetaspaceSize= 128M

-XX:MaxTenuringThreshold=2-XX:ParallelGCThreads=8-XX:+UseConcMarkSweepGC

1、首先看一下堆空间:old 区=1G,Eden 区=1.4G,S0=S1=300M

Image

2 、那么第一点,Eden 区大概需要 14 秒才能填满,填满之后,100M 的存活对象会进入 S0 区(由于这个区域变大,不会触发动态年龄判断)

Image

3、再过 14 秒,Eden 区,填满之后,还是剩余 100M 的对象要进入 S1 区。但是由于原来的 100M 已经是垃圾了(过了 14 秒了),所以,S1 也只会有 Eden 区过来的 100M 对象,S0 的 100M 已经别回收,也不会触发动态年龄判断。

Image

4、反反复复,这样就没有对象会进入 old 区,就不会触发 FullGC,同时我们的 MinorGC 的频次也由之前的 8 秒变为 14 秒,虽然空间加大,但是换来的还是 GC 的总时间会减少。

5、-Xss256K -XX:MetaspaceSize= 128M -XX:MaxMetaspaceSize= 128M

栈一般情况下很少用到 1M。所以为了线程占用内存更少,我们可以减少到 256K元空间一般启动后就不会有太多的变化,我们可以设定为 128M,节约内存空间。

6、-XX:MaxTenuringThreshold=2

这个是分代年龄(年龄为 2 就可以进入老年代),因为我们基本上都使用的是 Spring 架构,Spring 中很多的 bean 是长期要存活的,没有必要在 Survivor 区过渡太久,所以可以设定为 2,让大部分的 Spring 的内部的一些对象进入老年代。

7、-XX:ParallelGCThreads=8

线程数可以根据你的服务器资源情况来设定(要速度快的话可以设置大点,根据 CPU 的情况来定,一般设置成 CPU 的整数倍)。

Image

8、-XX:+UseConcMarkSweepGC

因为这个业务响应时间优先的,所以还是可以使用 CMS 垃圾回收器或者 G1 垃圾回收器。

Image

五 案例一 Major GC和Minor GC频繁

确定目标

服务情况:Minor GC每分钟100次 ,Major GC每4分钟一次,单次Minor GC耗时25ms,单次Major GC耗时200ms,接口响应时间50ms。

由于这个服务要求低延时高可用,结合上文中提到的GC对服务响应时间的影响,计算可知由于Minor GC的发生,12.5%的请求响应时间会增加,其中8.3%的请求响应时间会增加25ms,可见当前GC情况对响应时间影响较大。

(50ms+25ms)× 100次/60000ms = 12.5%,50ms × 100次/60000ms = 8.3% 。

优化目标:降低TP99、TP90时间。

优化

首先优化Minor GC频繁问题。通常情况下,由于新生代空间较小,Eden区很快被填满,就会导致频繁Minor GC,因此可以通过增大新生代空间来降低Minor GC的频率。例如在相同的内存分配率的前提下,新生代中的Eden区增加一倍,Minor GC的次数就会减少一半。

这时很多人有这样的疑问,扩容Eden区虽然可以减少Minor GC的次数,但会增加单次Minor GC时间么?根据上面公式,如果单次Minor GC时间也增加,很难保证最后的优化效果。我们结合下面情况来分析,单次Minor GC时间主要受哪些因素影响?是否和新生代大小存在线性关系?首先,单次Minor GC时间由以下两部分组成:T1(扫描新生代)和 T2(复制存活对象到Survivor区)如下图。(注:这里为了简化问题,我们认为T1只扫描新生代判断对象是否存活的时间,其实该阶段还需要扫描部分老年代,后面案例中有详细描述。)

Image

扩容前:新生代容量为R ,假设对象A的存活时间为750ms,Minor GC间隔500ms,那么本次Minor GC时间= T1(扫描新生代R)+T2(复制对象A到S)。

扩容后:新生代容量为2R ,对象A的生命周期为750ms,那么Minor GC间隔增加为1000ms,此时Minor GC对象A已不再存活,不需要把它复制到Survivor区,那么本次GC时间 = 2 × T1(扫描新生代R),没有T2复制时间。

可见,扩容后,Minor GC时增加了T1(扫描时间),但省去T2(复制对象)的时间,更重要的是对于虚拟机来说,复制对象的成本要远高于扫描成本,所以,单次Minor GC时间更多取决于GC后存活对象的数量,而非Eden区的大小。因此如果堆中短期对象很多,那么扩容新生代,单次Minor GC时间不会显著增加。下面需要确认下服务中对象的生命周期分布情况:

Image

通过上图GC日志中两处红色框标记内容可知:1. new threshold = 2(动态年龄判断,对象的晋升年龄阈值为2),对象仅经历2次Minor GC后就晋升到老年代,这样老年代会迅速被填满,直接导致了频繁的Major GC。2. Major GC后老年代使用空间为300M+,意味着此时绝大多数(86% = 2G/2.3G)的对象已经不再存活,也就是说生命周期长的对象占比很小。

由此可见,服务中存在大量短期临时对象,扩容新生代空间后,Minor GC频率降低,对象在新生代得到充分回收,只有生命周期长的对象才进入老年代。这样老年代增速变慢,Major GC频率自然也会降低。

优化结果 通过扩容新生代为为原来的三倍,单次Minor GC时间增加小于5ms,频率下降了60%,服务响应时间TP90,TP99都下降了10ms+,服务可用性得到提升。

调整前:Image

调整后: Image

小结

如何选择各分区大小应该依赖应用程序中对象生命周期的分布情况:如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。

更多思考

关于上文中提到晋升年龄阈值为2,很多同学有疑问,为什么设置了MaxTenuringThreshold=15,对象仍然仅经历2次Minor GC,就晋升到老年代?这里涉及到“动态年龄计算”的概念。

动态年龄计算:Hotspot遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了survivor区的一半时,取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升年龄阈值。在本案例中,调优前:Survivor区 = 64M,desired survivor = 32M,此时Survivor区中age<=2的对象累计大小为41M,41M大于32M,所以晋升年龄阈值被设置为2,下次Minor GC时将年龄超过2的对象被晋升到老年代。

JVM引入动态年龄计算,主要基于如下两点考虑:

1 如果固定按照MaxTenuringThreshold设定的阈值作为晋升条件:a)MaxTenuringThreshold设置的过大,原本应该晋升的对象一直停留在Survivor区,直到Survivor区溢出,一旦溢出发生,Eden+Svuvivor中对象将不再依据年龄全部提升到老年代,这样对象老化的机制就失效了。b)MaxTenuringThreshold设置的过小,“过早晋升”即对象不能在新生代充分被回收,大量短期对象被晋升到老年代,老年代空间迅速增长,引起频繁的Major GC。分代回收失去了意义,严重影响GC性能。

2 相同应用在不同时间的表现不同:特殊任务的执行或者流量成分的变化,都会导致对象的生命周期分布发生波动,那么固定的阈值设定,因为无法动态适应变化,会造成和上面相同的问题。

总结来说,为了更好的适应不同程序的内存情况,虚拟机并不总是要求对象年龄必须达到Maxtenuringthreshhold再晋级老年代。

六 案例二 请求高峰期发生GC,导致服务可用性下降

确定目标 GC日志显示,高峰期CMS在重标记(Remark)阶段耗时1.39s。Remark阶段是Stop-The-World(以下简称为STW)的,即在执行垃圾回收时,Java应用程序中除了垃圾回收器线程之外其他所有线程都被挂起,意味着在此期间,用户正常工作的线程全部被暂停下来,这是低延时服务不能接受的。本次优化目标是降低Remark时间。

Image

优化

解决问题前,先回顾一下CMS的四个主要阶段,以及各个阶段的工作内容。下图展示了CMS各个阶段可以标记的对象,用不同颜色区分。

  1. Init-mark初始标记(STW) ,该阶段进行可达性分析,标记GC ROOT能直接关联到的对象,所以很快。

  1. Concurrent-mark并发标记,由前阶段标记过的绿色对象出发,所有可到达的对象都在本阶段中标记。

  1. Remark重标记(STW) ,暂停所有用户线程,重新扫描堆中的对象,进行可达性分析,标记活着的对象。因为并发标记阶段是和用户线程并发执行的过程,所以该过程中可能有用户线程修改某些活跃对象的字段,指向了一个未标记过的对象,如下图中红色对象在并发标记开始时不可达,但是并行期间引用发生变化,变为对象可达,这个阶段需要重新标记出此类对象,防止在下一阶段被清理掉,这个过程也是需要STW的。特别需要注意一点,这个阶段是以新生代中对象为根来判断对象是否存活的。

  1. 并发清理,进行并发的垃圾清理。

Image

可见,Remark阶段主要是通过扫描堆来判断对象是否存活。那么准确判断对象是否存活,需要扫描哪些对象?CMS对老年代做回收,Remark阶段仅扫描老年代是否可行?

结论是不可行,原因如下:

Image

如果仅扫描老年代中对象,即以老年代中对象为根,判断对象是否存在引用,上图中,对象A因为引用存在新生代中,它在Remark阶段就不会被修正标记为可达,GC时会被错误回收。新生代对象持有老年代中对象的引用,这种情况称为“跨代引用”。因它的存在,Remark阶段必须扫描整个堆来判断对象是否存活,包括图中灰色的不可达对象。

灰色对象已经不可达,但仍然需要扫描的原因:新生代GC和老年代的GC是各自分开独立进行的,只有Minor GC时才会使用根搜索算法,标记新生代对象是否可达,也就是说虽然一些对象已经不可达,但在Minor GC发生前不会被标记为不可达,CMS也无法辨认哪些对象存活,只能全堆扫描(新生代+老年代)。由此可见堆中对象的数目影响了Remark阶段耗时。分析GC日志可以得出同样的规律,Remark耗时>500ms时,新生代使用率都在75%以上。这样降低Remark阶段耗时问题转换成如何减少新生代对象数量。

新生代中对象的特点是“朝生夕灭”,这样如果Remark前执行一次Minor GC,大部分对象就会被回收。CMS就采用了这样的方式,在Remark前增加了一个可中断的并发预清理(CMS-concurrent-abortable-preclean),该阶段主要工作仍然是并发标记对象是否存活,只是这个过程可被中断。此阶段在Eden区使用超过2M时启动,当然2M是默认的阈值,可以通过参数修改。如果此阶段执行时等到了Minor GC,那么上述灰色对象将被回收,Reamark阶段需要扫描的对象就少了。

除此之外CMS为了避免这个阶段没有等到Minor GC而陷入无限等待,提供了参数CMSMaxAbortablePrecleanTime ,默认为5s,含义是如果可中断的预清理执行超过5s,不管发没发生Minor GC,都会中止此阶段,进入Remark。根据GC日志红色标记2处显示,可中断的并发预清理执行了5.35s,超过了设置的5s被中断,期间没有等到Minor GC ,所以Remark时新生代中仍然有很多对象。

对于这种情况,CMS提供CMSScavengeBeforeRemark参数,用来保证Remark前强制进行一次Minor GC。

优化结果

经过增加CMSScavengeBeforeRemark参数,单次执行时间>200ms的GC停顿消失,从监控上观察,GCtime和业务波动保持一致,不再有明显的毛刺。

Image

小结

通过案例分析了解到,由于跨代引用的存在,CMS在Remark阶段必须扫描整个堆,同时为了避免扫描时新生代有很多对象,增加了可中断的预清理阶段用来等待Minor GC的发生。只是该阶段有时间限制,如果超时等不到Minor GC,Remark时新生代仍然有很多对象,我们的调优策略是,通过参数强制Remark前进行一次Minor GC,从而降低Remark阶段的时间。

更多思考

案例中只涉及老年代GC,其实新生代GC存在同样的问题,即老年代可能持有新生代对象引用,所以Minor GC时也必须扫描老年代。

JVM是如何避免Minor GC时扫描全堆的?经过统计信息显示,老年代持有新生代对象引用的情况不足1%,根据这一特性JVM引入了卡表(card table)来实现这一目的。如下图所示:

Image

卡表的具体策略是将老年代的空间分成大小为512B的若干张卡(card)。卡表本身是单字节数组,数组中的每个元素对应着一张卡,当发生老年代引用新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。如上图所示,卡表3被标记为脏(卡表还有另外的作用,标识并发标记阶段哪些块被修改过),之后Minor GC时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了全堆扫描。

总结来说,CMS的设计聚焦在获取最短的时延,为此它“不遗余力”地做了很多工作,包括尽量让应用程序和GC线程并发、增加可中断的并发预清理阶段、引入卡表等,虽然这些操作牺牲了一定吞吐量但获得了更短的回收停顿时间。

七 案例三 发生Stop-The-World的GC

确定目标

GC日志如下图(在GC日志中,Full GC是用来说明这次垃圾回收的停顿类型,代表STW类型的GC,并不特指老年代GC),根据GC日志可知本次Full GC耗时1.23s。这个在线服务同样要求低时延高可用。本次优化目标是降低单次STW回收停顿时间,提高可用性。

Image

优化

首先,什么时候可能会触发STW的Full GC呢?1. Perm空间不足;2. CMS GC时出现promotion failed和concurrent mode failure(concurrent mode failure发生的原因一般是CMS正在进行,但是由于老年代空间不足,需要尽快回收老年代里面的不再被使用的对象,这时停止所有的线程,同时终止CMS,直接进行Serial Old GC);3. 统计得到的Young GC晋升到老年代的平均大小大于老年代的剩余空间;4. 主动触发Full GC(执行jmap -histo:live [pid])来避免碎片问题。

然后,我们来逐一分析一下:- 排除原因2:如果是原因2中两种情况,日志中会有特殊标识,目前没有。- 排除原因3:根据GC日志,当时老年代使用量仅为20%,也不存在大于2G的大对象产生。- 排除原因4:因为当时没有相关命令执行。- 锁定原因1:根据日志发现Full GC后,Perm区变大了,推断是由于永久代空间不足容量扩展导致的。

找到原因后解决方法有两种:1. 通过把-XX:PermSize参数和-XX:MaxPermSize设置成一样,强制虚拟机在启动的时候就把永久代的容量固定下来,避免运行时自动扩容。2. CMS默认情况下不会回收Perm区,通过参数CMSPermGenSweepingEnabled、CMSClassUnloadingEnabled ,可以让CMS在Perm区容量不足时对其回收。

由于该服务没有生成大量动态类,回收Perm区收益不大,所以我们采用方案1,启动时将Perm区大小固定,避免进行动态扩容。

优化结果

调整参数后,服务不再有Perm区扩容导致的STW GC发生。

小结

对于性能要求很高的服务,建议将MaxPermSize和MinPermSize设置成一致(JDK8开始,Perm区完全消失,转而使用元空间。而元空间是直接存在内存中,不在JVM中),Xms和Xmx也设置为相同,这样可以减少内存自动扩容和收缩带来的性能损失。虚拟机启动的时候就会把参数中所设定的内存全部化为私有,即使扩容前有一部分内存不会被用户代码用到,这部分内存在虚拟机中被标识为虚拟内存,也不会交给其他进程使用。

性能调优实战一:网站流量浏览量暴增后,网站反应页面响很慢

  • 1、问题推测:

在测试环境测速度比较快,但是一到生产就变慢,所以推测可能是因为垃圾收集导致的业务线程停顿。

  • 2、定位:

为了确认推测的正确性,在线上通过 jstat -gc 指令 看到 JVM 进行 GC 次数频率非常高,GC 所占用的时间非常长,所以基本推断就是因为 GC 频率非常高,所以导致业务线程经常停顿,从而造成网页反应很慢。

  • 3、解决方案:

因为网页访问量很高,所以对象创建速度非常快,导致堆内存容易填满从而频繁 GC,所以这里问题在于新生代内存太小,所以这里可以增加 JVM 内存就行了,所以初步从原来的 2G 内存增加到 16G 内存。

  • 4、第二个问题:

增加内存后的确平常的请求比较快了,但是又出现了另外一个问题,就是不定期的会间断性的卡顿,而且单次卡顿的时间要比之前要长很多。

  • 5、问题推测:

之前的优化加大了内存,所以推测可能是因为内存加大了,从而导致单次 GC 的时间变长从而导致间接性的卡顿。

  • 6、定位

:还是通过 jstat -gc 指令 查看到 的确 FGC 次数并不是很高,但是花费在 FGC 上的时间是非常高的,根据 GC 日志 查看到单次 FGC 的时间有达到几十秒的。

  • 7、解决方案:

因为 JVM 默认使用的是 PS+PO 的组合,PS+PO 垃圾标记和收集阶段都是 STW,所以内存加大了之后,需要进行垃圾回收的时间就变长了,所以这里要想避免单次 GC 时间过长,所以需要更换并发类的收集器,因为当前的 JDK 版本为 1.7,所以最后选择 CMS 垃圾收集器,根据之前垃圾收集情况设置了一个预期的停顿的时间,上线后网站再也没有了卡顿问题。

性能调优实战二:后台系统导出数据操作引发的 OOM

问题描述:公司的后台系统,偶发性的引发 OOM 异常,堆内存溢出。

  • 1、偶发还是频发。

因为是偶发性的,所以第一次简单的认为就是堆内存不足导致,所以单方面的加大了堆内存从 4G 调整到 8G。

  • 2、查看堆内存

问题依然没有解决,只能从堆内存信息下手,通过开启了 -XX:+HeapDumpOnOutOfMemoryError 参数 获得堆内存的 dump 文件。

  • 3、查看线程信息

VisualVM 对堆 dump 文件进行分析,通过 VisualVM 查看到占用内存最大的对象是 String 对象,本来想跟踪着 String 对象找到其引用的地方,但 dump 文件太大,跟踪进去的时候总是卡死,而 String 对象占用比较多也比较正常,最开始也没有认定就是这里的问题,于是就从线程信息里面找突破点。

  • 4、发现异常信息

通过线程进行分析,先找到了几个正在运行的业务线程,然后逐一跟进业务线程看了下代码,发现有个引起我注意的方法,导出订单信息。

  • 5、查看异常信息

因为订单信息导出这个方法可能会有几万的数据量,首先要从数据库里面查询出来订单信息,然后把订单信息生成 excel,这个过程会产生大量的 String 对象。

  • 6、发现:按钮可以一直点击

为了验证自己的猜想,于是准备登录后台去测试下,结果在测试的过程中发现导出订单的按钮前端居然没有做点击后按钮置灰交互事件,结果按钮可以一直点,因为导出订单数据本来就非常慢,使用的人员可能发现点击后很久后页面都没反应,结果就一直点,结果就大量的请求进入到后台,堆内存产生了大量的订单对象和 EXCEL 对象,而且方法执行非常慢,导致这一段时间内这些对象都无法被回收,所以最终导致内存溢出。

  • 7、点击按钮后置灰

最终没有调整任何 JVM参数,只是在前端的导出订单按钮上加上了置灰状态,等后端响应之后按钮才可以进行点击,然后减少了查询订单信息的非必要字段来减少生成对象的体积,然后问题就解决了。

性能调优实战三:堆外内存泄漏故障排查记录

监控系统图表查看:

Image

像是Java进程发生了内存泄漏,而我们堆内存的限制是4G,这种大于4G快要吃满内存应该是JVM堆外内存泄漏。

确认了下当时服务进程的启动配置:

-Xms4g -Xmx4g -Xmn2g -Xss1024K -XX:PermSize=256m -XX:MaxPermSize=512m -XX:ParallelGCThreads=20 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:+UseCMSCompactAtFullCollection -XX:CMSInitiatingOccupancyFraction=80

虽然当天没有上线新代码,但是当天上午我们正在使用消息队列推送历史数据的修复脚本,该任务会大量调用我们服务其中的某一个接口,所以初步怀疑和该接口有关。

下图是该调用接口当天的访问量变化: Image

可以看到案发当时调用量相比正常情况(每分钟200+次)提高了很多(每分钟5000+次)。

我们暂时让脚本停止发送消息,该接口调用量下降到每分钟200+次,容器内存不再以极高斜率上升,一切似乎恢复了正常。

接下来排查这个接口是不是发生了内存泄漏。

堆内存分析

虽说一开始就基本确认与堆内存无关,因为泄露的内存占用超过了堆内存限制4G,但是我们为了保险起见先看下堆内存有什么线索。

我们观察了新生代和老年代内存占用曲线以及回收次数统计,和往常一样没有大问题,我们接着在事故现场的容器上dump了一份JVM堆内存的日志。

堆内存Dump 堆内存快照dump命令:

jmap -dump:live,format=b,file=xxxx.hprof pid

也可以使用jmap -histo:live pid直接查看堆内存存活的对象。

导出后,将Dump文件下载回本地,然后可以使用Eclipse的MAT(Memory Analyzer)或者JDK自带的JVisualVM打开日志文件。

可以看到堆内存中,有一些nio有关的大对象,比如正在接收消息队列消息的nioChannel,还有nio.HeapByteBuffer,但是数量不多,不能作为判断的依据,先放着观察下。

是不是我的代码出发了WCS客户端的Bug,导致不断地申请直接内存的调用,最终吃满内存。

沙箱环境复现

为了能还原当时的故障场景,我在沙箱环境申请了一台压测机器,来确保和线上环境一致。

首先我们先模拟内存溢出的情况(大量调用接口):

我们让脚本继续推送数据,调用我们的接口,我们持续观察内存占用。

当开始调用后,内存便开始持续增长,并且看起来没有被限制住(没有因为限制触发Full GC)。

Image

接着我们来模拟下平时正常调用量的情况(正常量调用接口):

我们将该接口平时正常的调用量(比较小,且每10分钟进行一次批量调用)切到该压测机器上,得到了下图这样的老生代内存和物理内存趋势: Image

Image

问题来了:为何内存会不断往上走吃满内存呢?

当时猜测是由于JVM进程并没有对于直接内存大小进行限制(-XX:MaxDirectMemorySize),所以堆外内存不断上涨,并不会触发FullGC操作。

上图能够得出两个结论:

  • 1 在内存泄露的接口调用量很大的时候,如果恰好堆内老生代等其他情况一直不满足FullGC条件,就一直不会FullGC,直接内存一路上涨。

  • 2 而在平时低调用量的情况下, 内存泄漏的比较慢,FullGC总会到来,回收掉泄露的那部分,这也是平时没有出问题,正常运行了很久的原因。 由于上面提到,我们进程的启动参数中并没有限制直接内存,于是我们将-XX:MaxDirectMemorySize配置加上,再次在沙箱环境进行了测验。

结果发现,进程占用的物理内存依然会不断上涨,超出了我们设置的限制,“看上去”配置似乎没起作用。

直接内存分析

为了更进一步的调查清楚直接内存里有什么,我开始对直接内存下手。由于直接内存并不能像堆内存一样,很容易的看出所有占用的对象,我们需要一些命令来对直接内存进行排查,我有用了几种办法,来查看直接内存里到底出现了什么问题。

查看进程内存信息 pmap pmap - report memory map of a process(查看进程的内存映像信息)

pmap命令用于报告进程的内存映射关系,是Linux调试及运维一个很好的工具。

pmap -x pid 如果需要排序  | sort -n -k3**

执行后我得到了输出,可以看出,最下面一行是堆内存的映射,占用4G,其他上面有非常多小的内存占用,不过通过这些信息我们依然看不出问题。

堆外内存跟踪 NativeMemoryTracking

Native Memory Tracking (NMT) 是Hotspot VM用来分析VM内部内存使用情况的一个功能。我们可以利用jcmd(jdk自带)这个工具来访问NMT的数据。

NMT必须先通过VM启动参数中打开,不过要注意的是,打开NMT会带来5%-10%的性能损耗。

-XX:NativeMemoryTracking=[off | summary | detail]
# off: 默认关闭# summary: 只统计各个分类的内存使用情况.
# detail: Collect memory usage by individual call sites.

然后运行进程,可以使用下面的命令查看直接内存:

jcmd <pid> VM.native_memory [summary | detail | baseline | summary.diff | detail.diff | shutdown] [scale= KB | MB | GB] 
# summary: 分类内存使用情况.# detail: 详细内存使用情况,除了summary信息之外还包含了虚拟内存使用情况。# baseline: 创建内存使用快照,方便和后面做对比# summary.diff: 和上一次baseline的summary对比# detail.diff: 和上一次baseline的detail对比# shutdown: 关闭NMT

我们使用

jcmd pid VM.native_memory detail scale=MB > temp.txt

结果不能很明显的看出问题。(图略)

查看JVM内存信息 jmap

此时,找不到问题的我再次新开了一个沙箱容器,运行服务进程,然后运行jmap命令,看一看JVM内存的实际配置:

jmap -heap pid

得到结果

Attaching to process ID 1474, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.66-b17

using parallel threads in the new generation.
using thread-local object allocation.
Concurrent Mark-Sweep GC

Heap Configuration:
MinHeapFreeRatio = 40
MaxHeapFreeRatio = 70
MaxHeapSize = 4294967296 (4096.0MB)
NewSize = 2147483648 (2048.0MB)
MaxNewSize = 2147483648 (2048.0MB)
OldSize = 2147483648 (2048.0MB)
NewRatio = 2
SurvivorRatio = 8
MetaspaceSize = 21807104 (20.796875MB)
CompressedClassSpaceSize = 1073741824 (1024.0MB)
MaxMetaspaceSize = 17592186044415 MB
G1HeapRegionSize = 0 (0.0MB)

Heap Usage:
New Generation (Eden + 1 Survivor Space):
capacity = 1932787712 (1843.25MB)
used = 1698208480 (1619.5378112792969MB)
free = 234579232 (223.71218872070312MB) 87.86316621615607% used
Eden Space:
capacity = 1718091776 (1638.5MB)
used = 1690833680 (1612.504653930664MB)
free = 27258096 (25.995346069335938MB) 98.41346682518548% used
From Space:
capacity = 214695936 (204.75MB)
used = 7374800 (7.0331573486328125MB)
free = 207321136 (197.7168426513672MB) 3.4349974840697497% used
To Space:
capacity = 214695936 (204.75MB)
used = 0 (0.0MB)
free = 214695936 (204.75MB) 0.0% used
concurrent mark-sweep generation:
capacity = 2147483648 (2048.0MB)
used = 322602776 (307.6579818725586MB)
free = 1824880872 (1740.3420181274414MB) 15.022362396121025% used29425 interned Strings occupying 3202824 bytes

输出的信息中,看得出老年代和新生代都蛮正常的,元空间也只占用了20M,直接内存看起来也是2g...

嗯?为什么MaxMetaspaceSize = 17592186044415 MB?看起来就和没限制一样。

再仔细看看我们的启动参数:

-Xms4g -Xmx4g -Xmn2g -Xss1024K -XX:PermSize=256m -XX:MaxPermSize=512m -XX:ParallelGCThreads=20 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:+UseCMSCompactAtFullCollection -XX:CMSInitiatingOccupancyFraction=80

配置的是-XX:PermSize=256m -XX:MaxPermSize=512m,也就是永久代的内存空间。而1.8后,Hotspot虚拟机已经移除了永久代,使用了元空间代替。 由于我们线上使用的是JDK1.8,所以我们对于元空间的最大容量根本就没有做限制,-XX:PermSize=256m -XX:MaxPermSize=512m 这两个参数对于1.8就是过期的参数。

下面的图描述了从1.7到1.8,永久代的变更:

Image

那会不会是元空间内存泄露了呢?

我选择了在本地进行测试,方便更改参数,也方便使用JVisualVM工具直观的看出内存变化。

使用JVisualVM观察进程运行

首先限制住元空间,使用参数-XX:MetaspaceSize=64m -XX:MaxMetaspaceSize=128m,然后在本地循环调用出问题的接口。

得到如图:

Image

可以看出,在元空间耗尽时,系统出发了Full GC,元空间内存得到回收,并且卸载了很多类。

然后我们将元空间限制去掉,也就是使用之前出问题的参数:

-Xms4g -Xmx4g -Xmn2g -Xss1024K -XX:ParallelGCThreads=20 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:+UseCMSCompactAtFullCollection -XX:CMSInitiatingOccupancyFraction=80 -XX:MaxDirectMemorySize=2g -XX:+UnlockDiagnosticVMOptions

得到如图:

Image

可以看出,元空间在不断上涨,并且已装入的类随着调用量的增加也在不断上涨,呈现正相关趋势。

问题一下子明朗了起来,随着每次接口的调用,极有可能是某个类都在不断的被创建,占用了元空间的内存。

观察JVM类加载情况 -verbose

在调试程序时,有时需要查看程序加载的类、内存回收情况、调用的本地接口等。这时候就需要-verbose命令。在myeclipse可以通过右键设置(如下),也可以在命令行输入java -verbose来查看。

-verbose:class 查看类加载情况-verbose:gc 查看虚拟机中内存回收情况-verbose:jni 查看本地方法调用的情况

在本地环境,添加启动参数-verbose:class循环调用接口。当调用了很多次,积攒了一定的类时,我们手动执行Full GC,进行类加载器的回收,我们发现大量的fastjson相关类被回收。

如果在回收前,使用jmap查看类加载情况,同样也可以发现大量的fastjson相关类:

jmap -clstats 7984

问题根因

我们在调用wcs前将驼峰字段的实体类序列化成下划线字段,**这需要使用fastjson的SerializeConfig,而我们在静态方法中对其进行了实例化。SerializeConfig创建时默认会创建一个ASM代理类用来实现对目标对象的序列化。也就是上面被频繁创建的类com.alibaba.fastjson.serializer.ASMSerializer_1_WlkCustomerDto,如果我们复用SerializeConfig,fastjson会去寻找已经创建的代理类,从而复用。但是如果new SerializeConfig(),则找不到原来生成的代理类,就会一直去生成新的WlkCustomerDto代理类。

Image

Image

我们将SerializeConfig作为类的静态变量,问题得到了解决。

private static final SerializeConfig CONFIG = new SerializeConfig();

static {
CONFIG.propertyNamingStrategy = PropertyNamingStrategy.SnakeCase;
}

理论上来说,每个SerializeConfig实例若序列化相同的类,都会找到之前生成的该类的代理类,来进行序列化。们的服务在每次接口被调用时,都实例化一个ParseConfig对象来配置Fastjson反序列的设置,而未禁用ASM代理的情况下,由于每次调用ParseConfig都是一个新的实例,因此永远也检查不到已经创建的代理类,所以Fastjson便不断的创建新的代理类,并加载到metaspace中,最终导致metaspace不断扩张,将机器的内存耗尽。

这个问题并不局限于fastjson,只要是需要通过程序加载创建类的地方,就有可能出现这种问题。尤其是在框架中,往往大量采用类似ASM、javassist等工具进行字节码增强,而根据上面的分析,在jdk1.8之前,因为大多数情况下动态加载的Class都能够在full gc时得到回收,因此不容易出现问题,也因此很多框架、工具包并没有针对这个问题做一些处理,一旦升级到1.8之后,这些问题就可能会暴露出来。

小结

  • JDK1.8开始,自带的hostspot虚拟机取消了过去的永久区,而新增了metaspace区,从功能上看,metaspace可以认为和永久区类似,其最主要的功用也是存放类元数据,但实际的机制则有较大的不同。

  • 对于JVM里面的内存需要在启动时进行限制,包括我们熟悉的堆内存,也要包括直接内存和元生区,这是保证线上服务正常运行最后的兜底。

  • 使用类库,请多注意代码的写法,尽量不要出现明显的内存泄漏。

  • 对于使用了ASM等字节码增强工具的类库,在使用他们时请多加小心(尤其是JDK1.8以后)。

八 JVM 调优小结

8.1 选择合适的垃圾回收器

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

  • CPU 多核:关注吞吐量 ,那么选择 PS+PO 组合;

  • CPU 多核:关注用户停顿时间,JDK 版本 1.6 或者 1.7,那么选择 CMS;

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

 //设置Serial垃圾收集器(新生代)
开启:-XX:+UseSerialGC //设置PS+PO,新生代使用功能Parallel Scavenge 老年代将会使用Parallel Old收集器
开启 -XX:+UseParallelOldGC //CMS垃圾收集器(老年代)
开启 -XX:+UseConcMarkSweepGC //设置G1垃圾收集器
开启 -XX:+UseG1GC

8.2 调整内存大小

现象:垃圾收集频率非常频繁。

原因:如果内存太小,就会导致频繁的需要进行垃圾收集才能释放出足够的空间来创建新的对象,所以增加堆内存大小的效果是非常显而易见的。

注意:如果垃圾收集次数非常频繁,但是每次能回收的对象非常少,那么这个时候并非内存太小,而可能是内存泄露导致对象无法回收,从而造成频繁 GC。

 //设置堆初始值
指令1:-Xms2g 指令2:-XX:InitialHeapSize=2048m //设置堆区最大值
指令1:`-Xmx2g`
指令2: -XX:MaxHeapSize=2048m //新生代内存配置
指令1:-Xmn512m 指令2:-XX:MaxNewSize=512m

8.3 设置符合预期的停顿时间

现象:程序间接性的卡顿

原因:如果没有确切的停顿时间设定,垃圾收集器以吞吐量为主,那么垃圾收集时间就会不稳定。

注意:不要设置不切实际的停顿时间,单次时间越短也意味着需要更多的 GC 次数才能回收完原有数量的垃圾.

参数配置:

 //GC停顿时间,垃圾收集器会尝试用各种手段达到这个时间
-XX:MaxGCPauseMillis

8.4 调整内存区域大小比率

现象:某一个区域的GC频繁,其他都正常。

原因:如果对应区域空间不足,导致需要频繁GC来释放空间,在JVM堆内存无法增加的情况下,可以调整对应区域的大小比率。

注意:也许并非空间不足,而是因为内存泄造成内存无法回收,从而导致 GC 频繁。

参数配置:

 //survivor区和Eden区大小比率
指令:-XX:SurvivorRatio=6 //S区和Eden区占新生代比率为1:6,两个S区2:6

//新生代和老年代的占比
-XX:NewRatio=4 //表示新生代:老年代 = 1:4 即老年代占整个堆的4/5;默认值=2

8.5 8.5 调整对象升老年代的年龄

现象:老年代频繁 GC,每次回收的对象很多。

原因:如果升代年龄小,新生代的对象很快就进入老年代了,导致老年代对象变多,而这些对象其实在随后的很短时间内就可以回收,这时候可以调整对象的升级代年龄,让对象不那么容易进入老年代解决老年代空间不足频繁 GC 问题。

注意:增加了年龄之后,这些对象在新生代的时间会变长可能导致新生代的 GC 频率增加,并且频繁复制这些对象新生的 GC 时间也可能变长。

配置参数:

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

8.6 调整大对象的标准

现象:老年代频繁 GC,每次回收的对象很多,而且单个对象的体积都比较大。

原因:如果大量的大对象直接分配到老年代,导致老年代容易被填满而造成频繁 GC,可设置对象直接进入老年代的标准。

注意:这些大对象进入新生代后可能会使新生代的 GC 频率和时间增加。

配置参数:

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

8.7 调整GC的触发时机

现象:CMS,G1 经常 Full GC,程序卡顿严重。

原因:G1 和 CMS 部分 GC 阶段是并发进行的,业务线程和垃圾收集线程一起工作,也就说明垃圾收集的过程中业务线程会生成新的对象,所以在 GC 的时候需要预留一部分内存空间来容纳新产生的对象,如果这个时候内存空间不足以容纳新产生的对象,那么JVM就会停止并发收集暂停所有业务线程(STW)来保证垃圾收集的正常运行。这个时候可以调整GC触发的时机(比如在老年代占用 60% 就触发 GC),这样就可以预留足够的空间来让业务线程创建的对象有足够的空间分配。

注意:提早触发 GC 会增加老年代 GC 的频率。

配置参数:

 //使用多少比例的老年代后开始CMS收集,默认是68%,如果频繁发生SerialOld卡顿,应该调小
-XX:CMSInitiatingOccupancyFraction //G1混合垃圾回收周期中要包括的旧区域设置占用率阈值。默认占用率为 65%
-XX:G1MixedGCLiveThresholdPercent=65

8.8 调整 JVM本地内存大小

现象:GC 的次数、时间和回收的对象都正常,堆内存空间充足,但是报 OOM

原因:JVM 除了堆内存之外还有一块堆外内存,这片内存也叫本地内存,可是这块内存区域不足了并不会主动触发 GC,只有在堆内存区域触发的时候顺带会把本地内存回收了,而一旦本地内存分配不足就会直接报 OOM 异常。

注意:本地内存异常的时候除了上面的现象之外,异常信息可能是 OutOfMemoryError:Direct buffer memory。解决方式除了调整本地内存大小之外,也可以在出现此异常时进行捕获,手动触发 GC(System.gc())。

配置参数:

 XX:MaxDirectMemorySize

参考:美团技术团队

未完待续... ...

文:一只阿木木

欢迎转发、交流小数据。

Image

松花酿酒,春水煎茶。

眉上风止,见字如晤。

一只阿木木  

Image