5 mejores prácticas para manejar concurrencia en Java (con ejemplos reales)

java 5 mejores prácticas para manejar concurrencia en Java (con ejemplos reales)

5 mejores prácticas para manejar concurrencia en Java (con ejemplos reales)

La concurrencia en Java es poderosa pero peligrosa: los errores aparecen en producción y son difíciles de reproducir. Aquí tienes cinco mejores prácticas —con el porqué y ejemplos— para escribir código concurrente, correcto y mantenible.

1) Evita el estado compartido mutante: prefiere inmutabilidad y paso de mensajes

Si puedes representar datos como objetos inmutables, te quitas gran parte de los problemas. Para comunicar resultados, usa colas concurrentes o flujos basados en mensajes en lugar de compartir variables mutables.

// Inmutable simple
final class User {
    private final String id;
    private final String name;
    public User(String id, String name) {
        this.id = id; this.name = name;
    }
    public String getId(){ return id; }
    public String getName(){ return name; }
}

// Paso de mensajes con BlockingQueue
BlockingQueue<User> queue = new LinkedBlockingQueue<>();
// Productor: queue.put(new User("1","A"));
// Consumidor: User u = queue.take();

2) Usa las abstractions del JDK: Executors, CompletableFuture, Concurrent Collections

No reinventes hilos. Executors encapsulan gestión de hilos, y CompletableFuture facilita la composición asíncrona.

ExecutorService exec = Executors.newFixedThreadPool(10);

// CompletableFuture con executor explícito
CompletableFuture.supplyAsync(() -> fetchFromRemote(), exec)
    .thenApplyAsync(result -> transform(result), exec)
    .exceptionally(ex -> {
        log.error("Ocurrió", ex);
        return fallback();
    });

// Cerrar correctamente
exec.shutdown();
try {
    if (!exec.awaitTermination(60, TimeUnit.SECONDS)) {
        exec.shutdownNow();
    }
} catch (InterruptedException ie) {
    exec.shutdownNow();
    Thread.currentThread().interrupt();
}

3) Elige la herramienta correcta para sincronización: AtomicX, Locks o sincronización

No uses synchronized por costumbre; usa Atomic cuando sea solo contadores/flags, ReentrantLock para bloqueo explícito (con tryLock) y ReadWriteLock cuando hay muchas lecturas y pocas escrituras.

// Atomic vs synchronized
class CounterAtomic {
    private final AtomicInteger c = new AtomicInteger();
    void inc(){ c.incrementAndGet(); }
    int get(){ return c.get(); }
}

class CounterSync {
    private int c;
    synchronized void inc(){ c++; }
    synchronized int get(){ return c; }
}

// ReentrantLock con try/finally
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // sección crítica
} finally {
    lock.unlock();
}

// Evitar esperas indefinidas: tryLock con timeout
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
    try { /* trabajo */ } finally { lock.unlock(); }
} else {
    // fallback o reintento
}

4) Prevén deadlocks y starvation

Reglas prácticas: ordenar siempre adquisición múltiple de locks, evitar bloqueos anidados largos, y usar timeouts cuando posible.

// Ejemplo: evitar deadlock mediante orden consistente
// Supongamos locks para recursos A y B
Object lockA = new Object();
Object lockB = new Object();

void safeTransfer(Account from, Account to, int amount) {
    // Ordenar por id para evitar inversion de locks
    Object first = from.getId() < to.getId() ? lockFor(from) : lockFor(to);
    Object second = first == lockFor(from) ? lockFor(to) : lockFor(from);
    synchronized(first) {
        synchronized(second) {
            // transferir
        }
    }
}

5) Maneja correctamente las excepciones y la finalización de tareas

Cuando ejecutas tareas en un pool, las excepciones no manejadas pueden perderse. En CompletableFuture usa exceptionally/handle; en Runnable envuelve para loguear; en ThreadPoolExecutor sobreescribe afterExecute si necesitas salud/telemetría.

// Wrapper para capturar excepciones en Runnable
Runnable safe(Runnable r) {
    return () -> {
        try {
            r.run();
        } catch (Throwable t) {
            log.error("Error en tarea", t);
            throw t; // o manejar según necesidad
        }
    };
}

// Ejemplo de uso de ForkJoinPool para tareas CPU-bound
ForkJoinPool fj = new ForkJoinPool();
fj.submit(() -> parallelCompute(data));

Consejos rápidos y reglas de oro

  • Regla de dimensionamiento: para tareas CPU-bound usa numCores; para IO-bound considera cores * (1 + wait/compute).
  • Preferir Collections concurrentes (ConcurrentHashMap, CopyOnWriteArrayList) sobre sincronización manual cuando aplique.
  • volatile: útil para visibilidad (flags), no para operaciones compuestas; AtomicInteger o sincronización para atomicidad.
  • Usa ThreadLocal para estado por hilo (p. ej. formateadores de fecha), pero limpia el state en pools reutilizados.
  • Escribe tests de concurrencia (fuzzing, stress tests) y usa herramientas como Java Flight Recorder, async-profiler o ConTest.

Pequeño checklist antes de mandar a producción

  • ¿Todos los executors se cierran? (shutdown/awaitTermination)
  • ¿Tienes timeouts para operaciones bloqueantes?
  • ¿Se documentó la política de retry y backoff en tareas asíncronas?
  • ¿Se eliminaron variables estáticas mutables innecesarias?
  • ¿Se han probado condiciones de carrera y límites de carga?

Si quieres un ejemplo aplicando estas prácticas en una micro-app (producer/consumer con CompletableFuture, manejo de errores y métricas), puedo generar un proyecto completo con estructura de carpetas, build (Maven/Gradle) y pruebas. Como siguiente paso, revisa los puntos donde el estado muta y aplica inmutabilidad o confinamiento de hilo; evitarás la mayoría de las sorpresas en producción.

Advertencia: las abstracciones facilitan la concurrencia, pero no reemplazan el diseño: modela tus invariantes y constrúyelos para que sean fáciles de razonar bajo ejecución concurrente.

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