NightPxy 个人技术博客

JVM调优

Posted on By NightPxy

概述

JVM的调优进行一下总结
JVM的调优核心是调GC. GC产品,堆代分布等等都是为了GC而调优的

JVM的调优依赖JVM运行过程的各种日志,这是判断调优点的基石

JVM的调优大致分为解决问题和优化调优

检测手段


# 套用网上一句话:不要相信任何参数调优的文章(包括本文) 
# 打印最终参数 := 表示被修改后的
java -XX:+PrintFlagsFinal ..
# 打印初始参数  
java -XX:PrintFlagsInitial ..  
# 打印参数命令行  
-XX:PrintCommandLineFlages  

# 打印目标JVM进程参数  
jinfo -flags <pid>

问题型

JVM的问题型调整主要是 FullGC频繁

FullGC频繁

元空间

元空间(或者说永生代)满是会产生FullGC的
确定元空间满的标准非常简单,查看各代分布就能发现元空间已经满了

jstat pid [-gccapacity 或者 -gcmetacapacity] 都可以

内存泄露

内存泄露,必然会引起频繁FullGC
内存泄露的一大标志是 回收量小,也就是每次回收后的前后差异非常低

内存泄露必然回收量小,但回收量小不代表必然内存泄露
所以如果发现回收量每次都很小,有必要下Dump用MAT检查下此时的内存对象

# 下载Dump下来分析  
# 如果有内存泄露,这是必然能检测出来的方法  
jmap -dump:[live,]format=b,file=xx.hprof <pid>`  

# 快速简单识别
# 避免下Dump,但这个命令必带一次FullGC(先GC后打印) 
jmap -histo[:live] <pid>  
# 打印每个class的实例数目,占用内存大小,(*表示VM内部类)  
# 如果某个对象实例占用内存呈现不正常的占比超大,就值得怀疑 
#
#  num     #instances         #bytes  class name
#  ----------------------------------------------
#   1:         65220        9755240  <constMethodKlass>
#   2:         65220        8880384  <methodKlass>
#   3:         11721        8252112  [B
#   4:          6300        6784040  <constantPoolKlass>
#   5:         75224        6218208  [C
#   6:         93969        5163280  <symbolKlass>
#   7:          6300        4854440  <instanceKlassKlass>
#   8:          5482        4203152  <constantPoolCacheKlass>
#   9:         72097        2307104  java.lang.String
#  10:         15102        2289912  [I
#  11:          4089        2227728  <methodDataKlass>

晋升失败

晋升失败是指年轻代如果分配不下,触发内存担保然后失败后产生的FullGC
晋升失败在不同的GC产品中又不同的表象

  • CMS promation failed
  • G1 to space exhausted

晋升失败有两种完全不同的解决方向,年轻代调整老年代调整
如何抉择则取决于应用程序自身的情况

年轻代

调整年轻代是为了阻止错误晋升,也就是阻止每次都将大量的瞬时对象晋升老年代(这种情况一样会造成大量晋升失败),此时需要参考老年代回收量来辅助决策
如果老年代回收量每次都非常大(每次回收都能到一半甚至三分之二以上),就有相当大的几率判定是瞬时对象被错误晋升了(老年代的本意是长生命对象,也就是正常不应该有这么大的回收量,除非老年代里有相当大的瞬时对象进入了)

如果确定是错误晋升了,决定在年轻向调整又有两个解决方向,需参考老年代对象生命分布

  • 扩Survivor区
    如果老年代中的对象整体偏小,说明对象很多是非年龄满晋升,这说明S区小了(有年龄占比S区一半直接晋升的机制)
    -xx:SurvivorRatio=N(8),往下压(默认8,说明S区默认年轻代的十分之一)
  • 扩年轻代,或提升晋升年龄,(或者加堆)
    如果老年代中的对象整体偏大(或接近满年龄),说明Eden区小了(Eden区每满一次,复制整理一次年龄递增),年龄增长太快
    -xx:NewSize=N 扩充年轻代(相应Eden也会同步增大)
    -xx:MaxTenuringThreshold=N(15,CMS:6) 提升晋升年龄(大部分情况只需加大年轻代就可,除非单独调过)
    -xx:TargetSurvivorRatio=N(50) 直接晋升的判断百分比

调大年轻代,MiniorGC本身会高频触发,年轻代越大则代表需要扫描的对象越多,可能会有性能损失
但并不见得性能一定损失多少,其核心就在于扫描一次能回收多少对象(瞬时对象有多少)
MinorGC的性能损失主要是在内存整理而不是内存扫描上(MinorGC必带内存整理)
也就是说如果对象增多,但基本都被回收后需要加入内存整理(复制算法)的对象少则性能损失很小,但如果瞬时对象不多,大量对象都被滞留到年轻代然后不停的参与内存整理复制,就非常消耗性能了

老年代

如果对象本身没有被错误晋升,也就是说是老年代本身的问题,就需要真正从老年代着手了
老年代的调整目标只有一个,增大老年代分配担保的能力

  • 加大老年代(或者加堆)
  • 提前触发老年代GC,将MajorGC的触发阈值往下调
    -xx:CMSInitiatingOccupancyFraction=N(80) CMS-GC的触发阈值
    -xx:InitiatingHeapOccupancyPercent=N(45) G1-MixGC的触发阈值

老年代满

原理同,参看上节

特殊情况

并发标记失败

并发标记失败,是并发型GC(CMS或者G1)特有的问题
并发GC的特性是在只在标记GC-ROOT时STW.之后的标记过程中不STW(所以叫并发),也就是必有浮动垃圾存在(因为应用程序没有停止,所以会理所当然的继续产生新的垃圾)

并发标记失败是指,GC正在并发标记的时候,还未来得及回收之前老年代就满了,此时会直接停止并发标记,立即进入FullGC

并发标记失败非常好界定,因为此时GC会记录相应的异常信息

  • CMS concurrent mod failure
  • G1 concurrent-mod-abort

解决并发标记失败的核心思路是,必须保证在并发标记阶段不能让老年代用满

  • 提前触发,让老年代有更多的空闲空间
    CMS -XX:CMSInitiatingOccupancyFraction=N(80) 下调
    G1 -xx:InitiatingHeapOccupancyPercent=N(45) 下调
  • 为老年代设置更大的预留空间(救助空间)
CMS内存碎片

这是一种CMS-GC的特有情况
CMS是一种标记清理,因为没有内存整理,所以必然会产生老年代内存碎片
这是CMS的最大劣势,也是一种无解的存在,只能在一定程度上缓解

-xx:+UseCMSCompactAtFullCollection 在每一次FullGC之后强制一次内存整理

G1大对象分配

这是一种G1的特有情况
每一个大对象都是直入老年代的,但G1的Region特性,使得在G1上有略微不同
G1的大对象分配必须找到连续的老年Region区来存放大对象(其它老年代本身是连续的),如果没有符合条件的连续老年代H区,就会触发FullGC

大对象分配失败也非常好界定,G1会记录相应的异常信息

280.008: [G1Ergonomics (Concurrent Cycles) request concurrent cycle initiation, reason: occupancy higher than threshold, occupancy: 62344134656 bytes, allocation request: 46137368 bytes, threshold: 42520176225 bytes (45.00 %), source: concurrent humongous allocation]

G1识别大对象的标准是:超过Region一半以上就视为大对象
解决办法是调大Region大小(将大对象的识别标准提高和减少Region的碎片化)

  • -xx:G1HeapRegionSize=N(1M) 调大(上限32M)

调大Region对象是一种整体调整(不能针对某一个或某一类区),有一定的性能损耗
因为Region是G1的单次回收最小单位(G1依靠不停的回收一部分Region来获得停顿预测),调大Region则意味着粒度变大,单次回收的对象增多

优化型

MinorGC频繁

MinorGC主要是年轻代的瞬时对象回收,正常情况下,MinorGC必然是频次很高的
但MinorGC频繁有一个隐式问题是,对象的生命迭代非常快,可能产生的问题是将稍长一点的瞬时对象也推送到老年代,导致频繁的MajorGC

推定MinorGC是否正常(是否存在错误晋升)的参考指标是MajorGC的回收量以及老年代对象生命分布

详见上文

偏向锁停顿

这是使用synchronize锁的在高并发情况下的问题
synchronize锁有一个锁升级的膨胀优化过程(偏向锁->轻量锁->内核锁)
但是synchronize锁由偏向锁->轻量锁,也就是产生锁争夺的时候会产生线程停顿

  • 挂起持有偏向锁的线程
  • 锁状态由偏向错变为轻量锁,清理偏向指向
  • 恢复持有偏向锁线程(但此时它已经失去了偏向身份)

如果打印安全点停顿日志,会发现有大量的偏向锁停顿日志

-XX:+PrintSafepointStatistics  -XX:PrintSafepointStatisticsCount=1  

5.141: RevokeBias[13 0 2] [0 0 0 0 0] 0 
Total time for which application threads were stopped: 0.0000782 seconds, Stopping threads took: 0.0000269 seconds

# – JVM启动之后所经历的毫秒数(上例中是5.141)
# – 触发这次暂停的操作名(RevokeBias)。
# 如果你看见”no vm operation”,就说明这是一个”保证安全点”。JVM默认每秒会触发一次安全点来处理那些非紧急的排队的操作。GuaranteedSafepointInterval选项可以用来调整这一行为(设置为0的话就会禁用该功能)
# – 停在安全点的线程的数量(13)
# – 在安全点开始时仍在运行的线程的数量(0)
# – 虚拟机操作开始执行前仍处于阻塞状态的线程的数量(2)
# – 到达安全点时的各个阶段以及执行操作所花的时间(0)

-XX:-UseBiasedLocking关闭偏向锁(直接是轻量锁开始)
是否关闭偏向锁取决于应用程序的实际场景(使用了synchronize的地方)

  • 如果并发冲突非常激烈,偏向锁根本保持不住,就可以考虑关闭偏向锁
  • 如果并发冲突不激烈,那么偏向锁是一种非常好的优化手段(偏向锁等于无锁)

提前占领内存页

-XX:+AlwaysPreTouch 启动时就分配好并置零所以内存页

  • 提前分配内存页可以有效的降低启动后的第一次访问停顿
  • 提前分配内存页会增大启动时间

这个一般用在防止启动风暴的解决方案里
在有启动风暴危险的系统中,必然会有一个预热过程,所以不在乎启动时间稍长(必然长),而是特别在乎启动之后的请求涌入(系统刚启动的时候是系统最脆弱的时候,热点编译,缓存,各种连接池往往还处在初始状态,所以必须要预热)

避免伸缩

以堆为例,堆分配时一般同时设置-Xmx=N -Xms=N
这是为了避免伸缩堆

如果设置最小100M,最大200M,那么在某次GC后占用降低,堆大小会从200M又被释放回收到100M
之后在使用过程中,又需要再次申请逐步扩充到200M,之后再次被释放,又需要再次申请

元空间上限

元空间(使用堆外内存),默认是无限的
对于程序而言,任何无限两个字都需要打上深深的问号
建议设置元空间上限(如果动态代理用得多就设大一点就行)

-xx:MetaspaceSize=512M或1G,2G -xx:MaxMetaspaceSize=N

并发收集线程数

并发收集线程数计算

# 如果CPU核数小于8核
线程数=CPU核数  
# 如果CPU核数大于8核  
线程数=8+(CPU核数-8)*(5/8)

# 需要注意的是 CMS需要再算一下  
CMS的线程数= (上述线程数+3)/4

这在微小服务中可能是一个很可怕的数字
比如一个仅仅只是开一个线程扫描一个XXX然后发出通知的小作业,如果在一个64核的系统上
它的GC线程数是
` 8+(64-8)*5/8=43`
也就是它会开43个GC线程来处理GC(CMS需要12个线程),这个应用程序本身才跑了一个业务线程

-xx:ParallelGCThreads=N 手动设置线程数

GC调优案例

代理服务调优

代理服务是一个生产上的请求转发服务(简单来说就是接受一个请求,验证签名后请求转发到另一个系统去)

  • 几乎都是瞬时对象(方法局部变量,接受请求处理然后转发,即宣告结束)
    长生命对象几乎都是静态单例对象(比如配置)
  • 响应时间越短越好,不希望在这个转发过程产生太长停顿
  • 一个业务占比非常非常小的微小服务,不希望侵占太多的系统资源
-Xmx=6g -Xms=6g  # 堆6G 
-xx:MetaspaceSize=512M -xx:MaxMetaspaceSize=512M # 元空间512M
-xx:+useConcMarkSweepGC # 使用CMS  
-Xmn=2g # 年轻代2G (占比接近一半(理论上的最大占比))   
-XX:SurvivorRatio=2 -XX:MaxTenuringThreshold=15
-xx:+UseCMSCompactAtFullCollection # 强制FullGC之后内存整理
-xx:ParallelGCThreads=4  # 压低GC线程数 

核心思路是期望应用程序的GC全部依靠MinorGC解决

  • 老年代4G并不是为了大量使用老年代,而是为了确保分配担保必然成功,转而使用MinorGC
  • S区和年龄调大,是为了让对象尽可能就在年轻代解决
    这里基于的认知是,一个对象最多在年轻代倒腾一到两次就会被回收(事实也的确如此,局部方法变量只有临界点才有可能活到第二次MinorGC,绝大部分对象在第一次MinorGC就会被回收了)
  • 线上是16核机器,该应用的理论GC线程数是10线程(CMS),压低到4
    事实上用不到这么大的并发线程(极端一点说,用串行都不是不是),因为在这个应用中MajorGC是属于非常少的情况,而MinorGC不适用并发模式

Spark作业调优

Spark作业的特征是

  • 大对象较多(序列化的字节数组)
  • Spark作业对响应时间没有特别的大要求(正常停顿200MS是可以接受的)

Spark作业使用的G1
因为G1的自优化特性,需要干预G1的地方很少(也应该尽可能少干预G1工作过程)

# Spark作业的特殊性 
#     堆大小 和 堆外内存设置 不在java参数里设置  
-xx:+UseG1GC 
-xx:G1HeapRegionSize=16M  # 调大G1分区
-XX:MaxGCPauseMills=200ms # 期望停顿  

# GC监控  
-Xloggc:/tmp/spark.gc.log
-XX:+PrintGCDetails 
-XX:+PrintGCDateStamps 
-XX:+G1SummarizeConcMark