Blog

Hilos virtuales, scoped values y concurrencia estructurada en Java

Java puede ejecutar cien mil tareas bloqueantes sobre unos pocos hilos del SO. Mira cómo se montan y desmontan los hilos virtuales, qué los sigue anclando, por qué ScopedValue reemplaza a ThreadLocal y cómo StructuredTaskScope cancela el trabajo que falla.

Un hilo virtual es un java.lang.Thread que cuesta más o menos lo mismo que un objeto común. Puedes arrancar cien mil, dejar que cada uno se bloquee en una llamada lenta, y la JVM los ejecuta todos sobre unos pocos hilos del sistema operativo. Java 21 los hizo finales, y cambian la forma de escribir servidores: un hilo simple y bloqueante por petición vuelve a estar bien.

Este post cubre los hilos virtuales y cómo se montan sobre hilos portadores, cuándo ayudan y qué los sigue anclando. Después cubre ScopedValue, que reemplaza la mayoría de los usos de ThreadLocal, y StructuredTaskScope, que todavía es una funcionalidad en preview en Java 25. Cada programa de abajo se ejecutó en Java 25, y su salida está copiada de esa ejecución. Para ejecutar uno tú mismo, guárdalo como Main.java y ejecuta java Main.java.

Los hilos de plataforma son caros

Un Thread clásico de Java es un hilo de plataforma: un envoltorio delgado de Java alrededor de un hilo del sistema operativo. El sistema operativo reserva un stack para cada uno (el valor por defecto de la JVM en Linux x64 es 1 MB, lo verificamos con -XX:+PrintFlagsFinal), y crear uno es una llamada al sistema. Por eso un servidor puede permitirse miles, no millones.

Un hilo virtual también es un Thread, con los mismos métodos. Los builders de la parte sobre hilos tienen un gemelo virtual:

void main() throws InterruptedException {
    Thread platform = Thread.ofPlatform().start(() -> IO.println("hello from a platform thread"));
    platform.join();

    Thread first = Thread.ofVirtual().start(() -> IO.println("hello from a virtual thread"));
    first.join();

    Thread second = Thread.startVirtualThread(() -> IO.println("and from another one"));
    second.join();

    IO.println("platform.isVirtual() = " + platform.isVirtual());
    IO.println("first.isVirtual()    = " + first.isVirtual());
    IO.println("second.isVirtual()   = " + second.isVirtual());
    IO.println("main is virtual: " + Thread.currentThread().isVirtual());
}

Imprime:

hello from a platform thread
hello from a virtual thread
and from another one
platform.isVirtual() = false
first.isVirtual()    = true
second.isVirtual()   = true
main is virtual: false

Thread.ofVirtual().start(...) y Thread.startVirtualThread(...) hacen lo mismo. isVirtual() te dice en qué tipo de hilo estás, y main siempre corre en un hilo de plataforma. Todo lo demás es la API de Thread que ya conoces: join, interrupt, getState.

También compilamos el programa con javac --release 20. Falla con ofVirtual() is a preview API and is disabled by default. Con --release 21 compila, así que los hilos virtuales son finales desde Java 21.

Cien mil tareas bloqueantes

La mayoría del código no arranca hilos de uno en uno. Executors.newVirtualThreadPerTaskExecutor() arranca un hilo virtual nuevo por cada tarea que envías. Aquí, 100,000 tareas duermen un segundo cada una, en lugar de una llamada de red lenta:

void main() {
    var completed = new AtomicInteger();

    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (int i = 0; i < 100_000; i++) {
            executor.submit(() -> {
                Thread.sleep(Duration.ofSeconds(1));  // stands in for a slow network call
                completed.incrementAndGet();
                return null;
            });
        }
    }  // close() waits for every task to finish

    IO.println("tasks completed: " + completed.get());
}

Imprime:

tasks completed: 100000

El close() de un executor espera a cada tarea enviada, así que la cuenta se lee solo después de que corrieron las 100,000. La lambda devuelve null para que sea un Callable, que puede lanzar la InterruptedException que declara sleep.

¿Cuánto tardó? Lo medimos. Estos tiempos varían de una ejecución a otra y de una máquina a otra, así que tómalos como una idea aproximada:

$ time java Main.java
tasks completed: 100000

real	0m3.823s

Eso incluye compilar el archivo. Después cambiamos una línea para arrancar un hilo de plataforma por tarea, Executors.newThreadPerTaskExecutor(Thread.ofPlatform().factory()). También imprimió 100000, pero tardó 54 segundos. Crear 100,000 hilos del sistema operativo es lento. La versión virtual pasó la mayor parte del tiempo con las 100,000 tareas dormidas a la vez.

Montar y desmontar

Un hilo virtual solo necesita un hilo del sistema operativo mientras ejecuta código Java, y lo devuelve cada vez que se bloquea. Los hilos del sistema operativo que ejecutan hilos virtuales se llaman hilos portadores (carrier threads). Este es un dibujo simplificado con dos portadores y cuatro hilos virtuales:

portador 1 portador 2 libre libre por ejecutar heap stack guardado, espera IO VT4 VT3 VT2 VT1 cuatro hilos virtuales listos y los dos portadores libres VT1 y VT2 se montan: cada uno corre en un hilo portador VT1 se bloquea en IO: se desmonta y su stack queda en el heap el portador 1 queda libre, así que VT3 se monta en él termina el IO de VT1: vuelve a estar listo y espera un portador VT2 termina; VT1 vuelve a montarse en el portador 2, no en el de antes

Simplificado: dos hilos portadores comparten cuatro hilos virtuales. Un hilo virtual que se bloquea devuelve su portador, y su stack espera en el heap. Cuando puede volver a correr, se monta en el portador que esté libre. El scheduler real suele tener un portador por núcleo de CPU y muchos más hilos virtuales.

Aquí están esos pasos en palabras, por si la animación no se reproduce:

  1. Cuatro hilos virtuales, de VT1 a VT4, están listos para correr. El portador 1 y el portador 2 están libres.
  2. VT1 se monta en el portador 1 y VT2 se monta en el portador 2. Los dos ejecutan código Java. VT3 y VT4 esperan.
  3. VT1 empieza una lectura de red que tiene que esperar. Se desmonta: sus frames de stack se guardan en el heap, y el portador 1 queda libre.
  4. El portador 1 no se queda sin hacer nada. VT3 se monta en él y corre. VT4 sigue esperando.
  5. La lectura de VT1 termina. VT1 vuelve a estar listo para correr, así que se pone en la fila para conseguir un portador.
  6. VT2 termina y el portador 2 queda libre. VT1 se monta en el portador 2 y sigue desde donde se detuvo, en un portador distinto del que usó al principio.

Explicado como si tuvieras diez años

Un pueblo tiene unos pocos camiones de reparto grandes. Esos son los hilos de plataforma. Cada camión es caro, así que el pueblo solo puede pagar unos cuantos.

Los hilos virtuales son miles de mensajeros en bicicleta. Un mensajero solo se sube a un camión mientras un paquete de verdad se está moviendo. Cuando un mensajero tiene que esperar en una puerta a que alguien firme, se baja, y otro mensajero se sube al camión. Cuando abren la puerta, el primer mensajero se sube al próximo camión que pase.

Así, unos cuantos camiones mantienen ocupados a miles de mensajeros, siempre que la mayor parte del trabajo sea esperar en las puertas.

La versión precisa

Un hilo virtual es un objeto Thread cuyo stack no es un bloque fijo de memoria del sistema operativo. La JVM lo ejecuta montándolo sobre un hilo portador, y los portadores son hilos de plataforma de un ForkJoinPool que pertenece al JDK. Por defecto hay un portador por cada procesador disponible.

Cuando un hilo virtual se bloquea dentro del JDK, por ejemplo en Thread.sleep, en una lectura de socket, en CountDownLatch.await o en un lock, el JDK lo desmonta. Copia los frames de stack del hilo a objetos en el heap y libera el portador. Cuando pasa lo que estaba esperando, el hilo vuelve al scheduler y se monta en cualquier portador libre. Tu código no ve nada de esto. La llamada simplemente retorna más tarde. Por eso las 100,000 tareas dormidas salieron baratas: cada una era un objeto pequeño en el heap, no un hilo del sistema operativo estacionado.

Dónde falla la analogía: un mensajero decide bajarse. Un hilo virtual no. El JDK lo desmonta, y solo en puntos de bloqueo que conoce. Si el stack no se puede mover, el hilo se queda en el camión mientras espera. Eso se llama pinning (anclaje), y tiene su propia sección más abajo. Además, un camión lleva muchos paquetes, pero un portador ejecuta exactamente un hilo virtual a la vez.

Cuándo ayudan los hilos virtuales y cuándo no

Los hilos virtuales ayudan al código que pasa la mayor parte del tiempo esperando. El caso clásico es un servidor web que atiende cada petición llamando a una base de datos y a otros dos servicios, una llamada bloqueante tras otra. Puedes escribirlo en el estilo simple de un hilo por petición y aun así atender una cantidad muy grande de peticiones a la vez, porque una petición que espera no ocupa ningún hilo del sistema operativo.

No hacen más rápido el cálculo. Una tarea que pasa el tiempo sumando números nunca se bloquea, así que nunca se desmonta, y el número de portadores sigue siendo el número de núcleos. Diez mil hilos virtuales limitados por CPU no reciben más CPU que un pool de hilos de plataforma del tamaño de tu máquina.

Dos costumbres de los hilos de plataforma están mal para los hilos virtuales:

  • No los metas en un pool. Un pool existe para reutilizar algo caro. Los hilos virtuales son baratos, así que crea uno por tarea y deja que termine.
  • No limites la concurrencia con un pool pequeño. Si un servicio de más abajo solo aguanta 10 llamadas a la vez, limita las llamadas con un Semaphore.
void main() {
    var permits = new Semaphore(10);
    var running = new AtomicInteger();
    var peak = new AtomicInteger();
    var completed = new AtomicInteger();

    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (int i = 0; i < 1_000; i++) {
            executor.submit(() -> {
                permits.acquire();  // waits here while 10 tasks hold a permit
                try {
                    peak.accumulateAndGet(running.incrementAndGet(), Math::max);
                    Thread.sleep(Duration.ofMillis(10));  // the call to the limited service
                    completed.incrementAndGet();
                } finally {
                    running.decrementAndGet();
                    permits.release();
                }
                return null;
            });
        }
    }

    IO.println("tasks completed: " + completed.get());
    IO.println("never more than 10 at once: " + (peak.get() <= 10));
}

Imprime:

tasks completed: 1000
never more than 10 at once: true

Las 1,000 tareas recibieron un hilo virtual de inmediato, pero acquire() dejó pasar solo 10 a la vez. Las otras 990 esperaron sin costo, desmontadas. El release() está en finally, así que una llamada que falla no puede perder un permiso. La parte sobre java.util.concurrent cubre Semaphore y los executors.

Pinning: cuando un hilo virtual no puede soltar

Un hilo virtual está anclado (pinned) cuando se bloquea pero no puede desmontarse, así que retiene su portador todo el tiempo que espera. Con suficientes hilos anclados, todos los portadores quedan atascados y nada más corre.

En Java 21, bloquearse dentro de synchronized anclaba el hilo. Java 24 cambió eso (JEP 491), así que en Java 25 un hilo virtual que espera mientras tiene un monitor se desmonta como cualquier otro. Algunos casos todavía anclan. El programa de abajo prueba dos de ellos. Pide un solo portador con la propiedad del sistema jdk.virtualThreadScheduler.parallelism, que equivale a pasar -D en la línea de comandos. Con un solo portador, un hilo anclado bloquea a todos los demás hilos virtuales:

static final CountDownLatch configReleased = new CountDownLatch(1);

static class Config {
    static final String NAME = load();

    static String load() {
        awaitQuietly(configReleased);  // blocks inside a static initializer
        return "loaded";
    }
}

final Object lock = new Object();

void main() throws InterruptedException {
    // One carrier thread for every virtual thread. Set it before the first one starts.
    System.setProperty("jdk.virtualThreadScheduler.parallelism", "1");

    var lockReleased = new CountDownLatch(1);
    Thread inLock = Thread.ofVirtual().start(() -> {
        synchronized (lock) {
            awaitQuietly(lockReleased);  // blocks while holding a monitor
        }
    });
    IO.println("blocked in synchronized, others run: " + othersRun(lockReleased));
    inLock.join();

    Thread inInit = Thread.ofVirtual().start(() -> Config.NAME.length());
    IO.println("blocked in a static initializer, others run: " + othersRun(configReleased));
    inInit.join();
}

