Maison  >  Article  >  Java  >  [Lutte contre la concurrence Java]-----Analyse approfondie du principe de mise en œuvre de volatile

[Lutte contre la concurrence Java]-----Analyse approfondie du principe de mise en œuvre de volatile

黄舟
黄舟original
2017-02-24 10:01:151141parcourir

Dans le chapitre précédent, nous avons appris que synchronisé est un verrou lourd Bien que la JVM ait fait de nombreuses optimisations pour celui-ci, le volatile présenté ci-dessous est un verrou synchronisé léger. Si une variable est volatile, elle est moins coûteuse que son utilisation synchronisée car elle ne provoque pas de changement de contexte de thread ni de planification. La spécification du langage Java définit volatile comme suit :

Le langage de programmation Java permet aux threads d'accéder aux variables partagées. Afin de garantir que les variables partagées peuvent être mises à jour avec précision et cohérence, les threads doivent garantir que. ils sont acquis individuellement via des verrous exclusifs sur cette variable.

Ce qui précède est un peu alambiqué. En termes simples, si une variable est modifiée avec volatile, Java peut garantir que tous les threads voient que la valeur de cette variable est cohérente si un thread modifie volatile If. la variable partagée est mise à jour, les autres threads peuvent voir la mise à jour immédiatement. C'est ce qu'on appelle la visibilité des threads.

Bien que volatile semble relativement simple, son utilisation n'est rien de plus que d'ajouter volatile devant une variable, mais il n'est pas facile de bien l'utiliser (LZ admet que je l'utilise encore mal et que je suis toujours ambigu lors de l'utilisation il) ).

Concepts liés au modèle de mémoire

Comprendre volatile est en fait un peu difficile. Cela est lié au modèle de mémoire Java, donc avant de comprendre volatile, nous devons comprendre les concepts du modèle de mémoire Java, ici. juste une introduction préliminaire, et LZ présentera le modèle de mémoire Java en détail plus tard.

Sémantique du système d'exploitation

Lorsque l'ordinateur exécute un programme, chaque instruction est exécutée dans le CPU, et la lecture et l'écriture des données seront inévitablement impliquées pendant le processus d'exécution. Nous savons que les données pour l'exécution du programme sont stockées dans la mémoire principale. À ce stade, la lecture et l'écriture des données dans la mémoire principale ne sont pas aussi rapides que l'exécution des instructions dans le processeur. la mémoire principale, cela sera grandement affecté. Affecte l'efficacité, il y a donc un cache CPU. Le cache du processeur est unique à un processeur et ne concerne que le thread exécuté sur ce processeur.

Bien que le cache CPU résout le problème d'efficacité, il apportera un nouveau problème : la cohérence des données. Lorsque le programme est en cours d'exécution, les données nécessaires au fonctionnement seront copiées dans le cache du processeur. Lors de l'exécution des opérations, le processeur ne gère plus la mémoire principale, mais lit et écrit directement les données du cache uniquement lorsque l'opération est terminée. le CPU va Les données seront vidées dans la mémoire principale. Donnez un exemple simple :

i++

Lorsque le thread exécute ce code, il lira d'abord i (i = 1) dans la mémoire principale, puis copiera une copie dans le cache du CPU, puis le CPU Perform l'opération 1 (2), puis écrire les données (2) dans le cache, et enfin les rafraîchir dans la mémoire principale. En fait, il n'y a aucun problème à faire cela en monothread, mais le problème est en multithread. Comme suit :

S'il y a deux threads A et B effectuant tous deux cette opération (i), selon notre pensée logique normale, la valeur i dans la mémoire principale devrait être = 3, mais est-ce le cas ? L'analyse est la suivante :

Deux threads lisent la valeur de i (1) depuis la mémoire principale dans leurs caches respectifs, puis le thread A effectue la 1 opération et écrit le résultat dans le cache, et enfin l'écrit à la mémoire principale Dans la mémoire, à ce moment, la mémoire principale i==2, le thread B fait la même opération, et le i dans la mémoire principale est toujours =2. Le résultat final est donc 2 et non 3. Ce phénomène est un problème de cohérence du cache.

Il existe deux solutions pour mettre en cache la cohérence :

  1. En ajoutant LOCK# au bus

  2. En mettant en cache le protocole de cohérence

Mais il y a un problème avec l'option 1. Elle est implémentée de manière exclusive, c'est-à-dire que si le bus est verrouillé avec LOCK#, un seul CPU peut l'exécuter. être bloqué et le rendement est relativement faible.

La deuxième option, le protocole de cohérence du cache (protocole MESI), qui garantit que la copie des variables partagées utilisées dans chaque cache est cohérente. L'idée principale est la suivante : lorsqu'un processeur écrit des données, s'il s'avère que la variable utilisée est une variable partagée, les autres processeurs seront informés que la ligne de cache de la variable n'est pas valide. Par conséquent, lorsque d'autres processeurs liront la variable. variable, ils constateront que la variable n’est pas valide. L’invalidation rechargera les données de la mémoire principale.
[Lutte contre la concurrence Java]-----Analyse approfondie du principe de mise en œuvre de volatile

Modèle de mémoire Java

Ce qui précède explique comment garantir la cohérence des données au niveau du système d'exploitation. Jetons un coup d'œil au modèle de mémoire Java et étudions le modèle de mémoire Java. un peu : quelles garanties fournissons-nous et quels méthodes et mécanismes sont fournis en Java pour nous permettre de garantir l'exactitude de l'exécution du programme lors de l'exécution d'une programmation multithread.

En programmation concurrente, on rencontre généralement ces trois concepts de base : atomicité, visibilité et ordre. Jetons un coup d'œil à volatile

Atomicité

Atomicité : c'est-à-dire qu'une ou plusieurs opérations sont soit toutes exécutées et le processus d'exécution ne sera interrompu par aucun facteur, soit elles sont tous non exécutés.

L'atomicité est comme les transactions dans la base de données. Ils forment une équipe, vivant et mourant ensemble. En fait, comprendre l’atomicité est très simple. Regardons l’exemple simple suivant :

i = 0;            ---1
j = i ;            ---2
i++;            ---3
i = j + 1;    ---4

上面四个操作,有哪个几个是原子操作,那几个不是?如果不是很理解,可能会认为都是原子性操作,其实只有1才是原子操作,其余均不是。

1—在Java中,对基本数据类型的变量和赋值操作都是原子性操作;
2—包含了两个操作:读取i,将i值赋值给j
3—包含了三个操作:读取i值、i + 1 、将+1结果赋值给i;
4—同三一样

在单线程环境下我们可以认为整个步骤都是原子性操作,但是在多线程环境下则不同,Java只保证了基本数据类型的变量和赋值操作才是原子性的(注:在32位的JDK环境下,对64位数据的读取不是原子性操作*,如long、double)。要想在多线程环境下保证原子性,则可以通过锁、synchronized来确保。

volatile是无法保证复合操作的原子性

可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

在上面已经分析了,在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。

Java提供了volatile来保证可见性。

当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,当其他线程读取共享变量时,它会直接从主内存中读取。
当然,synchronize和锁都可以保证可见性。

有序性

有序性:即程序执行的顺序按照代码的先后顺序执行。

在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序它不会影响单线程的运行结果,但是对多线程会有影响。

Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。这里LZ就不再阐述了。

剖析volatile原理

JMM比较庞大,不是上面一点点就能够阐述的。上面简单地介绍都是为了volatile做铺垫的。

volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。

上面那段话,有两层语义

  1. 保证可见性、不保证原子性

  2. 禁止指令重排序

第一层语义就不做介绍了,下面重点介绍指令重排序。

在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序:

  1. 编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;

  2. 处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;

指令重排序对单线程没有什么影响,他不会影响程序的运行结果,但是会影响多线程的正确性。既然指令重排序会影响到多线程执行的正确性,那么我们就需要禁止重排序。那么JVM是如何禁止重排序的呢?这个问题稍后回答,我们先看另一个原则happens-before,happen-before原则保证了程序的“有序性”,它规定如果两个操作的执行顺序无法从happens-before原则中推到出来,那么他们就不能保证有序性,可以随意进行重排序。其定义如下:

  1. 同一个线程中的,前面的操作 happen-before 后续的操作。(即单线程内按代码顺序执行。但是,在不影响在单线程环境执行结果的前提下,编译器和处理器可以进行重排序,这是合法的。换句话说,这一是规则无法保证编译重排和指令重排)。

  2. 监视器上的解锁操作 happen-before 其后续的加锁操作。(Synchronized 规则)

  3. 对volatile变量的写操作 happen-before 后续的读操作。(volatile 规则)

  4. 线程的start() 方法 happen-before 该线程所有的后续操作。(线程启动规则)

  5. 线程所有的操作 happen-before 其他线程在该线程上调用 join 返回成功后的操作。

  6. 如果 a happen-before b,b happen-before c,则a happen-before c(传递性)。

我们着重看第三点volatile规则:对volatile变量的写操作 happen-before 后续的读操作。为了实现volatile内存语义,JMM会重排序,其规则如下:

对happen-before原则有了稍微的了解,我们再来回答这个问题JVM是如何禁止重排序的?

[Lutte contre la concurrence Java]-----Analyse approfondie du principe de mise en œuvre de volatile

En observant le code assembleur généré lorsque le mot-clé volatile est ajouté et non ajouté, on constate que lorsque le mot-clé volatile est ajouté, il y aura une instruction de préfixe de verrouillage supplémentaire . L’instruction de préfixe de verrouillage équivaut en réalité à une barrière mémoire. Une barrière mémoire est un ensemble d'instructions de traitement utilisées pour implémenter des restrictions séquentielles sur les opérations en mémoire. La couche inférieure de volatile est implémentée via des barrières de mémoire. La figure suivante montre les barrières de mémoire nécessaires pour suivre les règles ci-dessus :

Arrêtons l'analyse du volatile pour l'instant. Le système JMM est relativement volumineux et ne peut pas être expliqué clairement en quelques mots. Plus tard, nous combinerons. JMM avec une autre analyse approfondie de volatile.

[Lutte contre la concurrence Java]-----Analyse approfondie du principe de mise en œuvre de volatile

Résumé

Volatile semble simple, mais il est encore difficile de le comprendre. En voici juste une compréhension de base. Volatile est légèrement plus léger que synchronisé. Il peut remplacer synchronisé dans certaines situations, mais il ne peut pas remplacer complètement synchronisé. Il ne peut être utilisé que dans certaines situations. Pour l'utiliser, les deux conditions suivantes doivent être remplies :

  1. L'opération d'écriture de la variable ne dépend pas de la valeur actuelle ; La variable n'est incluse dans aucune autre dans l'invariant de la variable.

  2. volatile est souvent utilisé dans deux scénarios : marque d'état deux, double vérification
Références

Zhou Zhiming : « Compréhension approfondie de la machine virtuelle Java »

  1. Fang Tengfei : « L'art de la programmation simultanée Java »

  2. Java Programmation simultanée : Analyse des mots clés volatiles

  3. Programmation simultanée Java : L'utilisation et le principe de volatile

  4. À travers le chapitre précédent, nous avons appris que synchronisé est un verrou de niveau de poids, bien que la JVM ait fait de nombreuses optimisations pour cela, et que le volatile introduit ci-dessous soit léger et synchronisé. Si une variable est volatile, elle est moins coûteuse que son utilisation synchronisée car elle ne provoque pas de changement de contexte de thread ni de planification. La spécification du langage Java définit volatile comme suit :

Le langage de programmation Java permet aux threads d'accéder aux variables partagées. Afin de garantir que les variables partagées peuvent être mises à jour avec précision et cohérence, les threads doivent s'assurer que cette variable est. obtenu individuellement grâce à une serrure exclusive.

Ce qui précède est un peu alambiqué. En termes simples, si une variable est modifiée avec volatile, Java peut garantir que tous les threads voient que la valeur de cette variable est cohérente si un thread modifie volatile If. la variable partagée est mise à jour, les autres threads peuvent voir la mise à jour immédiatement. C'est ce qu'on appelle la visibilité des threads.

Bien que volatile semble relativement simple, son utilisation n'est rien de plus que d'ajouter volatile devant une variable, mais il n'est pas facile de bien l'utiliser (LZ admet que je l'utilise encore mal et que je suis toujours ambigu lors de l'utilisation il) ).

Concepts liés au modèle de mémoire

Comprendre volatile est en fait un peu difficile. Cela est lié au modèle de mémoire Java, donc avant de comprendre volatile, nous devons comprendre les concepts du modèle de mémoire Java, ici. juste une introduction préliminaire, et LZ présentera le modèle de mémoire Java en détail plus tard.

Sémantique du système d'exploitation

Lorsque l'ordinateur exécute un programme, chaque instruction est exécutée dans le CPU, et la lecture et l'écriture des données seront inévitablement impliquées pendant le processus d'exécution. Nous savons que les données pour l'exécution du programme sont stockées dans la mémoire principale. À ce stade, la lecture et l'écriture des données dans la mémoire principale ne sont pas aussi rapides que l'exécution des instructions dans le processeur. la mémoire principale, cela sera grandement affecté. Affecte l'efficacité, il y a donc un cache CPU. Le cache du processeur est unique à un processeur et ne concerne que le thread exécuté sur ce processeur.

Bien que le cache CPU résout le problème d'efficacité, il apportera un nouveau problème : la cohérence des données. Lorsque le programme est en cours d'exécution, les données nécessaires au fonctionnement seront copiées dans le cache du processeur. Lors de l'exécution des opérations, le processeur ne gère plus la mémoire principale, mais lit et écrit directement les données du cache uniquement lorsque l'opération est terminée. le CPU va Les données seront vidées dans la mémoire principale. Donnez un exemple simple :

Lorsque le thread exécute ce code, il lira d'abord i (i = 1) dans la mémoire principale, puis copiera une copie dans le cache du CPU, puis le CPU Perform l'opération 1 (2), puis écrire les données (2) dans le cache, et enfin les rafraîchir dans la mémoire principale. En fait, il n'y a aucun problème à faire cela en monothread, mais le problème est en multithread. Comme suit :

S'il y a deux threads A et B effectuant tous deux cette opération (i), selon notre pensée logique normale, la valeur i dans la mémoire principale devrait être = 3, mais est-ce le cas ? L'analyse est la suivante :
i++

Deux threads lisent la valeur de i (1) depuis la mémoire principale dans leurs caches respectifs, puis le thread A effectue la 1 opération et écrit le résultat dans le cache, et enfin l'écrit à la mémoire principale Dans la mémoire, à ce moment, la mémoire principale i==2, le thread B fait la même opération, et le i dans la mémoire principale est toujours =2. Le résultat final est donc 2 et non 3. Ce phénomène est un problème de cohérence du cache.

Il existe deux solutions pour mettre en cache la cohérence :

En ajoutant LOCK# au bus

  • 通过缓存一致性协议

  • 但是方案1存在一个问题,它是采用一种独占的方式来实现的,即总线加LOCK#锁的话,只能有一个CPU能够运行,其他CPU都得阻塞,效率较为低下。

    第二种方案,缓存一致性协议(MESI协议)它确保每个缓存中使用的共享变量的副本是一致的。其核心思想如下:当某个CPU在写数据时,如果发现操作的变量是共享变量,则会通知其他CPU告知该变量的缓存行是无效的,因此其他CPU在读取该变量时,发现其无效会重新从主存中加载数据。
    [Lutte contre la concurrence Java]-----Analyse approfondie du principe de mise en œuvre de volatile

    Java内存模型

    上面从操作系统层次阐述了如何保证数据一致性,下面我们来看一下Java内存模型,稍微研究一下Java内存模型为我们提供了哪些保证以及在Java中提供了哪些方法和机制来让我们在进行多线程编程时能够保证程序执行的正确性。

    在并发编程中我们一般都会遇到这三个基本概念:原子性、可见性、有序性。我们稍微看下volatile

    原子性

    原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

    原子性就像数据库里面的事务一样,他们是一个团队,同生共死。其实理解原子性非常简单,我们看下面一个简单的例子即可:

    i = 0;            ---1
    j = i ;            ---2
    i++;            ---3
    i = j + 1;    ---4

    上面四个操作,有哪个几个是原子操作,那几个不是?如果不是很理解,可能会认为都是原子性操作,其实只有1才是原子操作,其余均不是。

    1—在Java中,对基本数据类型的变量和赋值操作都是原子性操作;
    2—包含了两个操作:读取i,将i值赋值给j
    3—包含了三个操作:读取i值、i + 1 、将+1结果赋值给i;
    4—同三一样

    在单线程环境下我们可以认为整个步骤都是原子性操作,但是在多线程环境下则不同,Java只保证了基本数据类型的变量和赋值操作才是原子性的(注:在32位的JDK环境下,对64位数据的读取不是原子性操作*,如long、double)。要想在多线程环境下保证原子性,则可以通过锁、synchronized来确保。

    volatile是无法保证复合操作的原子性

    可见性

    可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

    在上面已经分析了,在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。

    Java提供了volatile来保证可见性。

    当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,当其他线程读取共享变量时,它会直接从主内存中读取。
    当然,synchronize和锁都可以保证可见性。

    有序性

    有序性:即程序执行的顺序按照代码的先后顺序执行。

    在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序它不会影响单线程的运行结果,但是对多线程会有影响。

    Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。这里LZ就不再阐述了。

    剖析volatile原理

    JMM比较庞大,不是上面一点点就能够阐述的。上面简单地介绍都是为了volatile做铺垫的。

    volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。

    上面那段话,有两层语义

    1. 保证可见性、不保证原子性

    2. 禁止指令重排序

    第一层语义就不做介绍了,下面重点介绍指令重排序。

    在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序:

    1. 编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;

    2. 处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;

    La réorganisation des instructions n'a aucun impact sur les threads uniques. Cela n'affectera pas les résultats d'exécution du programme, mais cela affectera l'exactitude des multi-threads. Étant donné que la réorganisation des instructions affectera l'exactitude de l'exécution multithread, nous devons interdire la réorganisation. Alors, comment la JVM interdit-elle la réorganisation ? Nous répondrons à cette question plus tard. Examinons d'abord un autre principe, celui qui se produit avant. Le principe se produit avant garantit « l'ordre » du programme. Il stipule que si l'ordre d'exécution de deux opérations ne peut pas être déduit de ce qui se passe avant. principe, ils ne peuvent alors pas garantir l'ordre et peuvent être réorganisés à volonté. Il est défini comme suit :

    1. Dans le même fil, l'opération précédente se produit avant l'opération suivante. (C'est-à-dire que le code est exécuté dans l'ordre au sein d'un seul thread. Cependant, le compilateur et le processeur peuvent réorganiser sans affecter les résultats de l'exécution dans un environnement monothread, ce qui est légal. En d'autres termes, cette règle ne peut pas garantir le réarrangement de la compilation et réarrangement des instructions).

    2. L'opération de déverrouillage sur le moniteur se produit avant son opération de verrouillage ultérieure. (Règles synchronisées)

    3. Les opérations d'écriture sur les variables volatiles se produisent avant les opérations de lecture ultérieures. (règle volatile)

    4. La méthode start() du thread se produit avant toutes les opérations ultérieures du thread. (Règles de démarrage du fil)

    5. Toutes les opérations d'un fil se produisent avant que d'autres fils n'appellent join sur ce fil et reviennent avec succès.

    6. Si a arrive avant b, b arrive avant c, alors a arrive avant c (transitif).

    Concentrons-nous sur la troisième règle volatile : les opérations d'écriture sur les variables volatiles ont lieu avant les opérations de lecture ultérieures. Afin d'obtenir une sémantique de mémoire volatile, JMM va réorganiser, et les règles sont les suivantes :

    Maintenant que nous avons un peu compris le principe de l'occurrence avant, répondons à cette question : Comment la JVM interdit-elle la réorganisation ?

    [Lutte contre la concurrence Java]-----Analyse approfondie du principe de mise en œuvre de volatile

    En observant le code assembleur généré lors de l'ajout du mot-clé volatile et sans le mot-clé volatile, on constate que lorsque le mot-clé volatile est ajouté, il y aura un instruction de préfixe de verrouillage supplémentaire . L’instruction de préfixe de verrouillage équivaut en réalité à une barrière mémoire. Une barrière mémoire est un ensemble d'instructions de traitement utilisées pour implémenter des restrictions séquentielles sur les opérations en mémoire. La couche inférieure de volatile est implémentée via des barrières de mémoire. La figure suivante montre les barrières de mémoire nécessaires pour suivre les règles ci-dessus :

    Arrêtons l'analyse du volatile pour l'instant. Le système JMM est relativement volumineux et ne peut pas être expliqué clairement en quelques mots. Plus tard, nous combinerons. JMM avec une autre analyse approfondie de volatile.

    [Lutte contre la concurrence Java]-----Analyse approfondie du principe de mise en œuvre de volatile

    Résumé

    Volatile semble simple, mais il est encore difficile de le comprendre. En voici juste une compréhension de base. Volatile est légèrement plus léger que synchronisé. Il peut remplacer synchronisé dans certaines situations, mais il ne peut pas remplacer complètement synchronisé. Il ne peut être utilisé que dans certaines situations. Pour l'utiliser, les deux conditions suivantes doivent être remplies :

    1. L'opération d'écriture de la variable ne dépend pas de la valeur actuelle ; La variable n'est incluse dans aucune autre dans l'invariant de la variable.

    2. volatile est souvent utilisé dans deux scénarios : marque d'état deux, double vérification
    Ce qui précède est [Concurrency Java morte]-- -- -Analyse approfondie des principes de mise en œuvre de volatile Pour plus de contenu connexe, veuillez faire attention au site Web PHP chinois (www.php.cn) !

    Déclaration:
    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