Qu'est-ce que la sécurité des fils et comment y parvenir?

1. Vue d'ensemble

Java prend en charge le multithreading prêt à l'emploi. Cela signifie qu'en exécutant le bytecode simultanément dans des threads de travail distincts, la machine virtuelle Java est capable d'améliorer les performances des applications.

Bien que le multithreading soit une fonctionnalité puissante, il a un prix. Dans les environnements multithread, nous devons écrire des implémentations de manière thread-safe. Cela signifie que différents threads peuvent accéder aux mêmes ressources sans exposer un comportement erroné ou produire des résultats imprévisibles. Cette méthodologie de programmation est connue sous le nom de «thread-safety».

Dans ce didacticiel, nous examinerons différentes approches pour y parvenir.

2. Implémentations sans état

Dans la plupart des cas, les erreurs dans les applications multithread sont le résultat d'un partage incorrect de l'état entre plusieurs threads.

Par conséquent, la première approche que nous examinerons consiste à assurer la sécurité des threads à l' aide d'implémentations sans état .

Pour mieux comprendre cette approche, considérons une classe d'utilité simple avec une méthode statique qui calcule la factorielle d'un nombre:

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; } } 

La méthode factorielle () est une fonction déterministe sans état. Étant donné une entrée spécifique, elle produit toujours la même sortie.

La méthode ne repose pas sur l'état externe et ne maintient pas du tout l'état . Par conséquent, il est considéré comme étant thread-safe et peut être appelé en toute sécurité par plusieurs threads en même temps.

Tous les threads peuvent appeler en toute sécurité la méthode factorial () et obtiendront le résultat attendu sans interférer les uns avec les autres et sans modifier la sortie que la méthode génère pour les autres threads.

Par conséquent, les implémentations sans état sont le moyen le plus simple de garantir la sécurité des threads .

3. Implémentations immuables

Si nous devons partager l'état entre différents threads, nous pouvons créer des classes thread-safe en les rendant immuables .

L'immuabilité est un concept puissant et indépendant du langage et il est assez facile à réaliser en Java.

Pour faire simple, une instance de classe est immuable lorsque son état interne ne peut pas être modifié après sa construction .

Le moyen le plus simple de créer une classe immuable en Java est de déclarer tous les champs private et final et de ne pas fournir de setters:

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

Un objet MessageService est effectivement immuable car son état ne peut pas changer après sa construction. Par conséquent, il est thread-safe.

De plus, si MessageService était réellement mutable, mais que plusieurs threads n'y ont qu'un accès en lecture seule, il est également sécurisé.

Ainsi, l' immuabilité n'est qu'un autre moyen de garantir la sécurité des threads .

4. Champs locaux de thread

Dans la programmation orientée objet (POO), les objets doivent en fait maintenir l'état à travers les champs et implémenter le comportement via une ou plusieurs méthodes.

Si nous avons réellement besoin de maintenir l'état, nous pouvons créer des classes thread-safe qui ne partagent pas l'état entre les threads en rendant leurs champs thread-locaux.

Nous pouvons facilement créer des classes dont les champs sont thread-locaux en définissant simplement des champs privés dans les classes Thread .

Nous pourrions définir, par exemple, une classe Thread qui stocke un tableau d' entiers :

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); } }

Alors qu'un autre peut contenir un tableau de chaînes :

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); } }

Dans les deux implémentations, les classes ont leur propre état, mais il n'est pas partagé avec d'autres threads. Ainsi, les classes sont thread-safe.

De même, nous pouvons créer des champs locaux de thread en affectant des instances ThreadLocal à un champ.

Considérons, par exemple, la classe StateHolder suivante :

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

Nous pouvons facilement en faire une variable locale de thread comme suit:

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(); } }

Les champs locaux de threads ressemblent à peu près aux champs de classe normaux, sauf que chaque thread qui y accède via un setter / getter obtient une copie initialisée indépendamment du champ afin que chaque thread ait son propre état.

5. Collections synchronisées

Nous pouvons facilement créer des collections thread-safe en utilisant l'ensemble de wrappers de synchronisation inclus dans le framework de collections.

