JVM 性能调优案例篇
概述
生产环境中的问题
- 生产环境发生了内存溢出该如何处理?
- 生产环境应该给服务器分配多少内存合适?
- 如何对垃圾回收器的性能进行调优?
- 生产环境CPU负载高该如何处理?
- 生产环境应该给应用分配多少线程合适?
- 不加log,如何确定请求是否执行了某一行代码?
- 不加log,如何实时查看某个方法的入参与返回值?
调优基本问题
- 为什么要调优?
- 防止出现OOM,进行JVM规划和预调优
- 解决程序运行中各种OOM
- 减少 FuIl GC出现的频率:解决运行慢:卡顿问题
- 调优的大方向
- 合理地编写代码
- 充分并合理的使用硬件资源
- 合理地进行 JVM 调优
- 不同阶段的考虑
- 上线前
- 项目运行阶段
- 线上出现OoM
- 两句话
- 调优,从业务场景开始,没有业务场景的调优都是耍流氓
- 无监控,不调优
调优监控的依据
- 运行日志
- 异常堆栈
- GC日志
- 线程快照
- 堆转储快照
性能优化的步骤
- 熟悉业务场景
- 性能监控
- GC频繁
- CPU load 过高
- OOM
- 内存泄漏
- 死锁
- 程序响应时间较长
- 性能分析
- GC日志,GC viewer
- 命令行工具:jstack,jmap,jinfo
- 分析dump文件
- 性能调优
- 增加内存,调整垃圾回收器
- 优化代码
- 增加机器
- 合理设置线程池线程数量
- 缓存,消息队列
性能评价/测试指标
OOM案例
OOM案例1:堆溢出
原因
- 代码中可能存在大对象分配
- 可能存在内存泄漏,导致在多次 GC 之后,还是无法找到一块足够大的内存容纳当前对象
解决方法
- 检查是否存在大对象的分配,最有可能的是大数组分配
- 通过 jmap 命令,把堆内存 dump下来,使用 MAT 等工具分析一下,检查是否存在内存泄漏的问题
- 如果没有找到明显的内存泄漏,使用 -xmx 加大堆内存
- 还有一点容易被忽略,检查是否有大量的自定义的 Finalizable 对象,也有可能是框架内部提供的,考虑其存在的必要性
OOM案例2:元空间溢出
原因
- 运街期间生成了大量的代理类,导致方法区被撑爆,无法卸载
- 应用长时间运行,没有重启
- 元空间内存设置过小
解决方法
- 检查是否永久代空间或者元空间设置的过小
- 检查代码中是否存在大量的反射操作
- dump之后通过mat检查是否存在大量由于反射生成的代理类
OOM案例3:GC overhead limit exceeded
原因
- 这个是JDK6新加的错误类型,一般都是堆太小导致的
- sun 官方对此的定义:超过98%的时间用来做 GC 并且回收了不到2%的堆内存时会拋出此异常,本质是一个预判性的异常,抛出该异常时系统没有真正的内存溢出
解决方法
- 检查项目中是否有大量的死循环或有使用大内存的代码,优化代码
- 添加参数
-XX:-UseGcoverheadLimit
,禁用这个检查,其实这个参数解决不了内存问题,只是把错误的信息延后,最终出现java.lang.outOfMemoryError: Java heap space
- dump内存,检查是否存在内存泄漏,如果没有,加大内存
OOM案例4:线程溢出
原因
- 出现这种异常,基本都是创建了大量的线程导致的
- 通过 -Xss 可以设置每个线程栈大小的容量
- JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256k
- 正常情况下,在相同物理内存下,减小这个值能生成更多的线程,但是操作系统对一个进程内的线,程数还是有限制的,不能无限生成,经验值在3000~5000左右
- 能创建的线程数的具体计算公式:(MaxProcessMemory - JVMMemory - ReservedOsMemory) / (ThreadStackSize)
- MaxProcessMemory 指的是进程可寻址的最大空间
- JVMMemory 指的是 JVM 内存
- ReservedosMemory 是保留的操作系统内存
- ThreadStackSize是线程栈的大小
- 在 Java 语言里,当你创建一个线程的时候,虚拟机会在JVM内存创建一个Thread对象同时创建个操作系统线程,而这个系统线程的内存用的不是JVMMemory,而是系统中剩下的内存:MaxProcessMemory - JVMMemory - ReservedOsMemory
- 由公式得出结论:你给JVM内存越多,那么你能创建的线程越少,越容易发生 java.lang.OutOfMemoryError: unable to create new native thread
解决方法
- 如果程序中有bug,导致创建大量不需要的线程或者线程没有及时回收,那么必须解决这个bug,改参数是不能解決问题的
- 如果程序确实需要大量的线程,现有的设置不能达到要求,那么可以通过修改 MaxProcessMemory、JVMMemory、Threadstacksize 这三个因素,来增加能创建的线程数
- MaxProcessMemory:使用64位操作系统
- JVMMemory:减少JVMMemory的分配
- 线程总数也受到系统空闲内存和操作系统的限制,检查是否该系统下有此限制
- /proc/sys/kernel/pid_max:系统最大pid值,在大型系统里可适当调大
- /proc/sys/kernel/threads-max:系统允许的最大线程数
- maxuserprocess (ulimit -u):系统限制某用户下最多可以运行多少进程或线程
- /proc/sys/vm/max map count:max map __count 文件包含限制一个进程可以拥有的VMA(虚拟内存区域)的数量,虚拟内存区域是一个连续的虚拟地址空间区域。在进程的生命周期中,每当程序尝试在内存中映射文件,链接到共享内存段,或者分配堆空间的时候,这些区域将被创建,调优这个值将限制进程可拥有 VMA 的数量,限制一个进程拥有VMA的总数可能导致应用程序出错,因为当进程达到了VMA上限但又只能释放少量的内存给其他的内核进程使用时,操作系统会抛出内存不足的错误,如果你的操作系统在NORMAL 区域仅占用少量的内存,那么调低这个值可以帮助释放内存给内核用
Jmeter
性能优化案例
性能优化案例1:调整堆大小提高服务的吞吐量
export CATALINA_OPTS="$CATALINA_OPTS -Xms120m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:SurvivorRatio=8"
export CATALINA_OPTS="$CATALINA_OPTS -Xmx120m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+UseParallelGC"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGCDetails"
export CATALINA_OPTS="$CATALINA_OPTS -XX:MetaspaceSize=64m"
export CATALINA_OPTS="$CATALINA_OPTS -XX:+PrintGDateStamps"
export CATALINA_OPTS="$CATALINA_OPTS -Xloggc:/opt/tomcat8.5/logs/gc.log"
性能优化案例2:JVM优化之 JIT 优化
堆是分配对象的唯一选择吗
在《深入理解Java虚拟机中》 关于Java堆内存有这样一段描述
- 随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了
- 在Java虛拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识,但是,有一种特殊情况,那就是如果经过逃逸分析 (Escape Analysis) 后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配,这样就无需在堆上分配内存,也无须进行垃圾回收了这也是最常见的堆外存储技术
- 此外,前面提到的基于 OpenJDK 深度定制的 TaoBaoVM,其中创新的GCIH (GC invisible heap) 技术实现 off-heap,将生命周期较长的Java 对象从 heap 中移至 heap 外,并且GC不能管理 GCIH 内部的 Java 对象,以此达到降低GC的回收频率和提升GC的回收效率的目的
编译的开销
时间开销
说 JIT 比解释快,其实说的是 “执行编译后的代码” 比 “解释器解释执行” 要快,并不是说 “编译” 这个动作比“解释”这个动作快,JIT编译再怎么快,至少也比解释执行一次略慢些,而要得到最后的执行结果还得再经过一个 “执行编译后的代码” 的过程,所以,对 “只执行一次” 的代码而言,解释执行其实总是比 JIT 编译执行要快,下面条件同时满足时就是严格的、只执行一次
- 只被调用一次,例如类的构造器
- 没有循环,对只执行一次的代码做了IT编译再执行,可以说是得不偿失
- 对只执行少量次数的代码,JIT编泽带来的执行速度的提升也木必能抵消掉最初编泽带来的开销
只有对频繁执行的代码,JIT才能保证有正面的收益
空间开销
一般的Java方法而言,编译后代码的大小相对于字节码的大小,膨账比达到10+ 是很正常的,同上面说的时间开销一样,这里的空间开销也是,只有对执行频繁的代码才值得编译,如果把所有代码都编译则会显著增加代码所占空间,导致代码爆炸,这也就解释了为什么有些JVM会选择不总是做JIT编译,而是选择用解释器加JIT编译器的混合执行引擎
即时编译对代码的优化
-
逃逸分析
-
如何将堆上的对象分配到栈,需要使用逃逸分析手段
-
逃逸分析 (Escape Analysis) 是目前Java虚拟机中比较前沿的优化技术,这是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法
-
通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上
-
逃逸分析的基本行为就是分析对象动态作用域
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸,例如作为调用参数传递到其他地方中
-
没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除
-
逃逸分析包括
-
全局变量赋值逃逸
-
方法返回值逃逸
-
实例引用发生逃逸
-
线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量
-
-
參数设罝
- 在 JDK 6u23版本之后,Hotspot中默认就己经开启了逃逸分析
- 如果使用的是较早的版本,开发人员则可以通过 “-XX:+DoEscapeAnalysis〞开启逃逸分析, “-XX+PrintEscapeAnalysis〞 查看逃逸分析的筛选结果
- 开发中能使用局部变量的,就不要使用在方法外定义
-
-
优化一:栈上分配
- 将堆分配转化为栈分配,如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配,这样就无需在堆上分配内存,也无须进行垃圾回收了可以减少垃圾回收时间和次数
- 分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收
-
优化二:同步消除
- 如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
- 线程同步的代价是相当高的,同步的后果是降低并发性和性能
- 在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程,如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步,这样就能大大提高并发性和性能,这个取消同步的过程就叫同步省略,也叫锁消除
-
优化三:标量替换
- 标量(Scalar)是指一个无法再分解成更小的数据的数据,Java中的原始数据类型就是标量
- 相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量
- 在 JIT 阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过 JIT 优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替,这个过程就是标量替换
- 参数 -XX:+EliminateAllocations:开启了标量替换 (默认打开),允许将对象打散分配在栈上
-
小结
- 逃逸分析并不成熟,关于逃逸分析的论文在1999年就己经发表了,但直到 JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的
- 其根本原因就是无法保证非逃逸分析的性能消耗一定能高于它的消耗,虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除,但是逃逸分析自身也是需要进行一系列复朵的分析的,这其实也是一个相对耗时的过程
- 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的,那这个逃逸分析的过程就白白浪费掉了
- 虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段
- 注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于了VM设计者的选择
- 目前很多书籍还是基于 JDK 7 以前的版本,JDK己经发生了很大变化,intern字符串的缓存和静态变量是曾经都被分配在永久代上,而永久代己经被元数据区取代,但是,intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上
性能优化案例3:合理配置堆内存
推荐配置
- Java整个堆大小设置,Xmx 和 Xms 设置为老年代存活对象的 3-4 倍,即FullGC之后的老年代内存占用的3-4倍
- 方法区(永久代 PermSize和MaxPermSize 或元空间 MetaspaceSize 和 MaxMetaspacesize)设置为老年代存活对象的1.2-1.5倍
- 年轻代 Xmn 的设置为老年代存活对象的1-1.5倍
- 老年代的内存大小设置为老年代存活对象的2-3倍
如何计算老年代存活对象
- JVM参数中添加GC日志,GC日志中会记录每次 FullGC 之后各代的内存大小,观察老年代GC之后的空间大小,可观察一段时间内,比如2天的 FullGC 之后的内存情况,根据多次的 FullGC 之后的老年代的空间大小数据来预估 FullGC 之后老年代的存活对象大小,可根据多次FulIGC 之后的内存大小取平均值
- 第一种的方式比较可行,但需要更改JVM参数,并分析日志,同时,在使用CMS回收器的时候,有可能不能触发FulIGC,所以日志中并没有记录FullGC的日志,在分析的时候就比较难处理。所以,有时候需要强制触发一次FullGC,观察FullGC之后的老年代存活对象大小
- 强制触发FullGC,会造成线上服务停顿 (STW),要谨慎!建议的操作方式为,在强制 FuIlGC 前先把服务节点摘除,FullGC之后再将服务挂回可用节点,对外提供服务,在不同时间段触发FullGC,根据多次 FullGC 之后的老年代内存情况来预估FullGC之后的老年代存活对象大小
- 如何强制触发Full GC?
- jmap -dump:live,format=b,file=heap.bin <pid>将当前的存活对象dump到文件,此时会触FullGC
- jmap -histo:live <pid>打印每个class的实例数目,内存占用,类全名信息,当加上 live 子参数后,只统计活的对象数量,此时会触发FuIlGC
- 在性能测试环境,可以通过Java监控工具来触发FullGC,比如使用VisualVM和JConsole,VisualVM集成了JConsole,VisualVM或者JConsole上面有一个触发GC的按钮
结论
在内存相对紧张的情况下,可以按照上述的方式来进行内存的调优,找到一个在GC频率和GC耗时上都
可接受的一个内存设置,可以用较小的内存满足当前的服务需要
但当内存相对宽裕的时候,可以相对给服务多增加一点内存,可以减少GC的频率,GC的耗时相应会增加一些内存
一般要求低延时的可以考虑多设置一点内存,对延时要求不高的,可以按照上述方式设置较小,如果在垃圾回收日志中观察到OutOfMemoryError,尝试把Java堆的大小扩大到物理内存的80%~90%,尤其需要注意的是堆空间导致的OutOfMemoryError以及一定要增加空间
- 比如说,增加-Xms和-Xmx的值来解决old代的OutofMemoryError
- 增加 -XX:Permsize 和 -XX:MaxPerisize 来解决 permanent 代引起的OutofMemoryError (jdk7之前) ,增加 -XX:Metaspacesize 和-XX:MaxMetaspacesize 来解决 Metspace 引起的 outofMemoryError (jdk8之后)
记住一点Java堆能够使用的容量受限于硬件以及是否使用64位的JVM,在扩大了Java堆的大小之后,再检查垃圾回收日志,直到没有OutofMemoryError为止,如果应用运行在稳定状态下没有 outofMemoryError 就可以进入下一步去计算活动对象的大小
如何估算GC频率
正常情况我们应该根据我们的系统来进行一个内存的估算,这个我们可以在测试环境进行测试,最开始可以将内存设置的大一些,比如4G这样,当然这也可以根据业务系统估第来的
比如从数据库获取一条数据占用128个字节,需要获取1000条数据,那么一次读取到内存的大小就是128B/1024Kb/1024M)*1000=0.122M
,那么我们程序可能需要并发读取,比如每秒读取100次,那么内存占用就是0.122*100=12.2M
如果堆内存设置1个G,那么年轻代大小大约就是333M,那么333M*80% /12.2M =21.84s
,也就是说我们的程序几乎每分钟进行两到三次youngGC,这样可以让我们对系统有一个大致的估算
特殊问题:新生代与老年代的比例、
JDK 1.8 默认使用 UseParallelGC 垃圾回收器,该垃圾回收器默认启动了 AdaptivesizePolicy,会根据GC的情况自动计算计算 Eden、From 和 To 区的大小:所以这是由于JDK1.8的自适应大小策略导致的,除此之外,我们下面观察GC日志发现有很多类似这样的Full GC (Ergonomics),也是一样的原因
我们可以在jvm参数中配置开启和关闭该配置
# 开启:
-xx:+UseAdaptivesizePolicy
# 关闭
-xx:-UseAdaptivesizePolicy
注意事项
- 在JDK 1.8中,如果使用 CMS,无论 UseAdaptivesizePolicy 如何设罝都会将 UseAdaptivesizePolicy 设置为 false,不过不同版本的JDK存在差异
- UseAdaptivesizePolicy 不能和 SurvivorRatio 参数显示设置搭配使用,一起使用会导致参数失效
- 由于 UseAdaptivesizePolicy 会动态调整 Eden、Survivor 的大小,有些情况存在 Survivor 被自动调为很小,比如十几MB甚至几MB的可能,这个时候 YGC 回收掉 Eden区后,还存活的对象进入 Survivor 装不下,就会直接晋升到老年代,导致老年代占用空间逐渐增加从而触发FULLGC, 如果一次 FULLGC 的耗时很长,比如到达几百毫秒,那么在要求高响应的系统就是不可取的
- 对于面向外部的大流量、低延迟系统,不建议启用此参数,建议关闭该参数
- 如果不想动态调整内存大小,以下是解决方案
- 保持使用 UseParallelG,显式设置 -xx:SurvivorRatio=8
- 使用 CMS 垃圾回收器,CMS 默认关闭 Adaptivesizepolicy,配罝参数 -XX:+UseConcMarkSweepGC
性能优化案例4:CPU占用很高排查方案
ps aux | grep java
查看到当前 Java进程使用 cpu、内存、磁盘的情况获取使用量异常的进程top -Hp 进程pid
检查当前使用异常线程的pid- 把线程 pid 变为16进制
jstack 进程pid | grep -A20 线程pid
得到相关进程的代码
性能优化案例5:G1并发执行的线程数对性能的影响
-XX:ConcGCThreads=2
- 主要是影响吞吐量,GC耗时缩短