Dos hilos de Java que actualizan la misma variable pueden perder escrituras en silencio. Mira por qué count++ es una carrera, cómo la arreglan synchronized, volatile, los atomics y los locks, y cómo forzar y detectar un deadlock.
Un hilo ejecuta código en paralelo con el resto de tu programa. Los hilos que solo tocan sus propios datos son fáciles. El problema empieza cuando dos de ellos cambian la misma variable: nada se rompe, nada te avisa y algunos de los cambios desaparecen.
Este post arranca hilos, muestra ese bug y lo fuerza a ocurrir en cada ejecución. Después cubre synchronized, volatile, los atomics, el deadlock, ReentrantLock, las colecciones thread-safe y los datos inmutables. Cada programa de abajo se ejecutó en Java 25, y su salida está copiada de esa ejecución. Para ejecutar uno tú mismo, guárdalo como Main.java y ejecuta java Main.java.
Arrancar un hilo: start, no run
Un objeto Thread no hace nada hasta que llamas a start(), que le pide a la JVM un hilo nuevo y ejecuta tu código en él. Llamar a run() se ve parecido, pero solo llama al método en el hilo en el que ya estás:
void main() throws InterruptedException {
Thread mainThread = Thread.currentThread();
boolean[] onMain = new boolean[2];
Thread first = new Thread(() -> onMain[0] = Thread.currentThread() == mainThread);
first.run();
Thread second = new Thread(() -> onMain[1] = Thread.currentThread() == mainThread);
second.start();
second.join();
IO.println("run() ran the task on the main thread: " + onMain[0]);
IO.println("start() ran the task on the main thread: " + onMain[1]);
IO.println("state of the thread we called run() on: " + first.getState());
}
Imprime:
run() ran the task on the main thread: true
start() ran the task on the main thread: false
state of the thread we called run() on: NEW
first.run() ejecutó la lambda en el hilo principal, y first nunca llegó a ser un hilo. Su estado sigue siendo NEW. Llamar a run() por error compila, funciona y, sin hacer ruido, no te da ninguna concurrencia.
second.join() hace que el hilo principal espere hasta que second termine. Sin eso, main podría leer onMain[1] antes de que el otro hilo lo hubiera escrito.
Un hilo daemon es uno al que la JVM no espera: cuando solo quedan hilos daemon, el programa termina. El ejemplo de deadlock de más abajo usa eso.
La mayoría del código no crea hilos a mano. La parte sobre java.util.concurrent cubre los executors, y la parte sobre hilos virtuales cubre los hilos baratos que agregó Java 21. Todo lo que dice este post sobre el estado compartido aplica a los dos.
Los hilos con sus propios datos no necesitan locks
El builder Thread.ofPlatform().start(...), definitivo desde Java 21, crea y arranca un hilo en una sola llamada. Aquí, cuatro hilos escriben cada uno en su propia posición del array:
void main() throws InterruptedException {
String[] words = {"apple", "banana", "cherry", "date"};
int[] lengths = new int[words.length];
var threads = new ArrayList<Thread>();
for (int i = 0; i < words.length; i++) {
int slot = i;
threads.add(Thread.ofPlatform().start(() -> lengths[slot] = words[slot].length()));
}
for (Thread t : threads) {
t.join();
}
IO.println(Arrays.toString(lengths));
}
Imprime:
[5, 6, 6, 4]
No hay dos hilos que escriban en la misma posición, así que no hay nada que coordinar, y hacer join de cada hilo antes de leer vuelve confiable el resultado.
Una actualización perdida que no puedes reproducir a voluntad
Una actualización perdida ocurre cuando dos hilos cambian la misma variable y un cambio pisa al otro. Aquí cuatro hilos suman 1 cada uno a un count compartido cien mil veces:
int count = 0;
void main() throws InterruptedException {
var threads = new ArrayList<Thread>();
for (int i = 0; i < 4; i++) {
threads.add(Thread.ofPlatform().start(() -> {
for (int n = 0; n < 100_000; n++) {
count++;
}
}));
}
for (Thread t : threads) {
t.join();
}
IO.println("count = " + count);
}
Esperarías 400000. Lo ejecutamos cinco veces seguidas, y los números cambian en cada ejecución, así que esta salida es un ejemplo, no algo que vayas a obtener igual:
$ for i in 1 2 3 4 5; do java Main.java; done
count = 135423
count = 149972
count = 120394
count = 165135
count = 199862
Más de la mitad de los incrementos desaparecieron. En otra máquina, o en una ejecución con suerte, podrías no perder ninguno, así que un test que pasa no prueba nada.
Go tiene un detector de carreras que señala este tipo de código. Java no trae uno. Así que en lugar de esperar a que el bug aparezca, el siguiente programa hace que ocurra siempre.
Forzar la actualización perdida
count++ no es un solo paso. Lee count, le suma 1 y vuelve a escribir el resultado. Un CountDownLatch nos deja detener a los dos hilos entre la lectura y la escritura:
int count = 5;
void main() throws InterruptedException {
var bothHaveRead = new CountDownLatch(2);
Runnable increment = () -> {
int seen = count; // 1. read
bothHaveRead.countDown();
awaitQuietly(bothHaveRead); // wait until the other thread has read too
count = seen + 1; // 2. add one and write
};
Thread t1 = Thread.ofPlatform().start(increment);
Thread t2 = Thread.ofPlatform().start(increment);
t1.join();
t2.join();
IO.println("two increments ran, starting from 5");
IO.println("count = " + count);
}
void awaitQuietly(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
Imprime:
two increments ran, starting from 5
count = 6
Un CountDownLatch(2) empieza en 2. countDown() lo baja, y await() bloquea hasta que llega a 0. Cada hilo lee 5, hace la cuenta regresiva y luego espera a que el otro hilo también la haga. Así que los dos hilos siempre tienen 5 antes de que alguno escriba, y los dos escriben 6. Lo ejecutamos 20 veces y obtuvimos 6 todas las veces.
Para ser honestos, el latch exagera. Sin él, el planificador (scheduler) solo a veces detiene un hilo entre la lectura y la escritura. En 400.000 incrementos, “a veces” es seguido, como mostraron las cinco ejecuciones de arriba. El latch elige el mal momento a propósito para que lo veas siempre.
Ver la actualización perdida
Dos hilos ejecutan count++ una vez cada uno sobre un count compartido que empieza en 5:
Dos hilos ejecutan count++ una vez cada uno sobre un count compartido de 5. Los dos leen 5 antes de que alguno escriba, así que los dos escriben 6, y se pierde un incremento. El latch del programa de arriba fuerza este orden; sin él, el planificador elige este orden solo algunas veces.
Aquí están esos pasos en palabras, por si la animación no se reproduce:
countes 5. T1 y T2 ejecutancount++una vez cada uno.- T1 lee
county guarda 5 en su propia copia local. - Antes de que T1 escriba nada, T2 también lee
county guarda 5. - T1 suma 1 a su 5 y escribe 6.
- T2 suma 1 a su 5 y también escribe 6, encima del 6 de T1.
- Hubo dos incrementos, pero
countpasó de 5 a 6. Se perdió un incremento.
Explicado como si tuvieras diez años
Dos chicos llevan los puntos en una sola pizarra. Cada vez que su equipo anota, un chico lee la pizarra, suma 1 de cabeza y escribe el número nuevo.
Los dos equipos anotan a la vez. El chico A lee 5 y piensa “6”. El chico B también lee 5 y piensa “6”. El chico A escribe 6. El chico B lo borra y escribe 6. Se anotaron dos puntos, y la pizarra subió uno. Un punto desapareció, y la pizarra se ve perfectamente normal.
synchronized es un único marcador. Solo el chico que tiene el marcador puede leer la pizarra y escribir en ella. El otro chico espera el marcador, y después lee 6 y escribe 7.
La versión precisa
count++ sobre un campo se compila en tres pasos de bytecode: leer el campo, sumar 1, guardar el campo. Otro hilo puede ejecutarse entre dos cualesquiera de ellos. Cuando dos secuencias de leer, modificar y escribir se solapan, el segundo guardado pisa al primero, y se basa en una lectura vieja.
Java no promete nada sobre cómo se intercalan los hilos, así que un programa como este no tiene una respuesta fija. El compilador JIT también puede agrandar el hueco. Tiene permitido guardar count en un registro de la CPU durante un tramo de iteraciones y escribirlo de vuelta más tarde, y esa es una de las formas en que una ejecución puede perder muchos más incrementos de los que imaginarías.
Dónde falla la analogía: los chicos se ven entre sí cuando van hacia la pizarra. Los hilos no. count++ nunca comprueba si hay otro hilo, y solo espera si agregas un lock. Y el marcador tiene que ser el mismo para los dos chicos: dos hilos que tienen dos locks distintos no se esperan en absoluto.
synchronized: un hilo a la vez
Todo objeto de Java tiene un lock incorporado, llamado su monitor. Un método synchronized toma el monitor de this antes de ejecutarse y lo libera al retornar, incluso si sale por una excepción. Un segundo hilo que lo llama sobre el mismo objeto espera.
Aquí está otra vez el programa forzado, con increment marcado como synchronized. Ahora el latch espera como máximo 300 milisegundos, porque el otro hilo no puede entrar para hacer la cuenta regresiva:
int count = 5;
int timedOut = 0;
synchronized void increment(CountDownLatch bothHaveRead) {
int seen = count;
bothHaveRead.countDown();
if (!awaitBriefly(bothHaveRead)) {
timedOut++; // the other thread never got in to read
}
count = seen + 1;
}
void main() throws InterruptedException {
var bothHaveRead = new CountDownLatch(2);
Thread t1 = Thread.ofPlatform().start(() -> increment(bothHaveRead));
Thread t2 = Thread.ofPlatform().start(() -> increment(bothHaveRead));
t1.join();
t2.join();
IO.println("count = " + count);
IO.println("waits that gave up: " + timedOut);
}
boolean awaitBriefly(CountDownLatch latch) {
try {
return latch.await(300, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
Imprime:
count = 7
waits that gave up: 1
El hilo que consigue primero el monitor lee 5 y hace la cuenta regresiva. Después espera una segunda lectura que no puede ocurrir, porque el otro hilo está bloqueado fuera de increment. A los 300 ms se rinde y escribe 6. Recién entonces puede entrar el segundo hilo. Lee 6, encuentra el latch ya en 0 y escribe 7. El intercalado malo ahora es imposible, no solo poco probable.
Con un latch.await() simple y sin timeout, este programa se quedaría colgado para siempre. El primer hilo tendría el lock mientras espera al segundo. Eso es un deadlock, y tiene su propia sección más abajo.
Un método static synchronized toma el lock del objeto Class de la clase en lugar de this. Además, el monitor es reentrante: un hilo que lo tiene puede llamar a otro método synchronized del mismo objeto sin bloquearse a sí mismo.
Elegir el objeto del lock
Un bloque synchronized nombra el objeto cuyo monitor toma. Lo habitual es un campo private final que existe solo para usarse como lock:
class Counter {
private final Object lock = new Object();
private int count;
void increment() {
synchronized (lock) {
count++;
}
}
int get() {
synchronized (lock) {
return count;
}
}
}
void main() throws InterruptedException {
var counter = new Counter();
var threads = new ArrayList<Thread>();
for (int i = 0; i < 4; i++) {
threads.add(Thread.ofPlatform().start(() -> {
for (int n = 0; n < 100_000; n++) {
counter.increment();
}
}));
}
for (Thread t : threads) {
t.join();
}
IO.println("count = " + counter.get());
}
Imprime:
count = 400000
Ese es el programa inestable de antes, arreglado. Lo ejecutamos 20 veces y obtuvimos 400000 todas las veces.
Tres razones para preferir un lock privado a los métodos synchronized:
- Nadie más puede tomarlo. Cualquier código que tenga un
Counterpuede escribirsynchronized (counter)y bloquear tus métodos. Ningún código de afuera puede llegar alock. - El bloque puede ser chico. Toma el lock solo en las líneas que tocan el estado compartido, y haz el trabajo lento afuera.
- Las lecturas también necesitan el lock.
get()también lo toma, así que nunca ve una actualización a medias y siempre ve la última escritura.
No uses como lock un objeto que cambia. synchronized (count) sobre un campo Integer toma el lock de un objeto distinto después de cada count++, porque el boxing crea un Integer nuevo. javac lo detecta: warning: [identity] attempt to synchronize on an instance of a value-based class. Ese es el nombre de la categoría en Java 25, y -Werror convierte la advertencia en una compilación fallida.
volatile: ver las escrituras de otro hilo
La visibilidad es el segundo problema del estado compartido. Un hilo escribe un campo, y otro hilo puede seguir viendo el valor viejo. Un campo volatile arregla eso: cada lectura ve la escritura más reciente. El uso clásico es un flag de parada:
volatile boolean running = true;
void main() throws InterruptedException {
var started = new CountDownLatch(1);
long[] loops = new long[1];
Thread worker = Thread.ofPlatform().start(() -> {
started.countDown();
while (running) {
loops[0]++;
}
});
started.await();
running = false;
worker.join();
IO.println("worker stopped: " + !worker.isAlive());
}
Imprime:
worker stopped: true
El worker gira en el bucle hasta que running es false, y join retorna en cuanto lo nota.
Esto nos sorprendió. Quitamos volatile, hicimos que main durmiera medio segundo antes de bajar el flag, y lo ejecutamos cinco veces con un timeout de cinco segundos. El worker nunca se detuvo, ni una vez. La razón probable es el JIT: nada dentro del bucle escribe running, así que el bucle compilado tiene permitido dejar de leer el campo. Sin volatile ni un lock, eso es legal.
Eso sí, volatile no hace seguro a count++. Un volatile int count hace que cada lectura vea el valor más reciente, pero leer, sumar y escribir siguen siendo tres pasos. Los dos hilos pueden leer 5 y escribir 6, exactamente como en la animación. volatile arregla la visibilidad, no la atomicidad. Úsalo para un flag que un hilo pone y otros leen.
Atomics: compare-and-set
java.util.concurrent.atomic tiene clases cuyas actualizaciones son pasos únicos e indivisibles. Se basan en compare-and-set (comparar y asignar): “pon el valor en 6, pero solo si todavía es 5”. Si otro hilo lo cambió en el medio, la llamada falla y devuelve false, y lo intentas de nuevo. Aquí está una vez más el intercalado forzado, con un AtomicInteger:
void main() throws InterruptedException {
var count = new AtomicInteger(5);
var retries = new AtomicInteger();
var bothHaveRead = new CountDownLatch(2);
Runnable increment = () -> {
int seen = count.get();
bothHaveRead.countDown();
awaitQuietly(bothHaveRead);
while (!count.compareAndSet(seen, seen + 1)) {
retries.incrementAndGet(); // someone changed it: read again
seen = count.get();
}
};
Thread t1 = Thread.ofPlatform().start(increment);
Thread t2 = Thread.ofPlatform().start(increment);
t1.join();
t2.join();
IO.println("count = " + count.get());
IO.println("failed compare-and-sets: " + retries.get());
}
void awaitQuietly(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
Imprime:
count = 7
failed compare-and-sets: 1
Los dos hilos siguen leyendo 5. Uno gana compareAndSet(5, 6). El compareAndSet(5, 6) del otro falla, porque ahora el valor es 6. Vuelve a leer 6 y asigna 7. No se pierde nada, y nadie esperó un lock.
Rara vez escribes ese bucle tú mismo. incrementAndGet() hace exactamente esto por dentro, y updateAndGet(x -> x * 2) lo hace para cualquier función:
void main() throws InterruptedException {
var count = new AtomicInteger();
var total = new LongAdder();
var threads = new ArrayList<Thread>();
for (int i = 0; i < 4; i++) {
threads.add(Thread.ofPlatform().start(() -> {
for (int n = 0; n < 100_000; n++) {
count.incrementAndGet();
total.increment();
}
}));
}
for (Thread t : threads) {
t.join();
}
IO.println("AtomicInteger: " + count.get());
IO.println("LongAdder: " + total.sum());
}
Imprime:
AtomicInteger: 400000
LongAdder: 400000
LongAdder es para contadores que muchos hilos incrementan todo el tiempo. Cuando muchos hilos se pelean por un mismo AtomicInteger, los compare-and-set fallan y se reintentan una y otra vez. Un LongAdder les da a los hilos celdas separadas donde sumar, y suma las celdas cuando llamas a sum(). Los incrementos se vuelven más baratos y las lecturas un poco más caras. Y sum() no es una instantánea mientras los hilos siguen sumando, así que léelo cuando hayan terminado, o cuando te sirva un valor aproximado.
Los atomics protegen un valor. Cuando dos campos tienen que cambiar juntos, como un saldo y un contador de transacciones, usa un lock.
Deadlock: dos locks en orden opuesto
Un deadlock ocurre cuando dos hilos tienen cada uno un lock que el otro necesita, así que los dos esperan para siempre. La causa habitual son dos locks tomados en órdenes opuestos. Este programa lo fuerza con un latch, lo detecta y termina:
import java.lang.management.ManagementFactory;
final Object accountA = new Object();
final Object accountB = new Object();
void main() throws InterruptedException {
var bothHoldOne = new CountDownLatch(2);
Thread t1 = Thread.ofPlatform().daemon().start(() -> {
synchronized (accountA) {
bothHoldOne.countDown();
awaitQuietly(bothHoldOne);
synchronized (accountB) {
IO.println("t1 moved money from A to B");
}
}
});
Thread t2 = Thread.ofPlatform().daemon().start(() -> {
synchronized (accountB) {
bothHoldOne.countDown();
awaitQuietly(bothHoldOne);
synchronized (accountA) {
IO.println("t2 moved money from B to A");
}
}
});
var threadBean = ManagementFactory.getThreadMXBean();
long[] stuck = threadBean.findDeadlockedThreads();
while (stuck == null) {
Thread.sleep(10);
stuck = threadBean.findDeadlockedThreads();
}
IO.println("deadlock detected: " + stuck.length + " threads");
IO.println("t1 state: " + t1.getState() + ", t2 state: " + t2.getState());
}
void awaitQuietly(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
Imprime:
deadlock detected: 2 threads
t1 state: BLOCKED, t2 state: BLOCKED
t1 tiene A y espera B. t2 tiene B y espera A. Ninguno de los mensajes de transferencia se imprime nunca. findDeadlockedThreads() le pide a la JVM los hilos atascados en un ciclo como este, y devuelve null mientras no haya ninguno. El bucle consulta una y otra vez hasta que el ciclo se forma.
Un hilo bloqueado en synchronized no se puede interrumpir, así que nada puede destrabar a estos dos. Son hilos daemon, así que la JVM termina de todos modos cuando main retorna. Lo ejecutamos 20 veces: imprimió las mismas dos líneas cada vez y terminó en más o menos un segundo y medio.
En un servidor real lo verías desde afuera. jcmd <pid> Thread.print vuelca todos los hilos, y para este programa incluyó Found one Java-level deadlock:, seguido de qué hilo tiene qué monitor.
La solución es una regla: todos los hilos toman los locks en el mismo orden.
final Object accountA = new Object();
final Object accountB = new Object();
int transfers = 0;
void transfer() {
synchronized (accountA) { // always A first
synchronized (accountB) { // then B
transfers++;
}
}
}
void main() throws InterruptedException {
Runnable work = () -> {
for (int n = 0; n < 50_000; n++) {
transfer();
}
};
Thread t1 = Thread.ofPlatform().start(work);
Thread t2 = Thread.ofPlatform().start(work);
t1.join();
t2.join();
IO.println("transfers = " + transfers);
}
Imprime:
transfers = 100000
No puede existir un hilo que tenga B mientras espera A, así que el ciclo no se puede formar. Con cuentas reales, elige el orden a partir de algo estable, como el id de la cuenta: toma primero el lock del id más chico.
ReentrantLock: un lock al que puedes renunciar
java.util.concurrent.locks.ReentrantLock hace lo mismo que synchronized, con llamadas explícitas a lock() y unlock(). El unlock siempre va en un finally, así que una excepción no puede dejar el lock tomado:
class Counter {
private final ReentrantLock lock = new ReentrantLock();
private int count;
void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
int get() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
void main() throws InterruptedException {
var counter = new Counter();
Runnable work = () -> {
for (int n = 0; n < 100_000; n++) {
counter.increment();
}
};
Thread t1 = Thread.ofPlatform().start(work);
Thread t2 = Thread.ofPlatform().start(work);
t1.join();
t2.join();
IO.println("count = " + counter.get());
}
Imprime:
count = 200000
Es más código que synchronized para el mismo resultado. ReentrantLock se gana su lugar cuando necesitas algo que synchronized no puede hacer, y lo principal es rendirse. tryLock con un timeout espera un tiempo limitado y devuelve false si el lock nunca quedó libre:
void main() throws InterruptedException {
var lock = new ReentrantLock();
boolean[] gotIt = new boolean[1];
lock.lock(); // main holds the lock for the whole test
try {
Thread other = Thread.ofPlatform().start(() -> {
try {
gotIt[0] = lock.tryLock(100, TimeUnit.MILLISECONDS);
if (gotIt[0]) {
lock.unlock();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
other.join();
} finally {
lock.unlock();
}
IO.println("other thread got the lock: " + gotIt[0]);
}
Imprime:
other thread got the lock: false
En el programa del deadlock, tryLock sobre el segundo lock le permitiría a un hilo echarse atrás, liberar su primer lock y reintentar, en lugar de esperar para siempre. lockInterruptibly() te da una espera que interrupt() puede terminar, algo que synchronized nunca permite.
Un ReadWriteLock, normalmente ReentrantReadWriteLock, tiene dos lados. Cualquier cantidad de hilos puede tener el lock de lectura a la vez, pero el lock de escritura es exclusivo y espera a que salgan todos los lectores. Ayuda cuando las lecturas superan por mucho a las escrituras y cada lectura tarda un rato. Para secciones críticas cortas muchas veces no es más rápido, así que empieza con un lock simple.
Colecciones thread-safe, y comprobar y luego actuar
Una colección thread-safe hace segura cada llamada individual, pero no una secuencia de llamadas. Collections.synchronizedList envuelve una lista para que cada método tome un mismo lock:
void main() throws InterruptedException {
List<Integer> numbers = Collections.synchronizedList(new ArrayList<>());
var threads = new ArrayList<Thread>();
for (int i = 0; i < 4; i++) {
threads.add(Thread.ofPlatform().start(() -> {
for (int n = 0; n < 1_000; n++) {
numbers.add(n);
}
}));
}
for (Thread t : threads) {
t.join();
}
long sum = 0;
synchronized (numbers) { // iterating needs the list's own lock
for (int n : numbers) {
sum += n;
}
}
IO.println("size = " + numbers.size() + ", sum = " + sum);
}
Imprime:
size = 4000, sum = 1998000
Cada add es seguro. Pero el bucle no es una sola llamada: son muchas llamadas a next(), así que tienes que tomar tú mismo el lock de la lista, como dice la documentación. Lo cambiamos por un ArrayList simple y lo ejecutamos 30 veces. 29 ejecuciones se quedaron cortas de 4000, y dos de ellas además lanzaron ArrayIndexOutOfBoundsException dentro de un hilo.
ConcurrentHashMap va más lejos. Las lecturas no bloquean, los que escriben toman el lock solo de una parte chica del mapa, e iterar nunca lanza ConcurrentModificationException. Aun así, no puede proteger código que comprueba y luego actúa en dos llamadas:
void main() throws InterruptedException {
var sessions = new ConcurrentHashMap<String, String>();
var created = new AtomicInteger();
var bothHaveChecked = new CountDownLatch(2);
Runnable login = () -> {
if (!sessions.containsKey("ana")) { // check
bothHaveChecked.countDown();
awaitQuietly(bothHaveChecked);
int n = created.incrementAndGet();
sessions.put("ana", "session-" + n); // then act
}
};
Thread t1 = Thread.ofPlatform().start(login);
Thread t2 = Thread.ofPlatform().start(login);
t1.join();
t2.join();
IO.println("sessions created for ana: " + created.get());
IO.println("entries in the map: " + sessions.size());
}
void awaitQuietly(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
Imprime:
sessions created for ana: 2
entries in the map: 1
El latch detiene a los dos hilos entre la comprobación y el put, así que los dos no ven ninguna sesión y los dos crean una. El mapa tiene una entrada, pero se crearon dos sesiones, y el segundo put reemplazó al primero en silencio. Es la misma actualización perdida que con count++, un nivel más arriba.
La solución es entregarle toda la decisión al mapa, en una sola llamada:
void main() throws InterruptedException {
var sessions = new ConcurrentHashMap<String, String>();
var created = new AtomicInteger();
var wordCounts = new ConcurrentHashMap<String, Integer>();
var words = List.of("red", "blue", "red", "green", "red", "blue");
var threads = new ArrayList<Thread>();
for (int i = 0; i < 8; i++) {
threads.add(Thread.ofPlatform().start(() -> {
sessions.computeIfAbsent("ana", user -> "session-" + created.incrementAndGet());
for (String w : words) {
wordCounts.merge(w, 1, Integer::sum);
}
}));
}
for (Thread t : threads) {
t.join();
}
IO.println("sessions created for ana: " + created.get());
IO.println("word counts: " + new TreeMap<>(wordCounts));
}
Imprime:
sessions created for ana: 1
word counts: {blue=16, green=8, red=24}
Ocho hilos pidieron la sesión de Ana, y se creó exactamente una. ConcurrentHashMap.computeIfAbsent ejecuta la función como máximo una vez por clave, y otro hilo que actualiza esa clave puede tener que esperar mientras se ejecuta. merge(w, 1, Integer::sum) es el “suma uno, o empieza en uno” atómico. El programa copia el mapa a un TreeMap antes de imprimirlo, así que las claves salen ordenadas.
Aquí también intentamos forzar el mal momento, con un latch dentro de la función y un timeout. En cinco ejecuciones el segundo hilo nunca logró entrar. Esperó, y después encontró la sesión de Ana ya creada. Como otros hilos pueden quedar esperándola, mantén esa función corta, y no actualices el mismo mapa desde adentro.
Los datos inmutables no necesitan lock
La forma más fácil de ser thread-safe son los datos que no pueden cambiar. Si nada escribe, no hay carrera que perder. Un record con un List.copyOf en su constructor compacto se puede pasar sin riesgo a cualquier cantidad de hilos:
record Order(String customer, List<String> items) {
Order {
items = List.copyOf(items);
}
}
void main() throws InterruptedException {
var items = new ArrayList<>(List.of("tea", "cake"));
var order = new Order("ana", items);
items.add("soup"); // changes our list, not the order's copy
int[] sizes = new int[4];
var threads = new ArrayList<Thread>();
for (int i = 0; i < sizes.length; i++) {
int slot = i;
threads.add(Thread.ofPlatform().start(() -> sizes[slot] = order.items().size()));
}
for (Thread t : threads) {
t.join();
}
IO.println("every thread saw: " + Arrays.toString(sizes));
IO.println(order);
}
Imprime:
every thread saw: [2, 2, 2, 2]
Order[customer=ana, items=[tea, cake]]
Los campos del record son final, y List.copyOf le dio una lista no modificable propia. El add que hace después quien lo llamó no puede alcanzarla. La parte sobre records explica por qué importa la copia. Para “cambiar” un valor inmutable, construye uno nuevo y publícalo a través de un único campo AtomicReference o volatile. Así, el único estado compartido que queda es una referencia.
Qué recordar
start()ejecuta código en un hilo nuevo, yrun()solo lo llama en el tuyo.join()espera a que un hilo termine antes de que leas sus resultados.count++es leer, sumar, escribir. Dos hilos pueden intercalar esos pasos y perder una actualización, y Java no tiene un detector de carreras que lo atrape.synchronizeddeja que un solo hilo a la vez tenga el monitor de un objeto. Usa como lock un objeto private final, y toma el lock tanto para leer como para escribir.volatilehace que una escritura sea visible para otros hilos. No hace atómico acount++.AtomicIntegeractualiza un valor con compare-and-set. UsaLongAdderpara contadores muy concurridos, y un lock cuando varios valores tienen que cambiar juntos.- El deadlock viene de tomar los locks en órdenes distintos. Tómalos siempre en un mismo orden, y usa
tryLockcon un timeout cuando necesites una salida. - Una colección thread-safe hace seguras las llamadas individuales. Comprobar y luego actuar sigue teniendo carreras, así que usa
computeIfAbsentymerge, y prefiere datos inmutables cuando puedas.
El estado mutable compartido necesita una regla sobre quién puede tocarlo, y sin esa regla decide el planificador.