Fonctionnement interne de volatile et synchronized en Java

L'implémentation de volatile et synchronized en Java couvre plusieurs couches, du code source au matériel. Cet article explore les mécanismes sous-jacents de ces deux fonctionnalités de synchronisation.

  1. Synchronized

1.1 Niveau bytecode

En analysant le bytecode généré par javap, on observe que les méthodes marquées avec synchronized possèdent le drapeau ACC_SYNCHRONIZED. Les instructions monitorenter et monitorexit sont implicites.

public synchronized void methodeSynchro();
  flags: ACC_PUBLIC, ACC_SYNCHRONIZED

Pour les blocs synchronized, le bytecode contient explicitement monitorenter et monitorexit.

0: new #2                  // Instanciation d'un nouvel objet Object
3: dup
4: invokespecial #1        // Appel du constructeur d'Object
7: astore_1                // Stockage de la référence dans une variable locale (verrou)
8: aload_1                 // Chargement de la variable locale sur la pile
9: monitorenter            // Entrée dans le moniteur
10: ...                    // Corps du bloc synchronisé
   : aload_1
   : monitorexit           // Sortie du moniteur
   : ...

1.2 Niveau JVM

L'implémentation de monitorenter repose sur la classe ObjectMonitor dans la JVM. Voici une représentation simplifiée de sa structure :

ObjectMonitor() {
    // Liste chaînée des threads en compétition pour le verrou
    ObjectWaiter * volatile _cxq;
    // Threads en attente du verrou (état bloqué)
    ObjectWaiter * volatile _EntryList;
    // En-tête du marqueur (Mark Word dans l'objet)
    volatile markOop _header;
    // Nombre de threads tentant d'acquérir le verrou
    volatile intptr_t _count;
    // Nombre de threads en attente
    volatile intptr_t _waiters;
    // Compteur de réentrance du verrou
    volatile intptr_t _recursions;
    // Objet associé au moniteur
    void* volatile _object;
    // Thread propriétaire du moniteur
    void* volatile _owner;
    // Threads en état d'attente (wait)
    ObjectWaiter * volatile _WaitSet;
    // Verrou pour la manipulation du WaitSet
    volatile int _WaitSetLock;
    // Nombre de verrous imbriqués (niveau le plus externe = 0)
    volatile intptr_t _recursions;
}

1.2.1 Méthode enter

La méthode enter de ObjectMonitor gère l'acquisition du verrou à différents niveaux : sans verrouillage, verrou biaisé, verrou léger et verrou lourd. Le noyau utilise l'opération Atomic::cmpxchg_ptr (CAS).

Type de verrou Méthode Description
Verrou biaisé Atomic::cmpxchg_ptr Remplace _owner par le thread courant ; succès = acquisition
Verrou léger TrySpin -> Atomic::cmpxchg_ptr Spinning continu pour remplacer _owner ; succès = acquisition
Verrou lourd EnterI -> Atomic::cmpxchg_ptr Park du thread puis tentative de remplacement de _owner ; succès = acquisition
void ATTR ObjectMonitor::enter(TRAPS) {
  Thread * const Self = THREAD;
  void * courant;
  
  // Acquisition sans verrou (CAS) pour devenir biaisé
  courant = Atomic::cmpxchg_ptr(Self, &_owner, NULL);
  if (courant == NULL) {
     return;
  }
	
  // Vérification de réentrance
  if (courant == Self) {
     _recursions++;
     return;
  }

  if (Self->is_lock_owned((address)courant)) {
    _recursions = 1;
    _owner = Self;
    return;
  }

  // Spinning pour verrou léger
  if (Knob_SpinEarly && TrySpin(Self) > 0) {
     return;
  }

  // Transition vers verrou lourd
  Atomic::inc_ptr(&_count);
  // Boucle d'attente pour acquisition
  for (;;) {
    EnterI(THREAD);
    // ... code omis pour brièveté
  }
}

1.2.2 La fonction cmpxchg_ptr

Cette fonction utilise une opération CAS au niveau matériel. Sur les systèmes multiprocesseurs, elle inclut le préfixe lock pour garantir l'atomicité.

#define LOCK_IF_MP(mp) "cmp $0, " #mp "; je 1f; lock; 1: "

inline intptr_t Atomic::cmpxchg_ptr(intptr_t exchange_value, volatile intptr_t* dest, intptr_t compare_value) {
  return (intptr_t)cmpxchg((jlong)exchange_value, (volatile jlong*)dest, (jlong)compare_value);
}

