java-concurrencia-sincronizacion-race-conditions

Sincronización en Java: condiciones de carrera y synchronized

  • 4 min

La sincronización reúne los mecanismos que coordinan el acceso de varios hilos al estado compartido.

En el artículo anterior aprendimos a crear hilos para hacer varias tareas de forma concurrente.

Si esos hilos son independientes, no comparten este problema. Cuando dos hilos acceden al mismo estado y al menos uno escribe, necesitamos coordinar ese acceso.

Bienvenido al mundo de las Race Conditions (Condiciones de Carrera).

El problema: race conditions

Imagina un contador simple. Queremos que dos hilos sumen 1.000 visitas cada uno a una web. El resultado final debería ser 2.000.

class Contador {
    private int cuenta = 0;

    public void incrementar() {
        cuenta++; // El peligro está aquí
    }

    public int getCuenta() { return cuenta; }
}

public class Main {
    public static void main(String[] args) throws InterruptedException {
        Contador c = new Contador();

        // Hilo 1: Suma 1000 veces
        Thread t1 = new Thread(() -> {
            for(int i=0; i<1000; i++) c.incrementar();
        });

        // Hilo 2: Suma 1000 veces
        Thread t2 = new Thread(() -> {
            for(int i=0; i<1000; i++) c.incrementar();
        });

        t1.start();
        t2.start();

        t1.join(); // Esperamos a que terminen
        t2.join();

        System.out.println("Total visitas: " + c.getCuenta());
    }
}
Copied!

Si ejecutas esto, esperarías ver 2000. Puedes ver 2000 o un valor menor, como 1950, 1890 o 1999. El resultado no está garantizado y puede cambiar entre ejecuciones.

¿Por qué falla? (atomicidad)

El problema es que la operación cuenta++ NO es atómica. Para la CPU, sumar 1 implica tres pasos distintos:

  1. Leer el valor actual de la memoria (ej: 10).
  2. Sumar 1 al valor (10 + 1 = 11).
  3. Escribir el nuevo valor en memoria (11).

Si dos hilos entran a la vez, ocurre el desastre:

  1. Hilo A lee 10.
  2. Hilo B lee 10 (antes de que A escriba).
  3. Hilo A suma y escribe 11.
  4. Hilo B suma (sobre su 10) y escribe 11.

Resultado: Han hecho dos sumas, pero el contador solo ha subido 1. Hemos “perdido” una visita.

La solución: synchronized

Para arreglarlo, convertimos incrementar() en una sección crítica. Solo un hilo podrá modificar la cuenta con el mismo cerrojo cada vez, y los cambios tendrán la visibilidad definida por el modelo de memoria.

En Java, esto se logra con la palabra clave synchronized.

class ContadorSeguro {
    private int cuenta = 0;

    // Solo un hilo puede ejecutar este método sobre la misma instancia cada vez
    public synchronized void incrementar() {
        cuenta++;
    }

    public int getCuenta() { return cuenta; }
}
Copied!

Al añadir synchronized, Java crea un Cerrojo (Monitor Lock) asociado al objeto.

Bloques synchronized (sincronización fina)

Sincronizar un método entero es fácil, pero a veces es ineficiente. Si tu método tarda 5 segundos en ejecutarse, paralizas a todos los demás hilos durante 5 segundos.

A veces solo quieres proteger una línea concreta. Puedes usar un Bloque Synchronized.

public void metodoComplejo() {
    System.out.println("Preparando datos..."); // Esto se puede hacer en paralelo

    // Solo bloqueamos el acceso a la variable compartida
    // 'this' es el objeto que usamos como llave
    synchronized(this) {
        cuenta++;
    }

    System.out.println("Guardando logs..."); // Esto también en paralelo
}
Copied!

Esto mejora el rendimiento porque el “bloqueo” dura lo mínimo posible.

El coste de la sincronización

Quizás pienses: “Pues pongo synchronized a todos mis métodos y me curo en salud”. Mala idea.

  1. Rendimiento: Adquirir y soltar cerrojos consume CPU. Además, conviertes tu programa multihilo en uno secuencial (si todos hacen cola, nadie trabaja en paralelo).
  2. Deadlocks (Abrazo Mortal): Si el Hilo A tiene la llave del recurso 1 y espera al 2, y el Hilo B tiene la llave del 2 y espera al 1… ambos se quedan congelados para siempre. El programa se cuelga.

Alternativas modernas: atomicidad sin bloqueo

Para casos simples como contadores, Java incluye clases en java.util.concurrent.atomic que ofrecen operaciones atómicas sin usar un monitor. Su rendimiento depende de la contención; no son universalmente más rápidas que synchronized.

import java.util.concurrent.atomic.AtomicInteger;

class ContadorAtomico {
    // Variable especial thread-safe
    private AtomicInteger cuenta = new AtomicInteger(0);

    public void incrementar() {
        // Equivale a cuenta++ pero es atómico y seguro
        cuenta.incrementAndGet();
    }
}
Copied!