Préface
L'article précédent a parlé du principe CAS, qui a mentionné la classe atomique *. Le mécanisme de mise en œuvre des opérations atomiques repose sur les caractéristiques de visibilité de la mémoire de la volatile. Si vous ne connaissez pas encore CAS et Atomic *, il est recommandé de jeter un coup d'œil à ce dont nous parlons.
Trois caractéristiques de la concurrence
Tout d'abord, si nous voulons utiliser volatile, il doit être dans un environnement de concurrence multithread. Il y a trois caractéristiques importantes dans le scénario simultané dont nous parlons souvent: l'atomicité, la visibilité et l'ordre. Ce n'est que lorsque ces trois caractéristiques sont respectées que le programme simultané peut être exécuté correctement, sinon divers problèmes surviendront.
L'atomicité, les classes CAS et atomiques * mentionnées dans l'article précédent peuvent assurer l'atomicité des opérations simples. Pour certaines opérations responsables, il peut être implémenté à l'aide de verrous synchronisés ou diverses.
La visibilité fait référence au moment où plusieurs threads accèdent à la même variable, un thread modifie la valeur de la variable et d'autres threads peuvent immédiatement voir la valeur modifiée.
Ordonnance, l'ordre de l'exécution du programme est exécuté dans l'ordre du code et les instructions sont interdites d'être réorganisées. Il semble naturel que ce ne soit pas le cas. La réorganisation des instructions est la JVM pour optimiser les instructions et améliorer l'efficacité du fonctionnement du programme, et pour améliorer le parallélisme autant que possible sans affecter les résultats d'exécution d'un programme unique. Cependant, dans un environnement multi-thread, l'ordre de certains codes peut entraîner une incorrection logique.
Volatile implémente deux caractéristiques, la visibilité et l'ordre. Par conséquent, dans un environnement multi-thread, il est nécessaire d'assurer la fonction de ces deux caractéristiques, et le mot-clé volatil peut être utilisé.
Comment la volatile garantit la visibilité
En ce qui concerne la visibilité, vous devez comprendre le processeur et la mémoire principale de l'ordinateur. En raison du multi-threading, peu importe le nombre de fils qu'il y a, il sera finalement effectué dans un processeur informatique. Les ordinateurs d'aujourd'hui sont essentiellement multi-fondations, et certaines machines ont même des multi-processeurs. Jetons un coup d'œil au schéma de structure d'un multiprocesseur:
Il s'agit d'un processeur avec deux processeurs, un quad-core. Un processeur correspond à un emplacement physique et plusieurs processeurs sont connectés via un bus QPI. Un processeur se compose de plusieurs cœurs et d'un cache L3 partagé multi-cœurs entre les processeurs. Un noyau contient des registres, du cache L1, du cache L2.
Lors de l'exécution du programme, la lecture et la rédaction de données doivent être impliquées. Nous savons tous que bien que la vitesse d'accès à la mémoire soit déjà très rapide, elle est encore bien inférieure à la vitesse des instructions d'exécution du CPU. Par conséquent, dans le noyau, L1, L2 et L3 de niveau trois, des caches sont ajoutées. De cette façon, lorsque le programme est en cours d'exécution, les données requises sont d'abord copiées de la mémoire principale au cache du noyau, et une fois l'opération terminée, elle est ensuite écrite dans la mémoire principale. Le chiffre suivant est un diagramme schématique des données d'accès au CPU, des registres au cache à la mémoire principale et même aux disques durs, la vitesse devient de plus en plus lente.
Après avoir compris la structure du CPU, examinons le processus spécifique de l'exécution du programme et prenons une simple opération d'auto-incrément à titre d'exemple.
i = i + 1;
Lors de l'exécution de cette instruction, un thread fonctionnant sur un noyau copie la valeur de i au cache où se trouve le noyau. Une fois l'opération terminée, il sera réécrit à la mémoire principale. Dans un environnement multi-thread, chaque thread aura une mémoire de travail correspondante dans la zone de cache sur le noyau en cours d'exécution, c'est-à-dire que chaque thread a sa propre zone de cache de travail privée pour stocker les répliques de données requises pour l'opération. Ensuite, regardons le problème de I + 1. En supposant que la valeur initiale de I est 0, il y a deux threads qui exécutent cette instruction en même temps, et chaque thread a besoin de trois étapes pour exécuter:
1. Lisez la valeur I de la mémoire principale à la mémoire de travail du thread, c'est-à-dire la zone de cache du noyau correspondant;
2. Calculez la valeur de i + 1;
3. Écrivez la valeur du résultat à la mémoire principale;
Une fois les deux threads exécutés 10 000 fois chacun, la valeur attendue doit être de 20 000. Malheureusement, la valeur de I est toujours inférieure à 20 000. L'une des raisons de ce problème est le problème de cohérence du cache. Pour cet exemple, une fois qu'une copie de cache d'un thread est modifiée, la copie de cache d'autres threads doit être invalidée immédiatement.
Après avoir utilisé le mot-clé volatil, les effets suivants seront:
1. Chaque fois que la variable est modifiée, le cache de processeur (mémoire de travail) sera réécrit dans la mémoire principale;
2. La réécrire à la mémoire principale d'une mémoire de travail entraînera l'invalide du cache de processeur (mémoire de travail) des autres threads.
Parce que Volatile garantit la visibilité de la mémoire, il utilise en fait le protocole MESI qui garantit la cohérence du cache par CPU. Il existe de nombreux contenus du protocole Mesi, donc je ne l'expliquerai pas ici. Veuillez le vérifier vous-même. En bref, le mot-clé volatil est utilisé. Lorsque la modification d'un thread à la variable volatile sera immédiatement écrite dans la mémoire principale, ce qui entraînera l'invalidation de la ligne de cache des autres threads, et d'autres threads sont obligés d'utiliser à nouveau la variable, il doit être lu à partir de la mémoire principale.
Ensuite, nous modifions la variable I ci-dessus avec volatile et l'exécutons à nouveau, chaque thread exécutera 10 000 fois. Malheureusement, il est encore moins de 20 000. Pourquoi est-ce?
Volatile utilise le protocole MESI du CPU pour assurer la visibilité. Cependant, notez que Volatile ne garantit pas l'atomicité de l'opération, car cette opération d'auto-incitation est divisée en trois étapes. Supposons que le thread 1 lit la valeur I de la mémoire principale, en supposant qu'il est de 10, et un blocage se produit à ce moment, mais je n'ai pas encore été modifié. À l'heure actuelle, Thread 2 lit également la valeur I à partir de la mémoire principale. À l'heure actuelle, la valeur I LIRE par ces deux threads est la même, les deux, puis le thread 2 ajoute 1 à I et l'écrit immédiatement à la mémoire principale. À l'heure actuelle, selon le protocole MESI, la ligne de cache correspondant à la mémoire de travail du thread 1 sera définie sur un état non valide, oui. Cependant, veuillez noter que le thread 1 a déjà copié la valeur I de la mémoire principale, et maintenant il ne prend que le fonctionnement de l'ajout de 1 et de la rééducation à la mémoire principale. Les deux threads ajoutent 1 sur la base de 10, puis récupèrent à la mémoire principale, de sorte que la valeur finale de la mémoire principale n'est que de 11, pas le 12 attendu.
Par conséquent, l'utilisation du volatile peut assurer la visibilité de la mémoire, mais elle ne peut garantir l'atomicité. Si l'atomicité est toujours nécessaire, vous pouvez vous référer à cet article précédent.
Comment volatile garantit l'ordre
Le modèle de mémoire Java a un "Orderline" inné, c'est-à-dire qu'il peut être garanti sans moyen. Ceci est généralement appelé le principe en cours. Si l'ordre d'exécution de deux opérations ne peut pas être dérivé du principe des événements, alors ils ne peuvent garantir leur ordre et que les machines virtuelles peuvent les réorganiser à volonté.
Les éléments suivants sont 8 principes de se produire avant, extraits de la "compréhension approfondie des machines virtuelles Java".
Ici, nous parlerons principalement des règles du mot-clé volatil et donnerons un exemple de double vérification du célèbre singleton:
Class Singleton {private volatile static singleton instance = null; privé singleton () {} public static singleton getInstance () {if (instance == null) {// étape 1 synchronisé (singleton.class) {if (instance == null) // étape 2 instance = new singleton (); // Étape 3}} Instance de retour; }}Si l'instance n'est pas modifiée avec volatile, quels résultats peuvent être produits? Supposons qu'il y ait deux threads appelant la méthode getInstance (). Le thread 1 exécute Step1 et constate que l'instance est nul, puis verrouille la classe Singleton de manière synchrone. Détermine ensuite si l'instance est à nouveau nul, et constate qu'il est toujours nul, puis exécute l'étape 3 et démarre l'instanciation de Singleton. Au cours du processus d'instanciation, le thread 2 passe à l'étape 1 et peut constater que l'instance n'est pas vide, mais pour le moment, l'instance peut ne pas être complètement initialisée.
Qu'est-ce que ça veut dire? L'objet est initialisé en trois étapes et est représenté par le pseudo-code suivant:
mémoire = allocate (); // 1. Allouer l'espace mémoire de l'objet ctorinstance (mémoire); // 2. Initialiser l'instance d'objet = mémoire; // 3. Définissez l'espace mémoire de l'objet pointant vers l'objet
Étant donné que les étapes 2 et 3 doivent dépendre de l'étape 1, et les étapes 2 et 3 n'ont pas de dépendance, il est possible que ces deux déclarations subissent une réarrangement des instructions, c'est-à-dire, ou il est possible que l'étape 3 soit exécutée avant l'étape 2. Dans ce cas, l'étape 3 est encore exécutée. Tout à l'heure, Thread 2 juge que l'instance n'est pas nul, donc il renvoie directement l'instance d'instance. Cependant, pour le moment, l'instance est en fait un objet incomplet, il y aura donc des problèmes lors de l'utilisation.
L'utilisation du mot clé volatil signifie en utilisant le principe de "Écriture d'une variable modifiée par volatile, arrive avant de lire la variable à tout moment suivant" correspond au processus d'initialisation ci-dessus. Les étapes 2 et 3 rédigent les deux instances, ils doivent donc se produire plus tard lors de la lecture des instances, c'est-à-dire qu'il n'y aura aucune possibilité de renvoyer une instance qui n'est pas complètement initialisée.
Le JVM sous-jacent se fait à travers quelque chose appelé une "barrière de mémoire". La barrière de mémoire, également connue sous le nom de clôture de mémoire, est un ensemble d'instructions de processeur utilisées pour implémenter des restrictions séquentielles sur les opérations de mémoire.
enfin
Grâce au mot clé volatil, nous avons appris la visibilité et l'ordre dans la programmation simultanée, qui est bien sûr juste une compréhension simple. Pour une compréhension plus approfondie, vous devez compter sur vos camarades de classe pour l'étudier vous-même.
Articles connexes
Quelles sont les verrous CAS Spin dont nous parlons
Résumer
Ce qui précède est l'intégralité du contenu de cet article. J'espère que le contenu de cet article a une certaine valeur de référence pour l'étude ou le travail de chacun. Si vous avez des questions, vous pouvez laisser un message pour communiquer. Merci pour votre soutien à wulin.com.