Java und Kompatibilität

Aus MimiPedia
Version vom 30. September 2023, 18:30 Uhr von Ullrich (Diskussion | Beiträge) (→‎Neue Klassen und Methoden)
(Unterschied) ← Nächstältere Version | Aktuelle Version (Unterschied) | Nächstjüngere Version → (Unterschied)

Eine wesentliche Eigenshaft war von Anfang an die Abwärts-Kompatibilität; nicht nur in Bezug auf den Source- sondern auch auf den Object-Code. Alles was mit einer früheren Java-Version geschrieben und gebaut wurde, war anfangs ohne Änderungen auf jeder späteren Java-Version kompilerbar und lauffähig (zumindest soweit ich mich daran erinnere).

Was das im Einzelnen bedeutet und in wiefern das heute noch Bestand hat ist Thema dieses Artikels.

Vorbetrachtungen

Was "Version" bedeutet

Das Java-System besteht aus mehreren Komponenten, die zu einem großen Paket -- den JDK oder dem JRE -- zusammengefaßt werden. Dieses Gesamtsystem wird Versioniert. Die "Java-Version" bezeichnet nun die Version aller im Paket enthaltenen Komponenten. Für uns wichtig sind hier:

  • der Compiler der aus dem Java-Quell-Code .class files erzeugt
  • die Bibliotheken, vorkompilierte Klassen die als .class files im Paket enthalten sind

JDK und JRE

Die JRE (Java Runtime Environment) enthält die JVM (Java Virtual Machine) die den kompilierten Java-Code ausführt und die kompilierten Java-Bibliotheken die für die Ausführung unserer Java-Anwendungen notwendig sind.

Der JDK (Java Developement Kit) enthält die JRE und zusätzlich die Tools die zur Erstellung der Anwendung erforderlich sind. Allem voran den Java Compiler, alles andere ist hier nicht von Bedeutung. Neben den .class-Files enthält der JDK auf den Quell-Code zu den Bibliotheken, führt aber immer die .claass files aus.


Was den Compiler anbelangt

Quell-Code-Kompatibilität

Das bezieht sich auf die Frage, inwiefern Code der für Java X geschrieben wurde mit Java Y gebaut werden kann. Grundsätzlich gilt zunächst einmal

Für jedes X <= Y gilt: Wenn der Code mit Java X baut, baut er auch mit Java Y

Das Gegenteil gilt dementsprechend nicht. Nutzt der Code etwa λ-Ausdrücke und damit einen der Operatoren :: oder ->, so ist er mit Java-Versionen vor 8 nicht kompilierbar.

Die einzige Ausnahme von dieser Regel ist die Verwendung des einzelnen Underscore oder Unterstrich _ der bis einschließlich Java 8 als Bezeichner erlaubt war. Diese Verwendung führt ab Java 9 zu einem Fehler.

Mit Java 21 kommt der Underscore tatsächlich wieder zurück (das war wohl der Grund für das Verbot in Java 9). Diesmal bezeichnet er "unbenannte" Variablen, zu Beispiel Paraneter in λ-Ausdrücken, die im Ausdruck selbst nicht verwendet werden. Es kann also passieren, daß alter Java-8-Code mit Java 21 bricht.

Object-Code-Kompatibilität

Unter Object-Code versteht man alles, was beim Kompilier-Vorgang aus dem Compiler kommt. Im Falle von Java also vor allem die .class-Files.

Kompatibilität bezieht sich also auf die Frage, ob .class files die mit Java X erzeugt wurden mit Java Y verwendet werden können. Und auch hier gilt grundsätzlich:

Für jedes X <= Y gilt: Wenn das .class files mit Java X gebaut wurde, kann es mit Java Y verwendet werden

Das ist eine extrem mächtige Eigenschaft, die die Verteilung von binary libraries über Maven Central so einfach macht. Wenn man den Code auf eine neue Java-Version heben möchte, braucht man dafür nicht sämtliche verwendeten Bibliotheken austauschen. Man wird das sicherlich -- früher oder später -- tun, aber für die Migration ist das nicht unmittelbar erforderlich und das verkürzt die Zeit bis zum ersten lauffähigen "Migrat" ganz erheblich.

Auch hier gilt das Gegenteil nicht. Es gilt sogar in noch viel strengerem Maße als beim Quell-Code. Da jedes .class file eine Versions-Nummer enthält die der Java-Version des verwendeten Compilers entspricht und die mit jeder Java-Version hochgezählt wird, sind Kompilate neuerer Java-Versionen niemals in Java-Systemen älterer Versionen verwendbar.

Wie man dieser Auflistung entnehen kann, haben {{java|.class} files die mit Java 8 gebaut wurden die Version 52.0 während Java 11 die Versions-Nummer 55.0 erzeugt.

Es ist dabei völlig unerheblich, welche Java-Features der zugrundeliegende Java-Code enthält.

Cross-Compiling

Der klassische Cross Compiler arbeitet auf einer Rechner-Architektur -- zum Beispiel einem Windows-Rechner -- und erzeugt Object-Code der auf einer anderen Architektur -- zum Beispiel auf einem MacOS-Rechner -- läuft. Für Java ist das ohne Bedeutung weil jeder Java-Object-Code der auf einer beliebigen Maschine gebaut wurde auf jeder beliebigen anderen Maschine läuft.

Wir verstehen daher -- zweckmäßigerweise -- unter cross compiling im Java-Kontext:

Mit Java X wird ein .class file für Java Y erzeugt, wobei X > Y ist

Das Erzeugen zukünftiger Java-Versionen wäre wenig sinnvoll und geht schon deshalb nicht, weil der Compiler nicht in die Zukunft schauen kann. So haben .class files für Java 1.0 die Versionsnummer 45.3 während die für Java 1.1 die Nummer 45.3 haben. Man kann also unnmöglich sagen was werden wird.

Wir könne also beispielsweise mit Java 11 ein .class file bauen, das mit Java 8 verwendbar ist.

Die Möglichkeit des cross compiling bietet der Java-Compiler seit der Version 1.5.

Was die Bibliotheken anbelangt

Der Einfachheit halber machen wir hier JDK und JRe keinen Unterschied -- was bei Betrachtung der enthaltenen Bibliotheken auch keine Rolle spielt.

Mit dem nackten Java-Compiler eine anspruchsvolle Anwendung zu bauen wäre extrem aufwändig, denn die vielen Bibliotheken die man dafür benötigt selbst zu schreiben ist ein ermüdendes Geschäft und das riesige Sortiment an Java-Bibliotheken macht Java so vielseitig.

Der JDK bringt bereits eine große Menge wichtiger Bibliotheken mit und es sind im Laufe der Zeit immer mehr geworden.

Das Verschwinden von Bibliotheken

Solange die Veränderung nur in der Vergößerung - das Hinzufügen neuer Klassen und Methoden -- besteht, gibt es beim Umstieg auf eine höhere Java-Version keine Probleme weil der neue JDK alles enthält was der ältere JDK auch dabei hatte.

Mit Version 8 begann Oracle damit, den JDK auszudünnen und so viel Code wie möglich herauszulösen. Das bedeutet, daß ein Umstieg von einer älteren auf eine neuere Version mit -- teilweise erheblichem -- Aufwand verbunden ist. In den meisten Fällen ist es aber damit getan, die fehlenden Bibliotheken -- z.B. aus Maven Central -- zu beschaffen und in den class path aufzunehmen. Der Code selbst muß dabei in der Regel nicht angefaßt werden.

Was auch schon früher gemacht wurde, ist das Beseitigen abgekündigter Features und der zugehörigen Bibliotheken. Hier muß der Code üblicherweise nachbearbeitet werden, weil die herausgefallenen Klassen nicht mehr verwendet werden sollten. Dabei ist es wichtig daruf zu achten welche Klassen und Methoden abgekündigt werden. In der Regel geschieht das durch die @Deprecated-Annotation.

Neue Klassen und Methoden

Neuere JDK-Versionen enthalten erwartungsgemäß immer auch neue Klassen und Methoden für die neuen Features. Da sie im alten JDK unbekannt waren, sind sie im Code nicht anzutreffen, führen also nicht zu Problemen.

Es giebt jedoch ein Problem, das aus der Diskrepanz zwischen Entwicklung und Betrieb enstehen kann. Stellen wir uns dazu die folgende Situation vor:

Die Software läuft im Betrieb auf dem JRE in Version 8, während die Entwicklung den JDK 11 verwendet.

Das ist gängige Praxis wenn die Umstellung der Java-Version bereits geplant ist. Der existierende Java-8-Code wird mit dem JDK 11 kompiliert, die Bibliotheken werden ersetzt und die Entwicklung geht nun mit dem JDK 11 weiter. Das funktioniert, weil die für den Betrieb benötigten .class-Files per Cross-Compiling erzeugt werden können -- soweit so gut.

Ein Entwickler schreibt nun folgende Methode:

public boolean leer(String value) {
        Optional<String> x = Optional.ofNullable(value);
        return x.isEmpty();
    }

Der Code kompiliert ohne Probleme, die Software wird in betrieb genommen und -- scheitert. Warum?

Das Maven-Compiler-Pluging war folgendermaßen konfiguriert:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.7.0</version>
    <configuration>
      <target>8</target>
    </configuration>
  <plugin>

Der Compiler generiert pflichtgemäß Java-8-kompatiblen Code, aber ein Blick in die Optional-Klasse des JDK 11 zeigt:

/**
    * If a value is  not present, returns {@code true}, otherwise
    * {@code false}.
    *
    * @return  {@code true} if a value is not present, otherwise {@code false}
    * @since   11
    */
    public boolean isEmpty() {
        return value == null;
    }

Die Java-11-Version der Klasse ist tatsächlich Java-8-kompatibel, die Methode isEmpty ist jedoch nicht in der Version des JDK 8 enthalten. Deshalb läuft die Software im Betrieb auf einen Fehler.

Seit Java 9 gibt es die Compiler-Option release, die bei diesem Problem bereits im Kompilier-Vorgang einen Fehler erzeugt. Sie sorgt dafür daß der Compiler sicherstellt, daß der Code keine Features verwendet werden die in der Ziel-Version noch nicht verfügbar sind. In der Maven-plugin-Konfiguration schreibt man daher:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.7.0</version>
    <configuration>
      <release>8</release>
    </configuration>
  </plugin>

Wichtig beim Kompilieren ist, daß die Klassen auch wirklich neu gebaut werden. Also durch Löschen des target-Verzeichnisses oder durch ein mvn clean. Andernfalls wird der Compiler nicht erkennen, daß die Klassen neu zu Bauen sind.