62 JDK-interceptiepunten: In-Process Chaos Engineering met een Java Agent
Engineering category.testing 26 juni 2026

62 JDK-interceptiepunten: In-Process Chaos Engineering met een Java Agent

Een Java-agent die 62 JDK-aanroeplocaties instrumenteert met ByteBuddy. Per-testsessie-isolatie, Spring Boot 3/4 auto-wiring, en vrijwel nul JIT-overhead. Chaos engineering dat inline draait in uw JUnit-testsuite en de build bewaakt.

E
Engineering Team
Senior Solutions Architects
18 min lezen

De Laag die Toxiproxy Niet Kan Bereiken

Toxiproxy is uitstekend. tc netem is uitstekend. De LD_PRELOAD chaos-bibliotheken zijn uitstekend. Ze werken allemaal op de netwerk- of OS-grens — ze onderscheppen TCP-verbindingen, pakketaflevering en systeemaanroepen.

Maar dit is wat geen van hen kan zien: wat er in uw HikariPool gebeurt als het probeert een verbinding te lenen en een time-out tegenkomt. Wat er in uw ScheduledExecutorService gebeurt als die verzadigd is. Wat er in de SSL-implementatie van de JDK gebeurt als die een herverzendfout tegenkomt. Wat er in uw threadpool gebeurt als taken sneller binnenstromen dan workers ze kunnen verwerken.

Dit zijn JVM-interne storingen. Ze manifesteren zich niet als netwerkfouten op de TCP-laag. Ze manifesteren zich als uitzonderingen die worden gegooid vanuit JDK-klassen — `SQLException`, `RejectedExecutionException`, `SSLHandshakeException`, `TimeoutException` — en als patronen van resource-uitputting die pas zichtbaar worden wanneer u de juiste aanroeplocaties binnen de JVM zelf instrumenteert.

Dit is de kloof die de JVM chaos-agent opvult. Het vervangt de LD_PRELOAD-laag niet — het componeert ermee. Samen bieden ze foutinjectie op zowel de OS-grens als de JVM-interne grens: het volledige oppervlak van de storingen van een Java-applicatie.

62 JDK-aanroeplocaties: Het Volledige Oppervlak

De agent instrumenteert 62 specifieke JDK-aanroeplocaties — niet willekeurige, maar de locaties die ertoe doen voor enterprise backend-workloads. De selectie was gebaseerd op incidentanalyse van storingen in gedistribueerde systemen:

**Threading & Executors** Thread.start(), ThreadPoolExecutor.execute(), ScheduledExecutorService.schedule(), CompletableFuture.runAsync/supplyAsync, BlockingQueue-operaties (put, offer, poll, take), ForkJoinPool-inzendingen, levenscyclus van virtuele threads

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

**DNS & Naamresolutie** InetAddress.getByName/getAllByName — het interne resolutiepad van de JDK voordat de OS-resolver wordt aangeroepen

**JDBC & Gegevenstoegang** DataSource.getConnection (verbindingspool lenen), Statement.execute/executeQuery, PreparedStatement-operaties — op het niveau waar Hikari, DBCP en c3p0 allemaal doorheen gaan

**HTTP-clients** HttpURLConnection, HttpClient (JDK 11+) — send, sendAsync

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

**Tijd** System.currentTimeMillis(), System.nanoTime() — met VASTE offset-, DRIFT- en BEVRIEZINGSEFFECTEN

**JVM-internals** System.gc(), laden van klassen, reflectie, object-serialisatie/deserialisatie, ThreadLocal.get/set, JNDI-opzoekingen, JMX-operaties, laden van native bibliotheken, ZIP-inflatie

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: Hoe Het Werkt

Het instrumenteren van JDK-klassen is niet eenvoudig. JDK-klassen worden geladen door de bootstrap classloader — de root classloader zonder parent. Agent-code leeft in de agent classloader, die de bootstrap classloader niet bij naam kan zien. Het correct overbruggen van dit gat vereist zorgvuldig gebruik van JVMTI en het Java Memory Model.

**Stap 1 — Premain of agentmain.** De agent koppelt ofwel bij het opstarten (via `-javaagent:`) of dynamisch tijdens een test (via de JDK Attach API). Bij dynamisch koppelen roept de Spring Boot-teststarter `VirtualMachine.attach(processId)` aan en injecteert de agent-jar.

**Stap 2 — Bootstrap-injectie.** Een `BootstrapDispatcher`-klasse wordt uit de agent-jar geëxtraheerd en naar een tijdelijke JAR geschreven. Deze JAR wordt toegevoegd aan het bootstrap-classpath via `Instrumentation.appendToBootstrapClassLoaderSearch()`. De bootstrap classloader kan `BootstrapDispatcher` nu zien.

**Stap 3 — MethodHandle-bedrading.** Er wordt een array van 62 `MethodHandle[]` gebouwd, één handle per interceptiedoel, die verwijzen naar de implementatie in de agent classloader. De array en een `delegate`-object worden als `volatile`-velden in `BootstrapDispatcher` gepubliceerd. Het JMM garandeert dat elke thread die `delegate != null` observeert ook de volledig geïnitialiseerde `handles`-array ziet.

**Stap 4 — Bytecode-advice inlining.** Het `@Advice`-mechanisme van ByteBuddy kopieert de advice-bytecode direct in de body van de geïnstrumenteerde JDK-methode. Er is geen virtuele dispatch, geen interface-aanroep — de geweven bytecode is inline. Na JIT-compilatie bij warm-up (~10.000 aanroepen) compileert de volledige dispatch-keten naar native 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

Sessie-isolatie: Elke Test Krijgt Zijn Eigen Chaos

Het moeilijkste technische probleem in een JVM-niveau chaos-agent is niet de bytecode-instrumentatie — het is isolatie. Meerdere JUnit-tests draaien gelijktijdig in dezelfde JVM. Als test A JDBC-verbindingsfouten injecteert, mag test B die fouten niet zien.

De oplossing is `ChaosSession`, ondersteund door `ThreadLocal<UUID>`.

Elke testmethode (of testklasse, afhankelijk van de levenscyclus) krijgt een uniek sessie-ID. Chaos-scenario's kunnen worden geregistreerd als ofwel JVM-scoped (heeft invloed op alle threads) of sessie-scoped (heeft alleen invloed op threads die het sessie-ID van deze test meedragen). De `ThreadLocal` zorgt voor de binding.

Voor door executors ingediende taken moet het sessie-ID worden doorgegeven van de indienende thread naar de werkthread. De agent instrumenteert `ThreadPoolExecutor.execute()` om de ingediende `Runnable` te omhullen met een decorator die het huidige sessie-ID vastlegt en het op de werkthread herstelt voordat de taak wordt uitgevoerd. Deze doorgave vindt transparant plaats — de applicatiecode weet niet dat het gebeurt.

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 en 4: Zero-Config Bedrading

De Spring Boot-teststarters bestaan omdat de bedrading onzichtbaar moet zijn. U hoeft ByteBuddy, de bootstrap classloader of JVMTI niet te begrijpen om uw eerste chaostest uit te voeren. De starter verzorgt:

- Programmatisch instellen van `jdk.attach.allowAttachSelf=true` voordat de JVM deze vlag verwerkt (met behulp van een statische initializer in de auto-configuratieklasse van de starter) - Detecteren of de agent al gekoppeld is (idempotent — veilig bij parallelle uitvoering van testklassen) - Het koppelen van de agent via `VirtualMachine.attach("0")` (zelf-koppeling) als deze nog niet aanwezig is - Registreren van `ChaosControlPlane` en `ChaosSession` als Spring-beans in de test-ApplicationContext - Bieden van JUnit 5-parameterresolutie voor `ChaosSession`- en `ChaosControlPlane`-constructorargumenten

De runtime-starter (voor niet-testimplementatie) stelt daarnaast `/actuator/chaos` beschikbaar als een beveiligd Actuator-eindpunt voor live scenariobeheer.

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>

De Acht-Poort Activeringspijplijn

Elke evaluatie van een chaos-scenario gaat achtereenvolgens door acht poorten. Alle poorten moeten slagen voordat het effect wordt uitgevoerd. De poorten zijn snel — geëvalueerd op de aanroepende thread zonder I/O en minimale vergrendeling.

**Poort 1 — Gestart-controle.** Is het scenario geactiveerd? (Goedkope vlaguitl.)

**Poort 2 — Sessie-ID-overeenkomst.** Als het sessie-scoped is, draagt de `ThreadLocal` van de huidige thread het juiste UUID? (Thread-local uitl., geen vergrendeling.)

**Poort 3 — Selector-overeenkomst.** Komt deze aanroeplocatie overeen met de selector van het scenario? (Enum-vergelijking, O(1).)

**Poort 4 — Activeringsvenster.** Valt de huidige tijd binnen het geconfigureerde start-/eindvenster van het scenario? (Twee long-vergelijkingen.)

**Poort 5 — Opwarmtelling.** Heeft dit scenario minimaal N keer gematcht voordat het begon toe te passen? (AtomicLong uitl.)

**Poort 6 — Snelheidslimiet.** Heeft het schuivende-venster-tokenemmer capaciteit? (Gesynchroniseerd blok; onbetwist pad ~5 ns.)

**Poort 7 — Kans.** Willekeurige trekking tegen de geconfigureerde kans. (SplittableRandom, gezaaid met scenario-ID + tellingaantal — deterministisch voor het reproduceren van fouten.)

**Poort 8 — Maximale toepassingen.** CAS-lus op AtomicLong om een harde grens af te dwingen. (compareAndSet-lus; correct onder contention — in tegenstelling tot een simpele incrementAndGet die kan overschrijden.)

Alle acht poorten slagen → het effect toepassen. Enige poort faalt → direct doorgaan naar de echte JDK-methode zonder wijzigingen.

Achtergrondsbelasters: Voorbij Aanvraagpad-fouten

Naast inline aanvraagpad-effecten (vertraging, afwijzing, uitzondering-injectie) ondersteunt de agent achtergrondsbelasters — aan levenscyclus gebonden threads die continu resource-druk uitoefenen, onafhankelijk van het verkeer.

Belasters simuleren de langzaam-brandende storingen die niet worden getriggerd door specifieke operaties maar zich in de loop van de tijd ophopen:

**Geheugensbelasters:** Heap-druk (byte[]-chunks vasthouden), GC-druk (allocatiestorm), Metaspace-druk (synthetische klassedefinities), directe bufferdruk (off-heap ByteBuffer), String-intern-overstroming

**JVM-belasters:** Code-cache-druk (door ByteBuddy gegenereerde klassenoverstroming voor JIT-thrashing), finalizer-achterstand (phantom-reference queue-overstroming), referentiewachtrij-overstroming, safepoint-stormen (periodieke hertransformatie om stop-the-world-pauzes te triggeren)

**Thread-belasters:** Thread-lek (permanent geparkeerde threads die thread-stack-geheugen verbruiken), ThreadLocal-lek (vermeldingen op gepoolde threads die zich ophopen), deadlock-injectie (echte JVM-monitor-deadlock tussen N threads — geverifieerd met ThreadMXBean), monitor-contention (achtergrondsthreads die concurreren om een gedeeld slot), keep-alive-threads (voorkomen dat de JVM wordt afgesloten)

Belasters starten wanneer een scenario wordt geactiveerd en stoppen wanneer het wordt gesloten. Ze componeren met inline effecten: u kunt tegelijkertijd aanvraagpad-JDBC-afwijzingen en een code-cache-belaster actief hebben, en testen of uw applicatie herstelt van verbindingsfouten terwijl deze al onder JIT-compilatiedruk staat.

  • Heap-druk: configureerbare MB/s vasthouden in byte[]-allocaties om GC-druk te forceren
  • Metaspace-druk: synthetische klassen definiëren in een geïsoleerde ClassLoader om permgen/metaspace te verbruiken
  • Code-cache-druk: ByteBuddy-klassen genereren om de JIT-compiler te verzadigen
  • Safepoint-storm: periodieke hertransformatie forceren om stop-the-world-pauzes te triggeren
  • Echte deadlock: twee threads verwerven monitors in omgekeerde volgorde — gedetecteerd door ThreadMXBean
  • Thread-lek: threads permanent parkeren om -Xss stack-geheugen in de loop van de tijd uit te putten

Voorbeeld: SLA-gedrag Valideren onder JDBC-uitputting

Een concreet testscenario dat de kernwaarde van de agent demonstreert: bewijzen dat uw service correct reageert op een betaling met een SLA-doel wanneer de databaseverbindingspool onder druk staat.

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 en Observeerbaarheid

De agent stelt een JMX MBean beschikbaar op `com.macstab.chaos.jvm:type=ChaosDiagnostics` die een live momentopname biedt van alle actieve scenario's — hun huidige toestand, tellingaantallen en toegepaste tellingaantallen — zonder code-wijzigingen of aanvullende configuratie.

Dit is handig bij het debuggen van tests: als een chaostest onverwacht faalt, kunt u JConsole of VisualVM verbinden met de test-JVM en precies inspecteren welke scenario's actief zijn en hoe vaak ze zijn uitgevoerd.

De observeerbare bus maakt het mogelijk chaos-gebeurtenissen te publiceren naar externe metrische systemen. Als u chaostests uitvoert in een gedeelde integratieomgeving en chaos-gebeurtenissen wilt correleren met Grafana-dashboards of gedistribueerde traces, biedt de bus het integratiepunt.

  • JMX MBean: actieve scenario-lijst, tellingaantallen, toegepaste tellingaantallen, huidige toestand per scenario
  • In-process momentopname-API: bevraagbaar vanuit testcode voor beweringen over chaos-gedrag
  • Debug-dump: tekstweergave van alle scenario's en toestand — nuttig in testfoutuitvoer
  • Observeerbare bus: uitbreidbare gebeurtenisuitgever voor metrische/trace-integratie

Key Takeaways

De JVM chaos-agent vult de kloof tussen foutinjectie op infrastructuurniveau en echte JVM-interne storingen. 62 JDK-aanroeplocaties, per-testsessie-isolatie, Spring Boot 3/4 auto-wiring, achtergrondsbelasters voor langzaam-brandende storingen, en een JIT-geoptimaliseerd dispatch-pad dat ~60 ns per aanroep kost in het nul-scenario-geval.

De agent componeert met de C99 LD_PRELOAD-bibliotheek voor volledige stack-foutdekking: syscall-niveau fouten onder de JVM, bytecode-niveau fouten erin. Beide worden georkestreerd door het annotatiesysteem van het Java-testframework — één test, beide lagen actief, nul configuratieoverlap.

Chaos engineering als CI-gate is klaar. De enige vraag is welke storingsmodus uw circuit breaker nog niet afhandelt.

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

Engineering Team

Senior Solutions Architects

We bouwen al gedistribueerde systemen voordat 'microservices' een begrip was. Onze littekens vertellen verhalen.