Uno de los grandes retos de la computación Java (y de cualquier lenguaje de programación) ha sido siempre la mejora de la utilización de los recursos de la máquina host donde trabaja la máquina virtual.

Tradicionalmente, un hilo Java necesita ser transportado por un hilo de plataforma (OS Thread) para poder ser ejecutado en una CPU. La creación de hilos de plataforma es un proceso muy costoso y se empezó a utilizar ThreadPools para mantener y reutilizar hilos de plataforma reservados en el arranque.

Las APIs y frameworks, de naturaleza asíncrona y reactiva y basados en la filosofía fork-and-join lograban optimizar los procesos basándose en el mantra “divide y vencerás”. Bajo este paradigma y con la ayuda de las APIs, podemos trocear nuestra operación en varias subtareas (encapsuladas en funciones lambda) y así poder administrar el pool de hilos y distribuir los hilos disponibles entre todas esas subtareas de una manera eficiente.

Fue un gran avance en lo que a rendimiento y aprovechamiento de recursos se refiere, pero su complejidad para los equipos de desarrollo, teniendo que dividir la lógica de negocio en un conjunto de subprocesos orquestados (APIs Future, Completable Futures, bloques de sincronización, debugging…), es algo que todavía nos duele implementar y que muy posiblemente lo usemos de manera incorrecta.

VirtualThreads viene a lograr el mismo (o mayor) rendimiento que se consiguió con la programación reactiva en lo que a eficiencia de recursos se refiere. Es gestionado también por un forkJoinPool y, por lo tanto, también funciona bajo Work-Stolen. Pero, al contrario del enfoque reactivo/asíncrono, el equipo de desarrollo no se tendrá que preocupar por orquestar sus procesos y podrá escribir el código de manera secuencial (y más sencilla). Nuestro código correrá en los famosos hilos virtuales, pero la JVM es la que detectará y administrará el pool en base a los bloqueos y comportamientos en runtime que tenga nuestra aplicación.

El sumario de la JEP444 > Summary introduce Virtual Threads a Java Platform:

Virtual Threads are lightweight threads that dramatically reduce the effort of writing, maintaining, and observing high-throughput concurrent applications.
Documentación oficial de OpenJDK

Pero, antes de nada, veamos un poco de historia para entender la evolución.

Microprocesadores y sistemas operativos

Para conocer los recursos de una máquina (Linux) tenemos un descriptor en el archivo /proc/cpuinfo. Este archivo tiene varios bloques separados por una línea en blanco.
Cada bloque nos da información de cada core que tiene. Recuerda que los microprocesadores pueden tener uno o varios cores.

$ cat /proc/cpuinfo

processor   : 0
vendor_id   : GenuineIntel
cpu family  : 6
model       : 154
model name  : 12th Gen Intel(R) Core(TM) i7-1265U
....
....

processor   : 1
vendor_id   : GenuineIntel
cpu family  : 6
model       : 154
model name  : 12th Gen Intel(R) Core(TM) i7-1265U
....
....

processor   : 2
vendor_id   : GenuineIntel
cpu family  : 6
model       : 154
model name  : 12th Gen Intel(R) Core(TM) i7-1265U
....
....

Si hacemos wordcount, podemos ver que mi máquina tiene 12 cores (por eso hay 12 bloques en el archivo /proc/cpuinfo):

 $ cat /proc/cpuinfo | grep processor | wc -l
 12

Lo mismo encontramos en Configuración > “Acerca de” en mi máquina:

Pantallazo de la información que muestra "acerca de" en la configuración: memoria, procesador y gráficos

Core

Un core es una unidad de computación, realiza instrucciones y cálculos. Los cores son los encargados de ejecutar las instrucciones y cálculos que le indican los hilos de plataforma. Realiza la operación y retorna, el hilo es liberado y el procesador pasa al siguiente hilo en la fila de prioridad.

💡 “Simultaneidad y límites”: el número máximo de operaciones simultáneas que puede realizar un microprocesador es proporcional al número de cores que tiene.

Hilos de plataforma (Platform Threads)

Un hilo es una agrupación de instrucciones. Los hilos son priorizados y ejecutados en los cores del microprocesador.

El número máximo de hilos que puede simultanear y manejar un host (en entornos Unix) viene en el descriptor /proc/sys/kernel/threads-max.

 $ cat /proc/sys/kernel/threads-max
 124247

Estos hilos son compartidos entre todos los procesos de la máquina host, es decir, entre los que necesita el sistema operativo, los programas que corremos en paralelo, etc.

El número de hilos que está usando un proceso en la máquina lo podemos encontrar ejecutando este comando:

ps -eLf| wc -l
2494 total de hilos de todos los procesos.

Cada hilo en ejecución en un OS Linux crea una carpeta en /proc//task, así que puedes también consultar el número de directorios.

Para saber el número de hilos de sistema que está usando un proceso ejecutamos este comando:

$ ps -o thcount 416533
THCNT
   34

Y para monitorizarlo usamos watch y se refrescará en cada momento.

$ watch ps -o thcount 416533
THCNT
   34

JDK y el host

Java nos proporciona información acerca del entorno donde se está ejecutando la máquina virtual.

El java.lang.Runtime y los MXBean nos pueden proporcionar esta información. Este ejemplo nos muestra los 12 cores que vimos en el cat de /proc/cpuinfo:

The Java and the host
The Java and the host

La JDK en tiempo de ejecución, en base a la información recibida del host donde se está ejecutando, se adaptará para calcular sus procesamientos internos (tamaño de thread pools, ciclos de garbage collector…).

💡 “ActiveProcessorCount”. Si quisiéramos indicar a la JVM que se dimensione para hacer menor uso de los cores (por ejemplo, en un sistema embebido no queremos que se pase de recursos para no saturar el host) podemos indicar el parámetro -XX:ActiveProcessorCount=2.

Java Thread

Un proceso Java es una ejecución de un programa Java en un sistema operativo, y un Java Thread es una cadena de instrucciones.

La JVM se encarga de ir pasando al OS los hilos (ejecuciones) que quiere realizar en su OS Thread (processor) con su prioridad y sus reglas.

Todos los hilos de un mismo proceso comparten los recursos y la memoria usada. En este caso, una ejecución de un programa Java.

Grandes problemas a resolver

Como hemos visto, un hilo Java es un wrapper de un hilo de OS. Cuando un hilo Java es creado, la JVM le pide al OS que cree un hilo nativo para poder correr el hilo Java.

Para un sistema operativo, un thread es una ejecución independiente que pertenece a un proceso. Crear un hilo nativo es un proceso costoso en tiempo y en uso de memoria:

Mitigaciones (un poco de historia)

Vamos a recapitular cómo ha sido la evolución de la gestión de Threads a lo largo de la historia de las JVM.

Green Threads

Las implementaciones iniciales de JVM (1.0 hasta 1.3) basaban su ejecución en los llamados Green Threads. Estos threads y su ejecución eran manejados íntegramente por la JVM.

El hilo principal del proceso (Platform Thread) era único y era el que se usaba para la gestión de todos los hilos Java.

Esta gestión era ineficiente en términos de concurrencia. No era capaz de correr tareas simultáneas y bloqueaba la ejecución de un hilo, condicionando la entrada en ejecución del resto de hilos que hubiera.

A partir de la versión 1.2 se empezó a tener el enfoque de basar los Thread como wrappers de hilos de plataforma. De esta forma, la JVM se empezaría a beneficiar de los procesadores multicore y posibilitar la gestión de concurrencia de hilos en las aplicaciones.

💡 “Problemas”. Este enfoque exprimía la potencia del host pero aún tenía el problema de que el proceso de creación de hilos de plataforma era muy costoso.

Executor Service (java 1.5) y los Future

Como acabamos de comentar, la creación de hilos de plataforma seguía siendo un proceso costoso. Así que se empezó a trabajar con el uso de Thread Pools, aunque ya existieran librerías que lo implementaban.

Estos Thread Pools eran reservas de hilos de plataforma que eran administrados y mantenidos para poder dar cabida a la ejecución de los Java Threads.

La introducción de la programación con Futures sobre un pool de ExecuteService fue un cambio grande en el enfoque de programación en modo tradicional.

💡 “Problemas”. Este fue un gran enfoque, pero seguía manteniendo un problema básico en la ejecución de hilos: un hilo que estuviera en estado “bloqueado” (por ejemplo, esperando una operación de I/O) permanecía bloqueando el hilo de plataforma para el resto de ejecuciones y, hasta que no hubiera terminado, no podría ser usado por ninguna otra ejecución.

Imagina una request entrante que va hasta la base de datos y tarda en responder. Nuestro hilo estará usando el hilo de plataforma y no hará absolutamente nada hasta que obtengamos la respuesta de la base de datos:

Ejemplo donde se ve cómo debe pasar por varios threads hasta llegar a la base de datos

Desde el paso (1) hasta el paso (2), el Thread 1 ha estado sin hacer nada y sin poder usarse. Lo mismo pasa desde el paso (3) hasta el (4). Este es un claro ejemplo de un uso no óptimo de los recursos. Esos hilos, durante ese tiempo de bloqueo, podrían haber estado haciendo otros ejercicios.

ForkJoinPool (Java 1.7)

El gestor ForkJoinPool introdujo el concepto de work-stealing.

ForkJoinPool está pensado para optimizar el paralelismo en un enfoque de tareas Fork-and-Join. Es decir, dividir una tarea en tareas más pequeñas que puedan correr de manera concurrente.

Si has programado con las APIs Future, CompletableFuture o WebFlux, verás que al final estás escribiendo muchas subtareas en forma de lambdas. Estas lambdas pueden ser ejecutadas en distintos hilos que no son el principal que inició la tarea.

En un mismo hilo podemos dividir las tareas (fork) para ganar capacidad de computación y posteriormente combinar los resultados para obtener el resultado total.

ForkJoinPool sigue siendo un gestor ThreadPool de hilos plataforma, pero su gestión de los hilos le permite compartir los hilos del pool entre las distintas subtareas. De esta manera minimiza el costo que tendría una ejecución en un proceso bloqueante y optimiza y premia el uso de task concurrentes.

CompletableFuture (Java 1.8) cumple un papel crucial en la gestión de ForkJoinPool y dio lugar a una gran variedad de frameworks basados en programación asíncrona y reactiva que consiguen una gestión muy eficiente de los recursos del sistema.

Si usamos su api asíncrona vemos cómo, en una cadena de ejecución Step1->thenAsync->Step2, el hilo que inició el step1 no tiene por qué ser el hilo que ejecuta el step2:

        for(int i=1; i <= 2; i++) {
            CompletableFuture.supplyAsync(() -> {
                System.out.println("Step 3 executor"+ Thread.currentThread().getName());
                return "A";
            }).thenAcceptAsync(step1Result -> {
                System.out.println("Step 4 executor"+ Thread.currentThread().getName());
                System.out.println("bienvenido "+step1Result);
            });
        }
output:
Step 3 executorForkJoinPool.commonPool-worker-2
Step 3 executorForkJoinPool.commonPool-worker-1
Step 4 executorForkJoinPool.commonPool-worker-2
Step 4 executorForkJoinPool.commonPool-worker-3
bienvenido A
bienvenido A

💡 “Problemas”. Sobre todo, el principal problema es la complejidad.

Estos procesos requieren sincronización interna y los equipos de desarrollo necesitan adaptar el código en una cadena de subtareas bien balanceadas en carga (CompletableFutures) para hacer un recurso óptimo del ForkJoinPool. La gestión interna condiciona en cómo escribimos nuestro código, teniendo que poner foco en el cómo además de en el qué.

Virtual Threads: Project Loom (Java 21)

El Project Loom (Virtual Threads) nace con la intención de reemplazar al sistema operativo como gestor del ciclo de vida de los threads, ocupándose ahora la JVM de la gestión de memoria del stack del hilo y la orquestación.

Al desacoplar estas tareas del OS, “no estará limitado” por sus limitaciones.

Un Virtual Thread es, en crudo, un Runnable Java Object. Sigue necesitando un hilo de plataforma (sin hilos de plataforma no hay vida), pero esos hilos de plataforma son compartidos entre los virtual threads.

El stack del hilo virtual es gestionado por la JVM en el heap, de manera que los problemas de Context Switching ahora son manejados de manera más eficiente por la JVM.

💡 “Problemas”. Al gestionar todo el heap, puedes intuir que nuestros hilos virtuales pueden saturar el heap de nuestro proceso, debes cuidar tu programación para que el GC pueda hacer su trabajo correctamente.

El cambio se traduce en que, al manejar la JVM el ciclo de vida de los hilos, con pocos hilos de plataforma es posible dar cabida un gran número de hilos Java. Su manejo, además, es más eficiente porque un Virtual Thread es manejado como un objeto Java en sí mismo.

El tratamiento de hilos también está basado en workstolen. Es decir, cuando un Virtual Thread se encuentra en un estado bloqueado (I/O blocking), la JVM usa el mismo hilo de plataforma para ejecutar otros hilos virtuales. Pero como la gestión de memoria la lleva la JVM, este proceso deja de ser tan costoso y limitado.

Para el manejo de esos Platform Thread se sigue usando internamente el motor ForkJoinPool, pero los Virtual Threads son manejados internamente.

Estructura interna de un virtual thread

Vemos cómo con un único hilo del ForkJoinPool podemos manejar 2 hilos virtuales, ya que concurrentemente no están procesando a la vez. En el momento en el que existe concurrencia debido a un proceso largo de CPU del Thread 1, se pide otro hilo al forkJoinPool para continuar la ejecución del Virtual Thread 2.

Cuándo usar Virtual Threads

Como puedes deducir, a los Virtual Threads se les saca el máximo partido cuando nuestra aplicación tiene una gran concurrencia de operaciones I/O, como pueden ser accesos a BBDD, Web request, File access, etc. Es decir, las aplicaciones J2EE son las perfectas candidatas al uso de VirtualThreads.

💡 “Cuándo no usar Virtual Threads”. En operaciones de computación continua no existen bloqueos y el uso de CPU es continuo. Por lo tanto, no ocurren bloqueos I/O y el uso de Virtual Threads en este tipo de operaciones es “absurdo” . Un claro ejemplo es un procesado de vídeo, audio… donde hay un proceso que está haciendo uso extenso de un core y no tiene bloqueos en su ejecución.

Los bloques synchronized no deben usarse en Virtual Threads, ya que bloquean el acceso a la memoria y, por lo tanto, la JVM no podrá liberar el Virtual Thread.

Codificación

Al encargarse la JVM de la orquestación de hilos y detectar los procesos bloqueados, el equipo de desarrollo ya no necesita escribir su código de manera sincronizada, ni bloques bloqueantes, ni callbacks (reactive apis) y puede hacerlo en la manera secuencial tradicional (thread-sequential).

El debugging de estos hilos vuelve a tener el contexto “habitual”, lo cual nos facilita esta tarea que se había convertido en algo muy tedioso.

ThreadLocal: el uso de transporte de contexto a través de objetos ThreadLocal se desaconseja en los Virtual Threads, ya que podemos tener una gran cantidad de Virtual Threads y, por lo tanto, nuestras variables ThreadLocal pueden acabar saturando el Java Heap.

Spring Boot Embedded Tomcat Benchmark

Contexto: una aplicación Spring Boot que sirve un API Rest. Cada ejecución va a tener un sleep de 4 segundos, de manera que el hilo se quedará bloqueado esos 4 segundos y no será liberado ni podrá ser reutilizado. Vamos a probar varias implementaciones:

Tomcat Embedded

Por defecto tiene un pool de 200 hilos.

server.tomcat.threads.max: Maximum amount of worker threads. Doesn’t have an effect if Virtual Threads are enabled. Default value 200.

El ThreadPool reservado para Tomcat Embedded en una aplicación Spring Boot es de 200 Threads, lo que indica 200 peticiones concurrentes. En el modelo habitual (Thread Per Request) podremos llegar a 200 peticiones a la vez. Si lanzamos más, se quedarán a la espera de que se les pueda asignar un hilo de ejecución.

@RestController
@Slf4j
public class BlockingEndpoint{


    @GetMapping("/op")
    public ResponseEntity<String> doExtensiveOperation() {
        try {
            System.out.println(Thread.currentThread());
            Thread.currentThread().sleep(4000);


        } catch (InterruptedException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }


        return new ResponseEntity<String>("OK", HttpStatus.OK);
     }

Lanzamos Jmeter con 400 peticiones por segundo durante un minuto y obtenemos estos resultados tanto de peticiones procesadas como de estado de la JVM:

Virtual Threads
Tomcat Embedded

Durante este tiempo, ha procesado correctamente 3200 peticiones y vemos que en algunas peticiones ha tenido una latencia grande, llegando a los 63 segundos debido al encolamiento de peticiones en el pool. Dio un pico de 250 hilos simultáneos y el incremento del heap no ha sido muy grande.

Virtual Threads

Para el uso de Virtual Threads bajo Tomcat Embeded basta con setear la property:

spring:
  threads:
    virtual:
      enabled: true

Y el código Java será el mismo de la prueba anterior. Lanzamos las mismas 400 peticiones cada segundo durante un minuto.

Virtual threads pantallazo del ejemplo del lanzamiento de peticiones
Pantallazo donde se ve el monitoreo del lanzamiento de las peticiones

Los resultados indican que ha procesado 6000 peticiones, el doble que la prueba anterior. Que la petición más tardía ha llegado a 5 segundos, es decir una latencia de 1 segundo (ya que el código bloqueaba los 4 primeros). Y vemos también cómo el heap de la JVM se ha disparado hasta los 91 MB. Incluso la reserva de heap se fue a 171 Mb, ya que un Virtual Thread es un Java Object y por lo tanto vive en el heap.

WebFlux

Vamos a preparar un endpoint que hará una espera de 4 segundos:

