6 - Sincronización en Java

La sincronización en Java se basa en monitores y en la palabra clave synchronized. Permite exclusión mutua y coordinación entre hilos que comparten memoria.

6.1 Bloques synchronized

  • Monitor por objeto: cada objeto tiene un monitor que se adquiere al entrar en un bloque synchronized sobre él.
  • Exclusión mutua: solo un hilo puede ejecutar la sección crítica a la vez para el mismo lock.
  • Usa locks privados: sincroniza sobre campos privados (por ejemplo, private final Object lock = new Object();) para evitar interferencia externa.
  • Reentrancia: un mismo hilo puede reingresar al lock sin bloquearse; cuenta el número de entradas.
public class ContadorSeguro {
  private int valor = 0;
  private final Object lock = new Object();

  public void incrementar() {
    synchronized (lock) {
      valor++;
    }
  }

  public int leer() {
    synchronized (lock) {
      return valor;
    }
  }

  public static void main(String[] args) throws InterruptedException {
    ContadorSeguro contador = new ContadorSeguro();

    Runnable tarea = () -> {
      for (int i = 0; i < 1_000; i++) {
        contador.incrementar();
      }
    };

    Thread h1 = new Thread(tarea, "h1");
    Thread h2 = new Thread(tarea, "h2");
    h1.start();
    h2.start();
    h1.join();
    h2.join();

    System.out.println("Valor final: " + contador.leer()); // esperado: 2000
  }
}
Secuencia de sincronización con bloqueo seguro

Contraste con una versión sin sincronización para observar condiciones de carrera y resultados inconsistentes.

public class ContadorInseguro {
  private int valor = 0;

  public void incrementar() {
    valor++; // no sincronizado
  }

  public int leer() {
    return valor;
  }

  public static void main(String[] args) throws InterruptedException {
    ContadorInseguro contador = new ContadorInseguro();

    Runnable tarea = () -> {
      for (int i = 0; i < 1_000; i++) {
        contador.incrementar();
      }
    };

    Thread h1 = new Thread(tarea, "h1");
    Thread h2 = new Thread(tarea, "h2");
    h1.start();
    h2.start();
    h1.join();
    h2.join();

    System.out.println("Valor final inseguro: " + contador.leer());
  }
}
Condición de carrera en contador inseguro

El resultado varía porque valor++ no es atómico: lee, incrementa y escribe en pasos separados. Sin exclusión mutua, los hilos pueden intercalar operaciones y perder actualizaciones (race condition), generando valores finales menores a 2000 y diferentes en cada corrida.

6.2 Métodos synchronized

  • Bloqueo implícito en this: los métodos de instancia sincronizados usan el monitor del objeto actual.
  • Riesgo de exponer el candado: si this es accesible, otros códigos pueden sincronizar sobre el mismo monitor y afectar el orden.
  • Métodos estáticos sincronizados: bloquean sobre el objeto Class correspondiente.

6.3 wait(), notify(), notifyAll()

  • Reglas del monitor: solo se pueden invocar dentro de un bloque o método synchronized sobre el mismo objeto.
  • Esperas condicionales: usa un bucle while para chequear la condición antes y después de wait() (maneja spurious wakeups).
  • Notificación: notify() despierta un hilo; notifyAll() despierta a todos para recompetir por el lock.

Spurious wakeups son despertares espurios: un hilo puede salir de wait() sin haber sido notificado ni haberse cumplido la condición; por eso se reevalúa la condición dentro de un bucle while antes de continuar.

import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.TimeUnit;

public class ColaPC {
  private final Queue<Integer> cola = new LinkedList<>();
  private final int capacidad = 5;

  public void producir(int valor) throws InterruptedException {
    synchronized (this) {
      while (cola.size() == capacidad) {
        wait(); // espera espacio
      }
      cola.add(valor);
      System.out.println("Producido: " + valor + " | tamaño=" + cola.size());
      notifyAll(); // hay datos
    }
  }

  public int consumir() throws InterruptedException {
    synchronized (this) {
      while (cola.isEmpty()) {
        wait(); // espera datos
      }
      int valor = cola.remove();
      System.out.println("Consumido: " + valor + " | tamaño=" + cola.size());
      notifyAll(); // hay espacio
      return valor;
    }
  }

  public static void main(String[] args) throws InterruptedException {
    ColaPC buffer = new ColaPC();

    Runnable productor = () -> {
      for (int i = 1; i <= 10; i++) {
        try {
          buffer.producir(i);
          TimeUnit.MILLISECONDS.sleep(150);
        } catch (InterruptedException e) {
          Thread.currentThread().interrupt();
          return;
        }
      }
    };

    Runnable consumidor = () -> {
      for (int i = 1; i <= 10; i++) {
        try {
          buffer.consumir();
          TimeUnit.MILLISECONDS.sleep(250);
        } catch (InterruptedException e) {
          Thread.currentThread().interrupt();
          return;
        }
      }
    };

    Thread tProd = new Thread(productor, "productor");
    Thread tCons = new Thread(consumidor, "consumidor");
    tProd.start();
    tCons.start();
    tProd.join();
    tCons.join();
    System.out.println("Proceso completado");
  }
}

Partes esenciales del ejemplo:

  • Lock implícito en this: los métodos usan synchronized (this) para proteger la cola.
  • Esperas condicionales: el productor espera espacio (while hasta que haya capacidad); el consumidor espera datos (while hasta que no esté vacío).
  • Notificación: notifyAll() despierta a productores y consumidores para recompetir por el lock tras un cambio.
  • Bucles finitos: ambos hilos recorren 10 iteraciones, lo que permite que el programa termine y podamos ver el flujo completo.
  • Pausas intencionales: los sleep ayudan a observar el intercalado y facilitan reproducir el patrón productor-consumidor.