Debug IDEA 匿名内部类断点无法进入问题

​ 组内有同学发现,在进程启动前设置好的 匿名内部类 断点,在进程启动完成始终无法进入。我尝试复现,发现只在特定的类上才会出现,其他的类上的匿名内部类没有问题,这就非常奇怪了。一番操作,从 JDB 到 调试 IDEA 断点逻辑,终于发现症结:业务逻辑有一个扫包初始化类的逻辑,这个类初始化的顺序会影响 IDEA 对断点的设置。

无法断点的环境

​ 无法断点的类非常简单,抽象出来如下:

1
2
3
4
5
6
7
8
9
10
11
public class AnonymousClass {
public static void entry() {
new Runnable() { // 位置 0
@Override
public void run() {
System.out.println("Anonymous Inner class print."); // 位置1 设置断点
}
}.run();
System.out.println("Entry print.");
}
}

​ 尝试在进程启动前 位置1 处设置断点,然后在入口函数处调用 entry 方法,Debug 启动进程,可以发现断点始终无法进入。具体代码可见:anonymous_debug_problem

debug 无法 debug

​ 开始套娃 debug 无法 debug 的问题了,首先闪过的念头是,是不是 Idea 就无法支持在进程启动前的匿名内部类断点,随便找了另外一个匿名内部类,发现是可以正常进入的。如果是普遍现象,应该早就发现了,并且应该网上到处都是相关问题的讨论。

​ 观察一下进程启动后无法 debug 的情况,断点的小红点上没有出现 符号。根据 IDEA 文档 breakpoint-icons ,只有在断点上出现这个符号,才表明 IDEA 的断点 已验证 / Verified。有意思的是,这个时候重复 toggle 设置断点,又出现了 符号,断点可以正常进入了。

​ 这时候就有两种可能性了,一种是 IDEA 有 bug,没有往 JVM 设置断点;另外一种情况是 JVM 的问题,没有响应 IDEA 的断点请求。按上面的表现来看,前者的可能性更大。

尝试用其他工具断点定位问题

​ 这个时候,思路是尝试用其他工具断点,排除一下是否是 IDEA 有 bug。手头最容易获取的 JVM Debug 工具其实是 JDB,看这名字就知道跟 GDB 是一样的玩意,看了一下帮助文档,开始尝试。

​ 直接 jdb 入口类 Main(注意是全限定类名):

1
2
➜ jdb io.isenninha.anonymous_debug_problem.Main 
正在初始化jdb...

​ 在执行 run 指令前先设置断点,stop at <class id>:<line>。 这里有个小插曲,匿名内部类的断点必须是对应子类的名字,即带 $ 符号的类,直接用上层命名类是无法设置断点的(差点以为定位到问题了):

1
2
3
4
5
➜ jdb io.isenninha.anonymous_debug_problem.Main 
正在初始化jdb...
> stop at io.isenninha.anonymous_debug_problem.AnonymousClass$1:11
正在延迟断点io.isenninha.anonymous_debug_problem.AnonymousClass$1:11。
将在加载类后设置。

​ 可以看到提示 正在延迟断点io.isenninha.anonymous_debug_problem.AnonymousClass$1:11,此时类还未加载解析链接,自然是无法设置断点的。

​ 执行 run 指令:

1
2
3
4
5
6
7
8
> run
运行io.isenninha.anonymous_debug_problem.Main
设置未捕获的java.lang.Throwable
设置延迟的未捕获的java.lang.Throwable
>
VM 已启动: 设置延迟的断点io.isenninha.anonymous_debug_problem.AnonymousClass$1:11

断点命中: "线程=main", io.isenninha.anonymous_debug_problem.AnonymousClass$1.run(), 行=11 bci=0

​ 断点命中!那就是 IDEA 的 bug 了。

定位 IDEA 断点 bug

​ 定位到是 IDEA 的 bug 就是相对简单了,毕竟是开源并且用 Java/Kotlin 开发的。(JVM 也有开源的 OpenJDK)。问题是如何调试 IDEA ?

​ 理论上来说,可以在 IDEA 的启动参数打开远程 Debug 端口,然后用另外的调试工具进行调试,比如上面的 JDB,或者是另外一个版本的 IDEA;或者从 github 下载对应版本的 IDEA Community Edition;最简单的方法其实是新建一个 Plugin demo,依赖 java 模块,启动插件,会以沙箱的方式运行一个当前版本的 IDEA。

匿名内部类加载顺序异常

​ 看了一下相关代码,大概的流程是:IDEA 会在进程启动前设置的断点相关类注册进 VM,VM 在加载解析相关类后会在 DebuggerEventThread 中回调 IDEA,关键代码如下:

1
2
3
4
for (Event event : eventSet){
// 省略无关代码
processClassPrepareEvent(suspendContext, (ClassPrepareEvent)event, notifiedClassPrepareEventRequestors);
}

​ IDEA 会在收到 prepare evnet 加载类回调的时候,查询断点相关数据,来判断是否需要向 VM 设置断点。在这里,IDEA 收到了 io.isenninha.anonymous_debug_problem.AnonymousClass$1 匿名内部类的 prepare 回调。继续深入相关逻辑,发现在 PositionManagerImpl 中,会尝试解析本地设置的断点是否与回调类有关联。IDEA 会尝试搜索 top-level class,在我们的例子中,就是 io.isenninha.anonymous_debug_problem.AnonymousClass,诡异的是 IDEA 没有在已加载解析的类中找到这个类,于是跳过对此类的断点设置。具体代码在 PositionManagerImpl#getAllClasses

1
2
3
4
5
public List<ReferenceType> getAllClasses(@NotNull final SourcePosition position) throws NoDataException {
return ReadAction.compute(() -> StreamEx.of(getLineClasses(position.getFile(), position.getLine()))
.flatMap(aClass -> getClassReferences(aClass, position))
.toList());
}
验证一下 JVM 加载匿名内部类顺序

​ IDEA 收到的加载类回调顺序异常,还需要再验证一下是不是在 JVM 的加载顺序就已经异常了。在启动参数中增加 -verbose:class 即可输出 JVM 加载类相关日志。

1
2
3
4
➜ java -verbose:class io.isenninha.anonymous_debug_problem.Main | grep AnonymousClass

[Loaded io.isenninha.anonymous_debug_problem.AnonymousClass$1 from file:/home/senninha/idea/anonymous_debug_problem/anonymous_debug_problem/target/classes/]
[Loaded io.isenninha.anonymous_debug_problem.AnonymousClass from file:/home/senninha/idea/anonymous_debug_problem/anonymous_debug_problem/target/classes/]

显然,JVM 的加载顺序与 IDEA 收到的回调顺序是一致的。

确定 Bug

​ 对比了一下 IDEA 可以正常断点的匿名内部类,加载顺序是先 top-level 类 -> 匿名内部类,也就是按照控制流的顺序加载的。

​ 那这个问题是 IDEA 背,还是 JVM 呢?首先 JDB 在这种所谓异常加载顺序下依然可以正常设置断点,应该就是 IDEA 的问题,并且 IDEA 在 toggle 重复设置后又可以正常进入断点,显然是这部分的实现不够严谨,没有考虑在非正常控制流加载匿名内部类的情况。

​ 但为什么会出现 top-level 类还未解析,就开始解析匿名内部类呢?按照代码控制流,这里是 new 指令触发了匿名内部类的加载解析, top-level 类也会因为 invokestatic 指令触发解析的。

​ 难道是提前加载解析了?看了一下上下文的相关代码,果然发现端倪,由于该类需要做一些反射调用缓存,在到达控制流代码的时候已经提前扫包解析了,这也可以解释为什么其他匿名内部类的断点又可以进入。简化后的扫包初始化逻辑:

1
2
3
4
5
6
7
8
9
10
11
private static void fakeScanClass() {
List<String> classList = Arrays.asList(
"io.isenninha.anonymous_debug_problem.AnonymousClass$1",
"io.isenninha.anonymous_debug_problem.AnonymousClass");
for (String className : classList) {
try {
Class.forName(className, true, Thread.currentThread().getContextClassLoader());
} catch (ClassNotFoundException ignore) {
}
}
}

扫包出来的类顺序是通过 File#listFiles 确定的,而该方法的注释:

There is no guarantee that the name strings in the resulting array
will appear in any specific order; they are not, in particular, guaranteed to appear in alphabetical order.

在 ext4 文件系统上,默认的顺序是 alphabetical order

➜ ls
‘AnonymousClass$1.class’ AnonymousClass.class Main.class

因为 . 符号,所以匿名内部类排序在命名类之前,导致上层应用的加载顺序有问题。最简单的处理方式是扫包后根据类的全限定名(去掉后缀 .class)做一次排序,断点可以正常进入了,问题解决。

总结

​ 综上,由于 IDEA 的断点模块的 bug,启动进程前设置匿名内部类断点,必须按照控制流的调用顺序初始化所有相关的类,否则会由于无法找到 top-level class 导致设置断点失败。