    @GetMapping("/op")
    public Mono<String> doExtensiveOperation() {

        return Mono.delay(Duration.ofMillis(4000)).map(duration->"OK");

    }

Vemos que estamos bien adaptados al API haciendo uso de Mono.delay. Es importante este dato y lo veremos más adelante. Lanzamos las mismas 400 peticiones cada segundo durante un minuto:

Lanzamiento de peticiones
Pantallazo donde se ve el monitoreo del lanzamiento de las peticiones

Los resultados indican que ha logrado el mismo throughtput que Virtual Threads: 6000 peticiones. La latencia en el peor de los casos ha sido solo de 2 segundos más de lo que esperábamos, pero vemos que ha tenido un pico de hilos de 223. La memoria usada también se ha ido a niveles de Virtual Threads, con un heap de 114 Mb.

Como podemos ver, la capacidad de procesado en APIs Reactivas es muy bueno y parecido al Virtual Threads pero, por el contrario, vemos que ha tenido que hacer uso de 223 hilos del host mientras que Virtual Threads ha aprovechado mejor los recursos usando solo 49.

Pero, tengo que contarte algo: he hecho trampa.

Como comenté antes, en la prueba con WebFlux he usado el API Mono.delay específico de la librería para este propósito, pero para jugar en igualdad de condiciones, lo lógico es que mi código fuera así, escribiendo el malicioso Thread.sleep:

@GetMapping("/op")
    public Mono<String> doExtensiveOperation() {
        return Mono.fromSupplier(()->{
            try {
                System.out.println(Thread.currentThread());
                Thread.currentThread().sleep(4000);
            } catch (InterruptedException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
            return "OK";
        });
    }

El Thread.sleep bloquea el hilo completamente y ese hilo no es liberado, por lo que, si ejecutamos la prueba con WebFlux y este código, obtenemos este resultado:

Pantallazo donde volvemos a mostrar el ejemplo de lanzamiento de peticiones con webflux
Pantallazo donde se ve el monitoreo del lanzamiento de las peticiones

Ahora, encontramos que los resultados no son tan buenos y es cuando comprendemos que:

  1. Para escribir nuestro código en modo reactivo y asíncrono tenemos que saber bien qué estamos escribiendo, no solo la lógica de negocio, si no también el cómo.
  2. Que Virtual Threads nos ha dejado escribir nuestro código chapuza y, aún así, la JVM ha sido capaz de reasignar hilos cuando ha detectado que hay bloqueos en los Virtual Threads que llevaban esa ejecución, mejorando así la concurrencia y el rendimiento de la aplicación.

Librerías y frameworks

A día de hoy, muchos frameworks y librerías se han desarrollado bajo un enfoque asíncrono, utilizando bloques synchronized para mejorar su rendimiento.

Con la liberación de los Virtual Threads, hay muchas librerías y frameworks que deberán adaptar su código para beneficiarse del uso de los Virtual Threads.

Spring Boot

Empezó a trabajar en la migración/adaptación a Virtual Threads.

Spring Boot 3.2 ya permite migraciones disponibles en su ecosistema, enfocadas a aplicaciones de naturaleza web (Tomcat, Jetty). Basta con setear en application.properties:

spring.threads.virtual.enabled=true

Esto internamente hará usó de la implementación Executors.newVirtualThreadPerTaskExecutor() en los motores que requieran de un ExecutorService.

Async Executions

Si nuestro código esta escrito en modo asíncrono, existen varios workarounds para poder seguir manejándolo en modo “Virtual Thread”.

Spring Security

El contexto de seguridad de Spring hace uso de variables en ThreadLocal, lo cual no está aconsejado en Virtual Threads. Funciona pero puede saturar el heap. Para solventar el uso de ThreadLocal se está trabajando en ScopeValue JEP-481 ScopedValue que estará disponible en Java 23.

Reactor [WebFlux]

Loom viene a dar programación secuencial pero tratamiento asíncrono y orquestado por la JVM, y Reactor ya hacía esto de manera explícita en código. Por lo que, debido a la sencillez, deberíamos pasar a Loom. Hacer uso de Reactor en vez de Virtual Threads sería, en mi opinión, algo “específico”. A día de hoy se intenta adaptar Reactor para el uso de Virtual Threads en sus executors, para poder seguir manteniendo el código escrito en formato asíncrono para Webflux. Pero, a día de hoy, no es soportado oficialmente.

Spring Kafka

Spring-Kafka 3.2.4 tiene limitaciones trabajando con Virtual Threads. Te dejamos por aquí una librería de Spring Boot y otra de Spring Kafka.

Netty

La adaptación de Netty todavía está en proceso.

Conclusiones

Virtual Threads viene a mejorarnos el día a día a la hora de programar y va a dar un rendimiento espectacular a las aplicaciones.

A día de hoy queda mucho camino por recorrer y mucho trabajo de adaptación de librerías que necesitan subirse al carro de los Virtual Threads, pero vamos paso a paso.

Referencias

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.

Suscríbete