Buenas prácticas de multithreading en la práctica: edición Java — convenciones de la era de los hilos virtuales

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

Historial de revisiones (primera versión, publicada el 2 Aug 2026)
Primera publicación
Citar este artículo(DOI (archivo registrado): 10.5281/zenodo.22175924)

Los DOI siguientes remiten a versiones ya archivadas y pueden diferir del texto actual. Para citar el texto actual, utilice la URL de esta página.

Go Komura (2026). Buenas prácticas de multithreading en la práctica: edición Java — convenciones de la era de los hilos virtuales. KomuraSoft LLC. https://comcomponent.com/es/blog/multithreading-best-practices-java/

DOI (archivo registrado)
10.5281/zenodo.22175924
DOI (última versión registrada)
10.5281/zenodo.22175925

«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». Aunque todas son consultas de multithreading, el problema de elegir el medio de ejecución, el de proteger los datos compartidos y el de la IU o de cómo detener hay que pensarlos por separado.

Java incorporó el multithreading al lenguaje desde JDK 1.0 y ha reunido un conjunto maduro de herramientas en java.util.concurrent. En JDK 21 los hilos virtuales también se convirtieron en una funcionalidad oficial. Precisamente porque las herramientas son abundantes, el punto clave del diseño es elegir en este orden: qué ejecutar en paralelo, qué compartir y cómo detener.1

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, y tomando como objetivo principal el LTS JDK 21 en adelante, organiza principios y advertencias a partir de fuentes primarias vigentes en agosto de 2026. Se puede leer de forma independiente. Los mismos principios, desarrollados en otros lenguajes, están en la «edición .NET», la «edición C++» y la «edición C».

1. Primero la conclusión: diseñar por separado la ejecución, el compartir y la detención

Elegir hilos virtuales no resuelve por sí solo la contención sobre datos compartidos ni el camino de detención. En el código de negocio no se crean hilos de forma directa: la ejecución de las tareas se deja a ExecutorService y, a partir de ahí, se diseña en este orden.2

Qué decidir Política básica Dónde leer más
Con qué ejecutar Para la espera de E/S, un hilo virtual por tarea; para el cálculo de CPU, hilos de plataforma en un número cercano al de núcleos Capítulo 2
Hasta dónde aceptar El número simultáneo hacia un servicio externo, con Semaphore; para el trabajo que se acumula, un tope de capacidad o de admisión Capítulos 2 y 3
Qué compartir Dividir en resultados parciales y usar datos inmutables, colecciones concurrentes y colas Capítulo 3
Cómo proteger el estado Actualización atómica de un valor único con las clases Atomic; estado compuesto, con un bloqueo dedicado. No esperar atomicidad de volatile Capítulo 4
Cómo detener Combinar la parada cooperativa por interrupción con una comprobación de finalización acotada en el tiempo Capítulos 5 y 6
Cómo tratar la IU y la investigación La IU, en su hilo dedicado. El volcado se elige según se trate de hilos de plataforma o virtuales Capítulos 7 y 8

Si se lee desde el principio, se sigue el orden de esta tabla. Si ahora se investiga «no se detiene», se empieza por los capítulos 5 y 6; si «se quedó colgado», por el 8. Las advertencias de pinning, que difieren entre JDK 21-23 y JDK 24 en adelante, están en el apartado 2.4; la distinción entre funciones en preview y las oficiales, en el capítulo 9.

En el diagrama, una línea continua marca una relación que siempre se cumple y una línea discontinua una relación condicional (las condiciones están en la explicación de cada relación en la página de detalle). La lista completa de relaciones (28 en total, con evidencia y grado de certeza) y las definiciones de los conceptos principales están reunidas en la página de detalle del mapa de conocimiento (en japonés). Datos: JSON-LD / Turtle

2. Elegir el medio de ejecución: separar la espera de E/S del cálculo de CPU

2.1. Entregar el trabajo a ExecutorService

También en Java, la regla básica es no esparcir new Thread por el código de negocio. Se separa el «trabajo», expresado como Runnable / Callable, del «modo de ejecución» (número de hilos, cola, etc.) y la creación, reutilización y destrucción de hilos se deja a ExecutorService.2

El medio de ejecución se elige primero según qué domina el trabajo.13

Espera de E/Sllamadas HTTP, BD, archivosCálculo que usa la CPUHay una tarea que se quiere ejecutar en paralelo¿Qué domina la tarea?Hilos virtualesExecutors.newVirtualThreadPerTaskExecutor()Uno por tarea. No se ponen en poolPool fijo de hilos de plataformaExecutors.newFixedThreadPool(cerca del número de núcleos)o parallel streamEl límite de llamadas simultáneas a un servicio externose expresa con Semaphore, no con un pool

Figura 1: Para la espera de E/S se eligen hilos virtuales; para el cálculo de CPU, un pool convencional. El límite de accesos simultáneos se diseña por separado.

Los hilos virtuales no son «hilos rápidos». No aumentan la velocidad de ejecución del cálculo en sí: son una herramienta para tratar en paralelo una gran cantidad de trabajo con mucha espera y subir el rendimiento (throughput). Para un cálculo que satura la CPU se usa, como hasta ahora, un pool fijo de hilos de plataforma en un número cercano al de núcleos, o un parallel stream.3

2.2. Los hilos virtuales no se ponen en pool; el recurso que se quiere limitar se protege con Semaphore

Los hilos virtuales son hilos ligeros desacoplados de los hilos del sistema operativo. Como durante las operaciones de bloqueo que el JDK admite (E/S, bloqueos, sleep, etc.) pueden soltar el hilo del SO, alcanzan una concurrencia suficiente para que una sola JVM gestione millones. No todos los bloqueos permiten soltarlo, sin embargo. Las condiciones de pinning se explican por separado en el apartado 2.4.3

La forma de usarlos es un hilo virtual por tarea. Se tratan como desechables y baratos; no se diseña su reutilización metiendo hilos virtuales en un newFixedThreadPool. Se usa Executors.newVirtualThreadPerTaskExecutor().13

Por otro lado, un límite como «como máximo 10 conexiones simultáneas a la API externa» sí hace falta. Ese tope se expresa con un Semaphore, no con el tamaño de un pool de hilos virtuales. No poner los hilos virtuales en pool y poder acceder sin límite a un servicio externo son cosas distintas.3

Lo que corre dentro de un hilo virtual es código síncrono ordinario. La filosofía de diseño es poder ejecutar tal cual, a gran escala, el código sencillo de «un hilo por solicitud», sin reescribirlo a una forma como async/await de .NET. También se puede usar la forma try (var executor = Executors.newVirtualThreadPerTaskExecutor()), pero hasta dónde espera close() al terminar se comprueba en el apartado 6.3.12

2.3. En un pool de hilos de plataforma, pensar también un tope para la cola

«No poner en pool» es una historia de hilos virtuales. Java tiene pools de hilos maduros desde JDK 5 (2004) y hoy se siguen eligiendo según el uso.

ThreadPoolExecutor es un pool de propósito general cuyo número de hilos, cola, política de rechazo, etc. se pueden configurar con detalle. ForkJoinPool es de tipo work-stealing, y su commonPool() se usa como destino asíncrono por defecto de parallel stream y CompletableFuture. Para ejecución periódica está ScheduledThreadPoolExecutor. En los casos en que se reutilizan hilos de plataforma, como el cálculo de CPU, siguen siendo herramientas importantes.

Sin embargo, lo que Executors.newFixedThreadPool limita es el número de hilos; la cola no tiene límite. En un servicio residente donde las admisiones siguen superando el procesamiento, las tareas en espera y sus datos no dejan de consumir memoria. O se configura ThreadPoolExecutor con una cola acotada y una política de rechazo, o se coloca en el lado que admite un control de entrada como un Semaphore, de modo que se pueda aplicar contrapresión (backpressure). La cola del apartado 3.6 usa el mismo principio.

Un pool es una optimización para reutilizar hilos del SO, caros de crear y de mantener. Los hilos virtuales son lo bastante ligeros como para que el desarrollador ya no tenga motivo para reutilizarlos. Eso no significa que los pools se hayan vuelto ineficientes.

De hecho, debajo de los hilos virtuales el planificador del JDK usa un ForkJoinPool de work-stealing y, por defecto, opera tantos hilos portadores (hilos del SO) como procesadores disponibles. El esquema de tratar una gran cantidad de trabajo concurrente con pocos hilos del SO es el mismo; la gestión la asume la JVM. Resulta más claro pensar que Java avanza en la misma dirección que la E/S asíncrona de .NET, que no sigue ocupando un hilo durante la espera, manteniendo la forma del código síncrono.1

2.4. Pensar el pinning según la versión del JDK y según dónde se bloquea

Se llama pinning al estado en el que el hilo virtual no puede apartarse de su hilo portador y el hilo del SO también sigue esperando. Un pinning frecuente o prolongado erosiona la ventaja de escala de los hilos virtuales.3

Dónde se bloquea JDK 21-23 JDK 24 en adelante
Dentro de un bloque o método synchronized Se produce pinning. En los puntos con bloqueos frecuentes o prolongados, considerar el reemplazo por ReentrantLock JEP 491 resolvió esta restricción. El reemplazo mecánico solo como contramedida de pinning ya no hace falta
Durante la ejecución de código nativo (JNI) o de una foreign function Puede que no se pueda soltar el hilo portador Es distinto de la mejora de synchronized; el pinning en el límite nativo permanece

Lo que JEP 491 de JDK 24 resolvió es el pinning de synchronized originado en la implementación de los monitores. Si se cargan en masa operaciones que bloquean largo tiempo en controladores o API de dispositivos vía JNI, el problema de agotar los hilos portadores permanece. No se debe pensar «como es JDK 24, da igual dónde se espere». También conviene comprobar que las directrices internas no sigan ancladas en la advertencia de la época de JDK 21.43

3. Reducir el estado mutable compartido: revisar el compartir antes de escribir sincronización

3.1. Incluso con count++, si las operaciones se solapan se pierde el incremento

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 al código. count++ parece una sola expresión, pero se descompone en lectura, suma y escritura de vuelta. Si otro hilo se cuela en medio, se pierde uno de los incrementos.

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 en local(11)Suma en local(11)Escritura de vuelta(11)Escritura de vuelta(11)

Figura 2: Si dos hilos leen el mismo valor y luego escriben de vuelta, de dos incrementos solo queda uno.

El otro caso típico es el interbloqueo (deadlock), en el que los hilos se esperan mutuamente por el bloqueo del otro y no pueden avanzar. Se trata en el apartado 4.3. Ambos dependen del momento, así que lo que es raro en la máquina de desarrollo puede ser frecuente en producción, donde el número de núcleos y la carga son distintos. También dejan de reproducirse al añadir un depurador o registros, porque la observación cambia el momento.

Por eso se empieza por reducir los lugares que necesitan sincronización antes de sincronizar correctamente. Si el requisito es funcionar en varios hilos, lo que el diseño debe recortar es el dato mutable compartido.

3.2. El modelo de memoria de Java define cuándo otra escritura es visible para otros hilos

En Java, cómo se ven los datos compartidos está definido por la relación happens-before del modelo de memoria de Java (JMM). Acceder a una variable compartida sin sincronización no es comportamiento indefinido como en C++, pero un valor antiguo puede seguir viéndose, o las escrituras pueden verse reordenadas.5

Por ejemplo, si en un bucle solo se comprueba una bandera boolean, el valor que cambió otro hilo puede no hacerse visible nunca. Esto no es un error de la JVM: es un error de consistencia de memoria causado por no hacer la sincronización necesaria.

Quienes crean happens-before son synchronized, volatile y las clases de java.util.concurrent. Las colecciones concurrentes también garantizan, como parte de su especificación, la relación entre una actualización y una obtención posterior de ese valor. En lugar de ingeniar con una variable compartida en crudo, se usan las garantías de estas herramientas. Ahora bien, que un valor sea visible y que varias operaciones se ejecuten como un todo son cosas distintas. La diferencia con la atomicidad se organiza en el apartado 4.1.56

3.3. Dividir la agregación en resultados parciales y reunirlos al final

En una agregación paralela, antes de que todos los hilos escriban en una sola variable de total, se piensa primero en que cada hilo construya un resultado parcial y se sumen al final. Las operaciones reduce / collect de parallel stream ofrecen esta estructura como marco.

LongAdder, usado para incrementos de alta frecuencia, es también una estrategia de repartir las actualizaciones en celdas internas y sumarlas al leer. Primero se reducen las escrituras al estado compartido y después se elige la sincronización necesaria.

3.4. Si se vuelve inmutable, comprobar hasta dentro de los elementos

La configuración y los datos maestros se comparten como datos inmutables que no se reescriben tras construirse. Para sustituirlos, la práctica establecida es crear un objeto nuevo y cambiar una referencia volatile.

Sin embargo, usar record o List.copyOf no hace inmutable el dato en su conjunto. Los accesores de un record devuelven tal cual las referencias a sus componentes. Aunque List.copyOf / Map.copyOf impidan modificar la colección, los objetos elemento no se copian en profundidad.

Si los elementos son mutables, otro código que tenga una referencia al mismo elemento puede reescribir su contenido y la contención permanece. Para compartir como dato inmutable sin sincronización, hay que confirmar que todo el grafo de objetos, elementos incluidos, es inmutable. Si hay elementos mutables, se corta el compartir con una copia profunda o se acercan los elementos a record / tipos inmutables. Hay que distinguir «parece de solo lectura» de «es inmutable».

3.5. Usar las operaciones compuestas de ConcurrentHashMap y conocer el alcance de su garantía

«Si la clave no está, crearla e insertarla» no se escribe como comprobación e inserción por separado: se usa ConcurrentHashMap.computeIfAbsent. Toda la llamada al método se ejecuta de forma atómica y, si la clave está ausente, la función de mapeo se invoca exactamente una vez dentro de esa única llamada. La garantía es distinta de la de ConcurrentDictionary.GetOrAdd de .NET, cuya fábrica puede ejecutarse más de una vez bajo contención.6

Para un contador de frecuencia, la siguiente forma es la práctica establecida.

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

«Una vez en esa llamada» y «una vez en la vida de esa clave» no son lo mismo. Si la función devuelve null o lanza una excepción, no se registra nada y una llamada posterior la vuelve a ejecutar. Lo mismo ocurre si se elimina una entrada ya registrada. En una inicialización que no admite efectos secundarios duplicados, se diseña para que tenga éxito devolviendo un valor no nulo, e incluyendo el tratamiento de las entradas.6

Además, a cambio de ejecutarse de forma atómica, durante el cálculo se bloquean algunas actualizaciones de otros hilos. La función de mapeo se mantiene corta y simple, y no debe actualizar este mismo mapa desde dentro. Una actualización recursiva detectable puede resultar en IllegalStateException.6

3.6. Para el traspaso entre hilos, usar una cola con capacidad

En lugar de que varios hilos toquen los datos de forma directa, se traspasan a través de una BlockingQueue. Con un ArrayBlockingQueue al que se le da una capacidad, put bloquea cuando la cola está llena y aplica contrapresión de forma natural al lado que produce.

Es el mismo esquema que el canal acotado de la edición .NET. Incluso con hilos virtuales, un diseño que deja explícitos el límite entre productor y consumidor y el tope de lo acumulado sigue siendo eficaz.

4. Proteger el estado compartido que queda: distinguir volatile, las clases Atomic y los bloqueos

4.1. Visibilidad y atomicidad son garantías distintas

volatile es una herramienta para la visibilidad y el orden, es decir, para happens-before. No es la garantía de que «leer, calcular y escribir de vuelta» ocurran como un todo, así que aplicar ++ a un volatile int desde varios hilos no evita la pérdida de incremento de la figura 2.5

Qué proteger Herramienta a elegir Precaución
Notificación simple de estado, cambio de una referencia a datos inmutables volatile No se obtiene atomicidad de una operación compuesta
Actualización atómica de un valor único AtomicInteger / AtomicLong / AtomicReference Para estadísticas que se incrementan con alta frecuencia, también se usa LongAdder
Consistencia que abarca varios valores Un bloqueo Guardar la misma disciplina en todos los sitios que tocan los mismos datos

