Warum wird eine Java-Klasse mit einer Leerzeile anders kompiliert?

207

Ich habe die folgende Java-Klasse

public class HelloWorld {
  public static void main(String []args) {
  }
}

Wenn ich diese Datei kompiliere und ein sha256 für die resultierende Klassendatei ausführe, erhalte ich

9c8d09e27ea78319ddb85fcf4f8085aa7762b0ab36dc5ba5fd000dccb63960ff  HelloWorld.class

Als nächstes habe ich die Klasse geändert und eine leere Zeile wie folgt hinzugefügt:

public class HelloWorld {

  public static void main(String []args) {
  }
}

Wieder lief ich einen sha256 auf dem Ausgang und erwartete das gleiche Ergebnis, aber stattdessen bekam ich

11f7ad3ad03eb9e0bb7bfa3b97bbe0f17d31194d8d92cc683cfbd7852e2d189f  HelloWorld.class

Ich habe diesen TutorialsPoint-Artikel gelesen dass:

Eine Zeile, die nur Leerzeichen enthält, möglicherweise mit einem Kommentar, wird als Leerzeile bezeichnet, und Java ignoriert sie vollständig.

Meine Frage ist also, da Java leere Zeilen ignoriert, warum unterscheidet sich der kompilierte Bytecode für beide Programme?

Der Unterschied in HelloWorld.classeinem 0x03Byte wird nämlich durch ein 0x04Byte ersetzt.

KNejad
quelle
45
Beachten Sie, dass der Compiler bei der Erstellung von Klassendateien nicht deterministisch sein muss, obwohl dies normalerweise der Fall ist. Siehe diese Frage . Jar-Dateien sind standardmäßig nicht reproduzierbar, dh selbst das Kompilieren desselben Codes führt zu zwei verschiedenen JARs. Dies liegt daran, dass die Reihenfolge der Dateien und die Zeitstempel nicht übereinstimmen. Reproduzierbare Builds sind mit spezifischer Konfiguration möglich.
Giacomo Alzetta
22
TutorialsPoint behauptet, dass "Java" Leerzeilen völlig ignoriert . In Abschnitt 3.4 der Java-Sprachspezifikation wird etwas anderes angegeben. Welches zu glauben? ...
Skomisa
37
@skomisa Die Spezifikation.
wizzwizz4
4
@GiacomoAlzetta Es gibt nicht einmal ein angegebenes Bytecode-Formular für eine einzelne Bytecode-Datei. Beispielsweise ist die Reihenfolge der Mitglieder nicht angegeben. Wenn der Compiler die neuen unveränderlichen Sets intern mit Randomisierung verwendet, kann dies bei jedem Lauf zu einer anderen Reihenfolge führen. Es kann auch ein benutzerdefiniertes Attribut hinzugefügt werden, das die Kompilierungszeit enthält. Und so weiter ...
Holger
15
@DioPhung eine weitere Lektion gelernt: Tutorialspoint ist keine zuverlässige Quelle für gute Tutorials
jwenting

Antworten:

331

Grundsätzlich werden Zeilennummern für das Debuggen beibehalten. Wenn Sie also Ihren Quellcode so ändern, wie Sie es getan haben, beginnt Ihre Methode in einer anderen Zeile und die kompilierte Klasse spiegelt den Unterschied wider.

Federico klez Culloca
quelle
11
Das erklärt auch, warum es sich in den vom OP gemeldeten Bytes unterscheidet: end-of-transmissionsteht für den ASCII-Code 4 und end-of-textsteht für den ASCII-Code 3
Ferrybig
160
Um dies experimentell zu beweisen, habe ich die Hashes der Klassendateien der OP-Quelle -g:nonebeim Kompilieren mit dem Flag verglichen (wodurch alle Debugging-Informationen entfernt werden, siehe hier ) und in beiden Szenarien den gleichen Hash erhalten.
Captain Man
14
Zur formalen Unterstützung Ihrer Antwort aus Abschnitt 3.4 ( "Zeilenabschlusszeichen" ) der Java-Sprachspezifikation für Java SE 11 : "Ein Java-Compiler unterteilt als Nächstes die Folge von Unicode-Eingabezeichen in Zeilen, indem er Zeilenabschlusszeichen erkennt ... Die definierten Zeilen durch Zeilenterminatoren können die von einem Java-Compiler erzeugten Zeilennummern bestimmen " .
Skomisa
4
Eine wichtige Verwendung dieser Zeilennummern ist, wenn eine Ausnahme ausgelöst wird. Es kann Ihnen die Zeilennummer der Ausnahme in der Stapelverfolgung mitteilen.
Gparyani
114

Sie können die Änderung sehen, indem Sie javap -vausführliche Informationen ausgeben. Wie bei anderen bereits erwähnten wird der Unterschied in Zeilennummern liegen:

$ 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:

