1. Introducción
En pocas palabras, un estado mutable compartido conduce muy fácilmente a problemas cuando se trata de concurrencia. Si el acceso a los objetos mutables compartidos no se gestiona correctamente, las aplicaciones pueden volverse rápidamente propensas a algunos errores de concurrencia difíciles de detectar.
En este artículo, revisaremos el uso de bloqueos para manejar el acceso concurrente, exploraremos algunas de las desventajas asociadas con los bloqueos y, finalmente, presentaremos variables atómicas como alternativa.
2. Cerraduras
Echemos un vistazo a la clase:
public class Counter { int counter; public void increment() { counter++; } }
En el caso de un entorno de un solo subproceso, esto funciona perfectamente; sin embargo, tan pronto como permitimos que se escriba más de un hilo, comenzamos a obtener resultados inconsistentes.
Esto se debe a la operación de incremento simple ( contador ++ ), que puede parecer una operación atómica, pero en realidad es una combinación de tres operaciones: obtener el valor, incrementar y volver a escribir el valor actualizado.
Si dos subprocesos intentan obtener y actualizar el valor al mismo tiempo, es posible que se pierdan las actualizaciones.
Una de las formas de administrar el acceso a un objeto es usar candados. Esto se puede lograr utilizando la palabra clave sincronizada en la firma del método de incremento . La palabra clave sincronizada garantiza que solo un hilo pueda ingresar al método a la vez (para obtener más información sobre el bloqueo y la sincronización, consulte - Guía de palabras clave sincronizadas en Java):
public class SafeCounterWithLock { private volatile int counter; public synchronized void increment() { counter++; } }
Además, necesitamos agregar la palabra clave volátil para garantizar una visibilidad de referencia adecuada entre los hilos.
Usar candados resuelve el problema. Sin embargo, el rendimiento se ve afectado.
Cuando varios subprocesos intentan adquirir un bloqueo, uno de ellos gana, mientras que el resto de los subprocesos están bloqueados o suspendidos.
El proceso de suspender y luego reanudar un hilo es muy costoso y afecta la eficiencia general del sistema.
En un programa pequeño, como el contador , el tiempo dedicado al cambio de contexto puede llegar a ser mucho más que la ejecución real del código, lo que reduce en gran medida la eficiencia general.
3. Operaciones atómicas
Existe una rama de investigación centrada en la creación de algoritmos sin bloqueo para entornos concurrentes. Estos algoritmos aprovechan las instrucciones de máquinas atómicas de bajo nivel, como comparar e intercambiar (CAS), para garantizar la integridad de los datos.
Una operación CAS típica funciona en tres operandos:
- La ubicación de la memoria en la que operar (M)
- El valor esperado existente (A) de la variable
- El nuevo valor (B) que debe establecerse
La operación CAS actualiza atómicamente el valor de M a B, pero solo si el valor existente en M coincide con A; de lo contrario, no se realiza ninguna acción.
En ambos casos, se devuelve el valor existente en M. Esto combina tres pasos: obtener el valor, comparar el valor y actualizar el valor, en una sola operación a nivel de máquina.
Cuando varios subprocesos intentan actualizar el mismo valor a través de CAS, uno de ellos gana y actualiza el valor. Sin embargo, a diferencia del caso de los bloqueos, ningún otro hilo se suspende ; en cambio, simplemente se les informa que no lograron actualizar el valor. Los subprocesos pueden continuar con el trabajo y se evitan por completo los cambios de contexto.
Otra consecuencia es que la lógica central del programa se vuelve más compleja. Esto se debe a que tenemos que manejar el escenario cuando la operación CAS no tuvo éxito. Podemos volver a intentarlo una y otra vez hasta que tenga éxito, o no podemos hacer nada y seguir adelante según el caso de uso.
4. Variables atómicas en Java
Las clases de variables atómicas más utilizadas en Java son AtomicInteger, AtomicLong, AtomicBoolean y AtomicReference. Estas clases representan una referencia int , long , booleana y de objeto, respectivamente, que se pueden actualizar atómicamente. Los principales métodos expuestos por estas clases son:
- get () : obtiene el valor de la memoria, de modo que los cambios realizados por otros hilos sean visibles; equivalente a leer una variable volátil
- set () : escribe el valor en la memoria, de modo que el cambio sea visible para otros hilos; equivalente a escribir una variable volátil
- lazySet () : eventualmente escribe el valor en la memoria, tal vez reordenado con las siguientes operaciones de memoria relevantes. Un caso de uso es anular referencias, por el bien de la recolección de basura, a la que nunca se volverá a acceder. En este caso, se logra un mejor rendimiento retrasando la escritura volátil nula
- compareAndSet () - igual que se describe en la sección 3, devuelve verdadero cuando tiene éxito, de lo contrario es falso
- DébilCompareAndSet () : igual que se describe en la sección 3, pero más débil en el sentido de que no crea ordenaciones de sucesos anteriores. Esto significa que es posible que no vea necesariamente las actualizaciones realizadas en otras variables. A partir de Java 9, este método ha quedado obsoleto en todas las implementaciones atómicas a favor de thinCompareAndSetPlain () . Los efectos de memoria de débilesCompareAndSet () eran simples, pero sus nombres implicaban efectos de memoria volátiles. Para evitar esta confusión, desaprobaron este método y agregaron cuatro métodos con diferentes efectos de memoria, como débilCompareAndSetPlain () o débilCompareAndSetVolatile ()
En el siguiente ejemplo se muestra un contador seguro para subprocesos implementado con AtomicInteger :
public class SafeCounterWithoutLock { private final AtomicInteger counter = new AtomicInteger(0); public int getValue() { return counter.get(); } public void increment() { while(true) { int existingValue = getValue(); int newValue = existingValue + 1; if(counter.compareAndSet(existingValue, newValue)) { return; } } } }
Como puede ver, volvemos a intentar la operación compareAndSet y nuevamente en caso de falla, ya que queremos garantizar que la llamada al método de incremento siempre aumente el valor en 1.
5. Conclusión
En este tutorial rápido, describimos una forma alternativa de manejar la concurrencia donde se pueden evitar las desventajas asociadas con el bloqueo. También analizamos los métodos principales expuestos por las clases de variables atómicas en Java.
Como siempre, todos los ejemplos están disponibles en GitHub.
Para explorar más clases que utilizan internamente algoritmos sin bloqueo, consulte una guía de ConcurrentMap.