深入理解JVM - 实战JVM工具(上)

lazytimes

深入理解JVM - 实战JVM工具(上)

前言

​ 这篇文章主要介绍一下常用的JVM工具,当然介绍这些工具是没有意义的,因为不去使用吃个饭基本就会忘光,所以这篇文章主要为使用工具实操一下大致如何监控和调优代码。

前文回顾:

​ 上一节介绍了如何解读日志,可以网上搜一些调优的案例代码亲自试验一下,可以发现不同的机器哪怕是一样的JDK版本也会出现不同的效果,比如IDEA和Eclipse中执行的结果可能有出入,同时JVM本身产生的对象也会影响日志的结果。

​ 解读日志是掌握JVM的基本功,在掌握基本的解读能力之后,这篇文章来讲述JVM的工具实战技巧。

概述

​ 这篇文章主要讲述如何根据最原始的命令jstat对于JVM进行分析和调优,当然并不是所有的案例都可以通过调优实现的,所以这里也会有个别通过修复代码BUG进行调优的案例。

​ 之所以拆成上和下是考虑到文章篇幅的问题,个人也不喜欢动不动就万字的文章,但是要分析和说明案例的场景确实要不少的文字描述,所以这些文章会看的很累!请在精力较好的时候阅读。

常用工具介绍:

​ 工具简单提一下,其实写出来没啥意义,没有几个人会去专门背命令的,更多的是在实际的案例上如何使用才是重点。

jstat命令:

命令的格式:

​ 这里介绍一些比较常见的用法:

  • jstat -gc PID查看当前JVM进程的使用情况
  • jstat -gccapacity PID 堆内存的分析
  • jstat -gcnew PID:年轻代的GC分析,这里的TT和MTT可以看到对象在年轻代存活的年龄和存活的最大年龄
  • jstat -gcnewcapacitry PID 年轻代分析
  • jstat -gcold PID 老年代GC分析
  • jstat -gcoldcapacitry PID老年代内存分析
  • jstat -gcmetacapacity PID 元数据区的分析

参数介绍

 S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT
10240.0 10240.0  0.0    0.0   81920.0  13107.5   102400.0     0.0     4480.0 776.9  384.0   76.6       0    0.000   0      0.000    0.000

​ 下面是根据上面的参数对应下方的参数解释:

Jmap 命令

​ 关键作用:到底是哪个对象占用的内存是最多的。

使用案例:

  • Jmap -heap PID:打印比如说Eden区域总容量,已经使用容量,剩余容量等等。
  • Jmap -histo PID:了解对象的分布情况,可以打印各种class对象的占用比情况,通常为string占用最多
  • jmap -dump:live,format=b,file=dump,hprof PID:查看当前堆的快照内容

Jhat 命令

使用案例:

  • jhat dump.hprof -port 7000:使用Jhat 在浏览器分析,可以使用浏览器去分析上文出现的堆快照

线上系统常见监控手段:

  1. 在系统的高低峰之前执行jstat/ jmap/ jhat 的工具看看JVM是否正常运行。
  2. Zabbix,openfalcon,ganglia 等测试工具

实战案例场景

​ 注意,代码运行在Jdk8的版本上。

​ 在进行具体的调优之前,有必要说明一下调优的场景和模拟的业务场景,所以在介绍案例实际操作之前,需要先说明一下基本的业务场景:

高并发的APP系统:

​ 按照之前的调优案例,首先不介绍复杂的业务,而是从一个简单的社交APP入手,我们都知道一些社交APP为了吸引流量会拉上一些明星进行直播,这一类的APP的高并发场景在哪呢?

​ 我们假设如果你喜欢的明星突然说在某个社交APP直播了,你首先毫无疑问会下载APP,同时搜索你喜欢的明显进入他的 个人主页,重点来了,我们试想一下个人主页都会有什么,毫无疑问是一些个人动态日志或者一些图片和文字等内容。

​ 接着我们再分析:平时一两个人来来看,特别是流量比较少的时候,我们可以甚至可以直接从数据库加载,但是一旦这个明星被推广并且非常的热门,毫无疑问会吸引一大批人访问,比如十万的QPS进入,这时候靠后端肯定是顶不住的,我们毫无疑问需要引入Redis,并且给图片等非常消耗资源的内容存到图片服务器分压,这时候可以画一个简单的结构图如下:

​ 图中的分析可以得知这里会出现大量的 小对象,我们假设每一个请求会带来5M的文字对象或者图片对象,那么一个2G的新生代毫无疑问可能会瞬间被挤满(哪怕是Redis可以分担),这里就会出现一个问题,就是新生代在回收的时候发现还有很多对象在使用,导致新生代快速进入老年代,所以这个案例毫无疑问就是要保证新生代能有足够的对象存放。

​ 这就是最为简单的一个案例模拟,为什么要讲这个那里呢?这里要提到网上经常会出现的两个参数

​ 这两个参数的意思是开启CMS老年代回收之后的内存碎片整理,这里我们再次回忆一下这个参数的实际效果:

​ 当CMS在默认最后一步并发清理的时候(CMS执行四个步骤:初始标记,并发标记,重新标记,并发整理),如果此时除开触发92%老年代回收条件的剩余8%被大量进入老年代的新生代对象占满而无法分配,就会触发FULL GC,这个过程称之为:conccurent mode fail(其实就是并发执行失败)这时就会喊出serrial这个老人家过来大喊一声"stop world"并且进行单线程的回收。

​ 重点来了,在回收完成之后,serrial退居幕后,此时CMS会根据上面的参数判定,在第五次FULL GC的时候对于内存碎片进行整理,至于内存碎片是什么,这里可以自行百度标记-清理算法的缺点就可以明白,也可以看个人之前的文章进行了解:

深入理解JVM - 垃圾回收算法

​ 这个5其实是网上有人推荐的参数,因为多数情况下不需要每次FULL GC都进行内存碎片整理,但是实际上上面这个模拟案例显然不能设置这么多次,因为瞬时流量创建的对象过多,我们可以牺牲一些老年代的整理时间,来实现老年代的内存碎片规整化,尽量的减少了FULL GC的次数,这样用户进入主页的时候就不会一直转圈圈进不去了。

电商系统:

​ 这个案例在专栏之前的文章使用过,这里原样拷贝过来了:

背景:

​ 假设一个电商网站每天的访问量是20次/人,如果要上亿次的请求需要每天500万次的请求,同时如果这500万人按照10%的下单的标准,则是每天50万人会进行下单的操作,而下单操作按照2/8原则在4小时之内付款完成,那么此时的占用大概是50万/4小时 == 500000 / 14400,大概每秒也就 34个订单左右,这种情况下发现系统的影响并不会很大,老年代发生回收大概为几个小时一次,完全可以接受。

高并发的场景

​ 但是如果在秒杀的场景,情况又不一样了,如果在一秒内来1000笔订单,该如何处理?我们假设如果是3台机器,则每台需要处理至少300条请求。

计算JVM消耗

​ 根据上文模拟场景,假设每秒300个请求按照每个对象1KB来看,每一台机器要处理大概300KB的内存,把一个订单系统的处理对象放大10倍,则是3000KB,如果在算上其他的操作比如订单处理,则需要30000KB = 30MB的占用。

​ 如果虚拟机栈每个占用1M,则几百个线程需要几百M的空间。如果是4核心8G的机器,则分4G给JVM,4G中分1G给虚拟机栈500M多M,方法区:256M,堆外内存给256M。同时开启内存担保机制(jdk6之后不需要制定参数)然后新生代和老年代各分配1.5G

​ 按照上面的换算,我们发现如果每秒都来30M对象,那么1200M左右的EDEN区域(8:1:1的比例大概是1200给EDEN),大概40秒就能把新生代塞满,假设每次请求新生代会留下200M左右的存活对象会进入SURVIOR区域,但是我们发现配比的survior区域只有150M是无法进入Survior区域的,根据内存分配担保的机制,这些对象会分配到老年代,这也意味着一些即将成为垃圾的对象提前进入了老年代导致无法被正常回收!

​ 按照这样的分配效率不到一分钟新生代就会塞满。200M对象进入年代最多8、9次的minor gc就会导致Full gc,也就是说 8、9分钟就会触发老年代回收,这个触发的概率就十分高了,这会严重导致系统卡顿并且出现用户线程的停顿现象。

​ 但是如果Survior空间足够,那么此时回收进入到Survior空间之后,在下一次minor gc基本也为垃圾对象被回收了。

问题结构图:

​ 根据上面的介绍,可以得出下面的问题结构图:

如何优化

​ 这里直接说一下优化的思路:

  1. 首先,我们需要扩大Survior的空间或者扩大新生代的大小,比如把新生代扩大到2G的空间。
  2. 也可以通过加机器的方式扩大内存的空间
  3. 注意这里主要为计算的业务,不需要保证低延迟,所以使用普通的分代收集器CMS+ParNew即可。

代码模拟:

​ 如果用代码模拟,我们并不需要用那么大的内存空间模拟,我们用下面的代码简单的还原上面的情况:(当然参数并不是百分之百符合,但是可以模拟出业务场景存在的问题以及解决手段)

/**
 * 运行参数:-XX:NewSize=104857600 -XX:MaxNewSize=104857600 -XX:InitialHeapSize=209715200 -XX:MaxHeapSize=209715200 -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:PretenureSizeThreshold=20971520 -XX:+UseParNewGC-XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:gc-%t.log
 * @author zxd
 * @version v1.0.0
 * @Package : com.zxd.interview.minorGc
 * @Description : 测试案例:测试垃圾回收动销率
 * @Create on : 2021/7/25 11:47
 **/
public class Demo1 {

    private static final int _1M = 1024 *  1024;

    public static void main(String[] args) throws Exception {
        Thread.sleep(30000);
        while(true){
            loadData();
        }
    }

    private static void loadData() throws Exception{
        byte[] data = null;
        // 循环分配40M的对象
        for (int i = 0; i < 4; i++) {
            data = new byte[10 * _1M];
        }
        data = null;
        // 存活20M的对象
        byte[] data1 = new byte[10 * _1M];
        byte[] data2 = new byte[10 * _1M];
        // 临时生成20M保证YGC
        byte[] data3 = new byte[10 * _1M];
        data3 = new byte[10 * _1M];

        Thread.sleep(1000);
    }
}

​ 从代码中可以看到我们使用无限循环不断的往新生代分配对象,同时触发新生代的回收,可以看到此时Survior区域也是放不下老年代的,所以新生代的存活对象会直接进入到老年代。

-XX:NewSize=104857600 
-XX:MaxNewSize=104857600
-XX:InitialHeapSize=209715200
-XX:MaxHeapSize=209715200
-XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=15
-XX:PretenureSizeThreshold=20971520
-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-Xloggc:gc-%t.log

​ 这里的配置对象超过20M之后直接进入老年代,而根据模拟代码的情况,通常是不会直接进入老年代的。

调优前的运行效果:

​ 在启动代码的时候,我们需要立刻使用如下命令:

​ 第一步我们需要输入jps,命令查看当前的JVM线程:

34096 Jps
38820 Demo1

​ 接着我们需要立刻赶快执行上文提到的Jstat命令:

jstat -gc 38820 1000 10

​ 然后我们可以看到如下的结果:

下面来分析上面的结果:

​ 首先我们看一下S1的区域,在1秒过后他增长了1634K的大小,大概也就1.5M左右,这里可以认为是未知对象,同时可以看到在下一秒他已经被垃圾回收掉了。这里明显可以看到执行了一次 Young Gc,并且执行的时间为0.016s,当然这个回收是很难感知的。从新生代的回收也可以看出,我们存活的对象是直接进入老年代的,没有进入survior区域,导致survior区域存放的只是一些jvm产生的小对象。导致每次Young GC之后存活的20M对象都进入了老年代。

​ 接着我们来看下中间的部分:EU部分突然变成了0.0。毫无疑问是垃圾对象全部被回收了,也可以看新生代的回收时间居然一秒比一秒更长(最后一次参数,注意老年代的回收时间在倒数第二个参数)

为什么老年代的回收反而要比新生代的时间更短?

​ 其实从业务场景也可以推测出来,老年代的回收是根据新生代的回收出现的,但是新生代由于存在太多的对象进入老年代,根据对象分配的担保原则需要不断计算历代进入老年代的对象平均大小,同时这个阶段相当于新生代需要等待老年代进行判断回收完成才能操作,所以导致新生代的回收速度慢于老年代的速度

调优后的运行效果:

​ 首先我们看一下调优之后的运行参数:

-XX:NewSize=209715200 
-XX:MaxNewSize=209715200 
-XX:InitialHeapSize=314572800 
-XX:MaxHeapSize=314572800
-XX:SurvivorRatio=2  
-XX:MaxTenuringThreshold=15
-XX:PretenureSizeThreshold=20971520
-XX:+UseParNewGC 
-XX:+UseConcMarkSweepGC
-XX:+PrintGCDetails 
-XX:+PrintGCTimeStamps 
-Xloggc:gc-%t.log

​ 之前我们说过,我们可以扩大新生代的空间,或者调整survior空间的大小,所以这里主要把堆内存扩大到了300M,同时吧eden区域的比例改为2:1:1 的比例,其他参数基本没有过多的变化。

​ 这里还有其他的参数可以设置,下面介绍可以优化的配置:

​ 一个参数是“-XX:+CMSParallelInitialMarkEnabled”,这个参数会在CMS垃圾回收器的“初始标记”阶段开启多线程并发执行。

​ 另外一个参数是“-XX:+CMSScavengeBeforeRemark”,这个参数会在CMS的重新标记阶段之前,先尽量执行一次Young GC。

​ 接下来同样运行下面的命令:jstat -gc 18032 1000 100查看当前的堆使用情况:

这里再次强调参数标头对应的含义:

​ 下面是运行的结果

调优之后的GC情况

​ 从上面的内容可以看到,FULL GC的时间居然已经变成了0,所有的时间都在于Young GC上面。其他也可以看到survior区域每次都可以存放下所有的eden区域存活对象,这也和之前案例讲述的调优结果是一致的。

总结

​ 上面两个案例应该足以说明新生代优化的套路了,基本就是用jstat看看新生代老年代的变化情况,当然实际情况肯定没有这么简单,下一小节

写在最后

​ 文字功底实在一般,如果有任何不懂的地方感谢反馈,个人会在闲暇之余不断重写让尽可能多的人看懂。

思考题:

​ 做JVM调优的时候,可以根据下面的问题来进行思考:

  • 你们公司有没有类似这里讲的JVM参数模板?
  • 假如你是公司的架构师,结合你们公司的大部分业务系统的实际情况,会如何定制一套JVM参数模板?
  • 是否你们公司有各种不同配置的机器?
  • 针对不同配置的机器如何定制JVM参数模板?
  • 你们公司有没有那种特例的系统,比如并发量特别高或者数据量非常大?
  • 对特例系统该如何进行优化?
阅读 191

赐他一块白石,石上写着新名

52 声望
7 粉丝
0 条评论
你知道吗?

赐他一块白石,石上写着新名

52 声望
7 粉丝
宣传栏