Blog

Extreme Java Camp mit Dr. Heinz Kabutz
Präsentiert von Entwickler Akademie und Java Magazin
4
Aug

JDK-16-Schnittstellen zur JVM-Anbindung

Ein Artikel von Marc Schönefeld, Wolfgang Weigend | Seit der Existenz von Java gibt es die Notwendigkeit, auf Bibliotheken und Fremdspeicher zugreifen zu müssen, die mit anderen Programmiersprachen geschrieben wurden. Dies gilt insbesondere für solche, die mit C/C++ entwickelt wurden. Für diese Zugriffe bietet die Java-Plattform nur das Java Native Interface (JNI) an – bis jetzt …

Mittels JNI können die Anwendungen nativen Code enthalten, der in Programmiersprachen wie C/C ++ und in der Programmiersprache Java geschrieben ist. Aus dem Blickwinkel der Java-Plattform ermöglicht das JNI API die Einbindung von Legacy-Code und die Lösung von Interoperabilitätsproblemen zwischen Java-Code und nativ kompiliertem Code (Abb. 1). Die Speicheroperationen sind nicht unproblematisch, weil nativer Code ausgeführt wird, verwendete Objekte wieder freigegeben werden müssen und zwischen den nativen Aufrufen direkt wieder zur Java-Anwendung zurückgekehrt wird, ohne dabei mögliche Fehler im Native-Code erkennen zu können.

Projekt Panama

Zur besseren Entwicklerunterstützung bei der Bewältigung dieser Programmieraufgabe wurde das Projekt Panama [1] geschaffen. Es dient dazu, Interaktion zwischen JVM und Native-Code zu erleichtern. Die Verbindungen zwischen der JVM und den wohldefinierten Non-Java-Schnittstellen (Foreign APIs) wurden dafür überarbeitet – insbesondere den APIs, die üblicherweise von C-Programmierern verwendet werden. Das Projekt Panama enthält die JDK Enhancement Proposals zum Foreign Memory Access API (JEP-383) [2], das Foreign Linker API (JEP-389) [3] sowie das Vector API (JEP-338) und die folgenden Komponenten:

  • nativer Funktionsaufruf der JVM

  • nativer Datenzugriff der JVM oder innerhalb des JVM Heap

  • neue Datenlayouts im JVM Heap

  • native Metadatendefinition für die JVM

  • API-Extraktionstools für Headerdateien (jextract)

  • native Bibliotheksverwaltungs-APIs

  • nativ orientierter Interpreter und Laufzeit-Hooks

  • Klassen- und Methodenauflösungs-„Hooks“

  • nativorientierte JIT-Optimierungen

  • Werkzeuge oder Wrapper zur Einhaltung von Sicherheitsmerkmalen

  • Arbeitsfortschritte bei schwer zu integrierenden nativen Bibliotheken

Foreign Memory Access API

Mit dem Foreign Memory Access API in seiner dritten Inkubationsauflage ist der JEP 393 Bestandteil von JDK 16 [4]. Das API ermöglicht Java-Programmen den sicheren Zugriff auf fremden Speicher außerhalb des Java Heaps. Dabei wurde eine klare Rollentrennung zwischen den Interfaces MemorySegment und MemoryAddress geschaffen. Das neue Interface MemoryAccess beinhaltet allgemeine statische Memory-Zugriffsfunktionen und minimiert den Einsatz des VarHandle API in einfachen Fällen. Gemeinsam genutzte Speichersegmente werden unterstützt und die Segmente bieten die Möglichkeit, sie mit einem Cleaner zu registrieren.

Generell soll die Schnittstelle in der Lage sein, mit verschiedenen Arten von Foreign Memory, wie Native Memory, Persistent Memory und Managed Heap Memory zusammenzuarbeiten. Es soll vermieden werden, dass das API die JVM-Sicherheit untergräbt, unabhängig von der Art des Speichers, mit dem gerade gearbeitet wird. Die Clients sollen Kontrollmöglichkeiten haben, beispielsweise sollen Speichersegmente wieder freigegeben werden – entweder explizit über einen Methodenaufruf oder implizit, wenn das Segment nicht mehr verwendet wird. Für Anwendungsprogramme, die auf Foreign Memory zugreifen müssen, soll das API eine brauchbare Alternative zu älteren Java APIs wie sun.misc.Unsafe sein. Eine Reimplementierung veralteter Java APIs wie sun.misc.Unsafe ist jedoch nicht vorgesehen.

schoenefeld_weigend_foreign_1.tif_fmt1.jpgAbb. 1: Java-Applikationsaufruf von Native-C/C++-Code via JNI

Der bisherige Ansatz mit JNI

Bereits seit frühen JDK-Versionen wie Java 1.1 existiert mit dem Java Native Interface (JNI) eine standardisierte Vorgehensweise zur Anbindung nativer Bibliotheken. Im Gegensatz zur fortschreitenden Innovation des JDK von Version zu Version (Coin, Lambda, Java-Module-System), hat sich das Arbeiten mit dem JNI softwaretechnisch nicht weiterentwickelt und setzt beim Programmierer Kenntnisse in C und C++ mit den dazugehörigen Tools voraus. Die hohen Einstiegshürden und die Reduzierung des Fehlergrades bei der JNI-Nutzung führten zur Konzeption des Java Foreign Linker API.

Foreign Linker API

Java beinhaltet eine Laufzeitbibliothek, die ein großes Spektrum an notwendiger Funktionalität zur Softwareerstellung bereitstellt. Sollte der Fall eintreten, dass Funktionalität nicht durch die JRE implementiert wird, so kann sie entweder durch die Einbindung von Java-Bibliotheken oder mittels nativer Bibliotheken geliefert werden. Im Folgenden wird die Anbindung von nativer Funktionalität betrachtet. Je nach Plattform befindet sich diese bei Windows in DLLs, bei Linux oder macOs in Shared Libraries.

Mit dem JEP 389 erhält JDK 16 das Foreign Linker API zunächst unter dem Status eines Incubator-Projekts. Der Zugriff auf native Bibliotheken kann nun statisch typisiert (in JNI ist alles ein jobject*) mittels reinem Java-Glue-Code erfolgen, den das Native API generiert. Unter Zuhilfenahme der Funktionalität des Foreign Memory APIs (https://openjdk.java.net/jeps/383) werden die Einstiegshürden für Entwickler drastisch reduziert, um native Bibliotheken sicher und fehlerfrei zu nutzen. Zusätzliche strukturelle Verbesserungen wurden im JDK 16 dazu nicht vorgenommen. Folgende Designziele zeigen die Unterschiede zu JNI auf der Konzeptebene auf:

  • Einfache Nutzung: JNI wird durch ein mächtigeres Entwicklungsvorgehen ersetzt, das sich dem Entwickler intuitiv erschließt.

  • Priorität C-Unterstützung: Der initiale Fokus zur Einführung des Foreign Linker API dient der Nutzung von Bibliotheken, die aus C-Code kompiliert wurden, dabei werden zunächst die Plattformen x64 und AArch64 bedient.

  • Langfristig Gemeingültigkeit: Das Foreign Linker API und dessen Implementierung soll auf lange Sicht genügend Flexibilität aufweisen, um auch für Bibliotheken anderer Plattformen (z. B. 32-bit x86) und importierte Funktionen anderer Sprachen als C zu dienen (z. B. C++ oder Fortran).

  • Performanz und Effizienz: Das Foreign Linker API soll Verarbeitungszeiten bieten, die mit denen von JNI vergleichbar oder besser sind.

Das Entwicklungswerkzeug jextract ist nicht im JDK 16 enthalten, sondern wird mit den Projekt-Panama-Early-Access-Builds (OpenJDK Version 17-panama) ausgeliefert [5], wie beispielsweise unter MS Windows im Pfad D:\jdk-17\bin\jextract.exe. Das Tool jextract, das strenggenommen nicht zum laufzeitbezogenen Foreign Linker API gehört, vereinfacht die Entwicklung erheblich durch die Generierung von Java-Klassen aus den C-Headerdateien (Abb. 2). Bei der Ausführung von jextract werden die Incubator-Module jdk.incubator.foreign und jdk.incubator.jextract benutztDie Modul- und die Paketnamen sind gleichlautend.

schoenefeld_weigend_foreign_2.tif_fmt1.jpgAbb. 2: Java-Applikationsaufruf von native C/C++ Code mit Foreign Linker API

Laden von Bibliotheken und Kontrollfluss

Zum Laden von Bibliotheken ist es vor der Nutzung einer nativen Bibliothek notwendig, sie erstmalig zu laden und ihre Struktur zu analysieren. Beim klassischen JNI kommen die Methoden System::loadLibrary und System::load als Fassade für dlopen zum Einsatz. Das Foreign Linker API bietet zu diesem Zweck die LibraryLookup-Klasse. Mit ihr kann auf Symbole einer geladenen Bibliothek zugegriffen werden, konkret mit der Methode lookup, die bei Erfolg ein Objekt vom Typ LibraryLookup.Symbol zurückliefert:

LibraryLookup libclang = LibraryLookup.ofLibrary("somelibrary");
LibraryLookup.Symbol clangVersion = libclang.lookup("someMethod");

Ein weiterer Unterschied zu JNI ist die Bindung von ClassLoader zur nativen Bibliothek, d. h. eine geladene JNI-Bibliothek kann nur aus dem Sichtbarkeitsbereich eines ClassLoaders heraus genutzt werden. Diese Restriktion besteht beim Foreign Linker API nicht, denn aufgrund der Tatsache, dass niemals Java-Objekte an die nativen Bibliotheken übergeben werden, kann die gleiche Bibliothek auch von mehreren ClassLoader-Instanzen parallel genutzt werden.

Um native Bibliotheken in den Kontrollfluss von Java-Programmen einzubinden, gibt es zwei verschiedene Aufrufarten. Die eine ist der traditionelle Downcall, d. h., dass C-Code von Java aus aufgerufen aus. Das wird in der Regel eine Funktion sein, die in der Headerdatei aufgeführt ist. Die zweite Aufrufmethodik ist der Upcall. Soll beispielsweise bei der Nutzung des nativen qsort zu Vergleichszwecken eine in Java geschriebene Callback-Methode benutzt (compare) werden, so wird der Funktions-Pointer auf compare in einem MemorySegment gekapselt und im Rahmen des Funktionsaufrufs an die qsort-Methode übergeben. Mittels der Verfügbarkeit von Up- und Downcall können Java-Code und nativer Code interaktiv zusammenarbeiten.

Berechnungsbeispiel einer SHA-256-Prüfsumme mit OpenSSL

Als anschauliches Beispiel zur Nutzung des Foreign Linker API dient die Berechnung einer SHA-256-Prüfsumme mittels OpenSSL (Listing 1). Anstatt der im C-Code üblichen Malloc- und Free-Klammern übernimmt das Foreign Memory API die Speicherallokationen. Aus Performancegründen wird zunächst mittels des Try-With-Resources-Patterns ein Objekt vom Typ NativeScope erzeugt, d. h., dass Allokationen in diesem Sichtbarkeitsbereich aus dem Non-Java Heap bedient werden. Als Erstes wird ein Speicherbereich der Länge von 216 Bytes (resultierend aus den summierten Feldlängen der SHA256_CTX-Struktur, 8*8+2*8+16*8+2*4) reserviert.

Listing 1: Calculating SHA-256 mit Foreign Linker API

import jdk.incubator.foreign.MemoryAddress;
import jdk.incubator.foreign.MemoryAccess;
import jdk.incubator.foreign.NativeScope;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Arrays;
import static openssl.sha.sha_h.*;
import static jdk.incubator.foreign.CLinker.*;
public class ShaTest {
 
  public static void main(String[] args) throws Exception {
  
  String val = args[0];
  byte[] orig = val.getBytes(); 
  
  System.out.println("\n[+] Calculating SHA-256 of  '"+val+"' "+Arrays.toString(orig)+" with OpenSSL");
 
  try (var scope = NativeScope.unboundedScope()) {
  /*
  typedef struct SHA256state_st {
  SHA_LONG h[8];
  SHA_LONG Nl, Nh;
  SHA_LONG data[SHA_LBLOCK];
  unsigned int num, md_len;
  } SHA256_CTX; */ 
    int ictx256len = 8*8+2*8+16*8+2*4;
    var pSha256ctx = scope.allocate(ictx256len);
    var data = scope.allocate(1024);
    var databb = data.asByteBuffer(); 
    databb.put(orig);
    var md=scope.allocate(32); //32
    int rc = SHA256_Init(pSha256ctx);
    // OpenSSL returns 1 in case of Success, this took a while to notice 
    if (rc!=1) {
      System.exit(-1);
    }
    rc = SHA256_Update(pSha256ctx,data,orig.length);
    if (rc!=1) {
      System.exit(-1);
    }
    rc = SHA256_Final(md,pSha256ctx);
    if (rc!=1) {
      System.exit(-1);
    }
    //System.out.println("b");
    
    byte[] arr = new byte[32]; 
    md.asByteBuffer().get(arr,0,32);
    System.out.println(Arrays.toString(arr));
    System.out.println(bytesToHex(arr));
    }
    System.out.println("\n[+] Calculating SHA-256 of  '"+val+"' "+Arrays.toString(orig)+" with java.security");
    MessageDigest digest = MessageDigest.getInstance("SHA-256");
    byte[] encodedhash = digest.digest(orig);
    System.out.println(Arrays.toString(encodedhash));
    System.out.println(bytesToHex(encodedhash));
  }
 
  private static String bytesToHex(byte[] hash) {
    StringBuilder hexString = new StringBuilder(2 * hash.length);
    for (int i = 0; i < hash.length; i++) {
      String hex = Integer.toHexString(0xff & hash[i]);
      if(hex.length() == 1) {
        hexString.append('0');
      }
      hexString.append(hex);
    }
    return hexString.toString();
  }}

Das geschieht mit dem allocate-Befehl, der als int-Parameter die Länge in Bytes übernimmt, und ein Objekt vom Typ MemorySegment zurückgibt.

Als zweite Allokation wird ein Datenbuffer (aus Vereinfachungsgründen als fixer Wert von 1024) reserviert. Dieser ist dann ebenfalls in einem Objekt des Typs MemorySegment gekapselt. Über die Methode asByteBuffer kann dieser Speicherbereich dann als ByteBuffer in Java verwendet werden. Der zu hashende Stringwert wird nun per Put in diesen ByteBuffer geschrieben. Im dritten Speicherbereich werden 32 Bytes reserviert, in denen das Resultat der SHA256-Operation abgelegt wird. Nachdem nun alle benötigten Speicherbereiche verfügbar sind, können die von OpenSSL zur Verfügung gestellten SHA256-Methoden ausgeführt werden:

  • SHA256_Init

  • SHA256_Update

  • SHA256_Final

Das Ergebnis der Operation steht nach Abschluss des SHA256_Final im ersten Parameter referenzierten Speicherbereich zur Verfügung, und das ist der im Vorfeld reservierte Bereich für die Prüfsumme der Länge 32 Bytes. Diese Bytes werden auch mittels eines Bytebuffers in den Java-Kontext überführt und können analog zur Java-Methode MessageDigest::digest für Integritätschecks genutzt werden.

Foreign Linker API im Build-Prozess

Um das Foreign Linker API in Java-Programmen zu nutzen, sind die folgenden Schritte notwendig:

  • Aufruf von jextract um Java-Zugriffsklassen und Schnittstellen für C-Headerdateien zu erstellen

  • Nutzung der Klassen der java.foreign APIs zur Bereitstellung des entsprechenden Laufzeitszenarios

  • Aufruf der den ursprünglichen C-Funktionen entsprechenden Methoden, die vorher mit jextract erzeugt wurden

Für das SHA256-Beispiel sind folgende Kommandozeilenaufrufe erforderlich:

  • Die folgenden Funktionen von libcrypto.so werden exportiert (T)

    nm -D libcrypto.so | grep SHA256
    00000000001cf320 T SHA256
    00000000001cf040 T SHA256_Final
    00000000001cedd0 T SHA256_Init
    00000000001cf030 T SHA256_Transform
    00000000001cee30 T SHA256_Update
  • Die zugehörigen Header befinden sich in /usr/include/openssl/sha.h

    jextract /usr/include/openssl/sha.h -t openssl.sha  –lcrypto
  • Der Quelltext befindet sich in ShaTest.java, zudem der Import des Standardmoduls für das Foreign Linker API

    javac --add-modules jdk.incubator.foreign ShaTest.java
  • Die auszuführende Hauptklasse ist ShaTest.class und libcrypto.so befindet sich in /usr/lib/x86_64-linux-gnu/, so braucht man die Aktivierungsoption für den nativen Speicherzugriff

    java --add-modules jdk.incubator.foreign  -
    Djava.library.path=/usr/lib/x86_64-linux-gnu/  -
    Dforeign.restricted=permit  ShaTest $1

Fazit und Ausblick

Die langfristig geplante Umsetzung vom Projekt Panama (Foreign Function / Data Interface) mit dem Foreign Memory Access API und dem Foreign Linker API im JDK 16 nimmt konkrete Gestalt an. Mit diesen Schnittstellen wird der typsichere Zugriff auf native Bibliotheken ermöglicht und stark vereinfacht. Dazu kommt die komfortable Nutzung der gewohnten Abstraktionsklassen, wie beispielsweise Bytebuffer. Im Vergleich zu JNI verkürzen sich die Entwicklungszeiten durch die Reduzierung von Zugriffsbarrieren. Die Nutzung nativer Zusatzfunktionen führt potenziell zu mehr Sicherheit als zuvor. Die neuen APIs sind auf einer höheren Ebene angesiedelt und bieten damit bessere Möglichkeiten, eine externe Bibliothek in Java-Entwicklungsprojekte einzubinden. Die Integration einer Machine Learning Library in ein bestehendes Java-Projekt, beispielsweise mit der Tribuo Java Library oder mit der TensorFlow Library (C/C++, Python), gestaltet sich dadurch wesentlich einfacher für Entwickler. Dennoch brauchen das Foreign Memory Access API und das Foreign Linker API nach dem Incubator-Status den notwendigen Reifegrad und die Stabilität, um auf lange Sicht die JNI-Calls abzulösen und beliebigen Third-Party-Code benutzen zu können.

 

 

Links & Literatur

[1] https://openjdk.java.net/projects/panama/

[2] https://openjdk.java.net/jeps/383

[3] https://openjdk.java.net/jeps/389

[4] https://openjdk.java.net/projects/jdk/16/

[5] https://jdk.java.net/panama/

Keine Infos mehr verpassen!