Maison > Java > javaDidacticiel > Analysez l'exemple de code d'implémentation du mot clé volatile Java à partir de la racine (image)

Analysez l'exemple de code d'implémentation du mot clé volatile Java à partir de la racine (image)

黄舟
Libérer: 2017-03-22 10:47:23
original
1395 Les gens l'ont consulté

1. AnalyseAperçu

  1. Concepts associés au modèle de mémoire

  2. Trois concepts en programmation concurrente

  3. Modèle de mémoire Java

  4. Analyse approfondie duVolatilemot-clé

  5. Scénarios d'utilisation du mot-clé volatile

2. Concept

Problème de cohérence du cache. Les variables accessibles par plusieurs threads sont généralement appelées variables partagées. C'est-à-dire que si une variable est mise en cache dans plusieurs processeurs (cela se produit généralement uniquement dans la programmation multi-thread). alors il peut y avoir des problèmes d'incohérence du cache

Afin de résoudre le problème d'incohérence du cache, il existe généralement deux solutions :

    En ajoutant LOCK# au bus
  • Grâce au protocole de cohérence du cache
  • Ces deux méthodes sont au niveau matériel La méthode fournie ci-dessus
Il y a un problème. avec la méthode 1 ci-dessus, car les autres processeurs ne peuvent pas accéder à la mémoire pendant la période de verrouillage, ce qui entraîne une inefficacité

Le protocole de cohérence de cache le plus connu est le protocole MESI d'Intel qui garantit la copie. des variables partagées utilisées dans chaque cache est cohérente. Son idée principale est la suivante : lorsque le processeur écrit des données, si la variable exploitée s'avère être une variable partagée, elle sera supprimée sur les autres processeurs. Il existe également une copie de la variable. dans le CPU, qui enverra un signal pour avertir les autres CPU d'invalider la ligne de cache de la variable. Par conséquent, lorsque d'autres CPU ont besoin de lire cette variable, ils constatent que la ligne de cache mettant en cache la variable dans leur propre cache n'est pas valide. il va relire de mémoire

3. Trois concepts en programmation concurrente

En programmation concurrente, on le rencontre généralement Les trois problèmes suivants : l'atomicité. problème, problème de visibilité, problème d'ordre

3.1 Atomicité

Atomicité : un processus dans lequel une ou plusieurs opérations sont soit toutes exécutées et exécutées. Il ne sera interrompu par aucun facteur, ou il ne sera interrompu. ne sera pas exécuté du tout.

3.2 Visibilité

La visibilité signifie que lorsque plusieurs threads accèdent à la même variable, un thread modifie la valeur de la variable, les autres threads peuvent immédiatement voir la valeur modifiée <.>

3.3 Ordre

Ordre : c'est-à-dire que l'ordre d'exécution du programme est exécuté dans l'ordre du code, à en juger par la séquence de code, l'instruction 1 est avant l'instruction 2. Donc, quand la JVM est réellement. exécute ce code, garantira-t-il que l'instruction 1 sera exécutée avant l'instruction 2 ? Pas nécessairement. Pourquoi une duplication d'instructions peut-elle se produire ici ?

Ce qui suit explique 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 dans le programme est le même. le même que dans le code. L'ordre est cohérent, mais il 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.

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.

4. Modèle de mémoire Java

Dans la spécification de la machine virtuelle Java, une tentative est faite pour définir un modèle de mémoire Java (Java Memory

Model

, JMM) pour protéger divers plates-formes matérielles et opérations. Différences d’accès à la mémoire système pour obtenir des effets d’accès à la mémoire cohérents pour les programmes Java sur diverses 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.

4.1 AtomicitéEn Java, la lecture et l'affectation d'opérations à des variables du

type de données de base

sont des opérations atomiques, c'est-à-dire que ces opérations ne peuvent pas être interrompues, ni exécutées. ou non exécuté.

Veuillez analyser lesquelles des opérations suivantes sont des opérations atomiques :

x = 10 ; //Énoncé 1

    y = x; //Déclaration 2
  1. x; //Déclaration 3
  2. x = x 1;

En fait, seule l'instruction 1 est une opération atomique, et les trois autres instructions ne sont pas des opérations atomiques.

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.

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 grande gamme d'opérations, vous pouvez y parvenir via la synchronisation et le verrouillage. .

4.2 Visibilité

Pour la visibilité, Java fournit le mot-clé volatile pour assurer 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, la mémoire peut toujours être l'ancienne valeur d'origine. , la visibilité n'est donc 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 en même temps puis exécute le code de synchronisation, et la modification de la variable sera effectuée. vidé dans la mémoire principale avant de libérer le verrou. La visibilité est donc garantie.

4.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 d'exécution simultanée multithread.

En Java, vous pouvez utiliser le mot-clé volatile pour assurer un certain "ordre" (il peut interdire la réorganisation des instructions). 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 qui se produit 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é.

Ce qui suit est une introduction détaillée au principe qui se produit avant :

  1. Règles de séquence de programme : dans un fil, selon l'ordre du code, écrivez devant L'opération se produit avant l'opération écrite après

  2. Règle de verrouillage : Une opération de déverrouillage se produit avant la même opération de verrouillage après le même verrouillage

  3. volatile Règles de variable : Une opération d'écriture dans une variable se produit en premier avant une opération de lecture ultérieure sur cette variable

  4. Règle de propagation : Si l'opération A se produit avant l'opération B et que l'opération B se produit d'abord avant l'opération C, on peut conclure que l'opération A se produit avant l'opération C

  5. Règle de démarrage du thread : La méthode start() de l'objet Thread se produit ici en premier Chaque action du thread

  6. Règle d'interruption du 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

  7. Règle 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é via la méthode Thread.join() et la valeur de retour de Thread.isAlive().

  8. Règles de terminaison d'objet : L'initialisation d'un objet s'effectue en premier au début de sa méthode finalize()

Parmi ces 8 règles, Les 4 premières règles sont plus importantes, les 4 dernières règles sont évidentes.

Expliquons les 4 premières règles :

  1. Pour les règles d'ordre des programmes, je crois comprendre que l'exécution d'un morceau de code de programme ressemble à un seul thread. commande. Notez que bien que cette règle mentionne que « les opérations écrites au recto se produisent en premier 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 des modifications au 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.

  2. La deuxième règle est également plus facile à comprendre, c'est-à-dire que que ce soit dans un seul thread ou dans plusieurs threads, si le même verrou est dans un état verrouillé, le verrou doit l'être. mis à jour en premier. Une fois l'opération de libération effectuée, l'opération de verrouillage peut être poursuivie plus tard.

  3. La troisième règle est une règle 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.

  4. La quatrième règle reflète en fait la nature transitive du principe qui se produit avant.

5. Analyse approfondie du mot-clé volatile

5.1 Sémantique à deux niveaux du mot-clé volatile

