62 Puntos de Intercepción del JDK: Ingeniería del Caos In-Process con un Agente Java
Ingeniería category.testing 26 de junio de 2026

62 Puntos de Intercepción del JDK: Ingeniería del Caos In-Process con un Agente Java

Un agente Java que instrumenta 62 sitios de llamada del JDK usando ByteBuddy. Aislamiento de sesión por prueba, autoconfiguración para Spring Boot 3/4, y overhead de JIT casi nulo. Ingeniería del caos que se ejecuta en línea dentro de tu suite de pruebas JUnit y bloquea la build.

E
Engineering Team
Senior Solutions Architects
18 min de lectura

La Capa que Toxiproxy No Puede Alcanzar

Toxiproxy es excelente. tc netem es excelente. Las bibliotecas de caos basadas en LD_PRELOAD son excelentes. Todas operan en el límite de red o del sistema operativo — interceptando conexiones TCP, entrega de paquetes y llamadas al sistema.

Pero esto es lo que ninguna de ellas puede ver: qué ocurre dentro de tu HikariPool cuando intenta tomar prestada una conexión y se agota el tiempo de espera. Qué ocurre dentro de tu ScheduledExecutorService cuando está saturado. Qué ocurre dentro de la implementación SSL del JDK cuando encuentra una retransmisión. Qué ocurre dentro de tu pool de hilos cuando las tareas se acumulan más rápido de lo que los trabajadores pueden procesarlas.

Estos son modos de fallo internos del JVM. No se manifiestan como fallos de red en la capa TCP. Se manifiestan como excepciones lanzadas desde clases del JDK — `SQLException`, `RejectedExecutionException`, `SSLHandshakeException`, `TimeoutException` — y como patrones de agotamiento de recursos que solo se vuelven visibles cuando se instrumentan los sitios de llamada correctos dentro del propio JVM.

Esta es la brecha que llena el agente de caos del JVM. No reemplaza la capa LD_PRELOAD — se compone con ella. Juntas, ofrecen inyección de fallos tanto en el límite del sistema operativo como en el límite interno del JVM: la superficie completa de los modos de fallo de una aplicación Java.

62 Sitios de Llamada del JDK: La Superficie Completa

El agente instrumenta 62 sitios de llamada específicos del JDK — no elegidos al azar, sino los que importan para cargas de trabajo de backend empresarial. La selección fue impulsada por el análisis de incidentes en fallos de sistemas distribuidos:

**Hilos y Executors** Thread.start(), ThreadPoolExecutor.execute(), ScheduledExecutorService.schedule(), CompletableFuture.runAsync/supplyAsync, operaciones de BlockingQueue (put, offer, poll, take), envíos a ForkJoinPool, ciclo de vida de hilos virtuales

**Red y E/S** Socket.connect/read/write, SocketChannel.connect/read/write, Selector.select/selectNow, operaciones de DatagramChannel, ServerSocket.accept

**DNS y Resolución de Nombres** InetAddress.getByName/getAllByName — la ruta de resolución interna del JDK antes de que se llame al resolver del sistema operativo

**JDBC y Acceso a Datos** DataSource.getConnection (préstamo del pool de conexiones), Statement.execute/executeQuery, operaciones de PreparedStatement — en el nivel por el que pasan Hikari, DBCP y c3p0

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

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

**Tiempo** System.currentTimeMillis(), System.nanoTime() — con efectos de desplazamiento FIJO, DERIVA y CONGELACIÓN

**Internos del JVM** System.gc(), carga de clases, reflexión, serialización/deserialización de objetos, ThreadLocal.get/set, búsquedas JNDI, operaciones JMX, carga de bibliotecas nativas, inflado de 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 + Puente Bootstrap: Cómo Funciona

Instrumentar clases del JDK no es trivial. Las clases del JDK son cargadas por el classloader bootstrap — el classloader raíz que no tiene padre. El código del agente vive en el classloader del agente, que el classloader bootstrap no puede ver por nombre. Tender este puente correctamente requiere un uso cuidadoso de JVMTI y del Modelo de Memoria de Java.

**Paso 1 — Premain o agentmain.** El agente se adjunta ya sea al inicio (mediante `-javaagent:`) o dinámicamente en tiempo de prueba (mediante la API Attach del JDK). Al adjuntarse dinámicamente, el starter de pruebas de Spring Boot llama a `VirtualMachine.attach(processId)` e inyecta el jar del agente.

**Paso 2 — Inyección en bootstrap.** Una clase `BootstrapDispatcher` se extrae del jar del agente y se escribe en un JAR temporal. Este JAR se agrega al classpath de bootstrap mediante `Instrumentation.appendToBootstrapClassLoaderSearch()`. El classloader bootstrap ahora puede ver `BootstrapDispatcher`.

**Paso 3 — Cableado de MethodHandle.** Se construye un array `MethodHandle[]` de 62 posiciones, un handle por objetivo de intercepción, que apunta a la implementación del classloader del agente. El array y un objeto `delegate` se publican en `BootstrapDispatcher` como campos `volatile`. El JMM garantiza que cualquier hilo que observe `delegate != null` también vea el array `handles` completamente inicializado.

**Paso 4 — Inlining de bytecode advice.** El mecanismo `@Advice` de ByteBuddy copia el bytecode del advice directamente en el cuerpo del método del JDK instrumentado. No hay despacho virtual, no hay llamada de interfaz — el bytecode tejido es inline. Después de la compilación JIT en el calentamiento (~10.000 invocaciones), toda la cadena de despacho se compila a código 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

Aislamiento de Sesión: Cada Prueba Tiene Su Propio Caos

El problema de ingeniería más difícil en un agente de caos a nivel de JVM no es la instrumentación de bytecode — es el aislamiento. Múltiples pruebas JUnit se ejecutan de manera concurrente en el mismo JVM. Si la prueba A inyecta fallos de conexión JDBC, la prueba B no puede verse afectada por esos fallos.

La solución es `ChaosSession`, respaldada por `ThreadLocal<UUID>`.

Cada método de prueba (o clase de prueba, dependiendo del ciclo de vida) obtiene un ID de sesión único. Los escenarios de caos pueden registrarse como de ámbito JVM (afecta a todos los hilos) o de ámbito de sesión (afecta solo a los hilos que llevan el ID de sesión de esta prueba). El `ThreadLocal` proporciona el enlace.

Para tareas enviadas a executors, el ID de sesión debe propagarse desde el hilo que envía hasta el hilo trabajador. El agente instrumenta `ThreadPoolExecutor.execute()` para envolver el `Runnable` enviado con un decorador que captura el ID de sesión actual y lo restaura en el hilo trabajador antes de que la tarea se ejecute. Esta propagación ocurre de forma transparente — el código de la aplicación no sabe que está sucediendo.

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 y 4: Configuración Cero

Los starters de prueba de Spring Boot existen porque el cableado debería ser invisible. No deberías necesitar entender ByteBuddy, el classloader bootstrap, ni JVMTI para ejecutar tu primera prueba de caos. El starter se encarga de:

- Establecer programáticamente `jdk.attach.allowAttachSelf=true` antes de que el JVM procese este indicador (usando un inicializador estático en la clase de autoconfiguración del starter) - Detectar si el agente ya está adjunto (idempotente — seguro en la ejecución paralela de clases de prueba) - Adjuntar el agente mediante `VirtualMachine.attach("0")` (auto-attach) si aún no está presente - Registrar `ChaosControlPlane` y `ChaosSession` como beans de Spring en el ApplicationContext de prueba - Proveer resolución de parámetros JUnit 5 para los argumentos de constructor `ChaosSession` y `ChaosControlPlane`

El starter de runtime (para despliegue fuera de pruebas) además expone `/actuator/chaos` como un endpoint Actuator protegido para la gestión de escenarios en vivo.

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>

El Pipeline de Activación de Ocho Compuertas

Cada evaluación de un escenario de caos pasa por ocho compuertas en secuencia. Todas las compuertas deben superarse para que el efecto se aplique. Las compuertas son rápidas — se evalúan en el hilo que realiza la llamada, sin E/S y con bloqueos mínimos.

**Compuerta 1 — Verificación de inicio.** ¿Se ha activado el escenario? (Lectura barata de un indicador.)

**Compuerta 2 — Coincidencia de ID de sesión.** Si es de ámbito de sesión, ¿lleva el `ThreadLocal` del hilo actual el UUID correcto? (Lectura de thread-local, sin bloqueo.)

**Compuerta 3 — Coincidencia de selector.** ¿Coincide este sitio de llamada con el selector del escenario? (Comparación de enum, O(1).)

**Compuerta 4 — Ventana de activación.** ¿Está el tiempo actual dentro de la ventana de inicio/fin configurada del escenario? (Dos comparaciones de long.)

**Compuerta 5 — Contador de calentamiento.** ¿Ha coincidido este escenario al menos N veces antes de comenzar a aplicarse? (Lectura de AtomicLong.)

**Compuerta 6 — Límite de tasa.** ¿Tiene capacidad el token bucket de ventana deslizante? (Bloque sincronizado; ruta no contendida ~5 ns.)

**Compuerta 7 — Probabilidad.** Sorteo aleatorio contra la probabilidad configurada. (SplittableRandom sembrado con el ID del escenario + conteo de coincidencias — determinista para reproducir fallos.)

**Compuerta 8 — Máximo de aplicaciones.** Bucle CAS en AtomicLong para aplicar un límite estricto. (Bucle compareAndSet; correcto bajo contención — a diferencia de un simple incrementAndGet que puede sobrepasarse.)

Las ocho compuertas pasan → se aplica el efecto. Cualquier compuerta falla → la llamada pasa al método real del JDK sin cambios.

Estresores en Segundo Plano: Más Allá de los Fallos en la Ruta de Solicitud

Además de los efectos inline en la ruta de solicitud (retardo, rechazo, inyección de excepciones), el agente admite estresores en segundo plano — hilos vinculados al ciclo de vida que aplican presión de recursos de forma continua, independientemente del tráfico.

Los estresores simulan los modos de fallo de degradación lenta que no son desencadenados por operaciones específicas sino que se acumulan con el tiempo:

**Estresores de memoria:** Presión de heap (retención de bloques byte[]), presión de GC (rotación de allocaciones), presión de Metaspace (definiciones de clases sintéticas), presión de buffers directos (ByteBuffer fuera del heap), inundación de intern de strings

**Estresores del JVM:** Presión de caché de código (inundación de clases generadas por ByteBuddy para saturar el JIT), acumulación en la cola de finalizadores (inundación de cola de phantom-reference), inundación de la cola de referencias, tormentas de safepoint (retransformación periódica para provocar pausas stop-the-world)

**Estresores de hilos:** Fuga de hilos (hilos permanentemente detenidos que consumen memoria de stack), fuga de ThreadLocal (entradas en hilos del pool que se acumulan), inyección de deadlock (deadlock real de monitor JVM entre N hilos — verificado con ThreadMXBean), contención de monitor (hilos en segundo plano compitiendo por un bloqueo compartido), hilos keep-alive (evitan el cierre del JVM)

Los estresores se inician cuando se activa un escenario y se detienen cuando se cierra. Se componen con efectos inline: puedes tener simultáneamente rechazos JDBC en la ruta de solicitud y un estresor de caché de código activo, probando si tu aplicación se recupera de fallos de conexión mientras ya está bajo presión de compilación JIT.

  • Presión de heap: retener MB/s configurables en allocaciones de byte[] para forzar presión de GC
  • Presión de Metaspace: definir clases sintéticas en un ClassLoader aislado para consumir permgen/metaspace
  • Presión de caché de código: generar clases ByteBuddy para saturar el compilador JIT
  • Tormenta de safepoint: forzar retransformación periódica para provocar pausas stop-the-world
  • Deadlock real: dos hilos adquieren monitores en orden inverso — detectado por ThreadMXBean
  • Fuga de hilos: detener hilos permanentemente para agotar la memoria de stack -Xss con el tiempo

Ejemplo: Validar el Comportamiento del SLA Bajo Agotamiento de JDBC

Un escenario de prueba concreto que ejercita el valor central del agente: demostrar que tu servicio responde correctamente a un pago con un objetivo de SLA cuando el pool de conexiones a la base de datos está bajo presión.

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 y Observabilidad

El agente expone un MBean JMX en `com.macstab.chaos.jvm:type=ChaosDiagnostics` que proporciona una instantánea en vivo de todos los escenarios activos — su estado actual, conteos de coincidencias y conteos de aplicaciones — sin requerir cambios de código ni configuración adicional.

Esto es útil durante la depuración de pruebas: si una prueba de caos está fallando inesperadamente, puedes conectar JConsole o VisualVM al JVM de prueba e inspeccionar exactamente qué escenarios están activos y cuántas veces se han disparado.

El bus de observabilidad permite publicar eventos de caos en sistemas de métricas externos. Si estás ejecutando pruebas de caos en un entorno de integración compartido y quieres correlacionar eventos de caos con dashboards de Grafana o trazas distribuidas, el bus proporciona el punto de integración.

  • MBean JMX: lista de escenarios activos, conteos de coincidencias, conteos de aplicaciones, estado actual por escenario
  • API de instantánea in-process: consultable desde el código de prueba para aserciones sobre el comportamiento del caos
  • Volcado de depuración: representación textual de todos los escenarios y estados — útil en la salida de fallos de prueba
  • Bus de observabilidad: publicador de eventos enchufable para integración de métricas/trazado

Key Takeaways

El agente de caos del JVM llena la brecha entre la inyección de fallos a nivel de infraestructura y los modos de fallo reales internos del JVM. 62 sitios de llamada del JDK, aislamiento de sesión por prueba, autoconfiguración para Spring Boot 3/4, estresores en segundo plano para fallos de degradación lenta, y una ruta de despacho optimizada por JIT que cuesta ~60 ns por llamada en el caso de escenario cero.

El agente se compone con la biblioteca LD_PRELOAD en C99 para cobertura completa de fallos en toda la pila: fallos a nivel de syscall por debajo del JVM, fallos a nivel de bytecode dentro de él. Ambos son orquestados por el sistema de anotaciones del framework de pruebas Java — una prueba, ambas capas activas, sin superposición de configuración.

La ingeniería del caos como compuerta de CI está lista. La única pregunta es qué modo de fallo todavía no está gestionando tu circuit breaker.

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

Engineering Team

Senior Solutions Architects

Llevamos años construyendo sistemas distribuidos desde antes de que 'microservicios' fuera una palabra de moda. Nuestras cicatrices tienen historia.