Java JVM对象实例个数和空间占用大小

文摘   职场   2024-07-12 09:30   江苏  
  1. 背景

    日常开发,JDK提供的工具类,想必各位同学已经很熟练了。本篇介绍一个相对比较冷门的工具类——toos.jar。

    写这篇前铺垫了如何快速分析Java OOM文件SpringBoot 集成Prometheus》两篇,客不妨去看看先按计划轮到整理这块逻辑了,目的就是用代码替代:jmap -histo:live pid 把采集到的数据写到Promethues,再通过Grafana看板展示,节约troubleshooting时间,提高工作效率。


  2. 起步

    要把数据集成到Prometheus,分两步:

    a. 拿到类似上述jmap 指令执行返回的数据

    b. 自定义Prometheus指标,把数据放到对应的指标下


    对于a点我们可以利用JDK中提供了HotSpotVirtualMachine类来获取JVM中的信息(据悉只针对sun公司提供的可以,IBM的貌似不行),上述jmap 指令执行返回大致如下:


    这里先来个类似hello world的代码,看看如何拿到上面的数据:

    public static void main(String[] args) throws IOException, AttachNotSupportedException {        RuntimeMXBean bean = ManagementFactory.getRuntimeMXBean();        String name = bean.getName();        int index = name.indexOf('@');        String pid = name.substring(0, index);        // TODO !important 这里我用的平台是windows        HotSpotVirtualMachine machine = (HotSpotVirtualMachine) new sun.tools.attach.WindowsAttachProvider().attachVirtualMachine(pid);        InputStream is = machine.heapHisto("-all");
    ByteArrayOutputStream baos = new ByteArrayOutputStream(); int read; byte[] buff = new byte[1024]; while((read = is.read(buff)) > 0){ baos.write(buff, 0, read); } is.close(); machine.detach(); System.out.println(baos); }


    这里有个问题需要注意:项目是maven方式管理jar包,而JDK提供的tools.jar在安装目录的lib下:

    上面这个问题在实际处理过程中踩了不少坑,针对maven依赖不是外部的,情况可大致有以下几种办法:

    直接固定法:缺点很明显,不同的同学、不同的环境这个路径完全有可能不一样。这个只适合玩玩而已

     <dependency>    <groupId>com.sun</groupId>    <artifactId>tools</artifactId>    <version>1.0</version>    <optional>false</optional>    <scope>system</scope>     <!-- 直接固定法-->    <systemPath>D:/ProgramFiles/DevOps/jdk/jdk1.8.0_171/lib/tools.jar</systemPath></dependency>

    相对固定法:用内置的环境变量来替代固定盘符,如下这样只前进了一小步:

    <dependency>    <groupId>com.sun</groupId>    <artifactId>tools</artifactId>    <version>1.0</version>    <optional>false</optional>    <scope>system</scope>    <systemPath>${env.JAVA_HOME}../lib/tools.jar</systemPath></dependency>

    就在我以为这个可以的时候去Jenkins deploy时发现问题依然存在


    相对路径法:在项目的Resource目录下(创建目录)放置tools.jar,结合相对固定法,应该能解决掉问题


    <dependency>    <groupId>com.sun</groupId>    <artifactId>tools</artifactId>    <version>1.0</version>    <optional>false</optional>    <scope>system</scope>    <systemPath>${project.basedir}/src/main/resources/tools.jar</systemPath></dependency>
    看似上面的问题解决了,但只解决了同学们安装目录不统一的问题,但引入了新的问题:(貌似)当pom.xml文件存在systemPath标签的时候,其他maven坐标会失效(无论把这个移到最前还是最后),导致项目编译报错。
    “偷梁换柱”法:
    绞尽脑汁、想去想来还是亮出我的杀手锏:找运维同学把tools.jar上传到maven私服,怎么样?这主意一出来有那么几秒我都佩服自己的脑袋瓜。重新部署后用2牛顿的力点击了鼠标,期待的效果并没有出现,除了这个:

    大意了,我没有闪!这把飞刀很明显是提醒着我还年轻,忽略了Java的跨平台特性,我传给运维的jar文件是我本机的,本机的意思就是windows平台,所以当代码发上去的时候结果就早已注定
    值得肯定的一点是,解决jar的问题这个办法是有效的。至于跨平台的问题,我还有办法:让运维把安装在liunx平台下JDK对应目录中的tools.jar上传到私服,问题得以解决
    <dependency>    <groupId>com.sun</groupId>    <artifactId>tools</artifactId>    <!--1.0 for windows-->    <!--<version>1.0</version>-->    <!--1.0.1 for linux-->    <version>1.0.1</version>    <optional>false</optional></dependency>
    为避免有同学模仿我,还有一个小坑要给同学们说下,Java代码中要做相应的调整:
    //todo 这里要随着平台改变//tools.jar maven 1.0HotSpotVirtualMachine machine = (HotSpotVirtualMachine) new sun.tools.attach.WindowsAttachProvider().attachVirtualMachine(pid);//tools.jar maven 1.0.1HotSpotVirtualMachine machine = (HotSpotVirtualMachine) new sun.tools.attach.LinuxAttachProvider().attachVirtualMachine(pid);


  3. 整理

    上面只是一个伪代码,主要解决tools.jar的依赖和跨平台问题。但一个完整的功能还需要继续优化,比如需要考虑各业务怎么接入、怎么灵活控制、采集频率等。


    如何接入:

    不扯多的犊子了,做成基础工具类,业务系统升级maven依赖是最简单的。


    灵活控制:

    代码处理能模仿jmap拿到JVM中的实例信息这个只是基本要求,还要考虑到:

    升级后若该功能影响性能得话能不能实时关闭?

    每次取多少条合适?(在《如何快速分析Java OOM文件中》只取20条能判断问题)

    能不能自定义,只取包含自己公司项目包名关键字的信息?

    能不能过滤掉一些类信息?


    采集频率:

    采集太过频繁影响原有业务,频率太低出现问题时参考的意义不大。

    结合上述问题,我们需要先增加一些配置项,用来控制业务代码:

    //配置信息,也可以单独写到某个类中
    //控制每次提取多少行 默认20(这是由上一次排查问题的经验主义得出)@Value("${common.tools.show.jvm_line:20}")private Integer JVM_LINE;//控制是否只显示包含公司项目关键字的类名 如你的包名都是com.xxxx 那就当该值为true时,判断类名是否包含xxxx@Value("${common.tools.show.JVM_KEY_WORD:false}")private Boolean JVM_KEY_WORD;//总开关,控制是否打开采集JVM信息的开关,为减少大家接入的操作,默认为true@Value("${common.tools.show.jvm_info:true}")private Boolean JVM_INFO;//控制是否要过滤掉某个不想看的类,如[I,[B@Value("${common.tools.show.jvm_skip:}")private String JVM_SKIP;
    在相关的Class上增加@RefreshScope注解,可以让这些配置项的变更更灵活,应用收到对应的配置变更事件通知后能热加载,无需重启即可按新的配置执行相关逻辑。
    //1.用@Scheduled控制频率,刚启动时30秒后开始采集,后续每15秒采集一次(因为promethes 15采集一次)@Scheduled(fixedDelay = 15000,initialDelay = 30000)//cron="*/5 * * * * ?"public void showJvmBytes() throws IOException, AttachNotSupportedException{    if(!JVM_INFO){        log.debug("showJvmBytes show.jvm_info is:false,stop.");        return;    }    //2.节约时间如果是windows就可以不用往下了,(windows)本地调试时放开    String os = System.getProperty("os.name");        os = os.toLowerCase(Locale.ROOT);    if (os.indexOf("windows")>=0) {        log.debug("showJvmBytes the system is not allow,stop.");        return;    }    RuntimeMXBean bean = ManagementFactory.getRuntimeMXBean();    String name = bean.getName();    int index = name.indexOf('@');    String pid = name.substring(0, index);    HotSpotVirtualMachine machine = (HotSpotVirtualMachine) new sun.tools.attach.LinuxAttachProvider().attachVirtualMachine(pid);    try (InputStream inputStream = machine.heapHisto("-all");         ByteArrayOutputStream outputStream = new ByteArrayOutputStream();){        int read;        byte[] buff = new byte[1<<12];        while((read = inputStream.read(buff)) > 0){            outputStream.write(buff, 0, read);        }        machine.detach();// DEMO//            num     #instances         #bytes  class name//            ----------------------------------------------//            1:         21309       11975720  [B//            2:         56059        7190248  [C//            3:          3297        1638632  [I        //3.把拿到的数据按换行符拆开        String[] arr = outputStream.toString().split("\\n");        int row = 1;        for (String line : arr) {          //4.前几行不要或num大于配置的不要            if(StringUtils.isBlank(line) || line.indexOf("num")>=0 ||  line.indexOf("---")>=0){                log.debug("showJvmBytes line num is:{},value:{} invalid ,skip.",row,line);                continue;            }            //5.num大于配置的不要            if(row > JVM_LINE){                log.debug("showJvmBytes line num is:{},config:{},skip...",row,JVM_LINE);                break;            }
    //6.当需要过滤只展示包含关键字的类信息时,不满足条的不要 boolean FILTER_XXXX = line.indexOf("xxxx")>=0; if(JVM_KEY_WORD && !FILTER_XXXX){ log.debug("showJvmBytes num:{} line:{} not contain key word:{},skip...",row,line,"xpilot"); continue; } log.debug("showJvmBytes line:{}",line); //7: 21309 11975720 [B //按空格拆,拿到instance和bytes String[] line_arr = line.split(("\\s+")); //过滤掉不希望展示的类 String className = line_arr[line_arr.length-1]; if(!StringUtils.isBlank(JVM_SKIP) && JVM_SKIP.indexOf(className)>=0){ log.debug("showJvmBytes config JVM_SKIP:{} contains:{},skip...",JVM_SKIP,className); continue; } //数据写入Prometheus microMeterTool.jvmUsageByteGauge(className,Long.parseLong(line_arr[line_arr.length-2])); microMeterTool.jvmUsageInstanceGauge(className,Long.parseLong(line_arr[line_arr.length-3])); row++; } //清除某个指标的历史数据 microMeterTool.clearHistoryKey(keys,"app_jvm_instances"); microMeterTool.clearHistoryKey(keys,"app_jvm_bytes"); }catch (Exception e){ log.error("showJvmBytes:",e); }}

    代码中machine.heapHisto("-all")是用来获取堆信息的工具。为尽量避免影响业务、浪费资源,可以进一步优化一下,参数改为传:live(eg:machine.heapHisto("-live")),用来指定只包括存活的对象。但有一个点需要提前告诉大家,使用live参数为导致full gc,所以酌情使用:


    还有一个需要注意的就是对平台的判断:

    String os = System.getProperty("os.name");    os = os.toLowerCase(Locale.ROOT);if (os.indexOf("windows")>=0) {    log.debug("showJvmBytes the system is not allow,stop.");    return;}

    有的同学还是有钱的可能是习惯问题,自带电脑上班。收到线报,当用mac时我上面判断环境的地方就绕过了,运行后续代码就会报错:

    解决方案方案其实可以类似上面那样根据平台放三个对应的tools.jar到maven私服。这里暂时逆向思维处理,不用根据平台加载jar,代码调整为os.name包含:linux时放过就好,经测试,不影响原有逻辑。

    String os = System.getProperty("os.name");    os = os.toLowerCase(Locale.ROOT);log.debug("showJvmBytes show.jvm_info os_name is:{}",os);if (os.indexOf("linux") < 0) {    log.debug("showJvmBytes the system is not allow,stop.");    return;}

  4. 效果展示

    以上代码运行会在Prometheus产生对应的指标,运维同学配置后可通过Grafana展示:

    这些数据只是15秒内JVM的情况,垃圾回收可能会影响这些数据,所以注入Prometheus后需要同步删除历史数据,如何删除已在《SpringBoot集成Prometheus》这篇中有解释。


  5. 扩展

    @Scheduled注解有2种配置执行频率的方式:

    @Scheduled(fixedDelay = 15000,initialDelay = 30000)@Scheduled(cron = "${common.tools.jvm.monitor.cron:0/15 * * * * ?}")

    一开始,我的逻辑写好就能跑,因为其他同学也用这个注解跑了一个定时任务,他的类上加了@EnableScheduling。后来因为逻辑优化:他的定时任务不需要主动触发,所以对方加了通过配置文件的key来管控。奇怪的是我的Scheduled也不跑了,查询资料后得知:默认内部一个单线程Scheduer来处理@Scheduled注解实现的定时任务:org.springframework.scheduling.annotation.ScheduledAnnotationBeanPostProcessor#setScheduler

    译:如果两者都无法解析,会注册器内部创建一个本地单线程用来默认调度程序。


    解决方案:

    既然我的这个没有线程处理,那我们就主动造一个:

    @Bean("showJvmBytesExecutor")public ThreadPoolExecutor showJvmBytesExecutor() {    return new ThreadPoolExecutor(2, 2, 0, TimeUnit.SECONDS,            new ArrayBlockingQueue<>(100), new BasicThreadFactory("JVM_BYTE_MONITOR_EXECUTOR"),            new ThreadPoolExecutor.CallerRunsPolicy());}

    然后在对应的@Scheduled上指定用声明的这个线程异步来跑就可以了

    @Async("showJvmBytesExecutor")@Scheduled(fixedDelay = 15000,initialDelay = 30000)public void showJvmBytes() throws IOException, AttachNotSupportedException{   //ignore code}

    当然,这种问题也有其他方式可以解决:https://www.jb51.net/article/264297.htm 

  6. 总结

    本篇主要讲toos.jar的使用,功能已实现,大体代码如下。对于上述效果图中出现的[I、[J、[B...等类的解释,一并给大家整理如下:

    [ 代表的是数组,[[ 则代表二维数组,字母是Java中的数据类型,对开发同学来说,要记住的话只留意以下3个特殊的就行了(oracle官网有解释):



    至此,用JDK下的tools.jar完成了在《如何快速分析Java OOM文件》中提到的:jmap -heap pid 命令类似的信息输出 。

    关于指令Java字节码可以看这篇,比较详细,好奇的可以一步步跟着做做看。


    P.S:关于Promethes如何搭建、如何监控服务、如何自定义监控指标请查看《SpringBoot集成Prometheus》一文最后的自定义指标部分,祝你好运气


    划水专用:

    https://codeleading.com/article/41242615114/#google_vignette

    https://qa.1r1g.com/sf/ask/3302421/

    https://blog.csdn.net/HO1_K/article/details/127221511

    https://docs.oracle.com/javase/8/docs/api/java/lang/Class.html#getName--


    有问题欢迎指出,听说吐槽与关注更配哦

两岸猿声啼不住,轻舟已过万重山。

——李白《早发白帝城》

晚霞程序员
一位需要不断学习的30+程序员……