CodeGym /Cours /JAVA 25 SELF /Java Memory Model (JMM)

Java Memory Model (JMM)

JAVA 25 SELF
Niveau 58, Leçon 4
Disponible

1. Découverte de la Java Memory Model (JMM)

Problème de visibilité et d’ordonnancement

Dans un programme monothread, c’est simple : vous écrivez une valeur dans une variable — et vous pouvez la lire immédiatement. Dans un environnement multithread, cela fonctionne autrement. Les processeurs mettent en cache les valeurs, le compilateur et la JVM réordonnent parfois les instructions, et un thread peut voir une valeur « ancienne », même si un autre thread vient de la modifier.

La Java Memory Model (JMM) décrit précisément comment les threads communiquent via la mémoire : quand les modifications d’un thread deviennent visibles pour les autres et dans quel ordre les opérations se produisent. Si vous n’en tenez pas compte, le programme peut se comporter de façon imprévisible, alors qu’à première vue tout semble correct.

Comprendre la JMM aide à expliquer pourquoi un thread ne voit parfois pas des données récentes, comment utiliser correctement volatile, synchronized et les classes atomiques, et pourquoi les bugs du code multithread peuvent n’apparaître qu’en production. En bref, la JMM est la règle du jeu de la mémoire, et si vous l’ignorez, même le code le plus soigné peut se retourner contre vous.

Analogie

Imaginez que vous avez deux personnes (threads) qui écrivent et lisent des notes (variables) sur un tableau (mémoire). Parfois, l’une écrit, mais l’autre ne voit pas encore la nouvelle note — parce qu’elle regarde sa propre copie du tableau (cache). La JMM définit quand et comment ces notes deviennent visibles pour tout le monde.

2. happens-before : le fondement de la JMM

Qu’est-ce que happens-before ?

happens-before est une relation entre deux actions d’un programme : si l’action A happens-before l’action B, alors toutes les modifications faites dans A sont garanties visibles dans B.

Important : happens-before n’est pas simplement « s’est produit avant », mais bien « est garanti visible ».

Règles principales du happens-before

1. À l’intérieur d’un même thread

Tout ce qui se passe dans un seul thread est ordonné : si vous écrivez dans une variable puis la lisez — vous verrez votre propre modification.

2. Blocs synchronisés/moniteurs

Tout ce qui se produit avant la sortie d’un bloc synchronisé (synchronized) devient visible pour le thread qui entrera ensuite dans ce bloc.

synchronized(lock) {
    sharedVar = 42; // écriture
}
// ...
synchronized(lock) {
    System.out.println(sharedVar); // on verra 42 de manière garantie
}

3. Écriture/lecture volatile

L’écriture dans un champ volatile happens-before toute lecture ultérieure de ce champ par un autre thread.

volatile boolean ready = false;

// Thread 1
data = 123;
ready = true; // écriture volatile

// Thread 2
if (ready) { // lecture volatile
    System.out.println(data); // nous verrons data = 123 de manière garantie
}

4. Démarrage et terminaison des threads

  • L’appel à Thread.start() happens-before le début d’exécution du thread.
  • La fin d’un thread happens-before le retour de Thread.join().

5. Fin d’une tâche dans un Executor

Si vous soumettez une tâche à un Executor et attendez sa fin (Future.get()), toutes les modifications effectuées dans la tâche sont visibles après get().

6. Champs finaux

L’initialisation des champs avec le modificateur final dans le constructeur happens-before la publication de la référence à l’objet. C’est important pour les objets immuables.

3. Publication sûre des objets

Problème : objet « non à jour »

Si un thread crée un objet et le transmet à un autre thread sans synchronisation, l’autre thread peut voir des valeurs « brutes » des champs (par exemple, non initialisées ou anciennes).

class Holder {
    int value;
    Holder() { value = 42; }
}

Holder holder = null;

// Thread 1
holder = new Holder(); // on crée l'objet

// Thread 2
if (holder != null) {
    System.out.println(holder.value); // peut voir 0 au lieu de 42 !
}

Comment publier correctement des objets ?

1. Via des champs final

Si tous les champs de l’objet sont final et initialisés dans le constructeur, l’objet peut être publié en toute sécurité sans synchronisation supplémentaire.

class SafeHolder {
    final int value;
    SafeHolder() { value = 42; }
}

2. Via une référence volatile

Si la référence à l’objet est déclarée volatile, alors après l’affectation l’objet est garanti visible pour les autres threads.

volatile Holder holder;

// Thread 1
holder = new Holder();

// Thread 2
if (holder != null) {
    System.out.println(holder.value); // nous verrons 42 de manière garantie
}

3. Via une initialisation monothread avant publication

Si l’objet est créé et initialisé avant que la référence ne devienne accessible aux autres threads, tout est sûr.

Holder holder = new Holder(); // dans un seul thread
// ... puis holder devient accessible aux autres threads

4. Via un verrou

Si l’objet est créé à l’intérieur d’un bloc synchronisé, et que la référence n’est lue qu’à l’intérieur d’un bloc identique, tout est sûr.

Holder holder;

synchronized(lock) {
    if (holder == null) {
        holder = new Holder();
    }
}

// ... dans un autre thread
synchronized(lock) {
    if (holder != null) {
        // sûr
    }
}

4. Double-checked locking et volatile

Qu’est-ce que le double-checked locking ?

C’est un patron pour l’initialisation paresseuse d’un objet singleton :

class Singleton {
    private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) { // 1re vérification
            synchronized (Singleton.class) {
                if (instance == null) { // 2e vérification
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Problème : sans référence volatile pour instance, ce code ne fonctionne pas correctement ! Un thread peut voir un objet non totalement initialisé.

Pourquoi est-ce problématique sans volatile ?

La JVM peut « réorganiser » les instructions de sorte que la référence à l’objet soit affectée avant la fin du constructeur. Un autre thread verra un objet non initialisé.

Comment faire correctement ?

Déclarez instance comme volatile :

private static volatile Singleton instance;

Maintenant le double-checked locking fonctionne correctement : volatile garantit la relation happens-before entre l’écriture et la lecture de la référence.

Alternative : initialisation statique

Le moyen le plus simple et le plus sûr de faire un singleton est d’utiliser l’initialisation statique :

class Singleton {
    private static final Singleton INSTANCE = new Singleton();
    public static Singleton getInstance() { return INSTANCE; }
}

Ici la JVM garantit elle-même une initialisation correcte.

5. VarHandle : accès bas niveau moderne

Qu’est-ce que VarHandle ?

VarHandle est une API moderne (Java 9+) qui permet de travailler avec les variables à bas niveau : lire, écrire, effectuer des opérations atomiques, contrôler la visibilité et l’ordre des instructions.

Pourquoi utiliser VarHandle s’il existe des classes atomiques ?
— VarHandle permet de travailler avec n’importe quels champs (pas seulement int/long/Reference).
— Permet de choisir explicitement la sémantique d’accès : volatile, acquire/release, opaque.
— Utilisé pour implémenter des structures de données très performantes.

Sémantiques d’accès

  • Volatile : garantie complète de happens-before (comme pour un champ volatile).
  • Acquire/Release : garantie plus faible mais plus rapide (utilisée pour des structures lock-free).
  • Opaque : garantie minimale de visibilité, mais performance maximale.

Exemple d’utilisation de VarHandle

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

class Counter {
    int value;
    static final VarHandle VALUE_HANDLE;

    static {
        try {
            VALUE_HANDLE = MethodHandles.lookup().findVarHandle(Counter.class, "value", int.class);
        } catch (Exception e) {
            throw new Error(e);
        }
    }
}

Counter counter = new Counter();
Counter.VALUE_HANDLE.setVolatile(counter, 42);
int v = (int) Counter.VALUE_HANDLE.getVolatile(counter);

Quand utiliser VarHandle ?

  • Pour implémenter vos propres structures de données lock-free.
  • Quand vous avez besoin de performances maximales et de contrôle sur l’ordre des instructions.
  • Dans les applications courantes, les classes atomiques et synchronized suffisent le plus souvent.

6. Faux partage (false sharing) et alignement du cache

False sharing est une situation où deux threads travaillent sur des variables différentes, mais ces variables résident dans la même ligne de cache du processeur. En conséquence, les threads se perturbent mutuellement, car la modification d’une variable invalide le cache de l’autre.

Analogie : deux personnes sont assises à la même table (ligne de cache), mais chacune écrit sur sa moitié. Si l’une change quelque chose, l’autre doit « relire » toute la feuille.

Pourquoi est-ce mauvais ?

  • La performance chute fortement : les processeurs passent du temps à synchroniser les caches.
  • Particulièrement critique pour les variables « chaudes » souvent modifiées par différents threads.

Comment l’éviter ?

Séparez les champs « chauds » dans des objets différents ou utilisez des annotations/structures spéciales pour l’alignement (par exemple, @Contended). Dans les JVM modernes, vous pouvez activer l’option -XX:-RestrictContended et utiliser @sun.misc.Contended (Java 8+) pour aligner les champs.

Exemple :

@sun.misc.Contended
public volatile long value1;

@sun.misc.Contended
public volatile long value2;

NB : L’annotation @Contended ne fait pas partie de l’API standard, mais elle est utilisée dans le JDK pour optimiser les classes atomiques.

7. Pratique : correction du singleton et mini-benchmarks JMH

Corriger un singleton défaillant

Mauvais (sans volatile) :

class BrokenSingleton {
    private static BrokenSingleton instance;
    public static BrokenSingleton getInstance() {
        if (instance == null) {
            synchronized (BrokenSingleton.class) {
                if (instance == null) {
                    instance = new BrokenSingleton();
                }
            }
        }
        return instance;
    }
}

Correct (avec volatile) :

class SafeSingleton {
    private static volatile SafeSingleton instance;
    public static SafeSingleton getInstance() {
        if (instance == null) {
            synchronized (SafeSingleton.class) {
                if (instance == null) {
                    instance = new SafeSingleton();
                }
            }
        }
        return instance;
    }
}

Le mieux — initialisation statique :

class StaticSingleton {
    private static final StaticSingleton INSTANCE = new StaticSingleton();
    public static StaticSingleton getInstance() { return INSTANCE; }
}

Mini-benchmarks JMH : visibilité et atomicité

Attention : JMH est un framework spécial pour les microbenchmarks en Java. N’essayez pas de tirer des conclusions de performance sans JMH — les résultats peuvent être trompeurs !

Exemple : vérifier la visibilité de volatile

public class VolatileVisibility {
    volatile boolean flag = false;

    public void writer() {
        flag = true;
    }

    public void reader() {
        while (!flag) {
            // on boucle jusqu’à voir true
        }
        // changement observé
    }
}

Exemple : volatile n’est pas atomique

public class VolatileNotAtomic {
    volatile int counter = 0;

    public void increment() {
        counter++; // non atomique !
    }
}

Malgré volatile, en cas d’incréments concurrents depuis plusieurs threads, la valeur finale sera inférieure à celle attendue (l’opération counter++ se décompose en lecture, calcul et écriture).

8. Erreurs typiques avec JMM, volatile et la publication

Erreur n° 1 : attendre l’atomicité de volatile.
volatile ne garantit que la visibilité des changements, pas l’atomicité des opérations. L’opération counter++ ne devient pas atomique si la variable est volatile.

Erreur n° 2 : publication d’objets sans synchronisation.
Si vous créez un objet dans un thread et le donnez à un autre sans volatile, synchronized ou champs final, l’autre thread peut voir des valeurs « brutes ».

Erreur n° 3 : double-checked locking sans volatile.
Sans référence volatile pour le singleton, vous pouvez obtenir un objet non initialisé dans un autre thread.

Erreur n° 4 : utiliser d’anciens verrous avec des threads virtuels.
Certains anciens mécanismes de synchronisation (moniteur natif) peuvent empêcher la JVM de gérer efficacement les threads virtuels.

Erreur n° 5 : ignorer le faux partage.
Si des variables « chaudes » sont côte à côte en mémoire, les threads vont se gêner à cause des lignes de cache.

Erreur n° 6 : tirer des conclusions de performance sans JMH.
Les microbenchmarks sans JMH donnent souvent des résultats trompeurs à cause des optimisations de la JVM et des caches du processeur.

1
Mission
JAVA 25 SELF, niveau 58, leçon 4
Bloqué
Maison intelligente : publication sécurisée de la configuration 🏡
Maison intelligente : publication sécurisée de la configuration 🏡
1
Mission
JAVA 25 SELF, niveau 58, leçon 4
Bloqué
Gestionnaire de ressources : l'unique dans l'univers 🌌
Gestionnaire de ressources : l'unique dans l'univers 🌌
1
Étude/Quiz
On plonge dans la programmation concurrente, niveau 58, leçon 4
Indisponible
On plonge dans la programmation concurrente
On plonge dans la programmation concurrente
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION