Skip to content

JVM 性能调优之内存优化与 GC 优化

JVM 调优是一个系统而又复杂的过程,但我们知道,在大多数情况下,我们基本不用去调整 JVM 内存分配,因为一些初始化的参数已经可以保证应用服务正常稳定地工作了。 在应用服务的特定场景下,JVM 内存分配不合理带来的性能表现并不会像内存溢出问题这么突出。一般你没有深入到各项性能指标中去,是很难发现其中隐藏的性能损耗。

JVM 堆内存分配

JVM 内存分配的调优案例

一个高并发系统中的抢购接口,高峰时 5W 的并发请求,且每次请求会产生 20KB 对象(包括订单、用户、优惠券等对象数据)。 我们可以通过一个并发创建一个 1MB 对象的接口来模拟万级并发请求产生大量对象的场景,具体代码如下:

java
@GetMapping("/heap")
public String test(){
    List<Byte[]> list = new ArrayList<>();
    Byte[] b = new Byte[1024 * 1024];
    list.add(b);
    return "success";
}

AB 压测

对应用服务进行压力测试,模拟不同并发用户数下的服务的响应情况:

  1. 10 个并发用户/10 万请求量(总)
  2. 100 个并发用户/10 万请求量(总)
  3. 1000 个并发用户/10 万请求量(总)
bash
ab -c 10 -n 100000 http://127.0.0.1:8080/jvm/heap
ab -c 100 -n 100000 http://127.0.0.1:8080/jvm/heap
ab -c 1000 -n 100000 http://127.0.0.1:8080/jvm/heap
服务器信息

本机起一台 Linux 虚拟机,分配的内存为 2G,处理器数量为 2 个。

GC 监控

还有一句话,无监控不调优,所以我们需要监控起来。JVM 中我们使用 jstat 命令监控一下 JVM 的 GC 情况。 统计 GC 的情况。

bash
# 进程号 8404 可通过 jps 命令查询
jstat -gc 8404 5000 20 | awk '{print $13,$14,$15,$16,$17}'

# 每5000毫秒输出一次
[root@aa81067c5a5b /]# jstat -gc 170 5000 20 | awk '{print $13,$14,$15,$16,$17}'
YGC YGCT FGC FGCT GCT
13 0.554 3 0.451 1.005
13 0.554 3 0.451 1.005
13 0.554 3 0.451 1.005
堆空间监控

在默认不配置 JVM 堆内存大小的情况下,JVM 根据默认值来配置当前内存大小。 我们可以通过以下命令来查看堆内存配置的默认值:

bash
java -XX:+PrintFlagsFinal -version | grep HeapSize

# 输出
[root@aa81067c5a5b /]# java -XX:+PrintFlagsFinal -version | grep HeapSize
    uintx ErgoHeapSizeLimit                         = 0                                   {product}
    uintx HeapSizePerGCThread                       = 87241520                            {product}
    uintx InitialHeapSize                          := 102760448                           {product}
    uintx LargePageHeapSizeThreshold                = 134217728                           {product}
    uintx MaxHeapSize                              := 1644167168                          {product}
openjdk version "1.8.0_282"
OpenJDK Runtime Environment (build 1.8.0_282-b08)
OpenJDK 64-Bit Server VM (build 25.282-b08, mixed mode)

这台机器上启动的 JVM 默认最大堆内存为 1568MB,初始化大小为 98MB。(计算方式:字节/1024=kb/1024=mb)

测试项目启动

bash
java -jar aaa.jar

# 使用 jmap -heap <pid> 这种方式,我们看到这个 JVM 应用占据的堆空间大小
# 输出
[root@aa81067c5a5b /]# jmap -heap 170
Attaching to process ID 170, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.282-b08

using thread-local object allocation.
Parallel GC with 4 thread(s)

Heap Configuration:
   MinHeapFreeRatio         = 0
   MaxHeapFreeRatio         = 100
   MaxHeapSize              = 1644167168 (1568.0MB)
   NewSize                  = 34078720 (32.5MB)
   MaxNewSize               = 547880960 (522.5MB)
   OldSize                  = 68681728 (65.5MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 509083648 (485.5MB)
   used     = 400541360 (381.9860076904297MB)
   free     = 108542288 (103.51399230957031MB)
   78.6788893286158% used
From Space:
   capacity = 16252928 (15.5MB)
   used     = 16138384 (15.390762329101562MB)
   free     = 114544 (0.1092376708984375MB)
   99.29524083291331% used
To Space:
   capacity = 20971520 (20.0MB)
   used     = 0 (0.0MB)
   free     = 20971520 (20.0MB)
   0.0% used
PS Old Generation
   capacity = 101187584 (96.5MB)
   used     = 33481960 (31.930885314941406MB)
   free     = 67705624 (64.5691146850586MB)
   33.08900032636415% used

27453 interned Strings occupying 2529464 bytes.

压测结果分析

GC 频率

高频的 FullGC 会给系统带来非常大的性能消耗,虽然 MinorGC 相对 FullGC 来说好了许多,但过多的 MinorGC 仍会给系统带来压力。

内存

这里的内存指的是堆内存大小,堆内存又分为年轻代内存和老年代内存。堆内存不足,会增加 MinorGC ,影响系统性能。

吞吐量

频繁的 GC 将会引起线程的上下文切换,增加系统的性能开销,从而影响每次处理的线程请求,最终导致系统的吞吐量下降。

延时

JVM 的 GC 持续时间也会影响到每次请求的响应时间。

调整方案一

调整堆内存空间减少 GC:通过分析,堆内存基本被用完了,而且存在大量 MinorGC 和 FullGC,这意味着我们的堆内存严重不足,这个时候我们需要调大堆内存空间。 堆空间加大到 1.5G

bash
java -jar -Xms1500m -Xmx1500m jvm-1.0-SNAPSHOT.jar

调整方案二

bash
java -jar -Xms1500m -Xmx1500m -Xmn1000m -XX:SurvivorRatio=8 jvm-1.0-SNAPSHOT.jar

内存优化总结

image

一般情况下,高并发业务场景中,需要一个比较大的堆空间,而默认参数情况下,堆空间不会很大。所以我们有必要进行调整。 但是不要单纯的调整堆的总大小,要调整新生代和老年代的比例,以及 Eden 区还有 From 区,还有 To 区的比例。

所以在我们上述的测试中,调整方案二,得到结果是最好的。在三种测试情况下都能够有非常好的性能指标,同时 GC 耗时相对控制也较好。

对于调整方案一,就是单纯的加大堆空间,里面的比例不适合高并发场景,反而导致堆空间变大,没有明显减少 GC 的次数,但是每次 GC 需要检索对象的堆空间更大,所以 GC 耗时更长。

方案二:调整为一个很大的新生代和一个较小的老年代. 原因是,这样可以尽可能回收掉大部分短期对象, 减少中期的对象, 而老年代尽存放长期存活对象。

由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC,因此我们可以通过增大新生代空间来降低 Minor GC 的频率。 单次 Minor GC 时间是由两部分组成:T1(扫描新生代)和 T2(复制存活对象)。

默认情况: 一个对象在 Eden 区的存活时间为 500ms,Minor GC 的时间间隔是 300ms,因为这个对象存活时间 > 间隔时间,那么正常情况下,Minor GC 的时间为 :T1+T2。

方案一: 整堆空间加大,但是新生代没有增大多少,对象在 Eden 区的存活时间为 500ms,Minor GC 的时间可能会扩大到 400ms,因为这个对象存活时间 > 间隔时间,那么正常情况下,Minor GC 的时间为 :T1*1.5(Eden 区加大了)+T2

方案二: 当我们增大新生代空间,Minor GC 的时间间隔可能会扩大到 600ms,此时一个存活 500ms 的对象就会在 Eden 区中被回收掉,此时就不存在复制存活对象了,所以再发生 Minor GC 的时间为:即 T12(空间大了)+T20

可见,扩容后,Minor GC 时增加了 T1,但省去了 T2 的时间。 在 JVM 中,复制对象的成本要远高于扫描成本。如果在堆内存中存在较多的长期存活的对象,此时增加年轻代空间,反而会增加 Minor GC 的时间。如果堆中的短期对象很多,那么扩容新生代,单次 Minor GC 时间不会显著增加。因此,单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。

这个就解释了之前的内存调整方案中,方案一为什么性能还差些,但是到了方案二话,性能就有明显的上升。

推荐策略

  1. 新生代大小选择
  • 响应时间优先的应用: 尽可能设大, 直到接近系统的最低响应时间限制(根据实际情况选择). 在此种情况下,新生代收集发生的频率也是最小的. 同时, 减少到达老年代的对象.
  • 吞吐量优先的应用: 尽可能的设置大, 可能到达 Gbit 的程度. 因为对响应时间没有要求, 垃圾收集可以并行进行, 一般适合 8CPU 以上的应用.
  • 避免设置过小. 当新生代设置过小时会导致:
    • MinorGC 次数更加频繁
    • 可能导致 MinorGC 对象直接进入老年代, 如果此时老年代满了, 会触 发 FullGC.
  1. 老年代大小选择
  • 响应时间优先的应用: 老年代使用并发收集器, 所以其大小需要小心设置, 一般要考虑并发会话率和会话持续时间等一些参数. 如果堆设置小了, 可以会造成内存碎片, 高回收频率以及应用暂停而使用传统的标记清除方式; 如果堆大了, 则需要较长的收集时间. 最优化的方案,一般需要参考以下数据获得:
    • 并发垃圾收集信息
    • 持久代并发收集次数
    • 传统 GC 信息
    • 花在新生代和老年代回收上的时间比例。
  • 吞吐量优先的应用: 一般吞吐量优先的应用都有一个很大的新生代和一个较小的老年代. 原因是, 这样可以尽可能回收掉大部分短期对象, 减少中期的对象, 而老年代尽存放长期存活对象。

GC 优化

GC 性能衡量指标

吞吐量: 这里的衡量吞吐量是指应用程序所花费的时间和系统总运行时间的比值。我们可以按照这个公式来计算 GC 的吞吐量:系统总运行时间 = 应用程序耗时 +GC 耗时。如果系统运行了 100 分钟,GC 耗时 1 分钟,则系统吞吐量为 99%。GC 的吞吐量一般不能低于 95%。

停顿时间: 指垃圾回收器正在运行时,应用程序的暂停时间。对于串行回收器而言,停顿时间可能会比较长;而使用并发回收器,由于垃圾收集器和应用程序交替运行,程序的停顿时间就会变短,但其效率很可能不如独占垃圾收集器,系统的吞吐量也很可能会降低。

垃圾回收频率: 通常垃圾回收的频率越低越好,增大堆内存空间可以有效降低垃圾回收发生的频率,但同时也意味着堆积的回收对象越多,最终也会增加回收时的停顿时间。所以我们需要适当地增大堆内存空间,保证正常的垃圾回收频率即可。

分析 GC 日志

通过 JVM 参数预先设置 GC 日志,几种 JVM 参数设置如下:

  • -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 日志文件的输出路径

GC 调优策略

降低 Minor GC 频率

由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC,因此我们可以通过增大新生代空间来降低 Minor GC 的频率。 单次 Minor GC 时间是由两部分组成:T1(扫描新生代)和 T2(复制存活对象)。

情况 1:假设一个对象在 Eden 区的存活时间为 500ms,Minor GC 的时间间隔是 300ms,因为这个对象存活时间 > 间隔时间,那么正常情况下,Minor GC 的时间为 :T1+T2。

情况 2:当我们增大新生代空间,Minor GC 的时间间隔可能会扩大到 600ms,此时一个存活 500ms 的对象就会在 Eden 区中被回收掉,此时就不存在复制存活对象了,所以再发生 Minor GC 的时间为:即 T12(空间大了)+T20

可见,扩容后,Minor GC 时增加了 T1,但省去了 T2 的时间。 在 JVM 中,复制对象的成本要远高于扫描成本。如果在堆内存中存在较多的长期存活的对象,此时增加年轻代空间,反而会增加 Minor GC 的时间。如果堆中的短期对象很多,那么扩容新生代,单次 Minor GC 时间不会显著增加。因此,单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。 这个就解释了之前的内存调整方案中,方案一为什么性能还差些,但是到了方案二话,性能就有明显的上升。

降低 Full GC 的频率

由于堆内存空间不足或老年代对象太多,会触发 Full GC,频繁的 Full GC 会带来上下文切换,增加系统的性能开销。

减少创建大对象: 在平常的业务场景中,我们一次性从数据库中查询出一个大对象用于 web 端显示。比如,一次性查询出 60 个字段的业务操作,这种大对象如果超过年轻代最大对象阈值,会被直接创建在老年代;即使被创建在了年轻代,由于年轻代的内存空间有限,通过 Minor GC 之后也会进入到老年代。这种大对象很容易产生较多的 Full GC。

增大堆内存空间: 在堆内存不足的情况下,增大堆内存空间,且设置初始化堆内存为最大堆内存,也可以降低 Full GC 的频率。

选择合适的 GC 回收器

如果要求每次操作的响应时间必须在 500ms 以内。这个时候我们一般会选择响应速度较快的 GC 回收器,堆内存比较小的情况下(<6G)选择 CMS (Concurrent Mark Sweep)回收器和堆内存比较大的情况下(>8G)G1 回收器.

总结

GC 调优是个很复杂、很细致的过程,要根据实际情况调整,不同的机器、不同的应用、不同的性能要求调优的手段都是不同的,这些都需要大家平时去积累,去观察,去实践。 一般调优的思路都是“测试 - 分析 - 调优”三步走。 最后提个醒,任何调优都需要结合场景,明确已知问题和性能目标,不能为了调优而调优,以免引入新的 Bug,带来风险和弊端。

JVM 性能调优之预估调优与问题排查

JVM 调优分类

调优是一个很大的概念,简单说就是把系统进行优化,但是站在一个系统的角度,能够干的事情太多了,我们一般把 JVM 调优分成以下三类:

  • JVM 预调优
  • 优化 JVM 运行环境(慢、卡顿等)
  • 解决 JVM 中的问题(OOM 等) 调优中,现象最明显的是 OOM,因为有异常抛出,当然它也只是作为调优的一部分。预调优和优化运行环境估计很多人做的就是服务器重启而已。

JVM 预调优

业务场景设定

调优是要分场景的,所以一定要明显你调优项目的场景设定,像现在大家都是微服务架构了,服务拆分出来以后更加适合做场景设定。 比如这个服务就注重吞吐量,这个服务注重用户的体验(用户的响应时间)等等。

无监控不优化

这里的监控指的是压力测试,能够看到结果,有数据体现的,不要用感觉去优化,所有的东西一定要有量化的指标,比如吞吐量,响应时间,服务器资源,网络资源等等。总之一句话,无监控不优化。

处理步骤

计算内存需求

计算内存需求,内存不是越大越好,对于一般系统来说,内存的需求是弹性的,内存小,回收速度快也能承受。所以内存大小没有固定的规范。 虚拟机栈的大小在高并发情况下可以变小。

元空间(方法区)保险起见还是设定一个最大的值(默认情况下元空间是没有大小限制的),一般限定几百 M 就够用了,为什么说还限定元空间。

举例子:一台 8G 的内存的服务器,如果运行时还有其他的程序加上虚拟机栈加上元空间,占用超过 6 个 G 的话,那么我们设定堆是弹性的(max=4G), 那么其实堆空间拓展也超不过 2G,所以这个时候限制元空间还是有必要的。

选定 CPU

对于系统来说, CPU 的性能是越高越好,这个按照你的预算来定(CPU 的成本很高)。 尤其是现在服务器做了虚拟机化之后,虚拟机的性能指标不能单看虚拟化后的参数指标,更应该看实际物理机的情况。

选择合适的垃圾回收器

  • 对于吞吐量优先的场景,就只有一种选择,就是使用 PS 组合(Parallel Scavenge+Parallel Old)
  • 对于响应时间优先的场景,在 JDK1.8 的话优先 G1,其次是 CMS 垃圾回收器。

设定新生代大小、分代年龄

吞吐量优先的应用: 一般吞吐量优先的应用都有一个很大的新生代和一个较小的老年代. 原因是, 这样可以尽可能回收掉大部分短期对象, 减少中期的对象, 而老年代尽存放长期存活对象。

设定日志参数

  • -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 日志文件的输出路径

注意:一般记录日志的是,如果只有一个日志文件肯定不行,有时候一个高并发项目一天产生的日志文件就上 T,其实记录日志这个事情,应该是运维干的事情。日志文件帮助我们分析问题。

优化 JVM 运行环境(慢、卡顿等)

一般造成 JVM 卡或者慢的原因无非两个部分,一个是 CPU 占用过高,一个是内存占用过高。所以这个时候需要我们进行问题的排查,进行具体的故障分析。

解决 JVM 中的问题(OOM 等)

前面章节讲过,见(jvm.md):内存溢出(重点)

亿级流量电商系统 JVM 调优

亿级流量系统

亿级流量系统,其实就是每天点击量在亿级的系统,根据淘宝的一个官方的数据分析。 每个用户一次浏览点击 20~40 次之间,推测出每日活跃用户(日活用户)在 500 万左右。 同时结合淘宝的一个点击数据,可以发现,能够付费的也就是橙色的部分(cart)的用户,比例只有 10%左右。 90%的用户仅仅是浏览,那么我们可以通过图片缓存、Redis 缓存等技术,我们可以把 90%的用户解决掉。 10%的付费用户,大概算出来是每日成交 50 万单左右。

GC 预估

如果是普通业务,一般处理时间比较平缓,大概在 3, 4 个小时处理,算出来每秒只有几十单,这个一般的应用可以处理过来(不需要 JVM 预估调优)

另外电商系统中有大促场景(秒杀、限时抢购等),一般这种业务是几种在几分钟。我们算出来大约每秒 2000 单左右的数据, 承受大促场景的使用 4 台服务器(使用负载均衡)。每台订单服务器也就是大概 500 单/秒

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

加入我们设置堆的空间最大值为 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 区,而是进入老年代

所以我们推算,大概每个 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
  2. 那么第一点,Eden 区大概需要 14 秒才能填满,填满之后,100M 的存活对象会进入 S0 区(由于这个区域变大,不会触发动态年龄判断)
  3. 再过 14 秒,Eden 区,填满之后,还是剩余 100M 的对象要进入 S1 区。但是由于原来的 100M 已经是垃圾了(过了 14 秒了),所以,S1 也只会 有 Eden 区过来的 100M 对象,S0 的 100M 已经被回收,也不会触发动态年龄判断。
  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 的整数倍)
  8. -XX:+UseConcMarkSweepGC 因为这个业务响应时间优先的,所以还是可以使用 CMS 垃圾回收器或者 G1 垃圾回收器。

JVM 调优实战

java
package com.mengweijin.learning.basic.jvm;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

/**
 * VM参数: -XX:+PrintGC -Xms200M -Xmx200M
 *  GC调优---生产服务器推荐开启(默认是关闭的)
 *  -XX:+HeapDumpOnOutOfMemoryError
 *
 *  在 Linux 服务跑起来
 *  java -cp ref-jvm3.jar -XX:+PrintGC -Xms200M -Xmx200M com.mengweijin.learning.basic.jvm.FullGCProblem
 */
public class FullGCProblem {
    //线程池
    private static ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(50,
            new ThreadPoolExecutor.DiscardOldestPolicy());

    public static void main(String[] args) throws Exception {
        //50个线程
        executor.setMaximumPoolSize(50);
        while (true){
            calc();
            Thread.sleep(100);
        }
    }
    //多线程执行任务计算
    private static void calc(){
        List<UserInfo> taskList = getAllCardInfo();
        taskList.forEach(userInfo -> {
            executor.scheduleWithFixedDelay(() -> {
                userInfo.user();
            }, 2, 3, TimeUnit.SECONDS);
        });
    }
    //模拟从数据库读取数据,返回
    private static List<UserInfo> getAllCardInfo(){
        List<UserInfo> taskList = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            UserInfo userInfo = new UserInfo();
            taskList.add(userInfo);
        }
        return taskList;
    }
    private static class UserInfo {
        String name = "AAAAAA";
        int age = 18;
        BigDecimal money = new BigDecimal(999999.99);

        public void user() {
            //
        }
    }
}

