Guide du mot-clé volatile en Java

1. Vue d'ensemble

En l'absence de synchronisations nécessaires, le compilateur, le moteur d'exécution ou les processeurs peuvent appliquer toutes sortes d'optimisations. Même si ces optimisations sont bénéfiques la plupart du temps, elles peuvent parfois causer des problèmes subtils.

La mise en cache et la réorganisation font partie de ces optimisations qui peuvent nous surprendre dans des contextes simultanés. Java et JVM offrent de nombreuses façons de contrôler l'ordre de la mémoire, et le mot clé volatile en fait partie.

Dans cet article, nous nous concentrerons sur ce concept fondamental mais souvent mal compris dans le langage Java - le mot-clé volatile . Tout d'abord, nous allons commencer par un peu de contexte sur le fonctionnement de l'architecture informatique sous-jacente, puis nous nous familiariserons avec l'ordre de la mémoire en Java.

2. Architecture multiprocesseur partagée

Les processeurs sont responsables de l'exécution des instructions du programme. Par conséquent, ils doivent récupérer les instructions du programme et les données requises de la RAM.

Comme les processeurs sont capables d'exécuter un nombre important d'instructions par seconde, la récupération à partir de la RAM n'est pas idéale pour eux. Pour améliorer cette situation, les processeurs utilisent des astuces telles que l'exécution dans le désordre, la prédiction de branche, l'exécution spéculative et, bien sûr, la mise en cache.

C'est là que la hiérarchie de mémoire suivante entre en jeu:

À mesure que différents cœurs exécutent plus d'instructions et manipulent plus de données, ils remplissent leurs caches avec des données et des instructions plus pertinentes. Cela améliorera les performances globales au détriment de l'introduction de problèmes de cohérence du cache .

En termes simples, nous devrions réfléchir à deux fois à ce qui se passe lorsqu'un thread met à jour une valeur mise en cache.

3. Quand utiliser volatile

Afin d'élargir davantage la cohérence du cache, empruntons un exemple du livre Java Concurrency in Practice:

public class TaskRunner { private static int number; private static boolean ready; private static class Reader extends Thread { @Override public void run() { while (!ready) { Thread.yield(); } System.out.println(number); } } public static void main(String[] args) { new Reader().start(); number = 42; ready = true; } }

La classe TaskRunner gère deux variables simples. Dans sa méthode principale, il crée un autre thread qui tourne sur la variable prête tant qu'elle est fausse. Lorsque la variable devient vraie, le thread imprimera simplement la variable numérique .

Beaucoup peuvent s'attendre à ce que ce programme imprime simplement 42 après un court délai. Cependant, en réalité, le délai peut être beaucoup plus long. Il peut même se bloquer pour toujours, ou même imprimer zéro!

La cause de ces anomalies est le manque de visibilité et de réorganisation de la mémoire . Évaluons-les plus en détail.

3.1. Visibilité de la mémoire

Dans cet exemple simple, nous avons deux threads d'application: le thread principal et le thread de lecture. Imaginons un scénario dans lequel le système d'exploitation planifie ces threads sur deux cœurs de processeur différents, où:

  • Le thread principal a sa copie des variables prêtes et numériques dans son cache principal
  • Le fil du lecteur se retrouve également avec ses copies
  • Le thread principal met à jour les valeurs mises en cache

Sur la plupart des processeurs modernes, les demandes d'écriture ne seront pas appliquées immédiatement après leur émission. En fait, les processeurs ont tendance à mettre ces écritures en file d'attente dans un tampon d'écriture spécial . Après un certain temps, ils appliqueront ces écritures à la mémoire principale en une seule fois.

Cela dit, lorsque le thread principal met à jour le nombre et les variables prêtes , il n'y a aucune garantie sur ce que le thread de lecture peut voir. En d'autres termes, le thread de lecture peut voir la valeur mise à jour tout de suite, ou avec un certain retard, ou jamais du tout!

Cette visibilité de la mémoire peut entraîner des problèmes de vivacité dans les programmes qui reposent sur la visibilité.

3.2. Réorganisation

Pour aggraver les choses, le thread de lecture peut voir ces écritures dans n'importe quel ordre autre que l'ordre réel du programme . Par exemple, depuis que nous mettons à jour la variable numérique pour la première fois :

public static void main(String[] args) { new Reader().start(); number = 42; ready = true; }

On peut s'attendre à ce que le fil du lecteur imprime 42. Cependant, il est en fait possible de voir zéro comme valeur imprimée!

La réorganisation est une technique d'optimisation pour l'amélioration des performances. Fait intéressant, différents composants peuvent appliquer cette optimisation:

  • Le processeur peut vider sa mémoire tampon d'écriture dans n'importe quel ordre autre que l'ordre du programme
  • Le processeur peut appliquer une technique d'exécution dans le désordre
  • Le compilateur JIT peut optimiser via la réorganisation

3.3. Ordre de mémoire volatile

Pour garantir que les mises à jour des variables se propagent de manière prévisible à d'autres threads, nous devons appliquer le modificateur volatile à ces variables:

public class TaskRunner { private volatile static int number; private volatile static boolean ready; // same as before }

De cette façon, nous communiquons avec le runtime et le processeur pour ne réorganiser aucune instruction impliquant la variable volatile . En outre, les processeurs comprennent qu'ils doivent immédiatement vider toutes les mises à jour de ces variables.

4. synchronisation volatile et thread

Pour les applications multithreads, nous devons garantir quelques règles pour un comportement cohérent:

  • Exclusion mutuelle - un seul thread exécute une section critique à la fois
  • Visibilité - les modifications apportées par un thread aux données partagées sont visibles par les autres threads pour maintenir la cohérence des données

les méthodes et les blocs synchronisés fournissent les deux propriétés ci-dessus, au détriment des performances de l'application.

volatile est un mot-clé assez utile car il peut aider à garantir l'aspect de visibilité du changement de données sans, bien sûr, fournir une exclusion mutuelle . Ainsi, c'est utile dans les endroits où nous sommes d'accord avec plusieurs threads exécutant un bloc de code en parallèle, mais nous devons garantir la propriété de visibilité.

5. Arrive-avant la commande

The memory visibility effects of volatile variables extend beyond the volatile variables themselves.

To make matters more concrete, let's suppose thread A writes to a volatile variable, and then thread B reads the same volatile variable. In such cases, the values that were visible to A before writing the volatile variable will be visible to B after reading the volatile variable:

Technically speaking, any write to a volatile field happens before every subsequent read of the same field. This is the volatile variable rule of the Java Memory Model (JMM).

5.1. Piggybacking

Because of the strength of the happens-before memory ordering, sometimes we can piggyback on the visibility properties of another volatile variable. For instance, in our particular example, we just need to mark the ready variable as volatile:

public class TaskRunner { private static int number; // not volatile private volatile static boolean ready; // same as before }

Anything prior to writing true to the ready variable is visible to anything after reading the ready variable. Therefore, the number variable piggybacks on the memory visibility enforced by the ready variable. Put simply, even though it's not a volatile variable, it is exhibiting a volatile behavior.

En utilisant cette sémantique, nous pouvons définir seulement quelques-unes des variables de notre classe comme volatiles et optimiser la garantie de visibilité.

6. Conclusion

Dans ce didacticiel, nous avons exploré plus en détail le mot-clé volatile et ses capacités, ainsi que les améliorations qui y ont été apportées à partir de Java 5.

Comme toujours, les exemples de code peuvent être trouvés sur GitHub.