62 JDK-Interceptionpunkte: In-Process Chaos Engineering mit einem Java Agent
Engineering category.testing 26. Juni 2026

62 JDK-Interceptionpunkte: In-Process Chaos Engineering mit einem Java Agent

Ein Java Agent, der 62 JDK-Aufrufstellen mit ByteBuddy instrumentiert. Isolation pro Test-Session, Spring Boot 3/4 Auto-Wiring und nahezu null JIT-Overhead. Chaos Engineering, das inline in Ihrer JUnit-Testsuite läuft und den Build absichert.

E
Engineering Team
Senior Solutions Architects
18 Min. Lesezeit

Die Schicht, die Toxiproxy nicht erreicht

Toxiproxy ist ausgezeichnet. tc netem ist ausgezeichnet. Die LD_PRELOAD-Chaos-Bibliotheken sind ausgezeichnet. Sie alle operieren an der Netzwerk- oder OS-Grenze – sie fangen TCP-Verbindungen, Paketauslieferung und Systemaufrufe ab.

Aber folgendes können sie alle nicht sehen: Was passiert innerhalb Ihres HikariPool, wenn er versucht, eine Verbindung auszuleihen und auf einen Timeout trifft. Was passiert innerhalb Ihres ScheduledExecutorService, wenn er gesättigt ist. Was passiert innerhalb der SSL-Implementierung des JDK, wenn es eine Neuübertragung erkennt. Was passiert innerhalb Ihres Thread-Pools, wenn Aufgaben schneller einlaufen, als Arbeiter sie abarbeiten können.

Das sind JVM-interne Fehlermodi. Sie äußern sich nicht als Netzwerkfehler auf der TCP-Ebene. Sie äußern sich als Exceptions, die von JDK-Klassen geworfen werden – `SQLException`, `RejectedExecutionException`, `SSLHandshakeException`, `TimeoutException` – und als Muster der Ressourcenerschöpfung, die erst sichtbar werden, wenn man die richtigen Aufrufstellen innerhalb der JVM instrumentiert.

Das ist die Lücke, die der JVM-Chaos-Agent füllt. Er ersetzt nicht die LD_PRELOAD-Schicht – er ergänzt sie. Zusammen bieten sie Fehlerinjektion sowohl an der OS-Grenze als auch an der JVM-internen Grenze: die vollständige Angriffsfläche der Fehlermodi einer Java-Anwendung.

62 JDK-Aufrufstellen: Die vollständige Angriffsfläche

Der Agent instrumentiert 62 spezifische JDK-Aufrufstellen – nicht zufällige, sondern solche, die für Enterprise-Backend-Workloads relevant sind. Die Auswahl wurde durch Incident-Analysen über verteilte Systemausfälle gesteuert:

**Threading & Executors** Thread.start(), ThreadPoolExecutor.execute(), ScheduledExecutorService.schedule(), CompletableFuture.runAsync/supplyAsync, BlockingQueue-Operationen (put, offer, poll, take), ForkJoinPool-Einreichungen, Virtual-Thread-Lebenszyklus

**Netzwerk & I/O** Socket.connect/read/write, SocketChannel.connect/read/write, Selector.select/selectNow, DatagramChannel-Operationen, ServerSocket.accept

**DNS & Namensauflösung** InetAddress.getByName/getAllByName – der JDK-interne Auflösungspfad, bevor der OS-Resolver aufgerufen wird

**JDBC & Datenzugriff** DataSource.getConnection (Connection-Pool-Ausleihe), Statement.execute/executeQuery, PreparedStatement-Operationen – auf der Ebene, durch die Hikari, DBCP und c3p0 alle durchlaufen

**HTTP-Clients** HttpURLConnection, HttpClient (JDK 11+) – send, sendAsync

**SSL/TLS** SSLEngine.wrap/unwrap, SSLSocket.startHandshake

**Zeit** System.currentTimeMillis(), System.nanoTime() – mit FIXED-Offset-, DRIFT- und FREEZE-Effekten

**JVM-Interna** System.gc(), Klassenladung, Reflection, Objekt-Serialisierung/Deserialisierung, ThreadLocal.get/set, JNDI-Lookups, JMX-Operationen, Laden nativer Bibliotheken, ZIP-Inflation

java
// 62 interception targets across the JDK surface
Sealed interface ChaosSelector permits
    ThreadSelector,       // Thread.start, pool operations
    ExecutorSelector,     // ThreadPoolExecutor, ForkJoinPool
    QueueSelector,        // BlockingQueue operations
    FutureSelector,       // CompletableFuture
    SocketSelector,       // Socket, ServerSocket
    NioSelector,          // SocketChannel, Selector
    JdbcSelector,         // DataSource.getConnection, Statement
    HttpSelector,         // HttpURLConnection, HttpClient
    DnsSelector,          // InetAddress resolution
    SslSelector,          // SSLEngine, SSLSocket
    ClockSelector,        // System.currentTimeMillis, nanoTime
    GcSelector,           // System.gc
    ClassloadingSelector, // ClassLoader.loadClass
    SerializationSelector,// ObjectInputStream, ObjectOutputStream
    ThreadLocalSelector,  // ThreadLocal.get, set
    VirtualThreadSelector;// VirtualThread lifecycle

ByteBuddy + Bootstrap Bridge: Funktionsweise

Die Instrumentierung von JDK-Klassen ist nicht trivial. JDK-Klassen werden vom Bootstrap-Classloader geladen – dem Root-Classloader, der kein Elternteil hat. Agent-Code lebt im Agent-Classloader, den der Bootstrap-Classloader nicht namentlich sehen kann. Das korrekte Überbrücken dieser Lücke erfordert sorgfältigen Einsatz von JVMTI und des Java Memory Model.

**Schritt 1 – Premain oder agentmain.** Der Agent wird entweder beim Start (via `-javaagent:`) oder dynamisch zur Testlaufzeit (via die JDK Attach API) angehängt. Beim dynamischen Anhängen ruft der Spring Boot Test Starter `VirtualMachine.attach(processId)` auf und injiziert das Agent-Jar.

**Schritt 2 – Bootstrap-Injektion.** Eine `BootstrapDispatcher`-Klasse wird aus dem Agent-Jar extrahiert und in ein temporäres JAR geschrieben. Dieses JAR wird über `Instrumentation.appendToBootstrapClassLoaderSearch()` an den Bootstrap-Classpath angehängt. Der Bootstrap-Classloader kann `BootstrapDispatcher` jetzt sehen.

**Schritt 3 – MethodHandle-Verdrahtung.** Ein `MethodHandle[]`-Array mit 62 Slots wird aufgebaut, ein Handle pro Interceptionziel, das in die Implementierung des Agent-Classloaders zeigt. Das Array und ein `delegate`-Objekt werden als `volatile`-Felder in `BootstrapDispatcher` veröffentlicht. Das JMM garantiert, dass jeder Thread, der `delegate != null` beobachtet, auch das vollständig initialisierte `handles`-Array sieht.

**Schritt 4 – Bytecode-Advice-Inlining.** ByteBuddy's `@Advice`-Mechanismus kopiert den Advice-Bytecode direkt in den instrumentierten JDK-Methodenrumpf. Es gibt keinen virtuellen Dispatch, keinen Interface-Aufruf – der eingewobene Bytecode ist inline. Nach JIT-Kompilierung beim Aufwärmen (~10.000 Aufrufe) kompiliert die gesamte Dispatch-Kette zu nativem Code.

java
// Hot path after JIT compilation — effectively just:
public static void connect(Socket socket, SocketAddress endpoint, int timeout) {
    Object delegate = BootstrapDispatcher.delegate;  // volatile read
    if (delegate != null) {                           // null check — fast path
        BootstrapDispatcher.handles[SOCKET_CONNECT]   // MethodHandle
            .invoke(delegate, socket, endpoint);       // → ChaosRuntime
    }
    originalConnect(socket, endpoint, timeout);       // real JDK call
}
// Zero-scenario overhead: one volatile read + one null check + one untaken branch
// Measured: ~60 ns per call in the uncontested case after warm-up

Session-Isolation: Jeder Test bekommt sein eigenes Chaos

Das schwierigste Engineeringproblem bei einem JVM-level Chaos-Agent ist nicht die Bytecode-Instrumentierung – es ist die Isolation. Mehrere JUnit-Tests laufen gleichzeitig in derselben JVM. Wenn Test A JDBC-Verbindungsfehler injiziert, darf Test B diese Fehler nicht sehen.

Die Lösung ist `ChaosSession`, gestützt auf `ThreadLocal<UUID>`.

Jede Testmethode (oder Testklasse, abhängig vom Lebenszyklus) bekommt eine eindeutige Session-ID. Chaos-Szenarien können entweder als JVM-weit (betrifft alle Threads) oder als session-weit (betrifft nur Threads, die die Session-ID dieses Tests tragen) registriert werden. Das `ThreadLocal` stellt die Bindung her.

Für von Executoren eingereichte Aufgaben muss die Session-ID vom einreichenden Thread in den Worker-Thread propagiert werden. Der Agent instrumentiert `ThreadPoolExecutor.execute()`, um das eingereichte `Runnable` in einen Dekorator zu hüllen, der die aktuelle Session-ID erfasst und sie im Worker-Thread wiederherstellt, bevor die Aufgabe ausgeführt wird. Diese Propagation geschieht transparent – der Anwendungscode weiß nichts davon.

java
@ChaosTest  // @SpringBootTest composed annotation
class OrderServiceChaosTest {

    @Test
    void retryLogic_handles_transient_jdbc_failures(ChaosSession session) {
        var scenario = ChaosScenario.builder("transient-db")
            .selector(ChaosSelector.jdbc(OperationType.JDBC_CONNECTION_ACQUIRE))
            .effect(ChaosEffect.reject("chaos: simulated pool timeout"))
            .activationPolicy(ActivationPolicy.builder()
                .probability(1.0)
                .maxApplications(2)   // First 2 borrows fail, 3rd succeeds
                .build())
            .build();

        try (var handle = session.activate(scenario)) {
            try (var scope = session.bind()) {
                // This thread and any executor tasks it submits
                // will see the JDBC failures — nothing else will
                var result = orderService.placeOrder(testOrder);
                assertThat(result.status()).isEqualTo(OrderStatus.CONFIRMED);
            }
        }
        // Chaos cleaned up — next test is unaffected
    }
}

Spring Boot 3 und 4: Zero-Config-Verdrahtung

Die Spring Boot Test Starter existieren, weil die Verdrahtung unsichtbar sein sollte. Sie müssen ByteBuddy, den Bootstrap-Classloader oder JVMTI nicht verstehen, um Ihren ersten Chaos-Test auszuführen. Der Starter übernimmt:

- Programmatisches Setzen von `jdk.attach.allowAttachSelf=true` bevor die JVM dieses Flag verarbeitet (mittels eines statischen Initializers in der Auto-Konfigurationsklasse des Starters) - Erkennung, ob der Agent bereits angehängt ist (idempotent – sicher bei paralleler Testklassenausführung) - Anhängen des Agents via `VirtualMachine.attach("0")` (Self-Attach), falls noch nicht vorhanden - Registrierung von `ChaosControlPlane` und `ChaosSession` als Spring-Beans im Test-ApplicationContext - Bereitstellung von JUnit 5 Parameter-Auflösung für `ChaosSession`- und `ChaosControlPlane`-Konstruktorargumente

Der Runtime-Starter (für Nicht-Test-Deployments) stellt zusätzlich `/actuator/chaos` als geschützten Actuator-Endpunkt für das Live-Szenariomanagement bereit.

xml
<!-- Spring Boot 3 — add to pom.xml testImplementation -->
<dependency>
    <groupId>com.macstab.chaos</groupId>
    <artifactId>chaos-agent-spring-boot3-test-starter</artifactId>
    <version>${chaos-agent.version}</version>
    <scope>test</scope>
</dependency>

<!-- That's it. No -javaagent flag. No JVM args. No beans to define. -->

<!-- Spring Boot 4 -->
<dependency>
    <groupId>com.macstab.chaos</groupId>
    <artifactId>chaos-agent-spring-boot4-test-starter</artifactId>
    <version>${chaos-agent.version}</version>
    <scope>test</scope>
</dependency>

Die Acht-Tor-Aktivierungspipeline

Jede Chaos-Szenarienauswertung durchläuft der Reihe nach acht Tore. Alle Tore müssen passiert werden, damit der Effekt ausgelöst wird. Die Tore sind schnell – sie werden im aufrufenden Thread ohne I/O und mit minimaler Sperrung ausgewertet.

**Tor 1 – Gestartet-Prüfung.** Wurde das Szenario aktiviert? (Günstige Flag-Leseoperation.)

**Tor 2 – Session-ID-Abgleich.** Falls session-weit: Trägt das `ThreadLocal` des aktuellen Threads die richtige UUID? (ThreadLocal-Leseoperation, keine Sperre.)

**Tor 3 – Selektor-Abgleich.** Stimmt diese Aufrufstelle mit dem Selektor des Szenarios überein? (Enum-Vergleich, O(1).)

**Tor 4 – Aktivierungsfenster.** Liegt die aktuelle Zeit innerhalb des konfigurierten Start-/End-Fensters des Szenarios? (Zwei Long-Vergleiche.)

**Tor 5 – Aufwärm-Zähler.** Hat dieses Szenario mindestens N mal zugeschlagen, bevor es zu wirken beginnt? (AtomicLong-Leseoperation.)

**Tor 6 – Ratenbegrenzung.** Hat der Sliding-Window-Token-Bucket Kapazität? (Synchronisierter Block; unbestrittener Pfad ~5 ns.)

**Tor 7 – Wahrscheinlichkeit.** Zufallsziehung gegen die konfigurierte Wahrscheinlichkeit. (SplittableRandom, mit Szenario-ID + Trefferzähler geseedet – deterministisch für die Reproduktion von Fehlern.)

**Tor 8 – Maximale Anwendungen.** CAS-Schleife auf AtomicLong zur Durchsetzung eines harten Limits. (compareAndSet-Schleife; korrekt unter Konkurrenz – anders als ein einfaches incrementAndGet, das überschießen kann.)

Alle acht Tore bestanden → Effekt anwenden. Ein Tor schlägt fehl → Weitergabe an die echte JDK-Methode unverändert.

Hintergrund-Stressoren: Jenseits von Request-Pfad-Fehlern

Zusätzlich zu inline Request-Pfad-Effekten (Verzögerung, Ablehnung, Exception-Injektion) unterstützt der Agent Hintergrund-Stressoren – lebenszyklus-gebundene Threads, die unabhängig vom Verkehr kontinuierlich Ressourcendruck erzeugen.

Stressoren simulieren die schleichenden Fehlermodi, die nicht durch spezifische Operationen ausgelöst werden, sondern sich über die Zeit ansammeln:

**Speicher-Stressoren:** Heap-Druck (byte[]-Chunks zurückhalten), GC-Druck (Allokationswirbel), Metaspace-Druck (synthetische Klassendefinitionen), Direktpuffer-Druck (Off-Heap ByteBuffer), String-Intern-Flutung

**JVM-Stressoren:** Code-Cache-Druck (von ByteBuddy generierte Klassenflut für JIT-Thrashing), Finalizer-Rückstand (Phantom-Referenz-Queue-Flutung), Referenz-Queue-Flutung, Safepoint-Stürme (periodische Reinstrumentierung, um Stop-the-World-Pausen auszulösen)

**Thread-Stressoren:** Thread-Leak (dauerhaft geparkte Threads, die Thread-Stack-Speicher verbrauchen), ThreadLocal-Leak (Einträge auf gepoolten Threads, die sich ansammeln), Deadlock-Injektion (echter JVM-Monitor-Deadlock zwischen N Threads – verifiziert mit ThreadMXBean), Monitor-Konkurrenz (Hintergrund-Threads konkurrieren um eine gemeinsame Sperre), Keep-Alive-Threads (verhindern JVM-Shutdown)

Stressoren werden gestartet, wenn ein Szenario aktiviert wird, und gestoppt, wenn es geschlossen wird. Sie kombinieren sich mit Inline-Effekten: Sie können gleichzeitig Request-Pfad-JDBC-Ablehnungen und einen Code-Cache-Stressor aktiv haben und so testen, ob Ihre Anwendung sich von Verbindungsfehlern erholt, während sie bereits unter JIT-Kompilierungsdruck steht.

  • Heap-Druck: konfigurierbare MB/s in byte[]-Allokationen zurückhalten, um GC-Druck zu erzwingen
  • Metaspace-Druck: synthetische Klassen in einen isolierten ClassLoader definieren, um Permgen/Metaspace aufzubrauchen
  • Code-Cache-Druck: ByteBuddy-Klassen generieren, um den JIT-Compiler zu sättigen
  • Safepoint-Sturm: periodische Reinstrumentierung erzwingen, um Stop-the-World-Pausen auszulösen
  • Echter Deadlock: zwei Threads erwerben Monitore in umgekehrter Reihenfolge – erkannt durch ThreadMXBean
  • Thread-Leak: Threads dauerhaft parken, um -Xss-Stack-Speicher über die Zeit zu erschöpfen

Beispiel: SLA-Verhalten unter JDBC-Erschöpfung validieren

Ein konkretes Testszenario, das den Kernwert des Agents demonstriert: der Beweis, dass Ihr Service korrekt auf eine Zahlung mit einem SLA-Ziel antwortet, wenn der Datenbankverbindungspool unter Druck steht.

java
@SpringBootTest
@ChaosTest
class PaymentSLAChaosTest {

    @Autowired PaymentService paymentService;

    @Test
    void payment_meets_sla_under_intermittent_jdbc_pressure(ChaosSession session) {
        // Activate 20% JDBC connection failure — simulates pool under load
        var jdbcChaos = ChaosScenario.builder("jdbc-pressure")
            .selector(ChaosSelector.jdbc(OperationType.JDBC_CONNECTION_ACQUIRE))
            .effect(ChaosEffect.delay(Duration.ofMillis(150)))  // Not rejection — delay
            .activationPolicy(ActivationPolicy.probability(0.20))
            .build();

        // Also activate a background heap stressor
        var heapStressor = ChaosScenario.builder("heap-pressure")
            .effect(ChaosEffect.stressor(StressorType.HEAP_PRESSURE)
                .retainMbPerSecond(50)
                .build())
            .build();

        try (var h1 = session.activate(jdbcChaos);
             var h2 = session.activate(heapStressor)) {

            try (var scope = session.bind()) {
                long start = System.nanoTime();
                var result = paymentService.process(testPayment);
                long elapsedMs = (System.nanoTime() - start) / 1_000_000;

                assertThat(result.status()).isEqualTo(PaymentStatus.COMPLETED);
                assertThat(elapsedMs).isLessThan(500);  // SLA: p99 < 500ms
            }
        }
    }
}

JMX und Observability

Der Agent stellt ein JMX MBean unter `com.macstab.chaos.jvm:type=ChaosDiagnostics` bereit, das eine Live-Momentaufnahme aller aktiven Szenarien liefert – deren aktuellen Zustand, Trefferzähler und Anwendungszähler – ohne Codeänderungen oder zusätzliche Konfiguration zu erfordern.

Das ist beim Test-Debugging nützlich: Wenn ein Chaos-Test unerwartet fehlschlägt, können Sie JConsole oder VisualVM mit der Test-JVM verbinden und genau prüfen, welche Szenarien aktiv sind und wie oft sie ausgelöst wurden.

Der Observability-Bus ermöglicht das Veröffentlichen von Chaos-Ereignissen in externe Metriksysteme. Wenn Sie Chaos-Tests in einer gemeinsamen Integrationsumgebung ausführen und Chaos-Ereignisse mit Grafana-Dashboards oder verteilten Traces korrelieren möchten, bietet der Bus den Integrationspunkt.

  • JMX MBean: aktive Szenarienliste, Trefferzähler, Anwendungszähler, aktueller Zustand pro Szenario
  • In-Process-Snapshot-API: aus Testcode abfragbar für Assertions über Chaos-Verhalten
  • Debug-Dump: Textdarstellung aller Szenarien und Zustände – nützlich in der Testfehlerausgabe
  • Observability-Bus: steckbarer Event-Publisher für Metriken-/Tracing-Integration

Key Takeaways

Der JVM-Chaos-Agent füllt die Lücke zwischen infrastruktureller Fehlerinjektion und echten JVM-internen Fehlermodi. 62 JDK-Aufrufstellen, Isolation pro Test-Session, Spring Boot 3/4 Auto-Wiring, Hintergrund-Stressoren für schleichende Fehler und ein JIT-optimierter Dispatch-Pfad, der im Zero-Szenario-Fall ~60 ns pro Aufruf kostet.

Der Agent kombiniert sich mit der C99 LD_PRELOAD-Bibliothek für Full-Stack-Fehlerabdeckung: syscall-level-Fehler unterhalb der JVM, bytecode-level-Fehler innerhalb von ihr. Beide werden durch das Annotationssystem des Java-Testframeworks orchestriert – ein Test, beide Schichten aktiv, kein Konfigurationskonflikt.

Chaos Engineering als CI-Gate ist bereit. Die einzige Frage ist, welchen Fehlermodus Ihr Circuit Breaker noch nicht behandelt.

#chaos-engineering #java #jvm #java-agent #bytebuddy #junit5 #spring-boot #bytecode #resilience #ci-cd
E

Engineering Team

Senior Solutions Architects

Wir bauen verteilte Systeme, seit lange bevor 'Microservices' ein Begriff war. Unsere Narben erzählen Geschichten.