提起AQS(AbstractQueuedSynchronizer),不得不让我们想起多线程的同步的几种的实现方式,主要是wait/notify,synchronized,ReentrantLock,下面我们会介绍几种同步的实现方式,从而推理出AQS的加锁的过程。
-
自旋的方式
import java.util.concurrent.atomic.AtomicInteger; public class SpinLocksDemo { //标识--是否有线程在同步块---是否有线程上锁成功 private volatile AtomicInteger state = new AtomicInteger(0); //利用自旋加锁 public void lock() { while (!state.compareAndSet(0, 1)) { } } public void unlock() { state.compareAndSet(1, 0); } }
我们测试一下,书写一个测试类如下:
import java.util.concurrent.CountDownLatch; public class Test { static CountDownLatch countDownLatch = new CountDownLatch(1000); public static volatile int a = 0; public static void main(String[] args) throws InterruptedException { SpinLocksDemo lock = new SpinLocksDemo(); //SpinLocksAndYieldDemo lock = new SpinLocksAndYieldDemo(); //SpinLocksAndSleepDemo lock = new SpinLocksAndSleepDemo(); for (int i = 0; i < 1000; i++) { new Thread(() -> { lock.lock(); try { a++; countDownLatch.countDown(); System.out.println(Thread.currentThread().getName() + ":" + a); } finally { lock.unlock(); } }).start(); } countDownLatch.await(); System.out.println("a的值是:" + a); } }
运行结果如下:
我们可以看到最终的结果是对的,可以验证我们的锁是对的,但是缺点也很明显:耗费CPU资源。没有竞争到锁的线程会一直占用CPU资源进行CAS操作,假如一个线程得到锁后完成业务花费N秒,与此同时,其他线程就会处于空转、浪费cpu资源的状态。
所以我们可不可以让CAS不成功的线程让出CPU。于是有了下面的方式
-
yield+自旋
import java.util.concurrent.atomic.AtomicInteger; public class SpinLocksAndYieldDemo { //标识--是否有线程在同步块---是否有线程上锁成功 private volatile AtomicInteger state = new AtomicInteger(0); //利用自旋加锁 public void lock() { while (!state.compareAndSet(0, 1)) { //让出CPU Thread.yield(); } } public void unlock() { state.compareAndSet(1, 0); } }
- 我们再书写一个测试类如下:
import java.util.concurrent.CountDownLatch; public class Test { static CountDownLatch countDownLatch = new CountDownLatch(1000); public static volatile int a = 0; public static void main(String[] args) throws InterruptedException { //SpinLocksDemo lock = new SpinLocksDemo(); SpinLocksAndYieldDemo lock = new SpinLocksAndYieldDemo(); //SpinLocksAndSleepDemo lock = new SpinLocksAndSleepDemo(); for (int i = 0; i < 1000; i++) { new Thread(() -> { lock.lock(); try { a++; countDownLatch.countDown(); System.out.println(Thread.currentThread().getName() + ":" + a); } finally { lock.unlock(); } }).start(); } countDownLatch.await(); System.out.println("a的值是:" + a); } }
运行的结果如下:
可以看到我们的CPU的占有率没有达到100%。但是我们要解决自旋锁的性能问题需要让竞争锁的失败的线程不空转,在获取不到锁的时候能把CPU资源给让出来,yield方法就能让出CPU资源,当线程竞争失败时,会调用yield方法让出CPU。但是yield方法,只会短暂的让出CPU,下次执行的时候,可能CPU还会执行刚才那个加锁失败的线程,自旋+yield的方式并没有完全的解决问题,当系统只有两个线程竞争锁时,yield是有效的。但是如果有2000个线程,这个时候竞争也会上去,空转也会上去,没有从根本上解决问题。
于是又有了一种新的方式,如下:
-
sleep +自旋
public class SpinLocksAndSleepDemo{ //标识--是否有线程在同步块---是否有线程上锁成功 private volatile AtomicInteger state = new AtomicInteger(0); //利用自旋加锁 public void lock() { while (!state.compareAndSet(0, 1)) { //让当前线程睡一会儿 try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); } } } public void unlock() { state.compareAndSet(1, 0); } }
书写测试代码如下:
import java.util.concurrent.CountDownLatch; public class Test { static CountDownLatch countDownLatch = new CountDownLatch(1000); public static volatile int a = 0; public static void main(String[] args) throws InterruptedException { //SpinLocksDemo lock = new SpinLocksDemo(); //SpinLocksAndYieldDemo lock = new SpinLocksAndYieldDemo(); SpinLocksAndSleepDemo lock = new SpinLocksAndSleepDemo(); for (int i = 0; i < 1000; i++) { new Thread(() -> { lock.lock(); try { a++; countDownLatch.countDown(); System.out.println(Thread.currentThread().getName() + ":" + a); } finally { lock.unlock(); } }).start(); } countDownLatch.await(); System.out.println("a的值是:" + a); } }
运行结果如下:
可以看到结果是正确的,但是sleep的时间为什么是50?这个时间我们从那获取到呢?可以说这个问题是无解,因为你永远不知道这个线程执行的时间,所以这种方案也是不可行的。于是有了第四种类似AQS那种,具体如下:
-
park+自旋
import java.util.Queue; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.LockSupport; public class SpinLocksAndParkDemo { //标识--是否有线程在同步块---是否有线程上锁成功 private volatile AtomicInteger state = new AtomicInteger(0); //等待的队列 private volatile Queue<Thread> parkQueue = new LinkedBlockingQueue<>(); //利用自旋加锁 public void lock() { while (!state.compareAndSet(0, 1)) { park(); } } private void park() { Thread thread = Thread.currentThread(); //将当前线程加入到等待队列中 parkQueue.add(thread); //将当前的线程释放CPU阻塞 LockSupport.park(); } public void unlock() { lock_notify(); } private void lock_notify() { state.compareAndSet(1, 0); //得到要唤醒的线程的头部线程 Thread thread = parkQueue.poll(); //唤醒等待线程 LockSupport.unpark(thread); } }
书写测试类如下:
import java.util.concurrent.CountDownLatch; public class Test { static CountDownLatch countDownLatch = new CountDownLatch(1000); public static volatile int a = 0; public static void main(String[] args) throws InterruptedException { //SpinLocksDemo lock = new SpinLocksDemo(); //SpinLocksAndYieldDemo lock = new SpinLocksAndYieldDemo(); //SpinLocksAndSleepDemo lock = new SpinLocksAndSleepDemo(); SpinLocksAndParkDemo lock = new SpinLocksAndParkDemo(); for (int i = 0; i < 1000; i++) { new Thread(() -> { lock.lock(); try { a++; System.out.println(Thread.currentThread().getName() + ":" + a); countDownLatch.countDown(); } finally { lock.unlock(); } }).start(); } countDownLatch.await(); System.out.println("a的值是:" + a); } }
运行的结果如下:
通过上面的几种的实现方式,我们得出了AQS的实现的大概方式,但是现实的AQS比我们要想的复杂的多,下面就让我们通过AQS的源码来一一解析吧!
首先我们要看看AQS的结构的类图
我们先打开AbstractQueuedSynchronizer的源码,通过上面的结构图,我们可以知道该类维护了一个Node内部类,于是我们查看Node的源码如下,主要是用来实现上面的我们提到的队列。
static final class Node { //指示节点正在共享模式下等待的标记 static final Node SHARED = new Node(); //指示节点正在以独占模式等待的标记 static final Node EXCLUSIVE = null; //waitStatus值,指示线程已取消 static final int CANCELLED = 1; //waitStatus值,指示后续线程需要释放 static final int SIGNAL = -1; //waitStatus值,指示线程正在等待条件 static final int CONDITION = -2; //waitStatus值,表示下一个被默认的应该无条件传播的等待状态值 static final int PROPAGATE = -3; /* * SIGNAL:这个节点的后继被(或即将)阻塞(通过park),因此当前节点在释放或取消时必须释放它的后继。为 * 了避免竞争,acquire方法必须首先表明它们需要一个信号,然后重试原子获取,当失败时,阻塞。 * * CANCELLED:由于超时或中断,该节点被取消。节点不会离开这个状态。特别是,取消节点的线程不会再次 * 阻塞。 * * CONDITION:此节点当前处于条件队列中。在传输之前,它不会被用作同步队列节点,此时状态将被设置为 * 0。 * * PROPAGATE:释放的共享应该传播到其他节点。在doReleaseShared中设置这个(仅针对头节点),以确保传播 * 继续,即使其他操作已经干预。 * * 0:以上都不是 */ volatile int waitStatus; //上一个节点 volatile Node prev; //下一个节点 volatile Node next; //节点中的值 volatile Thread thread; //下一个等待节点 Node nextWaiter; //是否是共享的节点 final boolean isShared() { return nextWaiter == SHARED; } //返回当前的节点前置节点 final Node predecessor() throws NullPointerException { Node p = prev; if (p == null) throw new NullPointerException(); else return p; } //用于建立初始标头或SHARED标记 Node() { } //addWaiter时候调用 Node(Thread thread, Node mode) { this.nextWaiter = mode; this.thread = thread; } //Condition时候调用 Node(Thread thread, int waitStatus) { this.waitStatus = waitStatus; this.thread = thread; } }
所以可以根据上面的代码,大概的想想一下,这个队列是什么样,具体如下图:
-
公平锁
-
lock方法只有一个线程的情况,如下图所示:
只有一个线程的时候,会直接调用tryAcquire,然后判断state的是不是等于0
- 如果等于0,证明是第一次加锁,通过CAS操作将state的值改成1,然后true。表示加锁成功。
- 如果不等于0,表示不是第一次加锁,这个锁是重入锁。这个时候将原来的state值继续通过CAS操作加上1。再次返回true,表示加锁成功。
需要注意的是这个时候AQS的队列没有创建出来。
-
lock方法中有两个线程的情况,如下图所示:
我们假设线程A直接获取到了锁,但是线程A还没有解锁,这个时候线程B来进行加锁,走来会执行tryAcquire()方法,这个时候线程A没有解锁,所以这个tryAcquire()方法会直接返回false(state!=0,也不是重入锁),然后会调用addWaiter(Node.EXCLUSIVE)方法,这个时候会在这个方法中的enq(node)的方法中初始化AQS队列,也会利用尾插法将当前的节点插入到AQS队列中去。AQS队列如下图所示:
这个方法返回当前的节点,然后调用acquireQueued(addWaiter(Node.EXCLUSIVE), arg))方法。这个方法中是一个死循环,由于线程A没有释放锁,会执行shouldParkAfterFailedAcquire(p, node)(p表示线程B节点的上一个节点,node表示线程B的节点)第一次进这个方法会将线程B节点的上一个节点的waitStatus的值改成-1,然后返回false,这个时候的AQS队列如下图:
然后第二次进入这个方法的时候,会返回true,会执行后面的方法parkAndCheckInterrupt(),这个时候线程B就会被park在这。
上面的情况都是在线程A没有解锁的时候,如果在死循环中线程A已经解锁了。这个时候判断线程B节点的上一个节点是不是头结点,如果是的话,直接执行tryAcquire(),将当前线程B设置成独占线程,同时将state的值通过CAS操作设置成1,如果成功的话,直接返回true。表示加锁成功。这个时候会执行这个if判断中代码。执行setHead(node),这个时候AQS队列如下图:
这个时候原来的线程B节点出队列,然后永远会维护一个头结点中thread为null的AQS队列。
-
lock方法中有三个线程情况,如下图:
三个线程和两个线程的情况是差不多的。加锁成功的节点永远是头结点的下一个节点中的线程加锁成功,因为是公平锁。
-
-
公平锁与非公平锁的区别:
- 非公平锁会走来直接尝试加锁,如果加锁成功,直接执行线程中的代码,如果加锁不成功,直接走公平锁的逻辑
总结流程图如下:
讲完了加锁的过程,我们再来看看解锁的过程。
-
线程解锁的三种情况:
- 当前线程不在AQS队列中,执行tryRelease()方法。同时当前线程不是重入锁,直接将当前的线程独占标识去除掉,然后将state的值通过CAS的操作改成0,如果当前线程加的是重入锁。解锁一次,state的值减1,如果state的值是等于0的时候,返回true。表示解锁成功。
- AQS队列中只有一个头结点,这个时候tryRelease()返回的结果和上面的情况是一样的。这个时候返回的true,会进当前的if中去,然后判断头结点是不是为null和头结点中waitStatus的值是不是等于0。这个时候head不等于null,但是waitState是等于0,if判断不成立,不会执行unpark的方法。会直接返回true。表示解锁成功。
- AQS队列中不止一个头结点,这个时候tryRelease()返回的结果和上面的情况是一样的。这个时候返回的true,会进当前的if中去,然后判断头结点是不是为null和头结点中waitStatus的值是不是等于0。这个时候head不等于null,但是waitState是等于-1,if判断成立,会执行unpark的方法。unpark方法中会unpark头结点的下一个节点,然后如果当前的节点的状态是取消的状态,会从最后一个节点开始找,找到当前节点的下一个不是取消状态的节点进行unpark。这个时候也会直接返回true。表示解锁成功。
-
tryLock()方法和lock()方法是差不多,tryLock方法,尝试加锁不成功后就直接返回false,具体的代码如下:
-
tryLock(long timeout, TimeUnit unit)方法,加了一个获取锁的时间,如果这个时间内没有获取到锁,直接返回false,表示加锁失败。
走来会尝试加锁,如果成功,直接表示加锁成功,如果不成功会执行doAcquireNanos()方法,走来先将当前节点用尾插法的方式插入到AQS队列中去,如果AQS队列没有初始化,直接初始化,将当前的节点放入到尾结点中去。然后进入死循环,这个时候判断当前节点的上一个节点是不是头结点,再次尝试加锁,如果成功直接返回true,如果失败将当前的节点的线程直接park指定的时间,当时间到了直接唤醒。再次尝试获取锁,如果成功直接返回true,如果失败直接返回false,这个方法中是可以直接响应中断的。
-
lockInterruptibly和lock的区别:lockInterruptibly是会立即响应中断的,并且在park中的线程也会interruptibly唤醒的,因为这个时候返回true,直接抛出异常,响应对应的中断。
而lock是要等线程执行完才会响应中断,是因为park中线程被中断唤醒后,没有抛出异常,只是将中断的标志设置成了true,等到获取到锁,执行完,才会响应对象的中断
-