Maison  >  Article  >  Java  >  Une brève introduction à l'implémentation de la sécurité des threads dans Java et au principe de file d'attente CLH (exemple de code)

Une brève introduction à l'implémentation de la sécurité des threads dans Java et au principe de file d'attente CLH (exemple de code)

不言
不言original
2018-09-17 15:23:072039parcourir

Ce que cet article vous apporte est une brève introduction (exemple de code) sur l'implémentation de la sécurité des threads en Java et le principe de file d'attente CLH. Il a une certaine valeur de référence. Les amis dans le besoin peuvent s'y référer. . aide.

Synchronisation bloquante

En Java, nous utilisons souvent le mot-clé synchronisé pour obtenir une synchronisation mutuellement exclusive afin de résoudre les problèmes de concurrence multithread accéder aux données partagées. Une fois le mot-clé synchronzied compilé, deux instructions de bytecode, monitorenter et monitorexit, seront ajoutées avant et après le bloc de code de synchronisation contenu dans synchronisé. Le mot-clé synchronisé nécessite de spécifier un objet pour le verrouillage et le déverrouillage. Par exemple :

public class Main {

    private static final Object LOCK = new Object();
    
    public static void fun1() {
        synchronized (LOCK) {
            // do something
        }
    }
    
    public static void fun2() {
        synchronized (LOCK) {
            // do something
        }
    }
}

Lorsque l'objet n'est pas explicitement spécifié, le fait que la modification synchronisée soit une méthode d'instance ou une méthode statique détermine s'il faut utiliser l'instance d'objet ou l'instance de classe de la classe comme objet. Par exemple :

public class SynchronizedTest {
    public synchronized void doSomething() {
        //采用实例对象作为锁对象
    }
}
public class SynchronizedTest {
    public static synchronized void doSomething() {
        //采用SynchronizedTest.class 实例作为锁对象
    }
}

En raison du mutex de blocage implémenté sur la base de la synchronisation, en plus de bloquer le thread d'exploitation, il doit également réveiller ou bloquer le thread natif au niveau du système d'exploitation, ce qui nécessite de passer du mode utilisateur au mode noyau. Cette transition d'état peut prendre plus de temps que le temps d'exécution du code utilisateur, c'est pourquoi on dit souvent que synchronisé est un "verrou lourd" dans le langage Java.

Synchronisation non bloquante

Verrouillage optimiste et verrouillage pessimiste

Synchronisation à l'aide du mot-clé synchronisé Le principal problème de cette méthode est la consommation de performances causée par le blocage et le réveil des threads. Le blocage de la synchronisation est une stratégie de concurrence pessimiste. Tant qu'il existe une possibilité de concurrence, il estime que les verrous doivent être verrouillés.
Cependant, la stratégie de synchronisation a également une autre stratégie optimiste. La stratégie de concurrence optimiste fait progresser l'exploitation des données. Si aucun autre thread n'a exploité les données, alors l'opération est considérée comme réussie. Si d'autres threads exploitent également des données, réessayez généralement en continu jusqu'à succès. Cette stratégie de verrouillage optimiste ne nécessite pas de blocage de threads et constitue un moyen de synchronisation non bloquante.

CAS

