Java Virtual Threads: guía práctica
1. Descripción general
Los virtual threads son una característica oficial a partir de Java 21 (JEP 444) que te permiten escribir código síncrono (blocking style) pero aun así lograr un throughput alto como el de I/O no bloqueante. Este artículo cubre desde los conceptos, cómo crear virtual threads, comparar el rendimiento con platform threads, hasta integrarlos en Spring Boot. Al final, sabrás cuándo usar virtual threads y cuándo no.
2. El problema con los Platform Threads tradicionales
Cada platform thread en Java se mapea 1-1 a un thread del OS. Los threads del OS consumen memoria (típicamente 1MB de stack) y crear/cambiar de contexto (context switch) tiene un costo elevado. Por eso, las aplicaciones que manejan decenas de miles de solicitudes simultáneas (por ejemplo, un servidor esperando I/O) están limitadas por la cantidad de threads del pool que se pueden crear.
ExecutorService executor = Executors.newFixedThreadPool(200);
for (int i = 0; i < 10_000; i++) {
executor.submit(() -> {
try {
Thread.sleep(1000); // simular una llamada de I/O
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
Con un pool de 200 threads, las 10.000 tareas deben esperar en cola, y el tiempo total de finalización se alarga porque cada thread solo puede procesar una tarea a la vez, aunque la mayor parte del tiempo sea espera (bloqueado), no computación.
3. ¿Qué es un Virtual Thread?
Un virtual thread es un thread gestionado por la JVM (no por el OS), que se “monta” en un platform thread (llamado carrier thread) solo cuando está ejecutando código. Cuando un virtual thread realiza una operación bloqueante (I/O, Thread.sleep, lock…), la JVM automáticamente lo “desmonta” del carrier thread, liberando el carrier thread para procesar otros virtual threads.
El resultado: puedes crear millones de virtual threads sin preocuparte por agotar recursos, mientras que el código sigue siendo escrito en el estilo bloqueante acostumbrado — sin necesidad de CompletableFuture, sin necesidad de reactive.
3.1. Crear un virtual thread simple
Thread vThread = Thread.ofVirtual()
.name("order-processor")
.start(() -> {
System.out.println("Ejecutándose en: " + Thread.currentThread());
});
vThread.join();
Imprimir el thread mostrará algo como:
Ejecutándose en: VirtualThread[#21,order-processor]/runnable@ForkJoinPool-1-worker-1
Nota la parte @ForkJoinPool-... — ese es precisamente el carrier thread que está “llevando” este virtual thread.
4. Executor para Virtual Threads
La forma más común de usar virtual threads en aplicaciones reales es a través de ExecutorService.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> futures = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
int id = i;
futures.add(executor.submit(() -> {
Thread.sleep(1000); // simular una llamada de I/O (BD, HTTP…)
return "Task " + id + " completada";
}));
}
for (Future<String> f : futures) {
f.get();
}
}
newVirtualThreadPerTaskExecutor() crea un virtual thread nuevo para cada tarea, sin reutilizar threads como lo hace un thread pool tradicional. Como el costo de crear un virtual thread es muy bajo (unos cientos de bytes, no 1MB), esto es completamente razonable. El bloque try-with-resources asegura que el executor se cierre, esperando a que se completen todas las tareas al salir del bloque.
En comparación con el ejemplo en la sección 2 (pool de 200 platform threads), este código completa las 10.000 tareas en aproximadamente 1 segundo simultáneamente (porque ya no hay límite de pool), en lugar de tener que esperar en cola múltiples veces.
5. Comparar rendimiento con una prueba real
class VirtualThreadBenchmarkTest {
@Test
void virtualThreadsShouldHandleManyBlockingTasksFast() throws InterruptedException {
int taskCount = 10_000;
CountDownLatch latch = new CountDownLatch(taskCount);
Instant start = Instant.now();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < taskCount; i++) {
executor.submit(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
}
latch.await();
Duration elapsed = Duration.between(start, Instant.now());
assertThat(elapsed).isLessThan(Duration.ofSeconds(5));
}
}
Esta prueba crea 10.000 tareas, cada una durmiéndose 100ms. Si se ejecutaran secuencialmente tomaría 1000 segundos, pero con virtual threads, se completan todas en unos segundos porque las tareas realmente se ejecutan en paralelo. CountDownLatch se usa para esperar a que todas las tareas terminen antes de hacer la aserción.
Nota: Los benchmarks reales deberían medir también CPU, memoria usando JFR (Java Flight Recorder) en lugar de solo contar el tiempo, porque el tiempo también depende de la máquina donde se ejecuta la prueba.
6. Pitfall: Pinning cuando se usa synchronized
Un virtual thread no se desmontará del carrier thread si está dentro de un bloque synchronized — este fenómeno se llama pinning. En ese caso, el I/O bloqueante dentro de synchronized ocupará el carrier thread, perdiendo los beneficios del virtual thread.
public class InventoryService {
private final Object lock = new Object();
public void reserveStock(String sku) {
synchronized (lock) {
callExternalInventoryApi(sku); // ¡I/O bloqueante dentro de synchronized -> pinning!
}
}
}
La línea synchronized (lock) mantiene el carrier thread durante todo el tiempo que se llama a callExternalInventoryApi, aunque sea una operación de I/O. Si hay miles de virtual threads pinned simultáneamente, el pool de carrier threads (por defecto igual al número de núcleos de CPU) se agotará rápidamente.
Solución: reemplaza synchronized con java.util.concurrent.locks.ReentrantLock, que es compatible con virtual threads (sin causar pinning).
public class InventoryService {
private final ReentrantLock lock = new ReentrantLock();
public void reserveStock(String sku) {
lock.lock();
try {
callExternalInventoryApi(sku);
} finally {
lock.unlock();
}
}
}
ReentrantLock permite que los virtual threads se desmunten normalmente cuando encuentran I/O bloqueante, preservando el espíritu “ligero” de los virtual threads.
7. Integración de Virtual Threads en Spring Boot
A partir de Spring Boot 3.2, solo necesitas habilitar una propiedad para que Spring Web (Tomcat) use virtual threads para cada solicitud.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
spring.threads.virtual.enabled=true
Esta propiedad habilita virtualThreadExecutor para Tomcat (y otros executors soportados en Spring Boot). Cada solicitud HTTP se procesará en un virtual thread separado, en lugar de compartir un pool de platform threads fijo (200 threads por defecto en Tomcat).
@RestController
@RequestMapping("/orders")
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
return orderService.fetchOrderWithDetails(id); // llamar a BD + otra API, bloqueante como de costumbre
}
}
El código del controlador no necesita cambios — sigues escribiendo en estilo bloqueante como siempre. Cuando pruebas carga con herramientas como ab o k6, verás que el throughput aumenta notablemente en endpoints con alta latencia de I/O (llamadas a BD, llamadas a otros servicios), porque Tomcat ya no está limitado por los 200 threads por defecto.
8. Cuándo NO usar Virtual Threads
- CPU-bound task: Si el trabajo es principalmente computación (sin I/O esperado), los virtual threads no ofrecen beneficio — la cantidad de carrier threads sigue limitada por el número de núcleos de CPU.
ForkJoinPooloparallelStream()son más adecuados. - Código con
ThreadLocalpesado: Los virtual threads son compatibles conThreadLocal, pero si creas millones de virtual threads, cada uno manteniendoThreadLocalgrande consumirá memoria significativa — considera usarScopedValue(preview desde Java 21) en su lugar. - Bibliotecas antiguas con mucho
synchronized: Como se mencionó en la sección 6, el pinning puede silenciosamente degradar el rendimiento sin que lo notes inmediatamente. - Connection pool limitado: Los virtual threads ayudan a crear muchas solicitudes en paralelo, pero si el pool de conexiones de BD solo tiene 20 conexiones, seguirás teniendo un cuello de botella — necesitas aumentar el tamaño del pool correspondientemente o usar un connection pool que soporte bien concurrencia alta.
Nota: Los virtual threads resuelven el problema de la cantidad de threads, no el problema de la velocidad de procesamiento de cada tarea. No esperes que haga que el código CPU-bound corra más rápido.
9. Conclusión
Los virtual threads te permiten escribir código bloqueante simple pero que escala bien para miles de solicitudes simultáneas, siendo especialmente adecuados para aplicaciones I/O-bound como servicios web y microservicios que llaman a múltiples APIs/BDs. Solo necesitas cambiar el executor o habilitar una propiedad en Spring Boot, sin necesidad de reescribir la lógica en estilo reactivo. Sin embargo, ten cuidado con el pitfall del pinning con synchronized y recuerda que los virtual threads no ayudan con tareas CPU-bound.