Nous pouvons utiliser, par exemple, l'un de ces wrappers de synchronisation pour créer une collection thread-safe:

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(); 

Gardons à l'esprit que les collections synchronisées utilisent le verrouillage intrinsèque dans chaque méthode (nous examinerons le verrouillage intrinsèque plus tard).

Cela signifie que les méthodes ne sont accessibles que par un seul thread à la fois, tandis que les autres threads seront bloqués jusqu'à ce que la méthode soit déverrouillée par le premier thread.

Ainsi, la synchronisation a une pénalité en termes de performances, en raison de la logique sous-jacente de l'accès synchronisé.

6. Collections simultanées

Alternativement aux collections synchronisées, nous pouvons utiliser des collections simultanées pour créer des collections thread-safe.

Java fournit le package java.util.concurrent , qui contient plusieurs collections simultanées, telles que ConcurrentHashMap :

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

Contrairement à leurs homologues synchronisés , les collectes simultanées assurent la sécurité des threads en divisant leurs données en segments . Dans un ConcurrentHashMap , par exemple, plusieurs threads peuvent acquérir des verrous sur différents segments de carte, de sorte que plusieurs threads peuvent accéder à la carte en même temps.

Les collections simultanées sont beaucoup plus performantes que les collections synchronisées , en raison des avantages inhérents à l'accès simultané aux threads.

Il convient de mentionner que les collections synchronisées et simultanées ne font que rendre la collection elle-même sûre pour les threads et non le contenu .

7. Objets atomiques

Il est également possible d'assurer la sécurité des threads en utilisant l'ensemble de classes atomiques fourni par Java, notamment AtomicInteger , AtomicLong , AtomicBoolean et AtomicReference .

Les classes atomiques nous permettent d'effectuer des opérations atomiques, qui sont thread-safe, sans utiliser la synchronisation . Une opération atomique est exécutée en une seule opération au niveau de la machine.

Pour comprendre le problème que cela résout, examinons la classe Counter suivante :

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

Supposons que dans une condition de concurrence, deux threads accèdent à la méthode incrementCounter () en même temps.

En théorie, la valeur finale du champ compteur sera 2. Mais nous ne pouvons pas être sûrs du résultat, car les threads exécutent le même bloc de code en même temps et l'incrémentation n'est pas atomique.

Créons une implémentation thread-safe de la classe Counter en utilisant un objet AtomicInteger :

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

C'est thread-safe car, tandis que l'incrémentation, ++, prend plus d'une opération, incrementAndGet est atomique .

8. Méthodes synchronisées

Alors que les approches antérieures sont très bonnes pour les collections et les primitives, nous aurons parfois besoin d'un plus grand contrôle que cela.

Ainsi, une autre approche courante que nous pouvons utiliser pour assurer la sécurité des threads consiste à implémenter des méthodes synchronisées.

En termes simples , un seul thread peut accéder à une méthode synchronisée à la fois tout en bloquant l'accès à cette méthode à partir d'autres threads . Les autres threads resteront bloqués jusqu'à ce que le premier thread se termine ou que la méthode lève une exception.

Nous pouvons créer une version thread-safe de incrementCounter () d'une autre manière en en faisant une méthode synchronisée:

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

Nous avons créé une méthode synchronisée en préfixant la signature de méthode avec le mot-clé synchronized .

Puisqu'un thread à la fois peut accéder à une méthode synchronisée, un thread exécutera la méthode incrementCounter () , et à leur tour, les autres feront de même. Aucune exécution chevauchante ne se produira.

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.

En conséquence, il est possible que de nombreux threads lisent une ressource, tant qu'aucun thread n'écrit dessus. De plus, le thread qui écrit sur la ressource empêchera les autres threads de la lire .

Nous pouvons utiliser un verrou ReadWriteLock comme suit:

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. Conclusion

Dans cet article, nous avons appris ce qu'est la sécurité des threads en Java et avons examiné en profondeur différentes approches pour y parvenir .

Comme d'habitude, tous les exemples de code présentés dans cet article sont disponibles à l'adresse over sur GitHub.