JEXP                       JEXP

Intro

Wir alle wissen, dass Javascript ungefähr soviel mit Java gemeinsam hat wie "carpet" mit "car". Man konnte schon seit langen JavaScript innerhalb von Java ausführen, aber nicht ernsthaft nutzen. Erst seit in Java8 "Nashorn" die "Rhino" Implementierung ersetzt, macht das Ganze richtig Spass. Mit welchen Techniken und Tricks dynamischer JavaScript Code effizient ausgeführt werden kann, soll Thema dieses Artikels sein.

Sprachvergleich

Als Sprachen können sie verschiedener nicht sein, und neben einigen Schlüsselworten und Strukuren haben sie nur das Semikolon gemeinsam (welches in Javascript sogar weggelassen werden könnte). Das führt auch regelmässig zu Glaubensfrage, die für Softwareentwickler so typisch, aber nicht zielführend ist.

Java ist eine objektorientierte, vererbungsbasierte, statisch getypte, compilierte und "sichere" Sprache, die zu Bytecode kompiliert wird und in Servern und Anwendungen portabel auf allen Betriebssystemen läuft.

Javascript is eine objektorientierte, prototyp-basierte, dynamische, interpretierte Sprache, deren Mächtigkeit aus der flexiblen Erweiterbarkeit von Objekten zur Laufzeit und der starken Verbreitung als Browser-Scriptsprache kommt. Seit einigen Jahren ist es möglich mittels Node.js (basierend auf der V8-VM) Javascript auch ausserhalb des Browsers effizient auszuführen. Durch die Verbreitung als Sprache des Webs, gibt es viele Entwickler die "genug" JavaScript können, um sich durchzumogeln. Effizientes und wartbares JavaScript zu schreiben erfordert aber ebensoviel Erfahrung und noch mehr Disziplin als in Java.

Beide Sprachen haben sehr grosse Ökosysteme, mittlerweile auch mit reichhaltiger Toolunterstützung und werden in jeder Art von Projekt eingesetzt.

Geschichtliches

Im Navigator 2.0 veröffentlichte Netscape 1995 die von Brendan Eich entwickelte Scriptsprache "LiveScript". Die Namensänderung zu "JavaScript" kommt aus der Zeit als Netscape diese Scriptsprache enger mit Java-Applets integrieren wollte und Sun den notwendigen Integrationscode bereitstellte. Interessanterweise ist "JavaScript" seit der Übernahme von Sun jetzt eine Marke von Oracle!

Schon relativ zeitig (1997) wurde innerhalb von Netscape der Javascript-Interpreter "Rhino" in Java implementiert, der Teil eines Java-basierten Browsers werden sollte. Ein Jahr später wurde diese Implementierung über Mozilla veröffentlicht. Seit Java6 (2006) ist Rhino ein Bestandteil der Java-Runtime.

Die ersten Versionen von Rhino waren interessanterweise keine Interpreter, sondern generierten Bytecode, welcher schneller als die damaligen C/C++ Javascript Umgebungen war. Leider war dieser Prozess zu zeitaufwändig und speicherhungrig, so dass nachfolgend ein Interpreter die Ausführung des Javascripts übernahm. Den Compiler gab es noch im Hintergrund, dieser wurde aber nie weit verbreitet eingesetzt, obwohl es für serverseitiges Javascript schon interessante gewesen wäre.

Mit Rhino konnte man nun von Java aus Javascript-Code interpretiert, ausführen. Es konnten Variablen in den Ausführungscope gebunden und Ergebnisse zurückgeliefert werden. Die Integration erfolgte auch später mit der javax.ScriptEngine des JSR-223, welche eine einfache Integration für jedes Java-Projekt ermöglicht. Wenn da nicht die Performance von Rhino gewesen wäre. Der reine Interpreter war zu langsam, als es irgend jemand ernsthaft ausserhalb von einfachem, dynamischen Scripting für Java-Anwendungen eingesetzt hätte.

Das war der gesamten Java-Community lange ein Dorn im Auge, besonders da sich so viele andere, deutlich performantere dynamische Sprachen etablierten.

Mit Google’s V8 hielt eine sehr moderne und schnelle C++ -basierte JavaScript Engine in Chrome Einzug, die dann als Basis von Node.js den Erfolg von JavaScript auf dem Server begründete. Damit gab es einen deutlichen Zugzwang auf der JVM eine ebenso performante Lösung für die Ausführung von JavaScript anzubieten. Das es möglich ist, haben andere Projekte wie jRuby, Groovy und Clojure schon längst bewiesen.

2010 wurde ein Projekt als "Proof of Concept" für invokeDynamic gestartet, um zu zeigen dass MethodHandles die Implementierung dynamischer Sprachen auf der JVM viel einfacher und performanter machen. Da dieser PoC so erfolgreich war, wurde beschlossen, mit diesen Mechanismen eine neue, performante Javascript-Runtime zu entwickeln. Das Projekt wurde "Nashorn" genannt, als Anspielung auf "Rhino" und das JavaScript(Buch)-Nashorn und sehr zum Leidwesen englischer Muttersprachler. Es sollten alle modernen Ansätze für effiziente Ausführungsumgebungen für dynamische Sprachen genutzt werden, um eine zeitgemäße JavaScript-Runtime zu entwickeln.

Die Entwicklung von Nashorn hat seit 2010 viele Iterationen durchlaufen, seit Java 8 ist es offiziell Teil des JDK. Während dieser Zeit wurden verschiedene Ansätzen für die Lösung von Performance und Interoperabilitätsproblemen, die von Markus Lagergren und Attila Szegedi jeweils auf den JVM-Language-Summits vorgestellt wurden. Im Abschnitt "Technisches" werde ich detaillierter darauf eingehen.

Nützliches

Nashorn ist eine der wenigen Javascript Runtimes, die 100% ECMAScript 5.1 compliant sind. Für Java 9 ist eine Unterstützung von ECMAScript 6/2015 geplant, die teilweise schon umgesetzt ist. Die Entwickler von Nashorn haben aber sehr deutlich gemacht, dass Javascript vom Design und der Semantik eine der schlimmsten Sprachen darstellt.

"Und wer JavaScript performant auf der JVM zu laufen bekommt, hat die potentiellen Probleme der meisten anderen dynamischen Programmiersprachen auch gleich mit gelöst" [Lagergren JLS2014]

Die Ausführung von Javascript auf der JVM erlaubt interessante neue Optionen, die Nutzung des breiten Ökosystems und der Klassenbibliotheken. Aber auch die bewährten Test-, Integrations and Deployment-Plattformen stehen zur Verfügung, so dass große Javascript-Projekte in verlässlichen Umgebungen laufen können. Desweiteren kann Profiling von Ausführung und Speichernutzung sowohl über Debugger, als auch über Java-Flight-Recorder wichtige Laufzeitinformationen bereitstellen.

Für den Schnellstart mit Nashorn bedient man sich der mitgelieferten Shell "jjs", die im "bin" Verzeichnis des JDK/JRE zu finden ist:

// JavaScript mit Kurzform für Funktionen
[1,0].map(function(x) x*x).join("");

// Konvertierung von Feldern
var a = Java.to([1,"2","false"],"int[]");
for(x in a) print(x) // 1,2,0
Java.from(a) // 1,2,0

// Überladung von Funktionen
java.lang.System.out["println(double)"](12);  // 12.0

// Java Klasse deklarieren, erzeugen, aufrufen
var File = Java.type("java.io.File");
new File("/etc/passwd").exists(); // true

java.util.Array.asList(-1,1).stream()
        .reduce(0, function(a,x) a+x); // 0

// Funktionen als SAM-Parameter weitergeben
new Thread(function() { print("new thread")});

// Erweiterung von Klassen
var r = new java.lang.Runnable
       ({ run: function() { print('hello') }});
// Kurzform
var r = new java.lang.Runnable
       { run: function() print('hello') };

++[[]][+[]]+[+[]]; // 10

Die Einbindung in Java Programme erfolgt mittels der javax.script.ScriptEngine des JSR-223, von der man sich eine "nashorn" Runtime zurückgeben lässt. Da Nashorn nicht thread-safe ist, sollten Instanzen der ScriptEngine über ThreadLocal oder Pools verwaltet werden.

Da bei der Entwicklung von Nashorn viel Wert auf die gute Integration von Java Typen und Mechanismen gelegt wurde, gibt es bei der Integration nur ein wenig zu beachten. Die meisten Java-Typen und deren Methoden, können ohne Verlust von Typinformationen direkt in Javascript genutzt werden.

In Javascript können Klassen mittels Java.extend abgeleitet werden, neben den Typen (1 Klasse, n Interfaces) wird dann ein Javascript Objekt übergeben, dessen Properties die Funktionen und Eigenschaften enthalten, die für die Erweiterung genutzt werden können. Dabei müssen alle Methoden-Overloads mit demselben Namen durch diesselbe Javascriptfunktion gehandhabt werden, welche dann aufgrund der Parameteranzahl und -typen das Dispatching selbst vornehmen muss.

Alle JavaScript Objekte (also auch Felder) sind Subklassen von ScriptObjectMirror und implementieren das Map interface. Felder in JavaScript können aufgrund ihrer sehr eigenwilligen Semantik - sie können verschiedene Typen enthalten und auch spärlich besetzt sein - nicht direkt auf Java-Felder abgebildet werden, sondern nur mit Java.to(array[,"int[]"]). Eine Abbildung von Feldern auf das java.util.List Interface ist leider auch nicht möglich da dessen boolean Collection.remove(Object) mit der Object Map.remove(Object) des Map-Interfaces von ScriptObjectMirror kollidiert.

Java-Klassen sollten mittels ihres Paketes über var HashMap = Java.type("java.lang.HashMap") referenziert werden. Es gibt auch die Möglichkeit, globale Konstanten zu nutzen, die eine Paketstruktur simulieren, wie z.b. new java.io.File(), aber ihre eigenen Fehlerquellen mitbringen.

Java-Features wie Streams können mit Funktionen kombiniert und Java-Collections/Felder mit foreach genutzt werden.

Ein cooles Gimmick ist, dass Javascript Funktionen an allen Stellen genutzt werden können, die ein Single-Abstract-Method (SAM) Interface bzw. Klasse entgegennehmen.

Variablen können in den ScriptEngine-Kontext gelegt werden und Fragmente oder vollständige Skriptdateien ausgeführt werden, deren Kreationen dann ebenfalls im Kontext vorliegen. Für die Ausführung von in Javascript definierten Funktionen kann die ScriptEngine nach Invocable gecasted werden, um dann diese mit invokeFunction("name",args) auszuführen.

public class Main {
   public static void main(String[] args) throws Exception {
       ScriptEngineManager manager = new ScriptEngineManager();
       ScriptEngine engine = manager.getEngineByName("nashorn");
       engine.eval("function encode(x) { return encodeURIComponent(x); }");
       Invocable js = (Invocable)engine;
       System.out.println(js.invokeFunction("encode", "süß"));
   }
}
// s%C3%BC%C3%9F

Technisches

Die Leistungsfähigkeit der dynamischen Runtime, setzt sich aus den Zeiten für Ausführung, Warmup, Erreichen eines stabilen Zustands und Ausführung der Laufzeitbibliotheken zusammen. Jeder dieser Aspekte wird im folgenden kurz beleuchtet.

Ausführung

Die JVM ist zuallererst eine Java-Bytecode Maschine. Dass heisst, sie ist dafür geschaffen, stark getypten Bytecode - möglichst mit primitiven Datentypen und Operationen - am schnellsten auszuführen und am höchsten zu optimieren (JIT, Inlining, Unrolling).

Daher besteht das Hauptproblem für die Implementierung von JavaScript und anderer dynamischer Sprachen in ihrer potentiellen Dynamizität, bei JavaScript zusätzlich noch in der mangelnden Konsistenz und totalen Freiheit der Sprache.

Für die Transformation von JavaScript in Java-Bytecode bedeutet dass, dass man im korrekten Ansatz jeden Wert als Objekt behandeln muss. Bei jeder Operation ist dann ein Typ- und Überlaufs-Checks sowie Umwandlung und Unboxing vorzunehmen, damit dann die eigentliche Operation (z.b. Addition or Methodenaufruf) ausgeführt werden kann.

Nashorn war von Anfang an ein Vorzeigeprojekt für die invokedynamic Operation (JSR-292), ca. 10-20 Prozent der Instruktionen die von Nashorn generiert werden sind invokedynamic Bytecodes.

In jedem Schritt kann sich theoretisch auch jeder Bestandteil eines Objekts und Kontextes ändern, so dass man diese erneut laden und überprüfen muss. Dasselbe gilt für alle aufgerufenen Funktionen.

Aus dieser Dynamizität ergibt sich, dass wir für jeden Zugriff oder Aufruf immer wieder auf die JS-Runtime zugreifen müssen, um die letzte, aktuellste Variante einer Eigenschaft oder Methode zu erhalten.

Dieser dynamische Zugriff auf die Informationen erfolgt über MethodHandles die dann mittels invokeExact oder invoke aufgerufen werden und über die deklarierte Meta-Informationen der Runtime die Auflösung und Entscheidung überlassen.

In Nashorn wird für diese Entscheidung und Umwandlung die "dynalink" Bibliothek genutzt, die in diesem Rahmen schon viele hilfreiche Ansätze für dynamische Auswertung und Konvertierung umsetzt.

Dieser pessimistische Ansatz zwar korrekt, aber viel zu langsam als dass man ihn ernsthaft in Betracht ziehen kann. Zum Beispiel ist eine Addition von zwei Werten in einer Schleife zwischen mit dem Integer-Ansatz 150 mal schneller als mit Objekten

Zum Glück sind dynamische Sprachen in der Realität gar nicht so dynamisch, wie es zuerst den Anschein hat. Zwar ist es theoretisch möglich, zu jeder Zeit, jeden Typ, Methode, Variable oder Eigenschaft in Typ oder Wert zu ändern, selbst als Seiteneffekt eines anderen Methodenaufrufs. Nach der Anlaufphase am Anfang einer Javascript-Anwendung, sind die meisten dynamischen Rekonfigurationen vorgenommen worden und es gibt in den Kernbereichen der Anwendung den meisten kaum noch Überraschungen.

Diesen Umstand hat sich das Nashorn Team zunutze gemacht, in dem es von optimistischen Vorhersagen ausgehend, mit einer optimalen Implementierung für die effizientesten Typen (int, double, long) einer Methode beginnt. An allen Stellen an denen eine Verletzung dieser Annahmen auftreten kann (z.b. Variablenzugriff oder Operationen) werden innerhalb der Methode markiert und beim Abweichen des aktuellen vom erwartetem Typ wird eine UnwarrantedOptimismException erzeugt.

optimistic types

Diese wird dann am Ende der Methode behandelt und führt dazu, dass die Methode mit höherem Pessimismus für die Typerwartungen neu erzeugt und geladen wird. Die aktuellen lokalen Variablen werden gesichert und eine maximal pessimistische Implementierung der Methode ab der Markierung generiert und ausgeführt. Dieser Continuation-Ansatz funktioniert erstaunlich gut, man erreicht im optimalen Fall fast die Leisungsfähigkeit von getypten Java-Bytecode.

Er ist im JEP-196 "Nashorn Optimistic Typing"[JEP196] noch einmal im Detail beschrieben.

function() {
   return a + b;
}
public static f(ScriptFunction;Object;)I
0	aload 0
1	invokevirtual ScriptFunction.getScope()ScriptObject;
4	astore 2
5	aload 2
// Zugriff auf a mit Annahme dass es ein int ist
6	invokedynamic dyn:getProp|getElem|getMtd:a(Object;)I
11	istore 3
12	iload 3
13	aload 2
// Zugriff auf b mit Annahme dass es ein int ist
14	invokedynamic dyn:getProp|getElem|getMtd:b(Object;)I
// Integer-Addition mit Überlaufschutz
19	invokedynamic iadd(II)I
try {
  operation; // Zugriff auf a,b  und Addition
} catch (final UnwarrantedOptimismException e) {
  throw new RewriteException(e.getLocalVariables(), e.getProgramPoint());
}

Dieser Ansatz ist ähnlich zu dem der von Truffle und Graal genutzt wird, nur dass dort die Kommunikation zwischen Interpreterframework und Compiler automatisch funktioniert.

Durch die fehlende Typisierung kann die Vorhersage von Typen von Variablen nur indirekt abgeleitet werden, aus ihrer Deklaration, den Operationen die auf ihnen ausgeführt werden (z.b. Multiplikation auf Zahlen) und dem Fluss von Werten durch das Programm. Das macht man sich für die initiale, optimistische Variante zunutze, da man für bestimmte Variablen "weiss" welchen Typ sie haben und ihn damit für andere inferieren kann.

Überläufe

Für die mathematischen Operationen ist nachteilig dass die JavaScript Typen nicht mit Java kompatibel sind. Zahlen in Javascript sind etwas größer als in Java, so dass man zwar alle mit double abbilden kann, aber halt nicht mit int. Damit würde man aber deutliche Leistungseinbussen hinnehmen.

Für optimistische Integer-Operationen muss im Fall des Falles immer auf Überläufe reagiert werden können. In reinem Java-Code war der Überlaufs-Check zu langsam, daher wurde dieser mit neuen -Exact Operationen in java.lang.Math realisiert, z.B. addExact, die dann als intrinsische Methoden auf nur wenige Bytecode Operationen abgebildet werden können (jump on overflow). Somit kann man optimistisch addExact benutzen, und nur wenn der Integer-Wertebereich verlassen wird, muss die Operation mit Long oder Double wiederholt werden.

Ableitungen

Wenn eine Java Klasse oder Interface mittels durch Javascript abgeleitet wird, werden im Bytecode der generierten Klasse Instanzvariablen für den Script-Kontext und MethodHandles für die explizit überschriebenen Methoden, sowie toString, hashCode und equals vorgesehen. Diese werden dann im Constructor via JavaAdapterServices.getHandle() aus dem ScriptObject extrahiert und gespeichert.

Java.extend(java.lang.Runnable, function() print("test"))
package jdk.nashorn.javaadapters.java.lang;

public final class Runnable implements Runnable {

	ScriptObject global;
	MethodHandle run;
	MethodHandle toString;
	MethodHandle hashCode;
	MethodHandle equals;
}

this.run =  JavaAdapterServices.getHandle(target, "run", MethodType.type(void.class))
this.global = Context.getGlobal()

Beim Aufruf der bytecode-generierten, überschriebenen Methoden wird dann immer wieder gecheckt ob das jeweilige MethodHandle eine Funktionsreferenz enthält, die aufgerufen werden kann, oder wenn nicht dann ggf. die Methode der Superklasse.

Vergleiche

Schon als Nashorn noch im Beta-Stadium war, interessierte mich seine Leistungsfähigkeit, besonders im Vergleich zum in C++ implementierten V8. Eine kurze Recherche ergabt, dass es eine javax-Script-Engine Implementierung mittels V8[Jav8] gab, die über JNI angebunden wurde.

Beim Vergleich von V8 und Nashorn für einige Javascript-Beispiele zeigte sich, dass Nashorn durchaus mit V8 mithalten kann, wenn reines Javascript ausgeführt wird.

Sobald aber Callbacks und Datentransformationen zurück nach Java notwendig sind, hat Nashorn ganz klar die Nase vorn.

Ich habe den Vergleich noch einmal für eine schnelle Gegenüberstellung aufgesetzt. Für diesen Beispielcode sind hier die Ergebnisse, die für sich sprechen:

Reines Javascript
function compute(a,b) { return ((b % 3) - 1) * Math.pow(b,a % 3); }
function testRun(runs) {
   var sum = 0;
   for (var i=0;i < runs;i++) sum += compute(sum,i);
   return sum;
}
Javascript und Java
public static class Power {
    public int raise(int base, int exp) {
	    return (int)Math.pow(base,exp); }
}

engine.put("power", new Power());
engine.eval("function compute(a,b) { "+
  "return ((b % 3) - 1) * power.raise(b,a % 3); }");
engine.eval("function testRun(runs) { ...");

// 100 mal in Schleife
((Invocable)engine).invokeFunction("testRun", 10000);

Die Funktion testRun wurde mit 10000 Schleifendurchläufen aufgerufen. Nach einem Warmup, wurde die Zeit über 100 Durchläufe gemittelt und ein Benchmark berechnet 10 dieser gemittelten Zeiten. D.h. die compute Funktion wurde insgesamt 1 Million mal aufgerufen.

Um den Aufruf von java.lang.Math.pow auch mit V8 zu ermöglichen wurde eine Instanz einer kleinen Wrapper Klasse in die ScriptEngine injiziert. Dann wurde statt Math.pow, power.raise(base, exp) aufgerufen.

Beispiel

V8

Nashorn

reines Javascript

1,32 ms

0,42 ms

Javascipt und Java

12-200ms

0,41 ms

Die "jav8"-Engine scheint ein Probleme mit Speichermanagement beim Callback nach Java zu haben. Ich musste die Schleifendurchläufe von 10000 auf 1000 reduzieren, sonst reichte der Speicher nicht, die Laufzeit wurde auch mit jedem Run langsamer.

Hier ist auch noch einmal für den reinen Javascript Benchmark Octane der Vergleich zwischen Nashorn, Rhino und V8 zu sehen. Dabei zeigt sich, dass V8 immer noch deutlich schneller ist, Nashorn aber in derselben Liga spielt.

octane benchmark nashorn jdk9

Zukünftiges

Interessanterweise scheint sich schon jetzt eine zukunftssichere Alternative abzuzeichnen. Das von mir schon vorgestellte Gespann aus Truffle und Graal [JS???] kann für beliebige, dynamische Sprachen, basierend auf der Implementierung von Truffle AST-Konstrukten in annotierten Java-Methoden, mittels des optimierenden Compilers Graal extrem effizienten, typsicheren Laufzeit-Bytecode für Runtimes generieren. Diese werden dann entweder direkt als Maschinencode-Binary lauffähig sein, oder vom Hotspot-Compiler auch zur Laufzeit unterstützt von Graal und Truffle optimal ausgeführt.

Referenzen

Last updated 2016-02-03 16:12:27 CET
Impressum - Twitter - GitHub - StackOverflow - LinkedIn