Concurrencia en Java: la palabra clave sincronizada

Este es el segundo artículo de la serie de artículos sobre Concurrencia en Java. En el artículo anterior, aprendimos sobre el grupo de Ejecutores y varias categorías...

Introducción

Este es el segundo artículo de la serie de artículos sobre Concurrencia en Java. En el Artículo anterior, aprendimos sobre el grupo Executor y varias categorías de Executors en Java.

En este artículo, aprenderemos qué es la palabra clave “sincronizada” y cómo podemos usarla en un entorno de subprocesos múltiples.

¿Qué es la sincronización?

En un entorno de subprocesos múltiples, es posible que más de un subproceso intente acceder al mismo recurso. Por ejemplo, dos subprocesos que intentan escribir en el mismo archivo de texto. En ausencia de sincronización entre ellos, es posible que los datos escritos en el archivo se dañen cuando dos o más subprocesos tienen acceso de escritura al mismo archivo.

Además, en la JVM, cada subproceso almacena una copia local de las variables en su pila. El valor real de estas variables puede ser cambiado por algún otro subproceso. Pero es posible que ese valor no se actualice en la copia local de otro subproceso. Esto puede provocar una ejecución incorrecta de los programas y un comportamiento no determinista.

Para evitar estos problemas, Java nos proporciona la palabra clave synchronized, que actúa como un candado para un recurso en particular. Esto ayuda a lograr la comunicación entre subprocesos de modo que solo un subproceso acceda al recurso sincronizado y otros subprocesos esperen a que el recurso se libere.

La palabra clave synchronized se puede usar de diferentes maneras, como un bloque sincronizado:

1
2
3
synchronized (someObject) {
    // Thread-safe code here
}

También se puede utilizar con un método como este:

1
2
3
public synchronized void somemMethod() {
    // Thread-safe code here
}

Cómo funciona la sincronización en la JVM

Cuando un subproceso intenta ingresar al bloque o método sincronizado, debe adquirir un cerrar en el objeto que se está sincronizando. Uno y solo un hilo puede adquirir ese bloqueo a la vez y ejecutar código en ese bloque.

Si otro subproceso intenta acceder a un bloque sincronizado antes de que el subproceso actual complete su ejecución del bloque, tiene que esperar. Cuando el subproceso actual sale del bloque, el bloqueo se libera automáticamente y cualquier subproceso en espera puede adquirir ese bloqueo e ingresar al bloque sincronizado:

  • Para un bloque “sincronizado”, el bloqueo se adquiere en el objeto especificado entre paréntesis después de la palabra clave “sincronizado”.
  • Para un método estático sincronizado, el bloqueo se adquiere en el objeto .class
  • Para un método de instancia ‘sincronizado’, el bloqueo se adquiere en la instancia actual de esa clase, es decir, ’esta’ instancia

Métodos sincronizados

Definir métodos sincronizados es tan fácil como simplemente incluir la palabra clave antes del tipo de retorno. Definamos un método que imprima los números entre 1 y 5 de manera secuencial.

Dos subprocesos intentarán acceder a este método, así que primero veamos cómo terminará esto sin sincronizarlos, y luego bloquearemos el objeto compartido y veremos qué sucede:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class NonSynchronizedMethod {

    public void printNumbers() {
        System.out.println("Starting to print Numbers for " + Thread.currentThread().getName());

        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + " " + i);
        }

        System.out.println("Completed printing Numbers for " + Thread.currentThread().getName());
    }
}

Ahora, implementemos dos subprocesos personalizados que accedan a este objeto y deseen ejecutar el método printNumbers():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class ThreadOne extends Thread {

    NonSynchronizedMethod nonSynchronizedMethod;

    public ThreadOne(NonSynchronizedMethod nonSynchronizedMethod) {
        this.nonSynchronizedMethod = nonSynchronizedMethod;
    }

    @Override
    public void run() {
        nonSynchronizedMethod.printNumbers();
    }
}

class ThreadTwo extends Thread {

    NonSynchronizedMethod nonSynchronizedMethod;

    public ThreadTwo(NonSynchronizedMethod nonSynchronizedMethod) {
        this.nonSynchronizedMethod = nonSynchronizedMethod;
    }

    @Override
    public void run() {
        nonSynchronizedMethod.printNumbers();
    }
}

Estos subprocesos comparten un objeto común NonSynchronizedMethod y simultáneamente intentarán llamar al método no sincronizado printNumbers() en este objeto.

Para probar este comportamiento, escribamos una clase principal:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class TestSynchronization {
    public static void main(String[] args) {

        NonSynchronizedMethod nonSynchronizedMethod = new NonSynchronizedMethod();

        ThreadOne threadOne = new ThreadOne(nonSynchronizedMethod);
        threadOne.setName("ThreadOne");

        ThreadTwo threadTwo = new ThreadTwo(nonSynchronizedMethod);
        threadTwo.setName("ThreadTwo");

        threadOne.start();
        threadTwo.start();

    }
}

Ejecutar el código nos dará algo como:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Starting to print Numbers for ThreadOne
Starting to print Numbers for ThreadTwo
ThreadTwo 0
ThreadTwo 1
ThreadTwo 2
ThreadTwo 3
ThreadTwo 4
Completed printing Numbers for ThreadTwo
ThreadOne 0
ThreadOne 1
ThreadOne 2
ThreadOne 3
ThreadOne 4
Completed printing Numbers for ThreadOne

ThreadOne comenzó primero, aunque ThreadTwo se completó primero.

Y ejecutarlo nuevamente nos saluda con otro resultado no deseado:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Starting to print Numbers for ThreadOne
Starting to print Numbers for ThreadTwo
ThreadOne 0
ThreadTwo 0
ThreadOne 1
ThreadTwo 1
ThreadOne 2
ThreadTwo 2
ThreadOne 3
ThreadOne 4
ThreadTwo 3
Completed printing Numbers for ThreadOne
ThreadTwo 4
Completed printing Numbers for ThreadTwo

Estas salidas se dan completamente al azar y son completamente impredecibles. Cada ejecución nos dará una salida diferente. Considere esto con el hecho de que puede haber muchos más hilos, y podríamos tener un problema. En escenarios del mundo real, es especialmente importante tener esto en cuenta al acceder a algún tipo de recurso compartido, como un archivo u otro tipo de IO, en lugar de simplemente imprimir en la consola.

Ahora, vamos a sincronizar adecuadamente nuestro método:

1
2
3
4
5
6
7
8
9
public synchronized void printNumbers() {
    System.out.println("Starting to print Numbers for " + Thread.currentThread().getName());

    for (int i = 0; i < 5; i++) {
        System.out.println(Thread.currentThread().getName() + " " + i);
    }

    System.out.println("Completed printing Numbers for " + Thread.currentThread().getName());
}

Absolutamente nada ha cambiado, además de incluir la palabra clave synchronized. Ahora, cuando ejecutamos el código:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Starting to print Numbers for ThreadOne
ThreadOne 0
ThreadOne 1
ThreadOne 2
ThreadOne 3
ThreadOne 4
Completed printing Numbers for ThreadOne
Starting to print Numbers for ThreadTwo
ThreadTwo 0
ThreadTwo 1
ThreadTwo 2
ThreadTwo 3
ThreadTwo 4
Completed printing Numbers for ThreadTwo

Esto se ve bien.

Aquí, vemos que aunque los dos subprocesos se ejecutan simultáneamente, solo uno de los subprocesos ingresa al método sincronizado a la vez, que en este caso es ThreadOne.

Una vez que completa la ejecución, ThreadTwo puede comenzar con la ejecución del método printNumbers().

Bloques sincronizados {#bloques sincronizados}

El objetivo principal de los subprocesos múltiples es ejecutar tantas tareas en paralelo como sea posible. Sin embargo, la sincronización acelera el paralelismo de los subprocesos que tienen que ejecutar un método o bloque sincronizado.

Esto reduce el rendimiento y la capacidad de ejecución paralela de la aplicación. Esta desventaja no se puede evitar por completo debido a los recursos compartidos.

Sin embargo, podemos intentar reducir la cantidad de código que se ejecutará de forma sincronizada manteniendo la menor cantidad de código posible en el ámbito de sincronizado. Podría haber muchos escenarios en los que, en lugar de sincronizar todo el método, está bien sincronizar solo unas pocas líneas de código en el método.

Podemos usar el bloque sincronizado para encerrar solo esa parte del código en lugar del método completo.

Dado que hay menos cantidad de código para ejecutar dentro del bloque sincronizado, cada uno de los subprocesos libera el bloqueo más rápidamente. Como resultado, los otros subprocesos pasan menos tiempo esperando el bloqueo y el rendimiento del código aumenta considerablemente.

Modifiquemos el ejemplo anterior para sincronizar solo el ciclo for que imprime la secuencia de números, ya que, de manera realista, es la única parte del código que debe sincronizarse en nuestro ejemplo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class SynchronizedBlockExample {

    public void printNumbers() {

        System.out.println("Starting to print Numbers for " + Thread.currentThread().getName());

        synchronized (this) {
            for (int i = 0; i < 5; i++) {
                System.out.println(Thread.currentThread().getName() + " " + i);
            }
        }

        System.out.println("Completed printing Numbers for " + Thread.currentThread().getName());
    }
}

Veamos ahora la salida:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Starting to print Numbers for ThreadOne
Starting to print Numbers for ThreadTwo
ThreadOne 0
ThreadOne 1
ThreadOne 2
ThreadOne 3
ThreadOne 4
Completed printing Numbers for ThreadOne
ThreadTwo 0
ThreadTwo 1
ThreadTwo 2
ThreadTwo 3
ThreadTwo 4
Completed printing Numbers for ThreadTwo

Aunque puede parecer alarmante que ThreadTwo haya "comenzado" a imprimir números antes de que ThreadOne completara su tarea, esto es solo porque permitimos que el subproceso llegara más allá de System.out.println(Starting to print Numbers for ThreadTwo ) antes de detener ThreadTwo con el candado.

Eso está bien porque solo queríamos sincronizar la secuencia de los números en cada hilo. Podemos ver claramente que los dos subprocesos están imprimiendo números en la secuencia correcta simplemente sincronizando el bucle for.

Conclusión

En este ejemplo, vimos cómo podemos usar la palabra clave sincronizada en Java para lograr la sincronización entre varios subprocesos. También aprendimos cuándo podemos usar el método sincronizado y bloques con ejemplos.

Como siempre, puedes encontrar el código usado en este ejemplo aquí.