Java Performance-Optimierung mit Agentic AI: Autonome Diagnostik und umsetzbare Empfehlungen

Java-Performance-Diagnose in Production ist manuell, langsam und erfordert tiefgreifendes Expertenwissen. Es kommt ein Grafana-Alert, ein Thread Dump wird geholt, ein JFR Recording heruntergeladen, in JDK Mission Control geöffnet, Flamegraphs werden angestarrt, mit Metriken korreliert — stundenlange Arbeit pro Incident. Die meisten Teams haben keinen dedizierten Performance Engineer, sodass Alerts entweder ignoriert werden oder in generischen „füg mehr Replicas hinzu”-Antworten ende

Wir haben ein System gebaut, das dies autonom erledigt. Wenn ein Monitoring-Alert ausgelöst wird, werden Profiling-Daten und Thread Dumps gesammelt, Runtime-Metriken extrahiert, Flamegraphs generiert und alles an ein AI-Modell geschickt, das einen strukturierten Performance-Report erstellt — inklusive Root-Cause-Analyse und konkreten Code-Fixes mit Dateipfaden und Zeilennummern aus dem tatsächlichen Source Repository. Die Pipeline läuft auf Kubernetes, ausgelöst durch Grafana Webhooks, mit Ergebnissen in Amazon S3.

Die Beispielanwendung ist auf GitHub [1] verfügbar, und der Java on AWS Workshop [2] führt durch das komplette Setup.

Der Vier-Phasen-Ansatz

Das ist kein Chatbot, dem Fragen gestellt werden, sondern ist ein event-driven autonomes System mit vier Phasen:

Phase 1 — Continuous Profiling. async-profiler läuft als JVM Agent und zeichnet Wall-Clock Profiles im JFR-Format alle 30 Sekunden auf. Fertige Recordings werden über den Mountpoint for Amazon S3 CSI Driver nach Amazon S3 verschoben.

Phase 2 — Metrik-Erfassung und Alerting. Spring Boot Actuator stellt Prometheus-Metriken bereit. Grafana scrapt Request Rates und sendet einen Webhook, wenn Schwellenwerte überschritten werden.

Phase 3 — Deterministische Datenextraktion. Java-Code parst die binäre JFR-Datei in Runtime-Metriken (CPU Load, GC Heap, JVM Info), generiert Collapsed Stacks mithilfe der jfr-converter-Bibliothek von async-profiler, erzeugt einen HTML Flamegraph und sammelt einen Thread Dump vom Ziel-Pod.

Phase 4 — AI-gestützte Root-Cause-Analyse. Die extrahierten Daten gehen über Spring AI an Amazon Bedrock. Das Modell korreliert Collapsed Stacks mit Thread States, schlägt optional Source Code von GitHub per Tool Calling nach und erstellt einen strukturierten Report mit Health Assessment, Findings und priorisierten Empfehlungen.

Die zentrale Architekturentscheidung: Deterministischer Code übernimmt strukturierte, wiederholbare Arbeit (Binärdateien parsen, Flamegraphs generieren); die AI übernimmt Interpretation und Korrelation. Ein LLM sollte keine Binärdateien parsen.

Continuous Profiling mit async-profiler

Die Entscheidung fiel auf Wall-Clock Profiling statt CPU Profiling, weil Wall-Clock die gesamte Ausführungszeit einschließlich Wartezuständen erfasst — I/O Waits, Lock Contention, Thread Parking. CPU Profiling zeigt nur aktive CPU-Ausführung. In Produktion sind die schwerwiegendsten Probleme oft blockierende Aufrufe, die in CPU Profiles unsichtbar sind, aber als breite Balken in Wall-Clock Profiles sichtbar werden.

Das Container Image bündelt async-profiler:

RUN cd /tmp && \
 wget -q https://github.com/async-profiler/async-profiler/releases/download/v4.3/async-profiler-4.3-linux-x64.tar.gz && \
 mkdir /async-profiler && \
 tar -xzf ./async-profiler-4.3-linux-x64.tar.gz -C /async-profiler --strip-components=1
COPY --from=builder /async-profiler/ /async-profiler/

Zur Laufzeit startet die JVM mit dem angehängten Profiler Agent:

java -agentpath:/async-profiler/lib/libasyncProfiler.so=start,event=wall,file=/tmp/profile-%t.jfr,loop=30s \
 -jar -Dserver.port=8080 /store-spring.jar

event=wall aktiviert Wall-Clock Sampling. loop=30s rotiert die Ausgabe alle 30 Sekunden. Das JFR-Format ist kompakt — 50 bis 270 KB pro Snapshot — und enthält Stack Traces, CPU Load, GC Heap State und JVM-Informationen.

Eine Hintergrundschleife verschiebt fertige JFR-Dateien nach S3, wobei die neueste Datei übersprungen wird, um das Verschieben eines noch laufenden Recordings zu vermeiden (Mountpoint for Amazon S3 unterstützt keine sequentiellen Schreibvorgänge):

while true; do sleep 10;
  newest=$(ls /tmp/profile-*.jfr 2>/dev/null | sort -r | head -1);
  for f in /tmp/profile-*.jfr; do
 [ -f "$f" ] || continue;
 [ "$f" = "$newest" ] && continue;
 mv "$f" /s3/profiling/$HOSTNAME/;
  done;
done &

Der Mountpoint for Amazon S3 CSI Driver stellt den Amazon S3 Bucket als lokales Filesystem Mount dar, sodass der mv-Befehl direkt nach S3 schreibt, ohne SDK-Aufrufe aus der Anwendung.

Deterministische Datenextraktions-Pipeline

Bevor das AI-Modell irgendetwas sieht, werden durch Java-Code strukturierte Daten aus dem binären JFR Recording extrahiert. JfrParser nutzt die eingebaute jdk.jfr.consumer.RecordingFile API, um durch die Events zu iterieren:

try (var recording = new RecordingFile(jfrFile)) {
    while (recording.hasMoreEvents()) {
        RecordedEvent event = recording.readEvent();
        switch (event.getEventType().getName()) {
            case "profiler.WallClockSample", "jdk.ExecutionSample" -> totalSamples++;
            case "jdk.CPULoad" -> cpuLoads.add(new CpuLoad(
                getDouble(event, "jvmUser"), getDouble(event, "jvmSystem"),
                getDouble(event, "machineTotal")));
            case "jdk.GCHeapSummary" -> gcHeaps.add(new GcHeap(
                getLong(event, "heapUsed"), committed));
            case "jdk.JVMInformation" -> jvmInfo = getString(event, "jvmVersion")
                + " | args: " + getString(event, "jvmArguments");
        }
    }
}

Für die Stack-Trace-Analyse wird an die jfr-converter-Bibliothek von async-profiler (tools.profiler:jfr-converter:4.3) delegiert. Sie erzeugt zwei Outputs: Collapsed Stacks (Textformat für das AI-Modell) und einen HTML Flamegraph (interaktive Visualisierung für Menschen):

// Collapsed stacks — one line per unique stack with sample count
var args = new Arguments("--wall", jfrFile.toString(), tempCollapsed.toString());
args.output = "collapsed";
JfrToFlame.convert(jfrFile.toString(), tempCollapsed.toString(), args);

// HTML flamegraph with optional include filter for application frames
var args = new Arguments("--wall", "--inverted", "--include", includePattern,
    jfrFile.toString(), tempHtml.toString());
JfrToFlame.convert(jfrFile.toString(), tempHtml.toString(), args);

Collapsed Stacks sind besserer Model Input als rohe JFR-Daten oder Flamegraph-Bilder: Sie sind Text (kein Vision Model nötig), haben korrekte Frame-Zuordnung durch die eigene Logik von async-profiler und enthalten Sample Counts zur Quantifizierung der relativen Auswirkung. Der HTML Flamegraph bekommt einen optionalen --include-Filter (z. B. .*unicorn.*), um für die menschliche Betrachtung auf Application Frames zu fokussieren, während die an das Modell gesendeten Collapsed Stacks ungefiltert bleiben.

Thread Dumps kommen vom Spring Boot Actuator /actuator/threaddump Endpoint per HTTP. Wenn der Pod nicht erreichbar ist, läuft die Analyse ohne weiter — JFR-Daten allein reichen für nützliche Ergebnisse.

Die AI-Analyse-Engine

AiService nutzt Spring AI’s ChatClient, um die extrahierten Daten an Amazon Bedrock zu senden. Der System Prompt legt die Rolle der AI fest:

private static final String SYSTEM_PROMPT = """
    You are a Java performance engineer. You receive two data sources \
    from a production Spring Boot application running on Amazon EKS: \
    a JFR profiling summary (collapsed stacks, CPU load, GC heap, JVM info) \
    and a thread dump snapshot. Both were captured around the time \
    a monitoring alert fired. Analyze the data and report what you find.""";

Der User Prompt wird aus drei Abschnitten zusammengesetzt: JFR Runtime-Metriken (CPU Load Durchschnitte, GC Heap Ranges, JVM-Version und -Argumente), Collapsed Stacks mit Sample Counts und der Thread Dump. Der Prompt fordert ein strukturiertes Ausgabeformat an — Health Assessment, Findings und Recommendations — mit Anweisungen, spezifische Methoden, Thread-Namen und Zahlen zu zitieren.

Was dies über ein einfaches Prompt-and-Response hinaushebt, ist Tool Calling. Wenn eine GitHub Repository URL konfiguriert ist, registriert AiService ein GitHubSourceCodeTool beim ChatClient:

if (!repoUrl.isBlank()) {
    var tool = new GitHubSourceCodeTool(repoUrl, token, repoPath);
    builder.defaultTools(tool);
}

Das Tool nutzt Spring AI’s @Tool Annotation:

@Tool(description = "Fetch a source code file from the application GitHub repository. " +
      "Provide the path relative to the application root, e.g. " +
      "src/main/java/com/example/MyClass.java")
public String fetchSourceCode(String filePath) {
    var fullPath = repoPath.isEmpty() ? filePath : repoPath + "/" + filePath;
    var json = restClient.get()
        .uri("/contents/{path}", fullPath)
        .retrieve()
        .body(String.class);
    var node = MAPPER.readTree(json);
    var encoded = node.get("content").asText();
    return new String(Base64.getMimeDecoder().decode(encoded));
}

Während der Analyse entscheidet das Modell autonom, welche Source Files nachgeschlagen werden sollen, basierend auf Methoden, die in den Collapsed Stacks und im Thread Dump gefunden werden. Spring AI übernimmt das Tool Calling Protocol mit Amazon Bedrock automatisch. Das Ergebnis: Statt generischem „es sollte erwogen werden, dies asynchron zu machen” werden Empfehlungen geliefert, die UnicornService.java:87 mit dem aktuellen blockierenden Code und einem konkreten Replacement referenzieren.

Jede Analyse erzeugt 5 korrelierte Artefakte, die in S3 gespeichert werden: die rohe JFR-Binärdatei (für erneute Analyse), die Profiling-Zusammenfassung mit Collapsed Stacks, den Thread Dump Snapshot, den interaktiven HTML Flamegraph und den AI-generierten Performance Report. Der Report enthält ein Health Assessment, Findings, die Collapsed Stacks mit Thread States korrelieren, und priorisierte Empfehlungen mit konkreten Code-Fixes, die auf spezifische Source Files und Zeilennummern verweisen:

Event-driven Orchestrierung

Die Komponenten werden über Grafana Webhooks auf Kubernetes verbunden. Wenn die HTTP-POST-Request-Rate den konfigurierten Schwellenwert überschreitet, sendet Grafana einen Webhook an den /webhook-Endpoint des Analyzers mit dem Pod-Namen und der IP-Adresse, extrahiert aus den Prometheus Labels.

WebhookController validiert den Alert und übergibt ihn an AnalyzerService, der die Analyse-Pipeline asynchron mit Virtual Threads ausführt — der Webhook kehrt sofort mit einer Bestätigung zurück:

private final ExecutorService asyncExecutor = Executors.newVirtualThreadPerTaskExecutor();

public WebhookResponse processAlerts(List<Alert> alerts) {
    for (var alert : alerts) {
        asyncExecutor.submit(() -> processAlert(alert));
    }
    return new WebhookResponse("Accepted alerts for processing", alerts.size());
}

Die Pipeline enthält Retry-Logik für noch nicht fertig geschriebene JFR-Dateien — bis zu 10 Retries mit 30-Sekunden-Delays, wobei geprüft wird, ob die JFR-Datei tatsächliche Samples enthält (eine Datei mit 0 Samples bedeutet, dass der Profiler noch nicht fertig geschrieben hat). Jede Analyse erzeugt 5 korrelierte Artefakte, die in S3 gespeichert werden: die rohe JFR-Binärdatei (für erneute Analyse), die Profiling-Zusammenfassung mit Collapsed Stacks (Modelleingabe), den Thread Dump Snapshot, den interaktiven HTML Flamegraph und den AI-generierten Performance Report. Alle Dateien teilen sich ein Datetime-Prefix, das aus dem JFR-Dateinamen extrahiert wird, für einfache Korrelation.

Deployment und Sicherheit

Der Analyzer läuft als Spring Boot 4 Application auf Java 25 mit aktivierten Virtual Threads. Das Container Image wird mit Jib gebaut — kein Docker Daemon nötig, der Build erfolgt direkt aus Maven:

mvn compile jib:build -Dimage=${ECR_URI}:latest

Auf Amazon EKS läuft der Service in einem monitoring Namespace mit EKS Pod Identity für den Zugriff auf Amazon S3 und Amazon Bedrock — keine statischen Credentials, keine IAM Access Keys. Mit Amazon EKS Auto Mode werden Compute, Storage und Networking vollständig gemanagt, sodass der Fokus auf dem Workload statt auf der Cluster-Infrastruktur liegt. Das Deployment ist ein Standard-Kubernetes-Manifest mit einem ClusterIP Service (nur intern), Health Probes über Actuator und Resource Limits von 1 CPU / 2 GB Memory.

env:
- name: SPRING_AI_BEDROCK_CONVERSE_CHAT_OPTIONS_MODEL
  value: "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
- name: GITHUB_REPO_URL
  value: "https://api.github.com/repos/aws-samples/java-on-aws"
- name: GITHUB_REPO_PATH
  value: "apps/unicorn-store-spring"
- name: FLAMEGRAPH_INCLUDE
  value: ".*unicorn.*"

Das global.-Prefix bei der Model ID aktiviert Amazon Bedrock Cross-Region Inference — Requests werden automatisch an die nächste verfügbare Region geroutet, was die Verfügbarkeit verbessert und die Latenz reduziert.

Was wir gelernt haben

Die Grenze zwischen deterministisch und AI ist entscheidend. Ein LLM sollte keine Binärdaten parsen oder Flamegraphs generieren. jdk.jfr.consumer und jfr-converter sind deterministisch, schnell und korrekt. Die Aufgabe der AI ist die Interpretation — das Verbinden von Mustern über verschiedene Datenquellen hinweg, wofür ein Mensch Stunden bräuchte.

Collapsed Stacks sind das richtige Eingabeformat für das Modell. In Betracht gezogen wurden Flamegraph-Bilder (multimodal), rohe JFR Events und Thread Dumps allein. Collapsed Stacks haben gewonnen: Textformat (kein Vision Model nötig), korrekte Frame-Zuordnung durch async-profiler, Sample Counts zur Quantifizierung der Auswirkung, kompakt genug, um zusammen mit Thread Dumps in einen einzelnen Prompt zu passen.

Source Code Tool Calling transformiert die Ausgabequalität. Ohne: „Es sollte daüber nachgedacht werden, dies asynchron zu machen.” Mit: „In UnicornService.java:87 sollte publisher.publish().get() durch einen non-blocking whenComplete Callback ersetzt werden.” Der Unterschied zwischen einem Vorschlag und einem Pull Request.

Wall-Clock Profiling Overhead ist minimal. ~1–2 % in Production, akzeptabel für Continuous Profiling. Die 30-Sekunden-Rotation balanciert Granularität gegen Storage-Kosten.

Virtual Threads vereinfachen die Orchestrierung. Der Webhook kehrt sofort zurück, die Analyse läuft im Hintergrund. Kein Thread Pool Sizing, keine Reactive-Komplexität. Executors.newVirtualThreadPerTaskExecutor() und fertig.

Wie geht es weiter

Das aktuelle System analysiert jeden Alert unabhängig. Zwei Verbesserungen würden es deutlich nützlicher machen.

Knowledge Base mit internen Optimierungspraktiken. Jede Organisation hat interne Performance-Richtlinien — Connection Pooling Settings, Virtual Thread Best Practices, Datenbank-Tuning-Parameter. Die Integration einer RAG (Retrieval-Augmented Generation) Knowledge Base würde die Empfehlungen der AI in den tatsächlichen Standards des jeweiligen Teams verankern, statt generische Ratschläge zu geben. Amazon Bedrock Knowledge Bases bieten vollständig gemanagtes RAG — ein S3 Bucket mit Performance Runbooks wird verbunden, und das Modell ruft während der Analyse relevanten Kontext ab. Spring AI’s QuestionAnswerAdvisor macht daraus eine Konfigurationsänderung, keinen Rewrite. Das Integrationsmuster wurde in RAG Made Serverless: Amazon Bedrock Knowledge Base with Spring AI dokumentiert.

Memory für analyseübergreifende Korrelation. Derzeit ist jede Analyse stateless — das Modell weiß nicht, dass derselbe blockierende Aufruf letzte Woche in drei Analysen aufgetaucht ist, oder dass der GC-Druck seit dem letzten Deployment ansteigt. Durch das Hinzufügen von Memory könnten Findings über Analysen hinweg korreliert, wiederkehrende Muster erkannt und verfolgt werden, ob frühere Empfehlungen wirksam waren. Amazon Bedrock AgentCore bietet gemanagtes Memory mit Event Memory für die Analyse-Historie und Semantic Memory für langfristige Mustererkennung. Der Memory-Integrationsansatz wurde in AI Agent Memory Made Easy: Amazon Bedrock AgentCore Memory with Spring AI behandelt.

Zusammen würden diese Ergänzungen das System von einem Per-Incident-Diagnosetool in einen kontinuierlichen Performance-Berater verwandeln, der das Verhalten der Anwendung über die Zeit hinweg lernt.


Referenzen: 

[1] GitHub: https://github.com/aws-samples/java-on-aws 

[2] Workshop: https://catalog.workshops.aws/java-on-aws 

[3] Spring AI: https://docs.spring.io/spring-ai/reference/ 

[4] Amazon Bedrock: https://aws.amazon.com/bedrock/ 

[5] async-profiler: https://github.com/async-profiler/async-profiler

Möchten Sie tiefer einsteigen?
Yuriy Bezsonov und Sasch Möllering sind Speaker der JCON!
Dieser Artikel behandelt das Thema ihres JCON-Vortrags. Falls Du die Session verpasst hast, kein Problem! Das Video zum Talk ist nach der JCON verfügbar.

Total
0
Shares
Previous Post

Vaadin Security, Teil 4.3 – Bootstrap des ersten Administrators: vier Adapter, eine SetupView (zweimal) und Bilanz 

Next Post

Eine CalDAV-Testbench für die testgetriebene Entwicklung — Teil 1: Vom Problem zum Prüfstand

Related Posts