// Starts a second virtual thread that releases the first. Reports whether it got to run.
boolean othersRun(CountDownLatch release) throws InterruptedException {
    Thread.sleep(Duration.ofMillis(200));  // give the first thread time to block
    Thread other = Thread.ofVirtual().start(release::countDown);
    boolean ran = other.join(Duration.ofSeconds(1));
    release.countDown();  // if it couldn't run, release the first thread from here
    other.join();
    return ran;
}

static void awaitQuietly(CountDownLatch latch) {
    try {
        latch.await();
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    }
}

Imprime:

blocked in synchronized, others run: true
blocked in a static initializer, others run: false

En la primera prueba, un hilo virtual espera mientras tiene lock. Se desmonta, el único portador ejecuta el segundo hilo, y ese hilo libera al primero. En la segunda prueba, el hilo espera dentro del inicializador estático de Config. Se queda anclado, el segundo hilo nunca consigue el portador, y join se rinde después de un segundo. Lo ejecutamos 20 veces y obtuvimos las mismas dos líneas cada vez.

Para ver el pinning en un programa real, regístralo con Java Flight Recorder. El JDK tiene un evento jdk.VirtualThreadPinned para esto. Ejecutamos el mismo programa con una grabación e imprimimos los eventos (recortados; la duración varía):

$ java -XX:StartFlightRecording:filename=pinned.jfr Main.java
$ jfr print --events jdk.VirtualThreadPinned pinned.jfr
jdk.VirtualThreadPinned {
  duration = 1.20 s
  blockingOperation = "LockSupport.park"
  pinnedReason = "VM call to Main$Config.<clinit> on stack"
  ...
}

Hubo exactamente un evento, el del inicializador estático. La espera en synchronized no registró ninguno. También probamos una llamada nativa: una función de C, qsort, llamada con la API de funciones foráneas, con un comparador que vuelve a llamar a Java y duerme. Eso también ancló, con la razón "Native or VM frame on stack".

Una sorpresa: los artículos viejos te dicen que ejecutes con -Djdk.tracePinnedThreads=full. En Java 25 no hace nada. Se lo pasamos al mismo programa y no se imprimió nada extra, ni siquiera para el hilo anclado. Usa el evento de JFR.

ThreadLocal y sus problemas

Un ThreadLocal le da a cada hilo su propia copia de una variable. Los frameworks lo usan desde hace mucho para llevar cosas como el usuario actual a lo largo de una petición sin pasarlo a cada método. Tiene tres problemas, y los hilos virtuales empeoran los tres.

Es mutable, así que cualquier código en el hilo puede llamar a set y cambiar el valor sin que los demás se enteren. Vive tanto como el hilo, a menos que alguien se acuerde de remove(). Y cada hilo guarda su propia copia, así que un millón de hilos virtuales significa un millón de copias. El problema de la duración es el más fácil de mostrar:

static final ThreadLocal<String> USER = new ThreadLocal<>();

void main() throws Exception {
    try (ExecutorService pool = Executors.newFixedThreadPool(1)) {
        pool.submit(() -> {
            USER.set("ana");
            IO.println("request 1 runs as " + USER.get());
            // forgot USER.remove()
        }).get();

        pool.submit(() -> IO.println("request 2 runs as " + USER.get())).get();
    }
}

Imprime:

request 1 runs as ana
request 2 runs as ana

Las dos peticiones corrieron en el único hilo del pool. La primera puso el usuario y nunca lo quitó, así que la segunda petición corre como Ana. En un servidor real, eso es un usuario viendo los datos de otro.

ScopedValue: un valor mientras dura una llamada

Un ScopedValue se asocia a un valor mientras dura una llamada, y cada método al que llega esa llamada puede leerlo. Cuando la llamada retorna, la asociación desaparece. Pasó a ser final en Java 25. Lo verificamos: javac --release 24 lo rechaza por ser una API en preview.

static final ScopedValue<String> USER = ScopedValue.newInstance();

void main() {
    ScopedValue.where(USER, "ana").run(() -> handleRequest());
    IO.println("after run, bound: " + USER.isBound());

    ScopedValue.where(USER, "bo").run(() -> {
        audit("outer");
        ScopedValue.where(USER, "admin").run(() -> audit("inner"));
        audit("outer again");
    });
}

void handleRequest() {
    IO.println("handling, bound: " + USER.isBound());
    loadOrders();
}

