Caractéristiques de volatile
Quand on déclare une variable partagée comme volatile, la lecture/écriture de cette variable va être très particulière. Un bon moyen de comprendre la nature de volatile est de considérer les lectures/écritures individuelles sur des variables volatiles comme la synchronisation de ces opérations de lecture/écriture individuelles à l'aide du même verrou de moniteur. Illustrons avec un exemple spécifique. Veuillez consulter l'exemple de code suivant :
class VolatileFeaturesExample { volatile long vl = 0L; //使用volatile声明64位的long型变量 public void set(long l) { vl = l; //单个volatile变量的写 } public void getAndIncrement () { vl++; //复合(多个)volatile变量的读/写 } public long get() { return vl; //单个volatile变量的读 } }
Supposons qu'il existe plusieurs threads appelant respectivement les trois méthodes du programme ci-dessus. Ce programme est sémantiquement équivalent au programme suivant :
class VolatileFeaturesExample { long vl = 0L; // 64位的long型普通变量 public synchronized void set(long l) { //对单个的普通 变量的写用同一个监视器同步 vl = l; } public void getAndIncrement () { //普通方法调用 long temp = get(); //调用已同步的读方法 temp += 1L; //普通写操作 set(temp); //调用已同步的写方法 } public synchronized long get() { //对单个的普通变量的读用同一个监视器同步 return vl; } }
Comme le montre l'exemple de programme ci-dessus, une seule opération de lecture/écriture sur une variable volatile utilise le même verrou de moniteur qu'une opération de lecture/écriture sur une variable ordinaire synchronisée. l'effet d'exécution entre eux est le même.
La règle du verrouillage du moniteur garantit la visibilité de la mémoire entre les deux threads qui libèrent le moniteur et acquièrent le moniteur. Cela signifie qu'une lecture d'une variable volatile peut toujours être vue (n'importe quel thread) en écriture finale. à cette variable volatile.
La sémantique des verrous du moniteur détermine que l'exécution du code de la section critique est atomique. Cela signifie que même pour les variables doubles et longues de 64 bits, tant qu'il s'agit d'une variable volatile, la lecture et l'écriture dans la variable seront atomiques. S'il existe plusieurs opérations volatiles ou opérations composites similaires à volatiles, ces opérations ne sont pas atomiques dans leur ensemble.
En bref, les variables volatiles elles-mêmes ont les propriétés suivantes :
Visibilité. Une lecture à partir d'une variable volatile verra toujours la dernière écriture (par n'importe quel thread) dans la variable volatile.
Atomicité : la lecture/écriture d'une variable volatile unique est atomique, mais les opérations composées comme volatile ne sont pas atomiques.
Ce qui se produit avant la relation établie par volatile write-read
Ce qui précède concerne les caractéristiques des variables volatiles elles-mêmes. Pour les programmeurs, volatile a un plus grand impact sur la visibilité de la mémoire des threads que volatile lui-même. . Les fonctionnalités sont plus importantes et nécessitent plus d’attention de notre part.
À partir de JSR-133, la lecture en écriture des variables volatiles permet la communication entre les threads.
Du point de vue de la sémantique de la mémoire, les verrous volatiles et de moniteur ont le même effet : l'écriture volatile et la libération du moniteur ont la même sémantique de mémoire ; la lecture volatile et l'acquisition de moniteur ont la même sémantique de mémoire.
Veuillez consulter l'exemple de code suivant utilisant des variables volatiles :
class VolatileExample { int a = 0; volatile boolean flag = false; public void writer() { a = 1; //1 flag = true; //2 } public void reader() { if (flag) { //3 int i = a; //4 …… } } }
Supposons qu'après que le thread A ait exécuté la méthodewriter(), le thread B exécute la méthode reader(). Selon la règle de l'ordre du programme, la relation qui se produit avant établie dans ce processus peut être divisée en deux catégories :
Selon la règle de l'ordre du programme, 1 se produit avant 2 ; 3 se produit avant 4 ;
Selon les règles volatiles, 2 arrive avant 3.
Selon la règle de transitivité de se produit avant, 1 se produit avant 4.
La représentation graphique de ce qui précède se produit avant la relation est la suivante :
Dans la figure ci-dessus, les deux nœuds liés par chaque flèche représentent un se produit avant relation. Les flèches noires représentent les règles d'ordre du programme ; les flèches orange représentent les règles volatiles ; et les flèches bleues représentent les garanties d'occurrence fournies par la combinaison de ces règles.
Après que le thread A ait écrit une variable volatile, le thread B lit la même variable volatile. Toutes les variables partagées visibles par le thread A avant d'écrire la variable volatile deviendront visibles par le thread B immédiatement après que le thread B ait lu la même variable volatile.
La sémantique mémoire de l'écriture-lecture volatile
La sémantique mémoire de l'écriture volatile est la suivante :
Lors de l'écriture d'une variable volatile, JMM actualisera la variable partagée dans la mémoire locale correspondante au thread vers la mémoire principale.
Prenons l'exemple de programme VolatileExample ci-dessus. Supposons que le thread A exécute d'abord la méthodewriter(), puis que le thread B exécute la méthode reader(). Initialement, l'indicateur et a dans la mémoire locale de. les deux threads sont dans l'état initial. La figure suivante est un diagramme schématique de l'état des variables partagées après que le thread A ait effectué une écriture volatile :
Comme le montre la figure ci-dessus, après que le thread A ait écrit la variable flag, la mémoire locale du thread A est Les valeurs des deux variables partagées mises à jour par A sont vidées dans la mémoire principale. A ce moment, les valeurs des variables partagées dans la mémoire locale A et la mémoire principale sont cohérentes.
La sémantique mémoire de la lecture volatile est la suivante :
Lors de la lecture d'une variable volatile, JMM invalidera la mémoire locale correspondant au thread. Le thread lira ensuite la variable partagée depuis la mémoire principale.
Ce qui suit est un diagramme schématique de l'état de la variable partagée après que le thread B ait lu la même variable volatile :
Comme le montre la figure ci-dessus, après avoir lu la variable flag, la mémoire locale B a été invalidée. À ce stade, le thread B doit lire la variable partagée dans la mémoire principale. L'opération de lecture du thread B rendra cohérentes les valeurs des variables partagées dans la mémoire locale B et la mémoire principale.
Si nous combinons les deux étapes d'écriture volatile et de lecture volatile, après que le thread de lecture B ait lu une variable volatile, la valeur de toutes les variables partagées visibles avant que le thread d'écriture A n'écrive la variable volatile sera Deviendra immédiatement visible à la lecture du fil B.
Ce qui suit est un résumé de la sémantique de la mémoire de l'écriture volatile et de la lecture volatile :
Le thread A écrit une variable volatile. Essentiellement, le thread A envoie un message à un thread qui lira ensuite cette variable volatile. . (sa modification de la variable partagée).
Le thread B lit une variable volatile. En substance, le thread B reçoit un message envoyé par un thread précédent (la variable partagée a été modifiée avant d'écrire cette variable volatile).
Le thread A écrit une variable volatile, puis le thread B lit la variable volatile. Ce processus est essentiellement le thread A qui envoie un message au thread B via la mémoire principale.
Implémentation de la sémantique de la mémoire volatile
Ensuite, examinons comment JMM implémente la sémantique de la mémoire volatile en écriture/lecture.
Nous avons mentionné plus tôt que la réorganisation est divisée en réorganisation du compilateur et réorganisation du processeur. Afin d'obtenir une sémantique de mémoire volatile, JMM limitera respectivement les types de réorganisation de ces deux types. Ce qui suit est un tableau des règles de réorganisation volatile formulées par JMM pour les compilateurs :
Peut-il être réorganisé
Deuxième opération
Première opération
Lecture/écriture normale
Lecture volatile
écriture volatile
Lecture/écriture normale
NON
lecture volatile
NON
NON
NON
écriture volatile
NON
NON
Exemple Par exemple , la signification de la dernière cellule de la troisième ligne est : dans la séquence du programme, lorsque la première opération est une lecture ou une écriture d'une variable ordinaire, et si la deuxième opération est une écriture volatile, le compilateur ne peut pas réordonner ces deux opérations. opérations.
Dans le tableau ci-dessus, nous pouvons voir :
Lorsque la deuxième opération est une écriture volatile, quelle que soit la première opération, elle ne peut pas être réorganisée. Cette règle garantit que les opérations avant une écriture volatile ne sont pas réorganisées par le compilateur après une écriture volatile.
Lorsque la première opération est une lecture volatile, quelle que soit la deuxième opération, elle ne peut pas être réorganisée. Cette règle garantit que les opérations après une lecture volatile ne seront pas réorganisées par le compilateur pour précéder une lecture volatile.
Lorsque la première opération est une écriture volatile et la deuxième opération est une lecture volatile, la réorganisation ne peut pas être effectuée.
Afin d'obtenir une sémantique de mémoire volatile, lorsque le compilateur génère du bytecode, il insérera des barrières de mémoire dans la séquence d'instructions pour interdire des types spécifiques de réorganisation du processeur. Il est presque impossible pour le compilateur de trouver un arrangement optimal minimisant le nombre total de barrières insérées, c'est pourquoi JMM adopte une stratégie conservatrice. Ce qui suit est une stratégie d'insertion de barrière de mémoire JMM basée sur une stratégie conservatrice :
Insérez une barrière StoreStore devant chaque opération d'écriture volatile.
Insérez une barrière StoreLoad après chaque opération d'écriture volatile.
Insérez une barrière LoadLoad après chaque opération de lecture volatile.
Insérez une barrière LoadStore après chaque opération de lecture volatile.
La stratégie d'insertion de barrière de mémoire ci-dessus est très conservatrice, mais elle peut garantir qu'une sémantique de mémoire volatile correcte peut être obtenue dans n'importe quel programme sur n'importe quelle plate-forme de processeur.
Ce qui suit est un diagramme schématique de la séquence d'instructions générée après l'insertion d'écritures volatiles dans la barrière mémoire selon la stratégie conservatrice :
La barrière StoreStore dans la figure ci-dessus peut garantir que les écritures volatiles sont auparavant, toutes les écritures normales qui la précédaient étaient déjà visibles par n'importe quel processeur. En effet, la barrière StoreStore garantira que toutes les écritures normales ci-dessus sont vidées dans la mémoire principale avant les écritures volatiles.
Ce qui est plus intéressant ici, c'est la barrière StoreLoad derrière l'écriture volatile. Le but de cette barrière est d'empêcher les écritures volatiles d'être réorganisées par des opérations de lecture/écriture volatiles ultérieures. Parce que le compilateur ne peut souvent pas déterminer avec précision si une barrière StoreLoad doit être insérée après une écriture volatile (par exemple, une méthode retourne immédiatement après une écriture volatile). Afin de garantir que la sémantique de la mémoire volatile peut être correctement implémentée, JMM adopte ici une stratégie conservatrice : insérer une barrière StoreLoad après chaque écriture volatile ou devant chaque lecture volatile. Du point de vue de l'efficacité globale de l'exécution, JMM a choisi d'insérer une barrière StoreLoad après chaque écriture volatile. Parce que le modèle d'utilisation courant de la sémantique de la mémoire volatile en écriture-lecture est le suivant : un thread d'écriture écrit une variable volatile et plusieurs threads de lecture lisent la même variable volatile. Lorsque le nombre de threads de lecture dépasse largement le nombre de threads d'écriture, choisir d'insérer une barrière StoreLoad après une écriture volatile apportera des améliorations considérables en termes d'efficacité d'exécution. De là, nous pouvons voir une caractéristique de la mise en œuvre de JMM : d'abord garantir l'exactitude, puis rechercher l'efficacité de l'exécution.
Ce qui suit est un diagramme schématique de la séquence d'instructions générée après l'insertion de lectures volatiles dans la barrière mémoire selon la stratégie conservatrice :
上图中的LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面我们通过具体的示例代码来说明:
class VolatileBarrierExample { int a; volatile int v1 = 1; volatile int v2 = 2; void readAndWrite() { int i = v1; //第一个volatile读 int j = v2; // 第二个volatile读 a = i + j; //普通写 v1 = i + 1; // 第一个volatile写 v2 = j * 2; //第二个 volatile写 } … //其他方法 }
针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化:
注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后,方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器常常会在这里插入一个StoreLoad屏障。
上面的优化是针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以x86处理器为例,上图中除最后的StoreLoad屏障外,其它的屏障都会被省略。
前面保守策略下的volatile读和写,在 x86处理器平台可以优化成:
前文提到过,x86处理器仅会对写-读操作做重排序。X86不会对读-读,读-写和写-写操作做重排序,因此在x86处理器中会省略掉这三种操作类型对应的内存屏障。在x86中,JMM仅需在volatile写后面插入一个StoreLoad屏障即可正确实现volatile写-读的内存语义。这意味着在x86处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。
JSR-133为什么要增强volatile的内存语义
在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量之间重排序。在旧的内存模型中,VolatileExample示例程序可能被重排序成下列时序来执行:
在旧的内存模型中,当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类似)。其结果就是:读线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改。
因此在旧的内存模型中 ,volatile的写-读没有监视器的释放-获所具有的内存语义。为了提供一种比监视器锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和监视器的释放-获取一样,具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要volatile变量与普通变量之间的重排序可能会破坏volatile的内存语意,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。
由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而监视器锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,监视器锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势。如果读者想在程序中用volatile代替监视器锁,请一定谨慎。
以上就是Java内存模型深度解析:volatile的内容,更多相关内容请关注PHP中文网(m.sbmmt.com)!