¿Qué es Thread-Safety y cómo lograrlo?

1. Información general

Java admite subprocesos múltiples listos para usar. Esto significa que al ejecutar código de bytes simultáneamente en subprocesos de trabajo separados, la JVM es capaz de mejorar el rendimiento de la aplicación.

Aunque el subproceso múltiple es una característica poderosa, tiene un precio. En entornos multiproceso, necesitamos escribir implementaciones de una manera segura para subprocesos. Esto significa que diferentes subprocesos pueden acceder a los mismos recursos sin exponer un comportamiento erróneo o producir resultados impredecibles. Esta metodología de programación se conoce como "seguridad de subprocesos".

En este tutorial, veremos diferentes enfoques para lograrlo.

2. Implementaciones sin estado

En la mayoría de los casos, los errores en aplicaciones multiproceso son el resultado de compartir el estado incorrectamente entre varios subprocesos.

Por lo tanto, el primer enfoque que veremos es lograr la seguridad de subprocesos utilizando implementaciones sin estado .

Para comprender mejor este enfoque, consideremos una clase de utilidad simple con un método estático que calcula el factorial de un número:

public class MathUtils { public static BigInteger factorial(int number) { BigInteger f = new BigInteger("1"); for (int i = 2; i <= number; i++) { f = f.multiply(BigInteger.valueOf(i)); } return f; } } 

El método factorial () es una función determinista sin estado. Dada una entrada específica, siempre produce la misma salida.

El método no se basa en el estado externo ni lo mantiene en absoluto . Por lo tanto, se considera seguro para subprocesos y puede ser llamado de forma segura por varios subprocesos al mismo tiempo.

Todos los subprocesos pueden llamar de forma segura al método factorial () y obtendrán el resultado esperado sin interferir entre sí y sin alterar la salida que genera el método para otros subprocesos.

Por lo tanto, las implementaciones sin estado son la forma más sencilla de lograr la seguridad de los subprocesos .

3. Implementaciones inmutables

Si necesitamos compartir el estado entre diferentes subprocesos, podemos crear clases seguras para subprocesos haciéndolas inmutables .

La inmutabilidad es un concepto poderoso que no depende del lenguaje y es bastante fácil de lograr en Java.

En pocas palabras, una instancia de clase es inmutable cuando su estado interno no se puede modificar después de haber sido construida .

La forma más fácil de crear una clase inmutable en Java es declarando todos los campos privados y finales y no proporcionando establecedores:

public class MessageService { private final String message; public MessageService(String message) { this.message = message; } // standard getter }

Un objeto MessageService es efectivamente inmutable ya que su estado no puede cambiar después de su construcción. Por lo tanto, es seguro para subprocesos.

Además, si MessageService fuera realmente mutable, pero varios subprocesos solo tienen acceso de solo lectura, también es seguro para subprocesos.

Por lo tanto, la inmutabilidad es solo otra forma de lograr la seguridad de los hilos .

4. Campos locales de subprocesos

En la programación orientada a objetos (OOP), los objetos realmente necesitan mantener el estado a través de campos e implementar el comportamiento a través de uno o más métodos.

Si realmente necesitamos mantener el estado, podemos crear clases seguras para subprocesos que no comparten el estado entre subprocesos haciendo que sus campos sean locales para subprocesos.

Podemos crear fácilmente clases cuyos campos sean locales de subprocesos simplemente definiendo campos privados en clases de subprocesos .

Podríamos definir, por ejemplo, una clase Thread que almacena una matriz de enteros :

public class ThreadA extends Thread { private final List numbers = Arrays.asList(1, 2, 3, 4, 5, 6); @Override public void run() { numbers.forEach(System.out::println); } }

Mientras que otro podría contener una variedad de cadenas :

public class ThreadB extends Thread { private final List letters = Arrays.asList("a", "b", "c", "d", "e", "f"); @Override public void run() { letters.forEach(System.out::println); } }

En ambas implementaciones, las clases tienen su propio estado, pero no se comparte con otros subprocesos. Por lo tanto, las clases son seguras para subprocesos.

De manera similar, podemos crear campos locales de subprocesos asignando instancias de ThreadLocal a un campo.

Consideremos, por ejemplo, la siguiente clase StateHolder :

public class StateHolder { private final String state; // standard constructors / getter }

Podemos convertirla fácilmente en una variable local de subproceso de la siguiente manera:

public class ThreadState { public static final ThreadLocal statePerThread = new ThreadLocal() { @Override protected StateHolder initialValue() { return new StateHolder("active"); } }; public static StateHolder getState() { return statePerThread.get(); } }

Los campos locales de subprocesos son muy parecidos a los campos de clases normales, excepto que cada subproceso que accede a ellos a través de un establecedor / captador obtiene una copia inicializada de forma independiente del campo para que cada subproceso tenga su propio estado.

5. Colecciones sincronizadas

Podemos crear fácilmente colecciones seguras para subprocesos utilizando el conjunto de envoltorios de sincronización incluidos en el marco de colecciones.

Podemos usar, por ejemplo, uno de estos contenedores de sincronización para crear una colección segura para subprocesos:

Collection syncCollection = Collections.synchronizedCollection(new ArrayList()); Thread thread1 = new Thread(() -> syncCollection.addAll(Arrays.asList(1, 2, 3, 4, 5, 6))); Thread thread2 = new Thread(() -> syncCollection.addAll(Arrays.asList(7, 8, 9, 10, 11, 12))); thread1.start(); thread2.start(); 

Tengamos en cuenta que las colecciones sincronizadas usan el bloqueo intrínseco en cada método (veremos el bloqueo intrínseco más adelante).

Esto significa que solo un hilo a la vez puede acceder a los métodos, mientras que otros hilos se bloquearán hasta que el primer hilo desbloquee el método.

Por tanto, la sincronización tiene una penalización en el rendimiento, debido a la lógica subyacente del acceso sincronizado.

6. Cobros concurrentes

Alternativamente a las colecciones sincronizadas, podemos usar colecciones concurrentes para crear colecciones seguras para subprocesos.

Java proporciona el paquete java.util.concurrent , que contiene varias colecciones simultáneas, como ConcurrentHashMap :

Map concurrentMap = new ConcurrentHashMap(); concurrentMap.put("1", "one"); concurrentMap.put("2", "two"); concurrentMap.put("3", "three"); 

A diferencia de sus contrapartes sincronizadas , las colecciones simultáneas logran la seguridad de los subprocesos al dividir sus datos en segmentos . En un ConcurrentHashMap , por ejemplo, varios subprocesos pueden adquirir bloqueos en diferentes segmentos del mapa, por lo que varios subprocesos pueden acceder al mapa al mismo tiempo.

Las colecciones concurrentes son mucho más eficaces que las colecciones sincronizadas , debido a las ventajas inherentes del acceso a subprocesos concurrentes.

Vale la pena mencionar que las colecciones simultáneas y sincronizadas solo hacen que la colección en sí sea segura para subprocesos y no el contenido .

7. Objetos atómicos

También es posible lograr la seguridad de los subprocesos mediante el conjunto de clases atómicas que proporciona Java, incluidos AtomicInteger , AtomicLong , AtomicBoolean y AtomicReference .

Las clases atómicas nos permiten realizar operaciones atómicas, que son seguras para subprocesos, sin usar sincronización . Una operación atómica se ejecuta en una sola operación a nivel de máquina.

Para entender el problema que esto resuelve, veamos la siguiente clase Counter :

public class Counter { private int counter = 0; public void incrementCounter() { counter += 1; } public int getCounter() { return counter; } }

Supongamos que en una condición de carrera, dos subprocesos acceden al método incrementCounter () al mismo tiempo.

In theory, the final value of the counter field will be 2. But we just can't be sure about the result, because the threads are executing the same code block at the same time and incrementation is not atomic.

Let's create a thread-safe implementation of the Counter class by using an AtomicInteger object:

public class AtomicCounter { private final AtomicInteger counter = new AtomicInteger(); public void incrementCounter() { counter.incrementAndGet(); } public int getCounter() { return counter.get(); } }

This is thread-safe because, while incrementation, ++, takes more than one operation, incrementAndGet is atomic.

8. Synchronized Methods

While the earlier approaches are very good for collections and primitives, we will at times need greater control than that.

So, another common approach that we can use for achieving thread-safety is implementing synchronized methods.

Simply put, only one thread can access a synchronized method at a time while blocking access to this method from other threads. Other threads will remain blocked until the first thread finishes or the method throws an exception.

We can create a thread-safe version of incrementCounter() in another way by making it a synchronized method:

public synchronized void incrementCounter() { counter += 1; }

We've created a synchronized method by prefixing the method signature with the synchronized keyword.

Since one thread at a time can access a synchronized method, one thread will execute the incrementCounter() method, and in turn, others will do the same. No overlapping execution will occur whatsoever.

Synchronized methods rely on the use of “intrinsic locks” or “monitor locks”. An intrinsic lock is an implicit internal entity associated with a particular class instance.

In a multithreaded context, the term monitor is just a reference to the role that the lock performs on the associated object, as it enforces exclusive access to a set of specified methods or statements.

When a thread calls a synchronized method, it acquires the intrinsic lock. After the thread finishes executing the method, it releases the lock, hence allowing other threads to acquire the lock and get access to the method.

We can implement synchronization in instance methods, static methods, and statements (synchronized statements).

9. Synchronized Statements

Sometimes, synchronizing an entire method might be overkill if we just need to make a segment of the method thread-safe.

To exemplify this use case, let's refactor the incrementCounter() method:

public void incrementCounter() { // additional unsynced operations synchronized(this) { counter += 1;  } }

The example is trivial, but it shows how to create a synchronized statement. Assuming that the method now performs a few additional operations, which don't require synchronization, we only synchronized the relevant state-modifying section by wrapping it within a synchronized block.

Unlike synchronized methods, synchronized statements must specify the object that provides the intrinsic lock, usually the this reference.

Synchronization is expensive, so with this option, we are able to only synchronize the relevant parts of a method.

9.1. Other Objects as a Lock

We can slightly improve the thread-safe implementation of the Counter class by exploiting another object as a monitor lock, instead of this.

Not only does this provide coordinated access to a shared resource in a multithreaded environment, but also it uses an external entity to enforce exclusive access to the resource:

public class ObjectLockCounter { private int counter = 0; private final Object lock = new Object(); public void incrementCounter() { synchronized(lock) { counter += 1; } } // standard getter }

We use a plain Object instance to enforce mutual exclusion. This implementation is slightly better, as it promotes security at the lock level.

When using this for intrinsic locking, an attacker could cause a deadlock by acquiring the intrinsic lock and triggering a denial of service (DoS) condition.

On the contrary, when using other objects, that private entity is not accessible from the outside. This makes it harder for an attacker to acquire the lock and cause a deadlock.

9.2. Caveats

Even though we can use any Java object as an intrinsic lock, we should avoid using Strings for locking purposes:

public class Class1 { private static final String LOCK = "Lock"; // uses the LOCK as the intrinsic lock } public class Class2 { private static final String LOCK = "Lock"; // uses the LOCK as the intrinsic lock }

At first glance, it seems that these two classes are using two different objects as their lock. However, because of string interning, these two “Lock” values may actually refer to the same object on the string pool. That is, the Class1 and Class2 are sharing the same lock!

This, in turn, may cause some unexpected behaviors in concurrent contexts.

In addition to Strings, we should avoid using any cacheable or reusable objects as intrinsic locks. For example, the Integer.valueOf() method caches small numbers. Therefore, calling Integer.valueOf(1) returns the same object even in different classes.

10. Volatile Fields

Synchronized methods and blocks are handy for addressing variable visibility problems among threads. Even so, the values of regular class fields might be cached by the CPU. Hence, consequent updates to a particular field, even if they're synchronized, might not be visible to other threads.

To prevent this situation, we can use volatile class fields:

public class Counter { private volatile int counter; // standard constructors / getter }

With the volatile keyword, we instruct the JVM and the compiler to store the counter variable in the main memory. That way, we make sure that every time the JVM reads the value of the counter variable, it will actually read it from the main memory, instead of from the CPU cache. Likewise, every time the JVM writes to the counter variable, the value will be written to the main memory.

Moreover, the use of a volatile variable ensures that all variables that are visible to a given thread will be read from the main memory as well.

Let's consider the following example:

public class User { private String name; private volatile int age; // standard constructors / getters }

In this case, each time the JVM writes the agevolatile variable to the main memory, it will write the non-volatile name variable to the main memory as well. This assures that the latest values of both variables are stored in the main memory, so consequent updates to the variables will automatically be visible to other threads.

Similarly, if a thread reads the value of a volatile variable, all the variables visible to the thread will be read from the main memory too.

This extended guarantee that volatile variables provide is known as the full volatile visibility guarantee.

11. Reentrant Locks

Java provides an improved set of Lock implementations, whose behavior is slightly more sophisticated than the intrinsic locks discussed above.

With intrinsic locks, the lock acquisition model is rather rigid: one thread acquires the lock, then executes a method or code block, and finally releases the lock, so other threads can acquire it and access the method.

There's no underlying mechanism that checks the queued threads and gives priority access to the longest waiting threads.

ReentrantLock instances allow us to do exactly that, hence preventing queued threads from suffering some types of resource starvation:

public class ReentrantLockCounter { private int counter; private final ReentrantLock reLock = new ReentrantLock(true); public void incrementCounter() { reLock.lock(); try { counter += 1; } finally { reLock.unlock(); } } // standard constructors / getter }

The ReentrantLock constructor takes an optional fairnessboolean parameter. When set to true, and multiple threads are trying to acquire a lock, the JVM will give priority to the longest waiting thread and grant access to the lock.

12. Read/Write Locks

Another powerful mechanism that we can use for achieving thread-safety is the use of ReadWriteLock implementations.

A ReadWriteLock lock actually uses a pair of associated locks, one for read-only operations and other for writing operations.

Como resultado, es posible tener muchos subprocesos leyendo un recurso, siempre que no haya subprocesos escribiendo en él. Además, el hilo que escribe en el recurso evitará que otros hilos lo lean .

Podemos usar un bloqueo ReadWriteLock de la siguiente manera:

public class ReentrantReadWriteLockCounter { private int counter; private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(); private final Lock readLock = rwLock.readLock(); private final Lock writeLock = rwLock.writeLock(); public void incrementCounter() { writeLock.lock(); try { counter += 1; } finally { writeLock.unlock(); } } public int getCounter() { readLock.lock(); try { return counter; } finally { readLock.unlock(); } } // standard constructors } 

13. Conclusión

En este artículo, aprendimos qué es la seguridad de subprocesos en Java y analizamos en profundidad diferentes enfoques para lograrlo .

Como de costumbre, todos los ejemplos de código que se muestran en este artículo están disponibles en GitHub.