Java-debug-tool

作者: 一字马胡 | 来源:发表于2019-06-01 12:29 被阅读57次

    Java-debug-tool

    Java-debug-tool解决什么问题

    Java-debug-tool是为了解决日常问题排查的痛点而设计的,问题排查分成两个主要阶段,问题定位和问题修复,问题定位是说找到问题的原因,问题修复是说将问题解决,使得系统恢复正常运行。
    对于问题定位来说,我们的需求是:

    • 能知道方法入参和返回值,或者抛出的异常信息
    • 当方法有多个出口的时候,可以知道方法是从什么地方退出的,或者是从什么地方抛出异常的;
    • 一次方法调用的执行路径是怎么样的,每一行代码的耗时又是多少;
    • 获取到方法执行过程中的局部变量信息;

    本质上,问题定位的需求是实现单步调试,因为这样是最容易发现问题出在什么地方的,但是对于java来说,单步调试技术会停顿整个JVM,所以只能在测试的时候使用这种技术,对于生产环境来说就不能使用了,所以对于线上问题排查来说,基本可以不用考虑单步调试,但是如果集群有流量摘除等功能的话,倒是可以使用;java-debug-tool解决了这个问题,可以模拟单步调试的同时不会停顿正在运,使用行的JVM,下面会介绍Java-debug-tool到底实现了一些什么功能。

    找到了问题出现的原因,接着就是问题修复,问题修复最大的痛点其实是恢复生产,对于java来说,恢复生产意味着需要重启JVM,这样就会造成问题修复时间变长,Java-debug-tool为此提供了技术支持Java Instrumentation技术,可以在运行时的JVM中替换类的字节码,实现热修复。


    Java-debug-tool不能解决什么问题

    • (1)如果需要做性能优化分析,Java-debug-tool可能支持的力度很小,虽然可以通过Java-debug-tool观察到每一行代码的执行耗时,但是也仅仅是观察,所以性能问题还是需要其他专业的工具来进行;
    • (2)非JVM自身问题,比如机器CPU、磁盘I/O等问题,Java-debug-tool就无能为力了,Java-debug-tool专注于解决JVM自身的问题;
    • (3)Java-debug-tool仅支持方法级别的观察,无法观察到整体的调用链路,后续可能会支持多级方法链路的观察,但可能性不大,因为要支持这种方法间调用链路追踪,就得增强多个方法,而增强方法是对运行时有一定损耗的,如果一个方法调用链路特别长(对于java来说一般调用链路都很长),那么就悲剧了;
    • (4)Java-debug-tool不支持递归方法的观察,这个功能实现起来也是非常麻烦,而且极其不可控,所以千万不要用Java-debug-tool去观察一个递归方法,切记;

    如何使用

    Java-debug-tool使用Java开发,下面介绍如何使用Java-debug-tool进行问题排查;

    • (1)下载Java-debug-tool代码;
    • (2)进入script目录,执行javadebug-pack.sh脚本执行编译打包,要求JDK 1.8 +,并且一定要执行javadebug-pack.sh脚本之后再使用;
    • (3)如果是Spring项目,则只需要将下面的bean配置到项目中即可实现JVM启动之后Java-debug-tool Agent自动attach到目标JVM上的功能,如果不是Spring项目,请看(4)
        <!-- dynamic debug bean -->
        <bean id = "javaDebugInitializer" class="io.javadebug.spring.JavaDebugInitializer" factory-method="initializer" destroy-method="destroy" lazy-init="false"/>
    
    • (4)Java-debug-tool不要求在目标JVM启动的时候就必须attach到目标JVM上,可以动态attach,在目录 /bin下有多个可用的脚本,方便用于动态attach到目标JVM上,无论如何,你都需要首先知道目标JVM的进程id,然后执行一个脚本就可以动态attach到目标JVM上:
    ./javadebug-agent-launch.sh PID
    

    这样就可以在目标JVM上启动一个tcp服务,默认地址为:127.0.0.1:11234,如果你想要指定其他的地址,可以使用下面的命令:

    ./javadebug-agent-launch.sh PID@IP:PORT
    

    之后就可以在IP:PORT启动tcpServer,attach到目标JVM上之后,就可以连接目标JVM进行动态调试了,连接到目标JVM只需要执行下面的命令即可:

     ./javadebug-client-launch.sh
    
    

    默认就是连接 127.0.0.1:11234,如果attach目标JVM的时候指定的地址不是这个,需要显示指定地址:

     ./javadebug-client-launch.sh IP:PORT
    
    

    命令详解

    Java-debug-tool目前支持的命令不多,下面分别介绍一下当前支持的核心命令。首先介绍一下命令输出界面信息介绍:

    ---------------------------------------------------------------------------------------------
    命令              :mt
    命令执行Round       :1
    客户端ID           :10000
    客户端类型           :client:1
    协议版本            :version:1
    命令耗时            :179 (ms)
    STW时间           :45 (ms)
    ---------------------------------------------------------------------------------------------
    [ReturnTest.getIntVal] with params
    [1]
    [0 ms] (37)
    [0 ms] (43) [startTime = 1559358148073]
    [0 ms] (44) [strTag = the return/throw line test tag]
    [0 ms] (45)
    [0 ms] (47)
    [0 ms] (51)
    [3 ms] (52) [paramModel = 1.1]
    [0 ms] (53)
    return value:[101]  at line:53 with cost:5 ms
    
    ---------------------------------------------------------------------------------------------
    

    每个输出字段都介绍一下:

    字段 含义
    命令 本次输出执行的命令是什么 就是你输入的命令名称
    命令执行Round 这个调试客户端和目标JVM交互了几次 交互次数
    客户端ID 每个客户端首次连接服务端都会被分配一个ContextId,后续的交互都需要将这个ID带上 唯一ID
    客户端类型 这是一个保留字段,Java-debug-tool认为第一个连接到目标JVM的调试客户端应该是一个Master Client,权限最高
    协议版本 防伪,只有是从服务端拿到的协议才能继续交互
    命令耗时 命令的执行耗时,从命令输入处理开始计算,到命令结果展示出来结束,所以是客户端耗时 + 服务端耗时
    STW时间 动态增强字节码涉及到JVM字节码替换,会造成STW,这个时间就记录到底STW了多长时间,这个时间会比实际STW的时间长,只是一个粗略的时间 如果一个方法被一个client增强过了,后续的client就不能增强了,除非增强该方法的client退出,其他client才能继续增强;同时,一个client增强过的方法,其他client可以共享

    接着就是具体方法的执行路径信息,比如上面这个例子,说明本次观察的方法执行是 "ReturnTest.getIntVal",方法入参是1,方法执行路径是37-43-44-45-47-51-52-53,最终从53行退出,其中第52行耗时3ms,其他行耗时小于1ms,所以无法收集到,最终方法的执行结果是101,本次方法耗时5ms,并且可以看到43、44、52行都有变量赋值信息,格式为 varName = varVal.toString(),需要注意的是,varName可能是错误的,但是varVal是正确的,如果有多个,按照赋值顺序展示;这是方法正常返回的结果展示,下面看一个方法抛出异常的结果展示:

    ---------------------------------------------------------------------------------------------
    命令              :mt
    命令执行Round       :1
    客户端ID           :10001
    客户端类型           :client:0
    协议版本            :version:1
    命令耗时            :75 (ms)
    STW时间           :0 (ms)
    ---------------------------------------------------------------------------------------------
    [ReturnTest.getIntVal] with params
    [7]
    [0 ms] (37)
    [0 ms] (43) [startTime = 1559358921527]
    [0 ms] (44) [strTag = the return/throw line test tag]
    [0 ms] (45)
    [0 ms] (47)
    [0 ms] (51)
    [0 ms] (54)
    [0 ms] (59)
    [0 ms] (73) [paramModel = ParamModel{intVal=0, doubleVal='0.0'}]
    [0 ms] (74)
    [0 ms] (75)
    [0 ms] (76) [subVal = 200]
    [0 ms] (78)
    [0 ms] (82)
    throw exception:[java.lang.IllegalStateException: error occ with in:7]  at line:82 with cost:0 ms
    
    ---------------------------------------------------------------------------------------------
    

    可以看到本次方法执行路径,参数为7,在82行抛出了异常,其他信息和正常返回时类似,就不做过多解释了。

    methodTrace命令

    就像命令名称一样,这个命令是用于观察方法执行路径的,可以使用mt来替代命令,该命令参数较多,但是大部分都是可选的,下面先介绍每一个参数的含义,然后再介绍如何实现具体的功能。

    命令基本格式:
    mt -c <class> -m <method>

    可选参数:

    • -d :如果目标类中的目标方法是重载方法,那么你需要提供这个参数,比如int a(int a) => desc = "(I)I";

    • -t:选择具体的功能类型,可选项为:

      • return:当方法正常退出的时候,获取到一次方法链路信息;
      • throw:方方法抛出异常的时候,获取到一次方法链路信息;
      • record:记录方法调用信息,用于回放流量;
      • custom:用于实现用户自己输入参数观察,或者回放record的流量进行观察,当然,如果只是想发生一次请求也是可以的;
      • watch:等待特定的参数,使用Spring表达式进行参数匹配,当匹配到目标参数之后,会返回方法链路信息,如果Spring表达式有误,那么会直接在第一次方法调用之后返回;
    • -i:用于接收用户的参数输入,比如当t=custom的时候,i参数就是用户指定的参数,这个参数是通过特殊处理的json字符串,java-debug-tool将提供工具接口来生成这个字符串,当t=watch的时候,i参数就是用于匹配参数的Spring表达式。

    • -n:当t=record的时候,n参数的含义就是需要录制的流量数量,当前仅允许录制10个以内;

    • -time:当t=record的时候,该参数的含义是录制的时间限制,超出则停止录制;

    • -u:当t=custom的时候,如果提供了u参数,那么i参数将被忽略,u代表record的流量下标,从0开始,如果u参数获取到了具体的流量,那么本次custom输入的参数就会从u参数取出来的流量中拿到参数,如果t=record,并且u参数合法,那么就不会进行录制,而是会从录制好的流量中取出代表u下标的流量,用户可以查看具体的流量信息(包括该流量的方法链路);

    • -e:如果t=throw,那么如果-e内容合法,那么该参数就代表需要等待的目标异常,如果参数不合法,只要遇到一个异常,本次观察就会结束;当t=custom的时候,该参数用于匹配自定义输入,也就是说,如果你希望观察自定义输入的执行路径,你需要在custom类型下指定-e参数,内容是用于匹配输入的Spring表达式;

    • -s:有些情况下,你可能只需要看方法调用的路径,不需要耗时信息,或者不需要变量信息,那么这个参数有很有用,因为可能有些变量很长,展示出来很难看,而有些时候你只需要看看方法到底是从哪里退出来的,这个参数有很有帮助。可以是"line"/"cost"中的一个,前者表示只需要给我方法链路信息,后者其实是"line" + "cost";

    • -l:这个参数很有用,当某个方法很长,那么链路追踪信息打印出来会很难看,你可能只关心某一行的相关信息,比如就想看看某一行的代码执行耗时,以及这一行相关的变量信息,那么这个参数就可以派上用场,值就是具体的行号(对照源码);

    下面根据上面的参数来实现不同的观察功能,首先是用于测试的Java类:

    public class ReturnTest {
    
        private TestClass testClass = new TestClass();
    
        public static void say(int a) {
            int b = a * 10;
            System.out.println("hello:" + b);
            //return b;
        }
    
        public int getIntVal(int in) {
    //        if (in < 7) {
    //            System.out.println("in < 7, return");
    //            throw new UnsupportedOperationException("test");
    //        }
    
            if (in == 5) {
                String msg = null;
                // produce npe
                in += msg.length();
            }
    
            long startTime = System.currentTimeMillis() + fibonacci(2);
            String strTag = "the return/throw line test tag";
            if (in < 0) {
                return strTag.charAt(0);
            } else if (in == 0) {
                return 1000;
            }
            // > 0
            if (in < 2) {
                double dbVal = 1.1;
                return (int) (dbVal + 100);
            } else if (in == 2) {
                float fVal = 1.2f;
                return (int) (fVal + 200);
            }
            // > 2
            if (in % 2 == 0) {
                Random random = new Random();
                int rdm = random.nextInt(100);
                if (rdm >= 50) {
                    throw new IllegalArgumentException("npe test");
                } else if (rdm <= 20) {
                    throw new NullPointerException("< 20");
                }
                // end time
                long end = System.currentTimeMillis();
                long cost = startTime - end;
                int ret = testClass.test(in);
                return (int) (rdm * 10 + ret + (cost / 1000));
            } else {
                ParamModel paramModel = new ParamModel();
                paramModel.setIntVal(in);
                paramModel.setDoubleVal(1.0 * in);
                int subVal = getSubIntVal(paramModel);
    
                if (subVal == 100) {
                    throw new IllegalArgumentException("err occ with in:" + subVal);
                }
    
                throw new IllegalStateException("error occ with in:" + in);
            }
        }
    
        /**
         *  不支持递归函数
         *
         * @param n
         * @return
         */
        public int fibonacci(int n) {
            if (n < 0) {
                return -1;
            }
            if (n == 0) {
                return 0;
            }
            if (n <= 2) {
                return 1;
            }
            return fibonacci(n - 1) + fibonacci(n - 2);
        }
    
        public int getSubIntVal(ParamModel paramModel) {
            if (paramModel == null) {
                return -1;
            }
            if (paramModel.getIntVal() <= 0) {
                return (int) paramModel.getDoubleVal();
            } else if (paramModel.getIntVal() <= 5) {
                return 100;
            } else if (paramModel.getIntVal() <= 8) {
                return 200;
            } else {
                throw new RuntimeException("ill");
            }
        }
    
        public static void main(String[] args) {
            new Thread(new Runnable() {
                private Random random = new Random();
                private ReturnTest returnTest = new ReturnTest();
    
                @Override
                public void run() {
                    while (true) {
                        try {
                            System.err.println(returnTest.getIntVal(random.nextInt(10)));
                            TimeUnit.MILLISECONDS.sleep(5);
                        } catch (Exception e) {
                            //e.printStackTrace();
                            //System.out.println("error:" + e.getMessage());
                        }
                    }
                }
            }).start();
        }
    }
    
    public class TestClass {
    
        Aa aa = new Aa();
    
        public int test(int in) {
    
            if (in == 5) {
                return 100;
            }
            String tag = "the in:" + in;
            if (in < 5) {
                in += 2;
            } else {
                in -= 1;
            }
    
            if (in > 5) {
                throw new IllegalArgumentException("must <= 5");
            }
            if (in <= 3) {
                throw new NullPointerException("must >= 3");
            }
    
            return in * 100;
        }
    
    }
    
    • (1)观察一次方法调用的执行路径
    观察一次方法调用执行路径

    上面的图片展示了观察一次 "ReturnTest.getIntVal"方法调用的执行路径,本次方法入参是2,返回值是201,是从56行代码退出的,耗时1ms;

    • (2)在(1)中只要方法被调用一次,那么观察就会立刻结束,所以观察结果可能是方法正常结束,也可能是抛出了异常,如果只是希望观察方法正常退出,那么就可以指定-t参数为return,这样只有当第一次方法不抛出异常退出才会结束观察;

    • (3)和(2)相反的是,如果你希望监控一个异常的执行路径,比如一个方法执行偶尔会抛出某种异常,搞得你很摸不着头脑,那你就可以指定-t参数为throw来观察抛出异常的执行路径:

    观察方法异常退出执行路径

    当然,如果你想要观察的是某种特定的异常,可以指定-e参数:

    观察指定的异常
    • (4)自定义输入参数进行方法调用,并进行方法执行路径观察:需要注意的是,在执行自定义参数调用之前,Java-debug-tool需要获取到目标对象,或者目标方法是一个静态方法,否则Java-debug-tool命令会一直等待获取目标对象(不会主动创建目标对象)
    观察特定输入
    • (5)记录方法调用请求
    录制方法请求

    录制完成后可以回放请求:

    观察回放请求
    • (6)观察特定输入执行路径
    观察特定参数-1 观察特定参数-2

    redefineClass命令

    redefineClass命令用于替换一个类的字节码,可以简写成rdf,用于快速恢复生产环境,命令的参数没有mt命令复杂,但是需要有几点需要注意:

    • 一个client对一个类执行rdf命令,就会锁定这个类,其他client就不能对相同的类执行rdf,直至client退出;

    我们把getIntVal方法的开始部分的注释去掉,也就是:

    //        if (in < 7) {
    //            System.out.println("in < 7, return");
    //            throw new UnsupportedOperationException("test");
    //        }
    

    这一段内容,去掉之后,只要输入的参数小于7,那么就会抛出异常,我们使用mt命令配合custom来验证我们的rdf结果:

    执行rdf命令

    可以看到,此时输入参数为5的时候抛出了那个期望的异常;

    rollback命令

    rollback命令用于将一个增强过的类恢复到初始状态,可以使用back简写,目前仅支持恢复到初始状态,后续会记录增强stage,然后恢复到上一次增强过的字节码:

    回滚一个类

    findClass命令

    是不是曾经出现过因为jar包冲突导致的类加载错误的情况呢?findClass命令用于快速判断一个类是不是在目标JVM加载了,如果加载了,是从哪个jar包中加载的(jar一般都会有版本号,可以看看是不是从期望的jar版本中加载的),是被什么类加载器加载的,还可以仅仅使用类名(不含包名)来匹配,甚至通过正则表达式来匹配,可以使用简写fc:

    查找类信息

    help命令

    如果你不知道怎么使用一个命令,那么可以试试help命令:

    help命令

    如何重复发生上一次发送的命令

    有时候需要重复上一次输入的命令,但是上一次命令输入内容很多,如何快速实现上一次命令的复制呢?下面的一些字符可以快速帮你搞定这件事情:"p","r","s","go","last"

    重复命令发送

    后续将支持命令历史记录回放的功能,目前仅支持回放上一次输入。

    规划中的功能

    • (1)线程相关功能,包括当前线程总数、活动线程数等等信息,并能获取到某个线程的调用堆栈等信息;
    • (2)GC相关信息;

    相关文章

      网友评论

        本文标题:Java-debug-tool

        本文链接:https://www.haomeiwen.com/subject/vyyntctx.html