程序开发过程中,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.ui
和org.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),层次结构如下图:
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... >
- 设置断点,两种方式
对于本例,我们通过方法名的方式给 add() 加断点,执行命令如下,断点会设置在方法的第一行> stop ? 用法: stop at <class>:<line_number> 或 stop in <class>.<method_name>[(argument_type,...)]
> 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]
- 通过
print
或dump
命令查看此时指定变量的值,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]
- 通过
step
、next
或cont
命令继续执行程序-
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
命令连接到正在运行的 JVMdereck-mbp:~ Dereck$ jdb -connect com.sun.jdi.SocketAttach:port=51750 设置未捕获的java.lang.Throwable 设置延迟的未捕获的java.lang.Throwable 正在初始化jdb... >
- 后续设置断点及输入命令进行调试的步骤与方式1一样,这里就不在累赘了
这种方式没有因为网络原因而导致的卡顿、延迟现象,但操作起来可能比较复杂,不是很直观,但对于在不改变系统运行环境、又没有详细 log 的情况下快速进行问题的排查还是有一定的帮助的。不过,不要在生产环境使用,因为一旦进入断点,程序就会被中断了