void loadOrders() {
    audit("loading orders");  // three calls deep, no parameter passed
}

void audit(String action) {
    IO.println(USER.get() + ": " + action);
}

Imprime:

handling, bound: true
ana: loading orders
after run, bound: false
bo: outer
admin: inner
bo: outer again

ScopedValue.where(USER, "ana").run(...) asocia USER mientras corre la lambda. audit lo lee tres llamadas más abajo, y nadie se lo pasó. Después de que run retorna, isBound() es false.

No hay método set. La única forma de cambiar el valor es asociarlo otra vez para una llamada más pequeña, como hace la asociación "admin". Cuando esa llamada interna retorna, vuelve el valor anterior, "bo". Así, el valor asociado no puede filtrarse a una petición posterior, y el código que llamas no puede cambiártelo.

Leer un scoped value que no está asociado lanza una excepción:

static final ScopedValue<String> USER = ScopedValue.newInstance();

void main() {
    IO.println("bound: " + USER.isBound());
    IO.println("with a fallback: " + USER.orElse("guest"));
    IO.println("user: " + USER.get());
}

Imprime y se detiene:

bound: false
with a fallback: guest
Exception in thread "main" java.util.NoSuchElementException: ScopedValue not bound

Compruébalo con isBound(), o usa orElse cuando haya un valor por defecto razonable.

Concurrencia estructurada (preview)

Concurrencia estructurada significa que las tareas que empiezan juntas terminan juntas. Si divides una petición en subtareas, ninguna vive más que la petición, y un fallo en una detiene a las demás. La API de Java para esto, StructuredTaskScope, es una funcionalidad en preview en Java 25. Todavía puede cambiar antes de ser final, y ya cambió. Muchos artículos muestran new StructuredTaskScope.ShutdownOnFailure(). Lo verificamos con javac --release: esa clase existe en Java 21 y 24, y en 25 el mismo código falla con cannot find symbol. La API no compila sin un flag, así que estos programas se ejecutan con java --enable-preview Main.java.

El problema de repartir trabajo sin estructura

Un ExecutorService te deja arrancar dos llamadas en paralelo, pero nada las une. Aquí, una llamada es lenta y la otra falla:

void main() throws InterruptedException {
    var neverOpens = new CountDownLatch(1);
    var executor = Executors.newVirtualThreadPerTaskExecutor();

    Future<String> user = executor.submit(() -> {
        neverOpens.await();  // a slow call that is still going
        return "ana";
    });
    Future<Integer> orders = executor.submit(() -> {
        throw new IllegalStateException("orders service is down");
    });

    try {
        int count = orders.get();  // ask for the failing one first
        IO.println(user.get() + " has " + count + " orders");
    } catch (ExecutionException e) {
        IO.println("request failed: " + e.getCause().getMessage());
    }
    IO.println("user task still running: " + !user.isDone());

    executor.shutdownNow();  // interrupts it; close() here would wait forever
    IO.println("stopped after shutdownNow: " + executor.awaitTermination(1, TimeUnit.SECONDS));
}

Imprime:

request failed: orders service is down
user task still running: true
stopped after shutdownNow: true

La petición falló, pero la tarea del usuario siguió corriendo. Nada le dijo que se detuviera, así que quedó suelta hasta que apagamos el executor a mano. Fíjate también en el comentario de orders.get(). Nuestra primera versión llamaba primero a user.get(), y se colgó: main esperaba la llamada lenta y nunca se enteró de que la otra ya había fallado.

StructuredTaskScope: fork y después join

Un StructuredTaskScope se abre en un bloque try-with-resources, y cada subtarea lanzada con fork dentro de él tiene que terminar antes de que el bloque acabe:

import java.util.concurrent.StructuredTaskScope.Subtask;

record Page(String user, int orders) {}

void main() throws InterruptedException {
    try (var scope = StructuredTaskScope.open()) {
        Subtask<String> user = scope.fork(() -> findUser(42));
        Subtask<Integer> orders = scope.fork(() -> countOrders(42));

        scope.join();  // waits for both

        IO.println(new Page(user.get(), orders.get()));
    }
}

String findUser(int id) throws InterruptedException {
    Thread.sleep(Duration.ofMillis(100));
    return "ana";
}

int countOrders(int id) throws InterruptedException {
    Thread.sleep(Duration.ofMillis(50));
    return 3;
}

Imprime:

Page[user=ana, orders=3]

Estas son las llamadas de Java 25:

  • StructuredTaskScope.open() abre un scope. Cada fork arranca la subtarea en un hilo virtual nuevo.
  • scope.join() espera a las subtareas.
  • Subtask.get() devuelve el resultado de una subtarea. Solo se permite después de join: llamarlo antes lanzó IllegalStateException: join not called.

Las reglas son estrictas. Cerrar un scope que hizo fork pero nunca join lanzó IllegalStateException: Owner did not join after forking cuando lo probamos.

Subtask es un tipo anidado, así que el programa lo importa. Los imports automáticos de un archivo fuente compacto cubren los tipos de nivel superior de java.util.concurrent, pero no los anidados.

Un fallo cancela el resto

Con open() y sin argumentos, el scope espera a que todas las subtareas tengan éxito. Si una falla, cancela las demás. Este programa fija el orden: la subtarea que falla espera hasta que la otra haya empezado, y la otra espera un latch que nunca se abre.

import java.util.concurrent.StructuredTaskScope.FailedException;

void main() throws InterruptedException {
    var userStarted = new CountDownLatch(1);
    var neverOpens = new CountDownLatch(1);
    var userSaw = new AtomicReference<String>("nothing");

    try (var scope = StructuredTaskScope.open()) {
        scope.fork(() -> {
            userStarted.countDown();
            try {
                neverOpens.await();  // a slow call that would never finish
                userSaw.set("finished");
            } catch (InterruptedException e) {
                userSaw.set("interrupted");
            }
        });
        scope.fork(() -> {
            userStarted.await();  // fail only once the other subtask is waiting
            throw new IllegalStateException("orders service is down");
        });

        scope.join();
        IO.println("both succeeded");
    } catch (FailedException e) {
        IO.println("request failed: " + e.getCause().getMessage());
    }
    IO.println("the user subtask saw: " + userSaw.get());
}

Imprime:

request failed: orders service is down
the user subtask saw: interrupted

Cuando la segunda subtarea lanzó la excepción, el scope interrumpió a la primera. join() lanzó una StructuredTaskScope.FailedException, cuya causa es la excepción original. Para cuando terminó el bloque try, el scope ya había esperado a que la subtarea interrumpida terminara, así que userSaw ya tenía valor cuando main lo leyó. Compáralo con la versión del executor, donde la tarea lenta siguió corriendo.

Otros joiners

Un joiner decide qué espera join() y qué devuelve. Se lo pasas a open:

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

void main() throws InterruptedException {
    var slowWasCancelled = new AtomicBoolean();

    try (var scope = StructuredTaskScope.open(Joiner.<String>anySuccessfulResultOrThrow())) {
        scope.fork(() -> {
            throw new IllegalStateException("mirror A is down");
        });
        scope.fork(() -> {
            try {
                new CountDownLatch(1).await();  // mirror C never answers
                return "mirror C";
            } catch (InterruptedException e) {
                slowWasCancelled.set(true);
                throw e;
            }
        });
        scope.fork(() -> "mirror B");

        String first = scope.join();
        IO.println("first good answer: " + first);
    }
    IO.println("slow mirror cancelled: " + slowWasCancelled.get());

    try (var scope = StructuredTaskScope.open(Joiner.<Integer>allSuccessfulOrThrow())) {
        for (int n = 1; n <= 4; n++) {
            int x = n;
            scope.fork(() -> x * x);
        }
        List<Integer> squares = scope.join().map(Subtask::get).toList();
        IO.println("squares: " + squares);
    }
}

Imprime:

first good answer: mirror B
slow mirror cancelled: true
squares: [1, 4, 9, 16]

anySuccessfulResultOrThrow() ignora los fallos mientras otra subtarea todavía pueda tener éxito. En cuanto una retorna, join() devuelve ese resultado y las demás se cancelan. allSuccessfulOrThrow() hace que join() devuelva un Stream de las subtareas en el orden en que las lanzaste, así que los cuadrados salen en orden.

Java 25 también tiene awaitAll(), que espera a todo y nunca lanza, y awaitAllSuccessfulOrThrow(), que te da el mismo comportamiento que open() sin argumentos. Con awaitAll(), una de nuestras subtareas falló, join() retornó con normalidad, y el state() de esa subtarea era FAILED. Un segundo argumento de open configura el scope. Con cf -> cf.withTimeout(Duration.ofMillis(100)), una subtarea lenta hizo que join() lanzara StructuredTaskScope.TimeoutException.

Los scoped values llegan a las subtareas

Una subtarea lanzada en un scope ve los scoped values que estaban asociados cuando se abrió el scope:

static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

void main() {
    ScopedValue.where(REQUEST_ID, "req-7").run(() -> {
        try (var scope = StructuredTaskScope.open()) {
            var user = scope.fork(() -> log("find user"));
            var orders = scope.fork(() -> log("count orders"));
            scope.join();
            IO.println(user.get());
            IO.println(orders.get());

            String[] fromPlain = new String[1];
            Thread.ofVirtual().start(() -> fromPlain[0] = log("plain thread")).join();
            IO.println(fromPlain[0]);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
}

String log(String what) {
    String id = REQUEST_ID.isBound() ? REQUEST_ID.get() : "no request id";
    return "[" + id + "] " + what;
}

Imprime:

[req-7] find user
[req-7] count orders
[no request id] plain thread

Las dos subtareas leyeron req-7 en sus propios hilos. El hilo virtual simple que arrancamos en el mismo lugar no lo vio. Solo un scope pasa los scoped values, porque un scope garantiza que sus subtareas terminan antes que la asociación. No hay que copiar ni limpiar nada.

Depurar hilos virtuales

Un volcado de hilos (thread dump) lista cada hilo y lo que está esperando, pero el clásico jcmd <pid> Thread.print deja fuera a los hilos virtuales. Lo verificamos, y ninguno de nuestros hilos virtuales apareció. Usa Thread.dump_to_file. Ejecutamos un programa que lanza dos subtareas, cada una durmiendo 15 segundos, y lo volcamos mientras esperaba (muy recortado):

$ java --enable-preview Main.java &
$ jcmd <pid> Thread.dump_to_file -format=json threads.json
$ cat threads.json
...
        "container": "java.util.concurrent.StructuredTaskScopeImpl@bef2d72",
        "parent": "<root>",
        "owner": "3",
        "threads": [
          {
            "tid": "26",
            "virtual": true,
            "state": "TIMED_WAITING",
            "stack": [
              ...
              "java.base\/java.lang.Thread.sleep(Thread.java:601)",
              "Main.fetch(Main.java:11)",
              "Main.lambda$main$0(Main.java:3)",
              ...

Los hilos se agrupan por contenedor. Las dos subtareas están dentro del scope, y su owner es el hilo 3, que es main. Así, el volcado muestra la estructura de tu código: qué hilo abrió el scope y qué subtareas le pertenecen. Con un executor simple, los hilos virtuales se agrupan bajo el executor, sin owner.

Qué recordar

  • Un hilo virtual es un Thread barato que se monta sobre un hilo portador solo mientras corre. Crea uno por tarea con Executors.newVirtualThreadPerTaskExecutor(). Son finales desde Java 21.
  • Los hilos virtuales ayudan al código bloqueante, limitado por IO. El trabajo limitado por CPU no gana nada con ellos.
  • No metas hilos virtuales en un pool. Limita el acceso a un recurso escaso con un Semaphore.
  • Desde Java 24, synchronized ya no ancla en la mayoría de los casos. Un hilo bloqueado en un inicializador estático o bajo un frame nativo todavía ancla, y el evento de JFR jdk.VirtualThreadPinned muestra dónde.
  • ScopedValue, final en Java 25, asocia un valor inmutable mientras dura una llamada. A diferencia de un ThreadLocal, el código que llamas no puede cambiarlo, y no puede filtrarse a la siguiente tarea.
  • StructuredTaskScope está en preview en Java 25. Las subtareas lanzadas en un scope terminan antes de que se cierre, un fallo cancela las demás, y los scoped values llegan a ellas.
  • Encuentra los hilos virtuales con jcmd <pid> Thread.dump_to_file -format=json, no con Thread.print.

Escribe el código bloqueante simple, y dale a cada tarea su propio hilo virtual.

¿Qué tan útil te resultó este post?

¡Haz clic en un corazón para calificar!

Calificación promedio 0 / 5. Total de votos: 0

Todavía no hay votos. Sé el primero en calificar este post.