„Codes like a class, works like a primitive“

Im Fokus: Projekt Valhalla

Rodion Alukhanov, Alexander Peters, Vadym Kazulkin

©Shutterstock / drumdredd777

Projekt Valhalla [1] ist ein sehr umfangreiches Projekt, das Oracle bereits im Jahr 2014 gestartet hat. Aktuell gibt es bereits den zweiten Prototyp, am dritten wird gearbeitet. In diesem Artikel erläutern wir die Beweggründe hinter dem Projekt sowie Lösungsansätze und den aktuellen Stand.

Ziel des Projekts Valhalla ist die Bereitstellung einer JVM-Infrastruktur für die Arbeit mit unveränderlichen Objekten, die sich nur durch den Zustand ihrer Eigenschaften unterscheiden. „Codes like a class, works like a primitive“, wie es Brian Goetz, Java Language Architect bei Oracle, sagt.

In Java gibt es sowohl primitive Typen als auch Referenztypen. Das Speichern von Objekten im Java Heap hat seinen Preis. Die Metadaten des Objekts benötigen den zusätzlichen Speicher, z. B. für die Informationen, die für die Synchronisation von Objekten, Objektidentität, Polymorphismus und Garbage Collection benötigt werden. Allein das Speichern dieser Metadaten kann mehr Speicherplatz verbrauchen als die eigentlichen Eigenschaften des Objekts. Außerdem hat sich Hardware in den letzten Jahren massiv verändert. Wir haben ausschließlich mit Multikernprozessoren zu tun. Dabei haben sich die Kosten von sogenannten Cache Misses erhöht. Im Fall von Cache Miss wird der Wert nicht aus einem der Prozessorcaches, sondern aus dem Hauptspeicher geholt. Um das zu veranschaulichen, werfen wir einen Blick auf die Hardwarearchitekturen. Abbildung 1 zeigt eine sehr einfache Darstellung der Memory-Hierarchie. Wir sehen, dass jeder Prozessor eigene L1-(First Level-) und L2-(Second Level-)Caches besitzt. Es gibt außerdem noch einen optionalen, prozessorübergreifenden L3-Cache.

Abb. 1: Memory-Hierarchie

Abb. 1: Memory-Hierarchie

In den Caches werden die Informationen gespeichert, die der Prozessor wahrscheinlich sehr häufig und in naher Zukunft für seine Berechnungen benötigt. Bei der Suche nach Informationen geht der Prozessor die Caches in der Reihenfolge L1, L2, L3 nacheinander durch. Falls in keinem der Caches die vom Prozessor gesuchte Information zu finden ist (Cache Miss), findet ein Zugriff auf den Hauptspeicher statt. Generell gilt: Je näher der Cache am Prozessor ist, desto kleiner ist er. Wie groß die Caches tatsächlich sind, kann man unter Linux mithilfe von lscpu ermitteln – für Windows empfiehlt sich die Nutzung des Tools CPU-Z. Normalerweise ist der L1-Cache ab 64 KB (32 KB jeweils für Instruction und Data Cache), der L2-Cache ab 256 KB und der optionale L3-Cache ab 2 MB groß. Einen sehr guten Überblick über die Arbeitsweise der Caches liefert der Artikel von Joel Hruska [2]. An dieser Stelle sind noch die Zugriffszahlen wichtig: Je näher am Prozessor, desto schneller ist der Zugriff. Diese Zugriffszeiten können wir z. B. auf der Seite „Latenzen, die jeder Entwickler wissen soll“ [3] ermitteln. Wir vergleichen jetzt die Zahlen für das Jahr 1996 (Abb. 2) – Geburt der Programmiersprache Java – und 2020 (Abb. 3).

Abb. 2: Zugriffszeiten auf L-Caches und RAM im Jahre 1996

Abb. 2: Zugriffszeiten auf L-Caches und RAM im Jahre 1996

Abb. 3: Zugriffszeiten auf L-Caches und RAM im Jahre 2020

Abb. 3: Zugriffszeiten auf L-Caches und RAM im Jahre 2020

Wie wir in den beiden Abbildungen zu den Zugriffszeiten sehen, hat im Jahr 1996 der Zugriff auf den Hauptspeicher im Vergleich zum L1-Cache fast das Sechsfache gekostet (134 ns vs. 23 ns). Der gleiche Zugriff kostet im Jahre 2020 schon das Hundertfache (100 ns vs. 1 ns). Es ist ein enormer Performancegewinn, wenn die Werte, auf die man häufig zugreift, näher am Prozessor gespeichert werden. Auf dem Hauptspeicher wird nicht auf die einzelnen Cacheeinträge zugegriffen, sondern aus Performancegründen auf die ganzen Cache-Lines (normalerweise 64 Bytes groß), wo sich mehrere Cacheeinträge befinden.

Jetzt aber zurück zu den Referenztypen in Java. Dabei haben wir auch mit solchen zu tun, die unveränderlich sind und sich nur durch den Zustand ihrer Eigenschaften unterscheiden. Die besten Beispiele aus der täglichen Nutzung der Standard-Java-Bibliotheken sind die Klassen LocalDateTime und Optional. Solche Objekte benötigen per Definition keine Information für die Synchronisation von Objekten, Objektidentität und Polymorphismus, da sie unveränderbar sind. Den Garbage Collector kann man für solche Objekte ebenfalls optimieren. Auch im Hinblick auf die Zugriffszeiten ist bei den genannten Referenztypen ein enormes Optimierungspotenzial vorhanden.

Es ist an dieser Stelle denkbar, statt klassischer Referenzobjekte eine Datenstruktur einzusetzen, die ähnliche Objekteigenschaften besitzt, nicht aber den genannten Overhead hat.

Inline Types

Hauptfokus des Projekts Valhalla ist die Erweiterung der Java-Sprache um einen neuen Datentyp namens Inline Type (ursprünglich Value Type genannt), der einige wichtige Eigenschaften der Referenztypen wie Konstruktoren, Methoden und Felder inklusive Sichtbarkeitsmodifikatoren beibehält, sich aber in einigen Merkmalen unterscheidet. Dabei sollen sich die Inline Types an das Laufzeitverhalten primitiver Typen anpassen.

Bei Inline Types handelt es sich um die Datenstrukturen, deren Werte statt einer Referenz direkt im Speicher abgelegt werden. Dabei wird genau so viel Speicher belegt, wie es für die in einzelnen Feldern enthaltenen Daten nötig ist. Speicherverbrauch für Metadaten entfällt komplett. Man kann das mit primitiven Datentypen vergleichen, bei denen ein int genau 32 Bit verbraucht.

Das direkte Speichern der Daten statt ihrer Referenzen wird als eine Verflachung (Flattening) bezeichnet. Die Vorteile kann man am besten bei der Benutzung in einem Container sehen: In einem Array von Referenzobjekten wird eine Referenz zu dem Objekt gespeichert, was dazu führt, dass vor einem Zugriff auf dieses Objekt eine Dereferenzierung durchgeführt wird. Die Inline-Objekte werden in einem Array direkt abgelegt. Dadurch befinden sie sich im zusammenhängenden Speicher, was die Lokalität und die Chance auf Cache Hits erhöht (Abb. 4).

Abb. 4: Vergleich: Array mit Referenzobjekten vs. Array mit Inline-Objekten

Abb. 4: Vergleich: Array mit Referenzobjekten vs. Array mit Inline-Objekten

Die Verwendung von Inline Types bringt große Vorteile im Vergleich zur Benutzung von Referenztypen:

  • Reduzierung des Speicherverbrauchs: Das Speichern der Objektmetadaten entfällt. Durch Wegfall dieser Informationen kann der Speicherverbrauch der Größe der Daten selbst entsprechen oder diese sogar übertreffen [5].

  • Reduzierte Indirektion: In Java werden Objekte als Referenztypen gespeichert, daher findet bei jedem Zugriff auf ein Objekt eine Dereferenzierung statt, wodurch zusätzliche Anweisungen ausgeführt werden. Im Inline-Objekt verbundene Daten sind ohne Dereferenzierung sofort an dieser Stelle im Speicher vorhanden.

  • Erhöhte Lokalität: Bei Inline-Objekten ist (durch Flattening) die Wahrscheinlichkeit höher, dass die Werte der einzelnen Felder im Speicher nebeneinander abgelegt werden. Bei Containern und Klassen, die Inline Types in ihren Feldern verwenden, ist dieser Effekt am größten. Infolgedessen erhöht sich die Wahrscheinlichkeit von Cachetreffern, da die Hardware die Cachezeilen vorzeitig abruft.

Der größte Unterschied zwischen Inline-Objekten und Referenzobjekten ist die fehlende Objektidentität. Durch den Verzicht auf die Identität müssen die Inline-Objekte Synchronisation, Veränderbarkeit und Layoutpolymorphismus aufgeben. Dementsprechend sind Inline-Klassen und alle ihre Felder stets final, und die Klassen haben einige Beschränkungen bezüglich Supertypen, die später in den Beispielen erläutert werden. Auch die Methoden clone und finalize dürfen in den Inline-Klassen nicht überschrieben werden.

Inline-Klassen in der Praxis

Inline-Klassen erinnern uns von der Handhabe her an Case Classes aus Scala [6]. Ältere Entwickler werden wahrscheinlich an Delphis Records denken. Obwohl man viele Gemeinsamkeiten mit anderen Sprachen erkennt, sind Inline-Klassen eigenständige Strukturen und haben eine Reihe von einzigartigen Eigenschaften. Ein kurzer Überblick: Inline-Klassen können

  • Felder und Methoden besitzen

  • Interfaces implementieren

  • mittels ==-Operation verglichen werden

  • als Generics-Parameter übergeben werden

  • selbst Generics-Parameter besitzen

Inline-Klassen können nicht

  • den Zustand verändern (sie sind immutable)

  • abgeleitet werden (sie sind sozusagen final)

  • Threads synchronisieren

  • geklont werden

  • Operatoren wie == oder + überschreiben

Wie in Java üblich, benötigt die Implementierung wesentlich mehr Code als in Scala nötig gewesen wäre (Listing 1).

inline class Point {
  public int x, y;
  public Point(int x, int y) {
    this.x = x;
    this.y = y;
  }
}

Inline-Klassen als Primitive

Inline-Klassen sind ähnlich wie Scala Case Classes immutable. Alle Felder der Inline-Klasse sind final, auch wenn sie nicht explizit als solche deklariert sind. Diese Eigenschaft macht auch die Methode clone obsolet. Das Klonen einer Inline-Klasse ist auf Compilerebene gesperrt.

Point p1 = new Point(1, 2);
p1.x = 3; // compiler error: cannot assign a value to final variable
p1.clone(); // compiler error: Inline types do not support clone

Auch eine Inline-Klasse selbst ist immer final und kann nicht erweitert werden.

inline class Point2 extends Point { } // compiler error: cannot inherit from // final Point

Die Inline-Klasse hat nützliche Standardimplementierungen für equals:

