Buenas prácticas de multithreading en la práctica — Edición Java: el estándar en la era de los hilos virtuales

· Actualizado el: · · Multithreading, Java, Aplicaciones empresariales, Investigación de fallos, Diseño

«Quiero paralelizar en Java un proceso por lotes de un sistema empresarial», «en mi aplicación web con Spring la caché compartida se corrompe de vez en cuando», «heredé una aplicación Swing antigua plagada de new Thread» — Java es un lenguaje que incorporó el multithreading desde JDK 1.0, cuenta con la caja de herramientas madura java.util.concurrent y, además, con los hilos virtuales de JDK 21 volvió a reescribir el sentido común de la concurrencia. Precisamente porque las herramientas son abundantes, la decisión de «cuál elegir» se convierte directamente en la calidad del diseño.

Este artículo es la edición Java de la serie práctica sobre multithreading. Dirigido a quienes desarrollan sistemas empresariales, procesos por lotes y aplicaciones de servidor en Java, traduce los principios de diseño del multithreading — no crear hilos directamente, reducir el estado mutable compartido, disciplina en los bloqueos y diseñar primero cómo detener — a las herramientas de Java (centrado en JDK 21 en adelante, la LTS), y los organiza a partir de fuentes primarias vigentes en agosto de 2026, combinándolos con los criterios de uso de la era de los hilos virtuales y las trampas propias de Java. Está escrito para poder leerse de forma independiente. Los mismos principios, desarrollados en otros lenguajes, también están disponibles en la «edición .NET», la «edición C++» y la «edición C».

1. Ante todo, la conclusión

  • Tampoco en Java se debe escribir new Thread en el código de negocio.Las tareas se envían a ExecutorService y la gestión del ciclo de vida de los hilos se deja en manos de la librería.1
  • Las tareas dominadas por la espera de E/S van a hilos virtuales.Los hilos virtuales, oficializados en JDK 21, se usan con la regla «un hilo virtual por tarea» y nunca se ponen en pool. El límite de ejecuciones simultáneas se controla con Semaphore, no con un pool.23
  • Los hilos virtuales son una herramienta de rendimiento (throughput), no una herramienta para acelerar cálculos.La paralelización de tareas limitadas por CPU sigue a cargo, como siempre, de hilos de plataforma en un número cercano al de núcleos (pool fijo o parallel stream).3
  • Los bloqueos se hacen con un objeto de bloqueo private final o con un ReentrantLock dedicado.synchronized(this) o bloquear sobre un objeto público puede chocar con código externo. Si hace falta una adquisición con tiempo de espera (tryLock), la respuesta es ReentrantLock.
  • En JDK 21-23 existía el problema de que bloquear dentro de synchronized fijaba (pinning) los hilos virtuales, pero JDK 24 (JEP 491) lo resolvió.Conviene distinguir entre las advertencias antiguas y la situación actual.34
  • volatile garantiza visibilidad y orden, no atomicidad.Para contadores se usa AtomicInteger / LongAdder, y para el estado compuesto, bloqueos.5
  • La única forma correcta de detener un hilo es la parada cooperativa mediante interrupción (interrupt).Thread.stop / suspend / resume ahora lanzan UnsupportedOperationException. No se debe tragar InterruptedException: hay que restaurar el estado o volver a lanzar la excepción hacia arriba.6
  • La parada de ExecutorService sigue el patrón en dos fases «shutdown → awaitTermination → shutdownNow».shutdownNow es solo un mejor esfuerzo (en la implementación estándar, vía interrupción), así que presupone que las tareas respondan a la interrupción.1
  • La interfaz de Swing es propiedad exclusiva del EDT (hilo de despacho de eventos).Desde otro hilo hay que solicitarlo mediante SwingUtilities.invokeLater.7

2. Por qué el multithreading es difícil — condiciones de carrera, interbloqueos y el modelo de memoria

Los problemas que introduce el multithreading se reducen, en el fondo y sin importar el lenguaje, a dos tipos.

Una condición de carrera (race condition) es un error en el que el resultado cambia según el orden en que varios hilos llegan a un fragmento de código concreto. El ejemplo clásico es un contador compartido: la expresión count++, en realidad, se descompone en tres pasos — «leer → sumar → escribir de vuelta». Si dos hilos entran a la vez en esos tres pasos, el incremento de uno queda sobrescrito y desaparece por la escritura del otro. El resultado cambia en cada ejecución y no se puede predecir cuál saldrá.

Hilo BVariable compartida countHilo AHilo BVariable compartida countHilo Acount = 10Se sumó dos veces, pero count = 11Se perdió el incremento del hilo ALectura(10)Lectura(10)Suma local(11)Suma local(11)Escritura(11)Escritura(11)

Figura 1: la condición de carrera típica en la que se pierde un incremento sobre un contador compartido. Si otro hilo se cuela entre los tres pasos de count++, la última escritura sobrescribe a la anterior.

Un interbloqueo (deadlock) es el estado en el que dos hilos se esperan mutuamente por el bloqueo que tiene el otro, de modo que ninguno puede avanzar. Basta con que el hilo A mantenga el bloqueo 1 y espere el bloqueo 2, y el hilo B mantenga el bloqueo 2 y espere el bloqueo 1, para que ambos queden detenidos para siempre.

Espera a que se libere el bloqueo 2Espera a que se libere el bloqueo 1Hilo Amantiene el bloqueo 1Hilo Bmantiene el bloqueo 2

Figura 2: la espera circular de un interbloqueo. En cuanto las flechas de espera forman un ciclo, todos los hilos dentro de ese ciclo quedan detenidos para siempre.

Ambos dependen del momento exacto en que ocurren las cosas: una combinación del orden de ejecución que en la máquina de desarrollo solo se da una vez entre decenas de miles, en un servidor de producción con distinto número de núcleos y distinta carga puede ocurrir a diario. Que «no se reproduce con el depurador conectado» o que «desaparece al añadir un log» se debe a que la propia observación cambia el momento en que ocurren las cosas, y es un comportamiento típico de este tipo de errores de concurrencia. Por eso todos los principios de este artículo apuntan en una dirección: antes que «sincronizar correctamente», «reducir los lugares donde hace falta sincronizar».

2.1. La premisa propia de Java — el modelo de memoria y happens-before

Sobre esa base, lo propio de Java es que la forma en que se ve un dato compartido está definida por la relación happens-before del modelo de memoria de Java (JMM).

Leer y escribir una variable compartida sin sincronización no produce un «comportamiento indefinido» como en C++, pero de forma legítima puede ocurrir que se siga viendo un valor antiguo o que el orden de las escrituras aparezca invertido. Un error de consistencia de memoria del tipo «estoy observando una bandera boolean en un bucle, pero nunca veo el valor que cambió otro hilo» es un comportamiento que el JMM permite, no un fallo de la JVM.5 La herramienta para evitarlo es el mecanismo que crea relaciones happens-before — synchronized, volatile y las clases de java.util.concurrent—. Si se usan correctamente las colecciones concurrentes, la librería garantiza el «happens-before entre una operación de actualización y una lectura posterior».8

En resumen, la directriz práctica para Java se puede formular así: no intentar ingeniárselas con una variable compartida a pelo. Para compartir, usar las herramientas de java.util.concurrent y dejar que sea la librería la que cree las relaciones happens-before.

3. Cómo crear hilos — ExecutorService y los hilos virtuales

3.1. Separar la tarea de su ejecución

En Java, el principio de «no crear hilos uno mismo» lo encarna ExecutorService. Separa el trabajo (Runnable / Callable) de cómo se ejecuta (con cuántos hilos, con qué cola) y deja en manos de la librería la creación, reutilización y destrucción de los hilos.1

Desde JDK 21, la elección del mecanismo de ejecución se ha reducido a una disyuntiva simple.23

Predomina la espera de E/Sllamadas HTTP, BD, archivosCálculo que consume CPUHay una tarea que se quiere paralelizar¿Cuál es la naturaleza de la tarea?Hilo virtualExecutors.newVirtualThreadPerTaskExecutor()Uno por tarea. Nunca en poolPool fijo de hilos de plataformaExecutors.newFixedThreadPool(según núcleos)o parallel streamEl límite de concurrencia hacia servicios externosse fija con Semaphore, no con el pool

Figura 3: la elección del mecanismo de ejecución desde JDK 21. Se traza primero la línea «para la E/S se cambia la forma de esperar, para la CPU se paraleliza»: la E/S se reparte a hilos virtuales y la CPU, al pool tradicional.

Hay una advertencia en el lado de las tareas limitadas por CPU. Executors.newFixedThreadPool sí limita el número de hilos, pero la cola de espera es ilimitada. En un servicio de larga duración donde la entrada supera continuamente a lo que se procesa, lo único acotado al número de núcleos son los hilos: las tareas acumuladas en la cola, junto con sus datos, siguen consumiendo memoria. En ese tipo de configuración conviene usar ThreadPoolExecutor directamente para montar una cola con capacidad más una política de rechazo, o bien colocar un límite de entrada como Semaphore en el lado que envía las tareas, de forma que se pueda aplicar contrapresión (el mismo principio que la sección sobre colas del capítulo 4).

3.2. No usar mal los hilos virtuales

Los hilos virtuales son hilos ligeros desacoplados de los hilos del sistema operativo: durante las operaciones bloqueantes del JDK (E/S, bloqueos, sleep, etc. de la librería estándar) liberan el hilo del sistema operativo, lo que permite ejecutar varios millones en una sola JVM. Sin embargo, no es que «se liberen ante cualquier bloqueo». Si el bloqueo ocurre durante código nativo (JNI) o la ejecución de una foreign function, el hilo virtual permanece fijado (pinned) al hilo portador. Lo que resolvió JDK 24 (JEP 491, tratado más adelante) fue el pinning causado por synchronized; el pinning en el límite con código nativo sigue existiendo, así que si se cargan muchos hilos virtuales con procesos que bloquean durante mucho tiempo a través de controladores o APIs de dispositivo vía JNI, los hilos portadores se agotan. Aun así, como subraya la guía oficial, no son «hilos rápidos». La velocidad de ejecución del código no cambia; lo que ofrecen es escala (rendimiento, throughput).3

La disciplina de uso se reduce a tres reglas.3

  1. No ponerlos en pool.Los hilos virtuales son baratos y desechables; lo correcto es que «número de tareas = número de hilos virtuales». Meter hilos virtuales en un newFixedThreadPool es un error; se usan con la forma try (var executor = Executors.newVirtualThreadPerTaskExecutor()).
  2. El límite de ejecuciones simultáneas se expresa con Semaphore.Un límite como «hasta 10 conexiones simultáneas a una API externa» se expresa con un semáforo, no con el tamaño de un pool.
  3. No usarlos para trabajo limitado por CPU.Para paralelizar cálculos siguen siendo idóneos, como siempre, los hilos de plataforma en un número cercano al de núcleos.

Cabe señalar que dentro de un hilo virtual se ejecuta código síncrono normal. La filosofía de diseño de los hilos virtuales no es reescribir el código como hace async/await en .NET, sino permitir ejecutar en masa, tal cual, el «código sencillo de un hilo por solicitud».2

3.3. Un malentendido que conviene evitar — «no hacen falta pools» solo se aplica a los hilos virtuales

No hay que interpretar la disciplina de «no ponerlos en pool» como que «en Java no existe el mecanismo de pool de hilos (o que es ineficiente)». En realidad es justo lo contrario: los pools de Java son una herramienta madura que forma parte de la librería estándar desde JDK 5 (2004). El pool genérico y ampliamente configurable ThreadPoolExecutor (el que crean las distintas fábricas de Executors), el ForkJoinPool de tipo work-stealing (cuya instancia común commonPool() es el destino de ejecución por defecto de parallel stream y CompletableFuture), y el ScheduledThreadPoolExecutor para ejecución periódica siguen siendo, hoy en día, los protagonistas del trabajo limitado por CPU.

En origen, un pool es una optimización que responde a que «crear y mantener hilos del sistema operativo es costoso, así que conviene reutilizarlos». Los hilos virtuales eliminan esa premisa al hacer que el coste de creación sea prácticamente cero, por lo que deja de haber motivo para reutilizarlos: no es que el pool sea ineficiente, sino que se ha vuelto tan ligero que la optimización del pool ya no hace falta — esa es la lectura correcta. Es más, por debajo de los hilos virtuales, el planificador del JDK gestiona un número de hilos portadores (hilos del sistema operativo) cercano al de núcleos, funcionando como un ForkJoinPool de work-stealing.2 Es decir, se mantiene el esquema de «resolver una gran cantidad de concurrencia con un pool reducido de hilos del sistema operativo», solo que la gestión de ese pool ha pasado de manos del desarrollador a la JVM. Se puede decir que la respuesta de Java logra, sin cambiar la forma del código, el mismo punto de llegada al que llega async/await de .NET al devolver el hilo al pool en el momento del await.

4. Reducir el estado mutable compartido — dividir, hacer inmutable, colecciones concurrentes y colas

Las condiciones de carrera solo aparecen cuando se dan a la vez «varios hilos» y «datos mutables compartidos». El número de hilos lo determinan los requisitos, así que lo que el diseño puede reducir es lo compartido. Los medios se agrupan en tres familias — «dividir», «hacer inmutable» y «transferir» — y en Java se escriben así.

Dividir.En una agregación paralela, en lugar de que cada hilo escriba en una variable de total compartida, cada hilo genera un resultado parcial y estos se combinan al final. reduce / collect de parallel stream ofrecen precisamente ese esquema como marco de trabajo, y el LongAdder que se ve más adelante también es una implementación de la estrategia de dividir: «internamente reparte celdas para dispersar la contención y suma al leer». Reducir el número de escrituras sobre lo compartido viene antes que escribir bien la sincronización.

Hacer inmutable.Con record y colecciones inmutables (List.copyOf / Map.copyOf) se pueden crear «datos que no se modifican después de construirse» y compartirlos sin sincronización. Para la configuración o los datos maestros, el patrón estándar es «al reemplazarlos, crear un objeto nuevo y sustituir una referencia volatile». Ahora bien, «parecer de solo lectura» y «ser inmutable» son cosas distintas. Los accesores de un record devuelven la referencia tal cual de sus componentes, y la copia de List.copyOf también es superficial (no duplica los objetos elemento), así que si los elementos son mutables, cualquiera que tenga un alias puede modificar su contenido y la condición de carrera persiste. Solo se puede compartir sin sincronización cuando todo el grafo de objetos, elementos incluidos, es inmutable. Si contiene elementos mutables, hay que pasar una copia profunda o llevar también los elementos hacia record o tipos inmutables.

Usar las operaciones compuestas de las colecciones concurrentes.Para el «si no existe, crearlo e insertarlo» de ConcurrentHashMap se usa computeIfAbsent. Este método ejecuta toda la llamada de forma atómica y, si la clave está ausente, la función de mapeo se invoca exactamente una vez dentro de esa llamada.8 Que la garantía difiera de ConcurrentDictionary.GetOrAdd de .NET (donde la fábrica puede ejecutarse varias veces en caso de contención) es un punto que confunde fácilmente a quien va y viene entre ambos lenguajes. Sin embargo, no significa «exactamente una vez en toda la vida de la clave»: si la función devuelve null o lanza una excepción, no se registra la correspondencia y en una llamada posterior la función se vuelve a ejecutar (lo mismo ocurre si se elimina la entrada después de registrarla). Si la inicialización no admite efectos secundarios duplicados, hay que diseñarla de modo que la función termine con éxito y sin devolver null. Como contrapartida de ser atómica, mientras dura el cálculo se bloquean algunas actualizaciones de otros hilos, así que la función de mapeo debe mantenerse corta y no debe modificar este mismo mapa dentro de sí misma (una actualización recursiva puede provocar IllegalStateException).8

// Patrón estándar para un contador de frecuencias: computeIfAbsent + LongAdder
ConcurrentHashMap<String, LongAdder> freqs = new ConcurrentHashMap<>();
freqs.computeIfAbsent(key, k -> new LongAdder()).increment();

Transferir mediante una cola.El flujo de datos entre hilos se canaliza a través de BlockingQueue. Con un ArrayBlockingQueue de capacidad especificada, cuando se llena, put se bloquea y se genera una contrapresión natural, el mismo esquema que los canales acotados (bounded) de la edición .NET. Incluso en la era de los hilos virtuales, este diseño que deja claro el límite entre productor y consumidor sigue siendo válido.

5. La disciplina de los bloqueos — synchronized y ReentrantLock

5.1. Qué bloquear y qué no hacer mientras se mantiene el bloqueo

La unidad de bloqueo se piensa en función de los «datos», no de un «tramo de código». A cada conjunto de datos mutables que se quiere proteger se le asocia un objeto de bloqueo, y ese mismo bloqueo se adquiere en todos los lugares que tocan esos datos: cuando esa correspondencia se rompe, ahí está la raíz real de los errores de concurrencia. Hay que evitar synchronized(this) o synchronized(SomeClass.class), porque código externo puede bloquear el mismo objeto; en su lugar, se asocia uno a uno con los datos que se quieren proteger un private final Object lock = new Object(); que no se expone al exterior.

Se añaden dos reglas más de uso. La primera: no hacer, mientras se mantiene el bloqueo, nada que tarde o que dependa del exterior. Hacer E/S, invocar listeners o ejecutar código desconocido mientras se sostiene el bloqueo alarga el tiempo que se mantiene y, si el código llamado intenta tomar otro bloqueo, se genera la espera circular de la figura 2. La segunda: fijar el orden de adquisición cuando se toma más de un bloqueo. En los lugares donde se adquieren dos o más bloqueos, hay que convertir en regla que todos los hilos los tomen en el mismo orden, y donde no se pueda garantizar el orden, preparar con tryLock(timeout) (visto más adelante) una vía de «si no se puede tomar, soltar y reintentar».

Con synchronized basta cuando se trata de una exclusión mutua corta y simple. Cuando hace falta algo más, se pasa a ReentrantLock.

  • La adquisición con tiempo de espera mediante tryLock(timeout) (convierte un cuelgue eterno en un fallo que se puede registrar y tratar)
  • Cuando hace falta una política de equidad, varias Condition, o separar la adquisición y la liberación del bloqueo en métodos distintos

Al usar ReentrantLock, no se debe romper la forma de poner try justo después de lock() y unlock() en el finally (como no existe un equivalente a la RAII de C++, esta forma es toda la disciplina que hay).

5.2. Hilos virtuales y el «pinning» — lo que cambió en JDK 24

En los inicios de los hilos virtuales (JDK 21-23) existía la restricción de que bloquear dentro de un bloque synchronized fijaba (pinning) el hilo virtual al hilo del sistema operativo (no podía liberarlo, perdiéndose la ventaja de la escala), por lo que se recomendaba sustituirlo por ReentrantLock en los puntos con bloqueos frecuentes o prolongados.3 Esa restricción se resolvió al reescribirse la implementación de los monitores en JEP 491 de JDK 24, y synchronized dejó de fijar los hilos virtuales.4 Si se usa JDK 24 o posterior, no hace falta la sustitución mecánica como medida contra el pinning. Vale la pena comprobar si las guías internas antiguas siguen ancladas en la advertencia de la época de JDK 21.

5.3. El lugar de la familia Atomic y de volatile

La actualización atómica de una sola variable la cubren AtomicInteger / AtomicLong / AtomicReference (y, para una estadística que solo se incrementa con mucha frecuencia, el más resistente a la contención LongAdder). volatile garantiza visibilidad y orden (happens-before), pero no da atomicidad a las operaciones compuestas.5 La misma conclusión de las ediciones .NET y C++ vale también para Java: para banderas y valores individuales, la familia Atomic; para el estado compuesto, bloqueos; y no intentar arreglárselas solo con volatile.

6. Diseñar cómo detener — la interrupción como lenguaje común

6.1. El protocolo de interrupt

En Java, la parada y la cancelación están unificadas bajo la interrupción (interruption). t.interrupt() activa el estado de interrupción del hilo objetivo y, si este está bloqueado en sleep / wait / join, etc., lanza InterruptedException para despertarlo de inmediato (momento en el que el estado de interrupción se limpia).6 Los antiguos mecanismos forzados Thread.stop / suspend / resume son intrínsecamente inseguros, por lo que ahora, al invocarlos, provocan UnsupportedOperationException.6

Puede terminar por sí mismoNo puede terminar (p. ej. dentro de una librería)Quien detiene: t.interrupt()Se activa el estado de interrupciónHilo en cómputo:revisa Thread.interrupted() en un bucleBloqueado en sleep / wait / join:se lanza InterruptedException y despierta de inmediato(el estado se limpia)Hace la limpieza y termina por sí mismo¿Qué hacer en el catch?Thread.currentThread().interrupt()restaura el estado y deja la señal

Figura 4: la parada cooperativa mediante interrupción. Si se traga InterruptedException, la señal de parada desaparece — al capturarla solo hay dos opciones: «terminar» o «restaurar».

Basta con recordar una única regla práctica: no escribir código que capture InterruptedException y no haga nada.Si se puede terminar dentro de la propia responsabilidad, se termina ahí; si no se puede, se restaura el estado con Thread.currentThread().interrupt() y se transmite la señal a quien llamó (véase las preguntas frecuentes).

6.2. El apagado en dos fases de ExecutorService

La API de parada de ExecutorService está construida sobre el modelo de interrupción. shutdown() deja de admitir tareas nuevas y permite que las ya enviadas terminen; shutdownNow() intenta detener las tareas en ejecución. Como especificación de interfaz, esto es un mejor esfuerzo (best effort), y se indica explícitamente que la implementación estándar (ThreadPoolExecutor, etc.) cancela típicamente mediante Thread.interrupt() — es decir, las tareas que no responden a la interrupción tampoco se detienen con shutdownNow, y si se usa un Executor de implementación propia, hay que comprobar en su documentación cómo cancela (si envía o no una interrupción).1 El patrón estándar de parada que muestra la documentación oficial es el siguiente esquema en dos fases.1

/** true si la parada terminó. Si sigue en false, no hay que continuar liberando los recursos compartidos. */
boolean shutdownAndAwaitTermination(ExecutorService pool) {
    pool.shutdown();                    // Fase 1: deja de admitir tareas nuevas y espera a que terminen
    try {
        if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
            // Fase 2: solicita la cancelación. Se devuelven las tareas que quedaron sin ejecutar,
            // así que se marcan esos Future como cancelados para despertar a quien esperaba en get()
            pool.shutdownNow().forEach(r -> {
                if (r instanceof Future<?> f) f.cancel(false);
            });
            if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
                System.err.println("Pool did not terminate");
                return false;           // La parada no se completó. Hay que distinguirlo de un éxito
            }
        }
        return true;
    } catch (InterruptedException ex) {
        pool.shutdownNow().forEach(r -> {
            if (r instanceof Future<?> f) f.cancel(false);
        });
        Thread.currentThread().interrupt();   // Restaura también el propio estado de interrupción
        return false;                   // Esta ruta también puede dejar la parada incompleta
    }
}
Termina dentro del plazoTiempo agotadoTerminaSigue sin terminarshutdown()deja de admitir tareas nuevasawaitTerminationespera a que terminenParada completashutdownNow()envía interrupt a las tareas en curso(que respondan depende de cada tarea)awaitTerminationespera de nuevoSe registra como anomalía(sospechosas: tareas que no responden a interrupt)

Figura 5: el apagado en dos fases de ExecutorService. Un diseño escalonado: «esperar con calma → exigir mediante interrupción → si aun así no termina, registrarlo como anomalía».

Cabe señalar que la cancelación de los Runnable que devuelve shutdownNow() tiene un límite. Lo que se devuelve son los objetos que estaban en la cola de ejecución: con un submit normal, ese objeto es el propio FutureTask que se entregó al usuario, pero en las tareas enviadas a través de un envoltorio como ExecutorCompletionService, es el envoltorio de la cola, distinto del Future del usuario. En esa configuración, la cancelación anterior no completa el Future del lado del usuario, así que hay que diseñarlo manteniendo, al enviar las tareas, una lista propia de Future y cancelándola en el momento del apagado (o devolviendo al propietario las tareas que quedaron sin ejecutar).

El close() (AutoCloseable) de JDK 19 en adelante es la forma de escribir «hacer shutdown y esperar hasta completar» mediante try-with-resources, y la forma básica actual es try (var executor = ...) combinado con el newVirtualThreadPerTaskExecutor de los hilos virtuales.1 Sin embargo, close() no sustituye al patrón en dos fases anterior. Como espera la finalización sin tiempo de espera, si hay una sola tarea que no responde a la interrupción o que no termina, el hilo que intenta cerrar queda bloqueado para siempre. Es una herramienta adecuada para escenarios donde las tareas dentro del alcance son finitas y se garantiza que terminarán (el uso de «lanzar ahí mismo y esperar ahí mismo»); para lugares donde se quiere terminar siempre en un tiempo finito, como la ruta de apagado de una aplicación, use el patrón en dos fases con plazo. La cancelación de una tarea individual la realiza Future.cancel(true), también mediante interrupción.

7. El hilo de la interfaz de usuario — el EDT de Swing

En las aplicaciones de escritorio existe una regla, sin importar el lenguaje o el framework: «la interfaz de usuario es propiedad exclusiva del hilo que la gestiona». En Swing, ese hilo exclusivo es el hilo de despacho de eventos (EDT): los métodos de los componentes Swing no son, en principio, seguros para hilos, y tocarlos desde varios hilos provoca interferencia entre hilos y errores de consistencia de memoria. Las actualizaciones de pantalla desde otro hilo se solicitan al EDT con SwingUtilities.invokeLater, y a la inversa, si se hace un procesamiento largo en el propio EDT la interfaz se congela, así que el trabajo pesado se delega a un hilo de trabajo con SwingWorker u otro mecanismo similar.7 En JavaFX el esquema es el mismo: las actualizaciones de la interfaz se solicitan al hilo de la aplicación con Platform.runLater.

