Guía definitiva de concurrencia en Java para desarrolladores

java Guía definitiva de concurrencia en Java para desarrolladores

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

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión