如果说有什么在面试中经常被问到,但是在实际工作中又不经常用到的Java技术,那么JVM调优绝对可以排得上号。每当有同学被问到这个问题的时候,内心的OS大概是这样:我一个QPS几百的系统,有啥好调优的,默认配置用用得了,调JVM参数整不好系统还能干崩了,想想好像是这么个道理。但是对于一些高并发大流量业务场景,JVM调优就有用武之地了。因此个人觉得JVM调优也许不像SQL调优或者代码优化在工作中使用得那么频繁,甚至很多时候其实是用不上的,但是在需要用到的时候如果你能顶得住压力完成优化,那么你和其他人的不同就显现出来了。另外如果我们想进入一线互联网大厂,那么JVM调优就是必须要掌握的重要技能。
那么JVM到底应该怎样调优呢?有没有什么套路我们可以学习?本文主要着眼于如何进行参数预估以及JVM优化,希望对大家平时工作可以有所裨益。
预估比调优更重要
为什么需要进行预估
所谓凡事预则立不预则废,对于JVM调优来说也是如此。无论修改线上已有JVM参数配置还是优化代码实际都是一种无奈之举,因为生产环境出现了运行异常不得不采用这种方式进行优化,从而保障线上应用服务能够正常运行,否则就要拉程序员出来祭天了。但是如果我们在服务发布部署之前可以预估服务的容量而后进行对应的JVM参数配置,那么就相当于把可能出现的JVM异常扼杀在摇篮中。当然这是最理想的状态,在现实中也实际不容易做到,但是即便我们不能预估的那么准确,也总比不做容量预估直接裸奔上线的好。因此关于JVM调优这件事情,实际和《孙子兵法》的核心思想有异曲同工之妙,上兵伐谋,其次伐交,其次伐兵,其下攻城。也就是说JVM调优最高境界是预估不调,打仗最高境界是不战而胜。
如何进行预估
JVM参数预估基本流程
在明确了JVM参数预估对于生产环境中服务稳定运行的意义之后,我们一起看下如何进行JVM参数预估。首先我们应该先分析下自己系统的核心业务流程是什么,然后根据核心业务流程结合线上可能的流量,确认好我们核心业务代码中的对象创建以及销毁情况是怎样的,最后再针对性的进行相关JVM参数的预估配置。因此JVM参数预估的地址基本流程如下所示:
案例驱动
这里以一个实际的业务场景案例来帮助大家更好理解JVM参数预估的过程。假设有这样一个电商平台,它主要由商品中心、订单中心、营销中心、库存中心等子系统组成。那对于电商系统来说最核心的业务就是用户下单购物,我们就以用户下单购物这个业务流程来看看如何进行估算JVM参数。
假设平台有1个亿的注册用户,日活用户1000万,这些用户会在电商平台进行浏览商品、下单购买以及收货评价等操作,但是实际上真正下单购买的用户并没有那么多,如果有10%的转化率,那么就相当于每天电商平台有100w个订单。另外一般情况下这些订单主要分布在一天当中的高峰时间,比如中午或者晚上,毕竟其他时间大家要忙碌工作以及其他事情,中午休息或者晚上休息的时候才会有时间逛平台买买买。如果我们把用户购买的高峰时间定为3小时,也就是说极端情况下将所有的订单的生成都分布在这三个小时中完成,也就是每小时产生33万个订单,每秒产生92个订单左右。
在估算订单对象大小之前,我们先来看下堆中的对象由哪些元素组成。一个JVM对象的大小主要由三部分组成,分别是对象头、数据以及数据补齐。对象头以及对象补齐基本变化不大,因此对象的大小实际和对象中的属性有直接关系,对象中的属性越多,对象占用的空间大小也就越大。
Mark Word:主要存储对象自身的运行数据,包括HashCode、GC分代年龄锁状态标志、线程持有的锁等信息,根据操作系统位数的不同而不同,32位的操作系统对应的大小就是32bit,64位的操作系统对应的大小就是64bit;
Klass Pointer:指向对象对应的Class对象的内存地址,根据不同的操作数系统占用空间不同,在64位系统中占用8个字节;
Array Length:如果当前对象是一个数组对象那么此处存储的就是数组的大小,占用4个字节,如果不是数组对象那么就不占用。
回到我们刚才的案例当中,我们来具体估算一个订单对象大概占多少内存空间。订单对象主要包括了如下的属性:订单编号、商品编号、商品价格、创建时间、付款时间以及发货时间等,当然实际订单可能不止这些属性,我们只是说明对象大小估算的方法,因此对属性进行了相应的裁剪。如果数据层面包含了这些属性,那么数据部分的占用空间大小就是这些属性的大小总和。总共估算下来应该不到1kb,但是实际考虑到其他各种占用以及平台中肯定不止订单这一种对象,还会有库存对象、积分对象、物流对象、营销对象等等,因此我们考虑将对象的总和扩大30倍进行估算,也就是说平台中产生的各种对象的总和为30乘以1kb即为30kb。如果每秒产生92个对象的话,那么就相当于每秒产生2760kb的对象,也就是大概2Mb的对象,另外由于电商平台中布置下单这一个操作,还会包含订单查询、商品查询等等其他业务那么综合起来我们再放大10倍,也就是说每秒JVM中新增20Mb左右的对象。对于一台4核8G的服务器来说,我们可以为服务分配3G左右的堆内存,512Mb左右的元空间。
但是考虑到电商平台存在大促场景,这个时候的流量可能是平时的好几倍,因此我们实际上需要将堆内存中的年轻代进行放大,Eden区可以到1.6G,Survivor区可以各自200M。这样可以避免由于年轻代空间不足导致对象提前进入老年代而造成fullGC的频率变高,从而影响服务的稳定性。
JVM调优思路JVM
理想情况下预估的JVM参数应该可以cover线上的业务场景,但是假如公司业务发展飞快,业务体量迅速膨胀,原先预估的JVM配置参数可就不一定能满足线上生产环境所有情况,因此异常情况还是会出现。这里将JVM异常主要分为两类,一种是代码导致的JVM异常,另一种是JVM不合理配置导致的异常,包括JVM参数以及服务器内存配置。
代码导致的JVM异常
代码Bug应该是导致JVM异常最常见的情况,这种情况我们只能通过调整代码才能实现优化,因为即使临时调整JVM参数也只是缓兵之计,并没有根除问题所在,随着时间的推移,业务的发展问题还是会暴露。所以要想解决根本问题还是需要定位问题代码来进行优化。
那么首先我们就需要能够有手段定位到到底哪部分代码导致JVM异常。一般分为两种情况,一种就是已经发生内存溢出了,另一种是还没有发生内存溢出但是已经在崩溃的边缘,系统响应也变慢了。如果我们配置了-XX:HeapDumpPath参数,当JVM发生内存溢出的时候就可以到对应的目录去找到hprof文件。如果还没有发生内存溢出,这个时候我们可以通过操作命令jmap -dump:format=b,file=/tmp/文件名.hprof <PID>来手动导出内存快照来进行进行分析。有了hprof文件之后,我们可以通过MAT工具来分析和定位内存溢出代码位置,然后再进行针对性的优化。
Java代码引起的JVM异常可以分为以下几种情况,我们一起来看看有哪些:
(1)如下图的例子,客户端和服务端建立了websocket连接,如果连接未正常建立,又重新建立连接如果此时服务端未将连接关闭,那么就会导致重新使用新的请求对象,随着时间的累计JVM中出现大量对象来不及回收,导致JVM无法分配新的内存空间给服务中新产生的对象,最终导致JVM内存溢出。由于JVM中堆积了几千个RequestIInfo对象,同时服务还在不断产生新的RequestInfo对象,最终不可避免地就会发生OutOfMemoryError异常。通过MAT我们可以轻松定位到发生内存溢出的代码位置,搞清楚为什么会有RequestInfo对象被创建之后,我们就可以进行针对性的优化了。
(2)我们在实际项目开发的过程中必定会涉及到业务数据的查询,假如没有控制好数据查询的条件或者说本身查询的数据量就很大。那么就容易造成一次性查询大量数据,这些数据如果全部load到内存中就很容易导致内存溢出。所以一般涉及到数据查询的代码要做好相应的处理,分页查询也好,限制查询数据量也好或者流式查询也好,总之不能一次性将大量数据加载到内存中。
(3)在for循环或者while循环中大量创建对象,最终导致对象在对堆内存中堆积,这种是由于条件没有控制好条件导致对象被不断创建。
(4)我们都知道JVM运行时数据区的虚拟机栈是线程所独有的,JVM启动后会为每个线程的虚拟机栈分配固定大小的内存(-Xss参数),因此虚拟机栈的深度是确定的,如果代码中出现不合理的递归代码,就会造成虚拟机栈只入栈不出栈,最终导致虚拟机栈内存空间被耗尽,从而产生StackOverFlowError。
当我们知道了这些常见的可能导致JVM异常的代码结构之后,那么在平常做项目编写代码的时候就要时刻保持警惕。写完代码之后自己再回头看看这段代码的对象创建情况是怎样的,有没有大对象缓存,有没有不合理的while循环for循环,会不会有可能造成JVM内存溢出。当我们有了这样反观代码的意识之后,从根本上JVM内存溢出的概率大大降低,有益于线上服务的稳定性。
JVM参数不合理导致的异常
线上环境JVM参数不合理直接影响JVM运行稳定性。我们都知道对象都是存放在堆内存中的,而堆内存又被划分为年轻代和老年代,新产生的对象都会被分配在年轻代对应的堆内存中,如果此时我们设置的年轻代过小。那么对象进入到老年代堆空间的概率就会增大,当然引起full GC的可能性也会大大增加。因此JVM参数如果设置的不合理一般是堆内存大小、元数据区大小以及垃圾回收器。另外我们需要根据不同的业务场景来选择对应的垃圾回收器,如果对于停顿时间有比较高的要求可以考虑G1和ZGC。
通过上文我们可以明确无论是优化业务代码还是参数调优,其实都是在避免在堆中遗留过多的对象。可以看得出来,JVM调优的本质思想其实就是生产者-消费者模型,为什么这么说呢?你看一方面随着平台业务的不断进行,JVM中会不断产生对象,那么平台就相当于对象生产者。另一方面垃圾回收器这个勤劳的小蜜蜂在不断检测哪些对象已经是垃圾对象,然后根据策略进行垃圾回收释放内存空间,那么JVM就相当于对象消费者。一个生产对象,一个消费对象,这可不就是生产者消费者模型嘛。所以从这个角度来看,生产者和消费者的动态平衡才能保证JVM的正常运行,如果对象生产地快,而对象回收地慢就会导致内存溢出等JVM异常。所以JVM调优从本质上来说,就是通过各种手段构建对象生产与回收的动态平衡。
JVM常见配置参数
无论是发布部署前的JVM参数预估还是异常过后的参数优化,都是需要通过调节JVM对应的参数来完成的,因此我们需要掌握常用JVM参数项及其含义。
配置项 | 含义 |
-Xms | 初始堆大小 |
-Xmx | 初始堆最大值 |
-Xmn | 堆中新生代最大值 |
-XX:SurvivorRatio | survivor区与Eden区的比例 |
-XX:NewRatio | 新生代和老年代的比例 |
-XX:MetaspaceSize | 初始元空间大小 |
-XX:MaxMetaspaceSize | 元空间最大大小 |
-Xss | 线程虚拟机栈大小 |
-XX:+HeapDumpOnOutOfMemoryError | 开启内存溢出时进行内存快照 |
-XX:HeapDumpPath=/data/dump/jvm.hprof | 内存快照文件路径 |
JVM常见垃圾回收器
随着JDK版本的不断迭代,垃圾回收器同样也在不断迭代优化。当然不同的业务场景,我们可以选择不同的垃圾回收器来进行应对,而垃圾回收器也在向着回收效率高、停顿时间短的目标不断进行调整改进。
垃圾回收器 | 引入版本 | 特点 | 适用场景 |
Serial GC | JDK3 | 单线程方式进行垃圾回收,暂停所有应用线程 | 适用于小型应用,单CPU的系统或者不需要高并发的场景 |
ParNew GC | JDK3 | Serial GC多线程实现 | 年轻代垃圾回收 |
Parallel GC | JDK4 | 利用多CPU、多核心的系统资源,提高垃圾回收效率 | 对吞吐量有要求的应用场景,如数据处理、科学计算等 |
CMS GC | JDK5 | 采用多线程方式进行垃圾回收,能够缩短应用程序的暂停时间 | 适用于对响应时间有要求的应用场景 |
G1 GC | JDK7 | 内存划分为一个个Region,可以指定停顿时间 | 适用于部署早多核CPU大内存机器上的大型应用,对停顿时间有一定要求, |
ZGC | JDK11 | 支持超大堆空间,最大停顿时间不超过10ms | 业务对于停顿时间低于100ms |
总结
任何技术上的优化都是建立在对技术原理的深刻理解基础之上的,JVM调优亦是如此。文章中常见的JVM调优手段只不过是一些术,搞懂JVM的运行原理以及垃圾回收机制才是关键。另外在调优前我们得先搞清楚我们调优的目标是什么,有了目标的指引,我们才能做到有的放矢。其实无论是性能优化还是业务优化其实都是有一定的规律可以摸索,万变不离其宗,都是通过观:观察当前是个什么样的状态;析:分析整条业务链路找到可以优化的方向以及改造点;优:动手制定优化策略以及验证方法进行优化实操。