Skip to content

10 StampedLock

Introducción

En las versiones Java 5 y Java 6 el empleo de la clase ReentrantReadWriteLock presenta el problema de que puede producir inanición (starvation) en alguno de los hilos, es decir, que sea tan frecuente los accesos (por ejemplo de lectura) que un hilo que quiera acceder al recurso para escritura no puedan adquirir nunca el cerrojo. Incluso si no se produce inanición, es probable que la adquisición del cerrojo para escritura se retrase en demasía.

StampedLock

Cerrojo que proporciona un modo de adquisición optimista para lectura, además de los modos pesimistas para lectura y para escritura

Para solucionar este problema, Java 8 trajo consigo un nuevo tipo de cerrojo denominado StampedLock, que supone en la práctica una mejora de las prestaciones proporcionadas por ReentrantReadWriteLock, siendo más eficiente y escalable. Una de dichas prestaciones adicionales es la posibilidad de llevar a cabo una adquisición optimista del cerrojo de lectura, algo que no es posible mediante ReentrantReadWriteLock que siempre emplea una adquisición pesimista.

Cabe destacar que la clase StampedLock no implementa las interfaces Lock ni ReadWriteLock, aunque proporciona una funcionalidad muy similar e incluso adicional a esta última.

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

La clase StampedLock proporciona tres modos distintos de obtener el cerrojo. En todos estos modos el hecho de la adquisición del cerrojo se retorna de forma de un valor de tipo long conocido como stamp (sello, ticket), de ahí el nombre de la clase. Dicho stamp contiene internamente un número de versión y un modo de adquisición. Veamos los distintos modos de adquisición:

Adquisición pesimista para escritura

Se realiza mediante el método writeLock(). Si un hilo obtiene el cerrojo para escritura, ningún otro hilo puede obtener el cerrojo para escritura ni para lectura. Si otro hilo ya había adquirido el cerrojo para escritura, el hilo que llama a writeLock() será suspendido. Retorna un stamp.

Podemos usar también el método tryWriteLock(), que trata de adquirir el cerrojo para escritura y si no puede NO suspende al hilo desde el que se llama, sino que simplemente retorna inmediatamente un sello (stamp) con valor 0. También tenemos la versión sobrecargada tryWriteLock(long time, TimeUnit unit), que trata de adquirir el cerrojo para escritura y si no puede porque otro hilo ya lo ha adquirido para escritura, suspende al hilo desde el que se llama como máximo el tiempo indicado. Si transcurrido dicho tiempo no se ha conseguido adquirir el cerrojo para escritura, retorna un sello con el valor 0.

En este caso podemos afirmar que este modo es pesimista, en el sentido de que se asume que puede haber dos hilos intentando acceder para escritura, por lo que se prohíbe que esto suceda, haciendo que la adquisición sea en exclusiva. Como vemos, su funcionamiento es similar al del cerrojo de escritura de la clase ReentrantReadWriteLock.

Adquisición pesimista para lectura

Se realiza mediante el método readLock() o readLockInterruptibly(). Si un hilo obtiene el cerrojo para lectura, ningún otro hilo puede obtener el cerrojo para escritura, pero sí para lectura. Si otro hilo ya había adquirido el cerrojo para escritura, el hilo que llama a readLock() será suspendido. Estos métodos retornan un stamp.

Podemos usar también el método tryReadLock(), que trata de adquirir el cerrojo para lectura, y si no puede porque otro hilo ya lo ha adquirido para escritura, NO suspende al hilo desde el que se llama, sino que simplemente retorna inmediatamente un sello (stamp) con valor 0.

También tenemos la versión sobrecargada tryReadLock(long time, TimeUnit unit), que trata de adquirir el cerrojo para lectura. Si no puede porque otro hilo ya lo ha adquirido para escritura, suspende al hilo desde el que se llama como máximo el tiempo indicado. Si transcurrido dicho tiempo no se ha conseguido adquirir el cerrojo para lectura, retorna un sello (stamp) con el valor 0.

En este caso podemos afirmar que este modo es pesimista, en el sentido de que se asume que además del hilo que quiere acceder al cerrojo para lectura puede haber otro hilo intentando acceder para escritura, por lo que se prohíbe que esto suceda, haciendo que la adquisición del cerrojo para escritura sólo pueda realizarse cuando se libere el cerrojo de lectura. Como vemos, su funcionamiento es similar al del cerrojo de lectura de la clase ReentrantReadWriteLock.

Adquisición optimista para lectura

Se realiza mediante el método tryOptimisticRead(), que, independientemente de si el cerrojo está realmente disponible, nunca suspende al hilo llamador, sino que retorna un stamp. Si el cerrojo no está disponible porque ha sido adquirido para escritura, el valor retornado será 0.

