zurück zum Artikel

Was ist neu in Java 7? Teil 2 – Performance

Markus Eisele

Mit dem neuen Java haben sich in der Plattform einige Dinge verändert, die die Leistung von Anwendungen deutlich verbessern. Die zwei prominenten Vertreter sind das Fork/Join-Framework und die "New I/O APIs".

Die im ersten Teil der Artikelserie zu Java 7 vorgestellten Spracherweiterungen von Project Coin fallen sicherlich ebenfalls in die Kategorie Performance. Allerdings haben sich mit dem neuen Java in der Plattform einige Dinge verändert, die die Leistung von Anwendungen deutlich verbessern. Die zwei prominenten Vertreter sind das Fork/Join-Framework und die "New I/O APIs".

Waren Threads in Java zu Beginn lediglich eine Möglichkeit für das asynchrone Ausführen von Aufgaben, kam mit zunehmend mehr Kernen die Notwendigkeit auf zur Verteilung der Aufgaben unter Verwendung sogenannter Thread Pools und Scheduling-Mechanismen. Mit der heute verfügbaren Anzahl an Rechenkernen sind alle bis dahin in Java verfügbaren Mechanismen überholt. Auf Anwendungsebene implementierte Konzepte und geteilte Work Queues können heutige CPUs nicht mehr geeignet auslasten. Es war also ein neues Mittel zu finden, die rechenintensive Parallelität in Java zu erreichen. Grundsätzlich gab es dafür mehrere Ansätze, mit Java SE 7 hat sich die Community letztlich für den Fork/Join-Ansatz entschieden.

Mehr Infos

Was ist neu in Java 7?

Alle Artikel zur Reihe:

Als Teil der Bemühungen um Parallelität in Java entstand bereits 2002 der JSR 166 [2] (Concurrency Utilities). Mit dem in Java 5 integrierten Paket java.util.concurrent.* kamen erste Hilfsklassen für die Arbeit mit Parallelität in die Java-Plattform. Dabei handelte es sich um die Umsetzung vergleichsweise etablierter und bekannter Konzepte, die mit wenigen einfachen Interfaces den Entwickler unterstützen sollten. In Java 6 kamen dann sogenannte Deques (Double-ended Queues) und navigierbare Collections (Concurrent Sorted Maps und Sets) hinzu. Grundsätzliches Problem blieb bis dahin aber, dass es sich lediglich um Hilfsklassen handelte. Entwickler, die sie zur Umsetzung von Algorithmen einsetzen wollten, mussten die Verwaltung von Threads noch selber übernehmen. Fork/Join schließt die Lücke und stellt eine Erweiterung zu den ExecutorServices dar. Der mit Java 5 eingeführte java.util.concurrent.Executor [3] und die zugehörigen ExecutorServices können Arbeitspakete ausführen, welche das Runnable Interface implementieren. Das sogenannte Executor Framework bietet dabei Beispielimplementierungen und Methoden zur Steuerung des Lebenszyklus von Threads.

Die Geschichte des Modells beginnt 2000 mit dem von Doug Lea vorgestellten Konzept [4] eines Fork/Join-Frameworks (PDF [5]) für Java. Es handelt sich um die Implementierung eines Algorithmus nach dem Prinzip "Teile und herrsche" (divide and conquer). Die Algorithmen gehen nach dem folgenden Schema vor:

Ergebnis loese(Problem problem) {
if (problem is klein)
löse das Problem direkt
else {
teile das Problem in unabhängige Probleme auf
erstelle einen neuen Untertask um beide Teilprobleme zu lösen (fork)
verbinde beide Untertasks (join)
erstelle ein Gesamtergebnis aus den Teilergebnissen
}
}

Neue Subtasks startet der "fork". "join" stellt sicher, dass das Programm nicht weiter ausgeführt wird, bis der erstellte Subtask beendet wurde. Algorithmen dieser Art arbeiten fast alle rekursiv und zerlegen das eigentliche Problem immer weiter in seine Einzelteile, bis nur noch der kleinste Teil zu lösen ist.

Skizze: Schematische Darstellung von "Teile und herrsche"

Schematische Darstellung von "Teile und herrsche" (Abb. 1)

Die Lösung des Problems wird in einer Klasse implementiert, die RecursiveTask (mit Teilergebnis) oder RecursiveAction (ohne Teilergebnis) erweitert. Genauer: Der Algorithmus ist in der compute()-Methode zu implementieren.

Als konkretes Beispiel eignet sich die Aufgabenstellung, den Datensatz (Country,City,AccentCity,Region,Population,Latitude,Longitude) einer Stadt aus einer weltweiten Datenbank herauszusuchen. Mit 2.797.246 Einträgen bietet sich hier die freie MaxMind [6]-Karte an. Das Prinzip ist einfach: Der Entwickler ließt die Textdatei ein und fahndet per Regular Expression nach einem String-Ausdruck. Treffer fügt er einem HashSet<String> matches hinzu:

List<String> lines =
Files.readAllLines(Paths.get(file),
Charset.forName("iso-8859-1"));
Set<String> matches = new HashSet<>();
...
Pattern pattern = Pattern.compile(".*Hannover.*");
if (pattern.matcher(input).matches()) {
matches.add(input[i]);
}

Bei einem einfachen Durchlauf benötigt ein i7-Prozessor mit acht Kernen im Schnitt 3,5 Sekunden. Verändert der Entwickler die Implementierung und wendet einen Fork/Join-Algorithmus an, vergehen mit im Schnitt 1,5 Sekunden in diesem Fall weniger als die Hälfte. Zur Umsetzung muss er die dafür notwendige [i]ScanTask-Klasse erstellen und die .compute()-Methode aus dem java.util.concurrent.RecursiveTask überschreiben. Die Zerteilung des Problems richtet sich in diesem Beispiel nach der Anzahl der verbleibenden zu untersuchenden Zeilen. So wird es weiter zerlegt, wenn noch mehr als 20.000 vorhanden sind. Pakete kleiner dieses auch Threshold oder Schwellwert genannten Werts werden direkt sequenziell mit dem oben gezeigten Codestück verarbeitet.

Dabei besteht die Kunst genau darin, den richtigen Schwellwert zu finden. Grundsätzlich bedeutet ein kleinerer Wert mehr Parallelität, damit verbunden aber auch höheren Aufwand für Koordination und Tasks. Deswegen führt kein Weg um das Profiling des Codes herum. Am Beispiel mehrerer Schwellwerte und gemittelter Laufzeiten für das Codebeispiel ergibt sich das in Abbildung Zwei gezeigte Bild.

Grafik: Gemittelte Laufzeiten bei unterschiedlichem Schwellwert

Gemittelte Laufzeiten bei unterschiedlichem Schwellwert (Abb. 2)

Schön ist, dass sich der Code zur Bewältingung der Aufgabe unabhängig von der Ausführungsumgebung halten lässt. Grundsätzlich eignet sich das Fork/Join-Framework für rechenintensive Arbeiten mit minimaler I/O-Aktivität.

Das Thema I/O nimmt im neuen Java-7-Release eine besondere Stellung ein. Mit den "More New I/O APIs for the Java Platform" (NIO.2) kommt die 2006 begonnene Arbeit unter dem Dach des JSR 203 zu einem Abschluss. Auch an der Stelle ein wenig Geschichte: Bereits 2002 führte der JSR 51 ("New I/O APIs for the Java Platform") Buffer und Channel sowie nicht blockierende Sockets und Zeichensätze ein. Die unter java.io.File gesammelten Funktionen waren aber verbesserungswürdig und neben vielen störenden Kleinigkeiten – vor allem die fehlenden Basisfunktionen (Kopieren, Verschieben etc.) – ein Ärgernis. NIO.2 vereinheitlicht jetzt einiges, die nennenswerten neuen Klassen und Interfaces liegen dabei im package java.nio.file.*.

Die neue Schnittstelle Path identifiziert eine Datei im Dateisystem. Ein Path lässt sich auf diverse Arten erzeugen. Ergebnis ist dabei immer eine plattformspezifische Implementierung (beispielsweise für Windows: sun.nio.fs.WindowsPath). Dabei sind Methoden zum Zugriff, Vergleich und Verändern von Pfaden vorhanden.

// Path von Subpaths via FileSystem
Path path = FileSystems.getDefault().getPath("d:", "temp","test.txt");
// Path via Paths
Path path = java.nio.file.Paths.get("d:", " temp "","test.txt");
// Path via File
File file = new java.io.File("D:/temp"","test.txt");
Path path = file.toPath();

Die neue Klasse Files kann direkt auf den Path-Objekten arbeiten. Hier sind jetzt alle Basisbefehle vorhanden: neben dem Erstellen und Öffnen (zum Schreiben bzw. Lesen) auch die Genannten zum Kopieren, Verschieben und Löschen. Mit dem Interface FileAttribute lassen sich auch entsprechende Attribute hinterlegen. Die plattformspezifischen Implementierungen liegen unter java.nio.file.attribute.*. Sie stellen eine Sicht auf die beim jeweiligen Dateisystem vorhandenen Attribute dar.

DosFileAttributes attrs = Files.readAttributes(path, DosFileAttributes.class);
FileTime time = attrs.creationTime();
long size = attrs.size();

Die Files-Klasse unterstützt jetzt rekursive Operationen auf dem Dateisystem. Ein einfaches Beispiel ist mit dem SimpleFileVisitor schon vorhanden. Alle Methoden für den Zugriff auf das Dateisystem werfen nun eine einheitliche IOException. Spezifische Exceptions werden nur noch für einzelne, wiederherstellbare Fehlersituationen geworfen. Fast schon obligatorisch ist die nun vorhandene Unterstützung für symbolische Links.

Mit dem Interface DirectoryStream lässt sich auf Verzeichnisinhalten arbeiten. Dazu gehört auch ein auf regulären Ausdrücken basierter Filter. Der folgende Code listet alle *.html- und *.txt-Dateien im Verzeichnis:

List<Path> listSourceFiles(Path dir) throws IOException {
List<Path> result = new ArrayList<>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "*.{html,txt}")) {
for (Path entry : stream) {
result.add(entry);
}
} catch (DirectoryIteratorException ex) {
throw ex.getCause();
}
return result;
}

File-Systeme kann lassen sich über das neue Interface FileSystem abstrahieren. Mit den Service-Provider-Interfaces (FileSystemProvider) lassen sich hier eigene Implementierungen bereitstellen. Ein Beispiel finden Sie in den JDK-7-Demos (<JDK7>/demo/nio/zipfs/src.zip). Zu einem FileSystem gehören möglicherweise auch mehrere FileStores. Sie lassen sich am Standard-FileSystem erfragen:

  FileSystem fs = FileSystems.getDefault();
for (FileStore store : fs.getFileStores()) {
printFileStore(store);
}

Ein einfaches DiskUsage-Beispiel lässt sich nun in weniger als 40 Codezeilen realisieren. (Siehe das Listing aus dem Java-7-Tutorial [7].)

Eine letzte Neuerung stellt der WatchService dar. Nunmehr lassen sich Veränderungen von Dateien überwachen, indem auf entsprechende Benachrichtigungen reagiert wird. Dazu verwendet man direkt am FileSystem den WatchService und registriert einen WatchKey am Path:

WatchService watchService = FileSystems.getDefault().newWatchService();
WatchKey watchedPath = directory.register(watchService, StandardWatchEventKinds.ENTRY_DELETE);
WatchKey key = watchedPath.register(watchService, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE);

Am WatchService lässt sich darüber hinaus via take() ein signalisierter WatchKey abfragen:

WatchKey signalledKey = watchService.take();

Übrig bleibt eine Liste der geschriebenen Events:

List<WatchEvent<?>> list = signalledKey.pollEvents();

Die lassen sich dann auswerten. Wichtig ist, dass man den Überwachungsprozess wieder freigibt (reset()), damit weitere Events erneut benachrichtigt werden. (Das Listing für das WatchService-Beispiel findet man auf dem Heise-FTP-Server [8] (ZIP, 17kB).

Die Performance des NIO.2-APIs ist gewaltig nach oben gegangen. Auf den NIO.2-Slides (PDF [9]) zum offiziellen Java-7-Event am 7. Juli ist das detailliert zu erkennen.

Egal, welches neue Feature von Java 7 man "in die Finger bekommt": Bei der historischen Betrachtung wird schnell klar, dass seine Grundsteine vielfach bereits kurz nach der Verfügbarkei des letzten Java-Release gelegt wurden. Die hier vorgestellten neuen Funktionen von Java 7 führen das bereits Vorhandene im Sinne einer konsequenten Evolution weiter. Von Überraschungen kann daher nicht die Rede sein. Die Erweiterungen im Bereich der Nebenläufigkeit konnten als externe Bibliothek bereits auf Basis von Java 6 eingesetzt werden und sind nun in den Standard umgezogen. Auch die mit NIO.2 eingeführten Funktionen sind schon vergleichsweise lange bekannt. Lediglich die Umsetzung im Rahmen einer neuen Java-Funktion hat diesmal einfach nur viel zeit in Anspruch genommen. Auf die Kopierfunktion beispielsweise haben Java-Entwickler jetzt fast 15 Jahre lang warten müssen.

Markus Eisele [10]
ist Principle IT Architect bei der msg systems AG in München.
(rl [11])


URL dieses Artikels:
https://www.heise.de/-1288272

Links in diesem Artikel:
[1] https://www.heise.de/hintergrund/Was-ist-neu-in-Java-7-Teil-1-Produktivitaet-1274360.html
[2] http://www.jcp.org/en/jsr/summary?id=166
[3] http://download.oracle.com/javase/7/docs/api/java/util/concurrent/ExecutorService.html
[4] http://g.oswego.edu/dl/concurrency-interest/
[5] http://gee.cs.oswego.edu/dl/papers/fj.pdf
[6] http://www.maxmind.com/app/worldcities
[7] http://download.oracle.com/javase/tutorial/essential/io/examples/DiskUsage.java
[8] ftp://ftp.heise.de/pub/ix/developer/Eisele_Java7_Teil2.zip
[9] http://www.oracle.com/us/technologies/java/file-system-api-428204.pdf
[10] http://blog.eisele.net/
[11] mailto:rl@ix.de