System.out.println(new Point(1,2).equals(new Point(1,2)); // true;

Die equals-Methode lässt sich weiterhin überschreiben. Genauso wie bei der Referenzklasse muss man dabei aufpassen, dass auch die hashCode-Methode überschrieben ist. Der Equality-Operator (aka ==-Operator) funktioniert anhand von gespeichertem Wert.

System.out.println(new Point(1,2) == new Point(1,2)); // true

Hier ist Vorsicht geboten: Das Überschreiben der equals-Methode hat keinen Einfluss auf den ==-Operator. Beim Identity-Vergleich werden weiterhin alle Eigenschaften des Objekts verglichen. Der Equality-Operator lässt sich genauso wie alle anderen Operatoren wie + oder nicht überschreiben und bleibt dabei ähnlich wie bei den Strings tückisch (Listing 2).

inline class Str {
  public String body;
  public Str(String body) {
    this.body = body;
  }
}
System.out.println(new Str("a") == new Str("a")); // returns true
System.out.println(new Str(new String("a")) == new Str("a")); // returns // false

Diese Besonderheit bei der Behandlung von Strings treibt jährlich Tausende junger Entwickler in die Verzweiflung. Aktuell lässt sich diese Besonderheit durch Einheitlichkeit des Klassenmodells erklären. Wenn das Projekt Valhalla endgültig in Java einzieht, ist die alte Erklärung nicht mehr ganz schlüssig. Eine logische Vereinheitlichung durch Einführung von Inline-Strings und Inline-Arrays oder gar Umwandlung von bestehenden Klassen in Inline-Klassen wäre an dieser Stelle eine vorstellbare Option [5]. Angesichts der Komplexität und der Probleme bei der Rückwärtskompatibilität ist Oracle hier eher zurückhaltend [7].

Inline-Klassen und Nullity

Genauso wie bei primitiven Typen darf eine Variable der Inline-Klasse im Normalfall nicht null sein:

Point pointer = null; // compiler error: incompatible types: <null> cannot be // converted to Point

Eine Variable, die nie null sein kann, wäre an vielen Stellen sinnvoll. Sie dürfte die Menge von ungeliebten NullPointerExceptions reduzieren. Instance Variables (aka Class Fields) als Inline-Klassen werden ähnlich wie primitive Typen mit Nullwerten initialisiert. Es ist möglich, auch einen direkten Zugriff zu einem solchen Defaultwert zu haben:

System.out.println(Point.default); // -> [de.iplabs.Point x=0 y=0]

Die Tatsache, dass es keine nulls gibt, birgt einen gefährlichen Nebeneffekt. Ein mit einem Defaultwert automatisch initialisiertes Feld einer Inline-Klasse kann in bestimmten Fällen zu einem unerwünschten Zustand der Inline-Klasse führen. Beispielsweise erhält die Inline-Klasse PositiveInteger intern den Wert 0 und befindet sich dadurch in einem illegalen Zustand (0 ist nicht positiv). Das Problem kann nicht durch einen Default-Constructor gelöst werden, da Inline-Klassen keine Default-Constructors haben.

Die Entwickler von Valhalla legen viel Wert auf die Migration von bestehenden Klassen zu den Inline-Klassen. Darunter fällt auch die Option, eine nullable-Variable eines Valhalla-Typs zu deklarieren. Das aktuelle Release bietet folgende experimentelle Schreibweise an:

Point? pointer = null; // it is legal

Kotlin-Entwickler werden diese Möglichkeit sicherlich sehr praktisch finden und für ein positives Feedback sorgen.

Intern funktioniert das Nullity durch eine intern erzeugte Referenzklasse. Eine solche Klasse wird auf Bytecodeebene neben der Inline-Klasse erstellt. Die Umwandlung zwischen zwei Strukturen – Inline-Klasse und Referenzklasse – wird Inline Widening genannt. Sie wird in den nächsten Valhalla-Versionen die bekannte Boxing Conversion erweitern und gegebenenfalls ersetzen [4].

Inline-Klassen in der Klassenhierarchie

In der Java-Klassenstruktur sind Inline-Klassen unter java.lang.Object angesiedelt. Die einem Referenztyp vorbehaltenen Eigenschaften sind für Inline-Klassen gesperrt. Neben den bereits erwähnten clone() und finalize() gehören dazu auch alle Methoden der Threadverwaltung. Die Methoden werden bereits auf Compilerebene (wo möglich) unterbunden:

pointer.wait(); // compiler error: Inline types do not support wait

Wenn die verbotene Funktion durch Polymorphismus über java.lang.Object in Anspruch genommen wird, generiert die JVM eine RuntimeException:

Object obj = pointer;
synchronized (obj) { … } // java.lang.IllegalMonitorStateException

Inline-Klassen dürfen Methoden haben und Interfaces implementieren. Auch in weiten Aspekten sind Inline-Klassen recht kompatibel mit bekannten Java-Strukturen und können fast überall dort auftreten, wo die klassischen Klassen benutzt werden. Auch eine Mischnutzung ist im aktuellen Release erlaubt. Sowohl Generic Arguments als auch Arrays von Inline-Klassen sind erlaubt:

List<InterfacePoint> list2 = new ArrayList<>();
list2.add(new InlinePoint(1, 2));
list2.add(new PojoPoint(1, 2));
InterfacePoint[] arr = new InterfacePoint[] {
  new InlinePoint(1, 2), new PojoPoint(1, 2)};

Beim Verwenden von Generics muss man aktuell noch vorsichtig sein. An manchen Stellen findet beim Einsatz von Inline-Klassen offensichtlich noch ein aufwendiges Umwandeln statt. Das führt dazu, dass die Performance um den Faktor 10 einbricht. Darunter fallen auch Atomic Wrapper. Atomic-Klassen werden im aktuellen Release nicht unterstützt.

Performance in der Praxis

Die aktuellen objektorientierten JIT-Compiler profitieren von einer 25-jährigen Geschichte. Der Compilercode wurde in dieser Zeit verbessert und der Ressourcenverbrauch optimiert. Die Valhalla-Strukturen können da noch nicht mithalten. Der aktuell durch Typumwandlung eingeführte Mehraufwand im Bytecode hält sich an vielen Stellen gerade noch im Gleichgewicht mit den gewonnenen Performancevorteilen [7]. Oracle gibt eine sechsfach bessere Performance und tausendmal weniger RAM Allocations beim Multiplizieren von Complex-Matrizen an [7]. Ein Nachbau dieses Tests auf der aktuellen Hardware brachte uns lediglich den vierfachen Gewinn (Abb. 5). Das Beispiel von Oracle ist in Listing 3.

public static Complex[][] mul(Complex[][] a, Complex[][] b) {
  int size = a.length;
  Complex[][] R = new Complex[size][size];
  for (int i = 0; i < size; i++) {
    for (int j = 0; j < size; j++) {
      Complex s = new Complex(0, 0);
      for (int k = 0; k < size; k++)  {
        s = s.add(a[i][k].mul(b[k][j]));
      }
      R[i][j] = s;
    }
  }
  return R;
}

Der Code war offensichtlich mit Absicht nicht optimal geschrieben. Wenn man auf das Erstellen von zwei zusätzlichen Instanzen pro Iteration verzichtet und dabei veränderbare Instanzen nimmt, reduziert sich der Performancegewinn deutlich (Abb. 5).

Abb. 5: Complex-Matrix-Multiplikation 500 x 500 (ms, Intel Core i7-8700K)

Abb. 5: Complex-Matrix-Multiplikation 500 x 500 (ms, Intel Core i7-8700K)

Einen bescheidenen, aber trotzdem wesentlichen Performancevorteil liefert ein Test mit der Datenmanipulation, bei dem keine neuen Instanzen erzeugt werden. Als Beispiel haben wir den Bubble Sorting Algorithm über 10 000 Elemente genommen. Oracle schätzt den Geschwindigkeitsvorteil auf das Dreifache [7], was wir in unserem einfachen Test und mit dem aktuellen JDK nicht reproduzieren konnten. Die Werte mit unterschiedlichen Datentypen sieht man in Abbildung 6.

Abb. 6: Bubble Sorting Algorithm über 10 000 Elemente (ms, Intel Core i7-8700K)

Abb. 6: Bubble Sorting Algorithm über 10 000 Elemente (ms, Intel Core i7-8700K)

Wir sprechen dabei von einem dreißigprozentigen Performanceunterschied auf der aktuellen CPU mit sechs Cores. Der Zugewinn durch den Einsatz von Inline Types wächst mit der Anzahl von CPU Cores. Der gleiche Test auf einer älteren CPU mit zwei Cores brachte uns lediglich 20 Prozent.

Sogar wenn der realistische Performancevorteil bei den gemessenen 30 Prozent bleibt (was schon an sich nicht wenig ist), bietet Valhalla einen weiteren entscheidenden Vorteil. Das Projekt führt die noch vor Jahrzehnten durch OOP eingeführte Abstraktion und Performanz enger zusammen. Man muss keine Angst mehr vor der immutable-Klasse haben. Für eine elegante Abstraktion muss der Entwickler nicht mehr gezwungenermaßen mit deutlichen Performanceeinbußen bezahlen, und die Codequalität fällt nicht mehr der Performanceoptimierung zum Opfer.

Einschränkungen

Es muss noch einmal erwähnt werden, dass das aktuelle Release noch weit vom produktiven Einsatz entfernt ist. Zu den größeren Einschränkungen gehören die folgenden:

  • Das Release ist auf 64x-Architektur beschränkt.

  • Atomic-Klassen bieten keine Unterstützung für Inline-Klassen.

  • Es gibt deutliche Einschränkungen beim Funktionsumfang des JIT-Compilers [8].

Das Ziel des Release ist in erster Linie das Sammeln von Feedback durch Java-Developer. Es gibt noch eine Reihe von offenen Fragen und nicht abgeschlossenen Entscheidungen.

Aktuelle Diskussionen

Es steht noch nicht fest, wann das nächste Release von Valhalla veröffentlicht wird. Aktuell stehen viele Themen als Kandidaten zum Einzug in das nächste Release parat. So werden die Begriffe IdentityObject und InlineObject in Form von methodenlosen Interfaces eingeführt (ähnlich wie z. B. Serializable). Das wird unter anderem erlauben, in der Runtime die Klassen zu unterscheiden und durch Methodensignatur das eine oder andere Verhalten zu erzwingen. Es kann z. B. nützlich sein, wenn bekannt ist, dass die Instanzen einer Liste zur Threadsteuerung mittels synchronized-Block verwendet werden. Eine weitere praktische Neuerung ist die Möglichkeit für Inline-Klassen, eine eingeschränkte Menge von abstrakten Klassen erweitern zu dürfen. Eine solche abstrakte Klasse darf keine Felder, keine Non-Default-Constructors und keine synchronized-Methoden enthalten [4].

Die Umwandlung von bestehenden immutable-Klassen in Inline-Klassen wird fortgesetzt. Darunter fallen solche Klassen wie Optional, DateTime, Duration oder auch Integer. Die Überlegung ist, die bestehenden Klassen in Interfaces umzuwandeln und sie sowohl mit klassischer als auch Inline-Implementierung auszuliefern.

Eine weitere Aufteilung des aktuellen zweigeteilten Typsystems (Referenztypen und primitive Typen) in weitere Kategorien ist nicht erwünscht, daher ist es geplant, Inline Types mit primitiven Typen unter einem Dach zu vereinen. Primitive Typen werden dadurch zu Inline [4].

Fazit

In diesem Artikel haben wir den aktuellen Stand des Projekts Valhalla beleuchtet. Obwohl es noch ein paar Jahre dauern dürfte, bis Inline Types endgültig Einzug in die Java-Welt erhalten, stellt Oracle bereits jetzt die Weichen, Java auf moderner Hardware noch performanter zu machen. Man darf also sehr gespannt auf den nächsten Prototyp sein.

Verwandte Themen:

Geschrieben von
Rodion Alukhanov
Rodion Alukhanov
Rodion Alukhanov ist Senior Software Entwickler bei ip.labs GmbH mit Sitz in Bonn. Seine mehrjährige Erfahrung streckt sich von MS-DOS über die Hausautomation bis in die AWS Cloud. Seine Schwerpunkte sind Cloud Migration, Big Data und Integration Tests.
Alexander Peters
Alexander Peters
Alexander Peters ist Senior-Software-Entwickler bei der ip.labs GmbH mit Sitz in Bonn. Er hat mehr als zehn Jahre Berufserfahrung in der webbasierten Softwareentwicklung mit Java (EE) und dem Spring Framework. Seine Schwerpunkte liegen bei der serverseitigen Anwendungsentwicklung, der Integration in die Cloud sowie der Definition und Implementierung von externen und internen Schnittstellen.
Vadym Kazulkin
Vadym Kazulkin
Vadym Kazulkin ist Head of Technology Strategy bei ip.labs GmbH, einer hundertprozentigen Tochter der FUJIFLM Gruppe mit Sitz in Bonn. Ip.labs ist das weltweit führende White-Label-E-Commerce-Unternehmen im Bereich Softwareanwendungen für den digitalen Fotoservice. Vadym ist seit über 20 Jahren im Java-Ökosystem tätig. Zu seinen derzeitigen Schwerpunkten und Interessen gehören die Konzeption und Implementierung von hochskalierbaren und verfügbaren Lösungen, Serverless und AWS Cloud. Vadym ist Ko-Veranstalter Java User Group Bonn und des Serverless Bonn Meetup und ein häufiger Redner auf verschiedenen Meetups und Konferenzen.
Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: