23讲如何优化JVM内存分配
你好,我是刘超。
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[] 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通信优化。你知道堆外内存是如何创建和回收的吗?
期待在留⾔区看到你的⻅解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他⼀起讨论。
精选留⾔ <br />![](https://cdn.nlark.com/yuque/0/2022/png/1852637/1646315829158-deb1174f-fe91-48e3-8288-cca52148048d.png#)QQ怪<br />盲⽬增⼤堆内存可能会让吞吐量不增反减,堆内存⼤了,每次gc扫描对象也就越多也越需要花费时间,反⽽会适得其反<br />2019-07-16 22:49<br />作者回复<br />对的。合理设置堆内存⼤⼩,根据实际业务调整,不宜过⼤,也不宜过⼩。<br />2019-07-17 09:18
⻘梅煮酒
超哥好,我们经常发现⽣产环境内存使⽤超过90%持续3分钟,没有outofmer,
dump下来堆没有发现问题,这种情况每不确定⼏⼩时就会⼀次,求解答
2019-07-16 20:54
作者回复
你好,某⼀时间段⾼峰值的访问可能会有这种情况,JVM会最⼤可能进⾏对象的回收,防⽌内存溢出异常的发⽣。如果不是内存泄漏,或者瞬时并发量⼤⼤超过预期并发量的情况,⼏乎很少发⽣内存溢出异常。
建议结合内存持续占⽤率以及Full GC发⽣的频率来分析调优。
2019-07-17 09:26
迎⻛劲草
⽼师,你的这个抢购场景下我理解是不是新⽣代越⼤越好,因为对象都是⽣命周期较短的对象。尽量在新⽣代中被回收掉。
2019-07-18 21:38
作者回复
也不是越⼤越好,因为新⽣代过⼤,会导致minor gc的停顿时间过⻓。
我们知道,如果新⽣代很快就满了,会以担保的⽅式将新增的对象直接分配到⽼年代,这样增加了⽼年代回收的成本,这个成
本跟具体的垃圾收集器相关。所以我们需要适当的调⼤年轻代,将对象尽量留在年轻代回收。
如果调整太⼤,我们知道每次Minor GC分为对象标记和复制两个阶段,并且都是STW的,如果对象过于庞⼤,有可能标记时间要⼤于复制时间,这样反⽽适得其反。
2019-07-22 09:57
晓杰
可以通过directBuffer创建堆外内存,full gc可以对堆外内存进⾏回收
2019-07-17 14:17
晓杰
full gc会对堆外内存进⾏回收
2019-07-17 14:12
歪曲⼂
Unsafe DirectByteBuffer都可以直接开辟堆外内存 啥时候回收 可以在full gc的时候回收 难的是堆外的阈值设定 监控堆外内存 j
mx好像取不到堆外的⼤⼩了吧 之前看到R⼤在1.7的时候粗略的算下的 有种可能是⽼年代引⽤堆外的引⽤ 但是old gc或者full g
c迟迟不gc 那堆外就有可能oom
2019-07-17 11:18
我⼜不乱来
超哥,有两个疑问。
当第⼀次创建对象的时候 eden 空间不⾜会进⾏⼀次minor gc把存活的对象放到from s区。如果这个时候from s放不下。会发⽣
⼀次担保进⼊⽼年代吗?
当⼀次创建对象的时候eden空间不⾜进⼊from s区。当第⼆次创建对象的时候eden空间⼜不⾜了,这个时候会把,eden和第⼀次存在from s 区的对象进⾏gc 存活的放在 to s区,to s区空间不⾜,进⾏担保放⼊⽼年代?这样的理解对吗。
2019-07-16 14:38
作者回复
对的,细节把握的很好!
前提是⽼年代有容量这些对象的空间,才会进⾏分配担保。如果⽼年代剩余空间⼩于每次minor gc晋升到⽼年代的平均值,则会发起⼀次Full GC。
2019-07-17 09:52
LW
⽼师的压测结果图形化是⽤什么⼯具做的?
2019-07-16 13:21
作者回复
Excel
2019-07-17 09:44
杨俊
印象中本地内存分配堆外内存,c语⾔⽤到的内存就是这样,回收是通过GC⾃动扫描directbytebuffer对象回收
2019-07-16 09:52
作者回复
对的,可以⼿动回收掉,如果不⼿动回收,则会通过FullGC来回收。
2019-07-17 10:01
-W.LI-
⽼师好!堆外缓存实在FGC的时候回收的吧。
AdaptiveSizePolicy这个参数是不是不太智能啊?我项⽬4G内存默认开启的AdaptiveSizePolicy。发现只给年轻代分配了136M内存。平时运⾏到没啥问题,没到定时任务的点就频繁FGC。每次定时任务执⾏完,都会往⽼年代推40多M,⼀天会堆300多M 到⽼年代,也不⻅它把年轻代调⼤。⽤的parNew+CMS。后来把年轻代调整到1G(单次YGC耗时从20ms增加到了40ms),每天
⽼年代内存涨20M左右。
2019-07-16 08:59
作者回复
这个会根据我们的内存创建⼤⼩合理分配内存,并不仅仅考虑对象晋升的问题,还会综合考虑回收停顿时间等因素。
针对某些特殊场景,我们可以⼿动来配置调优。
2019-07-17 10:08
Liam
堆外内存⼀般是通过幻像引⽤的队列通知机制进⾏⼿动回收
另外去,G1如何调优呢?本篇⽂章的调优策略不适合G1吧
2019-07-16 08:46
作者回复
对的,G1在上⼀讲中提到了优化JVM的垃圾回收,后⾯问答题会详细讲解G1收集器。
2019-07-17 10:12
nightmare
设置了supervivor的值以后,吞吐量提升了,⽽minor gc确增加了,实际业务⾥⾯这么平衡 吞吐量和 minor gc的关系
2019-07-16 08:12