Une fois une variable partagée (variable membre d'une classe, class Une fois la variable membre statique) 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 un thread modifie une certaine valeur d'une variable, la nouvelle valeur est immédiatement visible par les autres threads.

  2. La réorganisation des instructions est interdite.

Concernant la visibilité, regardons d'abord un morceau de code Si le thread 1 s'exécute en premier et que le thread 2 s'exécute plus tard :

//线程1
boolean stop = false;
while(!stop){
doSomething();
}

//线程2
stop = true;
Copier après la connexion

Ce code est très typique. morceau de code, de nombreuses personnes peuvent utiliser cette méthode de marquage lors de l'interruption d'un fil de discussion. 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 le thread d'être interrompu (bien que cette possibilité soit très faible, mais une fois que cela se produit, cela provoquera une boucle infinie).

Ce qui suit explique pourquoi ce code peut empêcher l'interruption du fil de discussion. 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 change la valeur de la variable stop, mais avant d'avoir le temps de l'écrire dans la mémoire principale, le thread 2 bascule pour faire autre chose, alors le thread 1 ne connaît pas le changement du stop variable par le thread 2, donc le cycle continuera.

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 vous utilisez le mot-clé volatile, lorsque le thread 2 effectue une modification, la ligne de cache de la variable de cache s'arrête dans la mémoire de travail du thread 1 sera invalide (si elle est reflété sur la couche matérielle, c'est-à-dire que la ligne de cache correspondante dans le cache L1 ou L2 du CPU n'est pas valide); la mémoire du thread 1 n'est pas valide, le thread 1 la lit à nouveau. La valeur de la variable stop sera lue dans la mémoire principale.

  • Puis lorsque le thread 2 modifie la valeur d'arrêt (bien sûr, cela comprend 2 opérations, modifier la valeur dans la mémoire de travail du thread 2, puis écrire la valeur modifiée dans la mémoire) , cela fera que la ligne de cache de la variable de cache arrêtée dans la mémoire de travail du thread 1 est invalide. Puis lorsque le thread 1 la lira, il constatera que sa ligne de cache est invalide. Il attendra l'adresse mémoire principale correspondant au cache. ligne à mettre à jour, puis lire la dernière valeur dans la mémoire principale correspondante.

    Alors ce que lit le fil 1 est la dernière valeur correcte.
5.2 Le volatile garantit-il l'atomicité ?

Volatile ne garantit pas l’atomicité. Regardons un exemple ci-dessous.

Tout le monde pense au 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.

Il y a un malentendu ici. Le mot-clé volatile peut assurer la visibilité, 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.

public class Test {
    public volatile int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}
Copier après la connexion
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 instant.

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é

Le thread 2 effectue ensuite une auto-incrémentation ; opération d'incrémentation sur la variable. Le thread 2 lit également la valeur originale de la variable inc. Puisque le thread 1 ne lit que la variable inc sans modifier la variable, cela n'invalidera pas la ligne de cache de la variable inc dans la mémoire de travail du thread 2. Ainsi, le thread 2 ira directement dans la mémoire principale pour lire la valeur de inc, constatera que la valeur de inc est 10, puis ajoutera 1, écrira 11 dans la mémoire de travail et enfin l'écrira dans la mémoire principale.

Ensuite, le thread 1 ajoute ensuite 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 de 10 à ce moment, donc le thread 1 ajoute 1 à inc. La valeur de la dernière augmentation est 11, puis 11 est écrit dans la mémoire de travail, et enfin dans la mémoire principale.

Ensuite, une fois que les deux threads ont chacun effectué une opération d'auto-incrémentation, inc n'augmente 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.

根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。

把上面的代码改成以下任何一种都可以达到效果:

采用synchronized:

public class Test {
    public  int inc = 0;

    public synchronized void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}
Copier après la connexion

采用Lock:

public class Test {
    public  int inc = 0;
    Lock lock = new ReentrantLock();

    public  void increase() {
        lock.lock();
        try {
            inc++;
        } finally{
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}
Copier après la connexion

采用AtomicInteger:

public class Test {
    public  AtomicInteger inc = new AtomicInteger();

    public  void increase() {
        inc.getAndIncrement();
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}
Copier après la connexion

在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。

5.3 volatile能保证有序性吗?

volatile能在一定程度上保证有序性。

volatile关键字禁止指令重排序有两层意思:

1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

举个例子:

//x、y为非volatile变量
//flag为volatile变量

x = 2;         //语句1
y = 0;         //语句2
flag = true;   //语句3
x = 4;         //语句4
y = -1;        //语句5
Copier après la connexion

由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。

并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

5.4 volatile的原理和实现机制

这里探讨一下volatile到底如何保证可见性和禁止指令重排序的。

下面这段话摘自《深入理解Java虚拟机》:

“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”

lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

  2. 它会强制将对缓存的修改操作立即写入主存;

  3. 如果是写操作,它会导致其他CPU中对应的缓存行无效。

6、使用volatile关键字的场景

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:

  1. 对变量的写操作不依赖于当前值(比如++操作,上面有例子)

  2. 该变量没有包含在具有其他变量的不变式中

实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

事实上,我的理解就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

下面列举几个Java中使用volatile的几个场景。

状态标记量

volatile boolean flag = false;

while(!flag){
    doSomething();
}

public void setFlag() {
    flag = true;
}
Copier après la connexion
volatile boolean inited = false;
//线程1:
context = loadContext();  
inited = true;            

//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
Copier après la connexion

double check

class Singleton{
    private volatile static Singleton instance = null;

    private Singleton() {

    }

    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}
Copier après la connexion

至于为何需要这么写请参考:


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!

Étiquettes associées:
source:php.cn
Déclaration de ce site Web
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn
Tutoriels populaires
Plus>
Derniers téléchargements
Plus>
effets Web
Code source du site Web
Matériel du site Web
Modèle frontal