zoukankan      html  css  js  c++  java
  • 关于AQS

    一.AQS是什么

    AQS是AbustactQueuedSynchronizer的简称,它是一个Java提高的底层同步工具类,用一个int类型的变量表示同步状态,并提供了一系列的CAS操作来管理这个同步状态。

    AQS的主要作用是为Java中的并发同步组件提供统一的底层支持。

    同步工具类Semaphore、CountDownLatch、ReentrantLock、ReentrantReadWriteLock、FutureTask,CyclicBarrier都是是基于AQS实现的,

    用法是通过继承AQS实现其模版方法,然后将子类作为同步组件的内部类。

    AQS是一个同步器,设计模式是模板模式。

    核心数据结构:双向链表 + state(锁状态)。

    底层操作:CAS。

    二.AQS能做什么

    AQS提供了三大功能:独占锁、共享锁、ConditionObject。

    (1)独占锁exclusive是一个悲观锁。保证只有一个线程经过一个阻塞点,只有一个线程可以获得锁。

    (2)共享锁shared是一个乐观锁。可以允许多个线程阻塞点,可以多个线程同时获取到锁。它允许一个资源可以被多个读操作,或者被一个写操作访问,但是两个操作不能同时访问。

    (3)AQS使用一个int类型的成员变量state来表示同步状态,当state>0时表示已经获取了锁,当state = 0无锁。

    它提供了三个方法(getState()、setState(int newState)、compareAndSetState(int expect,int update))来对同步状态state进行操作,可以确保对state的操作是安全的。

    (4)AQS是通过一个CLH队列实现的(CLH锁即Craig, Landin, and Hagersten (CLH) locks,CLH锁是一个自旋锁,能确保无饥饿性,提供先来先服务的公平性。

    CLH锁也是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。)

    三.AQS原理

    1、AbstractQueuedSynchronizer图解

    为了更好理解AbstractQueuedSynchronizer的运行机制,可以首先研究其内部数据结构,如下图:

    图中展示AQS类较为重要的数据结构,包括int类型变量state用于记录锁的状态。

    继承自AbstractOwnableSynchronizer类的Thread类型变量exclusiveOwnerThread用于指向当前排他的获取锁的线程,AbstractQueuedSynchronizer.Node类型的变量headtail

    其中Node对象表示当前等待锁的节点,Nodethread变量指向等待的线程,waitStatus表示当前等待节点状态,mode为节点类型。

    多个节点之间使用prevnext组成双向链表,参考CLH锁队列的方式进行锁的获取,

    但其中与CLH队列的重要区别在于CLH队列中后续节点需要自旋轮询前节点状态以确定前置节点是否已经释放锁,期间不释放CPU资源,

    AQSNode节点指向的线程在获取锁失败后调用LockSupport.park函数使其进入阻塞状态,让出CPU资源,故在前置节点释放锁时需要调用unparkSuccessor函数唤醒后继节点。

    根据以上说明可得知此上图图主要表现当前thread0线程获取了锁,thread1线程正在等待。

    2、读写锁Sync对于AQS使用

    读写锁中Sync类是继承于AQS,并且主要使用上文介绍的数据结构中的statewaitStatus变量进行实现。

    实现读写锁与实现普通互斥锁的主要区别在于需要分别记录读锁状态及写锁状态,并且等待队列中需要区别处理两种加锁操作。

    Sync使用state变量同时记录读锁与写锁状态,将int类型的state变量分为高16位与第16位,高16位记录读锁状态,低16位记录写锁状态,

    如下图所示:

     Sync使用不同的mode描述等待队列中的节点以区分读锁等待节点和写锁等待节点。mode取值包括SHAREDEXCLUSIVE两种,分别代表当前等待节点为读锁和写锁。

    3、底层实现的数据结构是一个双向链表。

    Sync queue:同步队列,是一个双向链表。包括head节点和tail节点。head节点主要用作后续的调度。 Condition queue:非必须,单向链表。当程序中存在cindition的时候才会存在此列表。

    AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。

    如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

    AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。

    四.相关概念

    关于数据库或者存储系统的锁策略和机制。

    总体上来说锁有两大类:

    悲观锁:总是认为会有冲突发生,所以每次操作临界区资源时都会加锁。

    乐观锁:顾名思义,认为每次操作临界区资源时不会发生冲突,但会先记录一个版本号,在提交事务时,会检查版本号是否变更,从而作出判断放弃或者重试。

    对于一个高并发的应用程序来说,数据库常常会成为一个访问的瓶颈,这里面主要存在以下的几种访问情况:

    (1)读读并发;

    (2)读写并发;

    (3)写写并发;

    一般情况下,数据库都会有读共享写独占的锁并发的方案,也就是说读读并发是没问题的,但在读写并发时,则有可能出现读取不一致情况,也就是常说的脏读,

    所以在悲观锁的模式下,在有写线程的时候,是不允许有任何其他的读和写线程的,也就是说写是独占的,这样会导致系统的吞吐明显下降。

    如何避免这一情况,于是就出现了基于MVCC多版本控制并发的策略,在这种策略下读写并发是可以同时进行的,底层的原理是当前有并发的写线程在独占,

    那么读线程就直接读取事务log里面的历史最新版本的数据,这样以来就大大提高了并发吞吐能力,

    虽然读取的数据并不是最新的数据,但是历史上最新的,同时也保持了一致性,目前主流的数据库都支持这种模式。

    最后一种是写写并发场景,这种场景通常基于乐观锁的并发写方案也称OCC,多个并发的写线程,每个线程都不会修改原始数据,

    而是从原始数据上拷贝上一份数据,同时记录版本号,不同的线程更新自己的数据,在最终写会时会判断版本号是否变更,

    如果变更则意味有人已经更改过了,那么当前线程需要做的就是自旋重试,如果重试指定的次数依然失败,那么就应该放弃更新,

    这种策略仅仅适合写并发并不强烈的场景,如果写竞争严重,那么多次自旋重试的开销也是非常耗性能的,如果竞争激烈,那么写锁独占的方式则更加适合。

    五.CLH是什么?怎么实现

    1、SMP(Symmetric Multi-Processor)

    SMP(Symmetric Multi-Processing)对称多处理器结构,指服务器中多个CPU对称工作,每个CPU访问内存地址所需时间相同。其主要特征是共享,包含对CPU,内存,I/O等进行共享。

    SMP能够保证内存一致性,但这些共享的资源很可能成为性能瓶颈,随着CPU数量的增加,每个CPU都要访问相同的内存资源,可能导致内存访问冲突,可能会导致CPU资源的浪费。常用的PC机就属于这种。

    2、NUMA(Non-Uniform Memory Access)

    非一致存储访问,将CPU分为CPU模块,每个CPU模块由多个CPU组成,并且具有独立的本地内存、I/O槽口等,模块之间可以通过互联模块相互访问,

    访问本地内存的速度将远远高于访问远地内存(系统内其它节点的内存)的速度,这也是非一致存储访问的由来。

    NUMA较好地解决SMP的扩展问题,当CPU数量增加时,因为访问远地内存的延时远远超过本地内存,系统性能无法线性增加。

    3、CLH(Craig, Landin, and Hagersten locks):

    是一个自旋锁,能确保无饥饿性,提供先来先服务的公平性。

    CLH锁也是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋。

    当一个线程需要获取锁时:

    (1)创建一个的QNode,将其中的locked设置为true表示需要获取锁;

    (2)线程对tail域调用getAndSet方法,使自己成为队列的尾部,同时获取一个指向其前趋结点的引用myPred

    (3)该线程就在前趋结点的locked字段上旋转,直到前趋结点释放锁

    (4)当一个线程需要释放锁时,将当前结点的locked域设置为false,同时回收前趋结点。

    线程A需要获取锁,其myNode域为true,tail指向线程A的结点,然后线程B也加入到线程A后面,tail指向线程B的结点。

    然后线程A和B都在其myPred域上旋转,一旦它的myPred结点的locked字段变为false,它就可以获取锁。 明显线程A的myPred locked域为false,此时线程A获取到了锁。

    代码实现:

    public class CLHLock implements Lock {  
        AtomicReference<QNode> tail = new AtomicReference<QNode>(new QNode());  
        ThreadLocal<QNode> myPred;  
        ThreadLocal<QNode> myNode;  
      
        public CLHLock() {  
            tail = new AtomicReference<QNode>(new QNode());  
            myNode = new ThreadLocal<QNode>() {  
                protected QNode initialValue() {  
                    return new QNode();  
                }  
            };  
            myPred = new ThreadLocal<QNode>() {  
                protected QNode initialValue() {  
                    return null;  
                }  
            };  
        }  
      
        @Override  
        public void lock() {  
            QNode qnode = myNode.get();  
            qnode.locked = true;  
            QNode pred = tail.getAndSet(qnode);  
            myPred.set(pred);  
            while (pred.locked) {  
            }  
        }  
      
        @Override  
        public void unlock() {  
            QNode qnode = myNode.get();  
            qnode.locked = false;  
            myNode.set(myPred.get());  
        }  
    }

    CLH队列锁的优点是空间复杂度低(如果有n个线程,L个锁,每个线程每次只获取一个锁,

    那么需要的存储空间是O(L+n),n个线程有n个。myNode,L个锁有L个tail),CLH的一种变体被应用在了JAVA并发框架中。CLH在SMP系统结构下该方法是非常有效的。

    但在NUMA系统结构下,每个线程有自己的内存,如果前趋结点的内存位置比较远,自旋判断前趋结点的locked域,性能将大打折扣,一种解决NUMA系统结构的思路是MCS队列锁。

    六.MCS是什么?怎么实现

    MSC与CLH最大的不同并不是链表是显示还是隐式,而是线程自旋的规则不同:CLH是在前趋结点的locked域上自旋等待,

    而MSC是在自己的结点的locked域上自旋等待。正因为如此,它解决了CLH在NUMA系统架构中获取locked域状态内存过远的问题。

    MCS队列锁的具体实现如下:

    (1)队列初始化时没有结点,tail=null;

    (2)线程A想要获取锁,于是将自己置于队尾,由于它是第一个结点,它的locked域为false;

    (3)线程B和C相继加入队列,a->next=b,b->next=c。且B和C现在没有获取锁,处于等待状态,所以它们的locked域为true,尾指针指向线程C对应的结点;

    (4)线程A释放锁后,顺着它的next指针找到了线程B,并把B的locked域设置为false。这一动作会触发线程B获取锁;

    代码实现:

    public class MCSLock implements Lock {
        AtomicReference<QNode> tail;
        ThreadLocal<QNode> myNode;
    
        @Override
        public void lock() {
            QNode qnode = myNode.get();
            QNode pred = tail.getAndSet(qnode);
            if (pred != null) {
                qnode.locked = true;
                pred.next = qnode;
    
                // wait until predecessor gives up the lock
                while (qnode.locked) {
                }
            }
        }
    
        @Override
        public void unlock() {
            QNode qnode = myNode.get();
            if (qnode.next == null) {
                if (tail.compareAndSet(qnode, null))
                    return;
                
                // wait until predecessor fills in its next field
                while (qnode.next == null) {
                }
            }
            qnode.next.locked = false;
            qnode.next = null;
        }
    
        class QNode {
            boolean locked = false;
            QNode next = null;
        }
    }
  • 相关阅读:
    常见树的总结
    《深入理解Java虚拟机》读书笔记(第三章)
    《Jave并发编程的艺术》学习笔记(1-2章)
    Java多线程与并发之面试常问题
    Morris遍历-如何用空间复杂度O(1)来遍历二叉树
    BFPRT算法
    Manacher
    maven基础
    play framework + sbt入门之环境搭建
    rancher部署kubernets集群
  • 原文地址:https://www.cnblogs.com/ZJOE80/p/12882996.html
Copyright © 2011-2022 走看看