关于volatile,CAS,AQS,锁

版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/m0_37822939/article/details/80040589

关键字volatile

1.volatile保证可见性。
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

2.volatile不能确保原子性。
以变量inc自增为例,初始化值为10:

线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;

然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,也不会导致主存中的值刷新,所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。

解决方案:可以通过synchronized或lock,进行加锁,来保证操作的原子性。也可以通过AtomicInteger (AtomicInteger实际上就是用的CAS自旋volatile变量实现的,后面会介绍)。

3.volatile保证有序性。
举个简单的例子

//x、y为非volatile变量
//flag为volatile变量
 
x = 2;        //语句1
y = 0;        //语句2
flag = true;    //语句3
x = 4;        //语句4
y = -1;       //语句5
由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。

并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

用CAS实现volatile原子性方式

原子性表现为每个可以单独操作,不互相依赖,在线程中表现为每个线程都有所以它自己的一份copy值,不定期的刷新到主内存。(如果有锁,ulock时刷新到主内存)

而volatile变量不具有原子性,每次读写都是自己去主内存读主内存的值,也真是由于此种原因不能进行计数器操作,例如:

volatile i =1;
线程A,线程B 同时 i++;
i++ 即
i=i; //从主内存中读   1
i+1; //通过获取的值。计算 2
i=i+1; //把计算的值写入主内存中 3
当线程执行顺序如下时 A1 – >B1—>A2—>A3—>A1—>B2—>B3, 最后结果导致运行了两次结果还是2

对此,可以用CAS算法进行改进,CAS也可成为乐观锁,实现原理,通过保存原有值进行比较结果,直到更改成功,即自旋volatile变量实现。

实现原理,CAS保存了3个值 H当前值(作为预期值),V内存值,S计算值
代码实现如下:
public final int incrementAndGet(h, s) {
        for (;;) {
            inth=i;                 //A线程叫AH,B线程描述为BH       1
            int s = i +1;         // A线程叫AS,B线程描述为BS        2
            if(h==i){            // 比较内存值和预期值               3
               i=s;            // 如果相同,赋值,成功CAS            4
               return s;
            }
         }      
A1 (A开始时用AH保存内存中此时的i值)->
B1(B开始时也用BH保存当前i值)->
A2  (把计算值2赋给AS)
A3(比较保存的AH和读取内存值Ai,都是等于1,未改变)
A4(所以CAS成功,把AS即2放入内存中)
B2(把计算值2赋给BS)
B3(比较BH和读取当前内存值Bi,BH是1,Bi是2,所以不相等,返回到B1)
B1   (故重新取出内存值i,重复计算,此时BH=Bi=2,BS=3赋给主内存,完成计数)

那么问题就来了,成功过程中需要这2个步骤保证原子性:比较h == i,替换i = s ,
这里就要用到compareAndSet, 也即compareAndSwapInt。
CAS通过调用JNI的代码实现的。


而compareAndSwapInt就是借助C来调用CPU底层指令实现的。所以代码变为:

public final int incrementAndGet(h, s) {
        for (;;) {
            inth=i;                 //A线程叫AH,B线程描述为BH       1
            int s = i +1;         // A
            int s = i +1;         // A线程叫AS,B线程描述为BS        2
             if(compareAndSet(h, s))                 // 如果相同,赋值,成功CAS             3,4
              return s;
           }
      }
compareAndSet利用JNI来完成CPU指令的操作。源码如下:
public final boolean compareAndSet(int expect, int update){   
    return unsafe.compareAndSwapInt(this, valueOffset, expect,update);
}

AQS概述

AQS定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它,如常用的ReentrantLock/Semaphore/CountDownLatch

1. 框架


它维护了一个volatile int state(代表共享资源)和一个FIFO线程等待队列(多线程争用资源被阻塞时会进入此队列)。这里volatile是核心关键词,具体volatile的语义,在此不述。state的访问方式有三种:

  • getState()
  • setState()
  • compareAndSetState()
AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。

不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源state的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。自定义同步器实现时主要实现以下几种方法:

  • isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
  • tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
  • tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
  • tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
  • tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。

再以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。

一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。

2. 源码详解

本节开始讲解AQS的源码实现。依照acquire-release、acquireShared-releaseShared的次序来。

2.1 acquire(int)

此方法是独占模式下线程获取共享资源的顶层入口。如果获取到资源,线程直接返回,否则进入等待队列,直到获取到资源为止,且整个过程忽略中断的影响。这也正是lock()的语义,当然不仅仅只限于lock()。获取到资源后,线程就可以去执行其临界区代码了。下面是acquire()的源码:

public final void acquire(int arg) {
     if (!tryAcquire(arg) &&
         acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
         selfInterrupt();
}
函数流程如下:
1. tryAcquire()尝试直接去获取资源,如果成功则直接返回;
2. addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
3. acquireQueued()使线程在等待队列中获取资源,一直获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
4. 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。

  这时单凭这4个抽象的函数来看流程还有点朦胧,不要紧,看完接下来的分析后,你就会明白了。


2.1.1 tryAcquire(int)
此方法尝试去获取独占资源。如果获取成功,则直接返回true,否则直接返回false。这也正是tryLock()的语义,还是那句话,当然不仅仅只限于tryLock()。如下是tryAcquire()的源码:
protected boolean tryAcquire(int arg) {
         throw new UnsupportedOperationException();
}
什么?直接throw异常?说好的功能呢?好吧,还记得概述里讲的AQS只是一个框架,具体资源的获取/释放方式交由自定义同步器去实现吗?就是这里了!!!AQS这里只定义了一个接口,具体资源的获取交由自定义同步器去实现了(通过state的get/set/CAS)!!!至于能不能重入,能不能加塞,那就看具体的自定义同步器怎么去设计了!!!当然,自定义同步器在进行资源访问时要考虑线程安全的影响。


这里之所以没有定义成abstract,是因为独占模式下只用实现tryAcquire-tryRelease,而共享模式下只用实现tryAcquireShared-tryReleaseShared。如果都定义成abstract,那么每个模式也要去实现另一模式下的接口。说到底,Doug Lea还是站在咱们开发者的角度,尽量减少不必要的工作量。

2.1.2 addWaiter(Node)
此方法用于将当前线程加入到等待队列的队尾,并返回当前线程所在的结点。还是上源码吧:
 
 private Node addWaiter(Node mode) {
     //以给定模式构造结点。mode有两种:EXCLUSIVE(独占)和SHARED(共享)
     Node node = new Node(Thread.currentThread(), mode);
     
     //尝试快速方式直接放到队尾。
     Node pred = tail;
     if (pred != null) {
         node.prev = pred;
         if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
     
    //上一步失败则通过enq入队。
    enq(node);
    return node;
}
 不用再说了,直接看注释吧。
2.1.2.1 enq(Node)
此方法用于将node加入队尾。源码如下:
 
 private Node enq(final Node node) {
     //CAS"自旋",直到成功加入队尾
     for (;;) {
         Node t = tail;
         if (t == null) { // 队列为空,创建一个空的标志结点作为head结点,并将tail也指向它。
             if (compareAndSetHead(new Node()))
                 tail = head;
         } else {//正常流程,放入队尾
             node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

如果你看过AtomicInteger.getAndIncrement()函数源码,那么相信你一眼便看出这段代码的精华。CAS自旋volatile变量

2.1.3 acquireQueued(Node, int)
OK,通过tryAcquire()和addWaiter(),该线程获取资源失败,已经被放入等待队列尾部了。聪明的你立刻应该能想到该线程下一部该干什么了吧:进入等待状态休息,直到其他线程彻底释放资源后唤醒自己,自己再拿到资源,然后就可以去干自己想干的事了。没错,就是这样!是不是跟医院排队拿号有点相似~~acquireQueued()就是干这件事:在等待队列中排队拿号(中间没其它事干可以休息),直到拿到号后再返回。这个函数非常关键,还是上源码吧:
 
 final boolean acquireQueued(final Node node, int arg) {
     boolean failed = true;//标记是否成功拿到资源
     try {
         boolean interrupted = false;//标记等待过程中是否被中断过
         
         //又是一个“自旋”!
         for (;;) {
             final Node p = node.predecessor();//拿到前驱
             //如果前驱是head,即该结点已成老二,那么便有资格去尝试获取资源(可能是老大释放完资源唤醒自己的,当然也可能被interrupt了)。
            if (p == head && tryAcquire(arg)) {
                setHead(node);//拿到资源后,将head指向该结点。所以head所指的标杆结点,就是当前获取到资源的那个结点或null。
                p.next = null; // setHead中node.prev已置为null,此处再将head.next置为null,就是为了方便GC回收以前的head结点。也就意味着之前拿完资源的结点出队了!
                failed = false;
                return interrupted;//返回等待过程中是否被中断过
            }
            
            //如果自己可以休息了,就进入waiting状态,直到被unpark()
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;//如果等待过程中被中断过,哪怕只有那么一次,就将interrupted标记为true
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}
到这里了,我们先不急着总结acquireQueued()的函数流程,先看看shouldParkAfterFailedAcquire()和parkAndCheckInterrupt()具体干些什么。
2.1.3.1 shouldParkAfterFailedAcquire(Node, Node)
此方法主要用于检查状态,看看自己是否真的可以去休息了(进入waiting状态,如果线程状态转换不熟,可以参考本人上一篇写的Thread详解),万一队列前边的线程都放弃了只是瞎站着,那也说不定,对吧!
 
 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
     int ws = pred.waitStatus;//拿到前驱的状态
     if (ws == Node.SIGNAL)
         //如果已经告诉前驱拿完号后通知自己一下,那就可以安心休息了
         return true;
     if (ws > 0) {
         /*
          * 如果前驱放弃了,那就一直往前找,直到找到最近一个正常等待的状态,并排在它的后边。
          * 注意:那些放弃的结点,由于被自己“加塞”到它们前边,它们相当于形成一个无引用链,稍后就会被保安大叔赶走了(GC回收)!
          */
         do {
             node.prev = pred = pred.prev;
         } while (pred.waitStatus > 0);
         pred.next = node;
     } else {
          //如果前驱正常,那就把前驱的状态设置成SIGNAL,告诉它拿完号后通知自己一下。有可能失败,人家说不定刚刚释放完呢!
         compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
     }
     return false;
 }
整个流程中,如果前驱结点的状态不是SIGNAL,那么自己就不能安心去休息,需要去找个安心的休息点,同时可以再尝试下看有没有机会轮到自己拿号。
2.1.3.2 parkAndCheckInterrupt()
如果线程找好安全休息点后,那就可以安心去休息了。此方法就是让线程去休息,真正进入等待状态。
private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);//调用park()使线程进入waiting状态
    return Thread.interrupted();//如果被唤醒,查看自己是不是被中断的。
}
park()会让当前线程进入waiting状态。在此状态下,有两种途径可以唤醒该线程:1)被unpark();2)被interrupt()。(再说一句,如果线程状态转换不熟,可以参考本人写的Thread详解)。需要注意的是,Thread.interrupted()会清除当前线程的中断标记位。 
2.1.3.3 小结
OK,看了shouldParkAfterFailedAcquire()和parkAndCheckInterrupt(),现在让我们再回到acquireQueued(),总结下该函数的具体流程:
1. 结点进入队尾后,检查状态,找到安全休息点;
2. 调用park()进入waiting状态,等待unpark()或interrupt()唤醒自己;
3. 被唤醒后,看自己是不是有资格能拿到号。如果拿到,head指向当前结点,并返回从入队到拿到号的整个过程中是否被中断过;如果没拿到,继续流程1。
 
2.1.4 小结
OKOK,acquireQueued()分析完之后,我们接下来再回到acquire()!再贴上它的源码吧:
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}
再来总结下它的流程吧:
1. 调用自定义同步器的tryAcquire()尝试直接去获取资源,如果成功则直接返回;
2. 没成功,则addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
3. acquireQueued()使线程在等待队列中休息,有机会时(轮到自己,会被unpark())会去尝试获取资源。获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
4. 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
由于此函数是重中之重,我再用流程图总结一下:
 
至此,acquire()的流程终于算是告一段落了。这也就是ReentrantLock.lock()的流程,不信你去看其lock()源码吧,整个函数就是一条acquire(1)!!!
 
展开阅读全文

没有更多推荐了,返回首页