使用JDB直接在远程服务器上进行调试

程序开发过程中,debug 是必不可少的一部分,它能帮助我们及时发现一些不易察觉的 bug,但并不是所有的 bug 都能有幸在开发过程中就被发现,当程序被部署到远程服务器后,bug 的排查可能就不那么轻松了。

开发过程中常常会发现程序在我们本地运行的时候一切正常,但在测试环境或生产环境会出现不可预测的问题,也就是一些潜在的 bug 在特定的环境下才会暴露出来,可能是数据引起的,也可能是其他不确定的因素。此时通常的方法可能就是通过打印出更加详细的日志再进行分析,而日志的详细粒度也往往不容易把控,过多会增加分析的复杂度,过少又不易于发现问题。总之没有在本地 debug 来的痛快。有同学可能会说,”我们可以让远程 JVM 在启动的时候加载 JDWP Agent,然后在本地 IDE 中指定端口进行远程连接,从而进行调试“。不可否认,这是最理想的方案,但现实往往有点骨感,起码在我们公司,这个过程会让人崩溃。我们所有的服务器都部署在北美,由于网络原因,让 IDE 与远程 JVM 建立连接就需要消耗一点时间,之后服务器上的程序可能早就已经跑到断点了,而本地 IDE 还没有及时反应过来。这种卡顿、延迟现象让调试过程来的相当痛苦,还不如直接去分析日志。

换个思路:

“难道我们不能直接在远程服务器上直接进行调试吗?”

有同学可能会说:

“服务器通常是没有桌面的,如何在上面使用 IDE?”

这其实还是思维的固化,IDE 提供的 Debugger(调试器)其实就是 Java Debug Interface(JDI)的一个实现,比如我们再熟悉不过的Eclipse,它的两个插件org.eclipse.jdt.debug.uiorg.eclipse.jdt.debug,前者是Debugger的界面实现,后者就是JDI的一个完整实现。而 JDK 自带的jdb也是 JDI 的一个实现,所以我们完全可以直接使用这个自带工具进行调试。

Java Debugger(JDB)是一个用来调试Java类文件的命令行工具,它跟 Eclipse、Intellij 等 IDE 里的调试器一样,都是 Java Platform Debugger Architecture - JPDA 三大模块中最高层模块 JDI 的完整实现。

JPDA - Java 调试体系

说到这里,我们有必要先简单了解一下 JPDA。JPDA 由三个相对独立的模块组成,由低到高分别是 JVM 工具接口(JVMTI)、Java 调试线协议(JDWP)、Java 调试接口(JDI),层次结构如下图:

JPDA - Java调试体系

1. JVMTI

处于整个 JPDA 体系的最底层的 Java 虚拟机工具接口,是一套由虚拟机直接提供的 native 接口,由 C 语言实现,所有调试功能本质上都需要通过 JVMTI 来提供。通过这些接口,开发人员不仅调试在该虚拟机上运行的 Java 程序,还能查看它们运行的状态,设置回调函数,控制某些环境变量,从而优化程序性能。

2. JDWP

一个通讯交互协议,定义了调试器与目标虚拟机之间传递的信息的格式,包括请求命令、回应数据和错误代码。同样也是由 C 语言实现。

3. JDI

三个模块中最高层的接口,在多数的 JDK 中,它是由 Java 语言实现的。 通过它,调试工具开发人员就能通过调试器来远程操控目标虚拟机上被调试程序的运行。

Java Debugger(JDB)

下面我们来了解一下这个 JDK 自带工具的使用方法。JDB 提供了多种连接目标程序 JVM 的方式,这里介绍最常用的两种。

1. 由 jdb 命令创建目标 JVM

这种方式下,jdb 命令直接为目标 Java 程序启动一个 JVM,加载类信息,即程序的启动是由 jdb 命令直接触发的,启动成功后,目标 JVM 就会被暂停,等待用户输入命令来让程序得以执行。这就是我们在本地用 IDE 进行 debug 的方式。
比如用 JDB 调试如下程序:

// Test.java

package demo;

public class Test {
    private int base = 1;
  
    public int add(int a) {
        return base + a;
    }
}
// Main.java

package demo;

public class Main {

    public static void main(String[] args) {
        Test t = new Test();
        int result = t.add(2);
        System.out.println(result);
    }

}

编译上面两个源文件后,在控制台进行调试:

  • 通过 jdb <主类的全路径名> 启动 JVM,这个示例当中就是 jdb demo.Main
    dereck-mbp:temp Dereck$ jdb demo.Main
    正在初始化jdb...
    > 
    
  • 设置断点,两种方式
    > stop ?
    用法: stop at <class>:<line_number> 或
         stop in <class>.<method_name>[(argument_type,...)]
    
    对于本例,我们通过方法名的方式给 add() 加断点,执行命令如下,断点会设置在方法的第一行
    > stop in demo.Test.add
    正在延迟断点demo.Test.add。
    将在加载类后设置。
    > 
    
  • 通过 run 命令运行程序,它会自动从 main() 执行,一直到断点处暂停,等待用户输入后续命令。run 命令只适用于由 jdb 直接创建启动 JVM 的方式
    > run
    运行demo.Main
    设置未捕获的java.lang.Throwable
    设置延迟的未捕获的java.lang.Throwable
    > 
    VM 已启动: 设置延迟的断点demo.Test.add
    
    断点命中: "线程=main", demo.Test.add(), 行=8 bci=0
    8            return base + a;
    
    main[1] 
    
  • 通过 printdump 命令查看此时指定变量的值,print 用于查看简单类型,dump 用于查看对象类型
    main[1] print a
     a = 2
    main[1] print this
     this = "demo.Test@41975e01"
    main[1] dump this
     this = {
        base: 1
    }
    main[1] 
    
    如果执行 print 命令查看方法参数 a 报如下错误(“未知变量名
    a”)的话,需要在编译的时候,给 javac 加一个参数 -g, 比如 javac -g demo/Test.java
    main[1] print a
    com.sun.tools.example.debug.expr.ParseException: Name unknown: a
     a = 空值
    main[1] 
    
  • 通过 stepnextcont 命令继续执行程序
    • step 命令相当于 Eclipse 当中的 F5,如果当前语句是另一个方法调用时,会进入那个方法当中
    • next 命令相当于 F6,只会逐行执行,不会进入被调用的其它方法
    • cont 命令相当于 F8,从当前行一直执行到下一个断点,如果没有就一直执行到程序结束
    main[1] cont
    > 3
    
    应用程序已退出
    dereck-mbp:temp Dereck$ 
    

更多 JDB 命令请参考 Oracle 官方文档

2. 由 jdb 命令 attach 到已经处于运行状态的目标 JVM

这种方式适用于远程调试,也是我们直接在远程服务器上进行 debug 的方式。 它需要目标 JVM 自身在启动的时候传入一些额外的参数,大致格式如下:

java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=<PORT> <主类的全路径名> 

其中 address 参数可选,如果不指定的话会随机分配一个可用端口。
为了演示,这里用 Spring Initializr 创建了一个简单的 Web Application : Helloworld,除了生成的代码,新建了一个简单的 HelloworldController.java

package com.example.helloworld.helloworld;

import org.springframework.stereotype.*;
import org.springframework.web.bind.annotation.*;


@Controller
public class HelloworldController {

    private final String message = "helloworld";

    @RequestMapping("/")
    @ResponseBody
    String home() {
        return message;
    }
  
}
  • 通过 Maven 构建打包成 jar 文件
    mvn package
    
  • 打包成功后,启动 WEB 应用
    java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n -jar helloworld-0.0.1-SNAPSHOT.jar
    
  • 启动成功后,日志中会打印出提供给 jdb 连接的端口,因为我们之前未指定,所以这里随机分配了一个
    dereck-mbp:target Dereck$ java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n -jar helloworld-0.0.1-SNAPSHOT.jar 
    Listening for transport dt_socket at address: 51750
    
  • 通过 jdb 命令连接到正在运行的 JVM
    dereck-mbp:~ Dereck$ jdb -connect com.sun.jdi.SocketAttach:port=51750
    设置未捕获的java.lang.Throwable
    设置延迟的未捕获的java.lang.Throwable
    正在初始化jdb...
    > 
    
  • 后续设置断点及输入命令进行调试的步骤与方式1一样,这里就不在累赘了

这种方式没有因为网络原因而导致的卡顿、延迟现象,但操作起来可能比较复杂,不是很直观,但对于在不改变系统运行环境、又没有详细 log 的情况下快速进行问题的排查还是有一定的帮助的。不过,不要在生产环境使用,因为一旦进入断点,程序就会被中断了

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 122,568评论 15 534
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 67,359评论 12 114
  • 集群前后台协议需要做一些修改,我负责jdbc这边的修改。按照协议内容修改完代码之后却面临一个测试的问题:修改后的后...
    德彪阅读 1,132评论 0 1
  • 每次出去观看一部影片,就给做贼似的,不想让孩子和婆婆妈知道,因为出去看电影不带孩子,我自己就觉得有愧。可他提前就订...
    任小艺阅读 506评论 1 3
  • 李特这特题目有点蛋疼,因为目前只接受一种结果。我做的恰好和它要的结果不一样,但是我觉得我这种走法走出来也是没错的。...
    张土汪阅读 132评论 0 1