源代码,字节码,调试
阅读其他语言: English Español 日本語 한국어 Português
当调试Java程序时,开发者往往会有一种印象, 认为他们是在直接与源代码交互。 这并不奇怪 - Java的工具链在隐藏复杂性方面做得如此出色,以至于几乎让人觉得源代码在运行时就存在。
如果你刚开始学Java,你可能会记得那些图表,它们显示编译器如何将源代码转换为字节码,然后由JVM来执行。你可能也会疑惑:如果这是事实,那么我们为什么要检查并逐步执行源代码而不是字节码?JVM如何知道我们的源代码呢?
这篇文章与我以前关于调试的文章有些不同。而不是关注如何调试一个特定的问题,比如不响应的应用程序或内存泄漏,它探讨了Java和调试器在幕后是如何工作的。坚持下去 - 像往常一样,包含了一些实用的技巧。

字节码
让我们从一个快速的回顾开始。在Java书籍和指南中找到的图表确实是正确的 - JVM执行的是字节码。
考虑下面的类作为一个例子:
package dev.flounder;
public class Calculator {
int sum(int a, int b) {
return a + b;
}
}
当编译时,sum()
方法将转变为以下的字节码:
int sum(int, int);
descriptor: (II)I
flags: (0x0000)
Code:
stack=2, locals=3, args_size=3
0: iload_1
1: iload_2
2: iadd
3: ireturn
你可以使用JDK中包含的javap -v
命令查看你的类的字节码。
如果你使用IntelliJ IDEA,你也可以在IDE中做到这一点:在构建项目后,选择一个类,然后点击视图 (View) | 显示字节码 (Show Bytecode)。
由于类文件是二进制的,引用他们的原始内容并不会有所帮助。
为了易读性,本文的示例遵循javap -v
输出的格式。
字节码由一系列紧凑的平台无关的指令构成。在上面的例子中:
iload_1
和iload_2
将变量加载到操作数堆栈上iadd
将操作数堆栈的内容相加,将一个结果值留在堆栈上ireturn
从操作数堆栈返回值
除了指令,字节码文件还包含有关常量、参数数量、局部变量和操作数堆栈深度的信息。这就是JVM执行用JVM语言(如Java、Kotlin或Scala)编写的程序所需要的所有信息。
调试信息
由于字节码和你的源代码看起来完全不同,在调试过程中引用它将无法高效执行。因此,Java调试器的接口—比如JDB(与JDK一起提供的控制台调试器)或IntelliJ IDEA中的调试器—显示的是源代码而不是字节码。这使你可以不用思考正在执行的底层字节码,就可以调试你编写的代码。
例如,你与JDB的交互可能像这样:
Initializing jdb ...
> stop at dev.flounder.Calculator:5
Deferring breakpoint dev.flounder.Calculator:5.
It will be set after the class is loaded.
> run
run dev/flounder/Main
Set uncaught java.lang.Throwable
Set deferred uncaught java.lang.Throwable
VM Started: Set deferred breakpoint dev.flounder.Calculator:5
Breakpoint hit: "thread=main", dev.flounder.Calculator.sum(), line=5 bci=0
> locals
Method arguments:
a = 1
b = 2
IntelliJ IDEA将在编辑器中和调试 (Debug)工具窗口中显示与调试相关的信息:


如你所见,两个调试器都使用了正确的变量名,并引用了我们上面的代码片段中的有效行。
由于运行时不能访问源文件,它必须从其他地方收集这些数据。这就是调试信息发挥作用的地方。调试信息(也被称为调试符号)是紧凑的数据,将字节码与应用程序的源代码关联起来。它在编译过程中被包含在.class文件中。
调试信息有三种类型:
在接下来的章节中,我将简单解释每种类型的调试信息以及调试器如何使用它。
行号
行号信息存储在字节码文件的LineNumberTable属性中,看起来像这样:
LineNumberTable:
line 5: 0
line 6: 2
上面的表告诉调试器以下信息:
- 第5行包含偏移量为0的指令
- 第6行包含偏移量为2的指令
这种类型的调试信息帮助外部工具,如调试器或者性能分析器,追踪程序在源代码中执行的准确位置。
重要的是,行号信息也被用于异常堆栈跟踪中的源引用。在下面的例子中,我从我的其他教程中编译了代码,包含和不包含行号信息。这是由结果可执行文件生成的堆栈跟踪:
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:672)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at dev.flounder.Airports.parse(Airports.java:53)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
at dev.flounder.Airports.main(Airports.java:39)
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:672)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at dev.flounder.Airports.parse(Airports.java)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
at dev.flounder.Airports.main(Airports.java)
不包含行号信息的可执行文件生成的堆栈跟踪缺少了对应我项目代码的调用的行号。标准库和依赖项的调用仍然包含行号,因为它们已经被单独编译,没有受到影响。
除了堆栈跟踪,你可能会遇到一个涉及行号的类似情况,例如,在IntelliJ IDEA的帧 (Frames)选项卡中:


所以,如果你看到-1
而不是实际的行号,你不喜欢这样,确保你的程序是编译成包含行号信息的。
变量名
像行号信息一样,变量名被存储在类文件中。 我们示例的变量表如下所示:
LocalVariableTable:
Start Length Slot Name Signature
0 4 0 this Ldev/flounder/Calculator;
0 4 1 a I
0 4 2 b I
它包含以下信息:
- Start: 这个变量的范围开始的字节码偏移量。
- Length: 这个变量保持在范围内的指令数量。
- Slot: 查找此变量存储的索引。
- Name: 变量的名称,和源代码中出现的名称一样。
- Signature: 变量的数据类型,用Java的类型签名表示法表示。
如果从调试信息中缺少变量,那么一些调试器的功能可能无法按预期工作,
你将看到slot_1
、slot_2
等,而不是实际的变量名。