CPU 占用过高排查实战

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

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

bash
[mengweijin@mengweijin ~]$ top
top - 20:21:08 up 17 min,  3 users,  load average: 0.13, 0.09, 0.12
Tasks: 122 total,   1 running, 121 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0.8 us,  2.3 sy,  0.0 ni, 97.0 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
KiB Mem :  3861292 total,  2109928 free,   478384 used,  1272980 buff/cache
KiB Swap:        0 total,        0 free,        0 used.  3127948 avail Mem

   PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
  1888 root      20   0 2734512 100416  12440 S  58.5  2.6   0:26.67 java

得到 CPU 占用率最高的进程 ID 为 1888

2. 执行 top -p 1888 单独监控该进程

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

bash
[mengweijin@mengweijin ~]$ top -p 1888
top - 20:23:00 up 18 min,  3 users,  load average: 0.37, 0.19, 0.15
Tasks:   1 total,   0 running,   1 sleeping,   0 stopped,   0 zombie
%Cpu(s):  1.0 us,  2.9 sy,  0.0 ni, 96.1 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st
KiB Mem :  3861292 total,  2079964 free,   508260 used,  1273068 buff/cache
KiB Swap:        0 total,        0 free,        0 used.  3098012 avail Mem

   PID USER      PR  NI    VIRT    RES    SHR S  %CPU %MEM     TIME+ COMMAND
  1888 root      20   0 2734512 129088  12440 S  64.5  3.3   1:37.19 java
top - 20:23:35 up 19 min,  3 users,  load average: 0.39, 0.22, 0.16
Threads:  62 total,   2 running,  60 sleeping,   0 stopped,   0 zombie
%Cpu(s):  2.0 us,  2.9 sy,  0.0 ni, 94.9 id,  0.0 wa,  0.0 hi,  0.2 si,  0.0 st
KiB Mem :  3861292 total,  2065508 free,   522596 used,  1273188 buff/cache
KiB Swap:        0 total,        0 free,        0 used.  3083616 avail Mem

   PID USER      PR  NI    VIRT    RES    SHR S %CPU %MEM     TIME+ COMMAND
  7676 root      20   0 2734512 143424  12440 S  1.7  3.7   0:02.42 pool-1-thread-7

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

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

bash
"main" #1 prio=5 os_prio=0 tid=0x0000000002fd3800 nid=0xf0 waiting on condition [0x0000000002e0f000]
   java.lang.Thread.State: TIMED_WAITING (sleeping)
        at java.lang.Thread.sleep(Native Method)
        at com.mengweijin.learning.basic.jvm.FullGCProblem.main(FullGCProblem.java:28)
"VM Thread" os_prio=2 tid=0x0000000013918000 nid=0x3750 runnable
"GC task thread#0 (ParallelGC)" os_prio=0 tid=0x0000000002fe9000 nid=0x1dfc runnable
"GC task thread#1 (ParallelGC)" os_prio=0 tid=0x0000000002fea800 nid=0x3e34 runnable

6. 将第 4 步得到的线程编号 7676 转成 16 进制是 1dfc. (0x1dfc)

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

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

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

Jstat(代码中有打印 GC 参数,生产上可以使用这个 jstat –gc 来统计,达到类似的效果)

是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT 编译等运行数据,在没有 GUI 图形界面,只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的首选工具。

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

bash
PS C:\Users\mengweijin> jstat -gc 15212 2500 10
 S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT
22528.0 22528.0  0.0    0.0   23040.0  23040.0   136704.0   136228.5  5248.0 4888.8 640.0  502.8      14    0.525 1498   448.529  449.055
22528.0 22528.0  0.0    0.0   23040.0  23028.7   136704.0   136228.2  5248.0 4888.8 640.0  502.8      14    0.525 1509   451.219  451.745

使用这个大量的 FullGC 了 还抛出了 OUT Of Memory

  • S0C:第一个幸存区的大小
  • S1C:第二个幸存区的大小
  • S0U:第一个幸存区的使用大小
  • S1U:第二个幸存区的使用大小
  • EC:Eden 区的大小
  • EU:Eden 区的使用大小
  • OC:老年代大小
  • OU:老年代使用大小
  • MC:方法区大小
  • MU:方法区使用大小
  • CCSC:压缩类空间大小
  • CCSU:压缩类空间使用大小
  • YGC:年轻代垃圾回收次数
  • YGCT:年轻代垃圾回收消耗时间
  • FGC:老年代垃圾回收次数
  • FGCT:老年代垃圾回收消耗时间
  • GCT:垃圾回收消耗总时间

怎么办?OOM 了. 我们可以看到,这个里面 CPU 占用过高是什么导致的? 是业务线程吗?不是的,这个是 GC 线程占用过高导致的。JVM 在疯狂的进行垃圾回收,再回顾下之前的知识,JVM 中默认的垃圾回收器是多线程的(回顾下之前的知识),所以多线程在疯狂回收,导致 CPU 占用过高。

内存占用过高内存占用过高思路

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

把 JVM 中的对象全部打印出来, 但是这样太多了,那么我们选择前 20 的对象展示出来(Windows 下不支持 head -20),

jmap –histo 15212 | head -20

bash
PS C:\Users\mengweijin> jmap -histo 15212
 num     #instances         #bytes  class name
----------------------------------------------
   1:        188722       13587984  java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask
   2:        188829        7553160  java.math.BigInteger
   3:        188748        7549920  java.math.BigDecimal
   4:        189180        6053760  java.util.concurrent.ConcurrentHashMap$Node
   5:        188776        6040832  com.intellij.rt.debugger.agent.CaptureStorage$WeakKey
   6:        188776        6040832  java.lang.Throwable
   7:        188722        4529328  com.mengweijin.learning.basic.jvm.FullGCProblem$UserInfo
   8:        188722        4529328  java.util.concurrent.Executors$RunnableAdapter
   9:        188776        3020416  com.intellij.rt.debugger.agent.CaptureStorage$ExceptionCapturedStack
  10:        188722        3019552  com.mengweijin.learning.basic.jvm.FullGCProblem$$Lambda$2/1164175787
  11:             1        1065096  [Ljava.util.concurrent.RunnableScheduledFuture;
  12:            10        1051296  [Ljava.util.concurrent.ConcurrentHashMap$Node;
  13:          3067         364336  [C

定位问题的关键,就是这条命令。 188722 个对象。

问题总结(找到问题)

一般来说,前面这几行,就可以看出,到底是哪些对象占用了内存。 这些对象回收不掉吗?是的,这些对象回收不掉,这些对象回收不掉,导致了 FullGC, 里面还有 OutOfMemory。

前面代码中,任务数多于线程数,那么任务会进入阻塞队列,就是一个队列,你进去,排队,有机会了,你就上来跑。 但是同学们,因为代码中任务数一直多于线程数,所以每 0.1S,就会有 50 个任务进入阻塞对象,50 个任务底下有对象,至少对象送进去了,但是没执行。 所以导致对象一直都在,同时还回收不了。

为什么回收不了。因为 Executor 是一个 GCroots。 (static 修饰)

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

总结

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

  1. 如果 CPU 的 100%,要从两个角度出发,一个有可能是业务线程疯狂运行,比如说想很多死循环。还有一种可能性,就是 GC 线程在疯狂的回收,因为 JVM 中垃圾回收器主流也是多线程的,所以很容易导致 CPU 的 100%
  2. 在遇到内存溢出的问题的时候,一般情况下我们要查看系统中哪些对象占用得比较多,我的是一个很简单的代码,在实际的业务代码中,找到对应的对象,分析对应的类,找到为什么这些对象不能回收的原因,就是我们前面讲过的可达性分析算法,JVM 的内存区域,还有垃圾回收器的基础,当然,如果遇到更加复杂的情况,你要掌握的理论基础远远不止这些(JVM 很多理论都是排查问题的关键)

常见问题分析

超大对象

代码中创建了很多大对象 , 且一直因为被引用不能被回收,这些大对象会进入老年代,导致内存一直被占用,很容易引发 GC 甚至是 OOM

超过预期访问量

通常是上游系统请求流量飙升,常见于各类促销/秒杀活动,可以结合业务流量指标排查是否有尖状峰值。 比如如果一个系统高峰期的内存需求需要 2 个 G 的堆空间,但是堆空间设置比较小,导致内存不够,导致 JVM 发起频繁的 GC 甚至 OOM。

过多使用 Finalizer

过度使用终结器(Finalizer),对象没有立即被 GC,Finalizer 线程会和我们的主线程进行竞争,不过由于它的优先级较低,获取到的 CPU 时间较少,因此它永远也赶不上主线程的步伐,程序消耗了所有的可用资源,最后抛出 OutOfMemoryError 异常。

内存泄漏

大量对象引用没有释放,JVM 无法对其自动回收。

长生命周期的对象持有短生命周期对象的引用

例如将 ArrayList 设置为静态变量,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏

连接未关闭

如数据库连接、网络连接和 IO 连接等,只有连接被关闭后,垃圾回收器才会回收对应的对象。

变量作用域不合理

例如,

  1. 一个变量的定义的作用范围大于其使用范围,
  2. 如果没有及时地把对象设置为 null

内部类持有外部类

Java 的非静态内部类的这种创建方式,会隐式地持有外部类的引用,而且默认情况下这个引用是强引用,因此,如果内部类的生命周期长于外部类的生命周期,程序很容易就产生内存泄漏

如果内部类的生命周期长于外部类的生命周期,程序很容易就产生内存泄漏(垃圾回收器会回收掉外部类的实例,但由于内部类持有外部类的引用,导致垃圾回收器不能正常工作)

解决方法:你可以在内部类的内部显示持有一个外部类的软引用(或弱引用),并通过构造方法的方式传递进来,在内部类的使用过程中,先判断一下外部类是否被回收;

Hash 值改变

在集合中,如果修改了对象中的那些参与计算哈希值的字段,会导致无法从集合中单独删除当前对象,造成内存泄露(有代码案例 Node 类)

java
/**
 * 内存泄漏经典案例
 * 手写一个栈
 */
public class Stack{
   public Object[] elements; // 数组来保存
   private int size = 0;
   private static final int cap = 200000;
   public Static() { elements = new Object[cap]; }
   public void push(Object e) { // 入栈
      elements[size] = e;
      size++;
   }
   public Object pop() { // 出栈
      size = size - 1;
      Object o = elements[size];
      // elements[size] = null; // 让GC 回收掉(这里不写就会内存泄漏)
      return o;
   }
}

代码问题

代码问题和内存泄漏很大的关系,如果观察一个系统,每次进行 FullGC 发现堆空间回收的比例比较小,尤其是老年代,同时对象越来越多,这个时候可以判断是有可能发生内存泄漏。

内存泄漏

程序在申请内存后,无法释放已申请的内存空间。

内存泄漏和内存溢出辨析

  • 内存溢出:实实在在的内存空间不足导致;
  • 内存泄漏:该释放的对象没有释放,常见于使用容器保存元素的情况下。 如何避免:
  • 内存溢出:检查代码以及设置足够的空间
  • 内存泄漏:一定是代码有问题往往很多情况下,内存溢出往往是内存泄漏造成的。

我们一般优化的思路有一个重要的顺序:

  1. 程序优化,效果通常非常大;
  2. 扩容,如果金钱的成本比较小,不要和自己过不去;
  3. 参数调优,在成本、吞吐量、延迟之间找一个平衡点。