JUC包下的AQS — 队列同步器
AQS简介
AQS,即AbstractQueuedSynchronizer,在java.util.concurrent.locks包下面。AQS是用来构建锁和同步器的框架,基于AQS可以简单高效的开发出适合自己的同步器。
ReentrantLock,Semaphore,其他的诸如ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的,AQS可以认为是JUC的核心。
可重入锁非可重入锁
在讲解原理之前,我们先弄清楚两个概念,可重入锁非可重入锁。可重入锁指的是当一个线程获取锁之后,可以再次获取该锁,从而避免死锁。
ReentrantLock和synchronized都是可重入锁,AQS中共享变量state的初始值为0。对于可重入锁,当一个线程尝试获取锁时,会判断state是否等于0,等于0则可以获取锁,并将state置为1,若state值不是0,会判断获取锁的线程是否是占有锁的线程,如果是则执行state++。释放锁时,同样的也会执行state–,直到state等于0,线程才会真正释放锁。
对于非可重入锁,当一个线程尝试获取锁时,判断state值是不是0,是0则可以获取锁,不是0则进入阻塞状态,如果是同一个线程就会产生死锁。
乐观锁和悲观锁
悲观锁
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
乐观锁
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。
这篇博客对于乐观锁和悲观锁写的非常详细。
面试必备之乐观锁与悲观锁
AQS原理
1.使用Node实现FIFO队列,可以用于构建锁或者其他同步装置的基础框架。
2.使用一个int类型表示状态。
private volatile int state;//共享变量,使用volatile修饰保证线程可见性
3.子类通过继承并通过实现它的方法管理其状态{acquire和release}的方法操纵状态。
4.可以同时实现排它锁和共享锁模式(独占、共享),指的是实现它的子类可以选择独占或者共享,不是说可以选择独占和共享。
Exclusive(独占): 只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁。 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁。
非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的。
Share(共享): 多个线程可同时执行,如Semaphore/CountDownLatch。Semaphore、CountDownLatCh、CyclicBarrier、ReadWriteLock。
AQS的核心思想是: 当共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
并且只有head节点的直接后继节点可以请求获取锁,失败则将自己阻塞直到获取成功。
AQS同步组件
CountDownLatch
在程序执行过程中,可能需要满足某些条件才可以继续执行后续的操作,所以JDK1.5之后引入了该类。
//调用该方法的线程会被暂时挂起,直到count值减到0,才会被唤醒继续执行。
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
//和await()方法用法相同,可以设置时间,当一段时间后没有达到条件也会被继续执行
public boolean await(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}
//令count值减一
public void countDown() {
sync.releaseShared(1);
}
CountDownLatch可以使一个线程等待其他线程各自执行完毕后再执行,但是不支持count值进行重置,如果业务上有要求的话,可以考虑使用CyclicBarrier。
Semaphore(信号量)
可以控制某个资源可以被同时访问的线程个数,下面这是两个比较重要的方法。
//获得一个许可
public void acquire() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
//方法可以传入permits参数,代表依次获取多个,同样可以依次释放多个
public void acquire(int permits) throws InterruptedException {
if (permits < 0) throw new IllegalArgumentException();
sync.acquireSharedInterruptibly(permits);
}
//释放一个许可
public void release() {
sync.releaseShared(1);
}
例子:
private static int threadCount = 20;
public static void main(String[] args) throws InterruptedException {
ExecutorService exec = Executors.newCachedThreadPool();
final Semaphore semaphore = new Semaphore(3);
for (int i = 0; i < threadCount; i++) {
final int threadNum = i;
exec.execute(() -> {
try {
//获取许可
semaphore.acquire();
test(threadNum);
//释放许可
semaphore.release();
} catch (InterruptedException e) {
log.info("exception",e);
}
});
}
log.info("finish");
exec.shutdown();
}
private static void test(int threadNum) throws InterruptedException {
log.info("{}",threadNum);
Thread.sleep(1000);
}
从这个例子可以看出,执行结果是3个3个出现的,对应了new Semaphore(3)将信号量设置为3。
Semaphore还有一个比较重要的方法:tryAcquire()
//尝试获取信号量,在特定时间获取不到则放弃执行
public boolean tryAcquire(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}
CyclicBarrier
CyclicBarrier和CountDownLatch用法很相似,只不过CountDownLatch执行的是减一操作,CyclicBarrier执行的是加一操作。可以实现一组线程相互等待,直到到达某个条件才会继续执行,当一个线程调用await()方法时,就会进入阻塞状态,直到达到临界值才会唤醒所有等待的线程继续执行。
比如可以对多个目标进行相加的操作时,需要等待每个线程都操作完毕时再进行统一相加,就可以使用CyclicBarrier来实现。相对的,CyclicBarrier的临界值可以循环使用。
//可以设置等待时间,超过时间就算没有到达指定条件,依然会执行
public int await(long timeout, TimeUnit unit)
throws InterruptedException,
BrokenBarrierException,
TimeoutException {
return dowait(true, unit.toNanos(timeout));
}
例子:
private static CyclicBarrier barrier = new CyclicBarrier(5);
public static void main(String[] args) throws InterruptedException {
ExecutorService exec = Executors.newCachedThreadPool();
for (int i = 0; i < 10; i++) {
final int threadNum = i;
Thread.sleep(1000);
exec.execute(() -> {
try {
race(threadNum);
} catch (Exception e) {
log.info("Exception", e);
}
});
}
exec.shutdown();
}
private static void race(int threadNum) throws Exception {
Thread.sleep(1000);
log.info("{} is ready", threadNum);
try {
barrier.await(2000, TimeUnit.MILLISECONDS);
} catch (BrokenBarrierException e) {
log.info("BarrierException", e);
}
log.info("{} continue", threadNum);
}
需要注意的是: 设置时间可能会抛出BrokenBarrierException异常,如果希望下面的代码继续执行,就需要将异常捕获而不让其抛出。
BrokenBarrierException这个异常的意思就是,当某个等待的线程被中断,其它等待的线程会抛出这个异常。
//这样做可以在达到条件时,先执行这里的代码
private static CyclicBarrier barrier = new CyclicBarrier(5, () -> {
log.info("callback is running");
});
ReentrantLock(可重入锁)
ReentrantLock和synchronized的区别:
① 两者都是可重入锁
② synchronized 依赖于 JVM 而 ReenTrantLock 依赖于 API
③ReenTrantLock 比 synchronized 增加了一些高级功能
1.ReenTrantLock提供了一种能够中断等待锁的线程的机制,synchronized 则不能。
2.ReenTrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。
3.ReentrantLock类线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现选择性通知
在synchronized可以实现的情况下,还是比较推荐使用synchronized。
我们来看一下部分源码
//无参构造方法,默认给了一个不公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
//有参构造方法,可以自己设着公平或非公平
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
//尝试获取锁,如果没有被其它线程锁定,则获取锁
//也可以传入时间,代表一段时间后,锁没有被其它线程锁定,则获取锁
public boolean tryLock() {
return sync.nonfairTryAcquire(1);
}
例子:
//声明一个锁
private final static Lock lock = new ReentrantLock();
//切记解锁一定要放在finally中,防止出现死锁的情况
private static void add() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
ReentrantReadWriteLock(读写锁)
在没有任何读写锁的时候,才可以获得写锁,这实际上是用的悲观锁的设计思想。所以在读操作频繁,写操作不频繁的情况下,有可能会造成饥饿现象。比如,一个线程希望进行写操作,但是由于写操作很频繁,写进程就会一直等待。
用法:
private final Map<String, Data> map = new TreeMap<>();
private final static ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final Lock readLock = lock.readLock();
private final Lock writeLock = lock.writeLock();
public Data get(String key){
readLock.lock();
try{
return map.get(key);
}finally {
readLock.unlock();
}
}
public Set<String> getAllKeys(){
readLock.lock();
try{
return map.keySet();
}finally {
readLock.unlock();
}
}
public Data put(String key,Data value){
writeLock.lock();
try{
return map.put(key,value);
}finally {
readLock.unlock();
}
}
class Data{
}
Condition
前面在介绍AQS时,我们知道AQS实际上是通过内置的FIFO队列来完成获取资源线程的排队工作。
实际上在这个队列下面还存在着一个Condition队列,暂时可以将其理解为一个假死队列,调用方法进入该队列的线程就像是死掉了一样,但是它是在等待一个时机,条件达到的时候可以调用方法将其唤醒继续执行。
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
Condition condition = reentrantLock.newCondition();
new Thread(() ->{
try{
reentrantLock.lock();
log.info("wait signal");//1
condition.wait();
}catch (InterruptedException e){
e.printStackTrace();
}
log.info("get signal");//4
reentrantLock.unlock();
}).start();
new Thread(() -> {
reentrantLock.lock();
log.info("get lock");//2
try{
Thread.sleep(3000);
}catch (InterruptedException e){
e.printStackTrace();
}
condition.signalAll();
log.info("send signal");//3
reentrantLock.unlock();
}).start();
}
代码中的的注释代表的是执行的顺序,当线程执行condition. wait()之后,该线程就会进入Condition队列,并且相当于执行了unlock()进行了解锁。
当另一个线程尝试获取该线程时可以获取,当执行condition.signalAll()之后,就会唤醒Condition队列中所有的线程,当然也可以一个一个的唤醒,之后继续执行。
实际上,用到Condition的场景很少,这里当做了解。