Terminé el post de ayer con una frase que me quedó pendiente: "un synchronized mal puesto te arruina el virtual thread". La dejé así porque no tenía el dato preciso a mano y no quería inventarlo. Hoy lo tengo. Fui directo a la fuente — JEP 444, la que introdujo virtual threads en Java 21 — y ahí está, en texto llano, sin ambigüedad: son dos casos. No tres, no "depende del contexto", no "varios escenarios posibles". Dos.
Esa precisión importa. Si estás evaluando migrar un backend con código legacy a virtual threads, la pregunta no es "¿puede pasar pinning?". La pregunta es "¿mi código toca alguno de estos dos casos específicos?". Y esa pregunta sí se puede responder con un grep.
Mi tesis es simple y un poco incómoda: la mayoría de las guías de migración que circulan tratan el pinning como un riesgo difuso, cuando en realidad es un problema acotado y buscable. Tratarlo como "cuidado con la concurrencia en general" es lo que hace que la gente reescriba código que no necesitaba tocar.
Qué dice JEP 444 sobre pinning (textual)
La JEP es clara y va a lo concreto. Cito directo de la sección "Detecting and diagnosing pinning":
"A virtual thread cannot be unmounted during blocking operations when it is pinned to its carrier thread. This can happen in two cases: First, when it executes code inside a synchronized block or method. Second, when it executes a native method or a foreign function."
Traduciendo sin perder precisión: un virtual thread queda pinneado (no puede desmontarse del carrier thread) en exactamente dos situaciones:
- Dentro de un bloque o método
synchronized— el monitor nativo de la JVM no sabe manejar el desmontaje de virtual threads, así que si el thread bloquea ahí (por ejemplo esperando I/O dentro del bloque), se queda pegado al carrier. - Ejecutando un método nativo o una función foránea — código JNI, o cualquier llamada que cruce al mundo nativo vía Foreign Function & Memory API. La JVM no tiene visibilidad ni control sobre qué hace ese código del otro lado, entonces no puede desmontarlo.
Fuera de esos dos casos, un virtual thread bloqueado se desmonta del carrier y libera ese carrier thread para otro trabajo. Eso es literalmente todo el punto de Project Loom. Si tu bloqueo cae en uno de estos dos casos, no pasa. El carrier queda ocupado como si fuera un thread de plataforma tradicional.
El experimento mínimo: reproducir el pinning con synchronized
No hace falta un benchmark de producción para ver esto. Alcanza con este snippet, corrido con -Djdk.tracePinnedThreads=full (la flag de diagnóstico que la misma JEP menciona):
// Bloque synchronized + operación bloqueante adentro = pinning
Object monitor = new Object();
Thread.startVirtualThread(() -> {
synchronized (monitor) {
try {
Thread.sleep(Duration.ofSeconds(1)); // bloqueo DENTRO del synchronized
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});Con esa flag activa, la JVM imprime en consola el stack trace exacto del punto de pinning. Si movés el Thread.sleep fuera del bloque synchronized, el pinning desaparece — el virtual thread se desmonta normalmente durante el sleep. Ese contraste (adentro vs afuera del bloque) es la forma más directa de verlo con tus propios ojos, sin necesitar métricas de nadie.
flowchart TD
A[Virtual thread ejecuta] --> B{Bloqueo dentro de synchronized o nativo/JNI?}
B -->|Si| C[Pinning: carrier thread queda ocupado]
B -->|No| D[Desmontaje normal: libera el carrier]Dónde se equivoca la gente
La receta común que vi repetida en varios lugares es: "reemplazá todos los synchronized por ReentrantLock y listo, virtual threads friendly". Esa receta no es falsa, pero simplifica de más. El costo oculto es que no todo synchronized es igual de riesgoso.
Un synchronized que protege una operación de memoria pura — incrementar un contador, actualizar un mapa en memoria — no bloquea nada de I/O adentro. Ahí el pinning existe técnicamente (el thread queda marcado como pinneado mientras sostiene el monitor) pero la duración es tan corta que en la práctica no compite con nada. El problema real aparece cuando adentro del synchronized hay una llamada bloqueante: una query a base de datos, un Thread.sleep, un socket read.
El contraejemplo que conviene tener en la cabeza: un método synchronized que hace Files.readAllBytes() de un archivo grande. Ahí sí importa, y ahí sí hay que reescribir. Pero salir a cambiar cada synchronized de un codebase legacy sin distinguir estos casos es trabajo desperdiciado — y en código concurrente, tocar locks sin necesidad es la forma más rápida de introducir un bug de carrera que antes no existía. Lo digo porque lo vi pasar: gente que sale con el machete a reemplazar synchronized por sport, y termina con un deadlock que antes no estaba, por no entender que ReentrantLock no es un drop-in reemplazo semántico.
Para el caso nativo/JNI la situación es distinta: ahí no hay "depende". Si el código cruza a nativo, está pinneado todo el tiempo que dure esa llamada, punto. No hay versión corta o larga que cambie el diagnóstico.
Matriz de decisión: cuándo mirar esto primero
| Situación en el código | Riesgo de pinning | Qué mirar primero |
|---|---|---|
synchronized con solo operaciones en memoria | Bajo, duración mínima | Confirmar que no hay I/O adentro antes de tocar nada |
synchronized con I/O bloqueante adentro (DB, archivo, socket) | Alto | Migrar a ReentrantLock o mover el I/O fuera del bloque |
| Llamadas JNI o Foreign Function & Memory API | Total durante la llamada | Evaluar si esa dependencia nativa es evitable o aislarla en su propio pool |
Librerías legacy con synchronized internos no visibles | Desconocido sin perfilar | Correr con -Djdk.tracePinnedThreads=full antes de migrar |
| Pool de virtual threads con carriers agotándose bajo carga | Sospechoso | Revisar logs de pinning antes de asumir que es otro cuello de botella |
Esta tabla no reemplaza medir. Cada fila es un criterio para decidir dónde mirar primero, no una conclusión sobre qué va a pasar en un sistema específico.
Límites de esto
Lo que puedo afirmar con la JEP en la mano son los dos casos documentados y el comportamiento de la flag de diagnóstico — eso es texto oficial, verificable por cualquiera que abra el link. Lo que no puedo afirmar sin correr algo real es cuánto impacta el pinning en un sistema productivo específico: eso depende de cuántos virtual threads compiten por cuántos carriers, cuánto dura cada bloqueo, y cuántos núcleos tiene la máquina. Sin ese experimento con datos propios, cualquier número que te diera sería inventado. Si el post de ayer dejó afuera esta precisión fue justamente para no caer en esa generalización.
Tampoco es una conclusión universal decir "reescribí todos los synchronized". La JEP describe el mecanismo, no te dice qué proporción de tu código legacy cae en el caso riesgoso. Eso lo tenés que perfilar vos, con la flag de trace, antes de decidir qué tocar.
Sobre el futuro del mecanismo: hoy, con la evidencia pública disponible en JEP 444, son estos dos casos. No tengo forma de asegurar qué haría un JEP futuro con el manejo de synchronized — eso sería especular, y no es el punto de este post.
Si ya leíste el análisis de virtual threads y las limitaciones generales del modelo de Loom, esto completa esa pieza — /blog/virtual-threads-java-loom-limitaciones tiene el panorama más amplio, este post es la letra pequeña de un punto específico.
FAQ
¿Qué significa que un virtual thread esté "pinneado" al carrier thread? Significa que no puede desmontarse durante una operación bloqueante. En vez de liberar el carrier thread para que otro virtual thread lo use, el carrier queda ocupado esperando, igual que con un thread de plataforma tradicional.
¿Todo synchronized causa pinning?
Técnicamente sí, mientras se sostiene el monitor. Pero el impacto real depende de si hay una operación bloqueante adentro del bloque. Un synchronized corto sobre memoria pura no genera un problema práctico.
¿Cómo detecto pinning en código que no escribiste vos?
Con la flag -Djdk.tracePinnedThreads=full al arrancar la JVM. Imprime el stack trace exacto de cada evento de pinning, ahí ves si viene de un synchronized o de una llamada nativa.
¿ReentrantLock reemplaza siempre a synchronized sin efectos secundarios?
No sin revisar el código. ReentrantLock no es reentrante automáticamente en el mismo sentido semántico si el código depende de detalles específicos del monitor nativo (como interrupción durante la espera). Hay que leer el método, no solo hacer un find-and-replace.
¿El caso de JNI aplica solo si usás JNI directamente? No. Aplica también si una librería de terceros que usás internamente hace llamadas nativas — drivers de bases de datos antiguos, compresión, criptografía con bindings nativos. El pinning ocurre en la llamada nativa, sin importar quién la escribió.
¿Este problema existe en Java 21 o se resolvió en versiones posteriores?
JEP 444 documenta el comportamiento tal como se entregó en Java 21 (la versión que estabilizó virtual threads). Con la evidencia pública disponible hoy, son estos dos casos; cualquier cambio en el manejo de synchronized quedaría documentado en su propia JEP el día que exista.
Mi postura
El pinning no es folklore de blog ni una advertencia genérica tipo "cuidado con la concurrencia". Son dos casos, documentados con nombre y apellido en la JEP oficial, y se pueden buscar en un codebase con un grep bien apuntado. Si estás migrando código legacy a virtual threads, el primer paso no es reescribir todo — es correr con la flag de trace y ver qué aparece. Después de eso, recién ahí, decidís qué tocar. Lo incómodo de esto es que la respuesta fácil ("cambiá todo a ReentrantLock") es la que más deuda técnica nueva genera si no perfilaste antes.
Fuente original:
- JEP 444: Virtual Threads — https://openjdk.org/jeps/444
Artículos Relacionados
Actuator endpoints en Spring Boot: allowlist, no deshabilitar lo obvio
Spring Boot Actuator expone por defecto más de lo que la mayoría de los equipos se imagina. La diferencia entre un endpoint útil para monitoreo y un mapa de variables de entorno para un atacante está en una decisión que casi nadie toma explícitamente: allowlist versus deshabilitar lo que parece obvio.
23 ago 2026 · 8′ · Tutoriales · spring-boot · java
Qué significa ser Java Champion en 2026: el criterio real detrás del reconocimiento y por qué me importa
Java Champion no es un título académico ni corporativo. Es reconocimiento de contribución a la comunidad, y en 2026 el contenido técnico de calidad en español es una contribución subvalorada y legítima. Mi objetivo declarado y el criterio real detrás del programa.
18 ago 2026 · 10′ · Tutoriales · open source · spring-boot
Firma digital CAdES vs XAdES en Java: las diferencias que importan cuando tu CA te pide una y vos tenés la otra
CAdES y XAdES no son intercambiables aunque ambos sean "firma avanzada". La elección depende del tipo de documento, del perfil de confianza y de lo que la CA espera validar. Guía técnica con DSS y Java, con los errores que ya me hicieron perder tiempo antes de aprender a preguntar el formato primero.
12 ago 2026 · 9′ · Tutoriales · seguridad · certificados
Comentarios (0)
¿Qué pensás de esto?
Dejá tu comentario en 10 segundos.
Usamos tu login solo para mostrar tu nombre y avatar. Nada de spam.
Todavía nadie comentó. Sé el primero — tu opinión vale oro cuando somos pocos.