Une introduction aux variables atomiques en Java

1. Introduction

En termes simples, un état mutable partagé conduit très facilement à des problèmes lorsque la concurrence est impliquée. Si l'accès aux objets mutables partagés n'est pas géré correctement, les applications peuvent rapidement devenir sujettes à des erreurs de concurrence difficiles à détecter.

Dans cet article, nous reviendrons sur l'utilisation des verrous pour gérer l'accès simultané, explorerons certains des inconvénients associés aux verrous et, enfin, introduirons des variables atomiques comme alternative.

2. Serrures

Jetons un coup d'œil à la classe:

public class Counter { int counter; public void increment() { counter++; } }

Dans le cas d'un environnement monothread, cela fonctionne parfaitement; cependant, dès que nous autorisons plus d'un thread à écrire, nous commençons à obtenir des résultats incohérents.

Ceci est dû à la simple opération d'incrémentation ( counter ++ ), qui peut ressembler à une opération atomique, mais en fait est une combinaison de trois opérations: obtenir la valeur, incrémenter et réécrire la valeur mise à jour.

Si deux threads tentent d'obtenir et de mettre à jour la valeur en même temps, cela peut entraîner la perte de mises à jour.

L'un des moyens de gérer l'accès à un objet consiste à utiliser des verrous. Cela peut être réalisé en utilisant le mot-clé synchronized dans la signature de la méthode d' incrémentation . Le mot clé synchronized garantit qu'un seul thread peut entrer la méthode à la fois (pour en savoir plus sur le verrouillage et la synchronisation, reportez-vous au Guide du mot-clé synchronisé en Java):

public class SafeCounterWithLock { private volatile int counter; public synchronized void increment() { counter++; } }

De plus, nous devons ajouter le mot-clé volatile pour garantir une bonne visibilité des références parmi les threads.

L'utilisation de verrous résout le problème. Cependant, la performance en prend un coup.

Lorsque plusieurs threads tentent d'acquérir un verrou, l'un d'eux l'emporte, tandis que le reste des threads est bloqué ou suspendu.

Le processus de suspension puis de reprise d'un thread est très coûteux et affecte l'efficacité globale du système.

Dans un petit programme, tel que le compteur , le temps passé à changer de contexte peut devenir bien plus que l'exécution réelle du code, réduisant ainsi considérablement l'efficacité globale.

3. Opérations atomiques

Il existe une branche de recherche axée sur la création d'algorithmes non bloquants pour des environnements concurrents. Ces algorithmes exploitent des instructions de machine atomique de bas niveau telles que comparer et échanger (CAS), pour garantir l'intégrité des données.

Une opération CAS typique fonctionne sur trois opérandes:

  1. L'emplacement mémoire sur lequel opérer (M)
  2. La valeur attendue existante (A) de la variable
  3. La nouvelle valeur (B) qui doit être définie

L'opération CAS met à jour atomiquement la valeur de M en B, mais uniquement si la valeur existante dans M correspond à A, sinon aucune action n'est entreprise.

Dans les deux cas, la valeur existante dans M est renvoyée. Cela combine trois étapes - obtenir la valeur, comparer la valeur et mettre à jour la valeur - en une seule opération au niveau de la machine.

Lorsque plusieurs threads tentent de mettre à jour la même valeur via CAS, l'un d'eux gagne et met à jour la valeur. Cependant, contrairement au cas des verrous, aucun autre thread n'est suspendu ; au lieu de cela, ils sont simplement informés qu'ils n'ont pas réussi à mettre à jour la valeur. Les threads peuvent ensuite continuer à travailler et les changements de contexte sont complètement évités.

Une autre conséquence est que la logique du programme de base devient plus complexe. C'est parce que nous devons gérer le scénario lorsque l'opération CAS n'a pas réussi. Nous pouvons le réessayer encore et encore jusqu'à ce qu'il réussisse, ou nous pouvons ne rien faire et passer à autre chose en fonction du cas d'utilisation.

4. Variables atomiques en Java

Les classes de variables atomiques les plus couramment utilisées en Java sont AtomicInteger, AtomicLong, AtomicBoolean et AtomicReference. Ces classes représentent respectivement une référence int , long , booléenne et objet qui peut être mise à jour de manière atomique. Les principales méthodes exposées par ces classes sont:

  • get () - récupère la valeur de la mémoire, de sorte que les modifications apportées par d'autres threads soient visibles; équivaut à lire une variable volatile
  • set () - écrit la valeur dans la mémoire, de sorte que le changement soit visible par les autres threads; équivaut à écrire une variable volatile
  • lazySet () - écrit éventuellement la valeur en mémoire, peut-être réorganisée avec les opérations de mémoire pertinentes suivantes. Un cas d'utilisation est l'annulation des références, dans un souci de garbage collection, qui ne sera plus jamais accessible. Dans ce cas, de meilleures performances sont obtenues en retardant l' écriture volatile nulle
  • compareAndSet () - identique à celui décrit dans la section 3, retourne true quand il réussit, sinon false
  • lowCompareAndSet () - identique à celui décrit dans la section 3, mais plus faible dans le sens où il ne crée pas d'ordres avant. Cela signifie qu'il ne verra pas nécessairement les mises à jour apportées à d'autres variables. Depuis Java 9, cette méthode a été déconseillée dans toutes les implémentations atomiques au profit de lowCompareAndSetPlain () . Les effets de mémoire de lowCompareAndSet () étaient simples mais ses noms impliquaient des effets de mémoire volatile. Pour éviter cette confusion, ils ont déconseillé cette méthode et ajouté quatre méthodes avec des effets de mémoire différents tels que lowCompareAndSetPlain () ou lowCompareAndSetVolatile ()

Un compteur thread-safe implémenté avec AtomicInteger est illustré dans l'exemple ci-dessous:

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

Comme vous pouvez le voir, nous réessayons l' opération compareAndSet et à nouveau en cas d'échec, car nous voulons garantir que l'appel à la méthode d' incrémentation augmente toujours la valeur de 1.

5. Conclusion

Dans ce rapide didacticiel, nous avons décrit une autre façon de gérer la concurrence dans laquelle les inconvénients associés au verrouillage peuvent être évités. Nous avons également examiné les principales méthodes exposées par les classes de variables atomiques en Java.

Comme toujours, les exemples sont tous disponibles sur GitHub.

Pour explorer plus de classes qui utilisent en interne des algorithmes non bloquants, reportez-vous à un guide de ConcurrentMap.