In-Vivo JVM Heap Introspection & Observability
Vom Integrations- zum Entwicklungsprojekt
Motivation
Angepasste und effiziente Cache-Mechanismen sind unentbehrlich für niedrige Latenz und hohe Leistung, nicht nur beim Betrieb einer Online-Plattform. Es sei denn, die gesamte Datenmenge passt in den Hauptspeicher. Besonders im Fall von Daten mit zufällig verteilten Zugriffsmustern bietet ein 100%-iger In-Memory-Ansatz gleichförmige, vorhersagbare, niedrige Latenz.
Da die Menge der Daten stetig wächst und die Nachteile einer einfachen Vergrößerung des Heap zunehmend kompromissbehaftet und teurer werden, tut man gut daran, die Instanzen möglichst speichereffizent abzubilden. Um dies zu optimieren, benötigt man Einblicke, die über den Code der Anwendung hinausgehen:
- das Speicherlayout eigener Klassen und ihrer Felder,
- ihre Anzahl und die Belegungshäufigkeit innerer Referenzen, sowie
- die Verteilung tatsächlicher Anzahl, Größe und Verwendung von Standard-Klassen wie Collections oder Strings,
- und all dies im Produktivbetrieb.
Lösung mit Bordmitteln?
Man möchte meinen, dass die Java Virtual Machine (JVM) mit all ihren Werkzeugen diese Art von Informationen bereitstellen kann. Es gibt dabei aber ein paar Haken.
Der kürzeste Weg zu einer Momentaufnahme wäre der Heap Dump. Dieser kann von der Produktivumgebung abgezogen und anderswo analysiert werden, sodass die Auswertung keine kostbaren Ressourcen bindet, auch wenn es ein Problem für sich sein kann, eine Datei dieser Größe zu transportieren.
Ein aussagekräftiger Heap Dump enthält nur lebende Objekte. Um diese Anforderung zu erfüllen, ist eine vollständige Garbage Collection (GC) notwendig. Diese sollte weitestgehend vermieden werden, weil sie trotz aller Parallelisierungsbemühungen der jüngeren Vergangenheit immer noch für einen gewissen Zeitraum die Ausführung der gesamten Anwendung anhält. Abhängig von der Größe und Zusammensetzung des Heap kann diese „stop-the-world GC“ leicht eine Größenordnung von Minuten erreichen. Selbst die Schritte der GC, die — normalerweise ohne schädliche Wirkung — parallel zur Anwendung laufen, werden durch die hohe Intensität der Performance abträglich.
Ein solcher Leistungsabfall ist normalerweise nicht tragbar und man müsste die zu untersuchende Anwendung aus dem Load Balancing entfernen – vorausgesetzt, der Betrieb ist redundant und kann kurzzeitig einer solchen Ressourcenverknappung standhalten.
Abgesehen von den Schwierigkeiten, einen Heap Dump zu beschaffen, bringen die Werkzeuge zur Analyse ihre individuellen Vor- und Nachteile mit sich. VisualVM z.B. benötigt bei einem Dump von rund 60 GiB mehrere Minuten, allein um die Datei zu öffnen; Objekte nach zurückgehaltener Größe zu sortieren, kann Tage dauern, selbst wenn das Arbeitsverzeichnis auf einer SSD der schnelleren Sorte liegt. Der Eclipse Memory Analyzer (MAT) benötigt auch einen Moment, aber nicht annähernd so lange wie VisualVM. Kommerzielle Lösungen auf der anderen Seite sind teils sehr mächtig, aber prohibitiv teuer für den täglichen Einsatz durch viele Entwickler.
Java Mission Control kombiniert mit Flight Recorder bieten nahezu Live-Monitoring einer Fülle von Metriken, bis hin zu Allokationsverhalten von Klassen und Code-Profiling. Je näher wir aber einer Momentaufnahme des Heap-Inhalts kommen, um so involvierter wird auch hier die GC.
Integration einer bestehenden Bibliothek in die Anwendung
Da die Instrumentierung der JVM in der Praxis eher unbefriedigende Ergebnisse liefert, stellt sich die Frage nach programmatischen Lösungen.
Das Projekt Java Object Layout (JOL) bietet seit Jahren tiefe Einblicke in die Interna der JVM, u.a. in das tatsächliche Layout von Klassen im Speicher. Ausgehend von einem oder mehreren Wurzelobjekten kann auch der Inhalt des gesamten dahinter verborgenen Referenzbaums analysiert werden. Die Maven-Abhängigkeit war schnell zum Projekt hinzugefügt. Von dort ging es bis zum ersten GraphLayout eines bekannten Speicherfressers nur noch bergab. Jedenfalls was die codeseitige Integration betraf; die ersten Testergebnisse waren dagegen durchwachsen.
Zum einen enthält ein GraphLayout Referenzen auf alle durchlaufenen Objekte. Bei Klassen, die einem stetigen Austausch unterliegen, ist diese Rückhaltung problematisch.
Weiter ist es nicht gerade klein. 1 GiB Echtdaten ergeben z.B. insgesamt 2.2 GiB, die im GraphLayout enthalten sind. Bedenkt man die Rechenzeit für die Erstellung und dass man keine sekundenaktuellen Ergebnisse braucht, so möchte man sie für eine sinnvolle Zeit zwischenspeichern. Bei dieser Größenordnung ist Caching aber undenkbar.
Schließlich sind die Ausgabeformate entweder nicht detailliert genug, oder aber viel zu geschwätzig: GraphLayout.toFootprint()
liefert nur den Gesamtverbrauch jeder Klasse ohne Hinweis über die Verschachtelung, wohingegen GraphLayout.toPrintable()
die tatsächlichen Speicherinhalte auf den Punkt widerspiegelt, ohne einen Hauch von Aggregation. Im letzteren Fall ist es nicht weiter schwierig, die Ausgaberoutine in eine Out-Of-Memory-Situation zu bringen.
Während also die Grundfunktionalität sehr zu begrüßen ist, lassen die Möglichkeiten zur Integration noch etwas zu wünschen übrig. Der Proof-Of-Concept (POC) betreffend trivialer Integration musste daher nach dem Fail-Fast-Ansatz als Fehlschlag deklariert werden.
Unveränderte Integration gescheitert, Anpassung möglich
Dank des Open-Source-Charakters des Originalprojekts ist es möglich, bestehende Funktionalität einfach zu ergänzen.
Der Aggregationscharakter der gewünschten Ausgaben legt eine oder mehrere Aggregationen direkt während des Durchlaufens der Speicherinhalte nahe. Somit verhält sich die Größe der Zwischenergebnisse proportional zur Komplexität der Strukturen, nicht zur Anzahl der betrachteten Objekte. Das macht Hoffnung auf verschiedene Ausgabetypen, die alle mit einem einzigen Heap-Durchlauf vorbereitet und gecached werden können, bis auf Anfrage eine lesbare Ausgabe erzeugt wird.
Grundkonzept ist ein Drill-Down in zwei Richtungen:
- Top-Down von beliebigem Wurzel-Objekt aus den Referenz-Graphen entlang, mit Fokus auf die zurückgehaltene Größe unterhalb.
- Bottom-Up als Verbrauch pro Klasse, aggregiert nach Verwendung, mit Fokus auf die flache eigene Größe, gruppiert nach Verwendung.
- Gruppierung jeweils nach enthaltendem Klasse-Feld-Paar.
Die Wunschvorstellung ist eine Art Tree/Grid/Explorer View, expand/collapse, evtl. ergänzt durch diverse angemessene grafische Darstellungen. Das MVP jedoch ist primitive ASCII-Art für Einrückung und Baumstruktur, mit Textausgabe wie im ursprünglichen Kommandozeilen-Tool.
Akzeptanzkriterien
- Die Heap-Durchwanderung muss maximal GC-freundlich operieren, um nicht eben jene Probleme zu verschärfen, die das Werkzeug zu vermeiden hilft.
- Zwischenergebnisse müssen zudem sehr kompakt sein. Sie behalten für einen gewissen Zeitraum ihre Gültigkeit, sind aber teuer in der Erstellung; daher sollten sie gecached werden.
- Weiter dürfen während der Ausgabephase keine gigantischen Puffer erzeugt werden.
Design
- Die Aggregation erfolgt ähnlich wie bei Stack Traces in Profilern. Der Pfad durch den Heap-Graphen bestimmt einen Lookup Key, der fürs Einfügen und Aufaddieren in einem Trie verwendet wird.
- In einem Nachverarbeitungsschritt wird der erzeugte Baum in eine speicherfreundliche Repräsentation überführt.
- Der ursprünglich beobachtete Buffer Bloat lässt sich trivial vermeiden, indem man die Ausgabe direkt in einen Writer vornimmt.
Entwicklungsmuster
- Der zweite POC entstand als Hack der ursprünglichen Codebasis. Aufgrund der unklaren Machbarkeit wurden Ideen direkt in Code ausgedrückt und im Debugger auf die Probe gestellt. So lässt sich initial gut vorankommen, bis die Grundarchitektur etabliert und die schwersten Probleme als lösbar bestätigt sind.
- Die Vorteile des explorativen Voranpreschens verlieren sich jedoch schnell. Als gangbarer Weg bewährte sich die Absicherung des status quo per Approval Testing, und der Übergang zu Test-Driven Development (TDD) und BabySteps für die folgenden inkrementellen Verbesserungen.
- Da die Performance hinsichtlich Laufzeit und Speicherverbrauch essenziell ist, wurde das Verhalten durch Profiling überwacht und ständig optimiert.
Ansätze zu Analyse und Optimierung
Nach wenigen Iterationen konnte das Werkzeug ohne negative Seiteneffekte produktiv eingesetzt werden. Zunächst wurde es mit sämtlichen In-Memory-Caches gefüttert, um deren Größen und Zusammensetzungen zu bestimmen. Dann stellten wir sämtliche Auffälligkeiten in Frage.
- Große Listen und Maps, die als LinkedList bzw. TreeMap implementiert sind, können durch ArrayList bzw. HashMap effizienter gestaltet werden, wenn die Algorithmen dies gestatten.
- Object-Arrays in Containerklassen, die viel ungenutzten Platz aufweisen, können evtl. konservativer initialisiert oder zur langfristigen Aufbewahrung auf die tatsächliche Größe gestutzt werden.
- Der Overhead für Boxing numerischer Typen kann stellenweise durch Verwendung der Trove-Collections vermieden werden.
- UnmodifiableMap etc. sollten durch ImmutableCollections ersetz werden, wo immer möglich.
- Ein strikt gekapselter Copy-On-Write-Ansatz auf primitiven Arrays kann manchmal eine SynchronizedCollection ersetzen. Im Falle vieler kurzer Listen steht der Overhead für die Container in keinem Verhältnis zum Inhalt. Diese Einsparung rechtfertigt jedoch nicht notwendigerweise, die Komplexität beliebig zu erhöhen.
Praktische Ergebnisse
Durch Analyse kritischer Komponenten in der Produktivumgebung konnten wir verschiedene Stellen identifizieren, an denen der tatsächliche Speicherverbrauch stark von der Erwartungshaltung der Entwickler abwich. Der Großteil davon war nach Betrachtung des Kontexts in der Codebasis leicht durch effizientere Implementierungen zu ersetzen. In manchen Fällen konnte der Overhead sogar komplett gestrichen werden. Einsparungen von 10-30% im jeweiligen Teilbereich waren die Folge; ein Extrembeispiel konnte gar um rund 85% reduziert werden. Allein durch diese „low-hanging fruits“ ist der permanente Speicherbedarf der Anwendung bereits um ca. 10% gesunken.
Beispielausgaben
LinkedList Footprint: COUNT % COUNT AVG SZ SUM RAW SUM % SUM DESCRIPTION 31 100.00 % -- 752 B 752 100.00 % (total) 10 32.26 % 24 240 B 240 31.91 % java.util.LinkedList$Node 10 32.26 % 24 240 B 240 31.91 % java.lang.String 10 32.26 % 24 240 B 240 31.91 % [B 1 3.23 % 32 32 B 32 4.26 % java.util.LinkedList LinkedList Heap Tree Drilldown: COUNT AVG SIZE RAW AVG SZ TOTAL SIZE RAW T SZ RETAINED CT PAR% R CT RETAINED SZ RAW R SZ PAR% R SZ DESCRIPTION 1 32 B 32 32 B 32 31 100.00 % 752 B 752 100.00 % (total) 1 32 B 32 32 B 32 31 100.00 % 752 B 752 100.00 % +--java.util.LinkedList 10 24 B 24 240 B 240 30 96.77 % 720 B 720 95.74 % | +--java.util.LinkedList$Node LinkedList.first/last 10 24 B 24 240 B 240 20 66.67 % 480 B 480 66.67 % | | +--java.lang.String Node.item 10 24 B 24 240 B 240 10 50.00 % 240 B 240 50.00 % | | | +--[B String.value [10 of 10 used (100.00 %)] LinkedList Class Histogram Drilldown: COUNT PAR% CT AVG SIZE RAW AVG SZ TOTAL SIZE RAW T SZ PAR% T SZ DESCRIPTION 31 100.00 % 24 B 24 752 B 752 100.00 % (total) 10 32.26 % 24 B 24 240 B 240 31.91 % +--java.util.LinkedList$Node 10 100.00 % 24 B 24 240 B 240 100.00 % | +--java.util.LinkedList$Node LinkedList.first/last 10 32.26 % 24 B 24 240 B 240 31.91 % +--java.lang.String 10 100.00 % 24 B 24 240 B 240 100.00 % | +--java.lang.String Node.item 10 100.00 % 24 B 24 240 B 240 100.00 % | | +--java.util.LinkedList$Node LinkedList.first/last 10 32.26 % 24 B 24 240 B 240 31.91 % +--[B 10 100.00 % 24 B 24 240 B 240 100.00 % | +--[B String.value 10 100.00 % 24 B 24 240 B 240 100.00 % | | +--java.lang.String Node.item 10 100.00 % 24 B 24 240 B 240 100.00 % | | | +--java.util.LinkedList$Node LinkedList.first/last 1 3.23 % 32 B 32 32 B 32 4.26 % +--java.util.LinkedList ArrayList Footprint: COUNT % COUNT AVG SZ SUM RAW SUM % SUM DESCRIPTION 22 100.00 % -- 560 B 560 100.00 % (total) 10 45.45 % 24 240 B 240 42.86 % java.lang.String 10 45.45 % 24 240 B 240 42.86 % [B 1 4.55 % 56 56 B 56 10.00 % [Ljava.lang.Object; 1 4.55 % 24 24 B 24 4.29 % java.util.ArrayList ArrayList Heap Tree Drilldown: COUNT AVG SIZE RAW AVG SZ TOTAL SIZE RAW T SZ RETAINED CT PAR% R CT RETAINED SZ RAW R SZ PAR% R SZ DESCRIPTION 1 24 B 24 24 B 24 22 100.00 % 560 B 560 100.00 % (total) 1 24 B 24 24 B 24 22 100.00 % 560 B 560 100.00 % +--java.util.ArrayList 1 56 B 56 56 B 56 21 95.45 % 536 B 536 95.71 % | +--[Ljava.lang.Object; ArrayList.elementData [10 of 10 used (100.00 %)] 10 24 B 24 240 B 240 20 95.24 % 480 B 480 89.55 % | | +--java.lang.String [i] 10 24 B 24 240 B 240 10 50.00 % 240 B 240 50.00 % | | | +--[B String.value [10 of 10 used (100.00 %)] ArrayList Class Histogram Drilldown: COUNT PAR% CT AVG SIZE RAW AVG SZ TOTAL SIZE RAW T SZ PAR% T SZ DESCRIPTION 22 100.00 % 25 B 25 560 B 560 100.00 % (total) 10 45.45 % 24 B 24 240 B 240 42.86 % +--java.lang.String 10 100.00 % 24 B 24 240 B 240 100.00 % | +--java.lang.String [i] 10 100.00 % 24 B 24 240 B 240 100.00 % | | +--[Ljava.lang.Object; ArrayList.elementData 10 45.45 % 24 B 24 240 B 240 42.86 % +--[B 10 100.00 % 24 B 24 240 B 240 100.00 % | +--[B String.value 10 100.00 % 24 B 24 240 B 240 100.00 % | | +--java.lang.String [i] 10 100.00 % 24 B 24 240 B 240 100.00 % | | | +--[Ljava.lang.Object; ArrayList.elementData 1 4.55 % 56 B 56 56 B 56 10.00 % +--[Ljava.lang.Object; 1 100.00 % 56 B 56 56 B 56 100.00 % | +--[Ljava.lang.Object; ArrayList.elementData 1 4.55 % 24 B 24 24 B 24 4.29 % +--java.util.ArrayList ImmutableCollections$ListN Footprint: COUNT % COUNT AVG SZ SUM RAW SUM % SUM DESCRIPTION 22 100.00 % -- 552 B 552 100.00 % (total) 10 45.45 % 24 240 B 240 43.48 % java.lang.String 10 45.45 % 24 240 B 240 43.48 % [B 1 4.55 % 56 56 B 56 10.14 % [Ljava.lang.Object; 1 4.55 % 16 16 B 16 2.90 % java.util.ImmutableCollections$ListN ImmutableCollections$ListN Heap Tree Drilldown: COUNT AVG SIZE RAW AVG SZ TOTAL SIZE RAW T SZ RETAINED CT PAR% R CT RETAINED SZ RAW R SZ PAR% R SZ DESCRIPTION 1 16 B 16 16 B 16 22 100.00 % 552 B 552 100.00 % (total) 1 16 B 16 16 B 16 22 100.00 % 552 B 552 100.00 % +--java.util.ImmutableCollections$ListN 1 56 B 56 56 B 56 21 95.45 % 536 B 536 97.10 % | +--[Ljava.lang.Object; ListN.elements [10 of 10 used (100.00 %)] 10 24 B 24 240 B 240 20 95.24 % 480 B 480 89.55 % | | +--java.lang.String [i] 10 24 B 24 240 B 240 10 50.00 % 240 B 240 50.00 % | | | +--[B String.value [10 of 10 used (100.00 %)] ImmutableCollections$ListN Class Histogram Drilldown: COUNT PAR% CT AVG SIZE RAW AVG SZ TOTAL SIZE RAW T SZ PAR% T SZ DESCRIPTION 22 100.00 % 25 B 25 552 B 552 100.00 % (total) 10 45.45 % 24 B 24 240 B 240 43.48 % +--java.lang.String 10 100.00 % 24 B 24 240 B 240 100.00 % | +--java.lang.String [i] 10 100.00 % 24 B 24 240 B 240 100.00 % | | +--[Ljava.lang.Object; ListN.elements 10 45.45 % 24 B 24 240 B 240 43.48 % +--[B 10 100.00 % 24 B 24 240 B 240 100.00 % | +--[B String.value 10 100.00 % 24 B 24 240 B 240 100.00 % | | +--java.lang.String [i] 10 100.00 % 24 B 24 240 B 240 100.00 % | | | +--[Ljava.lang.Object; ListN.elements 1 4.55 % 56 B 56 56 B 56 10.14 % +--[Ljava.lang.Object; 1 100.00 % 56 B 56 56 B 56 100.00 % | +--[Ljava.lang.Object; ListN.elements 1 4.55 % 16 B 16 16 B 16 2.90 % +--java.util.ImmutableCollections$ListN ConcurrentHashMap Footprint: COUNT % COUNT AVG SZ SUM RAW SUM % SUM DESCRIPTION 27 100.00 % -- 784 B 784 100.00 % (total) 10 37.04 % 24 240 B 240 30.61 % java.lang.String 10 37.04 % 24 240 B 240 30.61 % [B 5 18.52 % 32 160 B 160 20.41 % java.util.concurrent.ConcurrentHashMap$Node 1 3.70 % 80 80 B 80 10.20 % [Ljava.util.concurrent.ConcurrentHashMap$Node; 1 3.70 % 64 64 B 64 8.16 % java.util.concurrent.ConcurrentHashMap ConcurrentHashMap Heap Tree Drilldown: COUNT AVG SIZE RAW AVG SZ TOTAL SIZE RAW T SZ RETAINED CT PAR% R CT RETAINED SZ RAW R SZ PAR% R SZ DESCRIPTION 1 64 B 64 64 B 64 27 100.00 % 784 B 784 100.00 % (total) 1 64 B 64 64 B 64 27 100.00 % 784 B 784 100.00 % +--java.util.concurrent.ConcurrentHashMap 1 80 B 80 80 B 80 26 96.30 % 720 B 720 91.84 % | +--[Ljava.util.concurrent.ConcurrentHashMap$Node; ConcurrentHashMap.table [5 of 16 used (31.25 %)] 5 32 B 32 160 B 160 25 96.15 % 640 B 640 88.89 % | | +--java.util.concurrent.ConcurrentHashMap$Node [i] 10 24 B 24 240 B 240 20 80.00 % 480 B 480 75.00 % | | | +--java.lang.String 5 24 B 24 120 B 120 10 50.00 % 240 B 240 50.00 % | | | | +--Node.val 5 24 B 24 120 B 120 5 50.00 % 120 B 120 50.00 % | | | | | +--[B String.value [5 of 5 used (100.00 %)] 5 24 B 24 120 B 120 10 50.00 % 240 B 240 50.00 % | | | | +--Node.key 5 24 B 24 120 B 120 5 50.00 % 120 B 120 50.00 % | | | | | +--[B String.value [5 of 5 used (100.00 %)] ConcurrentHashMap Class Histogram Drilldown: COUNT PAR% CT AVG SIZE RAW AVG SZ TOTAL SIZE RAW T SZ PAR% T SZ DESCRIPTION 27 100.00 % 29 B 29 784 B 784 100.00 % (total) 10 37.04 % 24 B 24 240 B 240 30.61 % +--java.lang.String 10 100.00 % 24 B 24 240 B 240 100.00 % | +--java.util.concurrent.ConcurrentHashMap$Node 5 50.00 % 24 B 24 120 B 120 50.00 % | | +--Node.val 5 100.00 % 24 B 24 120 B 120 100.00 % | | | +--java.util.concurrent.ConcurrentHashMap$Node [i] 5 100.00 % 24 B 24 120 B 120 100.00 % | | | | +--[Ljava.util.concurrent.ConcurrentHashMap$Node; ConcurrentHashMap.table 5 50.00 % 24 B 24 120 B 120 50.00 % | | +--Node.key 5 100.00 % 24 B 24 120 B 120 100.00 % | | | +--java.util.concurrent.ConcurrentHashMap$Node [i] 5 100.00 % 24 B 24 120 B 120 100.00 % | | | | +--[Ljava.util.concurrent.ConcurrentHashMap$Node; ConcurrentHashMap.table 10 37.04 % 24 B 24 240 B 240 30.61 % +--[B 10 100.00 % 24 B 24 240 B 240 100.00 % | +--[B String.value 10 100.00 % 24 B 24 240 B 240 100.00 % | | +--java.util.concurrent.ConcurrentHashMap$Node 5 50.00 % 24 B 24 120 B 120 50.00 % | | | +--Node.val 5 100.00 % 24 B 24 120 B 120 100.00 % | | | | +--java.util.concurrent.ConcurrentHashMap$Node [i] 5 100.00 % 24 B 24 120 B 120 100.00 % | | | | | +--[Ljava.util.concurrent.ConcurrentHashMap$Node; ConcurrentHashMap.table 5 50.00 % 24 B 24 120 B 120 50.00 % | | | +--Node.key 5 100.00 % 24 B 24 120 B 120 100.00 % | | | | +--java.util.concurrent.ConcurrentHashMap$Node [i] 5 100.00 % 24 B 24 120 B 120 100.00 % | | | | | +--[Ljava.util.concurrent.ConcurrentHashMap$Node; ConcurrentHashMap.table 5 18.52 % 32 B 32 160 B 160 20.41 % +--java.util.concurrent.ConcurrentHashMap$Node 5 100.00 % 32 B 32 160 B 160 100.00 % | +--java.util.concurrent.ConcurrentHashMap$Node [i] 5 100.00 % 32 B 32 160 B 160 100.00 % | | +--[Ljava.util.concurrent.ConcurrentHashMap$Node; ConcurrentHashMap.table 1 3.70 % 80 B 80 80 B 80 10.20 % +--[Ljava.util.concurrent.ConcurrentHashMap$Node; 1 100.00 % 80 B 80 80 B 80 100.00 % | +--[Ljava.util.concurrent.ConcurrentHashMap$Node; ConcurrentHashMap.table 1 3.70 % 64 B 64 64 B 64 8.16 % +--java.util.concurrent.ConcurrentHashMap
Ausblick
- Verbesserte User Experience durch Ausgabe in komfortabler Grid View und Visualisierung z.B. via Apache echarts.
- Überarbeitung der Pfad-Lookup-Generierung und Erweiterung der Manipulationsmöglichkeiten für übersichtlichere Darstellungen und neuartige Ansichten.
- Weitere Refactorings und Optimierungen der Anwendung, wo kosteneffizient oder notwendig.
- Detailverbesserungen des Analysewerkzeugs, sowie diese aus der Verwendung hervorgehen.
Quellcode
- jol im Chrono24-GitHub (Fork mit angepassten Basisklassen)
- jol-addons im Chrono24-GitHub (neu hinzugefügte Erweiterungen)