A logo showing the text blog.marcnuri.com
English
Inicio»Desarrollo Backend»Guía Completa de Java Virtual Threads (Project Loom)

Entradas Recientes

  • Synology DS224+: Cómo actualizar discos duros en RAID 1
  • Fabric8 Kubernetes Client 7.5 está disponible!
  • Impulsando Mi Productividad como Desarrollador con IA en 2025
  • Black Box vs White Box Testing: Cuándo Usar Cada Enfoque
  • Fabric8 Kubernetes Client 7.4.0 está disponible!

Categorías

  • Antiguo
  • Cloud Native
  • Desarrollo Backend
  • Desarrollo Frontend
  • Herramientas
  • Ingeniería de calidad
  • Inteligencia Artificial
  • JavaScript
  • Operaciones
  • Personal
  • Proyectos personales
  • Reflexiones sobre Ingeniería

Archivos

  • enero 2026
  • diciembre 2025
  • octubre 2025
  • septiembre 2025
  • julio 2025
  • mayo 2025
  • abril 2025
  • marzo 2025
  • febrero 2025
  • enero 2025
  • diciembre 2024
  • noviembre 2024
  • agosto 2024
  • junio 2024
  • mayo 2024
  • abril 2024
  • marzo 2024
  • febrero 2024
  • enero 2024
  • diciembre 2023
  • noviembre 2023
  • octubre 2023
  • septiembre 2023
  • agosto 2023
  • julio 2023
  • junio 2023
  • mayo 2023
  • abril 2023
  • marzo 2023
  • febrero 2023
  • enero 2023
  • diciembre 2022
  • noviembre 2022
  • octubre 2022
  • septiembre 2022
  • agosto 2022
  • julio 2022
  • junio 2022
  • mayo 2022
  • marzo 2022
  • febrero 2022
  • enero 2022
  • diciembre 2021
  • noviembre 2021
  • octubre 2021
  • septiembre 2021
  • agosto 2021
  • julio 2021
  • enero 2021
  • diciembre 2020
  • octubre 2020
  • septiembre 2020
  • agosto 2020
  • junio 2020
  • mayo 2020
  • marzo 2020
  • febrero 2020
  • enero 2020
  • noviembre 2019
  • septiembre 2019
  • julio 2019
  • diciembre 2018
  • agosto 2018
  • julio 2018
  • junio 2018
  • mayo 2018
  • marzo 2018
  • febrero 2018
  • noviembre 2017
  • octubre 2017
  • agosto 2017
  • julio 2017
  • enero 2017
  • julio 2016
  • enero 2016
  • diciembre 2015
  • noviembre 2015
  • diciembre 2014
  • noviembre 2014
  • octubre 2014
  • marzo 2014
  • febrero 2011
  • junio 2008
  • mayo 2008
  • abril 2008
  • enero 2008
  • junio 2007
  • mayo 2007
  • abril 2007
  • marzo 2007

Guía Completa de Java Virtual Threads (Project Loom)

2023-10-15 en Desarrollo Backend etiquetado Java / JVM / Concurrencia / Rendimiento / Java 21 por Marc Nuri | Última actualización: 2026-01-23
English version

Introducción

Java 21, lanzado el 19 de septiembre de 2023, marca un momento histórico en la historia de Java con la introducción de Virtual Threads como funcionalidad lista para producción. Tras años de desarrollo bajo Project Loom, los virtual threads finalmente cumplen la promesa de hacer que las aplicaciones concurrentes de alto rendimiento sean sencillas de escribir, depurar y mantener.

Los virtual threads son hilos ligeros que reducen drásticamente el esfuerzo de escribir, mantener y observar aplicaciones concurrentes de alto rendimiento. Permiten a los desarrolladores escribir código bloqueante que escala como el código reactivo, sin sacrificar el modelo tradicional de programación de hilo-por-petición que ha servido a los desarrolladores de Java durante décadas.

En esta guía completa, exploraremos qué son los virtual threads, cómo funcionan internamente, cuándo usarlos y las buenas prácticas para aprovechar al máximo esta revolucionaria funcionalidad.

Nota

Los virtual threads se llamaban originalmente "fibers" durante el desarrollo temprano de Project Loom. El equipo los renombró porque "fibers" ya se usaba para construcciones similares-pero-diferentes en otros contextos, causando confusión.

Brian Goetz sugirió "virtual threads" para evocar la analogía con la memoria virtual: igual que la memoria virtual proporciona la ilusión de más memoria física de la que realmente existe, los virtual threads proporcionan la ilusión de más hilos del SO de los que realmente existen.

Entendiendo el Problema: ¿Por Qué Virtual Threads?

Antes de explorar los virtual threads, es esencial comprender el problema que resuelven. La concurrencia tradicional en Java se basa en platform threads, que son wrappers alrededor de los hilos del sistema operativo (SO).

El cuello de botella de los platform threads

Los platform threads tienen varias limitaciones:

  1. Intensivos en recursos: Cada hilo consume aproximadamente 1MB de memoria de pila y requiere recursos del kernel del SO.
  2. Sobrecarga por cambio de contexto: El planificador del SO gestiona los hilos, y los cambios de contexto son costosos (típicamente 1-10 microsegundos).
  3. Escalabilidad limitada: La mayoría de los sistemas tienen problemas más allá de 10.000-20.000 hilos concurrentes.
  4. Dimensionamiento de pools de hilos: Los desarrolladores deben ajustar cuidadosamente los pools de hilos, equilibrando rendimiento y consumo de recursos.

Considera un servidor web típico manejando 10.000 peticiones concurrentes. Con el modelo hilo-por-petición, necesitas 10.000 platform threads, consumiendo ~10GB de memoria solo para las pilas de los hilos. Esto no escala a aplicaciones cloud modernas que pueden necesitar manejar cientos de miles de conexiones concurrentes.

La alternativa reactiva y sus costes

Los frameworks reactivos como Spring WebFlux, RxJava y Vert.x surgieron para abordar estas limitaciones. Usan I/O no bloqueante con un número reducido de hilos, logrando escalabilidad masiva.

Un ejemplo real de este compromiso es el Fabric8 Kubernetes Client. Al construir operadores y controladores de Kubernetes basados en Java, cada watcher o SharedInformer tradicionalmente requería su propio hilo. En clústeres grandes con cientos de Custom Resource Definitions (CRDs) y miles de recursos monitorizados, la sobrecarga de hilos se convertía en un cuello de botella significativo. Esta limitación fue una de las principales razones por las que añadimos implementaciones de cliente HTTP reactivo a Fabric8, incluyendo Vert.x, que usa I/O no bloqueante para manejar cantidades masivas de watches concurrentes sin la sobrecarga de hilos.

Sin embargo, la programación reactiva tiene costes significativos:

  • Curva de aprendizaje pronunciada: Los desarrolladores deben pensar en términos de streams, publishers y subscribers.
  • Pesadilla de depuración: Los stack traces se vuelven prácticamente inútiles ya que la ejecución salta entre callbacks.
  • Adopción viral: Una vez que vas reactivo, todo debe ser reactivo, las librerías síncronas no pueden usarse.
  • Complejidad del código: La lógica simple se vuelve enrevesada con operadores como flatMap, switchIfEmpty y onErrorResume.

Los virtual threads prometen lo mejor de ambos mundos: la simplicidad del código bloqueante con la escalabilidad de los sistemas reactivos.

¿Qué son los Virtual Threads?

Los virtual threads son hilos ligeros gestionados por la JVM en lugar del sistema operativo. Son instancias de java.lang.Thread que se ejecutan sobre platform threads (llamados "carrier threads") pero no los monopolizan durante las operaciones bloqueantes.

☕ Java Virtual Machine

🖥️ Operating System

Virtual Threads (millions possible)

Carrier Thread Pool (ForkJoinPool)

mounted

mounted

mounted

parked (waiting)

parked (waiting)

OS Thread 1

OS Thread 2

OS Thread N

Carrier Thread 1

Carrier Thread 2

Carrier Thread N

VT 1

VT 2

VT 3

VT 4

VT ...N

Virtual Threads vs Platform Threads Architecture

Características principales

  • Baratos de crear: Los virtual threads consumen solo unos pocos cientos de bytes inicialmente, creciendo según sea necesario.
  • Baratos de bloquear: Cuando un virtual thread se bloquea, libera su carrier thread para otro trabajo.
  • API familiar: Implementan java.lang.Thread, por lo que el código existente funciona con cambios mínimos.
  • Depurables: Stack traces completos, soporte estándar de depurador e integración con JFR (Java Flight Recorder).

Cronología e Historia de Project Loom

Project Loom lleva más de una década en desarrollo. Aquí está el recorrido desde el concepto hasta la funcionalidad lista para producción:

Origins2013Quasar librarycreated by ParallelUniverse2015Ron Presslerpresents Quasar atJVMLS2017Ron Pressler joinsOracleProject Loomofficially startsPreview Releases2019First early-accessbuilds available2022JEP 425 - Preview inJava 19Significant APIrefinements2023JEP 436 - SecondPreview in Java 20StructuredTaskScopeintroducedGA and Evolution2023JEP 444 - Finalrelease in Java 21LTSVirtual Threads goGA2024Widespreadadoption beginsSpring Boot 3.2+native support2025JEP 491 - Pinning fixin Java 24Java 25 LTS withmature VTProject Loom Evolution

El extenso período de desarrollo permitió al equipo refinar la API, optimizar el rendimiento y asegurar la compatibilidad con el código Java existente.

Creando Virtual Threads

Hay cuatro formas principales de crear virtual threads en Java 21. Exploremos cada enfoque con ejemplos prácticos.

Método 1: Thread.startVirtualThread()

El siguiente fragmento de código muestra la forma más simple de iniciar un virtual thread:

StartVirtualThread.java
public class StartVirtualThread {
    public static void main(String[] args) throws InterruptedException {
        Thread vt = Thread.startVirtualThread(() -> {
            System.out.println("Running in: " + Thread.currentThread());
            System.out.println("Is virtual: " + Thread.currentThread().isVirtual());
        });
        vt.join();
    }
}

Este método inicia inmediatamente el virtual thread y devuelve un objeto Thread.

Método 2: Thread.ofVirtual().start()

El siguiente fragmento de código muestra cómo usar el patrón builder para mayor control sobre la configuración del hilo:

OfVirtualStart.java
public class OfVirtualStart {
    public static void main(String[] args) throws InterruptedException {
        Thread vt = Thread.ofVirtual()
            .name("my-virtual-thread")
            .start(() -> {
                System.out.println("Thread name: " + Thread.currentThread().getName());
                simulateWork();
            });
        vt.join();
    }

    private static void simulateWork() {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

El patrón builder te permite establecer el nombre del hilo, manejador de excepciones no capturadas y otras propiedades.

Método 3: Executors.newVirtualThreadPerTaskExecutor()

El siguiente fragmento de código muestra cómo usar el servicio executor para aplicaciones en producción:

VirtualThreadExecutor.java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

public class VirtualThreadExecutor {
    public static void main(String[] args) {
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(0, 10_000).forEach(i -> {
                executor.submit(() -> {
                    Thread.sleep(1000);
                    return i;
                });
            });
        } // executor.close() is called implicitly, waits for tasks to complete
        System.out.println("All tasks completed");
    }
}

Nota

El método newVirtualThreadPerTaskExecutor() crea un nuevo virtual thread para cada tarea enviada. A diferencia de los pools de hilos tradicionales, no hay necesidad de configurar tamaños de pool, los virtual threads son lo suficientemente baratos como para crearlos bajo demanda.

Método 4: ThreadFactory para virtual threads

El siguiente fragmento de código muestra cómo crear hilos con configuración consistente usando un ThreadFactory:

VirtualThreadFactory.java
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicLong;

public class VirtualThreadFactory {
    public static void main(String[] args) throws InterruptedException {
        AtomicLong counter = new AtomicLong();

        ThreadFactory factory = Thread.ofVirtual()
            .name("worker-", counter.getAndIncrement())
            .factory();

        Thread t1 = factory.newThread(() -> System.out.println(Thread.currentThread().getName()));
        Thread t2 = factory.newThread(() -> System.out.println(Thread.currentThread().getName()));

        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}

El thread factory es útil cuando se integra con librerías que aceptan un parámetro ThreadFactory.

Cómo Funcionan los Virtual Threads Internamente

Comprender los aspectos internos te ayuda a escribir mejor código y depurar problemas. Los virtual threads usan una técnica llamada "continuation" para pausar y reanudar la ejecución.

Operating SystemCarrier ThreadJVM SchedulerVirtual ThreadApplication CodeOperating SystemCarrier ThreadJVM SchedulerVirtual ThreadApplication CodeBlocking operation (I/O, sleep, lock)VT waiting for I/O...submit taskready to runmount VTexecute codesave continuation (stack)unmount from carriercarrier now freeavailable for other VTsI/O completemount VT againrestore continuationresume executiontask completeVirtual Thread Lifecycle: Mounting and Unmounting

El proceso de mounting y unmounting

  1. Mounting: Cuando un virtual thread está listo para ejecutarse, el planificador lo monta en un carrier thread disponible.
  2. Ejecución: El virtual thread se ejecuta en el carrier thread igual que un hilo normal.
  3. Bloqueo: Cuando el virtual thread encuentra una operación bloqueante, guarda su estado (continuation) y se desmonta del carrier.
  4. Parking: El virtual thread entra en estado parked, consumiendo recursos mínimos.
  5. Reanudación: Cuando la operación bloqueante se completa, el virtual thread se programa para montarse de nuevo (posiblemente en un carrier diferente).

Pool de carrier threads

Por defecto, la JVM usa un ForkJoinPool con work-stealing como pool de carrier threads. Sin embargo, este es un pool interno especializado, no el ForkJoinPool.commonPool() común usado por los streams paralelos. No puedes configurarlo usando las mismas propiedades del sistema que el pool común, utiliza las propiedades específicas de virtual threads mostradas a continuación.

El número de carrier threads por defecto es el número de procesadores disponibles, pero puede configurarse:

# Set carrier thread count
java -Djdk.virtualThreadScheduler.parallelism=4 MyApp

# Set maximum pool size (for unparking)
java -Djdk.virtualThreadScheduler.maxPoolSize=256 MyApp

Benchmarks de Rendimiento

Examinemos cómo se comparan los virtual threads con los platform threads en escenarios del mundo real. Puedes ejecutar el benchmark VirtualThreadsPerformance.java tú mismo para ver la diferencia.

Comparación de throughput

Según JEP 444, al ejecutar tareas concurrentes que duermen un segundo (simulando I/O bloqueante):

MétricaPlatform Threads (200 en pool)Virtual ThreadsMejora
Tareas por segundo~200~10,00050x más rápido
Capacidad concurrenteLimitada por tamaño del poolMillonesSin límite

La mejora en throughput proviene de la capacidad de los virtual threads de manejar eficientemente las operaciones bloqueantes sin consumir hilos del SO.

Huella de memoria y tiempo de creación

Una ventaja clave de los virtual threads es que no reservan una pila grande y contigua como los platform threads.

Según Oracle Java Magazine, un platform thread en sistemas Linux x64 típicos reserva aproximadamente 1 MB de memoria virtual para su pila por defecto (-Xss). Esta reserva ocurre incluso si el hilo nunca usa toda la pila.

Los virtual threads se comportan de forma muy diferente. Sus stack frames se alojan en el heap y crecen bajo demanda, comenzando desde una huella inicial muy pequeña. Esto permite a la JVM albergar millones de virtual threads en la misma memoria donde los platform threads estarían limitados a solo miles.

Como el uso de pila de los virtual threads depende de la profundidad real de llamadas y las variables locales, los números varían entre cargas de trabajo. La siguiente tabla ilustra el orden típico de magnitud de diferencia:

Tareas ConcurrentesMemoria Platform Threads (aprox. 1 MB c/u)Memoria Virtual Threads (varía según uso)
1,000~1 GBUnos pocos MB
10,000~10 GBDecenas de MB
100,000No prácticoCientos de MB
1,000,000No prácticoPosible en servidores modernos

Aunque el uso exacto de memoria de los virtual threads depende de la profundidad de pila de tu código, la tendencia es clara: los virtual threads permiten concurrencia masiva con requisitos de memoria drásticamente menores.

El tiempo de creación también es significativamente más rápido. Como los virtual threads no requieren asignación a nivel del SO, millones de ellos pueden crearse rápidamente, mientras que la creación de platform threads es comparativamente costosa debido a la interacción con el kernel.

Nota

Los virtual threads no son ligeros porque hagan menos trabajo. Son ligeros porque la JVM realiza las partes costosas de forma lazy, solo cuando es necesario.

Advertencia sobre cargas de trabajo CPU-bound

Los virtual threads están diseñados para manejar cantidades masivas de tareas I/O-bound de manera eficiente. Proporcionan poco beneficio cuando el trabajo está dominado por computación pura, porque las cargas de trabajo CPU-bound están limitadas por la disponibilidad de núcleos en lugar del número de hilos.

Advertencia

Para tareas intensivas en CPU, los virtual threads pueden lograr solo 50-55% del throughput de los platform threads debido a la sobrecarga adicional del planificador. En estos casos, prefiere executors basados en platform threads como ForkJoinPool o Executors.newFixedThreadPool().

Si tu aplicación pasa la mayor parte del tiempo esperando sistemas externos (bases de datos, llamadas HTTP, I/O de archivos), los virtual threads pueden mejorar dramáticamente la escalabilidad y simplificar tu código. Pero cuando la computación paralela es el cuello de botella, quédate con los pools tradicionales.

Thread Pinning: El Problema Crítico

El thread pinning es el concepto más importante a entender cuando se trabaja con virtual threads. Un virtual thread se "ancla" a su carrier thread cuando no puede desmontarse durante una operación bloqueante.

¿Qué causa el pinning?

  1. Bloques/métodos synchronized (JDK 21-23): La JVM no puede desmontar un virtual thread mientras mantiene un monitor lock.
    Actualización: JEP 491 en JDK 24 soluciona esta limitación, permitiendo a los virtual threads desmontarse incluso cuando mantienen monitor locks.
  2. Ejecución de código nativo: Las llamadas JNI previenen el desmontaje.
  3. Llamadas a funciones foráneas: Las llamadas a la API Panama Foreign Function & Memory (FFI) pueden causar pinning.

Detectando el pinning

Habilita la detección de pinning con flags de la JVM:

# Log pinning events
java -Djdk.tracePinnedThreads=full MyApp

# Or for shorter output
java -Djdk.tracePinnedThreads=short MyApp

Antes: Código que ancla (JDK 21-23)

PinnedThread.java
public class PinnedThread {
    private final Object lock = new Object();

    public void problematicMethod() {
        synchronized (lock) {          // ⚠️ Pinning starts here
            performBlockingIO();        // Virtual thread cannot unmount!
        }                              // Pinning ends
    }

    private void performBlockingIO() {
        try {
            Thread.sleep(1000);        // Would normally unmount, but can't
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Después: Código que no ancla (solución para JDK 21-23)

UnpinnedThread.java
import java.util.concurrent.locks.ReentrantLock;

public class UnpinnedThread {
    private final ReentrantLock lock = new ReentrantLock();

    public void improvedMethod() {
        lock.lock();                   // ✅ ReentrantLock allows unmounting
        try {
            performBlockingIO();        // Virtual thread CAN unmount
        } finally {
            lock.unlock();
        }
    }

    private void performBlockingIO() {
        try {
            Thread.sleep(1000);        // Thread unmounts during sleep
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

Precaución

El thread pinning puede degradar severamente el rendimiento en JDK 21-23. Si muchos virtual threads están anclados simultáneamente, efectivamente agotarás el pool de carrier threads, anulando los beneficios de los virtual threads.

Para JDK 21-23, prefiere ReentrantLock sobre synchronized cuando hay operaciones bloqueantes involucradas.

JDK 24+: Con JEP 491, synchronized ya no causa pinning, por lo que puedes usarlo libremente con virtual threads.

Cuándo NO Usar Virtual Threads

Los virtual threads no son una solución mágica. Aquí hay escenarios donde los platform threads siguen siendo la mejor opción:

Cargas de trabajo CPU-bound

Los virtual threads no proporcionan beneficio para tareas intensivas en CPU:

CpuBoundTask.java
// ❌ Don't use virtual threads for this
public long computePrimes(int limit) {
    return LongStream.range(2, limit)
        .filter(this::isPrime)
        .count();
}

Para trabajo CPU-bound, estás limitado por el número de núcleos físicos, no por los hilos. Usa el ForkJoinPool estándar o Executors.newFixedThreadPool() en su lugar.

Cuando necesitas caché thread-local

Las variables thread-local en virtual threads pueden causar problemas de memoria:

ThreadLocalIssue.java
// ⚠️ Problematic with virtual threads
private static final ThreadLocal<ExpensiveCache> CACHE =
    ThreadLocal.withInitial(ExpensiveCache::new);

public void processRequest() {
    // Each of 1 million virtual threads gets its own cache!
    ExpensiveCache cache = CACHE.get();
    // ...
}

Con millones de virtual threads, el almacenamiento thread-local puede consumir cantidades enormes de memoria. Considera usar ScopedValue (funcionalidad en preview) en su lugar.

Cuando las librerías usan synchronized extensivamente

Algunas librerías antiguas usan synchronized de forma generalizada:

  • Drivers JDBC legacy
  • Clientes HTTP antiguos
  • Algunos frameworks de logging

Verifica el comportamiento de pinning de tus dependencias antes de migrar.

Buenas Prácticas

Sigue estas directrices para aprovechar al máximo los virtual threads:

1. No hagas pooling de virtual threads

A diferencia de los platform threads, los virtual threads son baratos de crear. Hacer pooling de ellos es innecesario y puede limitar la escalabilidad:

DontPool.java
// ❌ Don't do this
ExecutorService pool = Executors.newFixedThreadPool(100,
    Thread.ofVirtual().factory());

// ✅ Do this instead
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

2. Usa try-with-resources para executors

Siempre cierra los servicios executor correctamente:

TryWithResources.java
// ✅ Proper resource management
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    executor.submit(() -> processRequest());
} // Automatically waits for tasks and shuts down

3. Prefiere ReentrantLock sobre synchronized

Cuando hay I/O bloqueante involucrado, usa locks de java.util.concurrent:

PreferReentrantLock.java
// ✅ Virtual thread friendly
private final ReentrantLock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();

public void waitForCondition() throws InterruptedException {
    lock.lock();
    try {
        while (!ready) {
            condition.await(); // Can unmount
        }
    } finally {
        lock.unlock();
    }
}

4. Mantén las operaciones bloqueantes cortas

Los virtual threads destacan con muchas operaciones bloqueantes cortas, no pocas largas:

ShortBlocking.java
// ✅ Many short operations - ideal for virtual threads
for (String url : urls) {
    executor.submit(() -> fetchUrl(url)); // Each fetch is short
}

// ⚠️ Fewer long operations - less benefit
executor.submit(() -> processLargeFile()); // Minutes of work

5. Usa structured concurrency

Cuando esté disponible, prefiere structured concurrency para código más limpio y mejor manejo de errores (ver sección Structured Concurrency).

Errores Comunes y Anti-patrones

Error 1: Abuso de Thread.yield()

No uses Thread.yield() pensando que ayudará al planificador:

YieldAbuse.java
// ❌ Don't do this
while (processing) {
    doWork();
    Thread.yield(); // Unnecessary with virtual threads
}

Los virtual threads se desmontan automáticamente durante las operaciones bloqueantes. El yield manual añade sobrecarga sin beneficio.

Error 2: Ignorar InterruptedException

Siempre maneja la interrupción correctamente:

HandleInterruption.java
// ❌ Wrong
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    // Ignoring - bad practice!
}

// ✅ Correct
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    throw new RuntimeException("Operation cancelled", e);
}

Error 3: Asumir que los virtual threads siempre son más rápidos

Los virtual threads ayudan con operaciones bloqueantes, no con computación:

NotAlwaysFaster.java
// Virtual threads won't help here
IntStream.range(0, 1000)
    .parallel()                    // Uses ForkJoinPool - good for CPU work
    .map(this::heavyComputation)
    .sum();

Virtual Threads con Spring Boot

Spring Boot 3.2+ proporciona soporte nativo para virtual threads. Habilítalos con una sola propiedad:

application.yml
spring:
  threads:
    virtual:
      enabled: true

O en application.properties:

application.properties
spring.threads.virtual.enabled=true

Qué habilita esto

  • Tomcat/Jetty/Undertow usan virtual threads para el manejo de peticiones
  • Los métodos @Async se ejecutan en virtual threads
  • Spring WebFlux continúa usando patrones reactivos (sin cambios)

Resultados de rendimiento con Spring Boot

Los primeros usuarios reportan mejoras significativas al migrar a virtual threads:

MétricaMejora
Uso de memoria43% de reducción
Latencia tail (p99)4x de mejora
Uso de CPU20-40% menor con la misma carga
Throughput2x de mejora para cargas I/O-bound

Configuración de executor personalizado

Para control más fino:

VirtualThreadConfig.java
@Configuration
public class VirtualThreadConfig {

    @Bean
    public AsyncTaskExecutor applicationTaskExecutor() {
        return new TaskExecutorAdapter(
            Executors.newVirtualThreadPerTaskExecutor()
        );
    }

    @Bean
    public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadCustomizer() {
        return protocolHandler -> {
            protocolHandler.setExecutor(
                Executors.newVirtualThreadPerTaskExecutor()
            );
        };
    }
}

Nota

Con Spring Boot 3.2+ y spring.threads.virtual.enabled=true, tu código bloqueante existente se beneficia automáticamente de los virtual threads sin ningún cambio de código. Esta es la ruta de migración más fácil para la mayoría de las aplicaciones.

Virtual Threads vs Reactivo

Ambos enfoques resuelven el problema de escalabilidad, pero difieren significativamente. Los frameworks reactivos como Vert.x, Mutiny (usado por Quarkus) y Spring WebFlux comparten características similares:

AspectoVirtual ThreadsReactivo (Vert.x/Mutiny)
Modelo programaciónImperativo, bloqueanteDeclarativo, no bloqueante
Curva aprendizajeBaja (APIs familiares)Alta (nuevo paradigma)
DepuraciónHerramientas estándarCompleja, trazas fragmentadas
Código existenteFunciona con cambios mínimosRequiere reescritura
Eficiencia CPUBuenaExcelente
Memoria bajo cargaBuenaMejor
Manejo errorestry/catchOperadores (onFailure, recover)
TestingTests unitarios simplesRequiere testing reactivo

Cuándo elegir reactivo

  • Datos en streaming (SSE, uso intensivo de WebSocket)
  • Requisitos de backpressure
  • El proyecto ya ha invertido en un ecosistema reactivo (Vert.x, Mutiny, RxJava)
  • Necesidad de máxima eficiencia a escala extrema

Cuándo elegir virtual threads

  • Aplicaciones tradicionales petición/respuesta
  • Equipo familiarizado con código bloqueante
  • Necesidad de integrar con librerías legacy
  • La depuración y mantenibilidad son prioridades

Virtual Threads vs Go Goroutines vs Kotlin Coroutines

¿Cómo se comparan los virtual threads de Java con funcionalidades similares en otros lenguajes?

FuncionalidadJava Virtual ThreadsGo GoroutinesKotlin Coroutines
LanzamientoJava 21 (2023)Go 1.0 (2012)Kotlin 1.3 (2018)
RuntimeJVMRuntime de GoJVM/Native/JS
Pila por defecto512 bytes + crece2 KB + crece~docena de objetos
Máx. concurrenteMillonesMillonesMillones
PlanificaciónWork-stealingPlanificador M:NDispatchers
BloqueoDesmontaje automáticoAutomáticofunciones suspend
Integración nativaJNI anclaCGO anclaEspecífico plataforma
Structured concurrencyPreviewExplícito (WaitGroup)Nativo

Ventaja de Go

Go fue diseñado desde cero con goroutines. Toda la librería estándar es no bloqueante, así que no hay equivalente al "pinning".

Ventaja de Java

Los virtual threads funcionan con código Java existente. No necesitas reescribir librerías ni aprender un nuevo paradigma. El vasto ecosistema de Java se vuelve automáticamente más escalable.

Enfoque de Kotlin

Las coroutines de Kotlin requieren funciones suspend explícitas, haciendo claro qué puede pausarse. Esto es más explícito pero requiere aprender nuevos patrones.

Structured Concurrency y Scoped Values

Java 21 introduce structured concurrency (preview) junto con los virtual threads. Estas funcionalidades trabajan juntas para simplificar la programación concurrente.

Nota

Structured concurrency y scoped values siguen en preview a partir de Java 24. Se espera que alcancen estado estable en Java 25 LTS o poco después, por lo que la API puede cambiar ligeramente.

StructuredTaskScope

Structured concurrency trata grupos de tareas concurrentes como una unidad única:

StructuredConcurrency.java
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;

public class StructuredConcurrency {
    record User(String name) {}
    record Order(String id) {}
    record Response(User user, Order order) {}

    public Response fetchUserAndOrder(String userId, String orderId)
            throws InterruptedException {
        try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
            Subtask<User> userTask = scope.fork(() -> fetchUser(userId));
            Subtask<Order> orderTask = scope.fork(() -> fetchOrder(orderId));

            scope.join()           // Wait for both tasks
                 .throwIfFailed(); // Propagate exceptions

            return new Response(userTask.get(), orderTask.get());
        }
    }

    private User fetchUser(String id) { /* ... */ return new User("Marc"); }
    private Order fetchOrder(String id) { /* ... */ return new Order("ORD-123"); }
}

Consejo

Structured concurrency proporciona tres garantías clave:

  1. Las tareas no sobreviven a su scope
  2. La cancelación es automática cuando el scope falla
  3. El manejo de errores está centralizado

Scoped Values

ScopedValue (preview) es el reemplazo moderno para ThreadLocal:

ScopedValuesExample.java
import jdk.incubator.concurrent.ScopedValue;

public class ScopedValuesExample {
    private static final ScopedValue<String> USER_ID = ScopedValue.newInstance();

    public void handleRequest(String userId) {
        ScopedValue.runWhere(USER_ID, userId, () -> {
            processRequest();
        });
    }

    private void processRequest() {
        String userId = USER_ID.get(); // Available in child virtual threads too
        System.out.println("Processing for user: " + userId);
    }
}

Los scoped values son inmutables y se heredan automáticamente por los virtual threads hijos, haciéndolos ideales para propagar contexto de peticiones.

Observabilidad y Depuración

Los virtual threads se integran con las herramientas de observabilidad existentes de Java.

Thread dumps

Usa jcmd para obtener thread dumps incluyendo virtual threads:

jcmd <pid> Thread.dump_to_file -format=json threads.json

El formato JSON incluye detalles de virtual threads:

{
  "tid": "123456",
  "name": "virtual-thread-1",
  "virtual": true,
  "state": "WAITING",
  "stack": [...]
}

Java Flight Recorder (JFR)

JFR proporciona eventos de virtual threads:

java -XX:StartFlightRecording=filename=recording.jfr MyApp

Los eventos incluyen:

  • jdk.VirtualThreadStart
  • jdk.VirtualThreadEnd
  • jdk.VirtualThreadPinned

Depuración en IDEs

IntelliJ IDEA y Eclipse soportan depuración de virtual threads:

  • Los breakpoints funcionan normalmente
  • Step through del código de virtual threads
  • Ver stack traces de virtual threads
  • Breakpoints condicionales en virtual threads

Mejoras de Observabilidad en JDK 24+

JDK 24 introduce funcionalidades adicionales de observabilidad para virtual threads. Si estás en JDK 21 o 22, todavía puedes usar thread dumps y eventos JFR descritos arriba, las funcionalidades siguientes son mejoras disponibles solo en JDK 24+.

# View virtual thread scheduler statistics
jcmd <pid> Thread.vthread_scheduler

# Enhanced thread dump with virtual thread details
jcmd <pid> Thread.dump_to_file -format=json threads.json

El VirtualThreadSchedulerMXBean proporciona acceso programático a métricas del planificador:

VirtualThreadMonitoring.java
import java.lang.management.ManagementFactory;
import jdk.management.VirtualThreadSchedulerMXBean;

// Get the virtual thread scheduler MXBean (JDK 24+)
VirtualThreadSchedulerMXBean mxBean = ManagementFactory.getPlatformMXBean(
    VirtualThreadSchedulerMXBean.class
);

// Monitor scheduler metrics
System.out.println("Parallelism: " + mxBean.getParallelism());
System.out.println("Pool size: " + mxBean.getPoolSize());
System.out.println("Mounted count: " + mxBean.getMountedVirtualThreadCount());
System.out.println("Queued count: " + mxBean.getQueuedVirtualThreadCount());

Ejemplo del Mundo Real: Cliente HTTP

El siguiente fragmento de código demuestra un ejemplo práctico de obtener datos de múltiples APIs concurrentemente:

ConcurrentHttpClient.java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class ConcurrentHttpClient {
    private static final HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(10))
        .build();

    public static void main(String[] args) throws Exception {
        List<String> urls = List.of(
            "https://api.github.com/users/octocat",
            "https://api.github.com/repos/openjdk/jdk",
            "https://api.github.com/orgs/spring-projects",
            "https://api.github.com/users/marcnuri-demo"
        );

        long start = System.currentTimeMillis();

        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<String>> futures = urls.stream()
                .map(url -> executor.submit(() -> fetchUrl(url)))
                .toList();

            for (Future<String> future : futures) {
                String response = future.get();
                System.out.println("Received " + response.length() + " bytes");
            }
        }

        long elapsed = System.currentTimeMillis() - start;
        System.out.printf("Completed %d requests in %d ms%n", urls.size(), elapsed);
    }

    private static String fetchUrl(String url) throws Exception {
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(url))
            .header("User-Agent", "Java Virtual Threads Demo")
            .GET()
            .build();

        HttpResponse<String> response = client.send(request,
            HttpResponse.BodyHandlers.ofString());

        return response.body();
    }
}

Este ejemplo demuestra:

  • Crear virtual threads vía executor
  • Peticiones HTTP paralelas
  • Gestión adecuada de recursos con try-with-resources
  • Medición de rendimiento

Al ejecutar este código, las cuatro peticiones se ejecutan concurrentemente, completándose aproximadamente en el tiempo de la petición más lenta en lugar de la suma de todos los tiempos de petición.

Guía de Migración

¿Listo para migrar tu aplicación a virtual threads? Sigue esta guía paso a paso.

Paso 1: Actualizar a Java 21+

Asegúrate de que tu proyecto use Java 21 o posterior:

pom.xml
<properties>
    <java.version>21</java.version>
</properties>

Paso 2: Identificar código bloqueante

Busca código que bloquea:

  • Llamadas a base de datos (JDBC, JPA)
  • Llamadas de cliente HTTP
  • I/O de archivos
  • Thread.sleep()
  • Contención de locks

Estos son candidatos principales para beneficiarse de virtual threads.

Paso 3: Verificar el pinning

Audita tu código en busca de bloques synchronized que contengan operaciones bloqueantes:

# Search for synchronized with blocking inside
grep -r "synchronized" --include="*.java" .

Reemplaza synchronized con ReentrantLock donde ocurra bloqueo.

Paso 4: Habilitar virtual threads

Para aplicaciones Spring Boot:

spring.threads.virtual.enabled: true

Para aplicaciones personalizadas, reemplaza pools de hilos:

// Before
ExecutorService executor = Executors.newFixedThreadPool(200);

// After
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

Paso 5: Probar bajo carga

Ejecuta pruebas de carga para verificar:

  • Mejoras en throughput
  • Sin advertencias de pinning (verifica logs)
  • El uso de memoria permanece razonable
  • Sin degradación en operaciones CPU-bound

Paso 6: Monitorear en producción

Habilita eventos JFR y monitorea:

  • Conteo de virtual threads
  • Eventos de pinning
  • Utilización de carrier threads

Conclusión

Los virtual threads representan el cambio más significativo en la concurrencia de Java desde la introducción de java.util.concurrent en Java 5. Cumplen con la promesa de Project Loom: la simplicidad del código bloqueante con la escalabilidad de los sistemas no bloqueantes.

Lecciones clave:

  1. Los virtual threads son baratos: Crea millones sin preocuparte por memoria o tiempo de inicio.
  2. Bloquear ahora es aceptable: Los virtual threads hacen el I/O bloqueante eficiente de nuevo.
  3. APIs familiares: Usa las APIs estándar de Thread y ExecutorService que ya conoces.
  4. Cuidado con el pinning: Reemplaza synchronized con ReentrantLock cuando hay operaciones bloqueantes involucradas.
  5. No apto para trabajo CPU-bound: Los virtual threads ayudan con cargas I/O-bound, no CPU-bound.
  6. Migración fácil: Spring Boot 3.2+ hace la adopción trivial con una sola propiedad de configuración.

El futuro de la concurrencia en Java está aquí, y es más accesible que nunca. Ya estés construyendo microservicios, pipelines de procesamiento de datos o aplicaciones web, los virtual threads pueden ayudarte a escalar para satisfacer la demanda mientras mantienes tu código simple y mantenible.

Código fuente

Puedes encontrar el código fuente de este artículo en GitHub.

Te Puede Interesar

  • Patrones de Concurrencia en Go: Goroutines y Channels
  • Recolectores de Basura de Java para la Nube
  • Cómo Inicializar un Nuevo Proyecto Go con Módulos
Twitter iconFacebook iconLinkedIn iconPinterest iconEmail icon

Navegador de artículos
Pruebas unitarias para APIs REST basadas en Go Gin Web Framework con httptestCómo preparar y desmontar tests unitarios en Go
© 2007 - 2026 Marc Nuri