inline jlong Atomic::cmpxchg(jlong exchange_value, volatile jlong* dest, jlong compare_value) {
  bool mp = os::is_MP();
  __asm__ __volatile__ (LOCK_IF_MP(%4) "cmpxchgq %1,(%3)"
                        : "=a" (exchange_value)
                        : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
                        : "cc", "memory");
  return exchange_value;
}

  1. Volatile

2.1 Niveau bytecode

Une variable volatile est identifiée par le drapeau ACC_VOLATILE dans le bytecode.

  static volatile int compteur;
    descriptor: I
    flags: ACC_STATIC, ACC_VOLATILE

2.2 Niveau JVM

Dans la JVM, lors d'une écriture d'une variable volatile, la méthode OrderAccess::storeload() est invoquée pour appliquer une barrière mémoire.

CASE(_putfield):
CASE(_putstatic):
    {
          int offset_champ = cache->f2_as_index();
          if (cache->is_volatile()) { // Détection volatile
            // Logique d'écriture selon le type
            if (tos_type == itos) {
              obj->release_int_field_put(offset_champ, STACK_INT(-1));
            } else if (tos_type == atos) {
              VERIFY_OOP(STACK_OBJECT(-1));
              obj->release_obj_field_put(offset_champ, STACK_OBJECT(-1));
              OrderAccess::release_store(&BYTE_MAP_BASE[(uintptr_t)obj >> CardTableModRefBS::card_shift], 0);
            }
            // ... autres types omis
            // Barrière storeload après écriture
            OrderAccess::storeload();
          } else {
            // Écriture non volatile (pas de barrière)
            if (tos_type == itos) {
              obj->int_field_put(offset_champ, STACK_INT(-1));
            }
            // ...
          }
          UPDATE_PC_AND_TOS_AND_CONTINUE(3, count);
  }

L'implémentation de OrderAccess::storeload() exécute une instruction assembleur avec le préfixe lock.

inline void OrderAccess::storeload() { fence(); }
inline void OrderAccess::fence() {
  if (os::is_MP()) {
    // Utilisation de addl verrouillé pour éviter mfence coûteux
#ifdef AMD64
    __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
    __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  }
}

  1. Instruction lock

Le préfixe assembleur lock est utilisé dans les systèmes multiprocesseurs pour garantir l'accès exclusif à la mémoire pendant l'exécution d'une instruction. Deux mécanismes sous-jacents existent :

  • Verrouillage du bus mémoire : empêche tous les autres cœurs d'accéder à la mémoire pendant l'opération.
  • Verrouillage des lignes de cache : grâce au protocole MESI, les modifications sont propagées et les autres caches sont invalidés.
  1. Protocole MESI

Le protocole MESI assure la cohérence des caches dans les architectures multicœurs. Chaque ligne de cache a un état :

  • Modifié (M) : la ligne est sale, différente de la mémoire principale. Doit être écrite avant que d'autres cœurs puissent lire.
  • Exclusif (E) : la ligne est propre et unique dans le système. Peut devenir modifiée ou partagée.
  • Partagé (S) : la ligne est propre et peut exister dans plusieurs caches.
  • Invalide (I) : la ligne est invalide, doit être rafraîchie depuis la mémoire principale.

Pour optimiser les performances, des mécanismes comme le store buffer et l'ivnalidate queue introduisent une cohérence finale plutôt que stricte. Le préfixe lock désactive ces optimisations, forçant une cohérence forte via le protocole MESI.

  1. Synthèse

Les problèmes de concurrence surviennent lorsque plusieurs cœurs modifient simultanément des données en mémoire, menant à des résultats imprévisibles. Les solutions impliquent de restreindre l'accès concurrent, par exemple via le verrouillage du bus ou l'invalidation de caches.

  1. Questions connexes

Pourquoi volatile n'assure-t-il pas l'atomicité ?
volatile garantit l'atomicité des lectures et écritures individuelles, mais pas des séquences lecture-modification-écriture. synchronized encapsule une plage de code, assurant l'atomicité globale.

Différence entre synchronized et les classes Atomic ?
Les classes Atomic utilisent le CAS (optimiste) avec spinning, tandis que synchronized combine optimisme (spinning) et pessimisme (parking des threads).

En examinant ObjectMonitor, on note une structure commune : une variable volatile et une file d'attente, similaire à d'autres mécanismes de verrouillage.

Étiquettes: Java synchronized volatile bytecode JVM

Publié le 4 juillet à 17h57