Programación en Java
Concurrencia en Java: threads, sincronización, executors y virtual threads
Avanzado10 min de lecturaActualizado
Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés
El código concurrente falla de maneras que nunca aparecen en un test unitario, así que la única defensa es entender las reglas. Esta guía explica qué es un thread, por qué dos threads pueden discrepar sobre el valor de una variable, cómo synchronized, volatile, los locks y los atómicos garantizan visibilidad y atomicidad, cómo los executors y CompletableFuture estructuran el trabajo y cómo los virtual threads de Java 21 cambian las cuentas en los servicios intensivos en I/O. Podrás escribir código con estado compartido que sea correcto, no simplemente afortunado.
Lo que aprenderás
- Explicar los conceptos del modelo de memoria de Java: atomicidad, visibilidad y happens-before
- Proteger correctamente el estado compartido con synchronized, volatile, los locks de java.util.concurrent y los atómicos
- Ejecutar y coordinar tareas con ExecutorService, Future y CompletableFuture
- Elegir colecciones concurrentes y colas bloqueantes para diseños productor-consumidor
- Gestionar correctamente la interrupción y el apagado, y saber cuándo convienen los virtual threads
La programación multihilo en Java no es difícil porque la API sea enorme —a la mayoría de los programas les bastan unas pocas clases—, sino porque los fallos son invisibles: un código incorrecto funciona bien un millón de veces en tu portátil y corrompe el total de un pedido en un servidor de 32 núcleos un viernes por la noche. Las reglas que deciden si el código es correcto son las del modelo de memoria de Java, y en cuanto las conoces, las construcciones de java.util.concurrent dejan de parecer un menú y pasan a ser respuestas a preguntas concretas. Esta guía se centra en Java 21, la versión que además introdujo los virtual threads.
Los threads y lo que sale mal
Un thread es un camino de ejecución independiente que comparte el heap del proceso con todos los demás threads. Crear uno es trivial:
Thread t = new Thread(() -> System.out.println("running on " + Thread.currentThread().getName()));
t.start(); // schedules the thread; run() would just call the method on the current thread
t.join(); // wait for it to finish
Los problemas empiezan con el estado mutable compartido. Para que los datos compartidos sean correctos se deben cumplir dos propiedades:
- Atomicidad: desde el punto de vista de los demás threads, una operación se completa entera o no se ejecuta en absoluto.
count++no es atómico: es una lectura, una suma y una escritura, y dos threads que las intercalan pierden un incremento. - Visibilidad: lo que escribe un thread acaba siendo visto por otro. Sin sincronización no hay ninguna garantía de ello: el JIT puede mantener un valor en un registro o reordenar sentencias, y un núcleo puede servir una línea de caché obsoleta. Un bucle
while (!stopped) {}puede quedarse girando indefinidamente, de verdad, aunque otro thread haya hechostopped = true.
El modelo de memoria de Java (capítulo 17 de la JLS) define, mediante la relación happens-before, cuándo se garantiza que una escritura sea visible para una lectura. Las aristas relevantes: liberar un monitor happens-before cualquier adquisición posterior de ese monitor; una escritura en un campo volatile happens-before cualquier lectura posterior de ese campo; Thread.start() happens-before todo lo que se ejecuta en el thread iniciado; todo lo que ocurre en un thread happens-before el retorno de join() sobre él; enviar una tarea a un executor happens-before la ejecución de la tarea. Todo código concurrente correcto se apoya en alguna de estas aristas, aunque su autor nunca la nombre.
synchronized, volatile y atómicos
synchronized adquiere el lock intrínseco (monitor) de un objeto, ejecuta el bloque y lo libera. Solo un thread a la vez puede tener un monitor concreto, así que el bloque es atómico respecto a los demás bloques sobre el mismo monitor, y el par adquirir/liberar aporta la visibilidad.
public final class Counter {
private long value; // guarded by this
public synchronized void increment() { value++; } // locks `this`
public synchronized long get() { return value; } // reads need the lock too, for visibility
}
Fíjate en el synchronized de get(). Un lock solo protege los datos si todos los accesos, lecturas incluidas, lo toman. Sincronizar solo a quien escribe sigue dejando a los lectores con valores obsoletos.
Un campo volatile da visibilidad sin exclusión mutua: las lecturas y escrituras van directamente a memoria principal (conceptualmente) y no se reordenan con el código que las rodea. Es la herramienta adecuada para un flag o una referencia que un thread publica y otros leen; es la equivocada para count++.
private volatile boolean running = true;
public void stop() { running = false; } // visible to the worker immediately
public void run() {
while (running) { doWork(); } // exits promptly after stop()
}
Para contadores y actualizaciones de una sola variable, java.util.concurrent.atomic ofrece clases sin locks basadas en la instrucción compare-and-swap de la CPU: AtomicInteger, AtomicLong, AtomicReference, AtomicBoolean y, para contadores con mucha contención, LongAdder. incrementAndGet, compareAndSet y updateAndGet(fn) son atómicos y visibles sin necesidad de lock.
private final AtomicLong requests = new AtomicLong();
requests.incrementAndGet();
private final AtomicReference<Config> config = new AtomicReference<>(initial);
config.updateAndGet(c -> c.withTimeout(Duration.ofSeconds(5))); // retried until the CAS succeeds
La otra vía hacia la seguridad es no tener estado mutable compartido: los objetos inmutables (records, List.of) pueden pasar libremente de un thread a otro, y el diseño concurrente más sencillo suele ser «cada tarea es dueña de sus datos; los resultados se combinan al final».
Locks y condiciones
synchronized no admite timeout, no se puede interrumpir mientras espera y siempre libera el lock al salir del bloque. ReentrantLock ofrece tryLock(timeout), lockInterruptibly() y equidad (fairness), a cambio de tener que liberarlo a mano en un finally:
private final ReentrantLock lock = new ReentrantLock();
public boolean transfer(Account to, long cents) throws InterruptedException {
if (!lock.tryLock(1, TimeUnit.SECONDS)) return false; // give up rather than deadlock
try {
debit(cents);
to.credit(cents);
return true;
} finally {
lock.unlock(); // always
}
}
ReentrantReadWriteLock deja avanzar a muchos lectores a la vez mientras los escritores son exclusivos, algo útil para cachés que se leen mucho más de lo que se escriben; StampedLock añade lecturas optimistas para el mismo caso, a costa de más complejidad.
Un deadlock se produce cuando el thread A tiene el lock 1 y espera el lock 2 mientras el thread B tiene el 2 y espera el 1. La solución clásica es un orden global de adquisición (bloquear siempre primero la cuenta con el ID más bajo); tryLock con timeout es la alternativa cuando imponer un orden no es práctico. Un thread dump (jcmd <pid> Thread.print) muestra explícitamente los threads en deadlock.
Object.wait()/notifyAll() y Condition.await()/signalAll() permiten que un thread duerma hasta que cambie cierto estado. Llámalos siempre dentro de un bucle que vuelva a comprobar la condición, porque los despertares espurios (spurious wake-ups) están permitidos y otro thread puede haber consumido ese estado. En la práctica rara vez lo escribirás tú: BlockingQueue, CountDownLatch, Semaphore y CompletableFuture lo encapsulan.
Executors: la forma correcta de ejecutar tareas
Crear un thread por tarea no escala y no te da ningún ciclo de vida. Un ExecutorService desacopla qué se ejecuta (un Runnable o un Callable<T>) de cómo se ejecuta (un pool de N threads, un único thread, un virtual thread por tarea):
ExecutorService pool = Executors.newFixedThreadPool(8);
try {
List<Callable<Long>> jobs = files.stream()
.map(f -> (Callable<Long>) () -> Files.size(f))
.toList();
long total = 0;
for (Future<Long> f : pool.invokeAll(jobs)) {
total += f.get(); // rethrows the task's exception as ExecutionException
}
System.out.println("Total bytes: " + total);
} finally {
pool.shutdown(); // stop accepting; finish queued tasks
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) pool.shutdownNow();
}
Desde Java 19, ExecutorService implementa AutoCloseable, y close() ejecuta shutdown() seguido de un awaitTermination() sin límite de tiempo, así que try-with-resources funciona para executors de vida corta.
Tamaño del pool: para trabajo CPU-bound, aproximadamente el número de núcleos (Runtime.getRuntime().availableProcessors()); para I/O bloqueante con threads de plataforma, más, o mejor aún, virtual threads. ThreadPoolExecutor expone el tamaño base, el tamaño máximo, la cola y la política de rechazo; las fábricas de Executors los ocultan, y newCachedThreadPool en particular crea threads sin límite cuando hay carga. Un ScheduledExecutorService ejecuta tareas tras un retardo o a ritmo fijo; sustituye a java.util.Timer, cuyo único thread muere con la primera excepción no capturada.
Las excepciones de las tareas no llegan a tus logs a menos que vayas a buscarlas: un Runnable enviado con execute acaba en el manejador de excepciones no capturadas del thread, y uno enviado con submit queda capturado en el Future y se pierde en silencio si nunca llamas a get(). Envuelve el cuerpo de las tareas en un try/catch que registre el error, o consume siempre el future.
CompletableFuture: componer trabajo asíncrono
Future.get() bloquea. CompletableFuture te permite describir qué ocurre cuando llega un resultado, y combinar varios resultados, sin bloquear ningún thread hasta el final:
CompletableFuture<Customer> customer = CompletableFuture.supplyAsync(() -> customers.load(id), pool);
CompletableFuture<List<Order>> orders = CompletableFuture.supplyAsync(() -> orderRepo.forCustomer(id), pool);
CompletableFuture<Invoice> invoice = customer
.thenCombine(orders, (c, os) -> Invoice.of(c, os)) // when both complete
.thenApply(pricing::apply) // transform
.exceptionally(ex -> Invoice.failed(id, ex)) // recover
.orTimeout(5, TimeUnit.SECONDS); // fail if too slow (Java 9)
Invoice result = invoice.join(); // block only at the edge
thenApply transforma, thenCompose encadena un paso que a su vez devuelve un future (el flatMap de los futures), thenCombine une dos, allOf/anyOf unen muchos, y handle/whenComplete/exceptionally se ocupan de los fallos. Los métodos con el sufijo Async ejecutan la continuación en el executor que les pases en lugar de en el thread que completó la etapa anterior; si no les pasas ninguno, los métodos *Async y supplyAsync usan el ForkJoinPool común, que está dimensionado para trabajo de CPU y se comparte con los streams paralelos: nunca bloquees en él.
Colecciones concurrentes y colas
Las colecciones de java.util.concurrent están diseñadas para compartirse, a diferencia de los wrappers Collections.synchronizedX, que serializan cada llamada y aun así dejan inseguras la iteración y las secuencias check-then-act.
| Clase | Uso |
|---|---|
ConcurrentHashMap | Map compartido; compute, merge y putIfAbsent son atómicos por clave; la iteración nunca lanza excepciones |
CopyOnWriteArrayList | Listas de listeners y de configuración: escrituras raras, lecturas frecuentes, iteración sobre una instantánea |
ConcurrentLinkedQueue / ConcurrentLinkedDeque | Cola no bloqueante para pasar elementos sin esperar |
ArrayBlockingQueue / LinkedBlockingQueue | Productor–consumidor con back-pressure: put bloquea si está llena, take bloquea si está vacía |
ConcurrentSkipListMap / Set | Map/set concurrentes ordenados |
ConcurrentHashMap.computeIfAbsent(key, fn) es atómico para la clave, lo que convierte una caché de memoización en una sola línea, pero fn debe ser breve y no debe tocar el mismo map, o provocará un deadlock o lanzará IllegalStateException.
El patrón productor–consumidor con una BlockingQueue acotada es la forma estándar de conectar etapas que van a distinta velocidad; el límite es lo que impide que un productor rápido agote la memoria. Un elemento poison pill o shutdownNow indica a los consumidores que deben parar.
Utilidades de coordinación
CountDownLatch: esperar a que ocurran N eventos (N workers arrancados, N resultados recibidos). De un solo uso.CyclicBarrier: N threads se esperan unos a otros en un punto y luego continúan; reutilizable.Semaphore: limita el acceso concurrente a un recurso a K permisos, por ejemplo K descargas simultáneas.Phaser: una barrera más general, de varias fases.ThreadLocal: una variable propia de cada thread, usada para el contexto de la petición y para utilidades no thread-safe comoSimpleDateFormat; llama siempre aremove()al final de la tarea cuando los threads vienen de un pool, o el valor se filtrará a la siguiente tarea.
Interrupción y apagado
Java no tiene una forma segura de matar un thread. La interrupción es una petición cooperativa: thread.interrupt() activa un flag; los métodos bloqueantes (sleep, wait, join, BlockingQueue.take, Future.get) responden lanzando InterruptedException y limpiando el flag; los bucles CPU-bound deben consultar ellos mismos Thread.currentThread().isInterrupted().
La regla cuando capturas InterruptedException y no puedes propagarla: restaura el flag y termina en cuanto puedas.
@Override
public void run() {
try {
while (!Thread.currentThread().isInterrupted()) {
Job job = queue.take(); // throws if interrupted while waiting
process(job);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // preserve the request for callers
}
}
Tragarse la excepción es justo lo que hace que un servicio ignore shutdownNow() y siga ejecutándose hasta que el sistema operativo lo mata.
Virtual threads (Java 21)
Un thread de plataforma envuelve un thread del sistema operativo: alrededor de un megabyte de pila reservado, unos pocos miles por proceso como mucho y caro de crear. Por eso existen los pools de threads, y por eso los servidores que bloquean un thread por petición se estancan en unos pocos miles de peticiones concurrentes. Un virtual thread (JEP 444, definitiva en Java 21) es un thread ligero que la JVM planifica sobre un pequeño pool de carrier threads. Cuando se bloquea en una operación de I/O, su pila se aparca en el heap y el carrier ejecuta otro virtual thread. Pueden existir millones a la vez.
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<String>> results = new ArrayList<>();
for (String url : urls) {
results.add(executor.submit(() -> httpClient.send(request(url), BodyHandlers.ofString()).body()));
}
for (Future<String> f : results) System.out.println(f.get().length());
} // close() waits for every task
El modelo de programación es el bloqueante de siempre, sin callbacks ni operadores reactivos, y precisamente de eso se trata. Algunas pautas:
- No metas los virtual threads en un pool. Son baratos: crea uno por tarea. Para limitar la concurrencia frente a un recurso escaso, usa un
Semaphore. - Evita
synchronizedalrededor de llamadas bloqueantes. En Java 21, un virtual thread que se bloquea dentro de un bloquesynchronizedo de una llamada nativa queda fijado (pinned) a su carrier, lo que anula la ventaja. Sustitúyelo porReentrantLock, que no fija el thread. (Java 24 eliminó la mayor parte del pinning, pero lo más probable es que estés usando la LTS 21.) - Cuidado con
ThreadLocal. Un millón de threads con un objeto en caché cada uno es mucha memoria; los scoped values (una funcionalidad en preview en Java 21) son su sucesor previsto. - El trabajo CPU-bound no gana nada. Los virtual threads reducen el coste de esperar, no el de calcular.
Spring Boot 3.2+ ejecuta el procesamiento de peticiones de Tomcat en virtual threads cuando se configura spring.threads.virtual.enabled=true, que es la forma más rápida de probarlos en un servicio existente.
Errores comunes
Lecturas sin sincronizar. Bloquear a quien escribe pero leer un campo sin el lock, o sin volatile, permite ver valores obsoletos indefinidamente.
Check-then-act sobre una colección compartida. if (!map.containsKey(k)) map.put(k, v) es una condición de carrera; usa putIfAbsent o computeIfAbsent.
Bloquear en el pool común. CompletableFuture.supplyAsync(() -> jdbcCall()) sin executor, o I/O bloqueante en un stream paralelo, deja sin recursos a todos los demás usuarios de ForkJoinPool.commonPool().
Perder las excepciones de las tareas. submit() sin get() oculta el fallo. Consume el future o registra el error dentro de la tarea.
Colas y pools sin límite. newCachedThreadPool y LinkedBlockingQueue sin capacidad convierten un pico de tráfico en un OutOfMemoryError.
Olvidar shutdown(). Los threads no daemon del pool mantienen viva la JVM después de que main retorne.
Tragarse InterruptedException. Rompe la cancelación para todos los llamadores que están por encima.
Double-checked locking sin volatile. El clásico bug del singleton perezoso; el patrón de la clase holder o un simple synchronized son más sencillos y correctos.
ThreadLocal sin remove(). Los threads de un pool arrastran el contexto de la petición anterior a la siguiente: un fallo de seguridad además de una fuga.
Puntos clave
- La corrección depende del modelo de memoria: los datos mutables compartidos necesitan una arista happens-before (un lock,
volatile, un atómico o un traspaso a través de un executor o una colección concurrente) tanto para quien escribe como para quien lee. synchronizedda atomicidad y visibilidad;volatile, solo visibilidad; los atómicos, actualizaciones sin lock de una única variable;ReentrantLockañade timeouts e interrumpibilidad.- Ejecuta las tareas mediante un
ExecutorServicecon un pool acotado y un apagado explícito; compón los resultados asíncronos conCompletableFuturey pasa tu propio executor para el trabajo bloqueante. - Prefiere
ConcurrentHashMap,CopyOnWriteArrayListyBlockingQueueacotadas a los wrappers sincronizados; sus operaciones atómicas por clave eliminan la mayoría de las carreras check-then-act. - La interrupción es cooperativa: gestiona
InterruptedExceptionrestaurando el flag y saliendo. - Los virtual threads hacen que el código bloqueante de un thread por tarea escale en servicios intensivos en I/O; créalos por tarea, evita
synchronizedalrededor de llamadas bloqueantes en Java 21 y no esperes ninguna mejora en el trabajo CPU-bound.
Fuentes y lecturas recomendadas
Preguntas frecuentes
- ¿Qué diferencia hay entre synchronized y volatile?
- volatile garantiza visibilidad: cualquier lectura posterior, en cualquier thread, ve la escritura. No hace atómicas las operaciones compuestas como count++. synchronized garantiza a la vez exclusión mutua y visibilidad para todo lo que ocurre dentro del bloque. Usa volatile para un flag que escribe un thread y leen otros; usa synchronized, un Lock o una clase atómica cuando una secuencia de lectura-modificación-escritura deba ser indivisible.
- ¿Cuándo debería usar virtual threads?
- En cargas con muchas tareas concurrentes que pasan la mayor parte del tiempo bloqueadas en I/O: handlers HTTP, llamadas a base de datos, consumidores de mensajes. Los virtual threads permiten que el modelo de un thread por petición escale a cientos de miles de tareas sin pools. No aceleran el trabajo CPU-bound, y en Java 21 el código que mantiene un bloque synchronized durante una llamada bloqueante fija (pin) el carrier thread, así que revisa esos puntos antes de migrar.
- ¿Por qué no crear threads con new Thread() en el código de la aplicación?
- Crear threads sin límite agota la memoria y la CPU bajo carga, y un thread sin más no te da cola, ni gestión de resultados, ni apagado. Un ExecutorService limita la concurrencia, devuelve Futures, propaga las excepciones y se apaga limpiamente. Para virtual threads, usa Executors.newVirtualThreadPerTaskExecutor.
- ¿Cómo detengo un thread de forma segura?
- Pídele que se detenga interrumpiéndolo, y escribe la tarea para que compruebe Thread.interrupted() en los bucles y deje propagar InterruptedException desde las llamadas bloqueantes. Desde Java 20, Thread.stop lanza UnsupportedOperationException, porque dejaba los locks y los datos en estados desconocidos. Los executors lo exponen mediante shutdownNow, que interrumpe todas las tareas en ejecución.