Igual que en las ediciones .NET y C++, las actualizaciones atómicas se asignan a las clases Atomic y el estado compuesto a los bloqueos. Lo importante es no intentar que algo sea seguro para hilos solo con volatile.

4.2. Correspondencia entre bloqueos y los datos que protegen, no con tramos de código

A cada conjunto de datos mutables que se quiere proteger se le hace corresponder un objeto de bloqueo dedicado. Y se toma ese mismo bloqueo en todos los sitios que tocan esos datos. En la revisión debe poder comprobarse «qué bloqueo protege estos datos».

Se evitan synchronized(this), synchronized(SomeClass.class) y el bloqueo sobre un objeto público. El código externo también puede bloquear el mismo objeto y se producen colisiones no deseadas. Se usa un private final Object lock = new Object(); que no se expone al exterior, o un ReentrantLock dedicado.

4.3. Evitar trabajo externo mientras se retiene el bloqueo y fijar el orden de adquisición

Hacer E/S, llamar a un listener o ejecutar código desconocido mientras se retiene un bloqueo alarga el tiempo de retención. Si el destinatario toma otro bloqueo, también puede crear una espera circular.

espera la liberación del bloqueo 2espera la liberación del bloqueo 1Hilo Areteniendo el bloqueo 1Hilo Breteniendo el bloqueo 2

Figura 3: Si uno retiene el bloqueo 1 y espera el 2, y el otro espera en el orden inverso, ninguno puede avanzar.

En los puntos donde se toman varios bloqueos, el orden de adquisición se fija para todos los hilos. Donde no se puede garantizar el orden, se prepara un camino con tryLock(timeout) que, si no se obtiene, suelta los bloqueos que retiene y reintenta. Se guardan juntas las dos reglas: no hacer trabajo largo ni externo mientras se retiene un bloqueo, y alinear el orden de adquisición.

4.4. Exclusión corta con synchronized; ReentrantLock cuando hacen falta funciones adicionales

Para una exclusión mutua corta y simple, synchronized basta. Se elige ReentrantLock cuando hace falta una adquisición con plazo mediante tryLock(timeout), una política de equidad, varias Condition, o separar la adquisición y la liberación en métodos distintos.

En el uso habitual de ReentrantLock, no se rompe la forma de try inmediatamente después de lock(), con unlock() en finally. No se espera que el bloqueo se libere solo como con el RAII de C++; se estructura para que se libere incluso si hay una excepción.

En combinación con hilos virtuales, también se comprueban las diferencias de JDK del apartado 2.4. Desde JDK 24 en adelante no hace falta sustituir synchronized de forma general solo como contramedida de pinning. La disciplina de mantener los bloqueos cortos no cambia.4

5. Decidir cómo se detienen las tareas: no tragarse las interrupciones

5.1. interrupt no es una terminación forzada, sino la señal de una parada cooperativa

La detención y la cancelación en Java se diseñan como parada cooperativa mediante interrupción. t.interrupt() activa el estado de interrupción del hilo objetivo. Si está bloqueado en sleep / wait / join o similar, un InterruptedException lo saca de la espera y, en ese momento, el estado de interrupción se limpia.7

En la API de JDK 21 a la que se refiere este artículo, los antiguos medios forzosos Thread.stop / suspend / resume lanzan UnsupportedOperationException. En lugar de volver a medios peligrosos que liberan bloqueos en un estado inconsistente o invitan a interbloqueos, la propia tarea responde a la señal de parada y termina.7

Puede terminar por sí mismoNo puede (dentro de una biblioteca, etc.)Quien detiene: t.interrupt()Se activa el estado de interrupciónHilo en cálculo:comprueba Thread.interrupted() en el bucleBloqueado en sleep / wait / join:salta InterruptedException y despierta de inmediato(el estado se limpia)Limpia y termina por sí mismo¿Qué hace el catch?Thread.currentThread().interrupt()restaura el estado y deja la señal

Figura 4: La propia tarea que recibe la interrupción limpia y termina. Si se traga la excepción, se pierde la señal de parada.

5.2. Dejar explícita la responsabilidad después de recibir InterruptedException

No se escribe código que capture InterruptedException y no haga nada. Si se puede terminar dentro de la propia responsabilidad, se limpia y se acaba. Si se deja la decisión a quien llamó, se relanza la excepción tal cual o se restaura el estado con Thread.currentThread().interrupt() para dejar la señal.7

Un bucle de cálculo también tiene que comprobar la interrupción y avanzar hacia la salida, como en la figura 4. Implementar solo el lado que envía la petición de parada no detiene nada si el lado que la recibe la ignora. Esta propiedad se aplica tal cual a shutdownNow() del capítulo siguiente.

6. Terminar ExecutorService: separar la petición de la comprobación de finalización

6.1. Llamar a shutdownNow no significa por sí solo que la detención haya terminado

Al terminar un ExecutorService se separan la operación que deja de admitir, la que pide cancelación y la que espera la finalización.2

API Papel Lo que no garantiza por sí sola
shutdown() Deja de admitir tareas nuevas y deja ejecutar las ya enviadas El hilo que llama no espera hasta la finalización
awaitTermination(...) Tras la petición de apagado, espera hasta la finalización, el vencimiento del plazo o una interrupción, lo que ocurra primero La finalización de la detención si se agota el tiempo
shutdownNow() Intenta detener las tareas en ejecución y devuelve las tareas en espera que no se ejecutaron La terminación forzada de las tareas en ejecución, o la espera hasta que terminen
close() Deja de admitir y espera hasta que el Executor termine Un tope al tiempo de espera

shutdownNow() es un mejor esfuerzo. En implementaciones estándar como ThreadPoolExecutor la cancelación típica es por interrupción, así que una tarea que no responde a la interrupción no se detiene. Si se usa un Executor propio, se comprueba el método de cancelación de esa implementación, incluido si envía o no una interrupción.2

Para cancelar una tarea concreta se usa Future.cancel(true). Tampoco aquí se confunde una petición de parada a una tarea en ejecución vía interrupción con que la tarea haya terminado realmente el trabajo.

6.2. Usar un apagado en dos fases acotado en el tiempo

Primero se deja de admitir y se espera a que termine; si pasa el plazo, se pide cancelación y se espera de nuevo la finalización. Si aun así no terminó, se comunica a quien llamó de un modo distinto del éxito. El ejemplo siguiente se basa en el patrón oficial de dos fases y añade el éxito o el fracaso de la detención y el tratamiento de los Future no ejecutados.2

/** Devuelve true cuando la detención ha terminado. No se debe pasar a liberar recursos compartidos mientras sea false. */
boolean shutdownAndAwaitTermination(ExecutorService pool) {
    pool.shutdown();                    // Fase 1: dejar de admitir tareas nuevas y esperar a que terminen
    try {
        if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
            // Fase 2: pedir cancelación. Devuelve las tareas bajadas sin ejecutar,
            // así que se marcan esos Future como cancelados para despertar a quien espera 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;           // Detención incompleta. Comunicarlo de un modo distinto del éxito
            }
        }
        return true;
    } catch (InterruptedException ex) {
        pool.shutdownNow().forEach(r -> {
            if (r instanceof Future<?> f) f.cancel(false);
        });
        Thread.currentThread().interrupt();   // Restaurar también el estado de interrupción propio
        return false;                   // En este camino la detención también puede estar incompleta
    }
}
Termina dentro del plazoTiempo agotadoTerminaAún no terminashutdown()Deja de admitir tareas nuevasawaitTerminationespera a que terminenDetención completashutdownNow()Envía interrupt a las tareas en ejecución(si responden depende de la tarea)awaitTerminationespera de nuevoRegistrar como anomalía(el sospechoso es una tarea que no responde a la interrupción)

Figura 5: Separar la fase que espera a que terminen de la que pide cancelación y espera de nuevo. Si aun así no acaba, se trata como detención incompleta.

No se deben liberar los recursos compartidos que usan las tareas mientras el valor de retorno sea false. No solo cuando la segunda espera agota el tiempo: también en el camino en que quien espera fue interrumpido, las tareas pueden seguir en marcha. No basta con dejar un registro y tratarlo como éxito; quien llamó debe poder juzgar que la detención está incompleta.

6.3. Usar close y try-with-resources solo en un ámbito que pueda terminar del todo

Desde JDK 19, ExecutorService se puede usar como AutoCloseable. try (var executor = ...) espera la terminación con close() al salir del ámbito. También se combina con un Executor de hilos virtuales.2

Sin embargo, close() espera sin tiempo de espera, así que no sustituye el patrón de dos fases acotado en el tiempo. Si hay una tarea que no termina o que no responde a la interrupción, el hilo que intenta cerrar también sigue esperando.

Se usa try-with-resources en un ámbito finito donde se envían las tareas en el acto y se puede esperar en el acto a que terminen. En un camino en el que no se puede seguir esperando, como el apagado de toda la aplicación, se diseñan una espera acotada y el tratamiento de una detención incompleta.2

6.4. La tarea en la cola y el Future del usuario no tienen por qué ser el mismo

Lo que shutdownNow() devuelve son los objetos que quedaban en la cola de ejecución. Con un submit simple, normalmente es el propio FutureTask que se entregó al usuario, así que el cancel(false) del ejemplo puede despertar a quien espera en get().

En cambio, si se envió a través de un envoltorio como ExecutorCompletionService, lo que vuelve es el envoltorio de la cola, un objeto distinto del Future del usuario. Hay configuraciones en las que el ejemplo anterior, por sí solo, no puede completar el Future del usuario.

En ese caso se guarda al enviar una lista de los Future del lado del usuario y se cancelan esos al apagar, o se diseña para devolver al dueño las tareas bajadas de la cola. Hay que comprobar el camino de detención hasta «quien espera un trabajo que ya se sabe que no se va a ejecutar».

7. Devolver la IU a su hilo dedicado: el EDT de Swing

Quien gestiona la IU de Swing es el hilo de despacho de eventos (EDT). Los métodos de los componentes Swing no son, por principio, seguros para hilos, y tocarlos desde varios hilos invita a interferencia entre hilos y a errores de consistencia de memoria.8

Para actualizar la pantalla desde otro hilo se pide al EDT con SwingUtilities.invokeLater. A la inversa, un procesamiento largo en el EDT congela la IU, así que el trabajo pesado se saca a un hilo trabajador con SwingWorker o similar. Sacar el trabajo y devolver la presentación del resultado al hilo de IU van juntos.8

El mismo esquema vale en JavaFX. Las actualizaciones de IU se piden al hilo de la aplicación con Platform.runLater. Por muchos tipos de hilo que haya, el principio de que la IU es propiedad exclusiva del hilo que la gestiona no cambia.

8. Preparar verificación e investigación: revisión de diseño, volcados y pruebas de carga

8.1. Revisar primero los datos compartidos y el camino de detención

Aunque las pruebas habituales pasen, puede ser solo que «esta vez no hubo contención». No se espera encontrar los errores de carrera solo con pruebas; la primera línea de defensa se pone en el diseño.

Se comprueba con una tabla de correspondencia los datos mutables compartidos, el bloqueo que los protege y el orden de adquisición de los bloqueos. Además, se comprueba que no haya un catch que se trague InterruptedException y que el camino de apagado e interrupción llegue a todas las tareas. El diseño de los capítulos 3 a 6 se convierte tal cual en ítems de revisión.

8.2. Tomar el volcado que corresponde al tipo de hilo

El estado de los hilos de plataforma se investiga con jstack o jcmd <pid> Thread.print. Con la opción -l se puede ver también información adicional sobre los bloqueos.9

Sin embargo, el volcado de formato tradicional no incluye los hilos virtuales de la aplicación. Para seguir una solicitud detenida en una configuración que usa hilos virtuales, se obtiene un formato que los incluye con jcmd <pid> Thread.dump_to_file -format=json <archivo>.1

En una investigación de cuelgue se toman dos o tres volcados cada pocos segundos y se cruzan los hilos que no se mueven. En la espera de un bloqueo de un hilo de plataforma se sigue qué espera y quién retiene ese bloqueo. En hilos virtuales, a partir del volcado que los incluye se comprueba en qué punto del procesamiento se han parado. Si se deja en el registro el vencimiento de tryLock(timeout), también se obtiene un disparador para tomar el volcado.

8.3. Agitar el orden de ejecución bajo una carga equivalente a producción

Además del volcado como segunda defensa, se hace una prueba de estrés como tercera. Ejecutar largo tiempo con un paralelismo mayor que el número de núcleos, aleatorizar el orden de procesamiento e insertar retardos artificiales facilita pisar el orden de ejecución en el que ocurre la contención.

Antes de la publicación hay que pasar al menos una vez una prueba con un volumen de datos y un número de hilos equivalentes a producción. Ahora bien, haber pasado la prueba de carga no se convierte en razón para no diseñar el estado compartido.

9. El lugar de las API nuevas: separar preview y funcionalidad oficial

Este capítulo es una organización a agosto de 2026. Se separan las herramientas básicas ya utilizables de las API en desarrollo, para no confundirlas.

Structured Concurrency (StructuredTaskScope) es una API que trata varias subtareas relacionadas como una sola unidad de trabajo y estructura la propagación del fallo y la cancelación. Se asume un uso basado en hilos virtuales, pero en este momento sigue siendo una función en preview. En la quinta preview de JDK 25 (JEP 505) se reformó a una forma de API que usa StructuredTaskScope.open(), y en JDK 26 continúa como sexta preview (JEP 525).1011

Por otro lado, Scoped Values, que tratan el compartir de contexto inmutable, se formalizaron en JDK 25. Son la solución a los problemas de ThreadLocal (mutabilidad, gestión del ciclo de vida, coste de la herencia).12

La dirección a la que apuntan las API nuevas es la misma que los principios de este artículo: dejar claros los límites de las tareas, acercar el compartir a lo inmutable y tratar la detención de forma cooperativa.

10. Resumen: 10 puntos a comprobar antes de implementar y en la revisión

# Qué comprobar
1 Si el código de negocio no crea new Thread de forma directa y deja las tareas a ExecutorService
2 Si se distinguen el medio de ejecución para la espera de E/S y para el cálculo de CPU, y si se ha pensado un tope o contrapresión también para la cola del pool fijo
3 Si los hilos virtuales no se ponen en pool y el número simultáneo hacia un servicio externo se limita con Semaphore
4 Si los datos compartidos se acercan a la división, la inmutabilidad y el traspaso, y se comprueba hasta los elementos de record y de las colecciones inmutables
5 Si un bloqueo dedicado se hace corresponder con los datos, evitando synchronized(this) y el bloqueo sobre un objeto público
6 Si se usan operaciones compuestas como computeIfAbsent, respetando las condiciones de reejecución de la función de mapeo y manteniéndola corta
7 Si no se espera atomicidad de volatile, y los contadores se asignan a las clases Atomic o a LongAdder y el estado compuesto a los bloqueos
8 Si no se traga InterruptedException y la señal de parada llega hasta el lado de la tarea
9 Si el cierre es el patrón de dos fases acotado en el tiempo. Si close() se limita a un ámbito que puede garantizar la finalización, y si se tratan la detención incompleta y los Future no ejecutados
10 Si las actualizaciones de IU de Swing / JavaFX se concentran en el EDT / hilo de la aplicación

Los hilos virtuales de Java abrieron el camino para escalar tal cual el código síncrono sencillo. Aun así, la herramienta de rendimiento, las de proteger el estado compartido y la de transmitir la detención son distintas. Elegir con los papeles de ejecución, compartir y detención separados es la base del diseño de multithreading en Java.

Artículos relacionados

Áreas de consultoría relacionadas

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

Referencias

  1. 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 work-stealing que opera 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 ↩6

  2. 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 ↩7 ↩8

  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, 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 ↩4

  7. Oracle, Thread (Java SE 21 & JDK 21 API). Sobre que Thread.stop / suspend / resume son intrínsecamente inseguros (liberan los bloqueos en un estado inconsistente y dejan visibles objetos corruptos; suspend invita a interbloqueos), están obsoletos con vistas a su eliminación y 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

  8. 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

  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 un 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 saturan la CPU sigue siendo adecuado, como hasta ahora, un pool de hilos de plataforma acotado a un número cercano al de núcleos (o un 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 mutua 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?
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 trabajo 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