8. Verificación y depuración — el volcado de hilos como arma

No se puede esperar detectar errores de concurrencia con las pruebas habituales, porque una prueba normal cuenta como éxito una ejecución en la que, por casualidad, no hubo condición de carrera. Conviene pensar la preparación en tres capas.

La primera línea de defensa es el diseño.En la revisión se comprueba, en forma de tabla, cuáles son los datos mutables compartidos, qué bloqueo protege a cada uno (la correspondencia de la sección 5.1), si el orden de adquisición de los bloqueos es único, si no hay ningún catch que trague InterruptedException, y si la ruta de parada (shutdown / interrupción) llega a todas las tareas.

En segundo lugar, dominar el volcado de hilos.Java cuenta con una herramienta estándar para capturar el «estado congelado de los hilos en este mismo instante»: jstack (o jcmd <pid> Thread.print) imprime las trazas de pila, y con la opción -l se puede sacar también información adicional sobre los bloqueos.9 Como advertencia, este formato de volcado tradicional está pensado para hilos de plataforma y no incluye los hilos virtuales de la aplicación. Al rastrear una solicitud bloqueada en una configuración que usa hilos virtuales (capítulo 3), use jcmd <pid> Thread.dump_to_file -format=json <archivo>, que sí puede volcar también los hilos virtuales.2 El procedimiento básico para investigar un cuelgue es tomar dos o tres volcados con unos segundos de diferencia y cruzar qué bloqueo está esperando el hilo que no avanza con quién lo tiene tomado. Si se registran en el log los agotamientos de tiempo de tryLock(timeout) (sección 5.2), incluso se puede automatizar el disparo de la toma de volcados.

En tercer lugar, sacudirlo con carga.Una prueba de estrés que ejecute durante un tiempo prolongado con un paralelismo mayor que el número de núcleos, aleatorice el orden de procesamiento e inserte retardos artificiales es un medio realista para que sea más fácil «acertar» con una condición de carrera en la máquina de desarrollo. Antes de publicar, haga pasar al menos una vez una prueba con un volumen de datos y un número de hilos equivalentes a producción.

9. El futuro de la concurrencia en Java — la concurrencia estructurada

Para terminar, un vistazo medio paso más allá. Sobre la base de los hilos virtuales, está en desarrollo la concurrencia estructurada (Structured Concurrency) (StructuredTaskScope), que «trata varias subtareas como una sola unidad de trabajo y estructura la propagación de fallos y la cancelación»; en agosto de 2026 sigue siendo una funcionalidad en preview. En la quinta vista previa de JDK 25 (JEP 505) se reformuló hacia la forma de API con StructuredTaskScope.open(), y en el JDK 26 actual continúa como sexta vista previa (JEP 525).1011 Por otro lado, Scoped Values, el mecanismo de compartición de contexto inmutable que resuelve los problemas de ThreadLocal, se convirtió en funcionalidad definitiva en JDK 25.12 Los principios de este artículo — delimitar claramente los límites de las tareas, compartir de forma inmutable y detener de manera cooperativa — coinciden con la dirección hacia la que apuntan estas nuevas API.

10. Resumen — lista de verificación para Java

  1. ¿Queda algún new Thread en el código de negocio? (¿todo está sobre ExecutorService o hilos virtuales?)
  2. ¿Se distingue el mecanismo de ejecución entre lo limitado por E/S y lo limitado por CPU? (la bifurcación de la figura 3)
  3. ¿No se están poniendo los hilos virtuales en pool, y se expresa el límite de concurrencia con Semaphore?
  4. ¿Los datos compartidos son inmutables (record / List.copyOf) o están sobre las herramientas de java.util.concurrent?
  5. ¿No hay ningún synchronized(this) ni bloqueo sobre un objeto público?
  6. ¿Se usan las operaciones compuestas de ConcurrentHashMap (computeIfAbsent, etc.) y se mantiene corta la función de mapeo?
  7. ¿No se espera atomicidad de volatile? (¿los contadores usan la familia Atomic o LongAdder?)
  8. ¿No hay ni un solo catch que trague InterruptedException?
  9. ¿La parada de ExecutorService sigue el patrón en dos fases con plazo? (¿el uso de close() se limita a los ámbitos donde se garantiza que las tareas terminan?)
  10. ¿Las actualizaciones de la interfaz de Swing/JavaFX están concentradas en el EDT o en el hilo de la aplicación?

Java es uno de los lenguajes con las herramientas de concurrencia mejor desarrolladas, y con la llegada de los hilos virtuales también se abrió el camino de «escalar tal cual el código síncrono sencillo». Precisamente por eso, entender correctamente el reparto de roles de las herramientas — cuál es la herramienta para el rendimiento, cuál para la exclusión mutua y cuál es la señal de parada — es la esencia del diseño del multithreading en Java.

Artículos relacionados

Áreas de consultoría relacionadas

KomuraSoft LLC ofrece revisión del diseño de multithreading para sistemas empresariales y procesos por lotes escritos en Java, investigación de fallos derivados de la concurrencia — como corrupción de estado compartido o el típico «a veces no se detiene / se queda colgado» (mediante análisis de volcados de hilos) — y asesoría técnica para la introducción de hilos virtuales.

Referencias

  1. Oracle, ExecutorService (Java SE 21 & JDK 21 API). Sobre que shutdown() deja completar las tareas ya admitidas mientras rechaza las nuevas; que shutdownNow() intenta detener las tareas en ejecución y devuelve la lista de las que esperaban, pero la implementación típica cancela vía Thread.interrupt(), lo cual es solo un mejor esfuerzo sin garantía adicional, de modo que las tareas que no responden a la interrupción no terminan; que awaitTermination permite esperar la finalización; que close() (desde Java 19, AutoCloseable) hace shutdown y espera la finalización, utilizable con try-with-resources; y que se muestra como ejemplo de uso el apagado en dos fases shutdown → awaitTermination → shutdownNow.  2 3 4 5 6

  2. OpenJDK, JEP 444: Virtual Threads. Sobre que los hilos virtuales se convirtieron en una funcionalidad oficial en JDK 21; que son hilos ligeros que reducen notablemente el esfuerzo de escribir, mantener y observar aplicaciones concurrentes de alto rendimiento; la filosofía de diseño de escalar tal cual el código síncrono sencillo de «un hilo por solicitud»; que el planificador de hilos virtuales del JDK es un ForkJoinPool de robo de trabajo (work-stealing) en modo FIFO, cuyo paralelismo por defecto es el número de procesadores disponibles; que se añadió un nuevo formato de volcado de hilos que incluye los hilos virtuales mediante jcmd Thread.dump_to_file (en texto plano y en JSON); y que los volcados de hilos tradicionales no incluyen los hilos virtuales.  2 3 4 5

  3. Oracle Java SE Core Libraries, Virtual Threads. Sobre que los hilos virtuales están implementados por el runtime de Java y liberan el hilo del sistema operativo durante la E/S bloqueante; que es una funcionalidad para la escala (rendimiento), no para la velocidad (latencia), y no es adecuada para procesamiento intensivo en CPU; que nunca deben ponerse en pool, sino usarse uno por tarea (newVirtualThreadPerTaskExecutor); que el límite de ejecuciones simultáneas se controla con Semaphore y no con un pool de hilos; que, en JDK 21, bloquear dentro de un synchronized fijaba (pinning) el hilo virtual al hilo del sistema operativo, por lo que se recomendaba sustituirlo por ReentrantLock en los bloqueos frecuentes o prolongados; y que -Djdk.tracePinnedThreads permite detectar ese pinning.  2 3 4 5 6 7

  4. OpenJDK, JEP 491: Synchronize Virtual Threads without Pinning. Sobre que en JDK 24 se reescribió la implementación de los monitores de la JVM para dar soporte a los hilos virtuales, de modo que bloquear dentro de un bloque o método synchronized ya no fija el hilo virtual al hilo portador (carrier thread); y que, por ello, la medida de la era JDK 21-23 de sustituir synchronized por ReentrantLock deja de ser necesaria en principio.  2

  5. Oracle, The Java Tutorials, Memory Consistency Errors. Sobre que los errores de consistencia de memoria surgen cuando varios hilos tienen una visión incoherente de los mismos datos; que la clave para evitarlos es la relación happens-before (la garantía de que una escritura en memoria hecha por una sentencia es visible para otra sentencia); y que synchronized, volatile, Thread.start / join, entre otros, establecen relaciones happens-before.  2 3

  6. Oracle, Thread (Java SE 21 & JDK 21 API). Sobre que Thread.stop / suspend / resume son intrínsecamente inseguros (suspend puede provocar interbloqueos y stop libera los bloqueos en un estado inconsistente dejando objetos corruptos visibles para otros hilos) y están obsoletos con vistas a su eliminación, por lo que actualmente lanzan UnsupportedOperationException al invocarlos; que interrupt() activa el estado de interrupción y, en un hilo bloqueado en sleep / wait / join, lanza InterruptedException para despertarlo (momento en el que ese estado se limpia); y sobre la diferencia de tratamiento del estado entre interrupted() e isInterrupted().  2 3

  7. Oracle, The Java Tutorials, The Event Dispatch Thread. Sobre que el código de manejo de eventos de Swing se ejecuta en el hilo de despacho de eventos (EDT); que la mayoría de los métodos de los objetos Swing no son seguros para hilos y llamarlos desde varios hilos puede provocar interferencia entre hilos y errores de consistencia de memoria, por lo que en principio el acceso a los componentes Swing debe hacerse desde el EDT; que, desde otros hilos, se solicitan tareas al EDT con SwingUtilities.invokeLater / invokeAndWait; y que las tareas ejecutadas en el EDT deben ser breves.  2

  8. Oracle, ConcurrentHashMap (Java SE 21 & JDK 21 API). Sobre que toda la llamada a computeIfAbsent se ejecuta de forma atómica y, cuando la clave está ausente, la función de mapeo se invoca exactamente una vez; que, mientras dura el cálculo, se bloquean algunas operaciones de actualización de otros hilos, por lo que el cálculo debe mantenerse corto y simple; que la función de mapeo no debe modificar este mismo mapa, y que una actualización recursiva detectable lanza IllegalStateException; que la operación de lectura (get) no bloquea; y que existe una relación happens-before entre una actualización sobre una clave y una lectura posterior de esa misma clave.  2 3

  9. Oracle, The jstack Command (Java SE 21 Tools Reference). Sobre que jstack imprime las trazas de pila (nombre de clase, de método y número de línea) de todos los hilos del proceso Java indicado; que la opción -l añade información detallada sobre los bloqueos; y que se utiliza junto con otras herramientas de diagnóstico como jcmd. 

  10. OpenJDK, JEP 505: Structured Concurrency (Fifth Preview). Sobre que la API de concurrencia estructurada trata un grupo de subtareas relacionadas como una sola unidad de trabajo, estructurando la propagación de errores y la cancelación; que StructuredTaskScope se reformuló para abrirse mediante el método de fábrica estático open; y que, en JDK 25, se trata de la quinta vista previa (preview), aún no una funcionalidad definitiva. 

  11. OpenJDK, JEP 525: Structured Concurrency (Sixth Preview). Sobre que la concurrencia estructurada continúa como sexta vista previa en JDK 26, es decir, que en agosto de 2026 sigue siendo una funcionalidad en preview incluso en el JDK vigente, y que su uso requiere habilitar las funciones en preview. 

  12. OpenJDK, JEP 506: Scoped Values. Sobre que Scoped Values se convirtió en funcionalidad definitiva en JDK 25; que es un mecanismo para compartir datos de contexto inmutables de forma segura y eficiente, tanto dentro de un hilo como entre hilos; y que es la solución a los problemas de ThreadLocal (mutabilidad, gestión del ciclo de vida y coste de la herencia). 

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

Si existen los hilos virtuales, ¿ya no hace falta el pool de hilos (ExecutorService)?
Depende del uso. Los hilos virtuales son un mecanismo para ejecutar en masa tareas dominadas por la espera de E/S; no aceleran el código, sino que aumentan el rendimiento (throughput). Para trabajo limitado por E/S se usa un hilo virtual por tarea (Executors.newVirtualThreadPerTaskExecutor) y nunca se deben poner en pool. En cambio, para paralelizar cálculos que agotan la CPU sigue siendo adecuado, como siempre, un pool de hilos de plataforma acotado a un número cercano al de núcleos (o parallel stream). Además, si se quiere limitar el número de accesos simultáneos a un servicio externo, la recomendación de la era de los hilos virtuales es restringirlo con Semaphore, no con el tamaño de un pool.
¿Debería usar synchronized o ReentrantLock?
Para una exclusión corta y simple, synchronized es suficiente y el código queda más conciso. Se elige ReentrantLock cuando hacen falta funciones como la adquisición con tiempo de espera mediante tryLock, una política de equidad o varias Condition. Hay una advertencia histórica sobre su combinación con los hilos virtuales: en JDK 21-23 existía el problema de que bloquear dentro de un bloque synchronized fijaba (pinning) el hilo virtual al hilo del sistema operativo, por lo que se recomendaba sustituirlo por ReentrantLock en los puntos con bloqueos frecuentes o prolongados. En JDK 24 (JEP 491) se reescribió la implementación de los monitores y esa restricción quedó resuelta. Desde JDK 24 en adelante, ya no hace falta la sustitución por motivo de pinning.
¿Basta con poner volatile para que el código sea seguro para hilos (thread-safe)?
No. El volatile de Java crea una relación happens-before entre la escritura y la lectura de esa variable, garantizando la visibilidad (que otros hilos vean la última escritura) y el orden, pero no garantiza la atomicidad de una operación compuesta como «leer, calcular y volver a escribir». Si varios hilos hacen ++ sobre un contador volatile int, se pierden incrementos. Para contadores use AtomicInteger / AtomicLong (o LongAdder para agregaciones de alta frecuencia), y para proteger varias variables en conjunto, use bloqueos. volatile es adecuado casi exclusivamente para casos como una simple bandera de estado, donde «un hilo escribe y los demás solo leen».
¿Está bien capturar InterruptedException con catch e ignorarla?
No. La interrupción es la señal estándar de parada y cancelación en Java, y tragársela crea «hilos que no se detienen». En el momento en que se lanza InterruptedException, el estado de interrupción ya se ha limpiado, así que si no se puede terminar el proceso por cuenta propia, hay que restaurar el estado con Thread.currentThread().interrupt() para dejar la señal a quien llamó, o bien relanzar la excepción tal cual hacia arriba. Un catch vacío que no hace nada es una causa típica de fallos como que el apagado no surta efecto o que shutdownNow sea ignorado.
¿No se puede detener un hilo con Thread.stop?
No se puede. Thread.stop es intrínsecamente inseguro (libera los bloqueos en un estado inconsistente y deja visibles objetos corruptos a otros hilos), por lo que lleva mucho tiempo obsoleto, y en las versiones actuales de Java, invocarlo lanza UnsupportedOperationException. Lo mismo ocurre con Thread.suspend / resume. El único medio legítimo para detener un hilo es la parada cooperativa mediante interrupción (interrupt). Si se usa ExecutorService, shutdown solo deja de admitir tareas nuevas y espera a que terminen las que ya se enviaron, sin enviar interrupción a las tareas en ejecución. El que intenta detener las tareas en ejecución es shutdownNow, y este es un mejor esfuerzo (en la implementación estándar, vía interrupción).

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog