Dos hilos que tocan el mismo dato al mismo tiempo sin ponerse de acuerdo: eso es una race condition. El resultado puede ser un número incorrecto, una excepción inesperada o, peor, un bug que solo aparece en producción bajo carga. Java lleva décadas ofreciendo herramientas para evitarlo, pero elegir la herramienta equivocada tiene un precio en rendimiento.
Por qué ocurre una race condition
El procesador no ejecuta instrucciones de Java, ejecuta bytecode compilado a instrucciones nativas. Una operación tan simple como contador++ se traduce en tres pasos: leer el valor de memoria, incrementarlo y escribirlo de vuelta. Si dos hilos hacen esos tres pasos a la vez, uno de los incrementos se pierde.
Un ejemplo clásico que demuestra el problema:
public class ContadorRoto {
private int valor = 0;
public void incrementar() {
valor++; // NO es thread-safe
}
public int getValor() {
return valor;
}
}
// En producción con 1000 hilos concurrentes,
// el resultado final raramente es 1000.
Si lanzas mil hilos que llaman a incrementar(), el valor final casi nunca es 1000. Puede ser 987, 1000 o cualquier número entre medias. El bug es no determinista, lo que lo hace especialmente difícil de reproducir.
La solución más antigua: synchronized
synchronized es el mecanismo original de Java para la exclusión mutua. Al marcar un método o bloque como sincronizado, garantizas que solo un hilo puede ejecutarlo a la vez.
public class ContadorSeguro {
private int valor = 0;
public synchronized void incrementar() {
valor++;
}
public synchronized int getValor() {
return valor;
}
}
Funciona, pero tiene un coste: cada vez que un hilo entra en el método, adquiere el monitor del objeto. Si hay muchos hilos esperando, se forma una cola. Para operaciones muy frecuentes o muy cortas, ese coste puede ser relevante.
volatile: visibilidad sin exclusión mutua
volatile resuelve un problema diferente. Sin él, la JVM puede cachear una variable en el registro del procesador, de modo que un hilo no ve las escrituras que hace otro hilo. Al declarar una variable como volatile, fuerzas que las lecturas y escrituras vayan siempre a memoria principal.
private volatile boolean activo = true;
public void detener() {
activo = false; // Visible para todos los hilos inmediatamente
}
volatile no sirve para el caso del contador, porque el problema ahí no es visibilidad sino la no-atomicidad del incremento. Pero sí es útil para flags de estado que un hilo escribe y otro lee.
AtomicInteger y las clases del paquete atomic
Para operaciones simples sobre números, java.util.concurrent.atomic ofrece una alternativa mucho más eficiente que synchronized. Usa instrucciones CAS (Compare-And-Swap) del procesador, que son atómicas a nivel hardware.
import java.util.concurrent.atomic.AtomicInteger;
public class ContadorAtomic {
private final AtomicInteger valor = new AtomicInteger(0);
public void incrementar() {
valor.incrementAndGet();
}
public int getValor() {
return valor.get();
}
}
El paquete incluye también AtomicLong, AtomicBoolean, AtomicReference y las variantes LongAdder y LongAccumulator, estas últimas más eficientes aún cuando hay muchos hilos escribiendo a la vez.
ReentrantLock: más control que synchronized
ReentrantLock hace lo mismo que synchronized pero te da más opciones: intentar adquirir el lock con timeout, interrumpir un hilo que espera, o usar condiciones múltiples dentro del mismo lock.
import java.util.concurrent.locks.ReentrantLock;
public class ContadorConLock {
private final ReentrantLock lock = new ReentrantLock();
private int valor = 0;
public void incrementar() {
lock.lock();
try {
valor++;
} finally {
lock.unlock(); // Siempre en un finally
}
}
}
Nota el finally: si olvidas liberar el lock cuando hay una excepción, bloqueas la aplicación para siempre. synchronized libera el monitor automáticamente, así que para casos sencillos es más seguro. Usa ReentrantLock cuando necesites las funciones extra.
java.util.concurrent: la caja de herramientas
Para escenarios más complejos, el paquete java.util.concurrent tiene estructuras listas para usar.
ConcurrentHashMap
HashMap no es thread-safe. Si varios hilos escriben al mismo tiempo, puedes acabar con una estructura corrompida o incluso un bucle infinito en versiones antiguas de Java. La solución es ConcurrentHashMap, que segmenta el mapa internamente para permitir lecturas y escrituras concurrentes con poca contención.
ConcurrentHashMap<String, Integer> mapa = new ConcurrentHashMap<>();
mapa.put("clave", 1);
mapa.computeIfAbsent("otra", k -> calcularValor(k));
BlockingQueue
BlockingQueue es perfecta para el patrón productor-consumidor. El productor añade elementos a la cola; el consumidor los saca. Si la cola está vacía, el consumidor espera. Si está llena, el productor espera.
BlockingQueue<Tarea> cola = new LinkedBlockingQueue<>(100);
// Productor
cola.put(nuevaTarea); // Bloquea si la cola está llena
// Consumidor
Tarea t = cola.take(); // Bloquea si la cola está vacía
CountDownLatch y Semaphore
CountDownLatch permite que un hilo espere a que varios terminen. Semaphore limita cuántos hilos pueden acceder a un recurso al mismo tiempo, útil para controlar el acceso a una base de datos o a una API externa con rate limits.
// CountDownLatch: esperar a que 5 tareas terminen
CountDownLatch latch = new CountDownLatch(5);
// Cada tarea llama: latch.countDown()
latch.await(); // El hilo principal espera aquí
// Semaphore: máximo 10 conexiones simultáneas
Semaphore semaforo = new Semaphore(10);
semaforo.acquire();
try {
// Acceso al recurso limitado
} finally {
semaforo.release();
}
Virtual threads (Java 21+, Project Loom)
Hasta Java 21, cada Thread de Java mapeaba a un hilo del sistema operativo. Crear miles de hilos era caro en memoria (cada uno ocupa unos 512 KB de stack por defecto). Con los virtual threads de Project Loom, puedes lanzar millones de hilos ligeros gestionados por la propia JVM.
// Thread de plataforma (el clásico)
Thread t1 = new Thread(() -> hacerAlgo());
// Virtual thread (Java 21+)
Thread t2 = Thread.ofVirtual().start(() -> hacerAlgo());
// Con ExecutorService
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> procesarPeticion());
}
}
Lo importante: los virtual threads no eliminan las race conditions. Si varios virtual threads acceden al mismo recurso sin sincronización, el problema sigue existiendo. Lo que cambia es el modelo de scheduling: la JVM puede suspender un virtual thread cuando hace una operación bloqueante (una consulta SQL, una llamada HTTP) y reutilizar el hilo de plataforma subyacente para otro virtual thread. Así consigues un throughput mucho mayor sin necesidad de programación reactiva.
Hay un matiz importante con synchronized y virtual threads: en Java 21, un bloque synchronized puede "pinear" el virtual thread a su carrier thread de plataforma, anulando los beneficios. Java 23 y 25 mejoran esto. Si usas Loom, prefiere ReentrantLock sobre synchronized en los puntos críticos.
El patrón "serializa el acceso, no el endpoint"
Cuando varios procesos necesitan modificar el mismo recurso (actualizar un saldo, reservar una plaza), la tentación es poner un synchronized en el método del servicio. Pero si tienes varias instancias del servicio en producción, la sincronización local no sirve de nada.
La solución es serializar el acceso a nivel de infraestructura: una cola o un scheduler que garantiza que las operaciones sobre el mismo recurso se procesan una a una. Un BlockingQueue por recurso, un executor con un solo hilo, o un sistema de mensajería como Kafka cumplen esa función mejor que un lock en memoria.
Esto conecta directamente con los patrones de diseño en Java, donde el patrón Command combinado con una cola permite procesar operaciones de forma segura y desacoplada.
Errores frecuentes
Double-checked locking sin volatile
// MALO: sin volatile, el compilador puede reordenar instrucciones
private static Singleton instancia;
public static Singleton getInstance() {
if (instancia == null) {
synchronized (Singleton.class) {
if (instancia == null) {
instancia = new Singleton(); // Puede publicarse sin estar inicializado
}
}
}
return instancia;
}
// CORRECTO: con volatile
private static volatile Singleton instancia;
HashMap en contexto concurrente
Ya se ha mencionado, pero vale la pena repetirlo: nunca uses HashMap, ArrayList o HashSet desde varios hilos sin sincronización externa. Para mapas, usa ConcurrentHashMap. Para listas de solo lectura compartida, Collections.unmodifiableList() o List.of().
Cuándo usar qué
- volatile: flags de estado simples, publicación de referencias inmutables.
- AtomicInteger / AtomicLong: contadores y acumuladores con alta contención.
- synchronized: bloques cortos y simples donde la legibilidad importa más que el rendimiento fino.
- ReentrantLock: necesitas timeout, interrupciones o múltiples condiciones.
- ConcurrentHashMap / CopyOnWriteArrayList: colecciones compartidas con muchas lecturas.
- BlockingQueue: patrón productor-consumidor.
- Virtual threads: muchas tareas IO-bound en Java 21+, sin cambiar la lógica de negocio.
Con Java 25 LTS, los virtual threads están completamente maduros y la integración con synchronized mejorada. Si arrancas un proyecto nuevo, son el punto de partida para cualquier servidor con carga concurrente.
La concurrencia en Java no es solo una cuestión de poner synchronized en el método correcto. Requiere entender qué garantías necesitas (atomicidad, visibilidad, orden), qué coste puedes asumir y qué escala tienes. Con las herramientas del paquete java.util.concurrent y los virtual threads, tienes lo que necesitas para la mayoría de los casos reales.
Referencia oficial: java.util.concurrent (Java 21 API).
Imagen: Pexels / Daniil Komov