Genauer gesagt unterscheidet sich die Klassendatei im LineNumberTableAbschnitt:

Das LineNumberTable-Attribut ist ein optionales Attribut variabler Länge in der Attributtabelle eines Code-Attributs (§4.7.3). Es kann von Debuggern verwendet werden, um zu bestimmen, welcher Teil des Code-Arrays einer bestimmten Zeilennummer in der ursprünglichen Quelldatei entspricht.

Wenn in der Attributtabelle eines Code-Attributs mehrere LineNumberTable-Attribute vorhanden sind, können sie in beliebiger Reihenfolge angezeigt werden.

In der Attributtabelle eines Codeattributs kann es mehr als ein LineNumberTable-Attribut pro Zeile einer Quelldatei geben. Das heißt, LineNumberTable-Attribute können zusammen eine bestimmte Zeile einer Quelldatei darstellen und müssen mit Quellzeilen nicht eins zu eins sein.

Karol Dowbecki
quelle
57

Die Annahme, dass "Java Leerzeilen ignoriert", ist falsch. Hier ist ein Code-Snippet, das sich je nach Anzahl der Leerzeilen vor der Methode unterschiedlich verhält 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]);
  }
}

Wenn vorher keine leeren Zeilen vorhanden sind main, wird gedruckt "foo", aber mit einer leeren Zeile zuvor mainwird gedruckt "bar".

Da das Laufzeitverhalten unterschiedlich ist, die .classDateien müssen unterschiedlich sein, unabhängig von irgendwelchen Zeitstempeln oder anderen Metadaten.

Dies gilt für jede Sprache, die Zugriff auf die Stapelrahmen mit Zeilennummern hat, nicht nur für Java.

Hinweis: Wenn es mit -g:none(ohne Debugging-Informationen) kompiliert wurde , werden die Zeilennummern nicht enthalten, werden getLineNumber()immer zurückgegeben -1und das Programm druckt immer "bar", unabhängig von der Anzahl der Zeilenumbrüche.

Andrey Tyukin
quelle
11
Es kann auch gedruckt werden Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: -1.
Xehpuk
1
@ xehpuk Der einzige Weg, einen -1zu bekommen, war die Verwendung der -g:noneFlagge. Gibt es eine andere Möglichkeit, diese Ausnahme mit normal zu erhalten javac?
Andrey Tyukin
3
Ich denke nur mit der -gOption. Es gibt auch -g:varsund -g:sourcewas die Erzeugung der verhindert LineNumberTable.
Xehpuk
14

Neben den Details zur Zeilennummer für das Debuggen kann Ihr Manifest auch die Erstellungszeit und das Erstellungsdatum speichern. Dies wird natürlich jedes Mal anders sein, wenn Sie kompilieren.

Graham
quelle
14
C # hat auch dieses Problem; Bis vor kurzem hat der Compiler immer eine neue GUID in die generierte Assembly eingebettet, sodass Sie garantiert sind, dass zwei Builds nicht binär identisch sind, sodass Sie sie voneinander unterscheiden können!
Eric Lippert
3
@EricLippert Wenn zwei Builds sich nur durch ihre generierte Zeit unterscheiden (dh identische Codebasis), sollten wir sie dann nicht als gleich behandeln? Mit der modernen CI / CD-Build-Pipeline (Jenkins, TeamCity, CircleCI) können wir zwischen Builds unterscheiden. Aus Anwendungssicht erscheint die Bereitstellung neuerer Binärdateien mit identischer Codebasis jedoch nicht sinnvoll.
Dio Phung
2
@DioPhung Es ist umgekehrt. Sie möchten nicht, dass zwei verschiedene Builds dieselbe GUID haben, da das System auf diese Weise entscheiden kann, welche verwendet werden soll. Daher ist es am einfachsten, jedes Mal eine neue GUID zu generieren. und dann bekommt man den Nebeneffekt, den Eric als unbeabsichtigte Folge beschreibt.
Graham
3
@vikingsteve Wie ich bereits sagte, wäre es noch weniger hilfreich, wenn zwei verschiedene Builds mit derselben GUID gemeldet würden, die dann als dieselbe Software an das System gemeldet würden. Dies würde zu einem Totalausfall jeglicher Art von Bereitstellungsschema führen. Daher ist es von entscheidender Bedeutung, dass GUIDs niemals dupliziert werden (mit angemessener Wahrscheinlichkeit!). Unterschiedliche GUIDs für zwei separate Builds desselben Quellcodes zu haben, ist höchstens ein trivialer Ärger. Angesichts eines unternehmenskritischen Fehlerszenarios ist das, was Sie für etwas wenig hilfreich halten, wirklich nicht von Bedeutung.
Graham
4
@vikingsteve Der Codeteil der Binärdatei ist immer noch derselbe (wenn ich verstehe, bin ich kein C # -Entwickler), es sind nur einige Metadaten, die an die Binärdatei angehängt sind.
Captain Man