Guía definitiva para dominar concurrencia en Java
Esta guía compacta te lleva desde los fundamentos hasta prácticas avanzadas para escribir código concurrente y seguro en Java. Cubriré el modelo de memoria, primitivas básicas, utilidades de alto nivel, patrones, errores comunes, herramientas de debugging y consejos de rendimiento. Incluye ejemplos prácticos y una estructura de proyecto mínima para que puedas experimentar.
1. Fundamentos y el Java Memory Model (JMM)
La concurrencia no es solo hilos: es cómo múltiples hilos ven y actualizan memoria compartida. Conceptos clave:
- Visibilidad: cambios por un hilo pueden no ser visibles a otro sin sincronzación.
- Atomicidad: operaciones indivisibles (p. ej. asignación de referencia, operaciones atómicas con AtomicInteger).
- Ordenamiento: el compilador/CPU puede reordenar instrucciones; las construcciones sincronizadas imponen barreras de memoria.
2. Primitivas básicas
Evita reinventar la sincronización. Usa las primitivas del JDK cuando sea posible:
synchronized— fácil, levanta un monitor; correcto para pequeños bloques críticos.volatile— garantiza visibilidad; no garantiza atomicidad para ++.java.util.concurrent.locks.ReentrantLock— funciones avanzadas: tryLock, interruptible locking, condiciones.- Atomic classes:
AtomicInteger,AtomicReference, etc., para operaciones atomicas de bajo coste.
// Ejemplo: uso correcto de volatile para bandera de parada
public class VolatileFlag {
private volatile boolean running = true;
public void stop() { running = false; }
public void work() {
while (running) {
// trabajo... la lectura ve cambios porque es volatile
}
}
}
3. Utilidades de alto nivel (recomendadas)
No controles hilos a mano. Emplea las utilidades del paquete java.util.concurrent:
ExecutorService/ThreadPoolExecutor— gestión de hilos y pools.CompletableFuture— composición asíncrona y manejo de excepciones.ConcurrentHashMap— mapas seguros y escalables.CountDownLatch,CyclicBarrier,Semaphore,DelayQueue— sincronización compleja.ForkJoinPool— trabajos recursivos y paralelismo por divide-and-conquer.
// Ejemplo: ejecutar tareas con ExecutorService y shutdown correcto
ExecutorService pool = Executors.newFixedThreadPool(4);
try {
List> futures = new ArrayList<>();
for (int i = 0; i < 8; i++) {
final int id = i;
futures.add(pool.submit(() -> {
Thread.sleep(100);
return "tarea-" + id;
}));
}
for (Future f : futures) {
System.out.println(f.get());
}
} finally {
pool.shutdown(); // nunca olvidar
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
pool.shutdownNow();
}
}
4. CompletableFuture: programación asíncrona práctica
// Encadenar tareas asíncronas y manejar excepciones
CompletableFuture.supplyAsync(() -> fetchFromRemote())
.thenApply(this::parse)
.thenCompose(this::fetchRelatedData)
.exceptionally(ex -> {
log.warn("falló: {}", ex.getMessage());
return fallbackValue();
})
.thenAccept(this::processResult);
Usa thenCompose para evitar anidamiento de futures y handle/exceptionally para robustez.
5. Patrones y arquitecturas comunes
- Producer-Consumer con
BlockingQueue. - Actor model (p. ej. Akka) para aislar estado y reducir bloqueos.
- Immutable objects + message passing para eliminar sincronización.
- Bulkhead y Circuit Breaker para tolerancia en sistemas distribuidos.
6. Errores críticos y cómo evitarlos
- Deadlocks: evita bloquear múltiples locks en distinto orden. Preferir tryLock con timeout.
- Race conditions: protege estados mutables; prefiere atomics o estructuras concurrentes.
- Busy-waiting: no usar bucles activos; emplea
wait/notifyoBlockingQueue. - Fugas de hilos: siempre cerrar pools (shutdown) y usar
ThreadFactorycon threads daemon donde tenga sentido.
// Ejemplo de deadlock (NO hacer esto)
class A { synchronized void a(B b) { b.last(); } synchronized void last() {} }
class B { synchronized void b(A a) { a.last(); } synchronized void last() {} }
// Si hilo1 llama a a(a->b) y hilo2 a b(b->a) puede quedar en deadlock
7. Rendimiento y escalabilidad
- Usa
ConcurrentHashMapen vez de sincronizar un HashMap compartido. - Dimensiona pools por trabajo: I/O-bound necesita más hilos; CPU-bound se aproxima al núm. de cores.
- Evita sincronizar grandes secciones de código; reduce la contención con particionamiento o estructuras lock-free.
- Prefiere batch processing y backpressure para picos de carga.
8. Debugging y testing
- Herramientas: jstack, jmap, jvisualvm, async-profiler, Flight Recorder.
- Inspecciona hilos bloqueados con
jstacky busca "BLOCKED" o "WAITING". - Determinismo: tests concurrency flakey. Reproduce con mayor contención y herramientas como jcstress para pruebas de memoria.
- Usa logs y trazas específicas de hilos (Thread.currentThread().getName()) en reproducciones.
9. Estructura mínima de proyecto para experimentar
concurrency-demo/
├─ src/main/java/com/example/concurrency/
│ ├─ ExecutorExamples.java
│ ├─ CompletableFutureExamples.java
│ └─ LockAndAtomicExamples.java
├─ src/test/java/... (pruebas de concurrencia)
├─ build.gradle (o pom.xml)
└─ README.md
Incluye pruebas que ejecuten carga controlada y casos límite (timeouts, cancelaciones).
10. Ejemplos completos rápidos
// Safe cache con ConcurrentHashMap + computeIfAbsent
public class SafeCache {
private final ConcurrentHashMap map = new ConcurrentHashMap<>();
public V getOrCompute(K key, Function mapping) {
return map.computeIfAbsent(key, mapping);
}
}
// ForkJoin example (sum array)
class SumTask extends RecursiveTask<Long> {
private final long[] arr; int lo, hi; static final int THRESHOLD = 1_000;
SumTask(long[] arr, int lo, int hi) { this.arr = arr; this.lo = lo; this.hi = hi; }
protected Long compute() {
if (hi - lo <= THRESHOLD) {
long s = 0; for (int i = lo; i < hi; i++) s += arr[i];
return s;
}
int mid = (lo + hi) >>> 1;
SumTask left = new SumTask(arr, lo, mid);
SumTask right = new SumTask(arr, mid, hi);
left.fork();
return right.compute() + left.join();
}
}
11. Buenas prácticas resumidas
- Prefiere utilidades del JDK por sobre manejo manual de Threads.
- Minimiza sección crítica y tamaño del lock.
- Valida con pruebas de estrés y herramientas de profiling.
- Documenta invariantes y orden de adquisición de locks para evitar deadlocks.
Consejo avanzado: explora Structured Concurrency (propuestas como Project Loom / Virtual Threads) y adapta tu diseño a modelos de concurrencia cooperativa; muchas aplicaciones I/O-bound simplifican su diseño al mover la mayor parte de la complejidad al runtime. Antes de reescribir, prueba con un benchmark representativo y mide latencia y throughput.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación