Recherche…


Remarques

Le modèle de mémoire Java est la section du JLS qui spécifie les conditions dans lesquelles un thread est assuré de voir les effets des écritures mémoire effectuées par un autre thread. La section pertinente dans les éditions récentes est "Modèle de mémoire JLS 17.4" (en Java 8 , Java 7 , Java 6 )

Il y a eu une refonte majeure du modèle de mémoire Java dans Java 5 qui (entre autres choses) a changé la façon dont le volatile fonctionnait. Depuis lors, le modèle de mémoire n'a pratiquement pas changé.

Motivation pour le modèle de mémoire

Prenons l'exemple suivant:

public class Example {
    public int a, b, c, d;
    
    public void doIt() {
       a = b + 1;
       c = d + 1;
    }
}

Si cette classe est utilisée comme une application à un seul thread, le comportement observable sera exactement comme prévu. Par exemple:

public class SingleThreaded {
    public static void main(String[] args) {
        Example eg = new Example();
        System.out.println(eg.a + ", " + eg.c);
        eg.doIt();
        System.out.println(eg.a + ", " + eg.c);
    }
}

va sortir:

0, 0
1, 1

Dans la mesure où le thread "main" peut le savoir , les instructions de la méthode main() et de la méthode doIt() seront exécutées dans l'ordre où elles sont écrites dans le code source. Ceci est une exigence claire de la spécification de langage Java (JLS).

Considérons maintenant la même classe utilisée dans une application multithread.

public class MultiThreaded {
    public static void main(String[] args) {
        final Example eg = new Example();
        new Thread(new Runnable() {
            public void run() {
                while (true) {
                    eg.doIt();
                }
            }
        }).start();
        while (true) {
            System.out.println(eg.a + ", " + eg.c);
        }
    }
}

Qu'est-ce que cette impression?

En fait, selon le JLS, il n'est pas possible de prédire que cela va imprimer:

  • Vous allez probablement voir quelques lignes de 0, 0 pour commencer.
  • Ensuite, vous voyez probablement des lignes comme N, N ou N, N + 1 .
  • Vous pourriez voir des lignes comme N + 1, N
  • En théorie, vous pourriez même voir que les lignes 0, 0 continuent pour toujours 1 .

1 - En pratique, la présence des instructions println est susceptible de provoquer des synchronisations et des vidages de mémoire cache inopinés. Cela risque de masquer certains des effets qui provoqueraient le comportement ci-dessus.

Alors, comment pouvons-nous les expliquer?

Réorganisation des missions

Une explication possible des résultats inattendus est que le compilateur JIT a modifié l'ordre des affectations dans la méthode doIt() . Le JLS exige que les instructions apparaissent pour s'exécuter dans la perspective du thread en cours. Dans ce cas, rien dans le code de la méthode doIt() ne peut observer l’effet d’une réorganisation (hypothétique) de ces deux instructions. Cela signifie que le compilateur JIT serait autorisé à le faire.

Pourquoi ça ferait ça?

Sur du matériel moderne typique, les instructions de la machine sont exécutées en utilisant un pipeline d'instructions qui permet à une séquence d'instructions de se trouver à différents stades. Certaines phases d'exécution des instructions prennent plus de temps que d'autres et les opérations de mémoire prennent plus de temps. Un compilateur intelligent peut optimiser le débit d'instructions du pipeline en ordonnant les instructions afin d'optimiser la quantité de chevauchement. Cela peut conduire à l'exécution de parties de relevés hors service. Le JLS permet cela à condition que cela n'affecte pas le résultat du calcul du point de vue du thread en cours .

Effets des caches de mémoire

Une deuxième explication possible est l'effet de la mise en cache de la mémoire. Dans une architecture informatique classique, chaque processeur possède un petit ensemble de registres et une plus grande quantité de mémoire. L'accès aux registres est beaucoup plus rapide que l'accès à la mémoire principale. Dans les architectures modernes, il existe des caches mémoire plus lents que les registres, mais plus rapides que la mémoire principale.

Un compilateur exploitera cela en essayant de conserver des copies des variables dans les registres ou dans les caches de mémoire. Si vous ne devez pas vider une variable dans la mémoire principale ou si vous n'avez pas besoin de la lire depuis la mémoire, le fait de ne pas le faire présente des avantages importants en termes de performances. Dans les cas où le JLS n'exige pas que les opérations de mémoire soient visibles par un autre thread, le compilateur Java JIT risque de ne pas ajouter les instructions "read barrier" et "write barrier" qui forceront les lectures et écritures de la mémoire principale. Encore une fois, les avantages de cette performance sont importants.

Bonne synchronisation

Jusqu'à présent, nous avons vu que le JLS permet au compilateur JIT de générer du code qui accélère le code mono-thread en réordonnant ou en évitant les opérations de mémoire. Mais que se passe-t-il lorsque d'autres threads peuvent observer l'état des variables (partagées) dans la mémoire principale?

La réponse est que les autres threads sont susceptibles d'observer des états de variables qui sembleraient impossibles ... sur la base de l'ordre de code des instructions Java. La solution consiste à utiliser la synchronisation appropriée. Les trois approches principales sont:

  • Utilisation de mutex primitifs et des constructions synchronized .
  • Utiliser volatile variables volatile .
  • Utiliser un support concurrentiel de niveau supérieur; par exemple des classes dans les paquets java.util.concurrent .

Mais même avec cela, il est important de comprendre où la synchronisation est nécessaire et quels sont les effets sur lesquels vous pouvez compter. C'est là qu'intervient le modèle de mémoire Java.

Le modèle de mémoire

Le modèle de mémoire Java est la section du JLS qui spécifie les conditions dans lesquelles un thread est assuré de voir les effets des écritures mémoire effectuées par un autre thread. Le modèle de mémoire est spécifié avec un degré raisonnable de rigueur formelle et, par conséquent, nécessite une lecture détaillée et attentive pour comprendre. Mais le principe de base est que certaines constructions créent une relation "arrive-avant" entre l'écriture d'une variable par un thread et une lecture ultérieure de la même variable par un autre thread. Si la relation "arrive avant" existe, le compilateur JIT est obligé de générer du code qui garantira que l'opération de lecture voit la valeur écrite par l'écriture.

Armé de cela, il est possible de raisonner sur la cohérence de la mémoire dans un programme Java, et de décider si cela sera prévisible et cohérent pour toutes les plates-formes d'exécution.

Des relations heureuses

(Ce qui suit est une version simplifiée de ce que dit Java Language Specification. Pour une compréhension plus approfondie, vous devez lire la spécification elle-même.)

Les relations heureuses sont la partie du modèle de mémoire qui nous permet de comprendre et de raisonner sur la visibilité de la mémoire. Comme le dit le JLS ( JLS 17.4.5 ):

"Deux actions peuvent être ordonnées par une relation avant- passé. Si une action se produit avant une autre, la première est visible et ordonnée avant la seconde."

Qu'est-ce que ça veut dire?

actes

Les actions auxquelles la citation ci-dessus fait référence sont spécifiées dans JLS 17.4.2 . Il y a 5 types d'action énumérés par la spécification:

  • Lecture: lecture d'une variable non volatile.

  • Écrire: écrire une variable non volatile.

  • Actions de synchronisation:

    • Lecture volatile: Lecture d'une variable volatile.

    • Écriture volatile: écriture d'une variable volatile.

    • Fermer à clé. Verrouiller un moniteur

    • Ouvrir. Déverrouiller un moniteur.

    • Les premières et dernières actions (synthétiques) d'un thread.

    • Actions qui démarrent un thread ou détectent la fin d'un thread.

  • Actions externes Une action qui a un résultat qui dépend de l'environnement dans lequel le programme.

  • Actions de divergence de threads. Ils modélisent le comportement de certains types de boucle infinie.

Ordre de programme et ordre de synchronisation

Ces deux ordres ( JLS 17.4.3 et JLS 17.4.4 ) régissent l’exécution des instructions dans un Java

L'ordre des programmes décrit l'ordre d'exécution des instructions dans un seul thread.

L'ordre de synchronisation décrit l'ordre d'exécution des instructions pour deux instructions connectées par une synchronisation:

  • Une action de déverrouillage sur le moniteur se synchronise avec toutes les actions de verrouillage ultérieures sur ce moniteur.

  • Une écriture dans une variable volatile se synchronise avec toutes les lectures ultérieures de la même variable par n'importe quel thread.

  • Une action qui lance un thread (c'est-à-dire l'appel à Thread.start() ) se synchronise avec la première action du thread qu'il démarre (c'est-à-dire l'appel à la méthode run() du thread).

  • L'initialisation par défaut des champs se synchronise avec la première action de chaque thread. (Voir le JLS pour une explication à ce sujet.)

  • L'action finale dans un thread se synchronise avec toute action dans un autre thread qui détecte la terminaison; Par exemple, le retour d'un appel join() ou d'un appel isTerminated() qui renvoie true .

  • Si un thread interrompt un autre thread, l'appel d'interruption dans le premier thread se synchronise avec le point où un autre thread détecte que le thread a été interrompu.

Happens-before Order

Cet ordre ( JLS 17.4.5 ) est ce qui détermine si une écriture en mémoire est garantie d'être visible pour une lecture de mémoire ultérieure.

Plus précisément, une lecture d'une variable v garantit une écriture dans v si et seulement si write(v) se produit avant read(v) ET il n'y a pas d'écriture intermédiaire dans v . S'il y a des écritures intermédiaires, alors la read(v) peut voir les résultats plutôt que la précédente.

Les règles qui définissent les événements avant la commande sont les suivantes:

  • Règle Happens-Before # 1 - Si x et y sont des actions du même thread et que x arrive avant y dans l'ordre du programme , alors x se produit avant y.

  • Règle Happens-Before # 2 - Il y a un bord de passe avant de la fin d'un constructeur d'un objet au début d'un finaliseur pour cet objet.

  • Règle Happens-Before # 3 - Si une action x se synchronise avec une action ultérieure y, alors x se produit avant y.

  • Happens-Before Rule # 4 - Si x arrive -avant y et y se produit -avant z alors x arrive-avant z.

De plus, diverses classes dans les bibliothèques standard Java sont spécifiées comme définissant les relations avant-terme . Vous pouvez interpréter cela comme signifiant que cela se produit en quelque sorte , sans avoir besoin de savoir exactement comment la garantie va être satisfaite.

Happens-before raisonnement appliqué à quelques exemples

Nous présenterons quelques exemples pour montrer comment appliquer les événements-avant de raisonner pour vérifier que les écritures sont visibles lors des lectures suivantes.

Code mono-thread

Comme vous vous en doutez, les écritures sont toujours visibles lors des lectures suivantes dans un programme à thread unique.

public class SingleThreadExample {
    public int a, b;
    
    public int add() {
       a = 1;         // write(a)
       b = 2;         // write(b)
       return a + b;  // read(a) followed by read(b)
    }
}

Par Happens-Before Rule # 1:

  1. L'action write(a) se produit avant l'action write(b) .
  2. L'action write(b) se produit avant l'action read(a) .
  3. L'action read(a) se produit avant l'action read(a) .

Par Happens-Before Rule # 4:

  1. write(a) arrive-avant write(b) ET write(b) arrive-avant de read(a) IMPLIES write(a) arrive-avant de read(a) .
  2. write(b) arrive-avant read(a) AND read(a) arrive-avant read(b) IMPLIES write(b) arrive-avant read(b) .

En résumé:

  1. La relation write(a) arrive-avant read(a) signifie que l’instruction a a + b est garantie de voir la valeur correcte de a .
  2. La relation write(b) arrive avant read(b) signifie que l'instruction a a + b est garantie de voir la valeur correcte de b .

Comportement de 'volatile' dans un exemple avec 2 threads

Nous allons utiliser l'exemple de code suivant pour explorer certaines implications du modèle de mémoire pour `volatile.

public class VolatileExample {
    private volatile int a;
    private int b;         // NOT volatile
    
    public void update(int first, int second) {
       b = first;         // write(b)
       a = second;         // write-volatile(a)
    }

    public int observe() {
       return a + b;       // read-volatile(a) followed by read(b)
    }
}

Tout d'abord, considérez la séquence suivante d'instructions impliquant 2 threads:

  1. Une seule instance de VolatileExample est créée. appelez ça ve
  2. ve.update(1, 2) est appelé dans un thread, et
  3. ve.observe() est appelé dans un autre thread.

Par Happens-Before Rule # 1:

  1. L'action write(a) se produit avant l'action volatile-write(a) .
  2. L'action volatile-read(a) se produit avant l'action read(b) .

Par Happens-Before Rule # 2:

  1. L'action volatile-write(a) dans le premier thread se produit avant l'action volatile-read(a) dans le deuxième thread.

Par Happens-Before Rule # 4:

  1. L'action write(b) dans le premier thread arrive avant l'action read(b) dans le second thread.

En d'autres termes, pour cette séquence particulière, il est garanti que le deuxième thread verra la mise à jour de la variable non volatile b créée par le premier thread. Cependant, il devrait également être clair que si les affectations dans la méthode de update étaient inverses ou si la méthode observe() lisait la variable b avant a , alors la chaîne « passe avant» serait rompue. La chaîne serait également brisée si volatile-read(a) dans le deuxième thread n'était pas postérieur à la volatile-write(a) dans le premier thread.

Lorsque la chaîne est rompue, il n'y a aucune garantie que d' observe() verra la valeur correcte de b .

Volatile à trois fils

Supposons que nous ajoutions un troisième thread dans l'exemple précédent:

  1. Une seule instance de VolatileExample est créée. appelez ça ve
  2. update appels à deux threads:
    • ve.update(1, 2) est appelé dans un thread,
    • ve.update(3, 4) est appelé dans le deuxième thread,
  3. ve.observe() est ensuite appelé dans un troisième thread.

Pour analyser cela complètement, nous devons considérer tous les liens possibles entre les instructions du thread un et le thread deux. Au lieu de cela, nous n'en considérerons que deux.

Scénario n ° 1 - supposons que la update(1, 2) précède la update(3,4) nous obtenons cette séquence:

write(b, 1), write-volatile(a, 2)     // first thread
write(b, 3), write-volatile(a, 4)     // second thread
read-volatile(a), read(b)             // third thread

Dans ce cas, il est facile de voir qu'il y a une chaîne ininterrompue avant- write(b, 3) de write(b, 3) à read(b) . De plus, il n'y a pas d'écriture à b . Ainsi, pour ce scénario, le troisième thread est assuré de voir que b valeur 3 .

Scénario n ° 2 - supposons que la update(1, 2) et la update(3,4) chevauchent et que les ations soient entrelacées comme suit:

write(b, 3)                           // second thread
write(b, 1)                           // first thread
write-volatile(a, 2)                  // first thread
write-volatile(a, 4)                  // second thread
read-volatile(a), read(b)             // third thread

Maintenant, bien qu'il y ait une chaîne de passe-avant de write(b, 3) à read(b) , il y a une action write(b, 1) intermédiaire exécutée par l'autre thread. Cela signifie que nous ne pouvons pas être certains que la valeur read(b) sera visible.

(Mis à part: cela démontre que nous ne pouvons pas compter sur la volatile pour assurer la visibilité des variables non volatiles, sauf dans des situations très limitées.)

Comment éviter d'avoir à comprendre le modèle de mémoire

Le modèle de mémoire est difficile à comprendre et difficile à appliquer. C'est utile si vous avez besoin de raisonner sur l'exactitude du code multi-thread, mais vous ne voulez pas avoir à faire ce raisonnement pour chaque application multithread que vous écrivez.

Si vous adoptez les principes suivants lors de l' écriture du code concurrent en Java, vous pouvez éviter en grande partie la nécessité de recourir à un raisonnement qui se passe-avant.

  • Utilisez des structures de données immuables lorsque cela est possible. Une classe immuable correctement implémentée sera compatible avec les threads et n'introduira pas de problèmes de sécurité des threads lorsque vous l'utiliserez avec d'autres classes.

  • Comprendre et éviter les "publications dangereuses".

  • Utilisez des mutex primitifs ou des objets Lock pour synchroniser l'accès à l'état des objets mutables qui doivent être thread-safe 1 .

  • Utilisez Executor / ExecutorService ou le framework de jointure de fork plutôt que de tenter de créer des threads directement.

  • Utilisez les classes `java.util.concurrent qui fournissent des verrous, des sémaphores, des verrous et des verrous avancés, au lieu d'utiliser directement wait / notify / notifyAll.

  • Utilisez les versions java.util.concurrent de cartes, d'ensembles, de listes, de files d'attente et de deques plutôt que la synchronisation externe de collections non concurrentes.

Le principe général est d'essayer d'utiliser les bibliothèques de concurrence intégrées de Java plutôt que de "déployer votre propre" concurrence. Vous pouvez compter sur leur fonctionnement, si vous les utilisez correctement.


1 - Tous les objets ne doivent pas être thread-safe. Par exemple, si un objet ou des objets sont confinés dans un thread (c’est -à- dire qu’il n’est accessible qu’à un seul thread), sa sécurité de thread n’est pas pertinente.



Modified text is an extract of the original Stack Overflow Documentation
Sous licence CC BY-SA 3.0
Non affilié à Stack Overflow