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 stopCe 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 :
- Visibilité. Une écriture dans un champ
volatileest garantie d'être visible aux lectures ultérieures dans tout autre thread. - Atomicité de la lecture et de l'écriture — mais uniquement pour le champ lui-même. La lecture/écriture d'un
volatile longetvolatile doubleest atomique ; sansvolatile, une lecture/écriture 64 bits peut être fragmentée sur une JVM 32 bits. - Ordre (happens-before). Tout ce qui s'est produit avant une écriture
volatiledans le thread écrivain est visible pour tout ce qui se produit après la lecturevolatilecorrespondante dans le thread lecteur. C'est la partie que vous ne voyez pas dans la syntaxe mais qui est la garantie la plus puissante. - 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
volatiledans 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 BROKENvolatile 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
volatilesimultané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
volatilesé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.
Ce qu'il faut retenir de l'exécution :
- Le worker
PLAINne s'est souvent pas arrêté dans la fenêtre du chien de garde. Le thread principal a écritstop = true; le thread worker, avec sa copie destopmise en cache dans un registre, ne l'a jamais remarqué. Ajoutezvolatileet le bug disparaît. C'est le problème de visibilité — tout programme multithread en a une version. - Le worker
VOLATILEs'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/lecturevolatile— quelques microsecondes au pire, et souvent moins. Peu coûteux pour le bon cas d'usage. - Le compteur
VOLATILE++était inférieur à200 000.volatilene transforme pasn++en opération atomique — c'est toujours lire-modifier-écrire, et deux threads peuvent tous les deux lire42et tous les deux stocker43. C'est l'utilisation incorrecte la plus courante devolatile. Pour les mises à jour composées, utilisezAtomicInteger(chapitre suivant). - Le coût de
volatileest 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 unvolatilechaud 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. volatileest également le mécanisme de publication pour une référence — écrivez les données, puis faites une écriturevolatilede 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.