Virtual Threads
1. Introduccion
Uno de los grandes retos de la computacion Java (de cualquier lenguaje de programacion) ha sido siempre la mejora de la utilización de los recursos de la maquina host donde trabaja la maquina virtual.
Tradicionalmente un hilo java necesita ser transportado por un hilo de Plataforma (OS Thread) para poder ser ejecutado en una CPU. La creacion 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 asincrona y reactiva, basados en la filosofia fork-and-join lograban optimizar los procesos basandose en el mantra “divide y vencerás”. Bajo este paradigma y con la ayuda de las APIs podiamos trocear nuestra operacion en varias subtareas (encapusuladas en funciones lambda) y asi 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 desarrolladores teniendo que dividir la logica de negocio en un conjunto de subprocesos orquestados (APIs Future, completable Futures, bloques de sincronizacion, debugging, etc…) es algo que todavia nos duele implementar y que muy posiblemente lo usemos de manera incorrecta.
VirtualThreads viene a lograr el mismo (o mayor) rendimiento que se logro con la programacion reactiva en lo que a eficiencia de recursos se refiere. Es gestionado tambien por un forkJoinPool y por lo tanto tambien intermanente funciona bajo Work-Stolen pero al contrario del enforque reactivo/asincrono, el desarrollador no se tendra que preocupar por orquestar sus procesos y podra escribir el codigo de manera secuencial (y mas sencilla). Nuestro codigo correra en los famosos hilos virtuales, pero la JVM es la que detectará y administrara el pool en base a los bloqueos y comportamientos en runtime que tenga nuestra aplicacion.
El sumario de la JEP444
Summary Introduce virtual threads to the Java Platform. Virtual threads are lightweight threads that dramatically reduce the effort of writing, maintaining, and observing high-throughput concurrent applications
Pero antes de nada veamos un poco de historia para entender la evolución…
2. Microprocesadores y sistemas Operativos
Para conocer loso recursos de una maquina (Linux) tenemos un descriptor en el archivo /proc/cpuinfo.
Este archivo tiene varios bloques separados por una (linea en blanco).
Cada bloque nos da informacion 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
....
....
Lo mismo encontramos en Configuracion > “Acerca de” en mi maquina.
3. CORE
Un core es una unidad de computacion, es decir realiza instrucciones y cálculos.
Los cores son los encargados de ejecutar las intrucciones y calculos que le indican los hilos de Plataforma.
Realiza la operacion y retorna, el hilo es liberado y el procesador pasa al siguiente hilo en la pila de prioridad.
Simultaneidad y limites
Por lo que podemos deducir que el número maximo de operaciones simultaneas que puede realizar un Microprocesador es proporcional al numero de cores que tiene.
4. HILOS DE PLATAFORMA (Platform Threads)
Un hilo es una agrupacion de instrucciones. Los hilos son priorizados y ejecutados en los cores del microprocesador.
EL numero maximo de hilos que puede simultanear y manejar un host (en entornos Unix) viene en el descriptor /proc/sys/kernel/threads-max
Estos hilos son compartidos entre todos los procesos de la maquina host; es decir entre los que necesita el Sistema operativo, los programas que corremos en paralelo, etc…El numero de hilos que esta usando un proceso en la maquina lo podemos encontrar ejecutando este comando:
Cada hilo en ejecucion en un Os Linux crea una carpeta en /proc/Para saber el numero de hilos de sistema que esta usando un proceso:
y para monitorizarlo usamos watch y se refrescara en cada momento.5. JDK y el Host
Java nos proporciona informacion acerca del entorno donde se está ejecutando la maquina virtual.
El java.lang.Runtime y los MXBean nos pueden proporcionar esta informacion:
En este ejemplo el numero de cores del host:
Vemos que nos da los 12 cores que vimos en el cat de /proc/cpuinfo
La JDK en tiempo de ejecución, en base a la informacion recibida del host donde se esta ejecutando se adaptara para calcular sus procesamientos internos (tamaño de thread pools, ciclos de garbage collector, etc..)
ActiveProcessorCount
Si quisieramos 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 parametro
-XX:ActiveProcessorCount=2.
6. Java Thread
Un proceso java, es una ejecucion de un programa Java en un sistema operativo.
Un Java Thread es una cadena de instrucciones.
La JVM se encarga de ir pasando (con su prioridad y reglas) al OS los hilos (ejecuciones) que quiere realizar en su OS Thread (processor).
Todos los hilos de un mismo proceso (en este caso una ejecucion de un programa java) comparten los recursos y la memoria usada.
7. 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 ejecucion independiente que pertenece a un proceso.
Crear un hilo nativo es un proceso costoso en tiempo y en uso de memoria:
- Reserva de memoria para almacenar el stack de la ejecucion. Esta cantidad de memoria es fija y bloqueada hasta que el hilo de plataforma termina.
En un sistema Linux x64, esta cantidad de memoria es 1MB. Cada vez que creamos un hilo , se reserva 1MB, por lo que existe una relacion directa entre memoria de la maquina y el numero de hilos que ejecutamos.
De aqui podemos deducir que hay una limitacion proporcional, es decir si creamos mas hilos (x1MB) simultaneos que su tamaño ocupa mas de la memoria disponible del host obtendermos un error.
Thread per Request Problem
Imaginemos un Tomcat que recibe 1000 peticiones simultaneas, esto originaria 10GB de memoria de Stack. Saturariamos el sistema y más si pensamos en entornos contenerizados. A este comportamiento se le conoce tambien como thread bottleneck.
- Context Swithching: Operaciones nativas para creacion y registrar el hilo en el pool de hilos nativo y las operaciones deribadas (order, priority, etc…)
Cada vez que un core ejecuta un Platform Thread necesita apuntar a un stack de memoria que pertenece al proceso propietario de ese hilo.
Como puedes imaginar, cuantos mas hilos, mas cambio de contexto y orquestacion y el resultado es el inverso al deseado, cuantos mas hilos mas saturacion del OS y por lo tanto degradacion en su comportamiento.
8. Mitigaciones (Un poco de historia)
Vamos a recapitular como ha sido la evolucion de la gestion de Threads a lo largo de la historia de las JVM.
Green Threads:
La implementaciones iniciales de JVM (1.0 hasta 1.3) basaban su ejecucion en los llamados Green Threads. Estos threads y su ejecucion eran manejados integramente por la JVM.
El hilo principal del proceso (Platform Thread) era unico y era el que se usaba para la gestion de todos los hilos Java.
Esta gestion era ineficiente en terminos de concurrencia, ya que no era capaz de correr tareas simultaenas. Asi como que el bloqueo en la ejecucion de un hilo condicionaba la entrada en ejecucion del resto de hilos que hubiera.
A partir de la version 1.2 se empezo 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 gestion de concurrencia de hilos en las aplicaciones.
Problemas
Este enfoque exprimía la potencia del Host pero aun tenia el problema de que el proceso de creacion de hilos de plataforma era muy costoso.
Executor Service (java 1.5) y los Future:
La creacion de hilos de Plataforma seguía siendo un proceso costoso como hemos visto anteriormente. Asi que (aunque ya existian y habia librerias que lo implementaban) se empezo a trabajar con el uso de Thread Pools.
Estos Thread Pools eran reservas de hilos de plataforma que eran administrados y mantenidos para poder dar cabida a la ejecucion de los Java Threads.
La introduccion de la programacion 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 basico en la ejecucion de hilos y este problema era, que un hilo que estuviera en estado bloqueado (por ejemplo esperando una operacion de I/O), permanecía bloqueando el hilo de plataforma para el resto de ejecuciones y hasta que no hubiera terminado no podria ser usado por ninguna otra ejecucion.
Imagina una request entrante que va hasta bbdd y la bbdd tarda en responder. Nuestro hilo estará usando el hilo de plataforma en hacer absolutamente nada hasta que obtengamos la respuesta de la bbdd
ForkJoinPool (Java 1.7)
El gestor ForkJoinPool introdujo el concepto de work-stealing.
ForkJoinPool esta pensado para optimizar el paralelismo en un enfoque de tareas Fork-and-Join. Es decir dividir una tarea en tareas mas pequeñas que puedan correr de manera concurrente.
Si has programado con las APIs Future, CompletableFuture, WebFlux, veras que al final estas 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 computacion y posteriormente combinar los resultados para obtener el resultado total.
ForkJoinPool sigue siendo un gestor ThreadPool de hilos plataforma, pero su gestion de los hilos le permite compartir los hilos del pool entre las distintas subtareas, de esta manera minimiza el costo que tendria una ejecucion en un proceso bloqueante y optimiza y premia el uso de task concurrentes .
CompletableFuture (java 1.8) cumple un papel crucial en la gestion de ForkJoinPool y dio lugar a una gran variedad de frameworks basados en programacion asincrona y reactiva que logran una gestion muy eficiente de los recursos del sistema.
Si usamos su api asincrona vemos como en una cadena de ejecucion Step1->thenAsync->Step2 el hilo que inicio el step1 no tiene porque 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);
});
}
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 la complejidad.
Estos procesos requieren sincronizacion internamente y los desarrolladores necesitamos adaptar nuestro codigo en una cadena de subtareas (CompletableFutures) bien balanceadas en carga para hacer un recurso optimo del ForkJoinPool. La gestion interna condiciona en como los desarrolladores escribimos nuestro codigo, teniendo que poner foco en el como ademas de en el qué.
La gestion de errores y Debug de procesos es tedioso y complicado.
9. Virtual Threads -Poject Loom- (Java 21)
EL projecto Loom (Virtual Threads) nace con la intención de reemplazar al sistema operativo como gestor del ciclo de vida de los threads, ocupandose ahora la JVM de la gestion de memoria del stack del hilo y la orquestación.
Al desacoplar estas tareas del OS, “no estará limitado” por las limitaciones del OS.
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 mas eficiente por la JVM.
Problemas
Al gestionarse todo el heap, puedes intuir que nuestros hilos virtuales pueden saturar el heap de nuestro proceso, debes ser cuidadoso con tu programacion 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 capaz de dar cabida un gran numero de hilos java. Su manejo ademas es mas eficiente porque un Virtual Thread es manejado como un objeto java en sí mismo.
El tratamiento de hilos tambien está basado en workstolen, es decir cuando un VirtualThread 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 gestion 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.
Cuando usar Virtual Threads
Como puedes deducir, a los Virtual Threads se les saca el maximo partido cuando nuestra aplicacion 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.
Cuando no usar Virtual Threads
En operaciones de computacion continua no existen bloqueos y el uso de CPU es continuo y 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 video, audio, etc… donde hay un proceso que esta haciendo uso extenso de un core y no tiene bloqueos en su ejecucion.
Los bloques synchronized no deben usarse en Virtual Threads, ya que bloquean el acceso a memoria y por lo tanto la JVM no podrá liberar el virtual thread.
Codificacion
Al encargarse la JVM de la orquestacion de hilos y detectar los procesos bloqueados, el desarrollador ya no necesita escribir su codigo de manera sincronizada, ni bloques bloqueantes, ni callbacks (reactive apis) etc.... 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 habia convertido en algo muy tedioso.
ThreadLocal: El uso de transporte de contexto a traves de objetos Threadlocal [se desaconseja en los Virtual Threads] (https://stackoverflow.com/questions/75047540/is-it-possible-to-create-a-threadlocal-for-the-carrier-thread-of-a-java-virtual), ya que podemos tener una gran cantidad de virtualThreads y por lo tanto nuestras variables ThreadLocal pueden acabar saturando el java heap.
10. SPRING BOOT Embeded Tomcat Benchmark
Contexto: Una aplicacion Spring Boot que sirve un API Rest.
Cada ejecucion va a tener un sleep de 4 segundos, de manera que el hilo se quedara bloqueado esos 4 segundos y por lo tanto no será liberado ni podra ser reutilizado.
Vamos a probar varias implementaciones:
10.1 Tomcat embedded
Por defecto tiene un pool de 200 hilos. https://docs.spring.io/spring-boot/appendix/application-properties/index.html#application-properties.server.server.tomcat.threads.max.
server.tomcat.threads.max -> Maximum amount of worker threads. Doesn’t have an effect if virtual threads are enabled. Default value 200.
Por defecto el ThreadPool reservado para Tomcat Embebido en una aplicacion 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 mas estas se quedaran a la espera de que se les pueda asignar un hilo de ejecucion.
@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);
}
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 simultaneos y el incremento del Heap no ha sido muy grande.
10.2 Virtual Threads
Para el uso de virtualThreads bajo Tomcat embeded basta con setear la property
Y el codigo Java sera el mismo de la prueba anterior.Lanzamos las mismas 400 peticiones cada segundo durante un minuto.
Los resultados indican que ha procesado 6000 peticiones el doble que la prueba anterior. Que la peticion mas tardía ha llegado a 5 segundos, es decir una latencia de 1 segundo (ya que los 4 primeros lo bloqueaba el codigo). Y vemos como el Heap de la JVM se ha disparado hasta los 91 MB. Incluso la reserva de heap se fue a 171 Mb, que es lo que comentabamos antes, un Virtual Thread es un Java Object, por lo tanto vive en el heap.
10.3 WebFlux
Vamos a preparar un enpoint que hara una espera de 4 segundos:
@GetMapping("/op")
public Mono<String> doExtensiveOperation() {
return Mono.delay(Duration.ofMillis(4000)).map(duration->"OK");
}


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 mas de lo que esperabamos, pero vemos que ha tenido un pico de hilos de 223. La memoria usada tambien 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 VirtualThreads a aprovechado mejor los recuros usando solo 49.
DE TODOS MODOS HE HECHO TRAMPAS, asi que sigue leyendo!!
Como comenté antes, en la prueba con WebFlux he usado el api Mono.delay especifico de la libreria para este proposito, pero para jugar en igualdad lo lógico es que mi codigo 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";
});
}
Ahora encontramos que los resultados no son tan buenos y entonces es cuando comprendemos que:
-
Para escribir nuestro codigo en modo reactivo y asincrono tenemos que saber bien que estamos escribiendo, no solo la lógica de negocio, si no tambien el cómo.
-
Que Virtual Threads nos ha dejado escribir nuestro codigo chapuza y aún así la JVM ha sido capaz de reasignar hilos cuando ha detectado que hay bloqueos en los virtualThreads que llevaban esa ejecucion, mejorando así la concurrencia y el rendimiento de la aplicación.
11. LIBRERIAS Y FRAMEWORKS
A dia de hoy muchos frameworks y librerias han sido desarrolladas bajo un enfoque asincrono, utilizando bloques synchronized para mejorar su rendimiento.
Con la liberacion de los Virtual Threads hay muchas librerias y frameworks que deberán adaptar su codigo para beneficiarse del uso de los virtual Threads.
Spring Boot: Empezó a trabajar en la migracion/adaptación a Virtual Threads.
https://spring.io/blog/2022/10/11/embracing-virtual-threads
Ya existen en Spring boot 3.2 migraciones disponibles en el ecosistema Spring boot, enfocado a aplicaciones de naturaleza web (Tomcat,Jetty). GUIA
Basta con setear en application.properties:
Esto intermante hara usó de la implementacion Executors.newVirtualThreadPerTaskExecutor() en los motores que requieran de un ExecutorService.Async Executions: Si nuestro codigo esta escrito en modo asincrono, existe varios workarounds para poder seguir manejandolo en modo “virtual thread” https://narasivenkat.medium.com/using-java-virtuals-threads-for-asynchronous-programming-29a3274e6294.
Spring Security: El contexto de seguridad de spring hace uso de variables en ThreadLocal, lo cual no esta aconsejado en Virtual Threads. Funciona , pero comenté , puede saturar el heap. Para solventar el uso de ThreadLocal se esta trabajando en ScopeValue [JEP-481 ScopedValue] (https://openjdk.org/jeps/481) que estara disponible en java 23. - https://stackoverflow.com/questions/78889090/alternative-of-threadlocal-for-virtual-threads
Reactor [WebFlux]: Loom viene a dar programacion secuencial pero tratamiento asincrono y orquestado por la JVM, y Reactor ya hacia esto de manera explicita en codigo. Por lo que, debido a la sencillez deberiamos pasar a Loom. Hacer uso de Reactor en vez de virtual thread sería en mi opinion por algo “especifico”. A dia de hoy se intenta adaptar Reactor para el uso de Virtual threads en sus executors, para poder seguir manteniendo el codigo escrito en formato asincrono para Webflux. Pero a dia de hoy no es soportado oficialmente. - https://github.com/reactor/reactor-core/issues/3084
Spring kafka Spring-Kafka 3.2.4 tiene limitaciones trabajando con Virtual Threads, hay librerias - https://github.com/spring-projects/spring-boot/issues/36396 - https://github.com/spring-projects/spring-kafka/commit/ae775d804f82483f99d4cab2a16ef2b27649252a
Netty La adaptacion de Netty todavia esta en proceso https://github.com/netty/netty/issues/8439
12. Conclusiones
Pues que VirtualThread viene a mejorarnos el dia a dia a la hora de programar, va a dar un rendimiento espectacular a las aplicaciones.
A dia de hoy queda mucho camino por recorrer y trabajo de adaptación de librerias que necesitan subirse al carro de los VirtualThreads.
Y lo mas importante, que mañana en la maquina de café de la oficina, puedes mirar por encima del hombro al que no haya leido todavia este post de virtual Threads. ¿Como se te queda el cuerpo?…
13. Referencias
- [Official JEP 444] (https://openjdk.org/jeps/444)
- https://stackoverflow.com/questions/78318131/do-java-21-virtual-threads-address-the-main-reason-to-switch-to-reactive-single
- https://medium.com/@ajinkyav/java-21-virtual-threads-the-hype-is-real-514871521863#:~:text=Before%20going%20further%2C%20it%20is,virtual.
- https://spring.io/blog/2022/10/11/embracing-virtual-threads
- https://medium.com/@med.ali.bennour/enhancing-java-concurrency-processor-core-threads-fibers-0cac6000e5fb
-
https://engineeringatscale.substack.com/p/what-are-java-virtual-threads
-
https://blog.fastthread.io/pitfalls-to-avoid-when-switching-to-virtual-threads/
- https://medium.com/codex/java-virtual-threads-9fad6c362890
- https://stackoverflow.com/questions/77874865/kafka-consumer-on-virtual-threads-is-this-ok
- https://github.com/spring-projects/spring-kafka/issues/3074
- https://github.com/spring-projects/spring-kafka/commit/ae775d804f82483f99d4cab2a16ef2b27649252a
- https://github.com/spring-projects/spring-boot/issues/36396
- https://github.com/spring-projects/spring-kafka/issues/3074
- https://github.com/netty/netty/issues/8439