Skip to content

7 Lock

La interfaz Lock

Java 5 trajo consigo un nuevo paquete de clases diseñado específicamente para proporcionar herramientas útiles para el desarrollo de aplicaciones concurrentes con múltiples hilos de ejecución, denominado java.util.concurrent.

En ese paquete Java proporciona otro mecanismo más potente y flexible que los cerrojos intrínsecos para la sincronización de bloques de código, conocido como sincronización explícita, basado en la interfaz Lock y las clases que la implementan, como por ejemplo la clase ReentrantLock.

Al igual que en el caso de synchronized y los cerrojos intrínsecos, al usar la interfaz Lock no sólo aseguramos la atomicidad sino también la visibilidad, por lo que no será necesario definir con volatile las variables protegidas mediante un Lock.

Este mecanismo presenta una serie de ventajas respecto al empleo de synchronized y los cerrojos intrínsecos, proporcionando funcionalidades adicionales. En primer lugar, permite que estructuras más complejas implementen una sección crítica, dado que no está limitado por la construcción de un bloque sintáctico synchronized.

Además, permite a un hilo adquirir el cerrojo de distintas formas. La primera de ella es adquirirla de forma similar a si usáramos synchronized. Para ello usaremos el método lock() de la interfaz Lock, que trata de adquirir el cerrojo y si no es posible bloquea al hilo en espera de poder adquirirlo. Si el hilo es interrumpido mientras está bloqueado esperando la adquisición del cerrojo, nada sucede, hasta que no consiga adquirir el hilo no podrá ser consciente de que ha sido interrumpido. Se trata por tanto de una adquisición bloqueante no interrumpible.

Lock

Figura 4 - Lock

Otra forma de adquisición, no disponible mediante synchronized, es usar el método lockInterruptibly() de la interfaz Lock, que trata de adquirir el cerrojo y si no es posible bloquea al hilo en espera de poder adquirirlo. Si el hilo es interrumpido mientras está bloqueado esperando la adquisición del cerrojo, el hilo es reactivado y se lanza la excepción InterruptedException. Si el hilo ya había sido marcado para interrupción antes de llamar a lockInteruptibly(), dicho método lanzará directamente la excepción InterruptedException sin conceder el cerrojo al hilo. Se trata por tanto de una adquisición bloqueante interrumpible.

Una tercera forma de adquisición, tampoco disponible mediante synchronized, es intentar adquirir el cerrojo sin tener que bloquearse si no puede hacerlo. Para tal fin, la interfaz Lock proporciona el método tryLock(), que no bloquea el hilo, sino que retorna un valor booleano para indicar si se ha adquirido el cerrojo o no, permitiendo que el hilo decida qué hacer si no lo ha adquirido. Se trata por tanto de una adquisición no bloqueante

Existe una cuarta alternativa intemedia, tampoco disponible mediante synchronized, consistente en usar el método tryLock(time, timeUnit) sobrecargado de manera que recibe el tiempo máximo que queremos que sea bloqueado el hilo en espera de poder adquirir el cerrojo, transcurrido el cual retornará true. Esta llamada es bloqueante, pero con un límite de tiempo. Si el hilo es interrumpido mientras está bloqueado en espera de adquirir el cerrojo, el hilo será reactivado y se lanzará la excepción InterruptedException. De igual forma, si el hilo ya había sido marcado como interrumpido antes de llamar a tryLock(timeout, timeUnit), se lanzará la misma excepción. Se trata por tanto de una adquisición bloqueante interrumpible con un tiempo máximo de espera. Si el valor pasado como tiempo es menor o igual a 0, se comportará igual que tryLock() y no se bloqueará.

Podemos usar el método tryLock(timeout, timeUnit) para evitar el problema conocido como dead lock (abrazo mortal), en el que dos hilos quedan interbloqueados en espera de adquisición de un cerrojo. Por ejemplo, si el hilo 1 adquiere el recurso A y trata de adquirir el recurso B, y el hilo 2 adquiere el recurso B y trata de adquirir el recurso A, ambos estarán interbloqueados para siempre. Sin embargo, si usamos tryLock(timeout, timeUnit) a la hora de adquirir los cerrojos correspondientes a los recursos, entonces podemos programar que si transcurrido el tiempo no se ha adquirido liberemos el recurso que ya teníamos, permitiendo al otro hilo avanzar, deshaciendo así el interbloqueo.

A la hora de liberar el cerrojo que hayamos adquirido, deberemos llamar al método unlock() de la interfaz Lock. Si no lo hiciéramos, los otros hilos que estuvieran esperando a adquirir el cerrojo estarían bloqueados indefinidamente, provocando una situación de punto muerto (deadlock). Por este motivo, si usamos bloques try-catch-finally dentro de nuestra sección crítica, debemos hacer la llamada a unlock() dentro de finally. De esta manera tanto si la sección crítica es ejecutada satisfactoriamente como si se produce una excepción, el cerrojo será liberado.

Debemos tener en cuenta que el cerrojo debe ser liberado desde el mismo hilo que lo adquirió. Esto parece bastante obvio, pero como estudiaremos más adelante, en otros tipos de sincronizadores, llamados semáforos, la regla anterior no es obligatoria.

El método unlock() lanza la excepción IllegalMonitorStateException si se llama sin haber adquirido el cerrojo.

Veámoslo en código:

lock.lock();
try {
    // Sección crítica
    // ...
} finally {
    // De esta manera, el cerrojo será liberado tanto 
    lock.unlock();
}

Unlock

Figura 5 - Unlock

Al usar synchronized la adquisición y liberación del cerrojo se realizaba de forma estructurada en forma de método o de bloque de sentencias (fully bracket). De hecho, cuando se adquieren varios cerrojos intrínsecos, éstos son liberados obligatoriamente en el orden inverso en el que fueron adquiridos. Sin embargo al usar la interface Lock, la adquisición y liberación de cerrojo no es estructurada (not fully bracket), sino que corresponde a simples llamadas a los métodos vistos anteriormente. Este sistema es más flexible, pero obliga al programador a ser muy cuidadoso al establecer el orden en el que se llama a dichos métodos.

Otro aspecto muy importante con el que debemos tener cuidado a la hora de usar la interfaz Lock es evitar crear interbloqueos (deadlock) debido a que dos o más hilos queden bloqueados esperando la liberación de cerrojos que nunca llegarán a ser liberados. Por ejemplo, supongamos que un hilo A adquiere un cerrojo X y otro hilo B adquiere otro cerrojo Y. Si ahora el hilo A trata de adquirir el cerrojo Y y el hilo B simultáneamente trata de adquirir el cerrojo X, ambos hilos estarán interbloqueados indefinidamente, porque están esperando la liberación de cerrojos que nunca serán liberados. En este caso el problema ocurre porque ambos hilos tratan de obtener los cerrojos en el orden inverso uno respecto al otro.

Otro aspecto diferenciador respecto a synchronized es que, como veremos en un apartado posterior, la interfaz Lock es usada por la interfaz ReadWriteLock y la clase ReentrantReadWriteLock, que permite que existan varios hilos lectores realizando operaciones de lectura sobre la estructura de datos protegida, algo que no es posible con los bloques o los métodos synchronized.

Una última diferencia respecto a synchronized es que, como estudiaremos en un apartado posterior, la interfaz Lock permite definir más de una condición de bloqueo por la que los hilos pueden tener que ser bloqueados dentro de la sección crítica una vez adquirido el cerrojo. Gracias a ello, cuando se produce un evento que puede hacer que deja de cumplirse una determinada condición de bloqueo, se puede notificar dicho evento a esa condición en concreto, de manera que sólo sean reactivados los hilos que estuvieran bloqueados esperando en dicha condición de bloqueo, y no en otras. Esto es especialmente interesante porque nos permite "notificar" del evento a un único hilo que estuviera esperando, de manera que sólo se reactive un único hilo. En la práctica, con los bloques synchronized, al no disponer de distintas colas de espera no es tan seguro "notificar" del evento a un único hilo, ya que puede que el hilo reactivado no estuviera esperando por dicho evento, con lo que ningún hilo que realmente estuviera esperando dicho evento sería despertado.

ReentrantLock

La clase ReentrantLock es la clase principal de implementa la interfaz Lock. Además de la funcionalidad definida por dicha interfaz, la clase ReentrantLock proporciona una serie de métodos informativos que permiten hacer un seguimiento del cerrojo:

  • getOwner(): Retorna el nombre del hilo que ha adquirido el lock.
  • getQueuedThreads(): Retorna la lista de hilos que están esperando a entrar en la sección crítica protegida por el lock.
  • hasQueuedThreads(): Retorna true si hay hilos esperando en el lock.
  • getQueueLength(): Retorna el número de hilos esperando en el lock.
  • isLocked(): Retorna true si algún hilo ha obtenido el lock.
  • isFair(): Retorna true si el lock tiene activado el modo justo.
  • getHoldCount(): Retorna el número de veces que el hilo actual ha adquirido el lock.
  • isHeldByCurrentThread(): Retorna si el lock ha sido adquirido por el hilo actual.

El cerrojo definido por ReentrantLock es también reentrante (de ahí su nombre), al igual que el cerrojo intrínseco usado por synchronized, por lo que cuando un hilo obtiene en un método el control de un cerrojo y realiza una llamada a otro método o a ese mismo método de forma recursiva, continúa teniendo el control del cerrojo, y si se trata de adquirir de nuevo no queda bloqueado por él mismo.

ReentrantLock

Figura 6 - ReentrantLock

El constructor de la clase ReentrantLock está sobrecargado para admitir un parámetro booleano denominado fair (justo), que permite establecer si queremos que se active en el cerrojo el modo justo (fair-mode). Por defecto, los cerrojos tienen desactivados este modo, pero podemos activarlo pasando el valor true para dicho parámetro en el constructor.

Al activar el modo justo, cuando varios hilos estén esperando en el cerrojo, y éste deba seleccionar un hilo para darle acceso a la sección crítica, al haber sido liberado por el hilo que poseía, se seleccionará el hilo que lleve más tiempo esperando para obtener el cerrojo (cola FIFO), y no se seleccionará aleatoriamente, como sucede cuando el modo justo está desactivado.

La ventaja principal del modo justo es que impide que se produzca inanición en la adquisición del cerrojo por parte de los hilos.

El modo justo es respetado por los métodos lock(), lockInterruptibly() y tryLock(time, timeUnit), pero no por el método tryLock(), que puede hacer que el hilo que lo llama "se cuele". Si queremos formar a que lo respete deberemos llamar a tryLock(0, timeUnit).

Finalmente, debemos tener en cuenta que la aplicación del modo justo a un cerrojo añade un determinado coste en tiempo de ejecución, por lo que sólo debemos establecerlo cuando los requerimientos así lo especifiquen.

Proyecto ReentrantLock

En este proyecto crearemos un programa que simula el envío de documentos a la cola de impresión de una impresora, teniendo en cuenta que sólo un documento puede estar imprimiéndose en un momento dado. Para la sincronización usaremos un objeto de la clase ReentrantLock, que implementa la interfaz Lock.

public class Main {

    public static void main(String[] args) {
        Printer printer = new Printer();
        Thread[] printJobThreads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            printJobThreads[i] = new Thread(new PrintJob(printer, "Document #" + i), "Print job #" + i);
        }
        for (int i = 0; i < 10; i++) {
            printJobThreads[i].start();
        }
    }

}
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Printer {

    private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss");
    private final Lock reentrantLock = new ReentrantLock(true);

    public void print(String document) throws InterruptedException {
        reentrantLock.lock();
        try {
            printDocument(document);
        } finally {
            // This is called even if an exception is thrown.
            reentrantLock.unlock();
        }
    }

    private void printDocument(String document) throws InterruptedException {
        System.out.printf("%s -> %s: Document printing started\n",
                LocalTime.now().format(dateTimeFormatter), Thread.currentThread().getName());
        System.out.printf("%s -> %s: %s...\n",
                LocalTime.now().format(dateTimeFormatter), Thread.currentThread().getName(), document);
        TimeUnit.SECONDS.sleep(ThreadLocalRandom.current().nextInt(5));
        System.out.printf("%s -> %s: Printing finished\n",
                LocalTime.now().format(dateTimeFormatter), Thread.currentThread().getName());
    }

}
import java.util.Objects;

public class PrintJob implements Runnable {

    private final Printer printer;
    private final String document;

    public PrintJob(Printer printer, String document) {
        Objects.requireNonNull(printer);
        Objects.requireNonNull(document);
        this.printer = printer;
        this.document = document;
    }

    @Override
    public void run() {
        try {
            printer.print(document);
        } catch (InterruptedException e) {
            System.out.printf("%s -> I've been interrupted while printing document\n",
                    Thread.currentThread().getName());
        }
    }

}