Guía definitiva de concurrencia en Java para desarrolladores
La concurrencia en Java es una de las áreas que separa a los desarrolladores promedio de los expertos. Esta guía cubre conceptos clave, patrones, herramientas del JDK y ejemplos prácticos para que puedas escribir código concurrente seguro y eficiente.
Índice rápido
- Conceptos básicos: Threads, Runnable, Callable
- API de alto nivel: ExecutorService, ForkJoinPool, CompletableFuture
- Sincronización: synchronized, volatile, locks y atomics
- Colecciones concurrentes y patrones
- Errores comunes y cómo evitarlos
- Estrategias de rendimiento y troubleshooting
- Proyecto de ejemplo: estructura y código
1) Conceptos básicos
Thread es la unidad de ejecución. Runnable y Callable representan tareas.
// Runnable example
Runnable r = () -> System.out.println("Hello from Runnable");
new Thread(r).start();
// Callable example
import java.util.concurrent.*;
Callable<String> c = () -> {
Thread.sleep(100);
return "Result";
};
ExecutorService es = Executors.newSingleThreadExecutor();
Future<String> f = es.submit(c);
String res = f.get();
es.shutdown();
Por qué preferir ExecutorService sobre crear Threads: gestión del pool, reutilización, control de concurrencia, shutdown y mejor integración con abstracciones modernas.
2) API de alto nivel
ExecutorService y ThreadPool
Patrón: separar producción de tareas del consumo. Ejemplo patrón trabajador/consumidor:
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int i = 0; i < 10; i++) {
final int id = i;
pool.submit(() -> {
System.out.println("Task " + id + " running on " + Thread.currentThread().getName());
});
}
pool.shutdown();
ForkJoinPool y paralelismo de divide y vencerás
Útil para tareas recursivas y CPU-bound. Implementa RecursiveTask o RecursiveAction.
import java.util.concurrent.*;
class SumTask extends RecursiveTask<Long>{
private final long[] arr; int lo, hi; int THRESHOLD = 10_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)/2;
SumTask left = new SumTask(arr,lo,mid);
SumTask right = new SumTask(arr,mid,hi);
left.fork();
return right.compute() + left.join();
}
}
CompletableFuture para composición asíncrona
Ideal para IO-bound y para encadenar etapas sin bloquear hilos.
CompletableFuture.supplyAsync(() -> heavyIoCall())
.thenApply(result -> process(result))
.thenAccept(finalRes -> System.out.println("Done: " + finalRes));
3) Sincronización y visibilidad
synchronized
Bloqueo reentrante asociado a un objeto. Útil para secciones críticas cortas.
public class Counter {
private int c = 0;
public synchronized void inc(){ c++; }
public synchronized int get(){ return c; }
}
volatile
Garantiza visibilidad inmediata entre hilos y evita reordenamientos. No es sustituto de locking cuando hay operaciones compuestas (read-modify-write).
private volatile boolean running = true;
// hilo A
running = false;
// hilo B
if (!running) { /* verá el cambio */ }
java.util.concurrent.locks
ReentrantLock, ReadWriteLock, Condition. Ofrecen mayor control (tryLock, lockInterruptibly, fairness).
ReentrantLock lock = new ReentrantLock();
if (lock.tryLock()){
try { /* sección crítica */ } finally { lock.unlock(); }
}
Atomics
Operaciones atómicas sin bloqueo con AtomicInteger, AtomicReference, LongAdder (mejor para contadores altamente contenciosos).
AtomicInteger ai = new AtomicInteger(0);
ai.incrementAndGet();
LongAdder ad = new LongAdder();
ad.increment();
// LongAdder ofrece mejor throughput en contención alta
4) Colecciones concurrentes y patrones
- ConcurrentHashMap: lecturas sin bloqueo, buen rendimiento en concurrencia.
- CopyOnWriteArrayList: perfecto para listas con muchas lecturas y pocas escrituras.
- BlockingQueue (ArrayBlockingQueue, LinkedBlockingQueue): ideales para producer/consumer.
BlockingQueue<String> q = new LinkedBlockingQueue<>();
// producer
q.put("item");
// consumer
String s = q.take();
5) Errores comunes y cómo evitarlos
- Deadlocks: evitar anidar locks; ordenar siempre adquisición o usar tryLock con timeout.
- Race conditions: proteger read-modify-write y usar atomics o locks.
- Visibility bugs: usar volatile o sincronización para publicar objetos correctamente.
- Thread leaks: siempre shutdown() o shutdownNow() en pools y cerrar recursos.
- Contención excesiva: medir y usar estructuras sin bloqueo (atomics, LongAdder) y pool adecuados.
// Ejemplo simple de deadlock
class A { synchronized void m(B b){ b.last(); } synchronized void last(){} }
class B { synchronized void n(A a){ a.last(); } synchronized void last(){} }
// Si hilo1: a.m(b) y hilo2: b.n(a) -> deadlock
// Mitigación: declarar un orden y adquirir en ese orden, o usar tryLock con timeout
6) Rendimiento y troubleshooting
Medir antes de optimizar. Herramientas: Java Flight Recorder, async-profiler, VisualVM, thread dumps (jstack).
- Evita bloquear en loops críticos.
- Usa pools dimensionados: cores * factor (IO-bound factor > 1, CPU-bound factor ≈ 1).
- Prefiere non-blocking para alta contención: ConcurrentHashMap, LongAdder, StampedLock para lecturas optimistas.
7) Proyecto práctico (estructura mínima)
Un micro-proyecto para experimentar concurrencia: un servicio que procesa tareas en paralelo, almacena resultados en ConcurrentHashMap y expone estado.
project-concurrency-demo/
├─ src/main/java/
│ ├─ com/example/demo/
│ │ ├─ App.java
│ │ ├─ TaskProcessor.java
│ │ └─ SharedState.java
└─ pom.xml (o build.gradle)
// App.java: inicia pool y envía tareas
// TaskProcessor.java: implementa Callable y usa atomics/locks donde haga falta
// SharedState.java: usa ConcurrentHashMap y LongAdder para métricas
// Minimal TaskProcessor.java
import java.util.concurrent.*;
public class TaskProcessor implements Callable<String>{
private final int id;
public TaskProcessor(int id){ this.id = id; }
public String call() throws Exception{
// simular IO
Thread.sleep(100);
return "task-" + id + "-done";
}
}
// Uso en App.java
ExecutorService pool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
for (int i=0;i<100;i++){
Future<String> f = pool.submit(new TaskProcessor(i));
// gestionar futures, por ejemplo con CompletableFuture.supplyAsync para no bloquear
}
pool.shutdown();
8) Checklist rápido antes de desplegar código concurrente
- ¿Se han cerrado/shutdown todos los pools en todas las rutas (incl. excepciones)?
- ¿Se han probado condiciones de carrera y escenarios con alta carga?
- ¿Se han recogido y analizado thread dumps y perfiles?
- ¿Se han definido límites y timeouts para locks y operaciones remotas?
9) Herramientas y lecturas recomendadas
- Java Concurrency in Practice (Brian Goetz)
- Documentación oficial de java.util.concurrent
- async-profiler, Java Flight Recorder, jstack
Consejo avanzado: cuando diseñes APIs concurrentes, decide si vas a exponer abstracciones bloqueantes o no bloqueantes y mantén esa decisión consistente; mezclar ambas sin contratos claros genera bugs sutiles. Advertencia: la mayoría de los problemas al escalar provienen de contención y bloqueo inadvertido — invierte tiempo en perfilado con cargas reales antes de optimizar.
Próximo paso sugerido: implementa el proyecto de ejemplo, instrumenta métricas (histogramas de latencia) y prueba con diferentes tamaños de pool y contención para entender el comportamiento en tu entorno
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación