JVM系列文章目录
JVM调优之预估调优与问题排查
前言
本文基于JDK1.8,Hotspot版本的JVM。
JVM调优分类
调优是个复杂且庞大的体系,这里我们可以系统化的把JVm调优分为三类:
- JVM预调优。
- 优化JVM运行时环境(运行慢、卡顿等问题)。
- 解决JVM中的问题(OOM等)。
JVM预调优
JVM预调优需要考虑一下两个方面:
-
业务场景
我们需要根据业务场景对JVM做调优,比如说侧重吞吐量的、注重响应时间的等,不同的业务场景调优的方式也不相同。 -
无数据不调优
这里说的数据指的是我们在正式上限前,对服务进行压测,针对压测出来的数据信息, 再进行调优;一定不能凭感觉或者单凭经验去调优,要结合量化的数据指标,比如说:吞吐量、响应时间、服务器资源、网络资源等等。 -
计算所需内存
内存并不一定是越大越好,我们需要根据实际需求和预算来调整内存大小,小内存也有小内存的好处:回收速度快。
相对的,我们处理调整堆内存,也可以着手虚拟机栈、元空间(方法区)。
比如说高并发的场景下我们完全可以调小虚拟机栈栈帧的大小,因为一个方法除非你写的太low了要不然很难打满1M。
至于调整元空间,其实我在上一篇内存优化与GC优化中有介绍,一般程序跑起来一段时间后,元空间大小基本上就固定了,由于元空间默认情况下是没有大小限制的,我们最好限定其最大值(一般几百M就够了,当然你得根据你的实际情况来调整)。 -
选择CPU
CPU当然是性能越强越好,但是也得按照你的预算来。 -
选择垃圾回收器
对于吞吐量优先的场景,只能选择PS组合(Parallel Scavenge + Parallel Old)。因为这一组垃圾回收器是多线程的,虽然会在垃圾回收的时候暂停所有业务线程,但是它们专注于垃圾回收,效率更高,回收更干净。GC时间短了,吞吐量自然就上去了。
对于响应时间优先的场景,JDK1.8的话优先选择G1,其次是CMS(这个我不是很推荐,由于内存碎片的问题,有可能过段时间你就要重启服务了,而且其他问题也不少,有条件还是直接G1吧)。 -
设定分代大小和分代年龄
这个在上一篇内存优化与GC优化最后的案例优化中也描写的很详细,吞吐量优先的场景下,在堆内存固定的情况时,一个更大的新生代,能让更多的短周期对象被回收,减少不必要的中期对象。
合理的调小分代年龄,能让长期对象尽快的进入老年代,不要在新生代晃荡,浪费时间。 -
设定日志
这个主要是为了方便我们分析问题。参数 -XX:+PrintGC #输出 GC 日志 -XX:+PrintGCDetails #输出 GC 的详细日志 -XX:+PrintGCTimeStamps #输出 GC 的时间戳(以基准时间的形式) -XX:+PrintGCDateStamps #输出 GC 的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800) -XX:+PrintHeapAtGC #在进行 GC 的前后打印出堆的信息 -Xloggc:../logs/gc.log #日志文件的输出路径
优化JVM运行时环境
一般造成JVM慢、卡顿一般是两种情况:
- CPU占用过高
- 内存占用过高
这个时候我们需要排查问题,再进行具体分析,本文后半部分会介绍如何排查和分析。
解决JVM中的问题
这个一般是OOM,可以参考我前面的博文深入理解JVM内存区域。
亿级流量的电商系统JVM调优
这个不是直接介绍代码,是结合现有信息和理论,虚拟化的场景,分析亿级流量电商该如何进行预调优。
系统流程
先上一张图,然后我们再慢慢介绍。
其实亿级电商平台的亿级说的是点击,根据某宝的官方分析数据,每个用户的一次浏览点击在20 ~ 40次之间,推测出日活用户在500万左右;在结合某宝的点击数据,我们能够发现,付费用户也就10%(上图橙色部分)左右。90%的用户都只是浏览,这些通过缓存技术就可以满足。根据剩下的10%用户,推测出每日成交大概在50万单左右。
GC预估
有了上面的分析之后,我们就可以模拟预估GC了,在上面的50万单,有两种情况:
- 普通业务,这种就比较平缓,没什么并发压力,如果是这个情况的话,基本上都是每个用户浏览各种的商品,然后各自下单,这种情况比较分散,我们假设是在三到四个小时之间,那么每秒也就几十单。
- 秒杀抢购业务,这种类似于双十一抢购,基本上都发生在几分钟内,那这样的话,一秒预计有2000单左右,我们假设有四台服务器分别处理,那经过负载均衡后,到每台服务器的订单也就一秒500单。
进行GC预估我们只需要考虑高并发的情况就可以了,我们假设每一个订单请求会产生0.2M的对象,那么一秒就会产生100M对象,而这些对象,在订单流程执行完之后就可以被回收了。
我们再假设JVM堆空间3G,那么按照默认的配置,新生代会占用1/3,老年代占用2/3,新生代按照8:1:1分配,old=2G,Eden=800M,S0=100M,S1=100M。
那么按照上面的假设条件,每8s新生代就会执行一次YGC,假设这个GC时间点又产生了100M对象(这种情况还是很常见),这时候这100M对象是不能被回收的,那么在GC后应该放入Surivior区,但是由于动态年龄判断的分配机制(如果Surivior区,同一年龄的对象占该Surivior区内存的一半,或比一半还多,这些对象就之间进入老年代),这100M对象直接进入了老年代,那么我们就可以预计出大概160s(20 * 8s)老年代就要进行垃圾回收,这时候就要触发FullGC,FullGC就不光回收新生代和老年代,还会回收元空间,这个回收的STW就远远比YGC的STW要长的多,这个很影响用户的秒杀抢购体验的(这个时候用户就该吐槽垃圾系统了,甚至用户可能会怀疑你后台操作数据,因为其他的请求处理都比较快,而这部分在GC时候来的请求响应时间响应时间相对就长很多很多,用户又不懂技术,保不齐会以最坏的恶意揣测,所以我们还是要好好进行一下调优滴。)。
GC预估调优
根据上面的分析,我对JVM进行一下参数设置:
-Xms3072M -Xmx3072M -Xmn2048M -XX:SurvivorRatio=7
-Xss256K -XX:MetaspaceSize= 128M -XX:MaxMetaspaceSize= 128M
-XX:MaxTenuringThreshold=2
-XX:ParallelGCThreads=8
-XX:+UseConcMarkSweepGC
现在我来挨个挨个解释:
-
-Xms3072M -Xmx3072M -Xmn2048M -XX:SurvivorRatio=7
这里我没有改变堆空间的整体大小,我调整了新、老生代的内存比例:
新生代2G,老年代1G,新生代按照7:1.5:1.5的比例分配,所以S0,S1都是300M,Eden区1.4G。
这样的话Eden区14s才会被填满,然后发生垃圾回收,而假设YGC的时候,订单业务又创建了100M对象,这100M对象只会在Surivior区,不会进入老年代,这里我们假设进入了S0。那么在下一个14s发生YGC的时候,Eden区对象被清理,S0里的100M对象也会被清理(因为已经是垃圾了),就算再产生100M对象,也是一样的进入Surivior区,不会进入老年代。 -
-Xss256K -XX:MetaspaceSize= 128M -XX:MaxMetaspaceSize= 128M
这里我限制了虚拟机栈,栈帧大小为256K,限定了元空间大小128M(我这里假设元空间在一段时间后大小固定在了128M,所以我这里直接限定大小128M)。 -
-XX:MaxTenuringThreshold=2
这里我将分代年龄限制在了2,由于我们现在大部分项目都是用的Spring,这样设置我们就可以让Spring的bean尽早的进入老年代。 -
-XX:ParallelGCThreads=8
限定了进行GC时的GC线程数为8,这个设置要根据CPU核心数来设置,一般是CPU核心数的倍数。 -
-XX:+UseConcMarkSweepGC
这里我们启用CMS垃圾回收器来降低响应时间。
问题排查
我这里模拟一些JVM问题,然后演示如何排查问题
CPU 占用过高排查
这里我模拟一个这样的场景:银行的用户征信计算,由于征信计算需要统计大量的用户信息,这个时候需要通过线程来进行批量处理,代码如下:
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* @author Abfeathers
* @date 2021/3/23 10:04
* @Description VM参数: -XX:+PrintGC -Xms200M -Xmx200M
* GC调优---生产服务器推荐开启(默认是关闭的)
* -XX:+HeapDumpOnOutOfMemoryError
*
*/
public class FullGCProblem {
//线程池
private static ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(50,
new ThreadPoolExecutor.DiscardOldestPolicy());
public static void main(String[] args) throws Exception {
//50个线程
executor.setMaximumPoolSize(50);
while (true){
calc();
Thread.sleep(100);
}
}
//多线程执行任务计算
private static void calc(){
List<UserInfo> taskList = getAllCardInfo();
taskList.forEach(userInfo -> {
executor.scheduleWithFixedDelay(() -> {
userInfo.user();
}, 2, 3, TimeUnit.SECONDS);
});
}
//模拟从数据库读取数据,返回
private static List<UserInfo> getAllCardInfo(){
List<UserInfo> taskList = new ArrayList<>();
for (int i = 0; i < 100; i++) {
UserInfo userInfo = new UserInfo();
taskList.add(userInfo);
}
return taskList;
}
private static class UserInfo {
String name = "abfeathers";
int age = 25;
BigDecimal money = new BigDecimal(999999.99);
public void user() {
//
}
}
}
-
为了方便快速出结果,我这里对堆大小进行限制
-XX:+PrintGC -Xms200M -Xmx200M
,并打印GC日志,现在我们将程序运行起来。 -
使用top -p 监控进程信息
我这里由于是mac使用的是 top -pid
我们持续监控一段时间后,发现CPU占用过高
-
在top展示的界面直接输入H,找到cpu 特别高的线程编号(由于mac不支持,所以我从活动监测器中进行了查询发现,CPU占用最高的是GC),我这里假设是6919。
-
执行 jstack 对当前的进程做 dump,输出所有的线程信息,将6919换算成16进制1b07,在线程信息中找这个id。发现是GC进程。
-
发现是GC的问题,那我们来看看GC信息,
jstat -gc <pid> 2000 20
,发现疯狂地在进行FullGC。Old区被塞得满满当当。
-
我们看看为什么会内存占用过高。发现有十几万的BigInteger、BigDecimal等对象没被回收。
-
根据对象定位代码问题。
这其实就是内存泄露,内存泄漏最终可能会导致内存溢出,所以当JVM进程CPU占用过高时,我们就要按照上面的方式去排查了。
常见问题分析
超大对象
代码中如果创建了很多超大对象,而且一直被引用,这些超大对象会直接进入老年代,导致内存一直被占用,容易频繁GC甚至发生OOM。
超越预期的访问量
这一般是上游的请求流量飙升,比如说一些秒杀抢购活动,这个可以结合业务流量指标排查是否达到峰值。这种时候由于高峰期访问,堆内存设置不足,会导致频繁GC甚至OOM。
过多的使用Finalizer
这个东西,我也在前面的博文中介绍过,一旦你重写了finalize方法,进行对象抢救。Finalizer这个线程会不停的循环等待java.lang.ref.Finalizer.ReferenceQueue中的新增对象。一旦Finalizer线程发现队列中出现了新的对象,它会弹出该对象,调用它的finalize()方法,将该引用从Finalizer类中移除,因此下次GC再执行的时候,这个Finalizer实例以及它引用的那个对象就可以回垃圾回收掉了。但是吧,它的优先级舒适太低,能获得CPU的时间太少,完全赶不上主线程,最后程序会耗尽资源OOM。
内存泄漏
大量无用对象的引用没有被释放,JVM没办法回收这些对象。
长生命周期的对象持有短生命周期对象的引用
举个例子:我们有一个静态变量ArrayList,它里面存放了大量的对象,但是这些对象使用完之后没有被即使清理,而是要等到程序结束才自动释放,这其实就是我们上面的内存泄漏 。
连接未关闭
数据库连接、网络连接、IO连接等,这些连接只有在关闭之后,垃圾回收器才能回收相应的对象。
变量作用域不合理
变量定义的范围大于它的使用范围。
内部类持有外部类
非静态内部类创建后会隐式的持有外部内的引用,而且这种引用还是强引用,如果内部类的生命周期长于外部类的话,就容易导致内存泄漏。(这种情况你可以在内部类的内部显示的持有外部类的一个软引用或者弱引用,又或者是通过构造方法的方式传递进来。在内部类使用外部累的时候,判断一下外部类是否被回收就行了)。
Hash值改变
在集合中,如果修改了对象中参与hash计算的字段,会导致无法从集合中单独删除该对象,从而造成内存泄漏。
代码问题
这个没什么好说的,代码真的太low的话,就像我上面那样,就有可能会导致内存泄漏。
内存泄漏和内存溢出区别
内存溢出:实实在在的内存空间不足导致。
内存泄漏:该释放的对象没有释放,常见于使用容器保存元素的情况下。
如何避免:
内存溢出:检查代码以及设置足够的空间 。
内存泄漏:一定是代码有问题 往往很多情况下,内存溢出往往是内存泄漏造成的。
优化的顺序
- 程序代码优化,这个是最高效,见效最好,最省成本的方式了。
- 扩容,如果有钱的话砸钱扩内存,反正现在硬件成本也已经下降了,扩容内存也不是那么花钱了。
- 参数调优,这个就需要我们在吞吐量、延时、内存占用这三者之间根据业务需求寻找平衡点了。
上一篇:JVM调优之内存优化与GC优化
下一篇:玩转MAT分析内存泄漏