1. Concepts associés au modèle de mémoire
Comme nous le savons tous, lorsqu'un ordinateur exécute un programme, chaque instruction est exécutée dans le processeur et le processus d'exécution des instructions implique inévitablement la lecture et l'écriture de données. Étant donné que les données temporaires pendant l'exécution du programme sont stockées dans la mémoire principale (mémoire physique), il existe un problème. La vitesse d'exécution du processeur étant très rapide, le processus de lecture des données de la mémoire et d'écriture des données dans la mémoire est difficile. différente de celle du CPU. La vitesse d'exécution des instructions est beaucoup plus lente, donc si l'opération des données doit être effectuée par interaction avec la mémoire à tout moment, la vitesse d'exécution des instructions sera considérablement réduite. Il y a donc un cache dans le CPU.
Autrement dit, lorsque le programme est en cours d'exécution, les données requises pour l'opération seront copiées de la mémoire principale vers le cache du processeur. Le processeur pourra ensuite lire directement les données de son cache et y écrire lors de l'exécution des calculs. , les données du cache sont actualisées dans la mémoire principale. Prenons un exemple simple, comme le code suivant :
1i = je + 1;
Lorsque le thread exécute cette instruction, il lit d'abord la valeur de i dans la mémoire principale, puis en copie une copie dans le cache. Ensuite, le processeur exécute l'instruction pour incrémenter i de 1, puis écrit les données dans le cache et. écrit enfin la valeur dans le cache. La dernière valeur de i dans le cache est vidée dans la mémoire principale.
Il n'y a aucun problème avec l'exécution de ce code dans un seul thread, mais il y aura des problèmes lors de l'exécution dans plusieurs threads. Dans un processeur multicœur, chaque thread peut s'exécuter dans un processeur différent, de sorte que chaque thread a son propre cache lors de son exécution (pour les processeurs monocœur, ce problème se produit également, mais il est planifié par thread. Forms à exécuter séparément ). Dans cet article, nous prenons comme exemple un processeur multicœur.
Par exemple, deux threads exécutent ce code en même temps. Si la valeur de i est initialement 0, alors nous espérons que la valeur de i deviendra 2 après l'exécution des deux threads. Mais est-ce que ce sera le cas ?
Il peut y avoir la situation suivante : initialement, les deux threads lisent respectivement la valeur de i et la stockent dans le cache de leurs CPU respectifs, puis le thread 1 ajoute 1, puis écrit la dernière valeur 1 de i dans la mémoire. À ce stade, la valeur de i dans le cache du thread 2 est toujours 0. Après avoir ajouté 1, la valeur de i est 1, puis le thread 2 écrit la valeur de i dans la mémoire.
La valeur finale de i est 1 et non 2. C’est le fameux problème de cohérence du cache. Les variables auxquelles plusieurs threads accèdent sont généralement appelées variables partagées.
En d’autres termes, si une variable est mise en cache dans plusieurs processeurs (ce qui se produit généralement dans la programmation multithread), il peut alors y avoir un problème d’incohérence du cache.
Afin de résoudre le problème d'incohérence du cache, il existe généralement deux solutions :
1) En ajoutant LOCK# au bus
2) Grâce au protocole de cohérence du cache
Ces deux méthodes sont fournies au niveau matériel.
Dans les premiers processeurs, le problème d'incohérence du cache était résolu en ajoutant des verrous LOCK# sur le bus. Étant donné que la communication entre le CPU et les autres composants s'effectue via le bus, si vous ajoutez LOCK# au bus, cela signifie que les autres CPU ne peuvent pas accéder à d'autres composants (tels que la mémoire), de sorte qu'un seul CPU peut l'utiliser. mémoire variable. Par exemple, dans l'exemple ci-dessus, si un thread exécute i = i +1, et si le signal de verrouillage LCOK# est envoyé sur le bus lors de l'exécution de ce code, alors les autres CPU ne peuvent qu'attendre que ce code soit complètement terminé. exécuté. Lisez la variable dans la mémoire où se trouve la variable i, puis effectuez l'opération correspondante. Cela résout le problème d’incohérence du cache.
Cependant, la méthode ci-dessus pose un problème pendant la période de verrouillage du bus, les autres processeurs ne peuvent pas accéder à la mémoire, ce qui entraîne une faible efficacité.
C'est ainsi qu'est apparu le protocole de cohérence du cache. Le plus connu est le protocole MESI d'Intel, qui garantit la cohérence de la copie des variables partagées utilisées dans chaque cache. Son idée principale est la suivante : lorsque le processeur écrit des données, s'il constate que la variable exploitée est une variable partagée, c'est-à-dire qu'une copie de la variable existe également dans d'autres processeurs, un signal sera envoyé pour informer les autres processeurs de définir le cache. ligne de la variable dans un état invalide. Par conséquent, lorsque d'autres processeurs ont besoin de lire cette variable et constatent que la ligne de cache qui met en cache la variable dans leur propre cache est invalide, ils la relisent depuis la mémoire.
2. Trois concepts en programmation concurrente
En programmation concurrente, nous rencontrons généralement les trois problèmes suivants : problème d’atomicité, problème de visibilité et problème d’ordre. Examinons d'abord de plus près ces trois concepts :
1.Atomicité
Atomicité : c'est-à-dire qu'une ou plusieurs opérations sont soit entièrement exécutées et le processus d'exécution ne sera interrompu par aucun facteur, soit elles ne sont pas exécutées du tout.
Un exemple très classique est le problème du virement bancaire :
Par exemple, transférer 1 000 yuans du compte A vers le compte B doit comprendre deux opérations : soustraire 1 000 yuans du compte A et ajouter 1 000 yuans au compte B.
Imaginez quelles seraient les conséquences si ces deux opérations n’étaient pas atomiques. Supposons qu'après avoir déduit 1 000 yuans du compte A, l'opération soit soudainement interrompue. Ensuite, il a retiré 500 yuans de B. Après avoir retiré 500 yuans, il a ensuite effectué l'opération consistant à ajouter 1 000 yuans au compte B. Cela aura pour conséquence que bien que le compte A soit déduit de 1 000 yuans, le compte B ne recevra pas les 1 000 yuans transférés.
Par conséquent, ces deux opérations doivent être atomiques pour garantir qu’aucun problème inattendu ne se produise.
Quelles seront les conséquences d’une même réflexion en programmation concurrente ?
Pour donner l’exemple le plus simple, réfléchissez à ce qui se passerait si le processus d’affectation à une variable 32 bits n’était pas atomique ?
1i = 9;
Si un thread exécute cette instruction, je supposerai temporairement que l'attribution d'une valeur à une variable de 32 bits implique deux processus : l'attribution d'une valeur aux 16 bits inférieurs et l'attribution d'une valeur aux 16 bits supérieurs.
Ensuite, une situation peut se produire : lorsqu'une valeur faible de 16 bits est écrite, elle est soudainement interrompue, et à ce moment-là, un autre thread lit la valeur de i, puis les mauvaises données sont lues.
2. Visibilité
La visibilité signifie que lorsque plusieurs threads accèdent à la même variable, si un thread modifie la valeur de la variable, les autres threads peuvent immédiatement voir la valeur modifiée.
Pour un exemple simple, regardez le code suivant :
//Code exécuté par le thread 1
int je = 0;
je = 10;
//Code exécuté par le thread 2
j = je;
Si le thread 1 est exécuté par CPU1, le thread 2 est exécuté par CPU2. De l'analyse ci-dessus, on peut voir que lorsque le thread 1 exécute la phrase i = 10, il chargera d'abord la valeur initiale de i dans le cache de CPU1, puis l'attribuera à 10, puis la valeur de i dans le cache de CPU1 devient 10 , mais il n'est pas écrit immédiatement dans la mémoire principale.
À ce moment-là, le thread 2 exécute j = i. Il lira d'abord la valeur de i dans la mémoire principale et la chargera dans le cache de CPU2. Notez que la valeur de i dans la mémoire est toujours 0 à ce moment-là. faites la valeur de j = 0, et non 10.
Il s'agit d'un problème de visibilité. Après que le thread 1 ait modifié la variable i, le thread 2 ne voit pas immédiatement la valeur modifiée par le thread 1.
3. Ordre
Ordre : c'est-à-dire que l'ordre d'exécution du programme est exécuté dans l'ordre du code. Pour un exemple simple, regardez le code suivant :
int je = 0;
drapeau booléen = faux ;
je = 1 ; //Déclaration 1
drapeau = vrai ; //Déclaration 2
Le code ci-dessus définit une variable de type int et une variable de type booléen, puis attribue respectivement des valeurs aux deux variables. À en juger par la séquence de code, l'instruction 1 est avant l'instruction 2. Ainsi, lorsque la JVM exécute réellement ce code, garantira-t-elle que l'instruction 1 sera exécutée avant l'instruction 2 ? Pas forcément, pourquoi ? Une réorganisation des instructions peut avoir lieu ici.
Expliquons ce qu'est la réorganisation des instructions. De manière générale, afin d'améliorer l'efficacité du fonctionnement du programme, le processeur peut optimiser le code d'entrée. Il ne garantit pas que l'ordre d'exécution de chaque instruction du programme est cohérent avec l'ordre du code. , mais cela garantira que le résultat final de l'exécution du programme est cohérent avec le résultat de l'exécution séquentielle du code.
Par exemple, dans le code ci-dessus, le fait que l'instruction 1 ou l'instruction 2 soit exécutée en premier n'a aucun impact sur le résultat final du programme. Il est alors possible que pendant le processus d'exécution, l'instruction 2 soit exécutée en premier et l'instruction 1 soit exécutée plus tard.
Mais il convient de noter que même si le processeur réordonne les instructions, il garantira que le résultat final du programme sera le même que le résultat de l'exécution séquentielle du code. Alors, sur quelle garantie s'appuie-t-il ? Regardez l'exemple suivant :
int a = 10 ; //Déclaration 1
int r = 2; //Déclaration 2
a = a + 3 ; //Déclaration 3
r = a*a; //Déclaration 4
Ce code comporte 4 instructions, donc un ordre d'exécution possible est :
Est-il donc possible que ce soit l'ordre d'exécution : Déclaration 2 Déclaration 1 Déclaration 4 Déclaration 3
Impossible, car le processeur prendra en compte les dépendances de données entre les instructions lors de la réorganisation. Si une instruction, l'instruction 2, doit utiliser le résultat de l'instruction 1, alors le processeur veillera à ce que l'instruction 1 soit exécutée avant l'instruction 2.
Bien que la réorganisation n'affecte pas les résultats de l'exécution du programme au sein d'un seul thread, qu'en est-il du multithreading ? Regardons un exemple :
//Thème 1 :
contexte = loadContext(); //Déclaration 1
initié = vrai; //Déclaration 2
//Thème 2 :
pendant que(!initié){
dormir()
}
faire quelque choseavecconfig(context);
Dans le code ci-dessus, puisque les instructions 1 et 2 n'ont aucune dépendance de données, elles peuvent être réorganisées. Si une réorganisation se produit, l'instruction 2 est exécutée en premier lors de l'exécution du thread 1, et le thread 2 pensera que le travail d'initialisation est terminé, puis il sortira de la boucle while et exécutera la méthode doSomethingwithconfig(context), mais à cette fois, le contexte n'existe pas. L'initialisation provoquera une erreur de programme.
Comme le montre ce qui précède, la réorganisation des instructions n'affectera pas l'exécution d'un seul thread, mais affectera l'exactitude de l'exécution simultanée des threads.
En d’autres termes, pour que les programmes concurrents s’exécutent correctement, l’atomicité, la visibilité et l’ordre doivent être garantis. Tant que l’un d’eux n’est pas garanti, cela peut entraîner un mauvais fonctionnement du programme.
3. Modèle de mémoire Java
Plus tôt, j'ai parlé de certains problèmes pouvant survenir dans les modèles de mémoire et la programmation simultanée. Jetons un coup d'œil au modèle de mémoire Java et étudions quelles garanties le modèle de mémoire Java nous offre et quelles méthodes et mécanismes sont fournis en Java pour garantir l'exactitude de l'exécution du programme lors de l'exécution d'une programmation multithread.
La spécification de la machine virtuelle Java tente de définir un modèle de mémoire Java (JMM) pour protéger les différences d'accès à la mémoire entre les différentes plates-formes matérielles et systèmes d'exploitation, afin que les programmes Java puissent obtenir un accès à la mémoire cohérent sur différentes plates-formes. Alors, que stipule le modèle de mémoire Java ? Il définit les règles d'accès aux variables du programme. Dans une plus large mesure, il définit l'ordre d'exécution du programme. Notez que afin d'obtenir de meilleures performances d'exécution, le modèle de mémoire Java n'empêche pas le moteur d'exécution d'utiliser les registres ou le cache du processeur pour améliorer la vitesse d'exécution des instructions, ni n'empêche le compilateur de réorganiser les instructions. En d’autres termes, dans le modèle de mémoire Java, il y aura également des problèmes de cohérence du cache et des problèmes de réorganisation des instructions.
Le modèle de mémoire Java stipule que toutes les variables sont stockées dans la mémoire principale (similaire à la mémoire physique mentionnée précédemment) et que chaque thread possède sa propre mémoire de travail (similaire au cache précédent). Toutes les opérations sur les variables par les threads doivent être effectuées dans la mémoire de travail et ne peuvent pas opérer directement sur la mémoire principale. Et chaque thread ne peut pas accéder à la mémoire de travail des autres threads.
Pour donner un exemple simple : en java, exécutez l'instruction suivante :
1i = 10;
Le thread d'exécution doit d'abord attribuer la ligne de cache où se trouve la variable i dans son propre thread de travail, puis l'écrire dans la mémoire principale. Au lieu d'écrire la valeur 10 directement dans la mémoire principale.
Alors, quelles garanties le langage Java lui-même offre-t-il en matière d'atomicité, de visibilité et d'ordre ?
1.Atomicité
En Java, les opérations de lecture et d'affectation à des variables de types de données de base sont des opérations atomiques, c'est-à-dire que ces opérations ne peuvent pas être interrompues et sont exécutées ou non.
Même si la phrase ci-dessus semble simple, elle n’est pas si facile à comprendre. Regardez l'exemple suivant i :
Veuillez analyser lesquelles des opérations suivantes sont des opérations atomiques :
x = 10 ; //Déclaration 1
y = x; //Déclaration 2
x++ ; //Déclaration 3
x = x + 1 ; //Déclaration 4
À première vue, certains amis pourraient dire que les opérations contenues dans les quatre instructions ci-dessus sont toutes des opérations atomiques. En fait, seule l’instruction 1 est une opération atomique, et les trois autres instructions ne sont pas des opérations atomiques.
L'instruction 1 attribue directement la valeur 10 à x, ce qui signifie que le thread exécutant cette instruction écrira directement la valeur 10 dans la mémoire de travail.
L'instruction 2 contient en fait deux opérations. Elle lit d'abord la valeur de x, puis écrit la valeur de x dans la mémoire de travail, bien que les deux opérations de lecture de la valeur de x et d'écriture de la valeur de x dans la mémoire de travail soient des opérations atomiques. , mais ensemble, ce ne sont pas des opérations atomiques.
De même, x++ et x = x+1 incluent 3 opérations : lire la valeur de x, ajouter 1 et écrire la nouvelle valeur.
Par conséquent, parmi les quatre instructions ci-dessus, seule l’opération de l’instruction 1 est atomique.
En d’autres termes, seules la simple lecture et l’affectation (et le nombre doit être attribué à une variable, l’affectation mutuelle entre variables n’est pas une opération atomique) sont des opérations atomiques.
Cependant, il y a une chose à noter ici : sur une plateforme 32 bits, la lecture et l'attribution de données 64 bits nécessitent deux opérations, et leur atomicité ne peut être garantie. Mais il semble que dans le dernier JDK, la JVM ait garanti que la lecture et l'attribution de données 64 bits sont également des opérations atomiques.
Comme le montre ce qui précède, le modèle de mémoire Java garantit uniquement que la lecture et l'affectation de base sont des opérations atomiques. Si vous souhaitez obtenir l'atomicité pour une plus large gamme d'opérations, vous pouvez y parvenir via la synchronisation et le verrouillage. Étant donné que synchronisé et Lock peuvent garantir qu'un seul thread exécute le bloc de code à tout moment, il n'y a pas de problème d'atomicité, garantissant ainsi l'atomicité.
2. Visibilité
Pour la visibilité, Java fournit le mot-clé volatile pour garantir la visibilité.
Lorsqu'une variable partagée est modifiée de manière volatile, elle garantira que la valeur modifiée sera immédiatement mise à jour dans la mémoire principale. Lorsque d'autres threads auront besoin de la lire, elle lira la nouvelle valeur de la mémoire.
Les variables partagées ordinaires ne peuvent pas garantir la visibilité, car après la modification d'une variable partagée ordinaire, il n'est pas certain quand elle sera écrite dans la mémoire principale. Lorsque d'autres threads la liront, l'ancienne valeur d'origine peut encore être dans la mémoire à ce moment-là. La visibilité n'est pas garantie.
De plus, la visibilité peut également être garantie grâce à synchronisé et Lock. Synchronized et Lock peuvent garantir qu'un seul thread acquiert le verrou et exécute le code de synchronisation en même temps, et que les modifications apportées aux variables sont vidées dans la mémoire principale avant de libérer le code. verrouillage. La visibilité est donc garantie.
3. Ordre
Dans le modèle de mémoire Java, le compilateur et le processeur sont autorisés à réorganiser les instructions, mais le processus de réorganisation n'affectera pas l'exécution des programmes monothread, mais affectera l'exactitude de l'exécution simultanée multithread.
En Java, vous pouvez utiliser le mot clé volatile pour assurer un certain "ordre" (le principe spécifique est décrit dans la section suivante). De plus, l'ordre peut être assuré grâce à synchronisé et Lock. Évidemment, synchronisé et Lock garantissent qu'un thread exécute le code de synchronisation à chaque instant, ce qui équivaut à laisser les threads exécuter le code de synchronisation de manière séquentielle, ce qui garantit naturellement l'ordre.
De plus, le modèle de mémoire Java possède un certain « ordre » inné, c'est-à-dire un ordre qui peut être garanti sans aucun moyen. C'est ce qu'on appelle souvent le principe de « se produire avant ». Si l’ordre d’exécution de deux opérations ne peut être déduit du principe de l’occurrence avant, alors leur ordre n’est pas garanti et la machine virtuelle peut les réordonner à volonté.
Présentons en détail le principe qui se produit avant :
Règles de séquence du programme : Dans un thread, selon l'ordre du code, les opérations écrites au début se produisent avant les opérations écrites au dos
Règles de verrouillage : une opération de déverrouillage se produit d'abord avant d'être confrontée à la même opération de verrouillage plus tard
Règles des variables volatiles : une opération d'écriture dans une variable se produit d'abord avant une opération de lecture ultérieure dans la variable
Règle de transition : si l'opération A se produit avant l'opération B et que l'opération B se produit avant l'opération C, alors on peut conclure que l'opération A se produit avant l'opération C
Règles de démarrage du fil : la méthode start() de l'objet Thread se produit en premier pour chaque action de ce fil
Règles d'interruption de thread : L'appel à la méthode thread interruption() se produit en premier lorsque le code du thread interrompu détecte l'occurrence de l'événement d'interruption
Règles de fin de thread : toutes les opérations dans un thread se produisent en premier lorsque le thread est terminé. Nous pouvons détecter que le thread s'est terminé en terminant la méthode Thread.join() et en renvoyant la valeur de Thread.isAlive()
. Règle de finalisation d'objet : L'initialisation d'un objet se produit en premier au début de sa méthode finalize()
Ces 8 principes sont extraits de « Compréhension approfondie de la machine virtuelle Java ».
Parmi ces 8 règles, les 4 premières règles sont les plus importantes, et les 4 dernières règles sont évidentes.
Expliquons les 4 premières règles ci-dessous :
Pour les règles d'ordre des programmes, je crois comprendre que l'exécution d'un morceau de code de programme semble être ordonnée dans un seul thread. Notez que bien que cette règle mentionne que « les opérations écrites au recto se produisent avant les opérations écrites au verso », cela devrait signifier que l'ordre dans lequel le programme semble être exécuté est dans l'ordre du code, car la machine virtuelle peut effectuer opérations sur le code du programme. Instructions réorganisées. Bien qu'une réorganisation soit effectuée, le résultat final de l'exécution est cohérent avec le résultat de l'exécution séquentielle du programme. Seules les instructions qui n'ont pas de dépendances de données seront réorganisées. Par conséquent, dans un seul thread, l’exécution du programme semble être exécutée dans l’ordre, ce qui doit être compris. En fait, cette règle est utilisée pour garantir l'exactitude des résultats d'exécution du programme dans un seul thread, mais elle ne peut pas garantir l'exactitude de l'exécution du programme dans plusieurs threads.
La deuxième règle est également plus facile à comprendre. Autrement dit, que ce soit dans un seul thread ou dans plusieurs threads, si le même verrou est dans un état verrouillé, le verrou doit d'abord être libéré avant que l'opération de verrouillage puisse continuer.
La troisième règle est plus importante et fera l’objet de l’article suivant. L'explication intuitive est que si un thread écrit d'abord une variable, puis qu'un thread la lit, alors l'opération d'écriture se produira certainement avant l'opération de lecture.
La quatrième règle reflète en fait la nature transitive du principe qui se produit avant.
4. Analyse approfondie du mot clé volatile
J'ai déjà parlé de beaucoup de choses, mais elles ouvrent toutes la voie à la discussion sur le mot-clé volatile, alors abordons le sujet ensuite.
1.Deux niveaux de sémantique du mot-clé volatile
Une fois qu'une variable partagée (variable membre de classe, variable membre statique de classe) est modifiée volatile, elle a deux niveaux de sémantique :
1) Assure la visibilité lorsque différents threads opèrent sur cette variable, c'est-à-dire que si un thread modifie la valeur d'une variable, la nouvelle valeur est immédiatement visible par les autres threads.
2) La réorganisation des instructions est interdite.
Regardons d'abord un morceau de code. Si le thread 1 est exécuté en premier et que le thread 2 est exécuté plus tard :
//Thème 1
booléen stop = faux ;
pendant que(!stop){
faire quelque chose();
}
//Thème 2
stop = vrai ;
Ce code est un morceau de code très typique, et de nombreuses personnes peuvent utiliser cette méthode de marquage lors de l'interruption d'un thread. Mais au fait, ce code fonctionnera-t-il tout à fait correctement ? Autrement dit, le fil sera-t-il interrompu ? Pas nécessairement, peut-être la plupart du temps, ce code peut interrompre le thread, mais il peut également empêcher l'interruption du thread (bien que cette possibilité soit très faible, mais une fois que cela se produit, cela provoquera une boucle infinie).
Expliquons pourquoi ce code peut empêcher l'interruption du thread. Comme expliqué précédemment, chaque thread a sa propre mémoire de travail pendant son exécution, donc lorsque le thread 1 est en cours d'exécution, il copiera la valeur de la variable stop et la placera dans sa propre mémoire de travail.
Ensuite, lorsque le thread 2 modifie la valeur de la variable stop, mais avant d'avoir le temps de l'écrire dans la mémoire principale, le thread 2 se tourne pour faire autre chose. Ensuite, le thread 1 ne connaît pas le changement de la variable stop par le thread 2, donc. il continuera à tourner en boucle.
Mais après avoir utilisé la modification volatile, cela devient différent :
Premièrement : l'utilisation du mot-clé volatile forcera l'écriture immédiate de la valeur modifiée dans la mémoire principale
;Deuxièmement : si le mot-clé volatile est utilisé, lorsque le thread 2 effectue une modification, la ligne de cache de l'arrêt de la variable de cache dans la mémoire de travail du thread 1 sera invalide (si elle est reflétée dans la couche matérielle, c'est le cache correspondant ligne dans le cache L1 ou L2 du CPU) Invalide);
Troisièmement : étant donné que la ligne de cache de la variable de cache stop dans la mémoire de travail du thread 1 n'est pas valide, le thread 1 ira dans la mémoire principale pour relire la valeur de la variable stop.
Ensuite, lorsque le thread 2 modifie la valeur stop (cela inclut bien sûr deux opérations, modifier la valeur dans la mémoire de travail du thread 2, puis écrire la valeur modifiée dans la mémoire), la ligne de cache de la variable stop sera mise en cache dans le mémoire de travail du thread 1. Invalide, puis lorsque le thread 1 lit, il constate que sa ligne de cache est invalide. Il attendra que l'adresse de la mémoire principale correspondant à la ligne de cache soit mise à jour, puis ira dans la mémoire principale correspondante pour lire. la dernière valeur.
Ensuite, ce que lit le thread 1 est la dernière valeur correcte.
2. Le volatil garantit-il l’atomicité ?
De ce qui précède, nous savons que le mot-clé volatile garantit la visibilité des opérations, mais volatile peut-il garantir que le fonctionnement des variables est atomique ?
Regardons un exemple :
Test de classe publique {
public volatile int inc = 0;
augmentation du vide public() {
inclus++;
}
public static void main (String[] args) {
Test final test = nouveau Test();
pour(int i=0;i<10;i++){
nouveau fil de discussion(){
public void run() {
pour(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //Assurez-vous que tous les threads précédents ont été exécutés
Thread.yield();
System.out.println(test.inc);
}
}
Pensez-y, quel est le résultat de ce programme ? Peut-être que certains amis pensent que c'est 10 000. Mais en fait, lorsque vous l'exécuterez, vous constaterez que les résultats sont incohérents à chaque fois et qu'ils sont toujours inférieurs à 10 000.
Certains amis peuvent avoir des questions, non, ce qui précède est une opération d'auto-incrémentation sur la variable inc. Puisque volatile garantit la visibilité, après avoir incrémenté inc dans chaque thread, la valeur modifiée peut être vue dans d'autres threads, donc 10 threads ont effectué 1000. opérations respectivement, alors la valeur finale de inc doit être 1000*10=10000.
Il y a un malentendu ici. Le mot clé volatile peut garantir que la visibilité est correcte, mais l'erreur dans le programme ci-dessus est qu'il ne garantit pas l'atomicité. La visibilité ne peut garantir que la dernière valeur est lue à chaque fois, mais volatile ne peut pas garantir l'atomicité des opérations sur les variables.
Comme mentionné précédemment, l'opération d'auto-incrémentation n'est pas atomique. Elle comprend la lecture de la valeur originale de la variable, l'ajout de 1 et l'écriture dans la mémoire de travail. Cela signifie que les trois sous-opérations de l'opération d'auto-incrémentation peuvent être exécutées séparément, ce qui peut conduire à la situation suivante :
Si la valeur de la variable inc est 10 à un certain moment,
Le thread 1 effectue une opération d'auto-incrémentation sur la variable. Le thread 1 lit d'abord la valeur d'origine de la variable inc, puis le thread 1 est bloqué
; Ensuite, le thread 2 effectue une opération d'auto-incrémentation sur la variable, et le thread 2 lit également la valeur d'origine de la variable inc. Puisque le thread 1 lit uniquement la variable inc et ne modifie pas la variable, cela ne provoquera pas le travail du thread 2. . La ligne de cache de la variable de cache inc dans la mémoire n'est pas valide, donc le thread 2 ira directement dans la mémoire principale pour lire la valeur de inc. Il trouve que la valeur de inc est 10, puis ajoute 1, écrit 11 dans le fichier. mémoire de travail, et enfin l'écrit dans la mémoire principale.
Ensuite, le thread 1 ajoute 1. Puisque la valeur de inc a été lue, notez que la valeur de inc dans la mémoire de travail du thread 1 est toujours 10 à ce moment, donc la valeur de inc après que le thread 1 ajoute 1 à inc est 11. . , puis écrivez 11 dans la mémoire de travail, et enfin dans la mémoire principale.
Ensuite, après que les deux threads aient chacun effectué une opération d'auto-incrémentation, inc n'a augmenté que de 1.
Après avoir expliqué cela, certains amis peuvent avoir des questions. Non, n'est-il pas garanti que lorsqu'une variable est modifiée en variable volatile, la ligne de cache sera invalide ? Ensuite, d'autres threads liront la nouvelle valeur, oui, c'est correct. Il s'agit de la règle des variables volatiles dans la règle qui se produit avant ci-dessus, mais il convient de noter qu'une fois que le thread 1 a lu la variable et est bloqué, la valeur inc n'est pas modifiée. Ensuite, bien que volatile puisse garantir que le thread 2 lit la valeur de la variable inc depuis la mémoire, le thread 1 ne la modifie pas, donc le thread 2 ne verra pas du tout la valeur modifiée.
La cause première est ici. L'opération d'auto-incrémentation n'est pas une opération atomique, et volatile ne peut pas garantir que toute opération sur une variable soit atomique.
Remplacez le code ci-dessus par l'un des codes suivants pour obtenir l'effet :
Utilisation synchronisée :
Test de classe publique {
public int inc = 0;
augmentation de vide synchronisée publique() {
inclus++;
}
public static void main (String[] args) {
Test final test = nouveau Test();
pour(int i=0;i<10;i++){
nouveau fil de discussion(){
public void run() {
pour(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //Assurez-vous que tous les threads précédents ont été exécutés
Thread.yield();
System.out.println(test.inc);
}
}
Voir le code
采用Verrouillage:
Test de classe publique {
public int inc = 0;
Verrouillage = new ReentrantLock();
augmentation du vide public() {
lock.lock();
essayez {
inclus++;
} enfin{
lock.unlock();
}
}
public static void main (String[] args) {
Test final test = nouveau Test();
pour(int i=0;i<10;i++){
nouveau fil de discussion(){
public void run() {
pour(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while (Thread.activeCount () & gt; 1) // Thread.yield();
System.out.println(test.inc);
}
}
Voir le code
采用AtomicInteger:
Test de classe publique {
public AtomicInteger inc = new AtomicInteger();
augmentation du vide public() {
inc.getAndIncrement();
}
public static void main (String[] args) {
Test final test = nouveau Test();
pour(int i=0;i<10;i++){
nouveau fil de discussion(){
public void run() {
pour(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while (Thread.activeCount () & gt; 1) // Thread.yield();
System.out.println(test.inc);
}
}
Voir le code
La version Java 1.5 de Java.util.concurrent.atomic est basée sur la version 1.5 de Java.util.concurrent.atomic. 1er janvier 2017 ,减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的(Comparer et échanger),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。
3.volatile能保证有序性吗?
Il s'agit d'une question de volatilité volatile. volatile关键字禁止指令重排序有两层意思:
1).结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
2) Il s'agit d'un système volatile, d'un système volatile et d'un système volatile.语句放到其前面执行。
可能上面说的比较绕,举个简单的例子:
//x、y为非volatile变量
//flag为volatile变量
x = 2 ; //语句1
y = 0 ; //语句2
drapeau = vrai ; //语句3
x = 4 ; //语句4
y = -1 ; //语句5
Le drapeau est volatile, il est 3, 3, 1, 2.也不会讲语句3放到语句4、语句5后面。但是要注意语句1 et 2的顺序、语句4 et 5的顺序是不作任何保证的。
Les éléments volatiles sont des éléments volatiles, 3 éléments, 1 et 2, 1 et 2.行结果对语句3、语句4、语句5是可见的。
那么我们回到前面举的一个例子:
//线程1:
contexte = loadContext(); //语句1
initié = vrai ; //语句2
//线程2:
pendant que(!initié){
dormir()
}
faire quelque choseavecconfig(context);
Dans le contexte du contexte,而线程2中就使用未初始化的context去进行操作,导致程序出错。
Les versions volatiles sont lancées et lancées, et la version 2 est la version 2.保证context已经初始化完毕。
4.volatile的原理和实现机制
Il s'agit d'une question de volatilité volatile.重排序的。
下面这段话摘自《深入理解Java虚拟机》:
« Les produits volatiles et les produits volatiles sont des produits volatiles.个lock前缀指令”
serrure 1)排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2) 3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
五.使用volatile关键字的场景
Le mot-clé synchronisé empêche plusieurs threads d'exécuter un morceau de code en même temps, ce qui affectera grandement l'efficacité de l'exécution du programme. Le mot-clé volatile a de meilleures performances que synchronisé dans certains cas, mais il convient de noter que le mot-clé volatile ne peut pas remplacer le mot-clé synchronisé. mot-clé , car le mot-clé volatile ne peut pas garantir l’atomicité de l’opération. De manière générale, les deux conditions suivantes doivent être remplies pour utiliser volatile :
1) Les opérations d'écriture sur les variables ne dépendent pas de la valeur actuelle
2) La variable n'est pas incluse dans un invariant avec d'autres variables
En effet, ces conditions indiquent que les valeurs valides pouvant être écrites dans des variables volatiles sont indépendantes de tout état du programme, y compris l'état actuel de la variable.
En fait, je crois comprendre que les deux conditions ci-dessus doivent garantir que l'opération est une opération atomique afin de garantir que les programmes utilisant le mot-clé volatile peuvent s'exécuter correctement pendant la concurrence.
Voici quelques scénarios dans lesquels volatile est utilisé en Java.
1. Montant de la marque de statut
drapeau booléen volatile = faux ;
pendant que(!flag){
faire quelque chose();
}
public void setFlag() {
drapeau = vrai ;
}
volatile boolean inited = false;
//Thème 1 :
contexte = loadContext();
initié = vrai ;
//Thème 2 :
pendant que(!initié){
dormir()
}
faire quelque choseavecconfig(context);
2.double vérification
classe Singleton{
instance Singleton statique volatile privée = null ;
Singleton privé() {
}
public statique Singleton getInstance() {
si(instance==null) {
synchronisé (Singleton.class) {
si(instance==null)
instance = new Singleton();
}
}
instance de retour ;
}
}
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!