DEV Community

Cover image for Virtual Threads no te salva de un synchronized mal puesto
Juan Torchia
Juan Torchia Subscriber

Posted on • Originally published at juanchi.dev

Virtual Threads no te salva de un synchronized mal puesto

Un backend Spring Boot típico —de esos con capas de servicio, repositorios JPA y algún cliente HTTP hacia un proveedor externo— migra a Java 21, activa Virtual Threads con spring.threads.virtual.enabled=true y espera magia. La demo interna sale bien: más throughput con menos memoria por thread, sin tocar una línea de lógica de negocio. Ahí es donde me gusta desconfiar un poco: cuando algo mejora sin que nadie haya tocado el código de negocio, casi siempre es porque todavía no lo probaste bajo la carga que importa. Alguien pide medir bajo carga real y ahí aparece la pregunta incómoda: ¿por qué un endpoint que llama a un servicio con un bloque synchronized adentro no mejoró nada?

Esa pregunta es el punto de partida de este post. No para vender Virtual Threads ni para enterrarlo, sino para separar lo que la JEP 444 garantiza de lo que el marketing alrededor de Project Loom dio por sentado.

Mi tesis: Loom no es magia gratis. Si el código tiene bloques synchronized o llamadas nativas bloqueantes, seguís con el mismo cuello de botella de siempre — solo que ahora corre sobre un thread que parece barato y no lo es en ese punto exacto. Lo incómodo de esto es que el flag no te avisa: la demo sale bien igual, y el problema aparece recién cuando hay tráfico real encima.

Virtual threads java loom limitaciones: qué dice la fuente oficial

La JEP 444 (Java 21, feature final) es clara en su objetivo: reducir el costo de escribir código concurrente estilo "un thread por request" sin cambiar el modelo de programación. La idea central es que un virtual thread se ejecuta sobre un carrier thread (un thread de plataforma real del pool de ForkJoinPool), y cuando el virtual thread hace una operación bloqueante compatible —I/O de red, Thread.sleep, locks de java.util.concurrent— la JVM lo desmonta del carrier y libera ese carrier para atender a otro virtual thread.

Eso es lo que la JEP promete y lo que efectivamente cumple. El texto oficial también documenta, sin vueltas, los casos donde el virtual thread no se puede desmontar y bloquea el carrier igual que un thread tradicional:

  • Código dentro de un bloque synchronized (antes de Java 24, donde se mejoró parcialmente esto para monitores no reentrantes en algunos escenarios, pero la JEP 444 documenta el comportamiento base de la versión inicial).
  • Llamadas nativas bloqueantes vía JNI o métodos nativos del sistema operativo.
  • Operaciones de archivo bloqueantes en algunos sistemas de archivos, según la implementación del filesystem.

Esto no es un detalle de letra chica. Es el corazón del trade-off. La JEP lo llama "pinning" — el virtual thread queda "clavado" (pinned) a su carrier thread durante la operación bloqueante, y mientras eso pasa, ese carrier no puede atender ningún otro virtual thread. Si tu pool de carriers es chico (por default, tantos como núcleos de CPU disponibles) y varios virtual threads quedan pinned al mismo tiempo por synchronized, terminás con el mismo problema de escasez de threads que Loom prometía eliminar.

Dónde se equivoca la gente: la receta común y su costo oculto

La receta que circula en charlas y posts de blog es: "cambiá @Async por virtual threads, activá el flag, listo". Funciona perfecto en el caso feliz: un endpoint que hace una consulta JPA, espera una respuesta HTTP de otro servicio, y devuelve JSON. Ahí Virtual Threads brilla, porque JDBC moderno y los clientes HTTP de Java (HttpClient, y drivers JDBC actualizados) ya son compatibles con el desmontaje.

El contraejemplo aparece en capas más viejas del código, las que nadie tocó en años. Es un patrón que se repite bastante seguido en proyectos con historia: una clase de utilidad compartida entre varios servicios, con un método synchronized que protege un caché en memoria o un contador. Antes de Loom, ese synchronized ya era un cuello de botella — pero como cada request tenía su propio thread de plataforma, el costo se diluía entre threads baratos de crear (relativamente) y el sistema operativo manejaba el scheduling.

Con Virtual Threads, el mismo synchronized tiene un costo distinto: si hay miles de virtual threads corriendo sobre un pool chico de carriers, y varios pasan por ese bloque al mismo tiempo, el pinning empieza a comerse los carriers disponibles. El síntoma no es un error visible. Es latencia que sube sin que el CPU esté al límite — la señal clásica de que algo está bloqueando threads que deberían estar libres.

// Ejemplo simplificado del patrón problemático
public class CacheUtil {
    private static final Map<String, Object> cache = new HashMap<>();

    // Este synchronized bloquea el carrier thread completo
    // mientras el virtual thread está "pinned"
    public static synchronized Object get(String key) {
        return cache.get(key);
    }
}
Enter fullscreen mode Exit fullscreen mode

La solución no es exótica: reemplazar synchronized por ReentrantLock (que sí es compatible con el desmontaje de virtual threads) o por estructuras de java.util.concurrent como ConcurrentHashMap. Pero eso implica auditar código, no solo prender un flag.

flowchart TD
  A[Virtual Thread ejecuta] --> B{¿Operación bloqueante?}
  B -->|I/O de red, sleep, ReentrantLock| C[Se desmonta del carrier]
  C --> D[Carrier libre para otro virtual thread]
  B -->|synchronized, JNI, nativo bloqueante| E[Queda pinned al carrier]
  E --> F[Carrier bloqueado hasta que termine]
Enter fullscreen mode Exit fullscreen mode

Matriz de decisión: cuándo migrar y cuándo esperar

No hay una respuesta universal, y cualquiera que te la dé sin mirar el código específico está vendiendo humo. Esto es una guía de dónde mirar primero, no una conclusión cerrada:

Situación Qué mirar primero Riesgo de pinning
Endpoints con JPA + drivers JDBC actualizados (compatibles con virtual threads) Versión del driver, si soporta desmontaje Bajo
Clientes HTTP con HttpClient de Java o WebClient reactivo Configuración del pool de conexiones Bajo
Código legacy con synchronized en utilidades compartidas Buscar todos los synchronized con grep antes de migrar Alto
Llamadas JNI o librerías nativas (compresión, criptografía de bajo nivel) Si la librería expone bloqueo nativo Alto — no hay forma de evitarlo sin cambiar la librería
Pools de conexión a bases de datos con locks internos viejos Revisar si el pool es compatible con Loom (HikariCP lo es desde versiones recientes) Medio

El criterio práctico, el que aplicaría yo antes de tocar nada en un sistema con tráfico real: antes de activar spring.threads.virtual.enabled=true en algo que no sea un experimento aislado, correr grep -rn "synchronized" sobre el código propio y sobre las dependencias que se puedan inspeccionar. Si aparecen bloques en el camino caliente de los endpoints con más tráfico, ahí está el trabajo real antes de tocar el flag.

Límites: lo que esta evidencia no permite concluir

Acá hay que ser honesto con lo que se puede afirmar y lo que no. La JEP 444 documenta el comportamiento de pinning como diseño conocido, no como bug. Eso es evidencia pública y verificable — cualquiera puede leer el documento. Lo que no se puede concluir sin un experimento propio, con logs y métricas de un caso real, es cuánto impacta ese pinning en un sistema específico. Depende de cuántos synchronized hay en el camino caliente, del tamaño del pool de carriers, y del patrón de tráfico.

Tampoco corresponde afirmar que Virtual Threads "no sirve" — sirve, y bien, para el caso que fue diseñado: I/O-bound con muchas conexiones concurrentes esperando red o disco. El error es asumir que resuelve automáticamente el modelo de concurrencia completo de una aplicación sin auditar qué hay debajo. Cualquier claim de mejora de throughput sin benchmark propio, reproducible y documentado, es marketing, no evidencia — y ese límite lo pongo yo también para este post: nada de lo que escribí acá viene con un número de throughput mío, porque no lo tengo, y prefiero decirlo así antes que inventarlo.

Cómo decidís, en la práctica

Mi postura, después de mirar la JEP con la misma atención con la que uno mira un stack trace en producción: Virtual Threads es una mejora real para el patrón "un thread por request" en backends I/O-bound, y ahí no hay drama en adoptarlo. El trabajo serio está antes de activar el flag, no después — auditar synchronized, revisar compatibilidad de drivers y librerías nativas, y entender que el pool de carriers sigue siendo un recurso finito.

Si el código tiene una capa vieja con locks manuales o dependencias nativas bloqueantes, migrar sin auditar es cambiarle el nombre al cuello de botella, no eliminarlo. Ese es el tipo de decisión técnica que conviene tomar con la documentación oficial abierta al lado, no con un blog post que promete throughput sin mostrar de dónde sale el número. La pregunta que me haría antes de activar el flag en un sistema que ya está en producción no es "¿mejora?", sino "¿qué synchronized no audité todavía?".

Si te interesa este tipo de análisis de trade-offs técnicos con evidencia pública en lugar de claims sueltos, en el blog hay más casos parecidos: cómo evaluar dependencias npm antes de sumarlas, dónde Prisma deja de controlar la query real contra PostgreSQL, o qué cambia realmente correr Qwen3 en local con Ollama — mismo criterio, otro stack.

Preguntas frecuentes

¿Virtual Threads reemplaza a los threads de plataforma?
No los reemplaza, corre sobre ellos. Cada virtual thread necesita un carrier thread (thread de plataforma) para ejecutar código. La diferencia es que muchos virtual threads pueden compartir pocos carriers, porque se desmontan durante operaciones bloqueantes compatibles.

¿Necesito cambiar código para usar Virtual Threads en Spring Boot?
Para el caso básico, no — activar el flag alcanza si el stack (JDBC driver, cliente HTTP) ya es compatible. El trabajo real aparece si hay synchronized, pools viejos o librerías nativas en el camino.

¿synchronized deja de funcionar con Virtual Threads?
Funciona, pero bloquea el carrier thread completo mientras dura, en lugar de desmontar el virtual thread. Eso reduce la escalabilidad que Loom promete en ese tramo de código específico.

¿Cómo reemplazo synchronized sin romper la semántica de exclusión mutua?
ReentrantLock de java.util.concurrent.locks es compatible con el desmontaje de virtual threads y mantiene la misma garantía de exclusión mutua, con una API explícita (lock()/unlock()) en vez de un bloque implícito.

¿Esto afecta a todos los proyectos con Java 21?
Solo a los que activan Virtual Threads explícitamente y tienen código con synchronized, JNI o I/O bloqueante no compatible en el camino caliente. Si no se activa el flag, el comportamiento de threads sigue siendo el tradicional.

¿Vale la pena migrar un backend Spring Boot generico hoy?
Depende del perfil de carga. Para sistemas I/O-bound con mucha concurrencia esperando red o disco, sí tiene sentido evaluarlo — con auditoría previa de código bloqueante. Para sistemas CPU-bound, el beneficio es marginal porque el cuello de botella no está en la espera de I/O.

Fuente original: https://openjdk.org/jeps/444


Este artículo fue publicado originalmente en juanchi.dev

Top comments (0)