Guía completa de concurrencia en Java para desarrolladores
Esta guía reúne patrones, errores comunes, herramientas y ejemplos prácticos para escribir código concurrente seguro y eficiente en Java. No es una referencia exhaustiva del JMM, pero sí un manual práctico para aplicar en producción.
Resumen rápido de conceptos clave
- Hilos (Threads): unidades de ejecución.
- JMM (Java Memory Model): define la visibilidad y reordenamientos permitidos.
- Volatile: garantiza visibilidad y orden de lectura/escritura para la variable.
- Synchronized y Locks: exclusión mutuamente, orden de adquisición y memoria.
- Atómicos (AtomicInteger, etc.): operaciones atómicas sin locks.
- Concurrent collections: estructuras optimizadas para concurrencia (ConcurrentHashMap, CopyOnWriteArrayList).
- Executors y CompletableFuture: abstracciones para gestionar y componer tareas.
Patrones y técnicas con ejemplos
1) Volatile: cuándo usarlo
Volatile asegura visibilidad y evita reordenamientos en torno a esa variable. Úsalo para flags o indicadores simples, no para invariantes compuestas.
public class StopFlag {
private volatile boolean stop = false;
public void requestStop() { stop = true; }
public void loop() {
while (!stop) {
// trabajo
}
}
}
No reemplaza la sincronización si necesitas actualizar múltiples variables de forma consistente.
2) Double-checked locking (patrón correctamente aplicado)
Inicialización perezosa segura con volatile:
public class Singleton {
private static volatile Singleton INSTANCE;
private Singleton() {}
public static Singleton getInstance() {
if (INSTANCE == null) { // primera verificación (sin lock)
synchronized (Singleton.class) {
if (INSTANCE == null) { // segunda verificación (con lock)
INSTANCE = new Singleton(); // write to volatile
}
}
}
return INSTANCE;
}
}
Sin volatile, el constructor podría 'escaparse' antes de terminar.
3) Locks: ReentrantLock y ReadWriteLock
ReentrantLock ofrece más control (tryLock, fairness) y un modo de desbloqueo en finally:
private final ReentrantLock lock = new ReentrantLock();
public void critical() {
lock.lock();
try {
// sección crítica
} finally {
lock.unlock();
}
}
ReadWriteLock mejora concurrencia cuando hay muchas lecturas y pocas escrituras:
private final ReadWriteLock rw = new ReentrantReadWriteLock();
private final Map<K,V> map = new HashMap<>();
public V get(K key) {
rw.readLock().lock();
try { return map.get(key); }
finally { rw.readLock().unlock(); }
}
public void put(K key, V value) {
rw.writeLock().lock();
try { map.put(key, value); }
finally { rw.writeLock().unlock(); }
}
4) Estructuras concurrentes: usar lo que ya existe
Evita sincronizar colecciones manualmente: usa ConcurrentHashMap, ConcurrentLinkedQueue, CopyOnWriteArrayList según el caso.
ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
// incremento seguro
counts.merge("key", 1, Integer::sum);
ConcurrentHashMap tiene operaciones atómicas útiles: compute, merge, putIfAbsent.
5) Atómicos y acumuladores
Para contadores y banderas sin locks:
AtomicInteger ai = new AtomicInteger(0);
ai.incrementAndGet();
LongAdder ad = new LongAdder(); // mejor en alta contención
ad.increment();
6) ExecutorService y composición con CompletableFuture
Evita crear Threads manualmente; usa pools y controla límites:
ExecutorService pool = Executors.newFixedThreadPool(8);
Future<String> f = pool.submit(() -> {
// tarea pesada
return "resultado";
});
// CompletableFuture para flujo asíncrono
CompletableFuture.supplyAsync(() -> heavy(), pool)
.thenApply(r -> transform(r))
.thenAccept(System.out::println);
Prefiere CompletableFuture para composición no bloqueante y manejo de excepciones.
7) ForkJoinPool y trabajo dividido
Para algoritmos divide-y-vencerás (trees, búsquedas, procesamientos):
ForkJoinPool fj = new ForkJoinPool();
class SumTask extends RecursiveTask<Integer> {
// split y join
}
int result = fj.invoke(new SumTask(data));
Errores comunes y cómo evitarlos
1) Confundir volatile con sincronización
Volatile garantiza visibilidad, no atomicidad ni consistencia de múltiples variables. Para invariantes compuestas usa locks o estructuras atómicas.
2) Deadlocks por orden de adquisición inconsistente
Evita deadlocks imponiendo un orden global para adquirir múltiples locks o usando tryLock con timeout y manejo de fallback.
// Problema clásico
synchronized(a) {
synchronized(b) { ... }
}
// Otro hilo hace: synchronized(b){ synchronized(a){...} }
// Solución: siempre adquirir en mismo orden: a -> b
3) Publicación insegura (Escape del this)
No publiques referencias parcialmente construidas desde el constructor:
public class Unsafe {
public Unsafe(EventSource src) {
src.registerListener(this::handle); // 'this' puede escapar antes de terminar
}
}
Solución: usar fábrica estática o construir y luego registrar.
4) Bloqueos excesivos y contención
No sincronices en métodos que hacen IO o trabajo pesado. Prefiere particionamiento de datos, locks finos o colecciones concurrentes.
Ejemplo práctico: caché concurrente con inicialización perezosa
Requisitos: cache que inicialice valores bajo demanda, evitando calcos (duplicates) y bloqueos globales.
public class LazyCache<K,V> {
private final ConcurrentHashMap<K, CompletableFuture<V>> map = new ConcurrentHashMap<>();
public V get(K key, Function<K,V> loader) {
try {
return map.computeIfAbsent(key, k ->
CompletableFuture.supplyAsync(() -> loader.apply(k))
).join();
} catch (CompletionException e) {
map.remove(key); // limpiar en caso de fallo
throw e;
}
}
}
Por qué funciona: computeIfAbsent asegura que solo habrá una entrada por clave; almacenar un CompletableFuture permite que otros hilos esperen sin recalcular.
Depuración y herramientas
- jstack / jcmd Thread.print para dump de hilos y detectar deadlocks.
- VisualVM, Java Mission Control para latencias y hotspots.
- Lock profiling tools (AsyncProfiler) para identificar contención.
Buenas prácticas rápidas
- Evita compartir estado mutable; cuando lo necesites, encapsúlalo claramente.
- Prefiere las colecciones y utilidades concurrentes de java.util.concurrent.
- Usa timeouts en lock/IO para evitar bloqueos indefinidos.
- Documenta las invariantes y el orden de adquisición de locks.
- Escribe tests de concurrencia (stress tests, property-based tests).
Estructura de proyecto ejemplo
concurrency-guide/
├─ src/main/java/com/example/concurrent/
│ ├─ Singleton.java
│ ├─ LazyCache.java
│ ├─ SafeCounter.java
│ └─ DeadlockDemo.java
└─ src/test/java/com/example/concurrent/
└─ ConcurrencyStressTest.java
Pruebas y stress testing
No confíes solo en pruebas unitarias. Añade:
- Tests multihilo con herramientas como JUnit + Executors.
- Stress tests con muchos hilos y duraciones largas.
- Fuzzing para interleavings (jcstress es útil para JMM tests).
Lecturas y especificaciones
- Java Memory Model (JSR-133) - especificación del JMM.
- Documentación de java.util.concurrent en la JDK.
- jcstress: framework para tests del JMM.
Consejo avanzado: diseña tu sistema pensando en la
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación