极客时间已完结课程限时免费阅读

24 | 如何优化JVM内存分配?

24 | 如何优化JVM内存分配?-极客时间

24 | 如何优化JVM内存分配?

讲述:李良

时长11:08大小10.19M

你好,我是刘超。
JVM 调优是一个系统而又复杂的过程,但我们知道,在大多数情况下,我们基本不用去调整 JVM 内存分配,因为一些初始化的参数已经可以保证应用服务正常稳定地工作了。
但所有的调优都是有目标性的,JVM 内存分配调优也一样。没有性能问题的时候,我们自然不会随意改变 JVM 内存分配的参数。那有了问题呢?有了什么样的性能问题我们需要对其进行调优呢?又该如何调优呢?这就是我今天要分享的内容。

JVM 内存分配性能问题

谈到 JVM 内存表现出的性能问题时,你可能会想到一些线上的 JVM 内存溢出事故。但这方面的事故往往是应用程序创建对象导致的内存回收对象难,一般属于代码编程问题。
但其实很多时候,在应用服务的特定场景下,JVM 内存分配不合理带来的性能表现并不会像内存溢出问题这么突出。可以说如果你没有深入到各项性能指标中去,是很难发现其中隐藏的性能损耗。
JVM 内存分配不合理最直接的表现就是频繁的 GC,这会导致上下文切换等性能问题,从而降低系统的吞吐量、增加系统的响应时间。因此,如果你在线上环境或性能测试时,发现频繁的 GC,且是正常的对象创建和回收,这个时候就需要考虑调整 JVM 内存分配了,从而减少 GC 所带来的性能开销。

对象在堆中的生存周期

了解了性能问题,那需要做的势必就是调优了。但先别急,在了解 JVM 内存分配的调优过程之前,我们先来看看一个新创建的对象在堆内存中的生存周期,为后面的学习打下基础。
第 20 讲中,我讲过 JVM 内存模型。我们知道,在 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 根据默认值来配置当前内存大小。我们可以通过以下命令来查看堆内存配置的默认值:
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 内存分配的调优过程

我们先使用 JVM 的默认配置,观察应用服务的运行情况,下面我将结合一个实际案例来讲述。现模拟一个抢购接口,假设需要满足一个 5W 的并发请求,且每次请求会产生 20KB 对象,我们可以通过千级并发创建一个 1MB 对象的接口来模拟万级并发请求产生大量对象的场景,具体代码如下:
@RequestMapping(value = "/test1")
public String test1(HttpServletRequest request) {
List<Byte[]> temp = new ArrayList<Byte[]>();
Byte[] b = new Byte[1024*1024];
temp.add(b);
return "success";
}

AB 压测

分别对应用服务进行压力测试,以下是请求接口的吞吐量和响应时间在不同并发用户数下的变化情况:
可以看到,当并发数量到了一定值时,吞吐量就上不去了,响应时间也迅速增加。那么,在 JVM 内部运行又是怎样的呢?

分析 GC 日志

此时我们可以通过 GC 日志查看具体的回收日志。我们可以通过设置 VM 配置参数,将运行期间的 GC 日志 dump 下来,具体配置参数如下:
-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/log/heapTest.log
以下是各个配置项的说明:
-XX:PrintGCTimeStamps:打印 GC 具体时间;
-XX:PrintGCDetails :打印出 GC 详细日志;
-Xloggc: path:GC 日志生成路径。
收集到 GC 日志后,我们就可以使用第 22 讲中介绍过的 GCViewer 工具打开它,进而查看到具体的 GC 日志如下:
主页面显示 FullGC 发生了 13 次,右下角显示年轻代和老年代的内存使用率几乎达到了 100%。而 FullGC 会导致 stop-the-world 的发生,从而严重影响到应用服务的性能。此时,我们需要调整堆内存的大小来减少 FullGC 的发生。

参考指标

我们可以将某些指标的预期值作为参考指标,上面的 GC 频率就是其中之一,那么还有哪些指标可以为我们提供一些具体的调优方向呢?
GC 频率:高频的 FullGC 会给系统带来非常大的性能消耗,虽然 MinorGC 相对 FullGC 来说好了许多,但过多的 MinorGC 仍会给系统带来压力。
内存:这里的内存指的是堆内存大小,堆内存又分为年轻代内存和老年代内存。首先我们要分析堆内存大小是否合适,其实是分析年轻代和老年代的比例是否合适。如果内存不足或分配不均匀,会增加 FullGC,严重的将导致 CPU 持续爆满,影响系统性能。
吞吐量:频繁的 FullGC 将会引起线程的上下文切换,增加系统的性能开销,从而影响每次处理的线程请求,最终导致系统的吞吐量下降。
延时:JVM 的 GC 持续时间也会影响到每次请求的响应时间。

具体调优方法

调整堆内存空间减少 FullGC:通过日志分析,堆内存基本被用完了,而且存在大量 FullGC,这意味着我们的堆内存严重不足,这个时候我们需要调大堆内存空间。
java -jar -Xms4g -Xmx4g heapTest-0.0.1-SNAPSHOT.jar
以下是各个配置项的说明:
-Xms:堆初始大小;
-Xmx:堆最大值。
调大堆内存之后,我们再来测试下性能情况,发现吞吐量提高了 40% 左右,响应时间也降低了将近 50%。
再查看 GC 日志,发现 FullGC 频率降低了,老年代的使用率只有 16% 了。
调整年轻代减少 MinorGC:通过调整堆内存大小,我们已经提升了整体的吞吐量,降低了响应时间。那还有优化空间吗?我们还可以将年轻代设置得大一些,从而减少一些 MinorGC(第 22 讲有通过降低 Minor GC 频率来提高系统性能的详解)。
java -jar -Xms4g -Xmx4g -Xmn3g heapTest-0.0.1-SNAPSHOT.jar
再进行 AB 压测,发现吞吐量上去了。
再查看 GC 日志,发现 MinorGC 也明显降低了,GC 花费的总时间也减少了。
设置 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 的内存分配性能。
再进行 AB 性能测试,我们可以看到吞吐量提升了,响应时间降低了。

总结

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

思考题

以上我们都是基于堆内存分配来优化系统性能的,但在 NIO 的 Socket 通信中,其实还使用到了堆外内存来减少内存拷贝,实现 Socket 通信优化。你知道堆外内存是如何创建和回收的吗?
期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 8

提建议

上一篇
23 | 如何优化垃圾回收机制?
下一篇
25 | 内存持续上升,我该如何排查问题?
unpreview
 写留言

精选留言(36)

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

    作者回复: 回答很全面,赞!

    共 4 条评论
    62
  • 迎风劲草
    2019-07-18
    老师,你的这个抢购场景下我理解是不是新生代越大越好,因为对象都是生命周期较短的对象。尽量在新生代中被回收掉。

    作者回复: 也不是越大越好,因为新生代过大,会导致minor gc的停顿时间过长。 我们知道,如果新生代很快就满了,会以担保的方式将新增的对象直接分配到老年代,这样增加了老年代回收的成本,这个成本跟具体的垃圾收集器相关。所以我们需要适当的调大年轻代,将对象尽量留在年轻代回收。 如果调整太大,我们知道每次Minor GC分为对象标记和复制两个阶段,并且都是STW的,如果对象过于庞大,有可能标记时间要大于复制时间,这样反而适得其反。

    23
  • 天天向上
    2020-01-04
    如果你在线上环境或性能测试时,发现频繁的 GC,且是正常的对象创建和回收,这个时候就需要考虑调整 JVM 内存分配了。。有个问题,这个频率多久算频繁呢?

    作者回复: 线上正常情况下FullGC出现的频率是非常低的,几天一次,一般FullGC如果出现一天超过一次,就已经算频繁了。 做性能压测的时候,FullGC的频率会高一些,但也是仅限于个位数。

    共 2 条评论
    18
  • QQ怪
    2019-07-16
    盲目增大堆内存可能会让吞吐量不增反减,堆内存大了,每次gc扫描对象也就越多也越需要花费时间,反而会适得其反

    作者回复: 对的。合理设置堆内存大小,根据实际业务调整,不宜过大,也不宜过小。

    10
  • 2019-09-11
    课后思考及问题 1:JVM 内存分配不合理最直接的表现就是频繁的 GC,这会导致上下文切换等性能问题,从而降低系统的吞吐量、增加系统的响应时间。 频繁的GC,GC线程和应用线程会频繁的切入切出,所以,降低了系统的性能。 2:老师好,现在有这么一个问题,我们有一个定时任务跑一次大概会有2亿条数据一条数据大概40kb大小,一次大概7.4TB多的数据,分布式任务50台机器需要刷新2个多小时,我们需要持久化,为了提高性能做了异步发送MQ到另外的机器来持久化,不过MQ积压严重,数据跑一次耗时太长,有什么建议的优化思路嘛?拆分消息会加剧业务处理的复杂度,目前我能想到的是加机器加带宽。请老师给个优化的思考?
    展开

    作者回复: 优化传输性能,例如使用特定的数据结构序列化与反序列化传输数据(protobuff序列化),并且提高单台服务并行处理能力。

    共 3 条评论
    6
  • 我又不乱来
    2019-07-16
    超哥,有两个疑问。 当第一次创建对象的时候 eden 空间不足会进行一次minor gc把存活的对象放到from s区。如果这个时候from s放不下。会发生一次担保进入老年代吗? 当一次创建对象的时候eden空间不足进入from s区。当第二次创建对象的时候eden空间又不足了,这个时候会把,eden和第一次存在from s 区的对象进行gc 存活的放在 to s区,to s区空间不足,进行担保放入老年代?这样的理解对吗。
    展开

    作者回复: 对的,细节把握的很好! 前提是老年代有容量这些对象的空间,才会进行分配担保。如果老年代剩余空间小于每次minor gc晋升到老年代的平均值,则会发起一次Full GC。

    6
  • 恰饭哒
    2019-07-16
    超哥好,我们经常发现生产环境内存使用超过90%持续3分钟,没有outofmer, dump下来堆没有发现问题,这种情况每不确定几小时就会一次,求解答

    作者回复: 你好,某一时间段高峰值的访问可能会有这种情况,JVM会最大可能进行对象的回收,防止内存溢出异常的发生。如果不是内存泄漏,或者瞬时并发量大大超过预期并发量的情况,几乎很少发生内存溢出异常。 建议结合内存持续占用率以及Full GC发生的频率来分析调优。

    共 3 条评论
    4
  • 考休
    2019-11-13
    根据老师的教程,在测试项目中,将年轻代的大小调整为3g,发现的确性能提升了,Mirror GC的次数也大大减少,但是Full GC的次数也明显多了几倍,这个是因为年轻代的空间过大,压缩了老年代的内存大小吗? java -jar -Xms4g -Xmx4g -Xmn3g heapTest-0.0.1-SNAPSHOT.jar

    作者回复: 是的

    共 3 条评论
    3
  • 风轻扬
    2019-09-10
    老师,如果允许分配担保机制失败。那即使老年代的空间不足以吃下年轻代的对象。jvm也会冒险进行minor gc的。gc之后,如果老年代还是吃不下对象,这个时候才会Full GC。那关闭这个分配担保机制,感觉好一点啊,反正有Full GC兜底呢😃

    作者回复: 打开分配担保机制,是为了避免Full GC过于频繁。

    3
  • -W.LI-
    2019-07-16
    老师好!堆外缓存实在FGC的时候回收的吧。 AdaptiveSizePolicy这个参数是不是不太智能啊?我项目4G内存默认开启的AdaptiveSizePolicy。发现只给年轻代分配了136M内存。平时运行到没啥问题,没到定时任务的点就频繁FGC。每次定时任务执行完,都会往老年代推40多M,一天会堆300多M到老年代,也不见它把年轻代调大。用的parNew+CMS。后来把年轻代调整到1G(单次YGC耗时从20ms增加到了40ms),每天老年代内存涨20M左右。
    展开

    作者回复: 这个会根据我们的内存创建大小合理分配内存,并不仅仅考虑对象晋升的问题,还会综合考虑回收停顿时间等因素。 针对某些特殊场景,我们可以手动来配置调优。

    3
  • 刘梦春
    2021-01-21
    堆内存最多4g 年轻代3g 1g的老年代怎么给年轻代做担保?
    1
  • Levvy
    2019-12-04
    最大堆内存1593M 还有124M 这俩数字是在哪看的,我怎么找不到

    作者回复: 在jmap -heap pid运行之后,有一个MaxHeapSize,这个就是1953M,还有一个是NewSize以及OldSize,加起来就是初始化的124M大小。

    共 2 条评论
    1
  • 小笨蛋
    2019-09-17
    请问堆内存的分配有没有一个大概的标准😭既然都提到了不能太大也不能太小

    作者回复: 需要根据自己的项目来具体做配置,如果不清除具体需要的配置大小,使用默认配置就可以了

    1
  • 又双叒叕是一年啊
    2019-08-29
    你好,请问G1调优能不能也讲讲。主要应该注意些什么和cms这种调优的差异

    作者回复: 嗯,在后面的答疑课堂中讲到了,有问题欢迎提出

    1
  • 披荆斩棘
    2021-11-22
    超哥,我最近在学并行编程过程中,学master- work并行程序设计模式,运行书中给的示例,就是“用线程计算1到10000的3次方和”,我的电脑是8核20g内存,用一个线程和用5个线程去跑,跑出来时间差不多,非常疑惑,按道理时间是1线程跑的1/5吧
  • aroll
    2021-03-24
    netty4通过引用计数,来处理缓冲区的复用与回收
  • 李飞
    2021-03-03
    老师,MinorGC频率多少比较合适呢?
  • 猪大强
    2020-12-22
    过分的调大堆内存,会增加MinorGC扫描的时间的,在高并发的情况下如何解决呢?
  • slofish
    2020-06-30
    实战项目中真的发生大量gc. 增大内存也不能根本解决问题,应该明确什么原因导致这些对象不被回收,而不是盲目增加,指标不治本
    1
  • Geek_323c91
    2020-05-19
    不知道能不能收到回复,我有个以为,网上查了很多资料 也没得到一个答案,-c 1000 -n 100000 和 -c 100000 和-n 10000的区别是什么 自己压测 怎么调整这2个值

    作者回复: -n表示请求次数 -c表示并发数,-c 1000 -n 100000表示总的请求100000次,且并发用户数为1000。-c 100000 和-n 10000表示总的请求10000次,并发用户数为100000。