Suche

GSC vs. JDK: Goldman Sachs Collections und Java Collection Framework im Vergleich

Nataliya Wierts

© Shutterstock.com/rudall30

In den letzten Jahren kamen immer mehr Alternativen zum Java Collection Framework (JCF) als Open-Source-Projekte auf den Markt, darunter Google Guava, Commons Collections, Apache Mahout, fastutil, Trove – und die Goldman Sachs Collections. Letztere, oft GSC abgekürzt, werden seit mehreren Jahren stetig verbessert. Sie sind unter der Apache-2.0-Lizenz frei verfügbar. Doch was sind die Vorteile des Einsatzes dieser JCF-Erweiterung gegenüber JCF? Lohnen sich die Mühen des Erlernens? Oder birgt der Einsatz dieser Bibliothek sogar Nachteile?

Es ist wohl ein seltener Fall im Bankensektor, dass selbst entwickelte Software der Open-Source-Community zur Verfügung gestellt wird. Der Quellcode der GSC ist auf GitHub verfügbar, genauso wie die gut strukturierten Tutorials (Kata). Dieser Artikel geht auf die folgenden Punkte ein:

  •  GSC und Java 7
  • Java 8 Streams und GSC
  • JMH-Benchmark-Tests von GS
  • Aggregation-Tests

Die Goldman-Sachs-Bibliothek basiert auf den Predicates, Functions und Procedures. Predicates werden dazu benutzt, um die Elemente aus der Collection auszuwählen, die bestimmten Kriterien entsprechen (Filtering), und Functions können benutzt werden, um zum Beispiel die Teile eines jeden Objekts in einer neuen Collection zusammenzufassen (Transformieren), sodass zum Beispiel aus dem Person-Objekt, das das Adresse-Objekt als Bestandteil hat, alle Adressen in einer neuen Collection zusammengefasst werden können. Die Procedures sind void und geben keinen Wert zurück. Goldman Sachs Collections liefern die besten Ergebnisse beim Einsatz von Java 8.

GSC und Java 7

Die selbst geschriebenen Predicates und Functions erinnern möglicherweise an das Comparable-Interface, das wir nutzen können, um eine Collection zu sortieren. Allerdings ist Comparable natürlich viel komplizierter und enthält eventuell viele Vergleiche, wogegen die Predicates/Functions sehr einfach sein können, aber trotzdem als eine Klasse implementiert werden müssen. Sehen wir uns ein Beispiel einer Funktion an, das alle Namen von Kunden zu einer Liste zusammenfassen kann (Listing 1).

Listing 1
Function<Customer, String> nameFunction = new Function<Customer, String>()
{
  @Override
  public String valueOf(Customer customer)
  {
    return customer.getName();
  }
};
MutableList customerNames = customers.collect(nameFunction);

Da wir in Java 7 keine Codeblöcke einer Funktion  übergeben können (mit Java 8 geht das dank Lambda-Expressions viel einfacher), bleibt uns nichts anderes übrig, als in solch einem Fall eine eigenständige Klasse zu schreiben. Wenn wir diesen Business Case mit dem Code ohne Einsatz von GSC vergleichen wollen, dann würde das ungefähr so aussehen:

List customerNamesList = new ArrayList<>();
for(Customer customer : customers){
  customerNamesList.add(customer.getName());
}

An diesem Beispiel ist deutlich zu sehen, dass man mit GSC deutlich mehr Codezeilen benötigt als ohne GSC. In beiden Fällen bekommen wir eine Liste, die Kundennamen enthält und die geändert werden kann (MutableList).

Um den Code durch Predicates und Functions nicht zu verkomplizieren, gibt es in GSC die Möglichkeit, in den Klassen zum Beispiel eine statische Variable anzulegen, sodass in unserem Fall die Funktion in der Klasse Customer zu finden wäre, inklusive anderer Predicates und Funktionen, die die Klasse Customer betreffen. Das verbessert zwar die Lesbarkeit des Codes, ist aber nichtsdestotrotz eher eine Verlagerung von schlecht lesbaren Klassen, die von Function bzw. Predicate ableiten.

Natürlich bietet GSC eine breite Palette an fertigen Predicates, wie zum Beispiel greaterThan, attributeGreaterThanOrEqualTo und auch eine breite Palette an fertigen Functions, wie zum Beispiel size, mapKeys, mapValues usw. Es können dadurch viele Business Cases abgedeckt werden. Aber was ist mit Java 8? GSC profitiert immens von der Lambda-Syntax, die in Java 8 eingeführt wurde.

Java 8 Streams und GSC

Tabelle 1 stellt Java 8 Streams und GSC gegenüber.

Tabelle 1: Java 8 Streams und GSC

Tabelle 1: Java 8 Streams und GSC

Es wird offensichtlich, warum GSC die fast 30-fache Menge an Code enthält. Es gibt viele zusätzliche Optionen für jeden Geschmack: Die Collections können immutable sein, Collections mit Primitives werden unterstützt, es gibt Multimaps, Multisets und Bimaps. Und bei GSC darf man sich zwischen eager und lazy entscheiden, wohingegen Java Streams nur lazy loading bietet.

JMH-Benchmark-Tests von GSC

Der Banchmark-Test-Einsatz, um Performance von Java-Frameworks in JVM zu messen, ist ein viel diskutiertes Thema. Es ist allgemein bekannt, dass Benchmark Testing viele Fallen verbirgt, sodass manchmal die leeren Methoden scheinbar schlechtere Performanceergebnisse liefern als jene Methoden, die mehrere Kalkulation durchführen. Daher wollen wir auf selbst geschriebene Benchmark-Tests verzichten und uns lieber mit fertigen Benchmarking-Frameworks beschäftigen. Davon gibt es auch relativ viele, wie zum Beispiel Caliper, JMH, Da Capo usw.

GS Collections bietet viele einsatzbereite Benchmark-Tests, die sich mithilfe von JMH ausführen lassen. JMH ist ein Benchmark-Framework und wurde als ein Teil des OpenJDK-Projekts entwickelt. JMH misst die Ergebnisse in Form von Operationen pro Millisekunde (ops/ms), falls BenchmarkMode als „Throughput“ definiert und OutputTimeUnits als TimeUnit.MILLISECONDS gesetzt ist (es ist konfigurierbar). Je höher der „Thoughput“-Wert, desto besser, denn das bedeutet, dass wir viel mehr Operationen innerhalb derselben Zeit durchführen konnten. Es gibt auch weitere Benchmark Modes, um zum Beispiel die Durchschnittszeit zu berechnen. JMH wird oft eingesetzt, um bestimmte Codeteile Stresstests zu unterziehen und um die Nebenläufigkeit (Concurrency) zu überprüfen.

Für jeden einzelnen JMH-Test kann mithilfe von Annotations konfiguriert werden, wie viele Iterationen für Warmup und wie viele Iterationen für die Messung durchgeführt werden sollen. Die Iterationen werden in einem eigenständigen Java-Prozess (Fork) gestartet, damit die Ergebnisse von vorigen Testiterationen nicht die Ergebnisse von jetzigen Testiterationen beeinflussen. Die Anzahl von Java-Prozessen ist auch konfigurierbar.

Wie funktioniert das Ganze? Zuerst erstellen wir unseren JMH-Test. Wir können den JMH-Test am besten gleich in einem eigenen Projekt anlegen. Und Apache Maven bietet für JMH Benchmark Testing die volle Unterstützung. Wir können ein Maven-Archetyp-Projekt nutzen, sodass die pom-Datei und der erste JMH-Test generiert werden. Es gibt auch IDE-Plug-in-Unterstützung zum Ausführen von JMH-Tests, weil die nicht wie einfache JUnit-Tests ausgeführt werden. Die im src-Verzeichnis mit Annotations erstellten Tests werden im targetVerzeichnis neu generiert, unter generated sources abgelegt und in eine JAR-Datei verpackt. Er reicht dafür aus, auf dem Maven-Projekt „package“ durchzuführen. Anschließend können die Tests als einzelne JARs ausgeführt werden (dafür sollte das Maven-Shade-Plug-in zur Hilfe gezogen werden, dem auch der Name von zu generierenden Benchmark-JARs übergeben werden kann):

java -jar target/microbenchmarks.jar

Aggregation-Tests

Da wir nicht alle Möglichkeiten abdecken können, vergleichen wir die häufigsten Fälle, und zwar Aggregation. FastList von GSC wird mit ArrayList verglichen. Die Listen enthalten die Position – jeweils als FastList<Position> und als ArrayList<Position> (Abb. 1).

Abb. 1: UML-Diagramm mit Beziehung zwischen Klassen Customer, Product und Position

Abb. 1: UML-Diagramm mit Beziehung zwischen Klassen Customer, Product und Position

Es gelten folgende Parameter bei jedem Test:

  • 10 forked JVM-Tests
  • 20 Warmup-Iterationen pro JVM
  • 10 Testiterationen zum Messen (Measurement-Test) pro JVM
  • 2 Sekunden pro Iteration

Wir haben jeweils drei Aggregationsfälle:

  • Product
  • Category
  • Account

Die Klasse MarketValueStatistics gehört nicht zum Domänenmodell. Sie leitet sich von java.util.DoubleSummaryStatistics ab und dient als Sammelklasse. Der Code fürs JDK sieht wie in Listing 2 aus.

Listing 2
public void aggregateByProduct_serial_lazy_jdk()
{
  Map<Product, DoubleSummaryStatistics> productDoubleMap =
    this.jdkPositions.stream().collect(
    Collectors.groupingBy(
      Position::getProduct,
        Collectors.summarizingDouble(Position::getMarketValue)));
    Assert.assertNotNull(productDoubleMap);
}

public void aggregateByAccount_serial_lazy_jdk()
{
  Map<Account, DoubleSummaryStatistics> accountDoubleMap =
    this.jdkPositions.stream().collect(
    Collectors.groupingBy(
      Position::getAccount,
      Collectors.summarizingDouble(Position::getMarketValue)));
  Assert.assertNotNull(accountDoubleMap);
}

public void aggregateByCategory_serial_lazy_jdk()
{
  Map<String, DoubleSummaryStatistics> categoryDoubleMap =
    this.jdkPositions.stream().collect(
    Collectors.groupingBy(
      Position::getCategory,
        Collectors.summarizingDouble(Position::getMarketValue)));
  Assert.assertNotNull(categoryDoubleMap);
}
...

Sowohl fürs JDK als auch für GSC führen wir die Tests im parallelen und seriellen Modus aus. Fürs JDK erreichen wir das, indem wir statt stream() die Methode parallelStream() aufrufen. Wie sieht unser Code für GSC aus? Das zeigt Listing 3.

Listing 3
public void aggregateByProduct_parallel_lazy_gsc()
{
  MapIterable<Product, MarketValueStatistics> productDoubleMap =
    this.gscPositions.asParallel(this.executorService, BATCH_SIZE)
  .aggregateBy(
    Position::getProduct,
    MarketValueStatistics::new,
    MarketValueStatistics::acceptThis);
  Assert.assertNotNull(productDoubleMap);
}

public void aggregateByCategory_serial_lazy_gsc()
{
  MapIterable<String, MarketValueStatistics> productDoubleMap =
    this.gscPositions.asLazy().aggregateBy(
    Position::getCategory,
      MarketValueStatistics::new,
      MarketValueStatistics::acceptThis);
  Assert.assertNotNull(productDoubleMap);
}
...

GSC hat auch jeweils sechs Tests, seriell und parallel, für jeweils drei Aggregationskategorien, wie auch im vorigen Fall mit dem JDK: Product, Category, Account. An dieser Stelle ist interessant, dass GSC im Gegensatz zum JDK nicht die Arbeit des Berechnens von BatchSize und der Anzahl von Threads in ExecutorService übernimmt. Normalerweise sollte BatchSize folgendermaßen berechnet werden: 1 + size/(8 * Anzahl von Prozessoren). Es scheint relativ einfach zu berechnen zu sein, aber leider kann die Methode Runtime.getRuntime().availableProcessors() nicht immer die Anzahl von Prozessoren liefern, sondern nur die Anzahl von Hyperthreads, Sockets usw. Es ist sehr stark hardwareabhängig. Abbildung 2 zeigt die Ergebnisse von Tests.

Abb. 2: Vergleich von Benchmark-Testergebnissen: JDK vs. GSC; 4 CPUs, CentOs 6.5

Abb. 2: Vergleich von Benchmark-Testergebnissen: JDK vs. GSC; 4 CPUs, CentOs 6.5

Wie darin deutlich zu sehen ist, hat in vielen Fällen das JDK bessere Ergebnisse als GSC. Warum? Es gibt nur knappe 26 Kategorien (so viele, wie Buchstaben im englischen Alphabet, da jede Kategorie jeweils einen Buchstaben als Namen hat), aber ca. 100 000 Accounts in unserem Testfall (Accounts werden durch org.apache.commons.lang.RandomStringUtils.randomNumeric(5) erstellt). GSC nutzt eine einzelne ConcurrentMap für die Ergebnisse. Jede Task schreibt in diese ConcurrentMap gleichzeitig, und dieser Zugriff bereitet GSC hohe Performanceverluste bei wenigen Keys (aggregateByCategory-Testfall). Aber das Problem betrifft GSC nicht, wenn es sehr viele Keys gibt (aggregateByProduct-Testfall). Das JDK hingegen nutzt fork/join für jede Task, und jede Task erstellt eine eigene Map. Anschließend werden die Maps gemerged, was größere Performanceverluste bedeutet. Das JDK weist in diesem Fall keine bessere Performance auf als GSC.

Manchmal liefert GSC im eager-Modus bessere Ergebnisse als im lazy-Modus. Das JDK bietet, wie erwähnt, nur den lazy-Modus.

Set-Tests

Die Set-Tests bieten einen ganz anderen Einblick in die Performance des JDK im Vergleich zu GSC, wie in Abbildung 3 zu sehen ist.

Abb. 3: Vergleich von Set-Benchmark-Testergebnissen: JDK vs. GSC; 4 CPUs, CentOs 6.5

Abb. 3: Vergleich von Set-Benchmark-Testergebnissen: JDK vs. GSC; 4 CPUs, CentOs 6.5

Sowohl in den count als auch in den addAll-Testfällen liefert GSC immer bessere Ergebnisse als das JDK. Die addAll-Tests sind einfach aufgebaut, sowohl im JDK als auch in GSC, sodass das gleiche Set 1 000 Mal in das vorhandene Set gemerged wird. Die count-Testfälle führen die Filterung durch und liefern die geraden Zahlen. Sowohl mit seriellen als auch mit parallelen Streams bietet GSC eine hervorragende Leistung. Der eager-Modus von GSC bietet sogar noch eine kleinere Verbesserung dem lazy-Modus gegenüber. Wir dürfen nicht vergessen, dass JDK für HashSets intern ein HashMap nutzt und damit die Operationen durchführt, was natürlich auch Nachteile mit sich bringt.

Fazit

Es lohnt sich, die Goldman-Sachs-Collections-Bibliothek auszuprobieren. Der Einstieg ist durch das Code-Kata-Projekt viel leichter geworden, und die Performancetests mithilfe von JMH-Tests stehen frei zur Verfügung. Natürlich ist der Einsatz nicht für jedes Geschäftsmodell empfehlenswert. Wenn die Applikation mehr oder weniger die sortierten Daten aus der Datenbank lädt und anzeigt, ohne die Daten zu transformieren, dann wird wahrscheinlich die Umstellung von JDK Collections auf GSC nicht unbedingt die erhofften Vorteile bringen. Wenn wir uns aber im Bereich der Umwandlung, Sortierung, Filterung usw. von sehr großen Collections bzw. Sets befinden, dann sollte man sich unbedingt die Zeit nehmen und sich mit GSC auseinandersetzen.

Aufmacherbild: Two Samurai in duel stance facing each other on grass field von Shutterstock / Urheberrecht: rudall30

Geschrieben von
Nataliya Wierts
Nataliya Wierts
Nataliya Wierts arbeitet als Java Freelancer in Hannover. Sie entwickelt seit über zehn Jahren Java-Applikationen sowohl im Frontend als auch in Backend-Bereich.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: