[Notes d'étude Java - Programmation simultanée] Mot-clé volatile Explication détaillée

avant-propos

Cet article présente le mot clé volatile de la programmation concurrente Java et ses principes associés, ainsi que certaines connaissances informatiques de base connexes.

1. Contexte du projet

Dans les projets récents, beaucoup de choses multi-thread ont été écrites. Parmi eux se trouve la mise en place d'un bloqueur de lumière multi-tâches, qui conduit à l'application du mot-clé volatil, et à apprendre le principe et les connaissances de base associées au passage.

Étant donné que le multitâche est exécuté par plusieurs threads, le blocage du multitâche pense naturellement à la communication entre les threads . Certaines variables du thread doivent donc être visibles .

En ce qui concerne la visibilité des threads, pour Java, il existe deux méthodes d'implémentation : volatile et synchroniser.

volatile n'est utilisé que pour assurer la visibilité de la variable à tous les threads, mais ne garantit pas l'atomicité

Mais comme le verrou de la synchronisation est trop lourd, un seul thread qui obtient le verrou est autorisé à effectuer des tâches, nous choisissons donc volatile pour implémenter cet interrupteur.

Deuxièmement, le principe de la mise en œuvre volatile

Tout d'abord, examinons à partir du modèle de mémoire sous-jacent de l'ordinateur, pourquoi les threads sont invisibles entre les threads.

1. Pourquoi les threads sont-ils invisibles ?

modèle de mémoire informatique

La vitesse de fonctionnement du processeur actuel est beaucoup plus rapide que la vitesse de lecture et d'écriture de la mémoire, donc afin de s'assurer que le processeur peut entièrement calculer, nous utilisons des supports avec différentes vitesses de lecture et d'écriture, combinées aux conditions d'application réelles, pour former un ordinateur. Parmi eux, le processeur a une structure de cache à 4 couches de niveau supérieur (3 couches intégrées), le cache (cache) est utilisé comme tampon entre la mémoire et le processeur, et la mémoire principale et le disque dur sont un stockage externe. médias.

insérez la description de l'image ici
Les calculs du CPU sont tous effectués dans son propre cache haute vitesse, réduisant le nombre de lectures et d'écritures avec la mémoire principale pour assurer l'efficacité de fonctionnement. Le modèle de mémoire spécifique est illustré dans la figure suivante :

insérez la description de l'image ici
Comme illustré dans le modèle ci-dessus, l'ordinateur copie les données requises de la mémoire principale vers le cache, puis réécrit les résultats calculés dans la mémoire principale. Lors du processus d'exécution des calculs, les modifications de diverses variables non publiques sont invisibles pour chaque thread, ce qui entraînera une insécurité du thread.

Modèle de mémoire JMM

Le modèle de mémoire de Java souffre en fait du même problème :

  • Toutes les variables d'instance et les variables de classe sont stockées dans la mémoire principale.
    (Il ne contient pas de variables locales, puisque les variables locales sont des threads privés, il n'y a donc pas de problème de race.)
  • Toutes les opérations de lecture et d'écriture du thread vers les variables sont effectuées dans la mémoire de travail, mais ne peuvent pas directement lire et écrire les variables dans la mémoire principale.

(image)
Par conséquent, dans le modèle de mémoire de Java, la visibilité des threads n'a pas encore été résolue. Alors, comment résoudre le problème de visibilité des threads ?

2. Comment résoudre la visibilité ?

Selon l'ordinateur et le modèle de mémoire JMM, si vous voulez qu'une variable soit visible à travers les threads, il semble n'y avoir que deux façons :

  • Verrouillez la variable, le thread qui obtient le verrou peut calculer la variable et les autres threads sont bloqués. (verrou pessimiste : synchronisé)
  • Les variables d'écriture sont toutes écrites dans la mémoire principale. Une fois les variables de la mémoire principale mises à jour, les autres threads sont informés que les variables ont expiré et les variables mises à jour sont utilisées dans les calculs ultérieurs des autres threads. (volatil)

Étant donné que seul le thread qui a obtenu le verrou peut effectuer des calculs, l'utilisation de verrous pessimistes synchronisés permet sans aucun doute d'obtenir la visibilité et l'atomicité des variables entre les threads . Mais c'est cher et la concurrence est faible.

Bien que l'utilisation de volatile ne puisse pas garantir son atomicité (le thread n'est pas sûr), il peut garantir la visibilité entre les threads.Par conséquent, en tant que bloqueur multitâche, l'application du mot-clé volatile répond aux exigences du projet.

Afin de mieux comprendre la portée de volatile et les risques de son application, vous pouvez d'abord vous intéresser à la sémantique mémoire de volatile.

La sémantique de mémoire de volatile

La sémantique de la mémoire peut être comprise comme les fonctions et les règles à implémenter en mémoire lorsque volatile effectue des calculs :

  • Lors de l'écriture d'une variable volatile, JMM actualisera la valeur de la variable partagée dans la mémoire locale correspondant au thread vers la mémoire principale.
  • Lors de la lecture d'une variable volatile, le JMM invalidera la mémoire locale correspondant au thread et lira la variable partagée à partir de la mémoire principale.

Pour obtenir la sémantique de mémoire ci-dessus, il est nécessaire de restreindre le réarrangement des instructions de JMM.

3. Réorganisation des instructions sous contrôle de la barrière mémoire

réorganisation des commandes

Tout d'abord, une brève introduction à ce qu'est le réarrangement des instructions.

Afin d'optimiser les performances, le compilateur réorganisera selon certaines règles spécifiques sous le principe du as-if-serial (quelle que soit la réorganisation, le résultat de l'exécution sous un seul thread ne peut pas être modifié.).

L'importance d'interdire le réarrangement des instructions est :

Lorsque vous écrivez une variable volatile pour la première fois, mettez à jour la variable mise à jour dans la mémoire principale ; lorsqu'un thread lit une variable volatile, il doit lire la variable dans la mémoire principale à la première fois. Cela garantit un fonctionnement correct.

L'exemple classique d'un problème causé par le réarrangement d'instructions est la double vérification du mode singleton. Voici d'abord un pseudo-code, et un exemple simple :

volatile boolean testMark = false;
int a = 2
//线程1执行task1
task1() {
    
    
	while(!flag) {
    
    
	
	}
	dosomething(a);
}
//线程2执行task2
task2() {
    
    
	a = 3;
	flag = true;
}

Pour l'exemple ci-dessus, il est clair que le codeur avait l'intention d'introduire la valeur a = 3 dans la fonction faire quelque chose. Mais si, dans la tâche2, les deux étapes d'exécution sont réarrangées ( pour un même thread, même si l'ordre de a = 3 et flag = true est inversé, le résultat final du calcul de ce thread ne sera pas modifié, il est donc possible de réorganiser les lignes ).

Ensuite après le réarrangement de l'instruction, il est évident que la fonction après exécution peut être incorrecte, donc avant et après la variable modifiée par le mot clé volatile, le réarrangement de l'instruction est interdit.

Le moyen d'empêcher la réorganisation des instructions consiste à utiliser des barrières de mémoire. Ici, je ne vais pas le présenter, jetez juste un œil à la définition :

Le compilateur Java insérera des instructions de barrière de mémoire aux positions appropriées lors de la génération de séries d'instructions pour interdire certains types de réorganisation du processeur.

arrive - avant

arrive - avant a deux définitions et est représenté par 8 règles. Celles-ci sont toutes implémentées dans JMM, nous n'avons pas à nous inquiéter trop, ici nous regardons juste les règles de volatile.

Règle de domaine volatil : une opération d'écriture dans un domaine volatil se produit avant toute lecture ultérieure du domaine volatil par un thread.

Tout comme dans l'exemple ci-dessus, après avoir modifié flag = true, il faut s'assurer que dans la boucle while de la fonction task1, la valeur de flag est true la prochaine fois.

4. Le volatil garantit-il l'atomicité ?

volatile peut rendre les affectations longues et doubles atomiques. (Ceci sera développé un autre jour.)

Cependant, volatile ne peut pas garantir l'atomicité des calculs En fait, l'opération la plus classique est ++.

Lorsqu'il y a une opération variable ++, les trois étapes suivantes seront générées dans un thread :

  • récupérer la valeur de la mémoire principale à la mémoire de travail
  • Calculer la valeur de la mémoire de travail
  • Mettre à jour la nouvelle valeur calculée dans la mémoire principale

Les instructions CPU correspondantes sont les suivantes :

mov    0xc(%r10),%r8d ; Load
inc    %r8d           ; Increment
mov    %r8d,0xc(%r10) ; Store
lock addl $0x0,(%rsp) ; StoreLoad Barrier

Si deux threads exécutent l'opération ++ de la même variable volatile, la situation possible :

  • Une fois que le thread 1 a lu la variable dans la mémoire de travail, car il n'y a pas de verrou, le thread 2 saisit le processeur après que le thread 1 l'a copié et copie immédiatement la variable de la mémoire principale dans la mémoire de travail.
  • Après cela, le thread 1 et le thread 2 ont successivement incrémenté la mémoire de travail.
  • Enfin, les threads 1 et 2 vident respectivement les valeurs auto-incrémentées dans la mémoire principale.

Notez que ce que j'ai dit auparavant : "Les autres variables de thread deviennent invalides après avoir été réécrites dans la mémoire principale" concerne les calculs qui n'ont pas encore été effectués . Dans l'exemple ci-dessus, en supposant qu'après le calcul du thread 1, le calcul du thread 2 est suivi immédiatement (le thread 1 n'a pas réécrit à ce moment), puis la valeur mise à jour est réécrite, alors le thread 2 n'invalidera pas le variable (parce que le calcul est terminé) , donc lorsque le thread 2 réécrit, il écrasera la valeur réécrite par le thread 1, rendant le thread dangereux, de sorte que les calculs volatils ne peuvent pas garantir l'atomicité.

3. Volatile et synchronisé

Dans cette section, nous pouvons en apprendre davantage sur l'application combinée de volatil et synchronisé : double vérification du mode singleton.

1. Double vérification singleton

Si le programme est monothread, le modèle singleton n'a vraiment rien à craindre. Mais s'il est multi-thread, vous devez toujours vérifier le problème d'insécurité des threads.

Écrivez d'abord un singleton simple :

public class Singleton {
    
    

    private static Singleton singleton = new Singleton();

    private void Singleton(){
    
    }

    public static Singleton getSingleton(){
    
    
    
        return singleton;
        
    }
}

De toute évidence, l'écriture de style faim ci-dessus. L'instance sera créée au démarrage de la JVM et il n'y aura pas d'insécurité de thread lors de la création de l'instance. (mais possible)

Mais s'il est écrit dans un style paresseux, il y aura évidemment des problèmes de sécurité des threads :

public class Singleton {
    
    

private static Singleton singleton = null;

    private void Singleton(){
    
    }

    public static Singleton getSingleton(){
    
    

        if(singleton == null){
    
    

            singleton = new Singleton();

        }

        return singleton;

    }

}

S'il y a deux threads qui doivent obtenir l'instance maintenant, il est facile de constater qu'il ne s'agit évidemment pas d'un mode singleton lors de la création d'une instance, et le thread n'est évidemment pas sûr.

Aussi, on peut évidemment ajouter un verrou avant la fonction get :

    public static synchronized Singleton getSingleton(){
    
    

        if(singleton == null){
    
    

            singleton = new Singleton();

        }

        return singleton;

    }

Mais cela signifie une forte baisse des performances, car lors de l'obtention d'une instance, un seul thread qui saisit le verrou peut toujours fonctionner.

Mais si vous l'améliorez un peu, verrouillez-le de la manière suivante :

public class Singleton {
    
    

    private static Singleton singleton =null;

    private void Singleton(){
    
    }

    public static Singleton getSingleton(){
    
    
        if(singleton == null){
    
                                       
            synchronized (Singleton.class){
    
              
                if(singleton == null){
    
            
                    singleton = new Singleton(); 
                }
            }
        }
        return singleton;
    }
}

Bien que cela améliore les performances, cela entraîne toujours une insécurité des threads. Comment n'est-ce pas sûr ici ? Nous pouvons supposer un cas limite - l'instruction pour créer un objet est réorganisée :

Trois étapes pour créer un objet :

  • Allouer de l'espace mémoire.
  • Appelez le constructeur pour initialiser l'instance.
  • adresse de retour à référencer
  • Le thread 1 s'applique à l'espace mémoire de l'objet et réécrit l'adresse mémoire dans la mémoire principale, mais l'objet n'a pas encore été initialisé. (étape 3 promue avant l'étape 2)

Pour un seul thread, ce n'est pas illégal, car le résultat du calcul du dernier thread unique est égal à celui avant le réarrangement.

  • Le thread 2 veut obtenir l'instance à ce moment, se rend dans la mémoire principale pour obtenir l'adresse de l'objet, y accède et constate qu'il s'agit d'un pointeur nul.

Par conséquent, le réarrangement des instructions posera des problèmes, nous devons ajouter volatile à la variable pour interdire le réarrangement des instructions.

public class Singleton {
    
    

    private static volatile Singleton singleton =null;

    private void Singleton(){
    
    }

    public static Singleton getSingleton(){
    
    
        if(singleton==null){
    
    
            synchronized (Singleton.class){
    
    
                if(singleton==null){
    
    
                    singleton =new Singleton();
                    }
            }
        }
    return singleton;
    }
}

C'est pourquoi les variables sont préfixées avec le modificateur volatile.

Quatre, volatile et statique

Variables modifiées par statique : parmi plusieurs instances, l'unicité des variables est garantie. Mais il n'y a aucune garantie de visibilité et d'atomicité.
Variables modifiées par volatile : les variables ne sont pas uniques parmi plusieurs instances. Mais la visibilité des threads est garantie, l'atomicité n'est pas garantie.

Par conséquent, la variable modifiée par static volatile est l'unicité entre plusieurs instances et la visibilité entre les threads.

V. Résumé

  • Scénario applicable : une propriété est partagée par plusieurs threads, et un thread modifie cette propriété, et d'autres threads peuvent immédiatement obtenir la valeur modifiée, telle que booleanflag ; ou comme déclencheur pour obtenir une synchronisation légère.
  • Les opérations de lecture et d'écriture des variables volatiles sont sans verrou et peu coûteuses. Il ne remplace pas synchronisé car il ne fournit pas d'atomicité et d'exclusion mutuelle.
  • Volatile ne peut être appliqué qu'aux attributs. Nous utilisons volatile pour modifier les attributs afin que les compilateurs ne réordonnent pas les instructions pour cet attribut.
  • volatile peut obtenir une visibilité et interdire la réorganisation des instructions dans les doubles vérifications singleton (scénarios spéciaux), garantissant ainsi la sécurité.

Visibilité des threads , réarrangement des instructions interdit , pas nécessairement atomique .

Je suppose que tu aimes

Origine blog.csdn.net/weixin_43742184/article/details/113887129
conseillé
Classement