Guía completa de concurrencia en Java para desarrolladores

java Guía completa de concurrencia en Java para desarrolladores

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<&gt();

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<&gt();

// 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<&gt();

    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

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