JMM 应用总结

公司内接手的老项目近段时间遇到了内存瓶颈,新发版 GC 日志刷个不停,且集中在 YGC,显然有不自然的内存分配,看来内存优化是绕不过了。
在 Java 技术栈内,对内存的分析优化主要集中在堆中,往往需要先使用一些内存分析工具导出堆的一份快照,然后查看是哪些对象在浪费空间,它们可能是非常大的、非常短命的。
除了 JMM,对宿主机的内存管理原理也是有必要掌握的,这样能从底层的角度来进行解释 JMM 的原理,在针对 JMM 进行调参的时候也能更有把握(和运维撕的时候也更不容易被忽悠),鉴于现在的生产环境绝大多数都是 Linux,因此我也会对 Linux 的虚拟内存管理机制作一个简单分析。

JMM 结构分析——JMM 到底说了个什么东西

JMM 隶属于 JVM,主要目标是定义程序中各个变量(非线程私有)的访问规则,即在虚拟机中将变量存储到内存和从内存取出变量这样的底层细节。

  • 在多线程编程中,JMM 定义了线程和主存之间的抽象关系:线程之间的共享变量存储在主存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读 / 写共享变量的副本,JMM 决定了一个线程对共享变量的修改何时对另一个线程可见。

    本地内存是 JMM 的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

  • 在对象分配中,JMM 定义了对象在方法区内的布局,及对象如何在堆中分配。

JMM 分析工具

  1. jps 找到目的 Java 进程的 ID,其实就是操作系统的进程 ID,相对来说 jps 更方便一些、自动过滤了无关进程。
  2. top
    比如查看 JVM 里各线程消耗资源的情况:top -p 进程号 -H
  3. jstat
  4. jinfo
  5. JConsole
  6. VisualVM

    如果是本地机器,可以用 JConsole、VisualVM 等图形界面工具直接分析内存占用情况,但是这些工具都依赖 JMX、而线上服务器一般不会开放 JMX 端口。
    作为代替,可以使用 jmap 导出 heapdump,然后使用 jhat 分析。

  7. jmap
    -heap:显示堆详细信息。
    -histo:现实堆中对象统计信息。
    -dump:live,format=b,file=heap.hprof:生成 Java 堆转储快照,live 表示只 dump 出存活的对象,format。
  8. jhat

JMM 出问题的常见现象

  1. 频繁 YGC;
  2. 频繁 FGC;
  3. OOM;

OOM

下面的代码用于演示如何触发 OOM,来自《深入理解 Java 虚拟机》:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOOM {

static class OOMObject {
}

public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
while (true) {
list.add(new OOMObject());
}
}
}

JMM 问题的排查思路

  1. 查看 GC 日志,判断是否有内存泄露,或者存在代码问题;
    开启 GC 日志需要添加 JVM 启动参数-Xloggc:/path/to/gc.log
  2. Dump 内存,分析对象结构。
  3. 编写压测脚本,尝试手动还原线上异常情况。
  4. 调整 JVM 参数,如新生代、年老代的大小、S0 和 S1 大小比例、垃圾回收器等。

常用调优参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# server 模式
-server
# 初始堆大小
-Xms5g
# 最大堆大小
-Xmx5g
# new 区大小,官方推荐为堆大小的3/8,但是线上普遍设置为2g,适合临时对象较多的业务
# 该值设置过大,则 old 区会很小,容易堆满导致full gc,并且new 区过大,增大每次young gc 的时间
# 该值设置过小,则 new 区会很小,会频繁出发young gc,也可能因为频繁young gc导致临时对象晋升,导致频繁full gc
# 具体配置根据应用 full gc 、young gc 的频率决定,可以结合XX:SurvivorRatio 参数同时调整
-Xmn3g
# eden:from:to 区比例(n:1:1),默认8。以现有的堆配置,大概为2000M:400M:400M。
# 该值设置过大,则eden区过大,减少young gc 频率,但是from、to区太小,导致对象无法进入to区,直接进去old
# 该值设置过小,则eden区过小,导致频繁young gc。但是survivor区会很大,减少临时对象进入old 区的概率
# 具体配置要根据 eden区每次进入to区的对象大小以及业务 young gc 的频率来决定,可以结合Xmn 参数同时调整
-XX:SurvivorRatio=3
# 使用CMS垃圾收集算法
-XX:+UseConcMarkSweepGC
# 使用cms作为垃圾回收使用70%后开始CMS收集
-XX:CMSInitiatingOccupancyFraction=70
# 设置一个最大的GC停顿时间(毫秒),这是个软目标,JVM会尽最大努力去实现它,默认没有最大值设置。
-XX:MaxGCPauseMillis=time
# 禁止显式调用GC
-XX:+DisableExplicitGC
# 下面是GC日志配置,忽略
-verbose:gc
-XX:+PrintGCDateStamps
-XX:+PrintGCDetails
# 打印日志时,异常强制抛出堆栈信息
-XX:-OmitStackTraceInFastThrow
# GC日志路径
-Xloggc:$CATALINA_BASE/logs/gc.log.$DATE
# 或者可以将GC log输出到tmpfs,避免磁盘 IO 阻塞导致 GC log write() 缓慢致使长时间 STW,弊端是tmpfs是基于内存的虚拟盘,机器崩溃后会失去gc log,这点可以通过脚本定时往磁盘备份,这样即便阻塞了也只影响备份速度,不会影响 Java 应用。如下配置(可以使用df命令查看tmpfs):
-Xloggc:/run/jlog/gc.log.$DATE
# 初始元空间大小
-XX:MetaspaceSize=256M
# 最大元空间大小
-XX:MaxMetaspaceSize=1G
# 熵收集守护进程,解决Tomcat启动时生成 session ID 阻塞的问题
-Djava.security.egd=file:/dev/./urandom
# pinpoint配置
-javaagent:$PINPOINT_BASE/pinpoint-bootstrap-$PINPOINT_VERSION.jar
-Dpinpoint.agentId=$PINPOINT_AGENTID
-Dpinpoint.applicationName=$PINPOINT_APPLICATION_NAME

内存估算

JVM 的主要优化思路包括以下两个步骤:

  • 合理优化新生代、老年代、Eden 和 Survivor 各个区域的内存大小。
  • 尽量优化参数让对象停留在新生代里被回收掉,尽量避免新生代的对象进入老年代。

合理配置的基础是正确预估系统的内存使用模型:

  • 每秒占用多少内存?
  • 多长时间触发一次 Minor GC?
  • 一般 Minor GC 后有多少存活对象?Survivor 能放得下吗?
  • 会不会频繁因为 Survivor 放不下而导致对象进入老年代?
  • 会不会因为动态年龄判断规则进入老年代?

JVM 的配置要根据具体业务决定,比如,如果业务查询较频繁,一般会设置很多内存缓存,所以老年代比较占空间,如果业务写频繁,数据一般不会在内存中停留太长时间,所以年轻代刷新得会比较频繁,当然,应用的具体属性还是得根据运行时状态来推断,比如 eden 区每次进入 to 区的对象大小以及业务 young gc 的频率。
内存使用模型预估步骤(以订单业务为例):

  1. 根据下单业务场景热度和线上服务器状况估算单机 QPS;
  2. 根据单个订单数据量大小计算内存开销;
  3. 除了下单外订单系统还会有很多订单相关的其他操作,比如订单查询之类的,所以可以往大了估算,比如扩大 10 倍;

比如,以一台 4 核 8G 的机器为例,一般给到 JVM 的内存会达到 4G,剩下空间会留给操作系统、Filebeat 等系统必备基础设施使用。
这 4G 中,堆内存可以给到 3G,其中新生代约 1.5G、老年代也是 1.5G。
每个线程的 Java 虚拟机栈会占用 1M,那么 JVM 里如果有几百个线程大概会有几百 M。
最后再留给永久代 256M 内存,这 4G 内存空间就差不多了。

  • 重年轻代、轻老年代
    如果应用以大量的临时对象为主,则需要较大的 new 区空间,并且 eden 尽可能的设置大,以存放临时对象。
    而持久化对象较少,因此 survivor 区和 old 不需设置较大的 size。
    推荐配置为:
    1
    export JAVA_OPTS="-Xms3g -Xmx3g -Xmn2g -server -XX:SurvivorRatio=5 -XX:+UseConcMarkSweepGC -XX:+CMSScavengeBeforeRemark -XX:MaxGCPauseMillis=100 -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -XX:+DisableExplicitGC -verbose:gc -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:-OmitStackTraceInFastThrow  -Xloggc:$CATALINA_BASE/logs/gc.log.$DATE -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=1G -Djava.security.egd=file:/dev/./urandom"
  • 重老年代、轻年轻代
    如果应用大量使用了存活时间较长的对象,例如缓存等,而临时对象较少的应用,则需要较大的 old 区来存放这些对象。
    而临时对象较少,则 eden 区不必设置过大,但是由于持久化的对象较多,可能出现对象大量晋升的情况,因此 survivor 区适当增大。
    推荐配置为:
    1
    export JAVA_OPTS="-Xms3g -Xmx3g -Xmn1.5g -server -XX:SurvivorRatio=3 -XX:+UseConcMarkSweepGC -XX:+CMSScavengeBeforeRemark -XX:MaxGCPauseMillis=100 -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -XX:+DisableExplicitGC -verbose:gc -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:-OmitStackTraceInFastThrow  -Xloggc:$CATALINA_BASE/logs/gc.log.$DATE -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=1G -Djava.security.egd=file:/dev/./urandom"
  • 使用 G1
    如果应用的性能遇到瓶颈,可以尝试把 GC 算法改为 G1,缩短 STW 时间,但有可能降低吞吐量。
    1
    2
    DATE=`date +%Y-%m-%d-%H-%M`
    export JAVA_OPTS="-Xms6g -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=1G -XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:+DisableExplicitGC -verbose:gc -XX:+PrintGCDateStamps -XX:+PrintGCDetails -XX:-OmitStackTraceInFastThrow -Xloggc:$CATALINA_BASE/logs/gc.log -Djava.security.egd=file:/dev/./urandom -server -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=12113"

什么时候不需要优化 GC

如果满足下面的指标,则一般不需要进行 GC 优化:

  1. MinorGC 执行时间不到 50ms;
  2. MinorGC 执行不频繁,约 10s 一次;
  3. FullGC 执行时间不到 1s;
  4. FullGC 执行频率不频繁,不低于 10 分钟 1 次

常用调优技巧

  • 新生代的 Survivor 区够用吗?
    比如新生代为 1.5G,老年代为 1.5G,Eden 区 1.2G,Survivor 区 150M。
    如果 Minor GC 后的对象多于 Survivor 区大小,比如 200M,那么必然会出现频繁地让对象进入老年代的情况;
    如果少于 Survivor 区大小,比如 100M,因为这是一批同龄对象,直接超过了 Survivor 区空间的 50%,此时也可能会导致对象进入老年代。
    对于订单系统业务场景来说,明显大部分对象都是短生命周期的,根本不应该频繁进入老年代,也没必要给老年代维持过大的内存空间,因此如果有出现上述情况,可以考虑把新生代调大(Eden、Survivor 同时变大)、老年代调小,比如新生代 2G、老年代 1G、Eden 区 1.6G、每个 Survivor200M,这样保证了每次 Minor GC 后对象都不到 Survivor 区的一半,大大降低了新生代对象进入老年代的概率。
    1
    2
    3
    4
    -Xms3072M -Xmx3072M -Xmn2048M -Xss1M
    -XX:SurvivorRatio=8
    -XX:PermSize=256M
    -XX:MaxPermSize=256M
  • -XX:MaxTenuringThreshold
    这个参数表示对象在新生代连续躲过多少次 Minor GC 后可以进入老年代,一般来说多次躲过的对象都是被@Service、@Controller 之类的注解标注的需要长期存活的核心业务逻辑组件,这个值一般没必要调大,甚至最好调得小一些(默认是 15),不要让对象一直占着宝贵的新生代空间。
    1
    -XX:MaxTenuringThreshold=5
  • -XX:PretenureSizeThreshold
    JVM 中大对象可以直接进入老年代,因为一般认为大对象是要长期存活和使用的,比如在 JVM 里要缓存一些数据,但是一般来说,给它设置个 1MB 足以,因为一般很少有超过 1MB 的大对象,如果有,一般也是提前分配的一个大数组用来作为缓存使用。
    1
    -XX:PretenureSizeThreshold=1M
  • 垃圾回收器
    现在最普遍的配置新生代采用 ParNew、老年代使用 CMS,ParNew 的核心参数就是配套的新生代内存大小、Eden 和 Survivor 的比例等,CMS 的核心参数就是老年代的内存大小、进入老年代的条件等:
    1
    2
    3
    4
    5
    6
    7
    8
    -Xms3072M -Xmx3072M -Xmn2048M -Xss1M
    -XX:SurvivorRatio=8
    -XX:PermSize=256M
    -XX:MaxPermSize=256M
    -XX:MaxTenuringThreshold=5\
    -XX:PretenureSizeThreshold=1M
    -XX:+UseParNewGC
    -XX:UseConcMarkSweepGC

例子

  1. 一次 FullGC
    一次FullGC
    可以看到,remark 阶段消耗了 1.44 秒,而该阶段 STW,应用无法提供服务。
    再看之前的 abortable preclean 阶段,该阶段执行了 5.07 秒,仍然没有执行一次 young gc,从而停止该阶段,直接进入 remark 阶段。导致 new 区临时对象过多,remark 阶段长时间的扫描全堆,执行了 1.44 秒
    因此对于出现这种情况的业务,可以增加配置 -XX:+CMSScavengeBeforeRemark,在 remark 前强制执行 young gc,减少 STW 时间。
    而弊端就是,应用有可能在 abortable preclean 阶段被动触发过一次 young gc,而在 remark 开始时又会触发一次无用的 young gc。