如何开发一款java应用运行时的监控程序?

前言

每个程序员都或多或少遇到过相当多的疑难杂症问题排查的时刻。我自己也是工作中遇到许多稀奇古怪的问题。最开始我们排查问题使用的是jprofiler。特别是使用jprofiler来排查调用链路的耗时问题。如下图所示:

333

但是jprofiler只能用于排查一些本地的问题。对于一些生产环境的由于网络隔离在加上权限受限, jprofiler就不是那么好使了。这时候萌生了自己做个小工具的想法。同时参考了一些工具和apm的实现, 简单实现了所需的功能。

我们现在思考下, 假设要开发一个java程序的监控工具,比如包含以下功能, 都需要怎么实现?

1. 实时或周期性的获取java进程运行数据, 包括但不限于内存,线程,操作系统,GC等。
2. 如何在运行时知道一个class是被哪个classloader加载的?
3. 如何动态的知道一个方法的执行时间?(对于基础的排查性能问题很有用)
4. 如何动态知道一个方法被调用时候的完整调用栈?
5. 如何动态的知道一次调用下一个方法的入参,返回值?
...

基础部分

在这个【基础部分】里, 我们可以很轻松的解决上边的问题1。这要感谢JDK5后提供的两大神器:Instrument和management。前置提供了应用程序之外的代理(agent)程序来监测和协助运行在JVM上的应用程序, 后者可以实时获取应用程序的实时运行数据。

Agent

我们遇到的第一个问题, 是如何将自己的监控程序和目标进程关联起来。
比如我1个monitor.jar,里边包含了我们的监控程序, 如何和生产环境正在运行的tomcat进程进行关联?

答案是JDK提供的agent机制。简单来说只需要做以下事情:

1. 监控代码的jar中包含Agent-Class属性。该值的名字是自定义的agent类。
2. 该类必须实现如下的方法:
   public static void agentmain(String agentArgs, Instrumentation inst);
3. 使用VirtualMachine vm = VirtualMachine.attach(targetPid)关联到目标进程

其中Instrumentation非常重要, 后续还会说明。关于Agent-Class属性可以通过maven-assembly-plugin插件来设置:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-assembly-plugin</artifactId>
    <executions>
        <execution>
            <goals>
                <goal>single</goal>
            </goals>
            <phase>package</phase>
            <configuration>
                <descriptorRefs>
                    <descriptorRef>jar-with-dependencies</descriptorRef>
                </descriptorRefs>
                <archive>
                    <manifestEntries>
                        <Premain-Class>xxx</Premain-Class>
                        <Agent-Class>xxx</Agent-Class>
                        <Can-Redefine-Classes>true</Can-Redefine-Classes>
                        <Can-Retransform-Classes>true</Can-Retransform-Classes>
                    </manifestEntries>
                </archive>
            </configuration>
        </execution>
    </executions>
</plugin>

更多内容可参考下文

https://docs.oracle.com/javase/8/docs/api/java/lang/instrument/package-summary.html

agent

Instrumentation

核心内容都在java.lang.instrumentation包下, 主要的类有:

public interface Instrumentation {
    ....
    java.lang.instrument.Instrumentation
    java.lang.instrument.ClassFileTransformer  
    ....
}

ClassFileTransformer提供了类的转换功能, 可以对字节码进行修改。 而Instrumentation除了可以管理ClassFileTransformer之外, 还有一些其他功能。比如:

getAllLoadedClasses()
该方法可以获取当前虚拟机加载的所有Class对象。记住是所有。那在这里问题2就很好解决了。
我只需要遍历这个结果集和给定的名字是否匹配即可。再通过getClassLoader()可以获取这个类到底被谁加载了。
同时, getProtectionDomain().getCodeSource().getLocation().getFile() 还可以获取到当前类
的具体路径, 对于排查问题会更有帮助。 

线程使用情况

java.lang.management.ThreadMXBean

以下方法比较有用:
getThreadCount()                //线程数
getDaemonThreadCount()          //daemon线程数
getPeakThreadCount()            //峰值
getTotalStartedThreadCount()    //启动过的线程数
   

操作系统

java.lang.management.OperatingSystemMXBean

以下方法比较有用:
getName()                       // 操作系统名称. 本机为 Mac OS X
getArch()                       // 系统架构. 本机为 x86_64
getAvailableProcessors()        // 处理器个数. 本机为 8
getSystemLoadAverage()          // 过去1分钟的load
getVersion()                    // 操作系统版本
 

内存

java.lang.management.MemoryMXBean
java.lang.management.MemoryManagerMXBean

垃圾回收

java.lang.management.GarbageCollectorMXBean

以下方法比较有用:
getName()                       // 收集器英文名称
getCollectionCount()            // 该收集器收集总次数
getCollectionTime()             // 该收集器收集总时间(ms)

注意:会有多个GarbageCollectorMXBean。
 

编译器

java.lang.management.CompilationMXBean

以下方法比较有用:
getName()                       //返回JIT编译器名称
getTotalCompilationTime()       //返回在编译上花费的累积耗费时间的近似值(以毫秒为单位)

注意:需要调用isCompilationTimeMonitoringSupported方法来确定是否支持编译期的监控。

类加载

java.lang.management.ClassLoadingMXBean

以下方法比较有用:
getLoadedClassCount()           //返回当前加载到 Java 虚拟机中的类的数量。
getTotalLoadedClassCount()      //返回自 Java 虚拟机开始执行到目前已经加载的类的总数。
getUnloadedClassCount()         //返回自 Java 虚拟机开始执行到目前已经卸载的类的总数。
isVerbose()                     //测试是否已为类加载系统启用了 verbose 输出。

运行时数据

java.lang.management.RuntimeMXBean

以下方法比较有用:
getName()                       //返回表示正在运行的 Java 虚拟机的名称
getStartTime()                  //返回 Java 虚拟机的启动时间
getManagementSpecVersion()      //返回正在运行的 Java 虚拟机实现的管理接口的规范版本。
getSpecName()                   //返回 Java 虚拟机规范名称。
getSpecVendor()                 //返回 Java 虚拟机规范供应商。
getSpecVersion()                //返回 Java 虚拟机规范版本。
getVmName()                     //返回 Java 虚拟机实现名称。本机为Java HotSpot(TM) 64-Bit Server VM
getVmVendor()                   //返回 Java 虚拟机实现供应商
getVmVersion()                  //返回 Java 虚拟机实现版本
getInputArguments()             //返回传入的JVM启动参数
getClassPath()                  //返回类路径
getBootClassPath()              //返回bootstrap的path 

系统级别采集

之前整理了很多数据获取的方式,但是都是基于java进程本身的。这里介绍常用的基于linux系统本身的采集方式:

cpu:        /proc/stat
memory:     /proc/meminfo
load:       /proc/loadavg
网卡:       /proc/net/dev
TCP&UDP:    /proc/net/snmp
io:         /proc/diskstats

需要注意以下细节:

1. proc文件系统是提供系统运行状态的利器。很多开源的linux监控系统都是基于proc来进行分析和开发。
2. mac os并不支持proc。
3. 不同的linux发行版在proc的输出展示上有微小的不同。比如redhat和fedora在展示TCP数据时列名稍稍有差异。                  

进阶

class文件格式

对于理解JVM和深入理解Java语言, 学习并了解class文件的格式都是必须要掌握的功课。 原因很简单, JVM不会理解我们写的Java源文件, 我们必须把Java源文件编译成class文件, 才能被JVM识别, 对于JVM而言, class文件相当于一个接口, 理解了这个接口, 能帮助我们更好的理解JVM的行为;另一方面, class文件以另一种方式重新描述了我们在源文件中要表达的意思, 理解class文件如何重新描述我们编写的源文件, 对于深入理解Java语言和语法都是很有帮助的。

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html
 

网络通信

思考2个问题:


1. 本机应用部署了monitor.jar, 我们如果想通过命令行交互, 怎么做?
2. 网络打通的远程服务器部署了monitor.jar, 命令行交互又该怎么做?

很容易的, 我们可以想到socket。摆在我们面前的有如下方案:

netty
mina
nio

这里我建议直接上nio。原因如下:

1. 我们要给monitor.jar减负。如果依赖了外部库, 会加重监控工具的臃肿程度;
2. nio已经足够用了。这个交互并不需要多么高的性能。杀鸡焉用牛刀;
3. mina的社区太不活跃了。虽然是个成熟的软件, 但是万一哪里踩坑都不好处理;    

会话保持

会话保持有2个重要的参数。

  • 会话保持时间, 常量。一般定义为10分钟就够了。
  • 触摸时间, 每次发起请求会更新触摸时间

可以后台起daemon线程, 周期性检查触摸时间和当前时间的差值是否超过了会话保持时间。如果超过需要关闭连接。

通信协议

既然确定了网络通信使用nio, 那我们务必要制定一套简单的通信协议, 能简明的告知服务端和客户端请求响应信息。这里我们拿dubbo来举例:

323

可以看到dubbo有明确的byte位来指明哪些byte存放什么内容。这样做的好处有2, 1是结构清晰, 2是可以构造出尽可能小的报文数据。在高并发的请求下是及其有用的(节省资源), 虽然我们这个project这里并不是很重要, 但是保持一个好习惯总是没错的。

表达式语言

通信的时候需要考虑是否有更灵活的表达方式。如果可以ognl是一个不错的选择。

核心

在很多场景下, 我们核心要处理的并不是直接获取数据, 而是通过改造目标类, 从而达到各种灵活的功能。 所以如何完成目标类的改造是核心问题。

选型

大部分情况下你可以有以下4种选择:

javassist: 对于字节码以及指令良好的封装
bytebuddy: 更优良的设计和封装, 并获得 Duke's Choice Awards 2015。也算是个明星项目了, 目前社区还很活跃, 国人开发的skywalking便是基于它
asm:       成熟度高, 但相对更底层一些。需要开发者对字节码以及指令有一定了解 
自研:      省省力气吧-_-

这里我的选择是直接上asm。理由是完成这个项目必不可少的需要了解字节码和方法指令, 那就干脆深入的去了解。其实ASM本身也提供了很多工具。

比如org.objectweb.asm.util.ASMifier就是一个带有Main方法的类。只需传入指定类的全名, 则会输出该类用asm描述的文本形式。

如何完成目标类的字节码更新

这里还是需要用到instrument工具。见:

void addTransformer(ClassFileTransformer transformer, boolean canRetransform);
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
  1. 首先通过addTransformer添加一个转换器。请务必canRetransform为true才可以完成运行时转换。
  2. 然后调用retransformClasses来执行该转换器。此时你的transformer可以做任何你想做的猥琐事情。

如何完成目标方法的改造

我们再回头看下问题3,4,5.

3. 如何动态的知道一个方法的执行时间?(对于基础的排查性能问题很有用)
4. 如何动态知道一个方法被调用时候的完整调用栈?
5. 如何动态的知道一次调用下一个方法的入参,返回值?

可以看到可以规划为1个问题,那就是如何在目标方法调用前和返回前拦截?
ASM早就考虑到这种需求了, 所以给我们提供了完美的支持:

org.objectweb.asm.commons.AdviceAdapter extends org.objectweb.asm.MethodVisitor
 
    protected void onMethodEnter() { }
 
    protected void onMethodExit(int opcode) { }

此时我们只需在对应方法的实现下完成自己的拦截代码即可。注意:onMethodExit方法是包含了正常返回以及异常返回的(抛出异常)。在这里问题3已经很好解决了。不考虑其他特殊情况下可以ThreadLocal存储一份时间来在onMethodEnter和onMethodExit之间比较即可。

问题4的解决方案也很简单。在前置方法使用 Thread.currentThread().getStackTrace()即可拿到完整的栈列表。只不过要注意考虑去掉一些无意义的调用行, 比如java.lang.Method.invokexxxx这种。

涉及到方法指令集操作的可以通过继承 org.objectweb.asm.commons.GeneratorAdapter 来完成。GeneratorAdapter提供了相当多的工具方法,比如我们常用的:

loadArgArray() //获取参数列表
box()          //装箱
loadThis()     //实例方法加载index=0的slot
...

问题5在这里也很好解决, 通过GeneratorAdapter.loadArgArray()即可获取到。这里涉及到一点操作数栈的知识,下边会说。

如何取消目标方法的改造

当我们执行完成我们的监控后或连接关闭时, 需要取消类和方法的改造。否则改造后的类一直存在, 直到应用重启。
如何取消类的改造也很简单, 参考ClassFileTransform的官方文档:

If the implementing method determines that no transformations are needed,
it should return <code>null</code>.

所以我们只需要在transform列表的末尾添加一个返回为空的transform即可。

类加载隔离

这里的自定义类加载器是必须的。我们很难在一个单独的jar包中完成所有的事情, 部分功能很有可能交给第三方去做, 如下:

网络通信: Netty, Mina
字节码指令: asm
工具类: common-lang, guava

其中网络通信我们可以使用原生nio, 工具类可以自己来写, 但是asm的使用显得不可避免。这时会不可避免的和应用代码产生冲突。所以自定义ClassLoader很有必要。

jvm指令集

不管我们最终选择asm还是bytebuddy。对于jvm的指令集还是有必要深入了解一番的。相关的指令集可以大概分为以下几类:

栈操作
dup(包括dup2,dup2_x1等)
swap
pop
常量
xCONST(x代表了各种类型, 包括ACONST_NULL)
BIPUSH
SIPUSH
LDC
LDC2
逻辑运算
xADD
xSUB
xMUL
xDIV
xREM
转型
I2F,F2D,L2D ...
对象&字段
NEW
PUTFIELD
GETFIELD
方法
INVOKEVIRTUAL 声明过的方法
INVOKESPECIAL private&构造器
INVOKESTATIC
INVOKEDYNAMIC 动态方法
数组
xALOAD
xASTORE
跳转&返回
IFEQ,IFGE,IFNE...
xRETURN,RETURN

如上列举出的指令相对会简单一些且会经常用到。这里有2个地方可以方便的查询这些指令:

1. 当然是官网的jvm规范
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html
2. 这里的解释相对官网会更通俗好懂一些
https://cs.au.dk/~mis/dOvs/jvmspec/ref-Java.html 

操作数栈&本地变量表

先上一张图,这张图比较老了。

23232

在JDK8之后, permanent区域已经被移除, 被新的MetaSpace所取代, 配合此文更佳:

http://ifeve.com/java-permgen-removed/

包装

写到这里, 核心的内容应差不多了。剩下的就是一些边角料。但是可以使你的project更加优雅和健全。

命令行解析

解析来自于命令行的参数并不是一件特别容易的事情。但是好在有优秀的工具:

jcommander (http://jcommander.org/)
jopts (https://github.com/jopt-simple/jopt-simple)

当然这里同样要考虑自己项目的复杂度, 我们的目标是尽可能做一个精简的监控程序。

awk&shell

如果你打算把你的project分享到gayhub上, awk&shell 务必要熟悉. 将监控程序做到自动化(下载,安装)

实现本地电脑监控服务器端电脑监控功能 public class Client { // 入口 public static void main(String[] args) { try { int choice = JOptionPane.showConfirmDialog(null, "请求控制对方电脑", "远程控制系统-Charles", JOptionPane.YES_NO_OPTION); if (choice == JOptionPane.NO_OPTION) { return; } String input = JOptionPane.showInputDialog("请输入要连接电脑的ip(包括端口号)", "127.0.0.1:10000"); // 获取服务器的主机 String host = input.substring(0, input.indexOf(":")); // 获取服务器的端口号 String post = input.substring(input.indexOf(":") + 1); System.out.println("服务器的主机:" + host + " " + "端口号:" + post); Socket client = new Socket(host,Integer.parseInt(post)); DataInputStream dis = new DataInputStream(client.getInputStream()); JFrame jframe = new JFrame("本地监控系统 - Charles"); jframe.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);//默认关闭进程 jframe.setSize(1024, 768);// 设置窗体大小 double height = dis.readDouble(); double width = dis.readDouble(); Dimension ds = new Dimension((int)width, (int)height); //设置 jframe.setSize(ds); //将服务器图片作为背景 JLabel backImage = new JLabel(); JPanel panel = new JPanel(); //设置滚动条 JScrollPane scrollPane = new JScrollPane(panel); panel.setLayout(new FlowLayout()); panel.add(backImage); jframe.add(scrollPane); jframe.setAlwaysOnTop(true); jframe.setVisible(true); while(true){ int len = dis.readInt(); byte[] imageData = new byte[len]; dis.readFully(imageData); ImageIcon image = new ImageIcon(imageData); backImage.setIcon(image); jframe.repaint(); } } catch (Exception e) { e.printStackTrace(); } } }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值