调试器运行缓慢的故障排除
阅读其他语言: English Español 한국어 Português
通常情况下,Java 调试器带来的开销是微不足道的。然而,在某些特定情况下,它仍然可能产生显著的运行时成本。在特别不走运的情况下,调试器甚至可能导致 VM 完全冻结。
让我们一起探究这些问题背后的原因及相应的解决方案。
我使用的是 IntelliJ IDEA。 其他 IDE 中的具体细节可能有所不同,且文中提及的一些功能可能在那些 IDE 中不可用。 然而,一般的故障排查策略仍然是适用的。
诊断原因
在探索解决方案之前,先明确问题所在是明智的。调试器导致应用变慢的最常见原因包括:
- 方法断点
- 过于频繁地评估表达式
- 评估的表达式计算负担过重
- 高延迟的远程调试
IntelliJ IDEA 在这一步中通过提供详尽的统计信息消除了猜测的需要,这些信息位于调试器的开销 (Overhead) 标签下:
要访问它,请从布局设置 (Layout Settings) 标签中选择开销 (Overhead)。它会显示断点和调试器特性列表,并针对每个断点或特性显示其被使用的次数及执行所花费的时间。
如果你决定临时关闭某个资源消耗较大的功能,可以通过在开销 (Overhead) 标签中取消相应复选框来实现。
一旦我们确定了慢速的源头,接下来就看看最常见的原因及其解决方法。
方法断点
在 Java 中使用方法断点时,根据所使用的调试器,你可能会遇到性能下降的问题。这是因为Java Debug Interface提供的对应功能显著较慢。
因此,IntelliJ IDEA 提供了模拟的方法断点。它们像实际的方法断点一样工作,但速度更快。该功能背后有一个技巧:不是设置实际的方法断点,而是由 IDE 将它们替换为项目中该方法所有实现上的常规行断点。
默认情况下,IntelliJ IDEA 中的所有方法断点都是模拟的:
如果你使用的调试器没有此功能,并且你在使用方法断点时遇到性能问题,你可以手动做同样的操作。虽然访问所有方法实现可能很繁琐,但在调试过程中可能会为你节省时间。
‘处理仿真方法断点的类’耗时过长
如果一个方法有大量实现,对其设置方法断点可能会需要一些时间。在这种情况下,IntelliJ IDEA 和 Android Studio 会显示一个对话框,提示“处理仿真方法断点的类” (Processing classes for emulated method breakpoints)。
如果这对你来说耗时过长,可以考虑使用行断点代替。或者,为了在运行时性能上做出妥协,你可以在断点设置中取消勾选模拟 (Emulated)。
热点代码中的条件断点
在热点代码中设置条件断点可能会极大地拖慢调试会话,具体取决于这段代码被执行的频率。
考虑以下示例:
public class Loop {
public static final int ITERATIONS = 100_000;
public static void main(String[] args) {
var start = System.currentTimeMillis();
var sum = 0;
for (int i = 0; i < ITERATIONS; i++) {
sum += i;
}
var end = System.currentTimeMillis();
System.out.println(sum);
System.out.printf("The loop took: %d ms\n", end - start);
}
}
const val ITERATIONS = 100_000
fun main() = measureTimeMillis {
var sum = 0
for (i in 0 until ITERATIONS) {
sum += i
}
println(sum)
}.let { println("The loop took: $it ms") }
让我们在 sum += i
处设置一个断点,并指定条件为 false
。这意味着调试器实际上不应该在这个断点处停止。然而,每次执行到这行代码时,调试器都必须评估 false
。
在我的测试中,带有和不带断点运行这段代码的结果分别是 39 毫秒
和 29855 毫秒
。值得注意的是,即使只有 10 万次迭代,差异仍然非常大!
评估一个看似简单的条件如 false
会消耗如此多时间可能令人惊讶。这是因为消耗的时间不仅包括表达式结果的计算,还包括处理调试器事件和与调试器前端的通信。
解决方案很简单。你可以直接将条件集成到应用程序代码中:
public class Loop {
public static final int ITERATIONS = 100_000;
public static void main(String[] args) {
var start = System.currentTimeMillis();
var sum = 0;
for (int i = 0; i < ITERATIONS; i++) {
if (false) { // condition goes here
System.out.println("break") // breakpoint goes here
}
sum += i;
}
var end = System.currentTimeMillis();
System.out.println(sum);
System.out.printf("The loop took: %d ms\n", end - start);
}
}
fun main() = measureTimeMillis {
var sum = 0
for (i in 0 until ITERATIONS) {
if (false) { // condition goes here
println("break") // breakpoint goes here
}
sum += i
}
println(sum)
}.let { println("The loop took: $it ms") }
这样设置后,VM 直接执行条件的代码,并可能对其进行优化。相反,只有当命中断点时,调试器才会介入。虽然在大多数情况下并非必需,但这种改变在你需要在热点路径中间有条件地暂停程序时可以为你节省时间。
所述技术在具有可用源代码的类上工作得非常好。然而,对于编译后的代码,比如库,这个技巧可能更难实现。这是一个特殊用例,我将在单独的讨论中进行介绍。
隐式评估
除了那些你需要自己指定表达式的特性,比如 断点条件 和 观察点, 还有一些特性会为你隐式地评估表达式。
以下是一个例子:
每当程序暂停时,调试器会显示当前上下文中可用的变量值。 某些类型可能具有复杂的结构,难以查看和导航。 为了方便起见,调试器使用特殊的表达式(称为类型呈现器)对它们进行转换。
渲染器可以很简单,如 toString()
,也可以更复杂,如那些转换集合内容的。它们可以是内置的,也可以是自定义的。
IntelliJ IDEA的调试器在显示数据的方式上非常灵活。 它甚至允许你通过注解来指定渲染器配置, 以便在多个贡献者共同工作于同一项目时提供一致的类表示。
要了解更多关于设置数据显示格式的信息,请参考 IntelliJ IDEA文档。
通常情况下,由调试渲染器带来的开销是可以忽略不计的,
但影响最终还是取决于特定的使用场景。
确实,如果你的某些 toString()
实现包含了挖掘加密货币的代码,
那么调试器在显示那个类的 toString()
值时将会很困难!
如果渲染某个特定类证明是缓慢的,你可以 关闭相应的渲染器。 作为一种更灵活的选择, 你可以使渲染器变为按需。 按需渲染器仅在你明确请求显示其结果时才会执行。
远程调试会话中的高延迟
从技术角度来看,调试远程应用程序与本地调试会话没有区别。 无论是哪种方式,连接都是通过套接字建立的——我们在此讨论中排除了 共享内存模式—— 并且调试器甚至不知道宿主机JVM在哪里运行。
然而,对于远程调试来说,一个可能区分的因素是网络延迟。 某些调试器功能每次使用时都会执行多次网络往返。 结合高延迟,这可能导致显著的性能下降。
如果真是这样,考虑在本地运行项目,因为这可能会节省时间。 否则,你可能会受益于暂时关闭一些高级功能。
结论
在这篇文章中,我们学习了如何解决导致调试器变慢的最常见问题。 虽然有时IDE会为你处理这些问题,但我认为理解底层机制是很重要的。 这会让你在日常调试中更加灵活、高效且富有创造力。
希望你发现这些技巧和窍门有用。 一如既往,你的反馈非常宝贵! 欢迎通过 X, LinkedIn, 或Telegram联系我。
愉快地调试!