上篇文章扔掉源码,15张图带你彻底理解java AQS通过15张图讲解了AQS管程模型中入口等待队列原理。AQS使用FIFO队列实现了一个锁相关的并发器模板,可以基于这个模板来实现各种锁。JDK建议并发锁工具类使用内部类实现AQS的同步属性。
今天我们就来聊一聊基于AQS实现的各种锁。
1 ReentrantLock
我们先来看一下UML类图:从图中可以看到,ReentrantLock使用抽象内部类Sync来实现了AQS的方法,然后基于Sync这个同步器实现了公平锁和非公平锁。主要实现了下面3个方法:
- tryAcquire(int arg):获取独占锁
- tryRelease(int arg):释放独占锁
- isHeldExclusively:当前线程是否占有独占锁
ReentrantLock默认实现的是非公平锁,可以在构造函数指定。
从实现的方法可以看到,ReentrantLock中获取的锁是独占锁,我们再来看一下获取和释放独占锁的代码:
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
独占锁的特点是调用上面acquire方法,传入的参数是1。
1.1 获取公平锁
获取锁首先判断同步状态(state)的值。
1.1.1 state等于0
这说明没有线程占用锁,当前线程如果符合下面两个条件,就可以获取到锁:
- 没有前任节点,如下图:
- CAS的方式更新state值(把0更新成1)成功。
如果获取独占锁成功,会更新AQS中exclusiveOwnerThread为当前线程,这个很容易理解。
1.1.2 state不等于0
这说明已经有线程占有锁,判断占有锁的线程是不是当前线程,如下图:
state += 1值如果小于0,会抛出异常。
如果获取锁失败,则进入AQS队列等待唤醒。
1.2 获取非公平锁
跟公平锁相比,非公平锁的唯一不同是如果判断到state等于0,不用判断有没有前任节点,只要CAS设置state值(把0更新成1)成功,就获取到了锁。
1.3 释放锁
公平锁和非公平锁,释放逻辑完全一样,都是在内部类Sync中实现的。释放锁需要注意两点,如下图:
为什么state会大于1,因为是可以重入的,占有锁的线程可以多次获取锁。
1.4 总结
公平锁的特点是每个线程都要进行排队,不用担心线程永远获取不到锁,但有个缺点是每个线程入队后都需要阻塞和被唤醒,这一定程度上影响了效率。非公平锁的特点是每个线程入队前都会先尝试获取锁,如果获取成功就不会入队了,这比公平锁效率高。但也有一个缺点,队列中的线程有可能等待很长时间,高并发下甚至可能永远获取不到锁。
2 ReentrantReadWriteLock
我们先来看一下UML类图:
从图中可以看到,ReentrantReadWriteLock使用抽象内部类Sync来实现了AQS的方法,然后基于Sync这个同步器实现了公平锁和非公平锁。主要实现了下面3个方法:
- tryAcquire(int arg):获取独占锁
- tryRelease(int arg):释放独占锁
- tryAcquireShared(int arg):获取共享锁
- tryReleaseShared(int arg):释放共享锁
- isHeldExclusively:当前线程是否占有独占锁
可见ReentrantReadWriteLock里面同时用到了共享锁和独占锁。
下图是定义的几个常用变量:
下面这2个方法用户获取共享锁和独占锁的数量:
static int sharedCount(int c) { return c >>> SHARED_SHIFT; } static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
从sharedCount可以看到,共享锁的数量要右移16位获取,也就是说共享锁占了高16位。从上图EXCLUSIVE_MASK的定义看到,跟EXCLUSIVE_MASK进行与运算,得到的是低16位的值,所以独占锁占了低16位。如下图:
这样上面获取锁数量的方法就很好理解了。参考1[1]
2.1 读锁
读锁的实现对应内部类ReadLock。
2.1.1 获取读锁
获取读锁实际上是ReadLock调用了AQS的下面方法,传入参数是1:
public final void acquireShared(int arg) { if (tryAcquireShared(arg) < 0) doAcquireShared(arg); }
ReentrantReadWriteLock内部类Sync实现了tryAcquireShared方法,主要包括如下三种情况:
- 使用exclusiveCount方法查看state中是否有独占锁,如果有并且独占线程不是当前线程,返回-1,获取失败。
- 使用sharedCount查看state中共享锁数量,如果读锁数量小于最大值(MAX_COUNT=65535),则再满足下面3个条件就可以获取成功并返回1:
- 当前线程不需要阻塞(readerShouldBlock)。在公平锁中,需要判断是否有前置节点,如下图就需要阻塞:
在非公平锁中,则是判断第一个节点是不是有独占锁,如下图就需要阻塞:
- 使用CAS把state的值加SHARED_UNIT(65536)。
这里是不是就更理解读锁占高位的说法了,获取一个读锁,state的值就要加SHARED_UNIT这么多个。
- 给当前线程的holdCount加1。
- 如果2失败,自旋,重复上面的步骤直到获取到锁。
tryAcquireShared(获取共享锁)会返回一个整数,如下:
- 返回负数:获取锁失败。
- 返回0:获取锁成功但是之后再由线程来获取共享锁时就会失败。
- 返回正数:获取锁成功而且之后再有线程来获取共享锁时也可能会成功。
2.1.2 释放读锁
ReentrantReadWriteLock释放读锁是在ReadLock中调用了AQS下面方法,传入的参数是1:
public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { doReleaseShared(); return true; } return false; }
ReentrantReadWriteLock内部类Sync实现了releaseShared方法,具体逻辑分为下面两步:
- 当前线程holdCounter值减1。
- CAS的方式将state的值减去SHARED_UNIT。
2.2 写锁
写锁的实现对应内部类WriteLock。
2.2.1 获取写锁
ReentrantReadWriteLock获取写锁其实是在WriteLock中调用了AQS的下面方法,传入参数1:
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
在ReentrantReadWriteLock内部类Sync实现了tryAcquire方法,首先获取state值和独占锁数量(exclusiveCount),之后分如下两种情况,如下图:
- state不等于0:
- 独占锁数量等于0,这时说明有线程占用了共享锁,如果当前线程不是独占线程,获取锁失败。
- 独占锁数量不等于0,独占锁数量加1后大于MAX_COUNT,获取锁失败。
- 上面2种情况不符合,获取锁成功,state值加1。
- state等于0,判断当前线程是否需要阻塞(writerShouldBlock)。
在公平锁中,跟readerShouldBlock的逻辑完全一样,就是判断队列中head节点的后继节点是不是当前线程。在非公平锁中,直接返回false,即可以直接尝试获取锁。
如果当前线程不需要阻塞,并且给state赋值成功,使用CAS方式把state值加1,把独占线程置为当前线程。
2.2.2 释放写锁
ReentrantReadWriteLock释放写锁其实是在WriteLock中调用了AQS的下面方法,传入参数1:
public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; }
ReentrantReadWriteLock在Sync中实现了tryRelease(arg)方法,逻辑如下:
- 判断当前线程是不是独占线程,如果不是,抛出异常。
- state值减1后,用新state值判断独占锁数量是否等于0
- 如果等于0,则把独占线程置为空,返回true,这样上面的代码就可以唤醒队列中的后置节点了
- 如果不等于0,返回false,不唤醒后继节点。
3 CountDownLatch
我们先来看一下UML类图:
从上面的图中看出,CountDownLatch的内部类Sync实现了获取共享锁和释放共享锁的逻辑。
使用CountDownLatch时,构造函数会传入一个int类型的参数count,表示调动count次的countDown后主线程才可以被唤醒。
public CountDownLatch(int count) { if (count < 0) throw new IllegalArgumentException("count < 0"); this.sync = new Sync(count); }
上面的Sync(count)就是将AQS中的state赋值为count。
3.1 await
CountDownLatch的await方法调用了AQS中的acquireSharedInterruptibly(int arg),传入参数1,不过这个参数并没有用。代码如下:
public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); if (tryAcquireShared(arg) < 0) doAcquireSharedInterruptibly(arg); }
Sync中实现了tryAcquireShared方法,await逻辑如下图:
上面的自旋过程就是等待state的值不断减小,只有state值成为0的时候,主线程才会跳出自旋执行之后的逻辑。
3.2 countDown
CountDownLatch的countDown方法调用了AQS的releaseShared(int arg),传入参数1,不过这个参数并没有用。内部类Sync实现了tryReleaseShared方法,逻辑如下图:
3.3 总结
CountDownLatch的构造函数入参值会赋值给state变量,入队操作是主线程入队,每个子线程调用了countDown后state值减1,当state值成为0后唤醒主线程。
4 Semaphore
Semaphore是一个信号量,用来保护共享资源。如果线程要访问共享资源,首先从Semaphore获取锁(信号量),如果信号量的计数器等于0,则当前线程进入AQS队列阻塞等待。否则,线程获取锁成功,信号量减1。使用完共享资源后,释放锁(信号量加1)。
Semaphore跟管程模型不一样的是,允许多个(构造函数的permits)线程进入管程内部,因此也常用它来做限流。
UML类图如下:
Semaphore的构造函数会传入一个int类型参数,用来初始化state的值。
4.1 acquire
获取锁的操作调用了AQS中的acquireSharedInterruptibly方法,传入参数1,代码见CountDownLatch中await小节。Semaphore在公平锁和非公平锁中分别实现了tryAcquireShared方法。
4.1.1 公平锁
Semaphore默认使用非公平锁,如果使用公平锁,需要在构造函数指定。获取公平锁逻辑比较简单,如下图:
4.1.2 非公平锁
acquire在非公平的锁唯一的区别就是不会判断AQS队列是否有前置节点(hasQueuedPredecessors),而是直接尝试获取锁。
除了acquire方法外,还有其他几个获取锁的方法,原理类似,只是调用了AQS中的不同方法。
4.2 release
释放锁的操作调用了AQS中的releaseShared(int arg)方法,传入参数1,在内部类Sync中实现了tryReleaseShared方法,逻辑很简单:使用CAS的方式将state的值加1,之后唤醒队列中的后继节点。
5 ThreadPoolExecutor
ThreadPoolExecutor中也用到了AQS,看下面的UML类图:
Worker主要在ThreadPoolExecutor中断线程的时候使用。Worker自己实现了独占锁,在中断线程时首先进行加锁,中断操作后释放锁。按照官方说法,这里不直接使用ReentrantLock的原因是防止调用控制线程池的方法(类似setCorePoolSize)时能够重新获取到锁,
5.1 tryAcquire
使用CAS的方式把AQS中state从0改为1,把当前线程置为独占线程。
5.2 tryRelease
把独占线程置为空,把AQS中state改为0。
Worker初始化的时候会把state置为-1,这样是不能获取锁成功的。只有调用了runWorker方法,才会通过释放锁操作把state更为0。这样保证了只中断运行中的线程,而不会中断等待中的线程。
6 总结
AQS基于双向队列实现了入口等待队列,基于state变量实现了各种并发锁,上篇文章讲了入口等待队列,而这篇文章主要讲了基于AQS的并发锁原理。
在管程模型中,还有一块儿没有介绍,就是条件等待队列,请看下篇。 ·············· END ··············