为啥 Java 类的编译与空行不同?
Posted
技术标签:
【中文标题】为啥 Java 类的编译与空行不同?【英文标题】:Why does a Java class compile differently with a blank line?为什么 Java 类的编译与空行不同? 【发布时间】:2019-03-08 13:45:29 【问题描述】:我有以下 Java 类
public class HelloWorld
public static void main(String []args)
当我编译这个文件并在生成的类文件上运行 sha256 时,我得到了
9c8d09e27ea78319ddb85fcf4f8085aa7762b0ab36dc5ba5fd000dccb63960ff HelloWorld.class
接下来我修改了类并添加了一个这样的空行:
public class HelloWorld
public static void main(String []args)
我再次在输出上运行了一个 sha256,期望得到相同的结果,但我得到了
11f7ad3ad03eb9e0bb7bfa3b97bbe0f17d31194d8d92cc683cfbd7852e2d189f HelloWorld.class
我在this TutorialsPoint article 上看到:
仅包含空格的行,可能带有注释,称为空行,Java 完全忽略它。
所以我的问题是,既然 Java 忽略了空行,为什么两个程序的编译字节码不同?
即HelloWorld.class
中的0x03
字节被0x04
字节替换。
【问题讨论】:
请注意,编译器在生成类文件时没有义务是确定性的,即使它们通常是确定性的。见this question。默认情况下,Jar 文件是 not 可重现的,即即使编译 相同的 代码也会产生两个不同的 JAR。那是因为文件的顺序和时间戳不匹配。使用特定配置可以进行可重现的构建。 TutorialsPoint 声称 "Java 完全忽略" 空白行。 Section 3.4 of the Java Language Specification 另有说明。相信哪一个?... @skomisa 规范。 @GiacomoAlzetta 对于单个字节码文件,甚至没有指定的字节码形式。例如,成员的顺序是未指定的,因此如果编译器使用新的不可变Set
s 并在内部进行随机化,则它可能会在每次运行时产生不同的顺序。它还可以添加包含编译时间的自定义属性。等等……
@DioPhung 另一个教训:tutorialspoint 不是好的教程的可靠来源
【参考方案1】:
基本上,行号是为调试而保留的,因此如果您按照您的方式更改源代码,您的方法会从不同的行开始,并且编译后的类会反映差异。
【讨论】:
这也解释了为什么它在 OP 报告的字节中不同:end-of-transmission
代表 ASCII 码 4,end-of-text
代表 ASCII 码 3
为了通过实验证明这一点,我在编译时使用 -g:none
标志比较了 OP 源的类文件的哈希值(这会删除所有调试信息,请参阅 here)并在两者中得到相同的哈希值场景。
为了正式支持您的回答,来自Java Language Specification for Java SE 11 的第 3.4 节(“行终结符”):“Java 编译器接下来会划分 Unicode 的序列通过识别行终止符将字符输入到行中...行终止符定义的行可以确定 Java 编译器生成的行号".
这些行号的一个重要用途是如果抛出异常;它可以告诉你堆栈跟踪中异常的行号。【参考方案2】:
您可以使用javap -v
查看更改,这将输出详细信息。和其他已经提到的一样,区别在于行号:
$ javap -v HelloWorld.class > with-line.txt
$ javap -v HelloWorld.class > no-line.txt
$ diff -C 1 no-line.txt with-line.txt
*** no-line.txt 2018-10-03 11:43:32.719400000 +0100
--- with-line.txt 2018-10-03 11:43:04.378500000 +0100
***************
*** 2,4 ****
Last modified 03-Oct-2018; size 373 bytes
! MD5 checksum 058baea07fb787bdd81c3fb3f9c586bc
Compiled from "HelloWorld.java"
--- 2,4 ----
Last modified 03-Oct-2018; size 373 bytes
! MD5 checksum 435dbce605c21f84dda48de1a76e961f
Compiled from "HelloWorld.java"
***************
*** 50,52 ****
LineNumberTable:
! line 3: 0
LocalVariableTable:
--- 50,52 ----
LineNumberTable:
! line 4: 0
LocalVariableTable:
更准确地说,LineNumberTable
部分中的类文件不同:
LineNumberTable 属性是代码属性(第 4.7.3 节)的属性表中的可选可变长度属性。调试器可以使用它来确定代码数组的哪一部分对应于原始源文件中的给定行号。
如果一个 Code 属性的属性表中存在多个 LineNumberTable 属性,则它们可以按任意顺序出现。
在代码属性的属性表中,源文件的每一行可能有多个 LineNumberTable 属性。也就是说,LineNumberTable 属性可以一起表示源文件的给定行,而不必与源文件一一对应。
【讨论】:
【参考方案3】:除了用于调试的任何行号详细信息外,您的清单还可能存储构建时间和日期。每次编译时自然会有所不同。
【讨论】:
C# 也有这个问题;直到最近,编译器总是在生成的程序集中嵌入一个新的 GUID,这样您就可以保证两个构建不是二进制相同的,这样您就可以将它们区分开来! @EricLippert 如果两个构建仅在生成时间上有所不同(即相同的代码库),我们不应该将它们视为相同吗?使用现代 CI / CD 构建管道(Jenkins、TeamCity、CircleCI),我们将有一种方法来区分构建,但从应用程序的角度来看,部署具有相同代码库的新二进制文件似乎没有用。 @DioPhung 恰恰相反。您不希望两个 不同 构建具有相同的 GUID,因为这是系统可以决定使用哪一个的方式。所以每次生成一个新的 GUID 是最简单的;然后你会得到 Eric 描述为意外后果的副作用。 @vikingsteve 就像我说的那样,使用相同的 GUID 报告两个不同的构建会更没有帮助,然后将报告给系统作为相同的软件。这将导致任何类型的供应方案完全失败,因此 GUID 永远不会重复(在合理的概率内!)是关键任务。为相同源代码的两个不同版本使用不同的 GUID 最多只是一件微不足道的烦恼。因此,面对关键任务失败的场景,你认为有点无用的东西真的不重要。 @vikingsteve 二进制文件的 code 部分还是一样的(如果我理解的话,我不是 C# 开发人员),只是附加了一些元数据到二进制文件。【参考方案4】:“Java 忽略空白行”的假设是错误的。这是一个代码 sn-p,它的行为取决于方法 main
之前的空行数:
class NewlineDependent
public static void main(String[] args)
int i = Thread.currentThread().getStackTrace()[1].getLineNumber();
System.out.println((new String[]"foo", "bar")[((i % 2) + 2) % 2]);
如果main
之前没有空行,则打印"foo"
,但main
之前有一个空行,则打印"bar"
。
由于运行时行为不同,.class
文件必须不同,无论时间戳或其他元数据如何。
这适用于所有可以访问带有行号的堆栈帧的语言,不仅适用于 Java。
注意:如果编译时使用-g:none
(没有任何调试信息),则不会包含行号,getLineNumber()
总是返回-1
,程序总是打印"bar"
,不管数字是多少换行符。
【讨论】:
也可以打印Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: -1
。
@xehpuk 我获得-1
的唯一方法是使用-g:none
标志。有没有其他方法可以使用普通的javac
获得这个异常?
我猜只有-g
选项。还有-g:vars
和-g:source
会阻止LineNumberTable
的生成。以上是关于为啥 Java 类的编译与空行不同?的主要内容,如果未能解决你的问题,请参考以下文章