Al obtener optimistamente el cerrojo de lectura, NO se bloquea la adquisición inmediata del cerrojo para para escritura por parte de otro hilo, a diferencia de cómo ocurre con readLock().

Se dice que la adquisición es optimista porque estamos presuponiendo que es muy improbable que algún otro hilo obtenga el cerrojo para escritura, por lo que ni siquiera nos suspendemos. Si realmente el acceso para escritura es muy poco frecuente entonces el rendimiento de este tipo de adquisición es muy alto, ya que el hilo nunca será suspendido. Por este motivo, la adquisición optimista es especialmente eficiente si vamos a ejecutar un fragmento de código corto que sólo realice lectura de valores. Cuando más largo sea el fragmento de código más probable sea que el stamp deje de ser válido porque otro hilo haya adquirido el cerrojo para escritura.

Sin embargo, debemos ser conscientes de que, aunque improbable, es posible que después de haber adquirido el cerrojo para lectura de forma optimista, otro hilo adquiera el cerrojo para escritura, y lo podrá hacer sin problema, dado que el acceso optimista para lectura no lo evita. En este caso, el stamp que recibió el hilo como respuesta a la llamada al método tryOptimisticRead() deja de ser válido y seguirá siendo inválido incluso después de que el hilo libere el cerrojo de escritura.

Por este motivo, después de haber obtenido el cerrojo para lectura de forma optimista será necesario que validemos el stamp recibido para comprobar que realmente podemos leer del recurso con seguridad. Para ello llamaremos al método validate(stamp), que retornará true sólo si el stamp sigue siendo válido (seguro de usar), es decir, sólo si no se ha adquirido el cerrojo de escritura desde el momento en el que obtuvo dicho stamp.

Si tenemos éxito con el método validate(), la sobrecarga por sincronización habrá sido prácticamente inexistente, y ni siquiera deberemos liberar ningún cerrojo, porque de hecho no se habrá obtenido realmente.

Si no hemos tenido éxito con el método validate() lo normal es que realicemos un segundo intento de adquisición del cerrojo para lectura, pero esta vez de forma pesimista, para asegurarnos de no tener que intentarlo una tercera vez.

Como vemos, la adquisición optimista para lectura es inherentemente frágil y obliga a que validemos el stamp después de haber realizado la lectura.

Pese a tener algunas similitudes, la clase StampedLock NO es una extensión de la clase ReentrantReadWriteLock, y de hecho NO implementa la interfaces Lock ni ReadWriteLock. A diferencia de ReadWriteLock, todos los métodos de adquisición del cerrojo en la clase StampedLock retornan un stamp, que puede ser usado posteriormente para otras operaciones, como liberar el cerrojo, comprobar si el acceso al cerrojo es aún válido o convertir el modo de acceso al mismo.

Para liberar un cerrojo adquirido previamente de forma pesimista usaremos alguno de los siguientes métodos, dependiendo del modo para el que se adquirió el cerrojo. Todos ellos reciben el stamp que representa la adquisición del cerrojo:

  • unlockWrite(stamp): Para liberar un cerrojo que se adquirió para escritura de forma pesimista.
  • unlockRead(stamp): Para liberar un cerrojo que se adquirió para lectura de forma pesimista.
  • unlock(stamp): Para liberar un cerrojo independientemente del modo para el que se adquirió. Es menos eficiente que los anteriores.

Normalmente la llamada a estos métodos se incluirá dentro de la rama finally de un try finally, para asegurarnos de que el cerrojo es liberado independientemente de si se produce un error o no.

Si hemos adquirido el cerrojo para lectura de forma optimista y ha sido validado entonces no será necesario liberarlo, ya que realmente no se habrá suspendido a ningún hilo.

Veamos un ejemplo:

class Point {

    // Variables a proteger.
    private double x, y;
    // Lock que las protege
    private final StampedLock stampedLock = new StampedLock();

    // Desplaza el punto
    void move(double deltaX, double deltaY) {
        // Adquiere el cerrojo para escritura (pesimista).
        long stamp = stampedLock.writeLock();
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            // Lo libera.
            stampedLock.unlockWrite(stamp);
        }
    }

    // Retorna la distancia respecto al origen.
    double distanceFromOrigin() {
        // Obtiene el cerrojo para lectura de forma optimista.
        long stamp = stampedLock.tryOptimisticRead();
        // Se leen los valores.
        double currentX = x, currentY = y;
        // Debe validar que el stamp es aún válido antes de retornar, porque
        // puede que otro hilo haya llamado mientras tanto al mótodo move()
        // y haya obtenido el cerrojo para escritura, cambiando los valores
        // x e y.
        if (!stampedLock.validate(stamp)) {
            // Si no es válido debemos volver a leer los valores, pero antes
            // obtenemos el cerrojo de lectura de forma optimista.
            stamp = stampedLock.readLock();
            try {
                currentX = x;
                currentY = y;
            } finally {
                // Se libera el cerrojo de lectura.
                stampedLock.unlockRead(stamp);
            }
        }
        // Si hemos llegado aquí, es porque hemos leido los valores, ya haya
        // sido de forma optimista o pesimista.
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
}

Otra diferencia importante de StampedLock respecto a ReentrantReadWriteLock, que sólo permitía lock downgrading es que StampedLock proporciona métodos específicos para convertir el modo de adquisición de un cerrojo ya adquirido previamente, permitiendo realizar lock upgrading, es decir cambiar a un modo de adquisición más severo:

  • tryConvertToWriteLock(stamp): Trata de adquirir para escritura un cerrojo adquirido anteriormente en otro modo. Este método nunca suspenderá al hilo llamador, sino que retornará un nuevo stamp, cuyo valor será 0 si la conversión no es posible porque otro hilo ya haya adquirido el cerrojo para escritura.
  • tryConvertToReadLock(stamp): Trata de adquirir para lectura un cerrojo adquirido anteriormente en otro modo. Este método nunca suspenderá al hilo llamador, sino que retornará un nuevo stamp, cuyo valor será 0 si la conversión no es posible porque otro hilo ya haya adquirido el cerrojo para escritura.
  • tryConvertToOptmisticRead(stamp): Trata de adquirir optimistamente para lectura un cerrojo adquirido anteriormente en otro modo. Este método nunca suspenderá al hilo llamador, sino que retornará un nuevo stamp.

Continuemos el ejemplo anterior, añadiendo a la clase Point un nuevo método en el que primero se adquiere el cerrojo para lectura de forma pesimista y una vez leídos los datos se trata de convertirlo a un cerrojo de escritura pesimista para poder modificarlos:

class Point {

    private double x, y;
    private final StampedLock stampedLock = new StampedLock();

    // ...

    // Mueve el punto sólo si éste se encuentra en el origen de coordenadas.
    void moveIfAtOrigin(double newX, double newY) { 
        // Obtenemos el cerrojo para lectura de forma pesimista.
        // (también lo podríamos haber hecho de forma optimista).
        long stamp = stampedLock.readLock();
        try {
            // Solo intentamos cambiar los datos si el punto se encuentre en el origen
            // de coordenadas. 
            // Debe ser un bucle para que volvamos a intentarlo si no
            // ha sido posible la conversión, teniendo en cuenta que otro hilo ha podido
            // cambiar la posición del punto entre las sentencias de las líneas 39 y 40.
            while (x == 0.0 && y == 0.0) {
                // Intentamos convertir el cerrojo a uno para escritura.
                long writeStamp = stampedLock.tryConvertToWriteLock(stamp);
                if (writeStamp != 0L) {
                    // Si ha sido posible la conversión del cerrojo, se guarda
                    // como stamp para luego liberarlo.
                    stamp = writeStamp;
                    // Se cambia la posición del punto.
                    x = newX;
                    y = newY;
                    // Para que se salga del bucle una vez hechos los cambios.
                    break;
                }
                else {
                    // Si no ha sido posible la conversión del cerrojo, se libera
                    // el cerrojo de lectura y se obtiene el de escritura de forma 
                    // pesimista.
                    // Al tratarse de un bucle while, se vovlerá a entrar y la conversión
                    // obligatoriamente será posible, ya que estaremos convirtiendo en 
                    // cerrojo de escrtura uno que ya lo es, por lo que finalmente
                    // se cambiará al punto de posición, saliendo del bucle.
                    stampedLock.unlockRead(stamp);
                    stamp = stampedLock.writeLock();
                }
            }
        } finally {
            // Se libera el cerrojo (el último obtenido, proveniente de la conversión).
            stampedLock.unlock(stamp);
        }
    }

 }

La clase StampedLock también nos proporciona una serie de métodos informativos, como isReadLocked() y isWriteLocked(), que retornan si el cerrojo ha sido adquirido, respectivamente, en modo de lectura pesimista o en modo de escritura pesimista.

Debemos tener en cuenta que StampedLock no implementa la característica de readquisición automática (reentrant) del cerrojo. Cada llamada a un método de adquisición del cerrojo retorna un nuevo stamp y puede bloquear el hilo incluso aunque éste ya hubiera adquirido el cerrojo, por lo que debemos prestar especial atención a no incurrir en deadlocks.

Otra diferencia importante es que StampedLock no tiene noción de propiedad del cerrojo, lo que implica que puede ser adquirido por un hilo y liberado por otro, siempre y cuando se use el mismo stamp.

Además, no se puede aplicar un modo justo (fair mode), porque no se implementa ninguna política sobre cuál es el hilo que debe obtener el cerrojo a continuación.

Por lo general, el empleo de StampedLock hace que nuestro código se ejecute más rápido que si usamos ReentrantReadWriteLock, aunque no en todas las ocasiones. Por otra parte, el uso de cerrojos de lectura y escritura frente al uso de ReentrantLock sólo mejora el rendimiento cuando el número de hilos lectores es muy superior al de hilos escritores, las operaciones de lectura no sean triviales y dispongamos de bastantes núcleos de procesamiento.

Proyecto StampedLock

En este proyecto crearemos una aplicación que simula la venta de un producto. Por un lado existirán clientes que consultarán el precio del producto y por otra parte un hilo que representa la tienda vendedora puede cambiar el precio del producto. Usaremos la clase StampedLock para permitir que varios clientes consulten el precio a la vez de manera optimista.

import java.util.concurrent.TimeUnit;

public class Main {

    public static void main(String[] args) throws InterruptedException {
        Product product = new Product(100.00);
        Thread[] clientThreads= new Thread[4];
        for (int i = 0; i < 4; i++) {
            clientThreads[i] = new Thread(new Client(product), "Client " + i);
        }
        Thread shopThread = new Thread(new Shop(product), "Shop");
        shopThread.start();
        // Wait to start some clients.
        TimeUnit.SECONDS.sleep(1);
        for (int i = 0; i < 2; i++){
            clientThreads[i].start();
        }
        // Wait to start the rest of the clients.
        TimeUnit.SECONDS.sleep(3);
        for (int i = 2; i < 4; i++){
            clientThreads[i].start();
        }
        // Try to check a client thread blocks the shop thread but not other clients threads.
        // Try to check the shop thread blocks client threads.
    }

}
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.StampedLock;

public class Product {

    private double price;
    private final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss");
    private final StampedLock stampedLock = new StampedLock();

    public Product(double initialPrice) {
        this.price = initialPrice;
    }

    public double getPrice() throws InterruptedException {
        long stamp = stampedLock.tryOptimisticRead();
        return consultPrice(stamp);
    }

    private double consultPrice(long stamp) throws InterruptedException {
        System.out.printf("%s -> %s - Consulting price...\n",
                LocalTime.now().format(dateTimeFormatter),
                Thread.currentThread().getName());
        TimeUnit.SECONDS.sleep(3);
        double value = price;
        if (!stampedLock.validate(stamp)) {
            stamp = stampedLock.readLock();
            try {
                value = price;
            } finally {
                stampedLock.unlockRead(stamp);
            }
        }
        System.out.printf("%s -> %s - Price: %.2f\n",
                LocalTime.now().format(dateTimeFormatter),
                Thread.currentThread().getName(),
                value);
        return value;
    }

    public void updatePrice(double increment) throws InterruptedException {
        long stamp = stampedLock.writeLock();
        try {
            incrementPrice(increment);
        } finally {
            stampedLock.unlock(stamp);
        }
    }

    private void incrementPrice(double increment) throws InterruptedException {
        System.out.printf("%s -> %s - Updating price...\n",
                LocalTime.now().format(dateTimeFormatter),
                Thread.currentThread().getName());
        TimeUnit.SECONDS.sleep(1);
        this.price += increment;
        System.out.printf("%s -> %s - New price: %.2f\n",
                LocalTime.now().format(dateTimeFormatter),
                Thread.currentThread().getName(),
                this.price);
    }

}
import java.util.Objects;

public class Client implements Runnable {

    private final Product product;

    public Client(Product product) {
        Objects.requireNonNull(product);
        this.product = product;
    }

    @Override
    public void run() {
        try {
            @SuppressWarnings("unused")
            double price = product.getPrice();
        } catch (InterruptedException e) {
            System.out.println("I've been interrupted while consulting the price");
        }
    }

}
import java.util.Objects;
import java.util.concurrent.TimeUnit;

public class Shop implements Runnable {

    private final Product product;

    public Shop(Product product) {
        Objects.requireNonNull(product);
        this.product = product;
    }

    @Override
    public void run() {
        for (int i = 0; i < 3; i++) {
            try {
                product.updatePrice(20.0);
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                System.out.println("I've been interrupted while updating the price");
                return;
            }
        }
    }

}

Si ejecutamos la aplicación veremos que varios lectores pueden estar leyendo a la vez, pero sólo uno puede estar escribiendo. Cambia los tiempos de los sleep() para tratar que un hilo cliente bloquee a un hilo tienda pero no a otros hilos cliente. Después trata de que un hilo tienda bloquee a hilos clientes.