Guía definitiva para dominar concurrencia en Java

java Guía definitiva para dominar concurrencia en Java

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/notify o BlockingQueue.
  • Fugas de hilos: siempre cerrar pools (shutdown) y usar ThreadFactory con 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 ConcurrentHashMap en 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 jstack y 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.

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