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.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación