前言:

Java 中各种 IDE 的 Debug 功能,都是通过 Java 提供的 Java Platform Debugger Architecture (JPDA) 来实现的。

借助 Debug 功能,可以很方便的调试程序,快速的模拟 / 找到程序中的错误。

Interllij Idea 的 Debug 功能上说虽然看起来和 Eclipse 差不多,但是在使用体验上,还是要比 Eclipse 好了不少。

Debug 中,最常用的莫过于下一步,下一个断点(Breakpoint),查看运行中的值几个操作;但是除了这些 IDE 还提供了一些 “高级” 的功能,可以帮助我们更方便的进行调试;

下面就介绍几种高级的并且也很有用的调试技巧。

Java8 Streams Debug:

Stream 作为 Java 8 的一大亮点,它和 java.io 包里的 InputStream 和 OutputStream 是完全不同的概念。

Java 8 中的 Stream 是对集合(Collection)对象功能的增强,它专注于对集合对象进行各种非常便利、高效的聚合操作(aggregate operation),或者大批量数据操作 (bulk data operation)。

IntStream.iterate(1, n -> n + 1)
                .skip(100)
                .limit(100)
                .filter(PrimeFinder::isPrime)//检查是否是素数
                .forEach(System.out::println);

上面这段代码,就是一个 streams 的常见用法,对集合排序并转换取值;IDEA 也提供了分析 streams 过程的功能:(注意:IDEA中安装了 Java Stream Debugger 插件才支持此功能)

修改程序执行流程:

在 Debug 调试的过程中,一般情况下,让程序正常执行即可。

但是某些情况下,需要动态的修改执行流程,此时如果通过修改代码再重新调试的方式还是太不方便了,好在 Idea 提供了一些动态修改程序执行流程的功能,可以让我们很灵活的进行调试。

1、返回上一个栈帧 / 删除当前栈帧 /“逆向运行”(Drop frame):

当我们在 Debug 时出现手抖等情况,提前或按错了下一步,导致错过了断点。此时可以通过 Idea 提供的 Drop Frame 功能,来返回到上一个栈帧:

虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)[插图] 用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

每一个方法从调用直至执行完成的过程,就对应着一个个栈帧在虚拟机栈中入栈到出栈的过程。

其实不光是 Java,其他编程语言的方法执行模型,也是一个栈结构,方法的执行对应着一次 push/pop 的操作;

比如下面这段代码,当执行过一次方法后,栈帧上有两个方法:

此时,点击 Drop Frame 按钮后,会删除栈顶上的数据,回到调用 log 方法前的位置:

注意:Drop Frame 虽然好用,但是可能在 Drop Frame 之后发生一些不可逆的问题,比如 IO 类的操作,或已修改的共享变量是无法回滚的,因为这个操作只是删除栈顶的栈帧,并不是真正的 “逆向运行”

2、强制方法返回(Force Return):

当一个方法比较长,或者 Step Info 到一个不太重要的方法想跳过该方法时,可以通过 Force Return 功能来强制结束该方法:

注意:Force Return 和 Step Out 不一样,Step Out 是跳出当前步骤,还是会执行方法中的代码;而 Force Return 是直接强制结束方法, 跳过该方法后的所有代码直接返回。比如下面这段代码,当使用 Force Return 后,evaluate 方法中的 println 并不会执行

当要强制返回的方法有返回值时(非 void),force return 还需要指定一个返回值:

3、触发异常:

当调用的方法可能抛出异常,调用者需要处理异常时,可以直接让方法抛出异常而不用修改代码;

下面是一段伪代码,模拟发送请求,超时自动重试:

当方法执行至 sendPacket 时,可以执行 Throw Exception 操作,提前结束方法并抛出指定的异常:

调用者收到异常后,就可以执行 catch 中的重试逻辑了,这样以来就不用通过修改程序等操作来模拟异常,非常的方便。

4、计算表达式:

计算表达式:指的是在调试时,可以将已经在调试中得到的结果,通过计算表达式进行动态处理,而不用去修改代码重新再调试;

下面举个例子描述下具体操作步骤:

编写的测试伪代码:

然后在调试的 variables 区域右击鼠标,点击 evaluate expression

然后出现计算表达式框:注意 getString( )方法是已经在代码中写好了的,并且 方法参数 bytes 是上面调试的代码得出的数据,最后点击 evaluate 执行表达式就会得到结果

Debug 运行中的 JVM 进程(Attach to Process):

当应用程序无法在 Idea 中运行,又想 Debug 这个运行中的程序时,可以通过 Attach to Process 功能,该功能可以 Debug 做到调试运行中的程序,当然前提是,保证这个正在运行的 JVM 进程代码和 Idea 中的代码一致;

这种场景其实挺常见的,比如你要调试 springboot executable jar 时,或者调试 tomcat 源码等独立部署运行的进程,通过 Attach to Process 就非常方便了,可以做到用 Idea 之外的环境 + Idea 中的代码 进行 Debug 。

这种功能其实在 C/C++ GDB 下也有,Debug 正在运行的程序而已,Intellij Clion 也是支持的。

远程调试(Remote Debug):

远程调试是 JVM 提供的功能,和上面的 Attach to Process 类似,只是这个进程从本地变成远程了。

比如我们的程序在本地没有问题,在服务器上却有问题;比如本地是 MacOs,服务器是 Centos,环境的不同导致出现某些 Bug,此时就可以通过 远程调试功能 来调试。

如果要启用远程调试,需要在远程 JVM 进程的启动脚本中添加以下参数:

-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=5005

suspend 参数 表示,JVM 进程是否已 “挂起” 模式启动,如果以 “挂起” 模式启动,JVM 进程会一直阻塞不继续执行,直到远程调试器连接到该进程为止;

这个参数非常有用,比如我们的问题是在 JVM 启动期间发生的(比如 Spring 的加载 / 初始化流程),就需要将 suspend 设置为 y,这样 JVM 进程就会等待 Ide 中的远程调试连接完成才会继续运行。否则远程的 JVM 已经运行了一段时间了,Ide 的 Debugger 才连接,早已经错过了断点的时机。

然后配置好 Host/Port,点击 Apply 保存即可:

最后,先启动远程的 JVM 进程,然后在 Idea 中已 Debug 来运行刚才配置的 Configuration 即可:

小提示 :远程调试下,由于有网络的开销,反应会比较慢,而且会导致远程程序的暂停,使用时请找一个没有人使用的环境。

多线程下的调试:

多线程程序是比较难写的,确切的说是很难调试,一个不小心就会因为线程安全的问题引起各种 Bug,并且这些 Bug 还可能很难复现。

由于操作系统的线程调度是我们无法控制的,所以多线程程序的错误有很大的随机性,一旦出现问题很难找到;我们的程序可能在 99.99% 的情况下都是正常的,但是最后的 0.01% 也很可能造成严重的错误。

线程安全的最常见问题就是 竞争条件 ,当某些数据被多个线程同时修改时,就可能会发生线程安全问题。

比如下面这个流程,正常情况下程序没问题:

当出现了竞争问题,单个线程的 read 和 write 操作之间,调度了其他线程,此时数据就会出错:

下面是一段示例代码,虽然共享数据 a 是一个 synchronizedList,但是它并不能保证 addIfAbsent 是个原子操作,因为 contains 和 add 是两个 synchronized 方法,两个方法的执行间隙间还是有可能被其他线程修改:

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class ConcurrencyTest {
    static final List a = Collections.synchronizedList(new ArrayList());

    public static void main(String[] args) {
        Thread t = new Thread(() -> addIfAbsent(17));
        t.start();
        addIfAbsent(17);
        t.join();
        System.out.println(a);
    }

    private static void addIfAbsent(int x) {
        if (!a.contains(x)) {
            a.add(x);
        }
    }
}

如果对这段代码进行 Debug 时,一个 Step Over( 下一步)之后,这个下一步操作的作用域是 整个进程 ,而不是当前线程。

也就是说,Debug 下一步之后,很可能被其他线程插入并执行了修改,这个共享数据 a 一样不安全,很可能出现重复添加元素 17 的问题;

但是上述问题只是可能出现,实际调试时很难复现。但是好在 Idea 的 Debug 可以将挂起粒度设置为线程,而不是整个进程:

Suspend 设置为 Thread 后,如下图所示,将断点打在 a.add 这一行,然后以 Debug 模式运行程序后,主线程和新建的线程都会挂在 addIfAbsent 方法中,我们可以在 Idea 中的 Debug 面板中切换线程:

此时,Main 线程和子线程都已经调用了 contains 方法,并都返回 false,挂起在 a.add 这一行,都准备将 17 添加到 a 中:

执行下一步后,Main 线程成功的将 17 添加到集合中:

此时切换到 Thread-0 线程,还是挂在 a.add(x) 这一行,但是集合 a 中已经有元素 17 了,但时 Thread-0 线程还是会继续 add,add 之后集合 a 就出现了重复元素 17,导致程序出现了 bug:

从上面的例子可以看出,在调试多线程程序的过程中,利用 Idea Debug 的 Suspend 功能,可以很方便的模拟多线程竞争的问题,这对于编写或调试多线程程序实在太方便了。

点赞 + 评论 + 转发 哟

如果本文对您有帮助的话,请挥动下您爱发财的小手点下赞呀,您的支持就是我不断创作的动力,谢谢啦!

您可以微信搜索 【木子雷】 公众号,大量Java学习干货文章,您可以来瞧一瞧哟!

参考资料:

①、Java Platform Debugger Architecture (JPDA) | Oracle Docs

②、Debugging Applications | Oracle Docs

③、Debug code - Help | IntelliJ IDEA