Pièges courants de la concurrence à Java

1. Introduction

Dans ce didacticiel, nous allons voir certains des problèmes de concurrence les plus courants en Java. Nous apprendrons également comment les éviter et leurs principales causes.

2. Utilisation d'objets thread-safe

2.1. Partage d'objets

Les threads communiquent principalement en partageant l'accès aux mêmes objets. Ainsi, lire à partir d'un objet pendant qu'il change peut donner des résultats inattendus. En outre, la modification simultanée d'un objet peut le laisser dans un état corrompu ou incohérent.

Le principal moyen d'éviter de tels problèmes de concurrence et de créer un code fiable est de travailler avec des objets immuables . C'est parce que leur état ne peut pas être modifié par l'interférence de plusieurs threads.

Cependant, nous ne pouvons pas toujours travailler avec des objets immuables. Dans ces cas, nous devons trouver des moyens de rendre nos objets mutables thread-safe.

2.2. Rendre les collections thread-safe

Comme tout autre objet, les collections conservent leur état en interne. Cela pourrait être modifié par plusieurs threads modifiant la collection simultanément. Ainsi, une façon de travailler en toute sécurité avec des collections dans un environnement multithread est de les synchroniser :

Map map = Collections.synchronizedMap(new HashMap()); List list = Collections.synchronizedList(new ArrayList());

En général, la synchronisation nous aide à parvenir à une exclusion mutuelle. Plus précisément, ces collections ne sont accessibles que par un seul thread à la fois. Ainsi, nous pouvons éviter de laisser les collections dans un état incohérent.

2.3. Collections spécialisées multithread

Considérons maintenant un scénario où nous avons besoin de plus de lectures que d'écritures. En utilisant une collection synchronisée, notre application peut subir des conséquences majeures sur les performances. Si deux threads veulent lire la collection en même temps, l'un doit attendre que l'autre se termine.

Pour cette raison, Java fournit des collections simultanées telles que CopyOnWriteArrayList et ConcurrentHashMap, accessibles simultanément par plusieurs threads:

CopyOnWriteArrayList list = new CopyOnWriteArrayList(); Map map = new ConcurrentHashMap();

Le CopyOnWriteArrayList réalise fil de sécurité en créant une copie distincte du tableau sous - jacent pour les opérations mutatifs comme ajouter ou retirer. Bien qu'il ait de moins bonnes performances pour les opérations d'écriture qu'un Collections.synchronizedList, il nous offre de meilleures performances lorsque nous avons besoin de beaucoup plus de lectures que d'écritures.

ConcurrentHashMap est fondamentalement thread-safe et est plus performant que l' encapsuleur Collections.synchronizedMap autour d'une Map non thread-safe . Il s'agit en fait d'une carte thread-safe de cartes thread-safe, permettant à différentes activités de se produire simultanément dans ses cartes enfants.

2.4. Utilisation de types non compatibles avec les threads

Nous utilisons souvent des objets intégrés tels que SimpleDateFormat pour analyser et mettre en forme des objets de date. La classe SimpleDateFormat mute son état interne lors de ses opérations.

Nous devons être très prudents avec eux car ils ne sont pas thread-safe. Leur état peut devenir incohérent dans une application multithread en raison d'éléments tels que les conditions de concurrence.

Alors, comment pouvons-nous utiliser le SimpleDateFormat en toute sécurité? Nous avons plusieurs options:

  • Créer une nouvelle instance de SimpleDateFormat à chaque fois qu'il est utilisé
  • Limitez le nombre d'objets créés à l'aide d'un objet ThreadLocal . Il garantit que chaque thread aura sa propre instance de SimpleDateFormat
  • Synchronisez l'accès simultané par plusieurs threads avec le mot-clé synchronized ou un verrou

SimpleDateFormat n'en est qu'un exemple. Nous pouvons utiliser ces techniques avec n'importe quel type non thread-safe.

3. Conditions de course

Une condition de concurrence critique se produit lorsque deux ou plusieurs threads accèdent aux données partagées et qu'ils essaient de les modifier en même temps. Ainsi, les conditions de concurrence peuvent provoquer des erreurs d'exécution ou des résultats inattendus.

3.1. Exemple de condition de course

Considérons le code suivant:

class Counter { private int counter = 0; public void increment() { counter++; } public int getValue() { return counter; } }

La classe Counter est conçue pour que chaque appel de la méthode d'incrémentation ajoute 1 au compteur . Toutefois, si un objet Counter est référencé à partir de plusieurs threads, l'interférence entre les threads peut empêcher cela de se produire comme prévu.

Nous pouvons décomposer l' instruction counter ++ en 3 étapes:

  • Récupérer la valeur actuelle du compteur
  • Incrémenter la valeur récupérée de 1
  • Stocker la valeur incrémentée dans le compteur

Maintenant, supposons que deux threads, thread1 et thread2 , invoquent la méthode d'incrémentation en même temps. Leurs actions entrelacées peuvent suivre cette séquence:

  • thread1 lit la valeur actuelle du compteur ; 0
  • thread2 lit la valeur actuelle du compteur ; 0
  • thread1 incrémente la valeur récupérée; le résultat est 1
  • thread2 incrémente la valeur récupérée; le résultat est 1
  • thread1 stocke le résultat dans counter ; le résultat est maintenant 1
  • thread2 stocke le résultat dans counter ; le résultat est maintenant 1

Nous nous attendions à ce que la valeur du compteur soit de 2, mais elle était de 1.

3.2. Une solution basée sur la synchronisation

Nous pouvons corriger l'incohérence en synchronisant le code critique:

class SynchronizedCounter { private int counter = 0; public synchronized void increment() { counter++; } public synchronized int getValue() { return counter; } }

Un seul thread est autorisé à utiliser les méthodes synchronisées d'un objet à la fois, ce qui force la cohérence dans la lecture et l'écriture du compteur .

3.3. Une solution intégrée

Nous pouvons remplacer le code ci-dessus par un objet AtomicInteger intégré . Cette classe offre, entre autres, des méthodes atomiques pour incrémenter un entier et est une meilleure solution que d'écrire notre propre code. Par conséquent, nous pouvons appeler ses méthodes directement sans avoir besoin de synchronisation:

AtomicInteger atomicInteger = new AtomicInteger(3); atomicInteger.incrementAndGet();

Dans ce cas, le SDK résout le problème pour nous. Sinon, nous aurions pu également écrire notre propre code, encapsulant les sections critiques dans une classe thread-safe personnalisée. Cette approche nous aide à minimiser la complexité et à maximiser la réutilisabilité de notre code.

4. Conditions de course autour des collections

4.1. Le problème

Un autre écueil dans lequel nous pouvons tomber est de penser que les collections synchronisées nous offrent plus de protection qu'elles ne le font réellement.

Examinons le code ci-dessous:

List list = Collections.synchronizedList(new ArrayList()); if(!list.contains("foo")) { list.add("foo"); }

Every operation of our list is synchronized, but any combinations of multiple method invocations are not synchronized. More specifically, between the two operations, another thread can modify our collection leading to undesired results.

For example, two threads could enter the if block at the same time and then update the list, each thread adding the foo value to the list.

4.2. A Solution for Lists

We can protect the code from being accessed by more than one thread at a time using synchronization:

synchronized (list) { if (!list.contains("foo")) { list.add("foo"); } }

Rather than adding the synchronized keyword to the functions, we've created a critical section concerning list, which only allows one thread at a time to perform this operation.

We should note that we can use synchronized(list) on other operations on our list object, to provide a guarantee that only one thread at a time can perform any of our operations on this object.

4.3. A Built-In Solution for ConcurrentHashMap

Now, let's consider using a map for the same reason, namely adding an entry only if it's not present.

The ConcurrentHashMap offers a better solution for this type of problem. We can use its atomic putIfAbsent method:

Map map = new ConcurrentHashMap(); map.putIfAbsent("foo", "bar");

Or, if we want to compute the value, its atomic computeIfAbsent method:

map.computeIfAbsent("foo", key -> key + "bar");

We should note that these methods are part of the interface to Map where they offer a convenient way to avoid writing conditional logic around insertion. They really help us out when trying to make multi-threaded calls atomic.

5. Memory Consistency Issues

Memory consistency issues occur when multiple threads have inconsistent views of what should be the same data.

In addition to the main memory, most modern computer architectures are using a hierarchy of caches (L1, L2, and L3 caches) to improve the overall performance. Thus, any thread may cache variables because it provides faster access compared to the main memory.

5.1. The Problem

Let's recall our Counter example:

class Counter { private int counter = 0; public void increment() { counter++; } public int getValue() { return counter; } }

Let's consider the scenario where thread1 increments the counter and then thread2 reads its value. The following sequence of events might happen:

  • thread1 reads the counter value from its own cache; counter is 0
  • thread1 increments the counter and writes it back to its own cache; counter is 1
  • thread2 reads the counter value from its own cache; counter is 0

Of course, the expected sequence of events could happen too and the thread2 will read the correct value (1), but there is no guarantee that changes made by one thread will be visible to other threads every time.

5.2. The Solution

In order to avoid memory consistency errors, we need to establish a happens-before relationship. This relationship is simply a guarantee that memory updates by one specific statement are visible to another specific statement.

There are several strategies that create happens-before relationships. One of them is synchronization, which we've already looked at.

Synchronization ensures both mutual exclusion and memory consistency. However, this comes with a performance cost.

We can also avoid memory consistency problems by using the volatile keyword. Simply put, every change to a volatile variable is always visible to other threads.

Let's rewrite our Counter example using volatile:

class SyncronizedCounter { private volatile int counter = 0; public synchronized void increment() { counter++; } public int getValue() { return counter; } }

We should note that we still need to synchronize the increment operation because volatile doesn't ensure us mutual exclusion. Using simple atomic variable access is more efficient than accessing these variables through synchronized code.

5.3. Non-Atomic long and double Values

So, if we read a variable without proper synchronization, we may see a stale value. For long and double values, quite surprisingly, it's even possible to see completely random values in addition to stale ones.

According to JLS-17, JVM may treat 64-bit operations as two separate 32-bit operations. Therefore, when reading a long or double value, it's possible to read an updated 32-bit along with a stale 32-bit. Consequently, we may observe random-looking long or double values in concurrent contexts.

On the other hand, writes and reads of volatile long and double values are always atomic.

6. Misusing Synchronize

The synchronization mechanism is a powerful tool to achieve thread-safety. It relies on the use of intrinsic and extrinsic locks. Let's also remember the fact that every object has a different lock and only one thread can acquire a lock at a time.

However, if we don't pay attention and carefully choose the right locks for our critical code, unexpected behavior can occur.

6.1. Synchronizing on this Reference

The method-level synchronization comes as a solution to many concurrency issues. However, it can also lead to other concurrency issues if it's overused. This synchronization approach relies on the this reference as a lock, which is also called an intrinsic lock.

We can see in the following examples how a method-level synchronization can be translated into a block-level synchronization with the this reference as a lock.

These methods are equivalent:

public synchronized void foo() { //... }
public void foo() { synchronized(this) { //... } }

When such a method is called by a thread, other threads cannot concurrently access the object. This can reduce concurrency performance as everything ends up running single-threaded. This approach is especially bad when an object is read more often than it is updated.

Moreover, a client of our code might also acquire the this lock. In the worst-case scenario, this operation can lead to a deadlock.

6.2. Deadlock

Deadlock describes a situation where two or more threads block each other, each waiting to acquire a resource held by some other thread.

Let's consider the example:

public class DeadlockExample { public static Object lock1 = new Object(); public static Object lock2 = new Object(); public static void main(String args[]) { Thread threadA = new Thread(() -> { synchronized (lock1) { System.out.println("ThreadA: Holding lock 1..."); sleep(); System.out.println("ThreadA: Waiting for lock 2..."); synchronized (lock2) { System.out.println("ThreadA: Holding lock 1 & 2..."); } } }); Thread threadB = new Thread(() -> { synchronized (lock2) { System.out.println("ThreadB: Holding lock 2..."); sleep(); System.out.println("ThreadB: Waiting for lock 1..."); synchronized (lock1) { System.out.println("ThreadB: Holding lock 1 & 2..."); } } }); threadA.start(); threadB.start(); } }

In the above code we can clearly see that first threadA acquires lock1 and threadB acquires lock2. Then, threadA tries to get the lock2 which is already acquired by threadB and threadB tries to get the lock1 which is already acquired by threadA. So, neither of them will proceed meaning they are in a deadlock.

We can easily fix this issue by changing the order of locks in one of the threads.

We should note that this is just one example, and there are many others that can lead to a deadlock.

7. Conclusion

Dans cet article, nous avons exploré plusieurs exemples de problèmes de concurrence que nous sommes susceptibles de rencontrer dans nos applications multithread.

Tout d'abord, nous avons appris qu'il fallait opter pour des objets ou des opérations qui sont soit immuables, soit thread-safe.

Ensuite, nous avons vu plusieurs exemples de conditions de concurrence et comment nous pouvons les éviter en utilisant le mécanisme de synchronisation. De plus, nous avons appris les conditions de course liées à la mémoire et comment les éviter.

Bien que le mécanisme de synchronisation nous aide à éviter de nombreux problèmes de concurrence, nous pouvons facilement en abuser et créer d'autres problèmes. Pour cette raison, nous avons examiné plusieurs problèmes auxquels nous pourrions être confrontés lorsque ce mécanisme est mal utilisé.

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