Conditions préalables à l'émergence des opérations atomiques

Examinons cet exemple pour vous aider à comprendre le fonctionnement des opérations atomiques :

public class Counter {
    int count;

    public void increment() {
        count++;
    }
}

Lorsque nous avons un thread, tout fonctionne très bien, mais si nous ajoutons du multithreading, nous obtenons de mauvais résultats, et tout cela parce que l'opération d'incrémentation n'est pas une opération, mais trois : une requête pour obtenir la valeur actuellecompter, puis incrémentez-le de 1 et écrivez à nouveau danscompter.

Et lorsque deux threads veulent incrémenter une variable, vous perdrez très probablement des données. Autrement dit, les deux threads reçoivent 100, par conséquent, les deux écriront 101 au lieu de la valeur attendue de 102.

Et comment le résoudre ? Vous devez utiliser des serrures. Le mot clé synchronized aide à résoudre ce problème, son utilisation vous donne la garantie qu'un thread accédera à la méthode à la fois.

public class SynchronizedCounterWithLock {
    private volatile int count;

    public synchronized void increment() {
        count++;
    }
}

De plus, vous devez ajouter le mot-clé volatile , qui garantit la bonne visibilité des références parmi les threads. Nous avons passé en revue son travail ci-dessus.

Mais il y a quand même des inconvénients. Le plus important est la performance, à ce moment-là, lorsque de nombreux threads tentent d'acquérir un verrou et que l'un d'entre eux a une opportunité d'écriture, le reste des threads sera soit bloqué, soit suspendu jusqu'à ce que le thread soit libéré.

Tous ces processus, blocages, passage à un autre statut sont très coûteux pour les performances du système.

Opérations atomiques

L'algorithme utilise des instructions machine de bas niveau telles que la comparaison et l'échange (CAS, compare-and-swap, qui assure l'intégrité des données et il existe déjà de nombreuses recherches à leur sujet).

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

  • Espace mémoire pour le travail (M)
  • Valeur attendue existante (A) d'une variable
  • Nouvelle valeur (B) à régler

CAS met à jour atomiquement M en B, mais seulement si la valeur de M est la même que A, sinon aucune action n'est entreprise.

Dans les premier et deuxième cas, la valeur de M sera renvoyée. Cela vous permet de combiner trois étapes, à savoir, obtenir la valeur, la comparer et la mettre à jour. Et tout se transforme en une seule opération au niveau de la machine.

Au moment où une application multithread accède à une variable et tente de la mettre à jour et que CAS est appliqué, l'un des threads l'obtiendra et pourra la mettre à jour. Mais contrairement aux verrous, les autres threads obtiendront simplement des erreurs indiquant qu'ils ne peuvent pas mettre à jour la valeur. Ensuite, ils passeront à d'autres travaux, et le changement est complètement exclu dans ce type de travail.

Dans ce cas, la logique devient plus difficile en raison du fait que nous devons gérer la situation lorsque l'opération CAS n'a pas fonctionné avec succès. Nous allons simplement modéliser le code afin qu'il n'avance pas jusqu'à ce que l'opération réussisse.

Introduction aux types atomiques

Avez-vous rencontré une situation où vous devez configurer la synchronisation pour la variable la plus simple de type int ?

La première façon que nous avons déjà couverte consiste à utiliser volatile + synchronized . Mais il existe aussi des classes Atomic* spéciales.

Si nous utilisons CAS, les opérations fonctionnent plus rapidement par rapport à la première méthode. Et en plus, nous avons des méthodes spéciales et très pratiques pour ajouter une valeur et des opérations d'incrémentation et de décrémentation.

AtomicBoolean , AtomicInteger , AtomicLong , AtomicIntegerArray , AtomicLongArray sont des classes dans lesquelles les opérations sont atomiques. Ci-dessous, nous analyserons le travail avec eux.

AtomicInteger

La classe AtomicInteger fournit des opérations sur une valeur int qui peuvent être lues et écrites de manière atomique, en plus de fournir des opérations atomiques étendues.

Il a des méthodes get et set qui fonctionnent comme la lecture et l'écriture de variables.

C'est-à-dire, "se passe avant" avec toute réception ultérieure de la même variable dont nous avons parlé plus tôt. La méthode atomique compareAndSet possède également ces fonctionnalités de cohérence mémoire.

Toutes les opérations qui renvoient une nouvelle valeur sont exécutées de manière atomique :

int addAndGet (int delta) Ajoute une valeur spécifique à la valeur actuelle.
booléen compareAndSet (entier attendu, mettre à jour l'entier) Définit la valeur sur la valeur mise à jour donnée si la valeur actuelle correspond à la valeur attendue.
int decrementAndGet() Diminue la valeur actuelle de un.
int getAndAdd(int delta) Ajoute la valeur donnée à la valeur actuelle.
int getAndDecrement() Diminue la valeur actuelle de un.
int getAndIncrement() Augmente la valeur actuelle de un.
int getAndSet(int nouvelle valeur) Définit la valeur donnée et renvoie l'ancienne valeur.
entier incrémentEtGet() Augmente la valeur actuelle de un.
lazySet(int nouvelleValeur) Enfin, réglez sur la valeur donnée.
booléen faibleCompareAndSet(attendu, mise à jour int) Définit la valeur sur la valeur mise à jour donnée si la valeur actuelle correspond à la valeur attendue.

Exemple:

ExecutorService executor = Executors.newFixedThreadPool(5);
IntStream.range(0, 50).forEach(i -> executor.submit(atomicInteger::incrementAndGet));
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.HOURS);

System.out.println(atomicInteger.get()); // prints 50