Skip to content

3 Cerrojo Intrínseco

Introducción

La exclusión mutua no está implementada por defecto en los tipos de Java, por lo que tendremos que usar algún mecanismo de sincronización.

El mecanismo básico de sincronización para conseguir la exclusión mutua en Java se construye en base a un entidad interna conocida como monitor, cerrojo monitor o cerrojo intrínseco.

Monitor, cerrojo monitor, cerrojo intrínseco o mutex

Objeto que actúa como vigilante de que el acceso a una determinada sección crítica se realice en exclusión mutua

Los monitores actúan sobre dos propiedades de los mecanismos de sincronización: por un lado hacer cumplir el acceso exclusivo al estado de un objeto, es decir, la exclusión mutua, y, por otro lado, asegurar la visibilidad de los cambios en todos los hilos, garantizando que determinadas acciones se llevan a cabo antes que otras (lo que se conoce como relaciones happens-before). Por este motivo, si el acceso a un determinado campo está siendo protegido con un cerrojo intrínseco no será necesario definir dicho campo como volatile, debido a que cuando un hilo trata de liberar el cerrojo intrínseco, los valores cacheados son escritos en las variables compartidas en memoria principal, lo que implica que el siguiente hilo que adquiera el mismo cerrojo siempre verá los valores más recientes de dichas variables compartidas.

Cada objeto tiene un cerrojo intrínseco asociado a él. Por convención, un hilo que necesita acceso exclusivo y consistente a los campos de un objeto tiene que adquirir el bloqueo intrínseco del objeto antes de acceder a ellos, y luego liberar el cerrojo intrínseco cuando haya terminado de usarlos. Se dice que un hilo es propietario (están en posesión) del cerrojo intrínseco en el periodo de tiempo transcurrido desde que adquiere el cerrojo hasta que lo libera. Mientras un hilo posea un el cerrojo intrínseco, ningún otro hilo puede adquirir el mismo cerrojo, y al intentarlo quedará bloqueado hasta que el cerrojo haya sido liberado por el hilo que lo poseía.

Cuando un hilo libera un cerrojo intrínseco, el sistema garantiza que dicha liberación se realizará antes de que otro hilo que estuviera bloqueado en espera del cerrojo lo adquiera (esta es la relación happens-before que comentamos anteriormente).

En Java, podemos usar la palabra reservada synchronized para controlar el acceso concurrente a un objeto. Cuando declaramos un método de una clase como synchronized, estamos indicando que cuando un hilo llame a dicho método en un determinado objeto automáticamente tratará de adquirir el cerrojo intrínseco asociado a dicho objeto antes de poder ejecutarlo, y que liberará automáticamente el cerrojo intrínseco cuando se termine de ejecutar el método, tanto si ha retornado satisfactoriamente como si se ha producido una excepción.

public class Account {
    // ...
    public synchronized void deposit(float amount) {
        // ...
    }
}

En la práctica esto implica que cuando un hilo esté ejecutando un método declarado como synchronized de un determinado objeto, ningún otro hilo podrá ejecutar dicho método ni cualquier otro método synchronized del mismo objeto, siendo bloqueado hasta que el hilo que está en posesión del cerrojo intrínseco asociado al objeto lo libere.

Método synchronized

Al definir un método como synchronized convertimos su código en una sección crítica accesible bajo exclusión mutua

La definición de métodos synchronized no afecta a la ejecución de otros métodos de la clase que no se hayan definido como synchronized, ya que para ejecutar éstos no es necesario adquirir el cerrojo intrínseco.

De esta manera, al usar la palabra reservada synchronized en la definición de un método, convertimos su código en una sección crítica accesible bajo exclusión mutua.

El cerrojo intrínseco recibe también el nombre de monitor, ya que actúa como "vigilante" que monitorea el acceso a sus métodos, y recibe el adjetivo de intrínseco porque no es ningún elemento externo el que actúa como cerrojo, sino un elemento interno asociado a él.

Conflicto de escritura

Figura 2 - Conflicto de escritura

El comportamiento de la palabra reservada synchronized es ligeramente diferente si el método es además estático (static), ya que en dicho caso, el cerrojo intrínseco estará asociada a la clase y no a una determinada instancia (objeto) de la clase. En ese caso, cuando un hijo ejecute un método static synchronized de la clase estará adquiriendo el cerrojo intrínseco asociado al objeto Class correspondiente a la clase. Por tanto, el acceso a los campos static synchronized es controlado por un cerrojo intrínseco distinto al cerrojo intrínseco asociado a cada instancia de la clase.

Como consecuencia, dos hilos no podrán estar ejecutando a la vez el mismo método static synchronized o dos métodos distintos static synchronized de la misma clase, pero sí es posible que un hilo esté ejecutando un método static synchronized de una clase y otro hilo esté ejecutando un método synchronized de una instancia de esa misma clase.

Debemos tener en cuenta que el uso de métodos sincronizados penaliza en cierta manera el rendimiento de la aplicación, ya que si tenemos varios hilos llamando a un método sincronizado, sólo uno de ellos podrá estar ejecutándolo en un momento dado y los demás tendrán que esperar, aumentando el tiempo real de ejecución de la aplicación. Así que la palabra reservada synchronized debe ser usada solamente con métodos que sepamos que van a ser llamados por varios hilos y que además modifiquen datos compartidos por varios hilos en un entorno concurrente.

Debemos tener en cuenta que si dentro de un método synchronized se llama al método Thread.sleep(milliseconds), el hilo sigue siendo poseedor del cerrojo intrínseco durante el tiempo que está durmiendo.

En Java, la capacidad de tener asociado un cerrojo intrínseco y por tanto tener métodos synchronized está incorporada en la clase Object, por lo que es heredada por todas las clases, lo que implica que cualquier objeto Java tiene asociado un objeto monitor y contener métodos synchronized.

Sincronización reentrante

Si un hilo ejecutando un método synchronized de un objeto llama internamente a otro método synchronized del mismo objeto, no tiene que adquirir de nuevo el cerrojo, porque ya lo tiene

Una de las características de la sincronización mediante la palabra reservada synchronized es que un hilo puede adquirir más de una vez un cerrojo intrínseco que ya posee. Esta casuística se produce en situaciones en las que un código protegido mediante synchronized invoca directamente o indirectamente a otro método (o a él mismo recursivamente) que también contiene código synchronized protegido por el mismo cerrojo intrínseco. Se dice por tanto que se trata de una sincronización reentrante (reentrant synchronization), con objeto de que un hilo no se bloquee a sí mismo.

Si queremos saber si el hilo en el que nos encontramos tiene adquirido en un momento dado el cerrojo intrínseco asociado a un determinado objeto, podemos llamar al método estático Thread.holdsLock(object).

Debemos tener en cuenta que el cerrojo intrínseco no proporciona ninguna garantía respecto al orden en el que los hilos que estén esperando adquirir el cerrojo finalmente lo adquieren. Esto implica que, teóricamente, existe riesgo de que algún hilo sufra de inanición, si el cerrojo intrínseco es adquirido constantemente por otros hilos, lo que no permite a nuestro hilo adquirirlo nunca. La solución a este problema consiste en usar la clase ReentrantLock y su modo justo (fair mode), como estudiaremos dentro de poco.

Proyecto SynchronizedMethod

En este proyecto vamos a realizar un programa que simule una cuenta bancaria en la que un hilo que realiza una serie de abonos a la cuenta y otro hilo realiza una serie de cargos. Debemos recordar que el orden de ejecución de hilos no está garantizado por la JVM, por lo que podrían intercalarse las operaciones de manera que el saldo no reflejara el valor correcto. Usaremos métodos synchronized para asegurar que el saldo final es el correcto incluso aunque se pretendieran realizar ambas operaciones simultáneamente.

import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

public class Main {

    public static void main(String[] args) throws InterruptedException {
        DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss");
        Account account = new Account(0);
        // Show initial balance.
        System.out.printf("%s -> Initial balance: %.2f€\n",
                LocalTime.now().format(dateTimeFormatter), account.getBalance());
        // Start both saver and consumer threads. Both threads share the same account.
        Thread saverThread = new Thread(new AccountSaver(account));
        saverThread.start();
        Thread consumerThread = new Thread(new AccountConsumer(account));
        consumerThread.start();
        // Wait for both threads to finish.
        saverThread.join();
        consumerThread.join();
        // Show final balance.
        System.out.printf("\n%s -> Final balance: %.2f€\n",
                LocalTime.now().format(dateTimeFormatter), account.getBalance());
    }

}
public class Account {

    private float balance;

    public Account(float initialBalance) {
        this.balance = initialBalance;
    }

    public float getBalance() {
        return balance;
    }

    // Try and remove synchronized keyword and see what happens.
    public synchronized void deposit(float amount) {
        balance += amount;
        System.out.print(".");
    }

    // Try and remove synchronized keyword and see what happens.
    public synchronized void debit(float amount) {
        balance -= amount;
        System.out.print(".");
    }

}
public class AccountConsumer implements Runnable {

    private final Account account;

    public AccountConsumer(Account account) {
        this.account = account;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            account.debit(5);
        }
    }

}
public class AccountSaver implements Runnable {

    private final Account account;

    public AccountSaver(Account account) {
        this.account = account;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            account.deposit(10);
        }
    }
}

Si ejecutamos el programa veremos que el saldo final es el esperado, es decir 50000, pero si no usamos la palabra reservada synchronized en los métodos de la clase Account el resultado será erroneo y diferente en cada ejecución.

Sentencias Synchronized

Con objeto de minimizar en la medida de lo posible la penalización en tiempo de ejecución que pueden producir los métodos sincronizados, podemos emplear la técnica consistente en proteger el acceso al bloque de código conflictivo del método en vez de al método completo.

Para ello usaremos la palabra reservada synchronized para crear un bloque con acceso protegido que contenga exclusivamente las líneas de código del método que accedan a datos compartidos, dejando el resto de operaciones fuera del bloque, lo que mejora el rendimiento de la aplicación. El objetivo es hacer la sección crítica lo más pequeña posible.

Cuando se usa la palabra reservada synchronized de esta manera la sintaxis que emplea es distinta a cuando se usa en la definición de un método. De hecho, le tendremos que pasar como parámetro a synchronized la referencia a un objeto, cuyo cerrojo intrínseco será usado para proteger el código contenido en el bloque.

synchronized (intrinsicLockSupplierObject) {
    // Critic section with mutual exclusion.
    // ...
}

Antes de ejecutar la primera línea de código del bloque synchronized el hilo que lo ejecuta deberá adquirir el cerrojo intrínseco asociado al objeto proporcionado. Cuando se termine de ejecutar la última línea de código del bloque se liberará el cerrojo intrínseco. Como consecuencia, la JVM garantiza que en un momento dado un único hilo podrá tener acceso a cualquiera de los bloques de código protegidos por el cerrojo intrínseco de dicho objeto.

Normalmente usaremos como cerrojo intrínseco el asociado al propio objeto en el que está ejecutando el método, para lo que emplearemos la palabra clave this, aunque en realidad podríamos haber especificado cualquier otro objeto, incluso uno creado expresamente para ello, como veremos más adelante.

synchronized (this) {
    // Critic section with mutual exclusion.
    // ...
}

Proyecto SynchronizedStatement

Consiste en el mismo proyecto del enunciado anterior, pero sincronizando solamente el bloque correspondiente a la sección crítica en vez de el método completo.

import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

public class Main {

    public static void main(String[] args) throws InterruptedException {
        DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss");
        Account account = new Account(0);
        // Show initial balance.
        System.out.printf("%s -> Initial balance: %.2f€\n",
                LocalTime.now().format(dateTimeFormatter), account.getBalance());
        // Start both saver and consumer threads. Both threads share the same account.
        Thread saverThread = new Thread(new AccountSaver(account));
        saverThread.start();
        Thread consumerThread = new Thread(new AccountConsumer(account));
        consumerThread.start();
        // Wait for both threads to finish.
        saverThread.join();
        consumerThread.join();
        // Show final balance.
        System.out.printf("\n%s -> Final balance: %.2f€\n",
                LocalTime.now().format(dateTimeFormatter), account.getBalance());
    }

}
public class Account {

    private float balance;

    public Account(float initialBalance) {
        this.balance = initialBalance;
    }

    public float getBalance() {
        return balance;
    }

    public void deposit(float amount) {
        synchronized (this) {
            balance += amount;
        }
    }

    public void debit(int amount) {
        synchronized (this) {
            balance -= amount;
        }
    }

}
public class AccountConsumer implements Runnable {

    private final Account account;

    public AccountConsumer(Account account) {
        this.account = account;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            account.debit(5);
        }
    }

}
public class AccountSaver implements Runnable {

    private final Account account;

    public AccountSaver(Account account) {
        this.account = account;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            account.deposit(10);
        }
    }

}

Si ejecutamos el programa veremos que el saldo final es el esperado, es decir 50000, pero si no usamos la palabra reservada synchronized en los métodos de la clase Account el resultado será erroneo y diferente en cada ejecución.