组内有同学发现,在进程启动前设置好的 匿名内部类 断点,在进程启动完成始终无法进入。我尝试复现,发现只在特定的类上才会出现,其他的类上的匿名内部类没有问题,这就非常奇怪了。一番操作,从 JDB 到 调试 IDEA 断点逻辑,终于发现症结:业务逻辑有一个扫包初始化类的逻辑,这个类初始化的顺序会影响 IDEA 对断点的设置。
无法断点的环境
无法断点的类非常简单,抽象出来如下:
1 | public class AnonymousClass { |
尝试在进程启动前 位置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 | ➜ jdb io.isenninha.anonymous_debug_problem.Main |
在执行 run 指令前先设置断点,stop at <class id>:<line>
。 这里有个小插曲,匿名内部类的断点必须是对应子类的名字,即带 $ 符号的类,直接用上层命名类是无法设置断点的(差点以为定位到问题了):
1 | ➜ jdb io.isenninha.anonymous_debug_problem.Main |
可以看到提示 正在延迟断点io.isenninha.anonymous_debug_problem.AnonymousClass$1:11
,此时类还未加载解析链接,自然是无法设置断点的。
执行 run
指令:
1 | run |
断点命中!那就是 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 | for (Event event : eventSet){ |
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 | public List<ReferenceType> getAllClasses(final SourcePosition position) throws NoDataException { |
验证一下 JVM 加载匿名内部类顺序
IDEA 收到的加载类回调顺序异常,还需要再验证一下是不是在 JVM 的加载顺序就已经异常了。在启动参数中增加 -verbose:class
即可输出 JVM 加载类相关日志。
1 | ➜ java -verbose:class io.isenninha.anonymous_debug_problem.Main | grep AnonymousClass |
显然,JVM 的加载顺序与 IDEA 收到的回调顺序是一致的。
确定 Bug
对比了一下 IDEA 可以正常断点的匿名内部类,加载顺序是先 top-level 类 -> 匿名内部类,也就是按照控制流的顺序加载的。
那这个问题是 IDEA 背,还是 JVM 呢?首先 JDB 在这种所谓异常加载顺序下依然可以正常设置断点,应该就是 IDEA 的问题,并且 IDEA 在 toggle 重复设置后又可以正常进入断点,显然是这部分的实现不够严谨,没有考虑在非正常控制流加载匿名内部类的情况。
但为什么会出现 top-level 类还未解析,就开始解析匿名内部类呢?按照代码控制流,这里是 new
指令触发了匿名内部类的加载解析, top-level 类也会因为 invokestatic
指令触发解析的。
难道是提前加载解析了?看了一下上下文的相关代码,果然发现端倪,由于该类需要做一些反射调用缓存,在到达控制流代码的时候已经提前扫包解析了,这也可以解释为什么其他匿名内部类的断点又可以进入。简化后的扫包初始化逻辑:
1 | private static void fakeScanClass() { |
扫包出来的类顺序是通过 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 导致设置断点失败。