W3docs

Le mot-clé volatile en Java

Utilisez volatile en Java pour garantir la visibilité et l'ordre des lectures et écritures de champs entre threads, sans verrou.

synchronized résout deux problèmes à la fois : l'exclusion mutuelle et la visibilité mémoire. Parfois, vous n'avez besoin que du second. Un simple drapeau booléen qu'un thread positionne et qu'un autre thread scrute n'a pas besoin d'un accès exclusif — il n'y a qu'un seul écrivain et un ou plusieurs lecteurs, et le travail lui-même ne se compose pas. Verrouiller est excessif ; ne pas verrouiller est buggué. volatile est le mot-clé qui vous donne la visibilité sans l'exclusion.

C'est un outil limité. Identifiez le bon cas d'usage et c'est la primitive thread-safe la plus rapide que Java possède. Utilisez-le mal — essayez de l'employer pour des mises à jour composées comme ++ — et vous aurez les mêmes bugs que synchronized était censé corriger.

Le problème que volatile existe pour résoudre

Sans aucune synchronisation :

class Worker implements Runnable {
  boolean stop = false;                                 // plain field
  public void run() {
    while (!stop) {                                     // hot loop
      doWork();
    }
  }
}

Worker w = new Worker();
new Thread(w).start();
Thread.sleep(1000);
w.stop = true;                                          // ask it to stop

Ce programme peut ne jamais s'arrêter. La raison : rien dans la JVM n'est obligé de propager stop depuis le cache CPU d'un thread vers la mémoire principale, et rien n'est obligé d'invalider la copie en cache du worker lorsque le thread principal écrit. Le JIT est également autorisé à extraire !stop entièrement de la boucle — le compilateur voit que le corps ne modifie pas stop, il met donc la valeur en cache dans un registre. Pour toujours.

C'est le bug de visibilité. La solution :

volatile boolean stop = false;

Maintenant, chaque écriture dans stop est propagée vers la mémoire principale et chaque lecture tire depuis la mémoire principale. Le JIT ne peut pas extraire la lecture ; la boucle voit la nouvelle valeur en quelques microsecondes après la mise à jour de l'écrivain.

Ce que volatile garantit

Quatre choses :

  1. Visibilité. Une écriture dans un champ volatile est garantie d'être visible aux lectures ultérieures dans tout autre thread.
  2. Atomicité de la lecture et de l'écriture — mais uniquement pour le champ lui-même. La lecture/écriture d'un volatile long et volatile double est atomique ; sans volatile, une lecture/écriture 64 bits peut être fragmentée sur une JVM 32 bits.
  3. Ordre (happens-before). Tout ce qui s'est produit avant une écriture volatile dans le thread écrivain est visible pour tout ce qui se produit après la lecture volatile correspondante dans le thread lecteur. C'est la partie que vous ne voyez pas dans la syntaxe mais qui est la garantie la plus puissante.
  4. Pas de réordonnancement autour de l'accès. Le compilateur et le CPU ne peuvent pas déplacer des lectures/écritures ordinaires au-delà d'un accès volatile dans l'une ou l'autre direction.

La troisième — happens-before via une paire écriture/lecture volatile — est parfois appelée la primitive de publication la moins coûteuse. Si vous écrivez un champ, puis un volatile boolean ready = true, un autre thread qui voit ready == true est garanti de voir également l'écriture précédente. C'est ainsi qu'une grande partie de l'initialisation thread-safe se fait sans verrou.

Ce que volatile ne garantit pas

L'erreur la plus courante :

volatile int counter = 0;

void increment() { counter++; }                       // STILL BROKEN

volatile rend la lecture atomique et l'écriture atomique — mais counter++ est lire-modifier-écrire, ce qui représente trois opérations. Deux threads lisent tous les deux 42, calculent tous les deux 43, écrivent tous les deux 43. Un incrément est toujours perdu.

Pour les mises à jour composées, volatile n'est pas suffisant. Utilisez synchronized, un Lock, ou — presque toujours la bonne réponse — un AtomicInteger.

L'autre chose que volatile ne fait pas :

  • Il ne donne pas l'exclusion mutuelle. Deux threads peuvent écrire dans un champ volatile simultanément ; le résultat est « l'un d'eux gagne, l'autre est perdu », ce qui est la bonne réponse pour un état de type drapeau et la mauvaise réponse pour « fusionner ces deux valeurs ».
  • Il ne synchronise pas plusieurs champs atomiquement ensemble. Si votre invariant est « si A est true alors B doit être 42 », écrire chacun dans un champ volatile séparé peut laisser un autre thread voir le nouveau A et l'ancien B.

Le patron de publication

L'application la plus utile de volatile en dehors du cas du drapeau d'arrêt :

class LazyResource {
  private Resource cached;                            // not volatile
  private volatile boolean ready = false;             // the publication flag

  public Resource get() {
    if (!ready) {
      synchronized (this) {
        if (!ready) {
          cached = buildResource();                   // expensive init
          ready = true;                               // publishes cached
        }
      }
    }
    return cached;
  }
}

L'écriture volatile de ready = true publie l'écriture précédente dans cached. Tout thread qui lit ensuite ready == true est garanti de voir le cached entièrement initialisé. Le verrou n'est contesté qu'au premier appel ; les appels suivants lisent simplement ready et sautent entièrement la synchronisation. C'est l'idiome classique du double-checked locking, rendu correct par volatile.

Sans volatile sur ready, l'optimisation est cassée — le second thread peut voir ready == true et ensuite lire cached comme null. Avec volatile, c'est impossible.

(L'alternative moderne consiste simplement à faire private final Resource cached = buildResource(); si l'initialisation anticipée convient, ou Supplier<Resource> lazy = Suppliers.memoize(...) depuis votre bibliothèque préférée. Le double-checked locking écrit à la main est rare dans le code moderne ; le patron vaut la peine d'être connu car vous le lirez.)

Quand volatile est exactement la bonne réponse

Un petit nombre de cas d'usage où volatile est la meilleure réponse :

  • Un drapeau de statut à écrivain unique lu par de nombreux threads. Signaux d'arrêt, drapeaux « prêt », numéros de version de configuration.
  • Une référence à une valeur immuable qui est échangée atomiquement. Cache que l'écrivain reconstruit et publie ; les lecteurs voient l'ancien ou le nouvel objet entier, jamais à moitié construit.
  • Publication du résultat d'une initialisation unique via le patron du double-checked locking.
  • Un horodatage ou numéro de séquence qu'un thread définit et que d'autres lisent — où manquer la toute dernière mise à jour est acceptable mais la valeur doit toujours être cohérente en elle-même.

En dehors de ces cas, vous voudrez généralement un outil de niveau supérieur. Ne soyez pas trop ingénieux avec volatile — sa sémantique est trop mince pour prendre en charge un état général.

volatile long et volatile double sur les JVM 32 bits

Un détail historique. Sur une JVM 32 bits, une lecture/écriture long ou double non-volatile est autorisée à être divisée en deux accès 32 bits. Deux threads peuvent produire une valeur fragmentée — les 32 bits de poids fort d'une écriture et les 32 bits de poids faible d'une autre. volatile long et volatile double sont garantis atomiques quelle que soit la taille des mots.

Les JVM modernes fonctionnent presque toujours en 64 bits, et les JVM 64 bits rendent de toute façon tous les primitifs atomiques, mais la règle est toujours dans la spécification. Si vous avez un long/double partagé entre threads, marquez-le volatile (ou utilisez AtomicLong/AtomicDouble).

Un exemple concret : le bug du drapeau d'arrêt

Le programme ci-dessous exécute le worker avec un booléen ordinaire en premier (qui généralement ne s'arrête jamais), puis avec un booléen volatile (qui s'arrête rapidement). Un chien de garde coupe le cas défaillant après 2 secondes pour que la démo se termine.

java— editable, runs on the server

Ce qu'il faut retenir de l'exécution :

  • Le worker PLAIN ne s'est souvent pas arrêté dans la fenêtre du chien de garde. Le thread principal a écrit stop = true ; le thread worker, avec sa copie de stop mise en cache dans un registre, ne l'a jamais remarqué. Ajoutez volatile et le bug disparaît. C'est le problème de visibilité — tout programme multithread en a une version.
  • Le worker VOLATILE s'est arrêté en une ou deux microsecondes après l'écriture. C'est le coût de la barrière mémoire que la JVM émet pour une paire écriture/lecture volatile — quelques microsecondes au pire, et souvent moins. Peu coûteux pour le bon cas d'usage.
  • Le compteur VOLATILE++ était inférieur à 200 000. volatile ne transforme pas n++ en opération atomique — c'est toujours lire-modifier-écrire, et deux threads peuvent tous les deux lire 42 et tous les deux stocker 43. C'est l'utilisation incorrecte la plus courante de volatile. Pour les mises à jour composées, utilisez AtomicInteger (chapitre suivant).
  • Le coût de volatile est faible mais réel — chaque lecture et chaque écriture touche la mémoire principale et émet une barrière mémoire. Pour un drapeau lu une fois par itération de boucle, c'est négligeable. Pour un champ lu dans une boucle interne serrée, le coût de la barrière s'accumule. Si vous trouvez un volatile chaud dans un profil, demandez-vous si la lecture peut être extraite vers une variable locale et si le corps de la boucle peut s'exécuter sur ce snapshot.
  • volatile est également le mécanisme de publication pour une référence — écrivez les données, puis faites une écriture volatile de la référence. Le thread lecteur qui voit la nouvelle référence est garanti de voir toutes les données que l'écrivain a préparées. C'est le bloc de construction des patrons double-checked locking et échange de valeur immuable.

Ce qui vient ensuite

Le chapitre suivant, Java Atomic Variables, présente AtomicInteger, AtomicLong, AtomicReference, et le reste de la famille java.util.concurrent.atomic — le bon outil pour « incrémenter un compteur depuis plusieurs threads » et toute autre mise à jour composée sans verrou.

Pratique

Pratique
Vous déclarez `private volatile int counter = 0;` et appelez `counter++` depuis plusieurs threads. Après que 4 threads ont chacun effectué 100 000 incréments, quelle valeur `counter` contient-il typiquement ?
Vous déclarez `private volatile int counter = 0;` et appelez `counter++` depuis plusieurs threads. Après que 4 threads ont chacun effectué 100 000 incréments, quelle valeur `counter` contient-il typiquement ?
Was this page helpful?