Java moderno: concurrencia con virtual threads y CompletableFuture (Java 21+)
En este artículo vas a ver cómo aprovechar virtual threads para simplificar código concurrente en Java. El ejemplo práctico realiza varias peticiones HTTP en paralelo usando HttpClient y un executor de virtual threads. Requisitos: Java 21+ (o una versión de Java que incluya virtual threads y el helper Executors.newVirtualThreadPerTaskExecutor()).
Por qué virtual threads
Las virtual threads permiten escribir código bloqueante tradicional (por ejemplo, llamadas HttpClient.send()) sin pagar el coste de crear miles de hilos del sistema. Son ligeras y gestionadas por la JVM, lo que hace la programación concurrente mucho más sencilla que con callbacks o frameworks reactivos cuando la mayor parte del trabajo es I/O.
Ejemplo práctico: peticiones HTTP concurrentes
Este ejemplo lanza peticiones a varias URLs en paralelo y recoge los resultados de forma sencilla y clara.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
public class VirtualThreadHttp {
public static void main(String[] args) throws Exception {
List<String> urls = List.of(
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/2",
"https://example.com"
);
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();
// try-with-resources para asegurarnos de cerrar el executor
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<CompletableFuture<HttpResponse<String>>> futures =
urls.stream()
.map(url -> CompletableFuture.supplyAsync(() -> {
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(5))
.GET()
.build();
try {
// send() bloquea; con virtual threads eso está bien
return client.send(req, BodyHandlers.ofString());
} catch (Exception e) {
throw new CompletionException(e);
}
}, executor))
.collect(Collectors.toList());
// espera a que todas acaben
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
// procesar resultados con manejo de errores por petición
for (int i = 0; i < urls.size(); i++) {
try {
HttpResponse<String> resp = futures.get(i).join();
System.out.printf("%s -> %d (len=%d)\n", urls.get(i), resp.statusCode(), resp.body().length());
} catch (CompletionException ce) {
System.err.printf("%s -> ERROR: %s\n", urls.get(i), ce.getCause());
}
}
}
}
}
Qué hace el código y por qué
- Usamos
Executors.newVirtualThreadPerTaskExecutor()para ejecutar cada tarea en una virtual thread. La API proporciona unExecutorServiceque puedes usar conCompletableFutureu otras APIs que acepten unExecutor. - Las llamadas a
client.send(...)son bloqueantes, pero al ejecutarlas en virtual threads no bloqueas hilos del sistema. El código se mantiene secuencial y fácil de razonar. - En caso de excepción envolvemos con
CompletionExceptionpara queCompletableFuturepropague correctamente el error. - Se usan timeouts en el
HttpRequesty en elHttpClientpara evitar hilos eternamente bloqueados por redes lentas.
Buenas prácticas y riesgos
- No confundas virtual threads con una bala de plata: si tu tarea es intensiva en CPU, sigue usando pools limitados de hilos de plataforma (p. ej.
ForkJoinPool.commonPool()o unThreadPoolExecutorconfigurado). Las virtual threads reducen la latencia en I/O masivo, pero no aceleran cómputo puro. - Cuidado con llamadas nativas que puedan bloquear la plataforma (synchronized en bibliotecas nativas, operaciones JNI que no soportan virtual threads): esas pueden “anclar” una virtual thread a un carrier thread y limitar escalabilidad.
- Evita fuga de recursos: usa try-with-resources para cerrar executors y sockets. No crees ejecutores ilimitados sin control en bucles que procesan entradas externas.
- Evita abusar de los ThreadLocals: si hay muchos hilos ligeros, ThreadLocal con grandes estructuras puede consumir memoria inesperada.
- Valida y sanitiza URLs y parámetros externos para evitar SSRF u otras vulnerabilidades si las peticiones usan datos no confiables.
Si necesitas control fino de cancelación y manejo de fallos en un conjunto de subtareas, considera usar Structured Concurrency (Java 21+). Structured concurrency te ayuda a cancelar todas las tareas cuando una falla y a agrupar el manejo de errores en un solo bloque lógico; su API te permite escribir código más robusto para flujos compuestos de tareas concurrentes.
Consejo avanzado: en producción mide siempre la latencia tail (p50/p95/p99) y el uso de GC cuando pases de cientos a miles de solicitudes concurrentes. Ajusta timeouts, limita las operaciones nativas y separa las rutas CPU-bound en pools dedicados. Si vas a exponer esta técnica en servicios públicos, añade validación estricta de entradas y límites (rate limiting, circuit breakers) para minimizar el riesgo de DoS y SSRF.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación