Maison  >  Article  >  Java  >  Analyse d'exemples d'applications volatiles des bases de Java

Analyse d'exemples d'applications volatiles des bases de Java

WBOY
WBOYavant
2023-05-28 11:23:391278parcourir

Analyse dexemples dapplications volatiles des bases de Java

Q : S'il vous plaît, parlez de votre compréhension de volatile ?
Réponse : volatile est un mécanisme de synchronisation léger fourni par la machine virtuelle Java Il possède 3 fonctionnalités :
1) Visibilité garantie
2)# 🎜🎜#. Aucune garantie d'atomicité 3)
Réarrangement des instructions interdit

Vous venez de finir d'apprendre les bases de Java, si quelqu'un vous demande ce qui est volatile ? S'il a une fonction, je pense que vous devez être très confus...

Peut-être avez-vous lu la réponse et ne comprenez pas du tout, quel est le mécanisme de synchronisation ? Qu'est-ce que la visibilité ? Qu’est-ce que l’atomicité ? Qu’est-ce que la réorganisation des instructions ?

1. Volatile garantit la visibilité

1.1.

Pour comprendre ce qu'est la visibilité, il faut d'abord comprendre JMM.

JMM (Java Memory Model, Java Memory Model) en lui-même est un concept abstrait et n'existe pas vraiment. Il décrit un ensemble de règles ou de spécifications. Grâce à cet ensemble de spécifications, les méthodes d'accès à diverses variables du programme sont déterminées. Règlements de JMM sur la synchronisation :

1) Avant qu'un thread ne soit déverrouillé, la valeur de la variable partagée doit être actualisée dans la mémoire principale
2) Avant que le thread ne soit verrouillé, la dernière valeur de la variable principale ; la mémoire doit être lue dans sa propre mémoire de travail ;
3) Le verrouillage et le déverrouillage sont le même verrou

Puisque l'entité du programme en cours d'exécution JVM est un thread, lorsque chaque thread est créé, JMM créera une mémoire de travail pour celui-ci (appelée à certains endroits espace de pile), la mémoire de travail est la zone de données privée de chaque thread.

Le modèle de mémoire Java stipule que toutes les variables sont stockées dans la mémoire principale. La mémoire principale est une zone de mémoire partagée accessible à tous les threads.

Mais les opérations du thread sur les variables (lecture, affectation, etc.) doivent être effectuées dans la mémoire de travail. Tout d’abord, vous devez copier les variables de la mémoire principale vers la mémoire de travail, effectuer des opérations, puis les réécrire dans la mémoire principale.

Après avoir lu l'introduction ci-dessus à JMM, je peux encore être confus quant à ses avantages. Ensuite, j'utiliserai un système de vente de billets comme exemple :

1) Comme indiqué. ci-dessous, les billets sont vendus à ce moment-là. Il ne reste qu'un seul ticket dans le backend du système, qui a été lu dans la mémoire principale : ticketNum=1.

2) À l'heure actuelle, plusieurs utilisateurs sur le réseau récupèrent des billets, il y a donc plusieurs threads qui exécutent des services d'achat de billets en même temps. Supposons que 3 threads aient lu le numéro actuel. de votes à ce moment : ticketNum=1, alors le ticket sera acheté.
3) Supposons que le thread 1 s'empare d'abord des ressources CPU, achète d'abord le ticket et change la valeur de ticketNum à 0 dans sa propre mémoire de travail : ticketNum=0, puis le réécrit dans la mémoire principale.

À ce moment, l'utilisateur du fil 1 a déjà acheté le ticket, donc le fil 2 et le fil 3 ne devraient pas pouvoir continuer à acheter des billets pour le moment, le système doit donc en informer le fil 2, thread 3, ticketNum à ce moment Déjà égal à 0 : ticketNum=0. S'il existe une telle opération de notification, vous pouvez la comprendre comme ayant une visibilité.

Analyse dexemples dapplications volatiles des bases de Java

Grâce à l'introduction ci-dessus et aux exemples de JMM, nous pouvons le résumer brièvement.

La visibilité du modèle de mémoire JMM signifie que lorsque plusieurs threads accèdent à une ressource dans la mémoire principale, si un thread modifie la ressource dans sa propre mémoire de travail et la réécrit dans la mémoire principale, alors le Le modèle de mémoire JMM doit demander aux autres threads de réobtenir les dernières ressources afin de garantir la visibilité des dernières ressources.

1.2, vérification du code de la visibilité volatile garantie

Dans la section 1.1, nous avons essentiellement compris la définition de la visibilité, et maintenant nous pouvons utiliser du code pour vérifier la définition. La pratique a prouvé que l'utilisation de volatile peut effectivement garantir la visibilité.

