7 errores críticos que todos cometen con la gestión de hilos en Java
La concurrencia es una de las áreas donde incluso desarrolladores experimentados cometen fallos que luego cuestan rendimiento, estabilidad o seguridad. Aquí tienes 7 errores frecuentes, ejemplos mínimos reproducibles y la forma correcta de resolverlos.
1) No sincronizar el acceso a datos compartidos (race condition)
Ejemplo: contador incrementado desde múltiples hilos sin protección.
public class RaceDemo {
static int counter = 0;
public static void main(String[] args) throws Exception {
Thread[] threads = new Thread[1000];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) counter++;
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println("counter=" + counter); // valor inesperado
}
}
Solución: usar AtomicInteger, o sincronización/locks cuando haga falta:
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicDemo {
static AtomicInteger counter = new AtomicInteger(0);
public static void main(String[] args) throws Exception {
Thread[] threads = new Thread[1000];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) counter.incrementAndGet();
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println("counter=" + counter.get()); // esperable
}
}
2) Usar wait/notify sin mientras (while) - pérdida de señal y spurious wakeups
Error común: usar if en vez de while, lo que puede aceptar condiciones falsas tras un wakeup.
// Mal
synchronized (lock) {
if (!ready) lock.wait();
// continuar
}
// Bien
synchronized (lock) {
while (!ready) lock.wait();
// continuar solo cuando ready == true
}
Además, cuando hay múltiples consumidores/proveedores usa notifyAll() para evitar hilos que quedan bloqueados.
3) Deadlocks por orden de bloqueo inconsistente
Ejemplo mínimo (dos hilos, dos bloqueos):
Object a = new Object();
Object b = new Object();
// Thread 1
synchronized (a) {
Thread.sleep(50);
synchronized (b) { }
}
// Thread 2
synchronized (b) {
Thread.sleep(50);
synchronized (a) { }
}
Solución: definir un orden global para adquirir locks o usar tryLock con timeout (ReentrantLock).
ReentrantLock lockA = new ReentrantLock();
ReentrantLock lockB = new ReentrantLock();
if (lockA.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (lockB.tryLock(100, TimeUnit.MILLISECONDS)) {
try { /* trabajo */ } finally { lockB.unlock(); }
}
} finally { lockA.unlock(); }
}
4) No cerrar/terminar pools de hilos (leaks)
Crear un ExecutorService y olvidarse de cerrarlo deja hilos vivos que impiden la salida y consumen recursos.
ExecutorService ex = Executors.newFixedThreadPool(4);
// submit tareas...
// olvidamos shutdown()
// Correcto:
ex.shutdown();
if (!ex.awaitTermination(10, TimeUnit.SECONDS)) {
ex.shutdownNow();
}
Si usas frameworks, delega la gestión de lifecycle al contenedor o usa try-with-resources con wrappers cuando sea posible.
5) Creer que volatile sustituye a la sincronización
volatile garantiza visibilidad de lecturas/escrituras en variables individuales, pero no atomicidad para operaciones compuestas (por ejemplo: x++).
volatile int x;
// x++ NO es atómico: lectura + incremento + escritura
Usa AtomicInteger o sincronización para operaciones compuestas o invariantes relacionadas.
6) Coordinar con Thread.sleep() en lugar de herramientas de sincronización
Thread.sleep() es impreciso y frágil para coordinación entre hilos (timings, carga de CPU, etc.). Usa CountDownLatch, Semaphore, CyclicBarrier o Phaser.
CountDownLatch latch = new CountDownLatch(1);
// productor
latch.countDown();
// consumidor
latch.await();
7) Bloquear recursos largos dentro de secciones sincronizadas
Bloquear y luego realizar operaciones de E/S o llamadas remotas dentro de un bloque sincronizado puede reducir la concurrencia drásticamente.
// Mal: sección sincronizada incluye I/O
synchronized (cacheLock) {
if (!cache.containsKey(key)) {
String data = remoteCall(); // lento
cache.put(key, data);
}
}
// Mejor: doble-checked locking o computeIfAbsent con ConcurrentHashMap
String data = cache.get(key);
if (data == null) {
data = remoteCall();
cache.putIfAbsent(key, data);
}
Herramientas y prácticas para depurar y evitar errores
- jstack / jcmd para obtener stacks y detectar deadlocks
- Java Flight Recorder / Mission Control para perfiles en producción
- VisualVM para inspeccionar hilos y heap
- Usar concurrencia de alto nivel: ExecutorService, CompletableFuture, concurrent collections
- Escribir tests deterministas:
concurrencystress,jcstresso pruebas con contadores/fences
Patrones y buenas prácticas rápidas
- Preferir colecciones concurrentes (ConcurrentHashMap) a sincronización manual cuando sea posible
- Minimizar la sección crítica: mantener sincronización lo más corta posible
- Documentar el orden de adquisición de locks en el código/arquitectura
- Favor abstraer la concurrencia en componentes reutilizables y probados
Consejo avanzado: si tu aplicación puede migrar a versiones recientes de la JVM, evalúa usar virtual threads (Project Loom) y structured concurrency: simplifican enormemente muchos de los patrones de concurrencia tradicionales, pero no eximen de entender sincronización, límites de recursos y operaciones bloqueantes. Como siguiente paso, prueba convertir un módulo con muchas callbacks a CompletableFuture o a virtual threads para comparar complejidad y rendimiento.
Advertencia: siempre perfila cambios concurrentes en condiciones de carga reales; lo que funciona en microbenchmarks puede fallar en producción debido a latencias y contención.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación