Comprendiendo el Mecanismo de Bloqueo en Java

Comprendiendo el Mecanismo de Bloqueo en Java

Comprendiendo el Mecanismo de Bloqueo en Java: Implementaciones y Diferencias



En el mundo de la programación concurrente en Java, la sincronización de subprocesos es un tema crucial que cualquier desarrollador debe comprender para construir aplicaciones robustas y eficientes. Un aspecto fundamental de esta sincronización es el mecanismo de bloqueo, que ofrece una forma más flexible y sofisticada de gestionar el acceso concurrente a recursos compartidos que las clásicas construcciones de sincronización como el bloque synchronized.



En esta entrada, exploraremos en profundidad el uso de la interfaz Lock, que ha estado disponible desde Java 1.5, y se define dentro del paquete java.util.concurrent.locks. Veremos sus diferentes implementaciones y sus aplicaciones, así como sus diferencias en comparación con los bloques synchronized. Todo esto nos ayudará a entender cómo estos mecanismos pueden mejorar nuestra programación en Java.



1. Visión General

Sencillamente, un bloqueo (lock) es un mecanismo de sincronización de subprocesos más flexible y sofisticado que un bloque synchronized. La interfaz Lock proporciona operaciones exhaustivas para gestionar el bloqueo, lo que permite a los desarrolladores tener un control más elaborado sobre el acceso a los recursos compartidos. A lo largo de este tutorial, exploraremos las diferentes implementaciones de la interfaz Lock y cómo se pueden aplicar en situaciones prácticas.



2. Diferencias entre Lock y Bloque Synchronized

  • Descripción: Un bloque synchronized está completamente contenido dentro de un método. En cambio, las APIs de Lock permiten que las operaciones lock() y unlock() se realicen en métodos separados.
  • Equidad: Un bloque synchronized no soporta la equidad. Cualquier subproceso puede adquirir el bloqueo una vez que se libera, y no se puede especificar preferencia. Por otro lado, las APIs de Lock permiten especificar la propiedad de equidad, asegurando que el subproceso que más tiempo ha estado esperando obtenga acceso al bloqueo.
  • Bloqueo: Un subproceso se bloquea si no puede acceder al bloque synchronized. Las APIs de Lock ofrecen el método tryLock(), que permite que un subproceso adquiera el bloqueo solo si está disponible y no está ocupado por otro subproceso, reduciendo así el tiempo de bloqueo.
  • Interrupción: Un subproceso que está en estado “esperando” para adquirir acceso a un bloque synchronized no puede ser interrumpido. En cambio, la API de Lock proporciona un método lockInterruptibly() que puede interrumpir el subproceso cuando está esperando el bloqueo.


3. API de Lock

  • void lock() – Adquiere el bloqueo si está disponible. Si no lo está, el subproceso se bloquea hasta que el bloqueo es liberado.
  • void lockInterruptibly() – Similar a lock(), pero permite interrumpir el subproceso bloqueado, reanudando su ejecución al lanzar una java.lang.InterruptedException.
  • boolean tryLock() – Versión no bloqueante del método lock(). Intenta adquirir el bloqueo inmediatamente y regresa true si el bloqueo tiene éxito.
  • boolean tryLock(long timeout, TimeUnit timeUnit) – Similar a tryLock(), pero espera un tiempo determinado antes de desistir de intentar adquirir el Lock.
  • void unlock() – Libera la instancia de Lock.

Es fundamental desbloquear siempre una instancia bloqueada para evitar condiciones de interbloqueo (deadlock). Se recomienda utilizar un bloque try/catch y finally al usar un bloqueo:

Lock lock = ...; 
lock.lock();
try {
    // acceso al recurso compartido
} finally {
    lock.unlock();
}

Además de la interfaz Lock, tenemos la interfaz ReadWriteLock, que mantiene un par de bloqueos: uno para operaciones de solo lectura y otro para operaciones de escritura. El bloqueo de lectura puede ser sostenido simultáneamente por varios subprocesos mientras no haya escritura en curso.

  • Lock readLock() – devuelve el bloqueo que se usa para leer.
  • Lock writeLock() – devuelve el bloqueo que se usa para escribir.


4. Implementaciones de Lock

4.1. ReentrantLock

La clase ReentrantLock implementa la interfaz Lock. Ofrece las mismas semánticas de concurrencia y memoria que el bloqueo de monitor implícito utilizado en métodos y declaraciones synchronized, con capacidades extendidas.

Veamos cómo podemos usar ReentrantLock para la sincronización:

public class SharedObjectWithLock {
    ReentrantLock lock = new ReentrantLock();
    int counter = 0;

    public void perform() {
        lock.lock();
        try {
            // Sección crítica aquí
            counter++;
        } finally {
            lock.unlock();
        }
    }
}

Es crucial rodear las llamadas a lock() y unlock() en un bloque try-finally para evitar situaciones de deadlock. Ahora veamos cómo funciona el método tryLock():

public void performTryLock() {
    boolean isLockAcquired = lock.tryLock(1, TimeUnit.SECONDS);
    
    if (isLockAcquired) {
        try {
            // Sección crítica aquí
        } finally {
            lock.unlock();
        }
    }
}

En este caso, el subproceso que llama a tryLock() esperará un segundo y renunciará a esperar si el bloqueo no está disponible.



4.2. ReentrantReadWriteLock

La clase ReentrantReadWriteLock implementa la interfaz ReadWriteLock. Veamos las reglas para adquirir el ReadLock o el WriteLock por parte de un subproceso:

  • Bloqueo de Lectura: Si ningún subproceso ha adquirido el bloqueo de escritura o lo ha solicitado, múltiples subprocesos pueden adquirir el bloque de lectura.
  • Bloqueo de Escritura: Si no hay subprocesos leyendo o escribiendo, solo un subproceso puede adquirir el bloque de escritura.

Veamos cómo utilizar ReadWriteLock:

public class SynchronizedHashMapWithReadWriteLock {
    Map<String, String> syncHashMap = new HashMap<>();
    ReadWriteLock lock = new ReentrantReadWriteLock();
    Lock writeLock = lock.writeLock();

    public void put(String key, String value) {
        try {
            writeLock.lock();
            syncHashMap.put(key, value);
        } finally {
            writeLock.unlock();
        }
    }

    public String remove(String key) {
        try {
            writeLock.lock();
            return syncHashMap.remove(key);
        } finally {
            writeLock.unlock();
        }
    }

    Lock readLock = lock.readLock();

    public String get(String key) {
        try {
            readLock.lock();
            return syncHashMap.get(key);
        } finally {
            readLock.unlock();
        }
    }

    public boolean containsKey(String key) {
        try {
            readLock.lock();
            return syncHashMap.containsKey(key);
        } finally {
            readLock.unlock();
        }
    }
}

En los métodos de escritura, hemos rodeado la sección crítica con el bloqueo de escritura; solo un subproceso puede acceder a ellos. Para los métodos de lectura, hemos hecho lo mismo con el bloqueo de lectura. Múltiples subprocesos pueden acceder a esta sección si no hay ninguna operación de escritura en progreso.



4.3. StampedLock

La clase StampedLock, introducida en Java 8, también soporta bloqueos de lectura y escritura, pero los métodos de adquisición de bloqueo devuelven un “stamp” que se utiliza para liberar un bloqueo o comprobar si el bloqueo sigue siendo válido:

public class StampedLockDemo {
    Map<String, String> map = new HashMap<>();
    private StampedLock lock = new StampedLock();

    public void put(String key, String value) {
        long stamp = lock.writeLock();
        try {
            map.put(key, value);
        } finally {
            lock.unlockWrite(stamp);
        }
    }

    public String get(String key) throws InterruptedException {
        long stamp = lock.readLock();
        try {
            return map.get(key);
        } finally {
            lock.unlockRead(stamp);
        }
    }
}

Un aspecto destacado de StampedLock es el soporte para bloqueo optimista. Normalmente, las operaciones de lectura no necesitan esperar a que se complete la operación de escritura, por lo que no se requiere un bloqueo de lectura completo. En su lugar, podemos mejorar a un bloqueo de lectura:

public String readWithOptimisticLock(String key) {
    long stamp = lock.tryOptimisticRead();
    String value = map.get(key);

    if (!lock.validate(stamp)) {
        stamp = lock.readLock();
        try {
            return map.get(key);
        } finally {
            lock.unlock(stamp);               
        }
    }
    return value;
}


5. Trabajando con Conditions

La clase Condition proporciona la capacidad para que un subproceso espere a que ocurra alguna condición mientras ejecuta la sección crítica. Esto puede ocurrir cuando un subproceso obtiene acceso a la sección crítica pero no tiene la condición necesaria para ejecutar su operación.

Tradicionalmente, Java ofrece los métodos wait(), notify() y notifyAll() para la intercomunicación entre subprocesos. Las Conditions tienen mecanismos similares, pero también podemos especificar múltiples condiciones:

public class ReentrantLockWithCondition {
    Stack<String> stack = new Stack<>();
    int CAPACITY = 5;

    ReentrantLock lock = new ReentrantLock();
    Condition stackEmptyCondition = lock.newCondition();
    Condition stackFullCondition = lock.newCondition();

    public void pushToStack(String item) {
        try {
            lock.lock();
            while (stack.size() == CAPACITY) {
                stackFullCondition.await();
            }
            stack.push(item);
            stackEmptyCondition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public String popFromStack() {
        try {
            lock.lock();
            while (stack.size() == 0) {
                stackEmptyCondition.await();
            }
            return stack.pop();
        } finally {
            stackFullCondition.signalAll();
            lock.unlock();
        }
    }
}


6. Conclusión

En este artículo, hemos visto diferentes implementaciones de la interfaz Lock y la clase StampedLock introducida recientemente. También exploramos cómo podemos utilizar la clase Condition para trabajar con múltiples condiciones, lo que ofrece un mayor control sobre el comportamiento de los subprocesos en aplicaciones concurrentes.

Al utilizar estas herramientas, los programadores pueden crear aplicaciones Java más eficientes y escalables, minimizando los riesgos de condiciones de carrera e interbloqueos. A medida que adquieran experiencia con estas implementaciones, se encontrarán mejor equipados para afrontar desafíos complejos en la programación concurrente.