62 Punti di Intercettazione JDK: Chaos Engineering In-Process con un Java Agent
Ingegneria category.testing 26 giugno 2026

62 Punti di Intercettazione JDK: Chaos Engineering In-Process con un Java Agent

Un Java agent che strumenta 62 call site JDK tramite ByteBuddy. Isolamento di sessione per test, auto-wiring Spring Boot 3/4 e overhead JIT quasi nullo. Chaos engineering che gira inline nella tua suite JUnit e blocca la build in caso di fallimento.

E
Engineering Team
Senior Solutions Architects
18 min di lettura

Il Livello che Toxiproxy Non Può Raggiungere

Toxiproxy è eccellente. tc netem è eccellente. Le librerie di chaos basate su LD_PRELOAD sono eccellenti. Tutte operano al livello di rete o del sistema operativo — intercettando connessioni TCP, la consegna dei pacchetti e le system call.

Ma ecco ciò che nessuna di esse riesce a vedere: cosa succede all'interno del tuo HikariPool quando tenta di ottenere una connessione e va in timeout. Cosa succede all'interno del tuo ScheduledExecutorService quando è saturo. Cosa succede all'interno dell'implementazione SSL del JDK quando incontra una ritrasmissione. Cosa succede all'interno del tuo thread pool quando i task si accumulano più velocemente di quanto i worker riescano a smaltirli.

Queste sono modalità di fallimento interne alla JVM. Non si manifestano come errori di rete a livello TCP. Si manifestano come eccezioni lanciate dalle classi JDK — `SQLException`, `RejectedExecutionException`, `SSLHandshakeException`, `TimeoutException` — e come pattern di esaurimento delle risorse che diventano visibili solo quando si strumentano i call site giusti all'interno della JVM stessa.

Questo è il gap che il JVM chaos agent colma. Non sostituisce il livello LD_PRELOAD — si compone con esso. Insieme, offrono l'iniezione di fault sia al confine del sistema operativo sia al confine interno della JVM: la superficie completa delle modalità di fallimento di un'applicazione Java.

62 Call Site JDK: L'Intera Superficie

L'agent strumenta 62 call site JDK specifici — non scelti a caso, ma quelli che contano per i workload backend enterprise. La selezione è stata guidata dall'analisi degli incidenti nei fallimenti dei sistemi distribuiti:

**Threading & Executor** Thread.start(), ThreadPoolExecutor.execute(), ScheduledExecutorService.schedule(), CompletableFuture.runAsync/supplyAsync, operazioni su BlockingQueue (put, offer, poll, take), submission su ForkJoinPool, ciclo di vita dei virtual thread

**Rete & I/O** Socket.connect/read/write, SocketChannel.connect/read/write, Selector.select/selectNow, operazioni su DatagramChannel, ServerSocket.accept

**DNS & Risoluzione dei Nomi** InetAddress.getByName/getAllByName — il percorso di risoluzione interno del JDK, prima che venga chiamato il resolver del sistema operativo

**JDBC & Accesso ai Dati** DataSource.getConnection (acquisizione dalla connection pool), Statement.execute/executeQuery, operazioni su PreparedStatement — al livello in cui passano tutti: Hikari, DBCP e c3p0

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

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

**Tempo** System.currentTimeMillis(), System.nanoTime() — con effetti FIXED offset, DRIFT e FREEZE

**Internals JVM** System.gc(), caricamento classi, reflection, serializzazione/deserializzazione di oggetti, ThreadLocal.get/set, lookup JNDI, operazioni JMX, caricamento di librerie native, decompressione ZIP

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: Come Funziona

Strumentare le classi JDK non è banale. Le classi JDK sono caricate dal bootstrap classloader — il classloader radice che non ha genitore. Il codice dell'agent risiede nell'agent classloader, che il bootstrap classloader non riesce a vedere per nome. Collegare correttamente questo gap richiede un uso attento di JVMTI e del Java Memory Model.

**Passo 1 — Premain o agentmain.** L'agent si aggancia all'avvio (tramite `-javaagent:`) oppure dinamicamente durante il test (tramite la JDK Attach API). Per l'aggancio dinamico, lo Spring Boot test starter chiama `VirtualMachine.attach(processId)` e inietta il jar dell'agent.

**Passo 2 — Iniezione nel bootstrap.** Una classe `BootstrapDispatcher` viene estratta dal jar dell'agent e scritta in un JAR temporaneo. Questo JAR viene aggiunto al bootstrap classpath tramite `Instrumentation.appendToBootstrapClassLoaderSearch()`. Il bootstrap classloader può ora vedere `BootstrapDispatcher`.

**Passo 3 — Cablaggio dei MethodHandle.** Viene costruito un array `MethodHandle[]` di 62 slot, uno per ogni target di intercettazione, che punta all'implementazione nell'agent classloader. L'array e un oggetto `delegate` vengono pubblicati in `BootstrapDispatcher` come campi `volatile`. Il JMM garantisce che qualsiasi thread che osservi `delegate != null` veda anche l'array `handles` completamente inizializzato.