源文件名称
这种类型的调试信息表明哪个源文件被用来编译类。就像行号信息一样,它存在于类文件中影响了不仅仅是外部工具,还有你的程序生成的堆栈跟踪。
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:672)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at dev.flounder.Airports.parse(Airports.java:53)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
at dev.flounder.Airports.main(Airports.java:39)
Exception in thread "main" java.lang.NumberFormatException: For input string: ""
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:672)
at java.base/java.lang.Integer.parseInt(Integer.java:778)
at dev.flounder.Airports.parse(Unknown Source)
at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
at dev.flounder.Airports.main(Unknown Source)
如果没有源文件名,相应的堆栈跟踪调用将被标记为Unknown Source
。
编译器标记
作为开发者,你可以控制是否在你的可执行文件中包含调试信息,并且,如果要包含,可以选择包含哪种类型。你可以通过使用-g
编译器参数来管理这个,就像这样:
javac -g:lines,vars,source
下面是语法:
命令 | 结果 |
javac | 使用行号和源文件名编译应用程序(大多数编译器的默认设置) |
javac -g | 使用所有可用的调试信息编译应用程序:行号,变量和源文件名 |
javac -g:lines,source | 使用指定类型的调试信息编译应用程序 - 在此示例中是行号和源文件名 |
javac -g:none | 编译不带调试信息的应用程序 |
默认设置可能在编译器之间有所不同。其中有些会完全排除调试信息,除非另行指定。
如果你使用构建系统,比如Maven或Gradle,你可以通过编译器参数传递相同的选项。例如:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<compilerArgs>
<arg>-g:vars,lines</arg>
</compilerArgs>
</configuration>
</plugin>
tasks.compileJava {
options.compilerArgs.add("-g:vars,lines")
}
为什么要移除调试信息?
正如我们刚刚看到的,调试符号能够启用调试过程,这在开发过程中是方便的。因此,调试符号通常被包含在开发构建中。在生产构建中,它们通常被排除;然而,这最终取决于你正在处理的项目类型。
下面是一些你可能要考虑的问题:
安全
由于调试器可以被用来篡改你的程序,包含调试信息使你的应用稍微更容易被黑客攻击和逆向工程,这对一些应用程序可能是不希望的。
虽然调试信息的缺失可能会让使用调试器干扰你的程序变得有点困难,但它不能全面保护它。即使有部分或缺少的调试信息,调试仍然是可能的,所以这个因素不足以阻止一个决心坚定的个体访问你的程序的内部。因此,如果你关注逆向工程的风险,你应该采取额外的措施,比如源代码混淆。
可执行文件的大小
一个可执行文件包含的信息越多,它就越大。 究竟增大多少取决于各种因素。一个特定的类文件的大小可能很容易被指令数量和常量池的大小所主导,使得提供一个通用的估算是不切实际的。然而,为了说明差异可能很大,我用我们之前用来比较堆栈跟踪的Airports.java进行了实验。结果是,不带调试信息的为4,460字节,带调试信息的为5,664字节。
在大多数情况下,包含调试符号是没有问题的。然而,如果可执行文件的大小是一个问题,比如在嵌入式系统中经常是这样,你可能会希望从你的二进制文件中排除调试符号。
为调试添加源文件
通常,所需的源文件位于你的项目内,因此IDE将没有问题找到它们。 然而,还有一些不太常见的情况 - 例如,当调试所需的源代码在你的项目外,比如当你步入你的代码使用的库时。
在这种情况下,你需要手动添加源文件:要么将它们放在源根下,要么将它们指定为依赖项。在调试过程中,IntelliJ IDEA会自动检测并匹配这些文件与JVM正在执行的类。
当项目丢失时
在大多数情况下,你将在同一个IDE中构建、启动和调试一个应用程序,使用原始项目。但是,如果你只有一些源文件,而项目本身丢失了呢?
下面是一个最简单的调试设置,将可以解决问题:
- 创建一个空的Java项目
- 在源根下添加源文件或将它们指定为依赖项
- 用调试代理启动目标应用程序。在Java中,这通常是通过添加一个VM选项来完成的,比如:
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005
- 创建一个远程JVM调试运行配置,具有正确的连接详细信息。使用此运行配置将调试器附加到目标
有了这种设置,你可以在不访问原始项目的情况下调试程序。IntelliJ IDEA将会匹配可用的源代码与运行时类,让你在调试会话中使用它们。这样,即使一个项目或库类就给你一个调试的入口点。
对于一个动手示例,查看 Debugger.godMode() – 用调试器破解JVM应用程序, 我们在这里使用这种技术来更改程序的行为,而无需访问其源代码
源代码不匹配
你在调试过程中可能会遇到一个令人困惑的情况,即你的应用程序似乎在一个空白行上挂起,或者 帧 (Frames) 选项卡中的行号与编辑器中的行号不匹配:


这种情况发生在调试反编译的代码(我们将在另一篇文章中讨论)或者源代码与JVM正在执行的字节码不完全匹配。
由于字节码和特定源文件之间的唯一链接是文件和它的类的名称,调试器必须依赖这个信息,辅以一些启发式方法。这对大多数情况都非常有效;然而文件在磁盘上的版本可能与用于编译应用程序的版本不同。在部分匹配的情况下,调试器将识别出差异并试图调和它们,而不是快速失败。根据差异的程度,这可能会有所帮助,例如,如果你唯一的源代码并不是最匹配的。
在幸运的情况下,如果你在其他地方有源代码的确切版本,你可以通过向项目添加它们并重新运行调试会话来解决这个问题。
结论
在本文中,我们探讨了源文件、字节码和调试器之间的关系。虽然对日常编程来说并不严格必需,但了解底层发生的情况可以帮助你更深入地理解生态系统,有时甚至可以帮你解决非标准情况和配置问题。我希望你会发现这个理论和技巧有用!
这个系列还有很多主题要讨论,所以请继续关注下一篇。如果有任何特定的内容你想看到,或者如果你有任何想法和反馈,我很想听到你的意见!