1.2.1. Aucune vérification du code de visibilité

Tout d'abord, vérifiez s'il n'y a pas de visibilité si volatile n'est pas utilisé.

package com.koping.test;import java.util.concurrent.TimeUnit;class MyData{
    int number = 0;

    public void add10() {
        this.number += 10;
    }}public class VolatileVisibilityDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        // 启动一个线程修改myData的number,将number的值加10
        new Thread(
                () -> {
                    System.out.println("线程" + Thread.currentThread().getName()+"\t 正在执行");
                    try{
                        TimeUnit.SECONDS.sleep(3);
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                    myData.add10();
                    System.out.println("线程" + Thread.currentThread().getName()+"\t 更新后,number的值为" + myData.number);
                }
        ).start();

        // 看一下主线程能否保持可见性
        while (myData.number == 0) {
            // 当上面的线程将number加10后,如果有可见性的话,那么就会跳出循环;
            // 如果没有可见性的话,就会一直在循环里执行
        }

        System.out.println("具有可见性!");
    }}

Le résultat en cours d'exécution est celui indiqué ci-dessous. Vous pouvez voir que bien que le thread 0 ait modifié la valeur du nombre en 10, le thread principal est toujours dans la boucle car le nombre n'est pas visible pour le moment et le système ne prendra pas l'initiative de notifier.


Analyse dexemples dapplications volatiles des bases de Java

1.2.1, vérification de la visibilité de la garantie volatile

Ajoutez volatile au numéro de variable à la ligne 7 du code ci-dessus et testez à nouveau, comme Comme suit la figure, le thread principal a réussi à quitter la boucle à ce moment-là, car JMM a activement informé le thread principal de mettre à jour la valeur de number et number n'est plus 0.


Analyse dexemples dapplications volatiles des bases de Java

2. Le volatile ne garantit pas l'atomicité

2.1 Qu'est-ce que l'atomicité ?

Après avoir compris la visibilité mentionnée ci-dessus, comprenons ce qu'est l'atomicité ?

Atomique fait référence à la caractéristique qui ne peut être divisée ou interrompue et qui maintient l'intégrité. En d’autres termes, lorsqu’un thread effectue une opération, il ne peut être interrompu par aucun facteur. Soit vous réussissez en même temps, soit vous échouez en même temps.

C’est encore un peu abstrait, donnons un exemple.

Comme indiqué ci-dessous, une classe pour tester l'atomicité est créée : TestPragma. Le code compilé montre que l'augmentation de n dans la méthode add s'effectue via trois instructions.

因此可能存在线程1正在执行第1个指令,紧接着线程2也正在执行第1个指令,这样当线程1和线程2都执行完3个指令之后,很容易理解,此时n的值只加了1,而实际是有2个线程加了2次,因此这种情况就是不保证原子性。
Analyse dexemples dapplications volatiles des bases de Java

2.2 不保证原子性的代码验证

在2.1中已经进行了举例,可能存在2个线程执行n++的操作,但是最终n的值却只加了1的情况,接下来对这种情况再用代码进行演示下。

首先给MyData类添加一个add方法

package com.koping.test;class MyData {
    volatile int number = 0;

    public void add() {
        number++;
    }}

然后创建测试原子性的类:TestPragmaDemo。验证number的值是否为20000,需要测试通过20个线程分别对其加1000次后的结果。

package com.koping.test;public class TestPragmaDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        // 启动20个线程,每个线程将myData的number值加1000次,那么理论上number值最终是20000
        for (int i=0; i<20; i++) {
            new Thread(() -> {
                for (int j=0; j<1000; j++) {
                    myData.add();
                }
            }).start();
        }

        // 程序运行时,模型会有主线程和守护线程。如果超过2个,那就说明上面的20个线程还有没执行完的,就需要等待
        while (Thread.activeCount()>2){
            Thread.yield();
        }

        System.out.println("number值加了20000次,此时number的实际值是:" + myData.number);

    }}

运行结果如下图,最终number的值仅为18410。
可以看到即使加了volatile,依然不保证有原子性。
Analyse dexemples dapplications volatiles des bases de Java

2.3 volatile不保证原子性的解决方法

上面介绍并证明了volatile不保证原子性,那如果希望保证原子性,怎么办呢?以下提供了2种方法

2.3.1 方法1:使用synchronized

方法1是在add方法上添加synchronized,这样每次只有1个线程能执行add方法。

结果如下图,最终确实可以使number的值为20000,保证了原子性。

但在实际业务逻辑方法中,很少只有一个类似于number++的单行代码,通常会包含其他n行代码逻辑。现在为了保证number的值是20000,就把整个方法都加锁了(其实另外那n行代码,完全可以由多线程同时执行的)。所以就优点杀鸡用牛刀,高射炮打蚊子,小题大做了。

package com.koping.test;class MyData {
    volatile int number = 0;

    public synchronized void add() {
      // 在n++上面可能还有n行代码进行逻辑处理
        number++;
    }}

Analyse dexemples dapplications volatiles des bases de Java

2.3.2 方法1:使用JUC包下的AtomicInteger

给MyData新曾一个原子整型类型的变量num,初始值为0。

package com.koping.test;import java.util.concurrent.atomic.AtomicInteger;class MyData {
    volatile int number = 0;

    volatile AtomicInteger num = new AtomicInteger();

    public void add() {
        // 在n++上面可能还有n行代码进行逻辑处理
        number++;
        num.getAndIncrement();
    }}

让num也同步加20000次。可以将原句重写为:使用原子整型num可以确保原子性,如下图所示:在执行number++时不会发生竞态条件。

package com.koping.test;public class TestPragmaDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        // 启动20个线程,每个线程将myData的number值加1000次,那么理论上number值最终是20000
        for (int i=0; i<20; i++) {
            new Thread(() -> {
                for (int j=0; j<1000; j++) {
                    myData.add();
                }
            }).start();
        }

        // 程序运行时,模型会有主线程和守护线程。如果超过2个,那就说明上面的20个线程还有没执行完的,就需要等待
        while (Thread.activeCount()>2){
            Thread.yield();
        }

        System.out.println("number值加了20000次,此时number的实际值是:" + myData.number);
        System.out.println("num值加了20000次,此时number的实际值是:" + myData.num);

    }}

Analyse dexemples dapplications volatiles des bases de Java

3、volatile禁止指令重排

3.1 什么是指令重排?

在第2节中理解了什么是原子性,现在要理解下什么是指令重排?

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排:
源代码–>编译器优化重排–>指令并行重排–>内存系统重排–>最终执行指令

处理器在进行重排时,必须要考虑指令之间的数据依赖性。

单线程环境中,可以确保最终执行结果和代码顺序执行的结果一致。

但是多线程环境中,线程交替执行,由于编译器优化重排的存在,两个线程使用的变量能否保持一致性是无法确定的,结果无法预测

看了上面的文字性表达,然后看一个很简单的例子。
比如下面的mySort方法,在系统指令重排后,可能存在以下3种语句的执行情况:
1)1234
2)2134
3)1324
以上这3种重排结果,对最后程序的结果都不会有影响,也考虑了指令之间的数据依赖性。

public void mySort() {
    int x = 1;  // 语句1
    int y = 2;  // 语句2
    x = x + 3;  // 语句3
    y = x * x;  // 语句4}

3.2 单线程单例模式

看完指令重排的简单介绍后,然后来看下单例模式的代码。

package com.koping.test;public class SingletonDemo {
    private static SingletonDemo instance = null;

    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t 执行构造方法SingletonDemo()");
    }

    public static SingletonDemo getInstance() {
        if (instance == null) {
            instance = new SingletonDemo();
        }
        return instance;
    }

    public static void main(String[] args) {
        // 单线程测试
        System.out.println("单线程的情况测试开始");
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println("单线程的情况测试结束\n");
    }}

首先是在单线程情况下进行测试,结果如下图。可以看到,构造方法只执行了一次,是没有问题的。
Analyse dexemples dapplications volatiles des bases de Java

3.3 多线程单例模式

接下来在多线程情况下进行测试,代码如下。

package com.koping.test;public class SingletonDemo {
    private static SingletonDemo instance = null;

    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t 执行构造方法SingletonDemo()");
    }

    public static SingletonDemo getInstance() {
        if (instance == null) {
            instance = new SingletonDemo();
        }

        // DCL(Double Check Lock双端检索机制)//        if (instance == null) {//            synchronized (SingletonDemo.class) {//                if (instance == null) {//                    instance = new SingletonDemo();//                }//            }//        }
        return instance;
    }

    public static void main(String[] args) {
        // 单线程测试//        System.out.println("单线程的情况测试开始");//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());//        System.out.println("单线程的情况测试结束\n");

        // 多线程测试
        System.out.println("多线程的情况测试开始");
        for (int i=1; i<=10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, String.valueOf(i)).start();
        }
    }}

在多线程情况下的运行结果如下图。可以看到,多线程情况下,出现了构造方法执行了2次的情况。
Analyse dexemples dapplications volatiles des bases de Java

3.4 多线程单例模式改进:DCL

在3.3中的多线程单里模式下,构造方法执行了两次,因此需要进行改进,这里使用双端检锁机制:Double Check Lock, DCL。即加锁之前和之后都进行检查。

package com.koping.test;public class SingletonDemo {
    private static SingletonDemo instance = null;

    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t 执行构造方法SingletonDemo()");
    }

    public static SingletonDemo getInstance() {//        if (instance == null) {//            instance = new SingletonDemo();//        }

        // DCL(Double Check Lock双端检锁机制)
        if (instance == null) {  // a行
            synchronized (SingletonDemo.class) {
                if (instance == null) {  // b行
                    instance = new SingletonDemo();  // c行
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        // 单线程测试//        System.out.println("单线程的情况测试开始");//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());//        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());//        System.out.println("单线程的情况测试结束\n");

        // 多线程测试
        System.out.println("多线程的情况测试开始");
        for (int i=1; i<=10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, String.valueOf(i)).start();
        }
    }}

在多次运行后,可以看到,在多线程情况下,此时构造方法也只执行1次了。
Analyse dexemples dapplications volatiles des bases de Java

3.5 Améliorations du mode singleton multi-thread, problèmes avec la version DCL

A noter que le mode singleton de la version DCL en 3.4 n'est toujours pas précis à 100% ! ! !

Vous ne comprenez pas très bien pourquoi la version 3.4DCL du mode singleton n'est pas précise à 100 % ?
Vous ne comprenez pas très bien pourquoi nous devons soudainement parler du mode singleton multithread après avoir terminé la simple compréhension du réarrangement des instructions dans la version 3.1 ?

Étant donné que la version 3.4DCL du mode singleton peut causer des problèmes dus au réarrangement des instructions, bien que la possibilité de ce problème puisse être d'une sur dix millions, le code n'est toujours pas précis à 100 %. Si vous souhaitez garantir une précision à 100 %, vous devez ajouter le mot-clé volatile. L'ajout de volatile peut interdire le réarrangement des commandes.

Ensuite, analysons pourquoi la version 3.4DCL du mode singleton n'est pas précise à 100 % ?

View instance = new SingletonDemo(); Les instructions compilées peuvent être divisées en trois étapes suivantes :
1) Allouer de l'espace mémoire à l'objet : memory = allocate();
2) Initialiser l'objet : instance (memory); ) Définissez l'instance pour qu'elle pointe vers l'adresse mémoire allouée : instance = mémoire ;

Comme il n'y a pas de dépendance de données entre les étapes 2 et 3, l'étape 132 peut être exécutée.

Par exemple, le thread 1 a exécuté l'étape 13 mais n'a pas exécuté l'étape 2. À ce moment, instance!=null, mais l'objet n'a pas encore été initialisé
Si le thread 2 s'empare du CPU à ce moment, il trouve cette instance ; !=null, puis revient à use directement, vous constaterez que cette instance est vide et une exception se produira.

Il s'agit d'un problème qui peut être causé par un réarrangement des instructions. Par conséquent, si vous voulez vous assurer que le programme est correct à 100 %, vous devez ajouter du volatile pour interdire le réarrangement des instructions.

3.6 Le principe des garanties volatiles pour interdire le réarrangement des instructions

En 3.1, nous avons brièvement introduit la signification du réarrangement d'exécution, puis à travers 3.2-3.5, nous avons utilisé le mode singleton pour illustrer les raisons pour lesquelles volatile doit être utilisé dans les multi- situations threadées, car il peut y avoir un réarrangement des instructions qui provoque des exceptions de programme.

Ensuite, nous introduirons le principe du volatile pour garantir que la réorganisation des instructions est interdite.

Nous devons d’abord comprendre un concept : la barrière mémoire, également connue sous le nom de barrière mémoire. C'est une instruction CPU qui a deux fonctions :

1) Garantir l'ordre d'exécution d'opérations spécifiques ;
2) Garantir la visibilité mémoire de certaines variables

Puisque le compilateur et le processeur peuvent effectuer un réarrangement des instructions ; Si une barrière de mémoire est insérée entre les instructions, elle indiquera au compilateur et au processeur qu'aucune instruction ne peut être réorganisée avec cette instruction de barrière de mémoire. En d'autres termes, en insérant une barrière de mémoire, il est interdit aux instructions avant et après la barrière de mémoire d'être réorganisées. -exécuté. Optimisation des besoins de planification

. Une autre fonction de la barrière mémoire est de forcer le vidage des données du cache de différents processeurs, afin que n'importe quel thread sur le processeur puisse lire la dernière version de ces données

.

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!

Déclaration:
Cet article est reproduit dans:. en cas de violation, veuillez contacter admin@php.cn Supprimer