**Passo 4 — Inlining dell'advice bytecode.** Il meccanismo `@Advice` di ByteBuddy copia il bytecode dell'advice direttamente nel corpo del metodo JDK strumentato. Non c'è virtual dispatch, nessuna chiamata a interfaccia — il bytecode intessuto è inline. Dopo la compilazione JIT al warm-up (~10.000 invocazioni), l'intera catena di dispatch viene compilata in codice nativo.

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

Isolamento di Sessione: Ogni Test Ha il Suo Chaos

Il problema ingegneristico più difficile in un chaos agent a livello JVM non è la strumentazione del bytecode — è l'isolamento. Più test JUnit vengono eseguiti in concorrenza nella stessa JVM. Se il test A inietta fallimenti nelle connessioni JDBC, il test B non può essere esposto a quei fallimenti.

La soluzione è `ChaosSession`, supportata da `ThreadLocal<UUID>`.

Ogni metodo di test (o classe di test, a seconda del ciclo di vita) ottiene un ID di sessione univoco. Gli scenari di chaos possono essere registrati come JVM-scoped (influenza tutti i thread) o session-scoped (influenza solo i thread che portano l'ID di sessione di quel test). Il `ThreadLocal` fornisce il binding.

Per i task sottomessi agli executor, l'ID di sessione deve propagarsi dal thread mittente al thread worker. L'agent strumenta `ThreadPoolExecutor.execute()` per avvolgere il `Runnable` sottomesso con un decorator che cattura l'ID di sessione corrente e lo ripristina sul thread worker prima dell'esecuzione del task. Questa propagazione avviene in modo trasparente — il codice applicativo non ne è consapevole.

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 e 4: Cablaggio Zero-Config

I test starter per Spring Boot esistono perché il cablaggio dovrebbe essere invisibile. Non dovresti dover capire ByteBuddy, il bootstrap classloader o JVMTI per eseguire il tuo primo chaos test. Lo starter si occupa di:

- Impostare programmaticamente `jdk.attach.allowAttachSelf=true` prima che la JVM elabori questo flag (usando un inizializzatore statico nella classe di auto-configurazione dello starter) - Rilevare se l'agent è già stato agganciato (idempotente — sicuro in esecuzione parallela di classi di test) - Agganciare l'agent tramite `VirtualMachine.attach("0")` (self-attach) se non è già presente - Registrare `ChaosControlPlane` e `ChaosSession` come Spring bean nel test ApplicationContext - Fornire la risoluzione dei parametri JUnit 5 per gli argomenti del costruttore `ChaosSession` e `ChaosControlPlane`

Lo starter runtime (per il deployment non di test) espone inoltre `/actuator/chaos` come endpoint Actuator protetto per la gestione live degli scenari.

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>

La Pipeline di Attivazione a Otto Gate

Ogni valutazione di uno scenario di chaos passa attraverso otto gate in sequenza. Tutti i gate devono passare affinché l'effetto venga applicato. I gate sono veloci — valutati sul thread chiamante senza I/O e con il minimo di locking.

**Gate 1 — Verifica avvio.** Lo scenario è stato attivato? (Lettura di un flag economica.)

**Gate 2 — Corrispondenza ID di sessione.** Se session-scoped, il `ThreadLocal` del thread corrente porta l'UUID corretto? (Lettura thread-local, nessun lock.)

**Gate 3 — Corrispondenza del selector.** Questo call site corrisponde al selector dello scenario? (Confronto enum, O(1).)

**Gate 4 — Finestra di attivazione.** L'ora corrente è all'interno della finestra start/end configurata per lo scenario? (Due confronti di long.)

**Gate 5 — Contatore di warm-up.** Questo scenario ha trovato corrispondenza almeno N volte prima di iniziare ad applicarsi? (Lettura di AtomicLong.)

**Gate 6 — Rate limit.** Il token bucket a finestra scorrevole ha capacità disponibile? (Blocco sincronizzato; percorso non conteso ~5 ns.)

**Gate 7 — Probabilità.** Estrazione casuale rispetto alla probabilità configurata. (SplittableRandom con seed basato sull'ID dello scenario + contatore di corrispondenze — deterministico per riprodurre i fallimenti.)

**Gate 8 — Numero massimo di applicazioni.** Loop CAS su AtomicLong per applicare un limite massimo. (Loop compareAndSet; corretto sotto contesa — a differenza di un semplice incrementAndGet che può sforare.)

Tutti e otto i gate passano → applica l'effetto. Un gate fallisce → passa direttamente al metodo JDK reale senza modifiche.

Stressor in Background: Oltre i Fault sul Percorso delle Richieste

Oltre agli effetti inline sul percorso delle richieste (delay, rejection, iniezione di eccezioni), l'agent supporta gli stressor in background — thread con ciclo di vita vincolato che applicano pressione sulle risorse in modo continuo, indipendentemente dal traffico.

Gli stressor simulano le modalità di fallimento a combustione lenta che non vengono innescate da operazioni specifiche ma si accumulano nel tempo:

**Stressor di memoria:** Pressione sull'heap (trattenere chunk di byte[]), pressione sul GC (churn di allocazione), pressione sul Metaspace (definizioni sintetiche di classi), pressione sui buffer diretti (ByteBuffer off-heap), flooding dell'intern pool delle stringhe

**Stressor JVM:** Pressione sulla code cache (flood di classi generate da ByteBuddy per il thrashing del JIT), backlog del finalizer (flood della coda di phantom-reference), flooding della coda di riferimento, safepoint storm (retrasformazione periodica per innescare pause stop-the-world)

**Stressor di thread:** Thread leak (thread parcheggiati in modo permanente che consumano memoria dello stack), ThreadLocal leak (entry su thread pooled che si accumulano), iniezione di deadlock (vero deadlock su monitor JVM tra N thread — verificato con ThreadMXBean), monitor contention (thread in background in competizione per un lock condiviso), keep-alive thread (impediscono lo shutdown della JVM)

Gli stressor vengono avviati quando uno scenario viene attivato e fermati quando viene chiuso. Si compongono con gli effetti inline: puoi avere simultaneamente rejection JDBC sul percorso delle richieste e uno stressor sulla code cache attivi, testando se la tua applicazione si riprende dai fallimenti di connessione mentre è già sotto pressione di compilazione JIT.

  • Pressione heap: trattenere MB/s configurabili in allocazioni byte[] per forzare pressione sul GC
  • Pressione Metaspace: definire classi sintetiche in un ClassLoader isolato per consumare permgen/metaspace
  • Pressione code cache: generare classi ByteBuddy per saturare il compilatore JIT
  • Safepoint storm: forzare la retrasformazione periodica per innescare pause stop-the-world
  • Deadlock reale: due thread acquisiscono i monitor in ordine inverso — rilevato da ThreadMXBean
  • Thread leak: parcheggiare thread in modo permanente per esaurire la memoria stack -Xss nel tempo

Esempio: Validare il Comportamento SLA sotto Esaurimento JDBC

Uno scenario di test concreto che mette alla prova il valore centrale dell'agent: dimostrare che il tuo servizio risponde correttamente a un pagamento rispettando il target SLA quando il connection pool del database è sotto pressione.

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 e Osservabilità

L'agent espone un JMX MBean a `com.macstab.chaos.jvm:type=ChaosDiagnostics` che fornisce uno snapshot in tempo reale di tutti gli scenari attivi — il loro stato corrente, i contatori di corrispondenza e i contatori di applicazione — senza richiedere modifiche al codice o configurazioni aggiuntive.

Questa funzionalità è utile durante il debug dei test: se un chaos test sta fallendo in modo inatteso, puoi collegare JConsole o VisualVM alla JVM di test e ispezionare esattamente quali scenari sono attivi e quante volte sono scattati.

L'observability bus consente la pubblicazione degli eventi di chaos su sistemi di metriche esterni. Se stai eseguendo chaos test in un ambiente di integrazione condiviso e vuoi correlare gli eventi di chaos con dashboard Grafana o trace distribuite, il bus fornisce il punto di integrazione.

  • JMX MBean: elenco degli scenari attivi, contatori di corrispondenza, contatori di applicazione, stato corrente per scenario
  • API di snapshot in-process: interrogabile dal codice di test per asserzioni sul comportamento del chaos
  • Debug dump: rappresentazione testuale di tutti gli scenari e dello stato — utile nell'output dei fallimenti di test
  • Observability bus: publisher di eventi collegabile per l'integrazione con metriche/tracing

Key Takeaways

Il JVM chaos agent colma il gap tra l'iniezione di fault a livello infrastrutturale e le reali modalità di fallimento interne alla JVM. 62 call site JDK, isolamento di sessione per test, auto-wiring Spring Boot 3/4, stressor in background per i fallimenti a combustione lenta e un percorso di dispatch ottimizzato dal JIT che costa ~60 ns per chiamata nel caso a zero scenari.

L'agent si compone con la libreria C99 LD_PRELOAD per una copertura fault full-stack: fallimenti a livello di syscall sotto la JVM, fallimenti a livello di bytecode al suo interno. Entrambi sono orchestrati dal sistema di annotazioni del framework di testing Java — un solo test, entrambi i livelli attivi, zero sovrapposizione di configurazione.

Il chaos engineering come gate CI è pronto. L'unica domanda è quale modalità di fallimento il tuo circuit breaker non stia ancora gestendo.

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

Engineering Team

Senior Solutions Architects

Costruiamo sistemi distribuiti da prima che 'microservizi' fosse un termine comune. Le nostre cicatrici raccontano storie.