La stratégie de concurrence optimiste comporte principalement deux étapes importantes, l'une consiste à opérer sur les données et l'autre à détecter les conflits, c'est-à-dire à détecter si d'autres threads ont également effectué des opérations sur ces données. Les opérations de données et la détection des conflits ici doivent être atomiques, sinon des problèmes similaires à i++ se produiront facilement.
CAS signifie comparer et échanger. À l'heure actuelle, la plupart des processeurs prennent en charge nativement les instructions atomiques CAS. Par exemple, dans les jeux d'instructions de IA64 et x86, il existe des instructions telles que cmpxchg pour compléter la fonction CAS. le niveau matériel.
L'instruction CAS nécessite généralement trois paramètres, à savoir l'adresse mémoire de la valeur, l'ancienne valeur attendue et la nouvelle valeur. Lorsque l'instruction CAS est exécutée, si la valeur à l'adresse mémoire correspond à l'ancienne valeur attendue, le processeur mettra à jour la valeur à l'adresse mémoire avec la nouvelle valeur, sinon elle ne sera pas mise à jour. Cette opération est garantie comme étant atomique au sein du CPU.
Il existe de nombreuses API liées au CAS en Java. Les plus courantes que nous avons sont diverses classes atomiques sous le package java.util.concurrent, telles que AtomicInteger, AtomicReference, etc.
Ces classes prennent toutes en charge les opérations CAS, et elles s'appuient en fait en interne sur les méthodes compareAndSwapInt() et compareAndSwapLong() dans sun.misc.Unsafe cette classe.
CAS n'est pas parfait Bien qu'il puisse garantir l'atomicité, il souffre du fameux problème ABA. La valeur d'une variable est A lorsqu'elle est lue pour la première fois, et elle est également A lorsqu'elle est relue. Peut-on expliquer que cette variable n'a pas changé entre les deux lectures ? ne peut pas. Pendant cette période, la variable peut changer de A à B, puis de B à A. Lors de la deuxième lecture, vous voyez A, mais en fait la variable a changé. La logique générale du code ne se souciera pas de ce problème ABA, car selon la logique du code, cela n'affectera pas la sécurité de la concurrence, mais si vous vous en souciez, vous pouvez envisager d'utiliser la synchronisation bloquante au lieu de CAS. En fait, le JDK lui-même fournit également une solution à ce problème ABA, en fournissant la classe AtomicStampedReference pour ajouter des versions aux variables afin de résoudre le problème ABA.

Spin lock

Synchronisation bloquante représentée par synchronisé, car le thread bloqué reprendra le fonctionnement du thread, ce qui doit impliquer le mode utilisateur et le mode noyau au niveau du système d'exploitation Basculer entre eux a un impact important sur les performances du système. La stratégie du spin lock est que lorsqu'un thread acquiert un verrou, s'il constate que le verrou est déjà occupé par un autre thread, il n'abandonne pas immédiatement la tranche de temps d'exécution du CPU, mais entre dans une boucle « dénuée de sens » pour voir si le fil Le verrou a été abandonné.
Mais le verrouillage de rotation convient à la section critique est relativement petite. Si le verrouillage est maintenu trop longtemps, l'opération de rotation elle-même gaspillera les performances du système.

Ce qui suit est une implémentation simple du verrouillage rotatif :

import java.util.concurrent.atomic.AtomicReference;
public class SpinLock {
   private AtomicReference<Thread> owner = new AtomicReference<Thread>();
   public void lock() {
       Thread currentThread = Thread.currentThread();
        // 如果锁未被占用,则设置当前线程为锁的拥有者
       while (!owner.compareAndSet(null, currentThread)) {}
   }

   public void unlock() {
       Thread currentThread = Thread.currentThread();
        // 只有锁的拥有者才能释放锁
       owner.compareAndSet(currentThread, null);
   }
}

上述的代码中, owner 变量保存获得了锁的线程。这里的自旋锁有一些缺点,第一个是没有保证公平性,等待获取锁的线程之间,无法按先后顺序分别获得锁;另一个,由于多个线程会去操作同一个变量 owner,在 CPU 的系统中,存在着各个 CPU 之间的缓存数据需要同步,保证一致性,这会带来性能问题。

公平的自旋

为了解决公平性问题,可以让每个锁拥有一个服务号,表示正在服务的线程,而每个线程尝试获取锁之前需要先获取一个排队号,然后不断轮询当前锁的服务号是否是自己的服务号,如果是,则表示获得了锁,否则就继续轮询。下面是一个简单的实现:

import java.util.concurrent.atomic.AtomicInteger;

public class TicketLock {
   private AtomicInteger serviceNum = new AtomicInteger(); // 服务号
   private AtomicInteger ticketNum = new AtomicInteger(); // 排队号

   public int lock() {
       // 首先原子性地获得一个排队号
       int myTicketNum = ticketNum.getAndIncrement();
       // 只要当前服务号不是自己的就不断轮询
       while (serviceNum.get() != myTicketNum) {
       }
       return myTicketNum;
    }

    public void unlock(int myTicket) {
        // 只有当前线程拥有者才能释放锁
        int next = myTicket + 1;
        serviceNum.compareAndSet(myTicket, next);
    }
}

虽然解决了公平性的问题,但依然存在前面说的多 CPU 缓存的同步问题,因为每个线程占用的 CPU 都在同时读写同一个变量 serviceNum,这会导致繁重的系统总线流量和内存操作次数,从而降低了系统整体的性能。

MCS 自旋锁

MCS 的名称来自其发明人的名字:John Mellor-Crummey和Michael Scott。
MCS 的实现是基于链表的,每个申请锁的线程都是链表上的一个节点,这些线程会一直轮询自己的本地变量,来知道它自己是否获得了锁。已经获得了锁的线程在释放锁的时候,负责通知其它线程,这样 CPU 之间缓存的同步操作就减少了很多,仅在线程通知另外一个线程的时候发生,降低了系统总线和内存的开销。实现如下所示:

import java.util.concurrent.atomic.AtomicReferenceFieldUpdater;
public class MCSLock {
    public static class MCSNode {
        volatile MCSNode next;
        volatile boolean isWaiting = true; // 默认是在等待锁
    }
    volatile MCSNode queue;// 指向最后一个申请锁的MCSNode
    private static final AtomicReferenceFieldUpdater<MCSLock, MCSNode> UPDATER = AtomicReferenceFieldUpdater
            .newUpdater(MCSLock.class, MCSNode.class, "queue");

    public void lock(MCSNode currentThread) {
        MCSNode predecessor = UPDATER.getAndSet(this, currentThread);// step 1
        if (predecessor != null) {
            predecessor.next = currentThread;// step 2
            while (currentThread.isWaiting) {// step 3
            }
        } else { // 只有一个线程在使用锁,没有前驱来通知它,所以得自己标记自己已获得锁
            currentThread.isWaiting = false;
        }
    }

    public void unlock(MCSNode currentThread) {
        if (currentThread.isWaiting) {// 锁拥有者进行释放锁才有意义
            return;
        }

        if (currentThread.next == null) {// 检查是否有人排在自己后面
            if (UPDATER.compareAndSet(this, currentThread, null)) {// step 4
                // compareAndSet返回true表示确实没有人排在自己后面
                return;
            } else {
                // 突然有人排在自己后面了,可能还不知道是谁,下面是等待后续者
                // 这里之所以要忙等是因为:step 1执行完后,step 2可能还没执行完
                while (currentThread.next == null) { // step 5
                }
            }
        }
        currentThread.next.isWaiting = false;
        currentThread.next = null;// for GC
    }
}

MCS 的能够保证较高的效率,降低不必要的性能消耗,并且它是公平的自旋锁。

CLH 自旋锁

CLH 锁与 MCS 锁的原理大致相同,都是各个线程轮询各自关注的变量,来避免多个线程对同一个变量的轮询,从而从 CPU 缓存一致性的角度上减少了系统的消耗。
CLH 锁的名字也与他们的发明人的名字相关:Craig,Landin and Hagersten。
CLH 锁与 MCS 锁最大的不同是,MCS 轮询的是当前队列节点的变量,而 CLH 轮询的是当前节点的前驱节点的变量,来判断前一个线程是否释放了锁。
实现如下所示:

import java.util.concurrent.atomic.AtomicReferenceFieldUpdater;
public class CLHLock {
    public static class CLHNode {
        private volatile boolean isWaiting = true; // 默认是在等待锁
    }
    private volatile CLHNode tail ;
    private static final AtomicReferenceFieldUpdater<CLHLock, CLHNode> UPDATER = AtomicReferenceFieldUpdater
            . newUpdater(CLHLock.class, CLHNode .class , "tail" );
    public void lock(CLHNode currentThread) {
        CLHNode preNode = UPDATER.getAndSet( this, currentThread);
        if(preNode != null) {//已有线程占用了锁,进入自旋
            while(preNode.isWaiting ) {
            }
        }
    }

    public void unlock(CLHNode currentThread) {
        // 如果队列里只有当前线程,则释放对当前线程的引用(for GC)。
        if (!UPDATER .compareAndSet(this, currentThread, null)) {
            // 还有后续线程
            currentThread.isWaiting = false ;// 改变状态,让后续线程结束自旋
        }
    }
}

从上面可以看到,MCS 和 CLH 相比,CLH 的代码比 MCS 要少得多;CLH是在前驱节点的属性上自旋,而MCS是在本地属性变量上自旋;CLH的队列是隐式的,通过轮询关注上一个节点的某个变量,隐式地形成了链式的关系,但CLHNode并不实际持有下一个节点,MCS的队列是物理存在的,而 CLH 的队列是逻辑上存在的;此外,CLH 锁释放时只需要改变自己的属性,MCS 锁释放则需要改变后继节点的属性。

CLH 队列是 J.U.C 中 AQS 框架实现的核心原理。

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:
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