如何优化JVM内存分配

JVM 内存分配性能问题

JVM 内存分配不合理最直接的表现就是频繁的 GC,这会导致上下文切换等性能问题,从而降低系统的吞吐量、增加系统的响应时间。因此,如果你在线上环境或性能测试时,发现频繁的 GC,且是正常的对象创建和回收,这个时候就需要考虑调整 JVM 内存分配了,从而减少 GC 所带来的性能开销。

对象在堆中的生存周期

我们知道,在 JVM 内存模型的堆中,堆被划分为新生代和老年代,新生代又被进一步划分为 Eden 区和 Survivor 区,最后 Survivor 由 From Survivor 和 To Survivor 组成。

当我们新建一个对象时,对象会被优先分配到新生代的 Eden 区中,这时虚拟机会给对象定义一个对象年龄计数器(通过参数 -XX:MaxTenuringThreshold 设置)。

同时,也有另外一种情况,当 Eden 空间不足时,虚拟机将会执行一个新生代的垃圾回收(Minor GC)。这时 JVM 会把存活的对象转移到 Survivor 中,并给对象的年龄 +1。对象在 Survivor 中同样也会经历 MinorGC,每经过一次 MinorGC,对象的年龄将会 +1。

当然了,内存空间也是有设置阈值的,可以通过参数 -XX:PetenureSizeThreshold 设置直接被分配到老年代的最大对象,这时如果分配的对象超过了设置的阀值,对象就会直接被分配到老年代,这样做的好处就是可以减少新生代的垃圾回收。

查看 JVM 堆内存分配

我们知道了一个对象从创建至回收到堆中的过程,接下来我们再来了解下 JVM 堆内存是如何分配的。在默认不配置 JVM 堆内存大小的情况下,JVM 根据默认值来配置当前内存大小。我们可以通过以下命令来查看堆内存配置的默认值:

1
2
java -XX:+PrintFlagsFinal -version | grep HeapSize
jmap -heap 17284

通过命令,我们可以获得在这台机器上启动的 JVM 默认最大堆内存为 1953MB,初始化大小为 124MB。

在 JDK1.7 中,默认情况下年轻代和老年代的比例是 1:2,我们可以通过–XX:NewRatio 重置该配置项。年轻代中的 Eden 和 To Survivor、From Survivor 的比例是 8:1:1,我们可以通过 -XX:SurvivorRatio 重置该配置项。

在 JDK1.7 中如果开启了 -XX:+UseAdaptiveSizePolicy 配置项,JVM 将会动态调整 Java 堆中各个区域的大小以及进入老年代的年龄,–XX:NewRatio 和 -XX:SurvivorRatio 将会失效,而 JDK1.8 是默认开启 -XX:+UseAdaptiveSizePolicy 配置项的。

还有,在 JDK1.8 中,不要随便关闭 UseAdaptiveSizePolicy 配置项,除非你已经对初始化堆内存 / 最大堆内存、年轻代 / 老年代以及 Eden 区 /Survivor 区有非常明确的规划了。否则 JVM 将会分配最小堆内存,年轻代和老年代按照默认比例 1:2 进行分配,年轻代中的 Eden 和 Survivor 则按照默认比例 8:2 进行分配。这个内存分配未必是应用服务的最佳配置,因此可能会给应用服务带来严重的性能问题。

JVM 内存分配的调优过程

分析 GC 日志

此时我们可以通过 GC 日志查看具体的回收日志。我们可以通过设置 VM 配置参数,将运行期间的 GC 日志 dump 下来,具体配置参数如下:

1
-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/log/heapTest.log

以下是各个配置项的说明:

  • -XX:PrintGCTimeStamps:打印 GC 具体时间;

  • -XX:PrintGCDetails :打印出 GC 详细日志;

  • -Xloggc: path:GC 日志生成路径。

收集到 GC 日志后,我们就可以使用 GCViewer 工具打开它,进而查看到具体的 GC 日志如下:

主页面显示 FullGC 发生了 13 次,右下角显示年轻代和老年代的内存使用率几乎达到了 100%。而 FullGC 会导致 stop-the-world 的发生,从而严重影响到应用服务的性能。此时,我们需要调整堆内存的大小来减少 FullGC 的发生。

参考指标

  • GC 频率:高频的 FullGC 会给系统带来非常大的性能消耗,虽然 MinorGC 相对 FullGC 来说好了许多,但过多的 MinorGC 仍会给系统带来压力。
  • 内存:这里的内存指的是堆内存大小,堆内存又分为年轻代内存和老年代内存。首先我们要分析堆内存大小是否合适,其实是分析年轻代和老年代的比例是否合适。如果内存不足或分配不均匀,会增加 FullGC,严重的将导致 CPU 持续爆满,影响系统性能。
  • 吞吐量:频繁的 FullGC 将会引起线程的上下文切换,增加系统的性能开销,从而影响每次处理的线程请求,最终导致系统的吞吐量下降。
  • 延时:JVM 的 GC 持续时间也会影响到每次请求的响应时间。

具体调优方法

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

1
java -jar -Xms4g -Xmx4g heapTest-0.0.1-SNAPSHOT.jar

以下是各个配置项的说明:-Xms:堆初始大小;-Xmx:堆最大值。

调大堆内存之后,我们再来测试下性能情况,发现吞吐量提高了 40% 左右,响应时间也降低了将近 50%。

调整年轻代减少 MinorGC:通过调整堆内存大小,我们已经提升了整体的吞吐量,降低了响应时间。那还有优化空间吗?我们还可以将年轻代设置得大一些,从而减少一些 MinorGC(第 22 讲有通过降低 Minor GC 频率来提高系统性能的详解)。

1
java -jar -Xms4g -Xmx4g -Xmn3g heapTest-0.0.1-SNAPSHOT.jar

设置 Eden、Survivor 区比例:在 JVM 中,如果开启 AdaptiveSizePolicy,则每次 GC 后都会重新计算 Eden、From Survivor 和 To Survivor 区的大小,计算依据是 GC 过程中统计的 GC 时间、吞吐量、内存占用量。这个时候 SurvivorRatio 默认设置的比例会失效。

在 JDK1.8 中,默认是开启 AdaptiveSizePolicy 的,我们可以通过 -XX:-UseAdaptiveSizePolicy 关闭该项配置,或显示运行 -XX:SurvivorRatio=8 将 Eden、Survivor 的比例设置为 8:2。大部分新对象都是在 Eden 区创建的,我们可以固定 Eden 区的占用比例,来调优 JVM 的内存分配性能。

小结

  • 现阶段大多数应用使用 JDK 1.8,其默认回收器是 Parallel Scavenge,并且默认开启了 AdaptiveSizePolicy。

  • AdaptiveSizePolicy 动态调整 Eden、Survivor 区的大小,存在将 Survivor 区调小的可能。当 Survivor 区被调小后,部分 YGC 后存活的对象直接进入老年代。老年代占用量逐渐上升从而触发 FGC,导致较长时间的 STW。

  • 保持使用 UseParallelGC,显式设置 -XX:SurvivorRatio=8。

  • 建议使用 CMS 垃圾回收器,默认关闭 AdaptiveSizePolicy。(-XX:+UseConcMarkSweepGC)

  • 建议在 JVM 参数中加上 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution,让 GC log 更加详细,方便定位问题。

  • 在大内存(比如8GB及以上)和高QPS的情况下,确保快速响应时间,可以考虑使用G1垃圾回收器进行垃圾回收,G1垃圾回收期可以每次收集部分垃圾来满足小的停顿时间要求。(##-XX:+UseG1GC -Xmx2048m -Xms2048m -XX:+AlwaysPreTouch)

根据应用场景选择合适的垃圾收集器

单个CPU的环境,在用户的桌面应用场景中,可用内存一般不大(几十M至一两百M)

启动参数:

1
-Xms128m -Xmx128m -XX:+UseSerialGC -XX:TargetSurvivorRatio=90 -XX:NewRatio=1 -XX:+DisableExplicitGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:C:\Users\cosmo-101\Desktop\gc.log

高吞吐为目标,对暂停时间没有特别高的要求

Parallel Scavenge收集器,参数说明如下:

1
2
3
4
-XX:+UseParallelOldGC      # 新生代和老年代都使用并行收集器
-XX:MaxGCPauseMillis # 控制最大垃圾收集停顿时间,大于0的毫秒数
-XX:GCTimeRatio # 设置垃圾收集时间占总时间的比率,0<n<100的整数,相当于设置吞吐量大小
-XX:+UseAdaptiveSizePolicy # 就不用手工指定一些细节参数,JVM会根据当前系统运行情况收集性能监控信息,动态调整这些参数,提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomiscs)

启动参数如下:

1
-Xms2g -Xmx2g -XX:+UseParallelOldGC -XX:MaxGCPauseMillis=200 -XX:GCTimeRatio=99 -XX:+UseAdaptiveSizePolicy -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=512m -XX:+DisableExplicitGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:C:\Users\cosmo-101\Desktop\gc.log

希望系统停顿时间最短,注重服务的响应速度

推荐CMS收集器,参数说明如下:

1
2
3
4
5
6
-XX:+UseConcMarkSweepGC       # 指定老年代使用CMS收集器,会默认使用ParNew作为新生代收集器
-XX:+CMSScavengeBeforeRemark # 在执行CMS Remark阶段前,执行一次Minor GC,以降低STW的时间。通过 Minor GC可以减少新生代对老年代对象的引用,这样可以减少根对象数量,从而降低CMS Remark的工作量.
-XX:CMSFullGCsBeforeCompaction
设置执行多少次不压缩的Full GC后,来一次压缩整理;
为了减少合并整理过程的停顿时间,默认为0;
也就是说每次都执行Full GC,不会进行压缩整理。

启动参数如下:

1
-Xms2g -Xmx2g -XX:+UseConcMarkSweepGC  -XX:TargetSurvivorRatio=90 -XX:NewRatio=1 -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=512m -XX:+CMSScavengeBeforeRemark -XX:CMSFullGCsBeforeCompaction=10 -XX:+DisableExplicitGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:C:\Users\cosmo-101\Desktop\gc.log

针对具有大内存、多处理器的机器,低GC延迟,堆大小约6GB或更大时

推荐G1收集器,参数说明如下:

【注】:使用G1回收器时,G1打破了以往将收集范围固定在新生代或老年代的模式,不需要为各个空间进行单独设置了,G1算法将堆整体划分为若干个区域(Region)。

1
2
3
4
5
-XX:+UseG1GC            # 指定使用G1收集器
-XX:MaxGCPauseMillis # 为G1设置暂停时间目标,默认值为200毫秒
-XX:G1HeapRegionSize # 设置每个Region大小,范围1MB到32MB;目标是在最小Java堆时可以拥有约2048个Region
-XX:ParallelGCThreads=n # STW期间,并行线程数。建议设置与处理器相同个数,最多为8。
如果处理器多于8个,则将n的值设置为处理器的大约5/8。

启动参数如下:

1
-Xms8g -Xmx8g -XX:+UseG1GC -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=512m -XX:+DisableExplicitGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:C:\Users\cosmo-101\Desktop\gc.log

设置合适的参数

1
2
3
4
5
6
7
8
9
10
11
12
-XX:TargetSurvivorRatio   # 默认值50,当survivor区存放的对象超过这个百分百,则对象会向老年代压缩,因此,有助于将对象留在新生代

-XX:NewRatio=1 # 老年代与新生代的比例,默认2:1,有助于将对象预留新生代,新生代Minor GC成本远远小于老年代的Full GC
-Xmn=70m

-XX:MetaspaceSize=256m # 初始元空间大小,默认21MB

-XX:MaxMetaspaceSize=256m # 最大元空间,默认是没有限制的,可以根据GC日志打印,如“Full GC (Metadata GC Threshold)”过于频繁,则调整此参数;未设置,虚拟机会自动从21MB初始大小逐渐增长,每次Full GC后自动调整

-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:gc.log # 保存gc日志,作为优化的依据

-XX:+DisableExplicitGC # 禁止System.gc(),免得程序员误调用gc方法影响性能

Jmeter压测

聚合报告参数详解

1
2
3
4
5
6
7
8
9
10
# 1、Label:每个 JMeter 的 element(例如 HTTP Request)都有一个 Name 属性,这里显示的就是 Name 属性的值;
# 2、#Samples:表示这次测试中一共发出了多少个请求,如果模拟10个用户,每个用户迭代10次,那么这里显示100;【我的是用户有100,只迭代一次,因此也是100】
# 3、Average:平均响应时间——默认情况下是单个 Request 的平均响应时间(ms);
# 4、Median:中位数,也就是 50% 用户的响应时间;
# 5、90% Line ~ 99% Line:90% ~99%用户的响应时间;
# 6、Min:最小响应时间;
# 7、Maximum:最大响应时间;
# 8、Error%:本次测试中出现的错误率,即 错误的请求的数量/请求的总数;
# 9、Throughput:吞吐量——默认情况下表示每秒完成的请求数(Request per Second);
# 11、Sent KB/src:每秒从客户端发送的请求的数量。

不同启动参数压测对比

1
-Xms128m -Xmx128m -XX:+UseSerialGC -XX:TargetSurvivorRatio=90 -XX:NewRatio=1 -XX:+DisableExplicitGC

内存尽用

1
-Xms8g -Xmx8g -XX:+UseG1GC -XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=512m -XX:+DisableExplicitGC

总结

JVM 内存调优通常和 GC 调优是互补的,基于以上调优,我们可以继续对年轻代和堆内存的垃圾回收算法进行调优。这里可以结合上一讲的内容,一起完成 JVM 调优。

虽然分享了一些 JVM 内存分配调优的常用方法,但我还是建议你在进行性能压测后如果没有发现突出的性能瓶颈,就继续使用 JVM 默认参数,起码在大部分的场景下,默认配置已经可以满足我们的需求了。但满足不了也不要慌张,结合今天所学的内容去实践一下,相信你会有新的收获。

思考题

以上我们都是基于堆内存分配来优化系统性能的,但在 NIO 的 Socket 通信中,其实还使用到了堆外内存来减少内存拷贝,实现 Socket 通信优化。你知道堆外内存是如何创建和回收的吗?

堆外内存创建有两种方式:1.使用ByteBuffer.allocateDirect()得到一个DirectByteBuffer对象,初始化堆外内存大小,里面会创建Cleaner对象,绑定当前this.DirectByteBuffer的回收,通过put,get传递进去Byte数组,或者序列化对象,Cleaner对象实现一个虚引用(当内存被回收时,会受到一个系统通知)当Full GC的时候,如果DirectByteBuffer标记为垃圾被回收,则Cleaner会收到通知调用clean()方法,回收改堆外内存DirectByteBuffer

打赏
  • Copyrights © 2020-2023 交个朋友之猿天地
  • Powered By Hexo | Title - Nothing
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信