一、简介
在Java中,谈到并发就不得不说到jdk中的J.U.C包,而说到此包必定要说到AQS(AbstractQueuedSynchronizer),从类名上可以看出这是一个抽象的、使用队列实现的同步器,AQS提供了一个FIFO队列,可以用于构建同步锁的基础框架,内部通过volatile的变量state来表示锁的状态,当state=0时表示锁空闲,当state>0时表示锁被占用,如果锁是可重入的,比如ReentrantLock,state的值会随着重入次数不断的+1,在锁释放的时候需要将state进行-1直到等于0,所以对于重入锁来说,重入多少次就要释放多少次,否则会一直占着锁导致其他线程无法申请到锁而一直等待,最终撑爆CPU。
AQS的核心思想是如果被请求的共享资源空闲,则将资源分配给当前请求资源的线程,并将共享资源设置为锁定状态,如果此时有其他线程过来请求此共享资源,会发现共享资源已经被占用,则阻塞该线程,将其放入到等待队列中。
AQS的等待队列又叫CLH队列,是一个FIFO性质的,CLH的名字来自于它的创建者,三位老前辈的名字的首字母(Craig, Landin, Hagersten),CLH队列是一个双向队列,AQS将每个请求共享资源但未成功的线程封装成一个CLH队列的Node节点并放入队列中等待分配。
二、AQS锁方式
AQS提供了独占锁和共享锁两种锁的声明,在其内部并未对锁进行具体实现,仅仅提供了一些模板方法,由具体的实现类决定如何实现。
-
独占锁
独占锁意思就是同一时刻只能有一个线程霸占共享资源,其他请求的线程全部等待,比如ReentrantLock。看一下大致的流程:
-
共享锁
共享锁设计的初衷是允许一个或多个线程等待一组事件完成,主要实现为CountDownLatch,主要原理是在创建的时候给state设定一个数值, 表示需要等待的事件数量,这个值需要和要等待执行的线程数一致,每个线程执行完成后,对state减一,在state不等于0之前,应该一直等待,除非遇到线程中断或等待超时。
三、AQS等待队列
当共享资源被占用时,其他请求该资源的线程将会阻塞,然后被加入到同步队列。就数据结构而言,队列的实现方式一种是数组,一种是链表,AQS中的等待队列是通过链表的方式来实现的,名字叫做CLH队列。
-
CLH队列节点是通过AQS内部Node类来封装的,一个节点表示一个线程,保存着线程的引用(
volatile Thread thread
)、状态(volatile int waitStatus
)、前驱节点(volatile Node prev
)、后继节点(volatile Node next
),这些变量全部都是volatile关键字修饰的,保证了节点的原子性。static final class Node { /** 共享 */ static final Node SHARED = new Node(); /** 独占 */ static final Node EXCLUSIVE = null; /** * 因为超时或者中断,节点会被设置为取消状态,被取消的节点时不会参与到竞争中的,他会一直保持取消状态不会转变为其他状态; */ static final int CANCELLED = 1; /** * 后继节点的线程处于等待状态,而当前节点的线程如果释放了同步状态或者被取消,将会通知后继节点,使后继节点的线程得以运行 */ static final int SIGNAL = -1; /** * 节点在等待队列中,节点线程等待在Condition上,当其他线程对Condition调用了signal()后,改节点将会从等待队列中转移到同步队列中,加入到同步状态的获取中 */ static final int CONDITION = -2; /** * 表示下一次共享式同步状态获取将会无条件地传播下去 */ static final int PROPAGATE = -3; /** 等待状态 */ 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; } Node() { } Node(Thread thread, Node mode) { this.nextWaiter = mode; this.thread = thread; } Node(Thread thread, int waitStatus) { this.waitStatus = waitStatus; this.thread = thread; } }
-
入列
CLH队列是双向链表的FIFO队列,知道了这个特性之后,对于它的入列就基本能明了了,每次入列均放在队列最后,我们来看看AQS的代码
private Node addWaiter(Node mode) { // 创建CLH队列节点 Node node = new Node(Thread.currentThread(), mode); // 获取当前的尾结点 Node pred = tail; // 如果尾结点不为null,说明队列已完成了初始化 if (pred != null) { // 当前节点的前置节点设置为原尾结点 node.prev = pred; // 将当前节点通过CAS设置为尾结点 if (compareAndSetTail(pred, node)) { // 将原尾结点的后置节点设置为当前节点 pred.next = node; // 返回当前节点 return node; } } // 队列为空的处理 enq(node); return node; } private Node enq(final Node node) { // 无限循环入队,直到成功 for (;;) { // 获取尾结点 Node t = tail; // 尾结点为空,说明队列尚未被初始化 if (t == null) { // 设置头结点 if (compareAndSetHead(new Node())) // 设置尾结点,头尾相等则表示队列为空 tail = head; } else { // 当前节点的前置节点设置为原尾结点 node.prev = t; // 将当前节点通过CAS设置为尾结点 if (compareAndSetTail(t, node)) { // 将原尾结点的后置节点设置为当前节点 t.next = node; // 返回原尾结点 return t; } } } }
从代码中看到两个方法都是通过CAS方法
compareAndSetHead(Node update)
和compareAndSetTail(Node expect, Node update)
来设置头尾节点,确保节点的添加是线程安全的,在enq(Node node)方法中通过无限循环来保证节点可用被正确添加,只有在成功之后才会返回。 -
出列
CLH队列遵循FIFO规则,首节点先占用共享资源,在线程释放同步状态后,将会唤醒它的后继节点(next),后继节点在获取同步状态成功之后将自己设置为首节点,这个过程其实就是双向链表的移动,将头结点的next指向原首节点的next,然后这个next节点的前置节点设置为null,这个过程不需要使用CAS来保证,因为同时只有一个线程能够成功获取到同步状态。
final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; for (;;) { // 获取当前节点的前置节点 final Node p = node.predecessor(); // 当前节点的前置节点是head的话,则表明轮到它来获取同步状态了 if (p == head && tryAcquire(arg)) { // 加锁成功,将当前节点设置为头结点 setHead(node); p.next = null; // help GC failed = false; return interrupted; } // 当前节点不是第二个节点 或者再次获取锁失败 // 判断是否需要挂起,在挂起后,判断线程是否中断 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } } private void setHead(Node node) { head = node; node.thread = null; node.prev = null; } final Node predecessor() throws NullPointerException { Node p = prev; if (p == null) throw new NullPointerException(); else return p; } private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws = pred.waitStatus; if (ws == Node.SIGNAL) // 当前节点已经被设置为等待唤醒的状态,可以安全的挂起了 return true; if (ws > 0) { // 当前节点node的前任节点被取消,那么跳过这些取消的节点,当跳过之后,重新尝试获取锁 do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { /** * 通过前面的判断,waitStatus一定不是 SIGNAL 或 CANCELLED。 * 推断出一定是 0 or PROPAGATE * 调用者需要再次尝试,在挂起之前能不能获取到锁, * 因此,将当前pred的状态设为SIGNAL,再次尝试获取锁之后,如果还没有得到锁那么挂起 */ compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }
一个线程对于锁的一次竞争的结果有两种:
- 要么成功获取到锁(不用进入到CLH队列)
- 要么获取失败被挂起,等待下次唤醒后继续循环尝试获取锁
因为AQS的队列是FIFO的,所以每次被CPU唤醒之后,如果当前线程不是头结点,则会被挂起,通过这种方式实现了竞争的排队策略。
四、独占锁ReentrantLock
-
独占锁的实现类有ReentrantLock、ReentrantReadWriteLock、ThreadPoolExecutor等,我们拿ReentrantLock学习一下独占锁的使用。先来看一下ReentrantLock的基本使用
class Cc { private final ReentrantLock lock = new ReentrantLock(); public void method() { lock.lock(); try { } finally { lock.unlock(); } } }
ReentrantLock会保证在lock.lock()和lock.unlock()之间的代码块在同一时间只有一个线程访问,其余线程会被挂起,直至获取到锁。
-
ReentrantLock的基本原理
ReentrantLock内部有公平锁(FairSync)和非公平锁(NonfairSync),默认采用非公平锁。
- 公平锁:每个线程抢占锁的顺序按照调用lock方法的顺序依次获取锁
- 非公平锁:每个线程抢占锁的顺序和调用lock方法的顺序无关,每一个线程到来之后都先获取锁,获取不到的话再加入到队列中
// 默认非公平锁 public ReentrantLock() { sync = new NonfairSync(); } // 创建ReentrantLock,公平锁or非公平锁 public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); } // 加锁解锁,使用sync完成 public void lock() { sync.lock(); } // 释放锁 public void unlock() { sync.release(1); }
了解了ReentrantLock的锁竞争机制之后,我们来看下它到底是怎么实现独占锁的。既然ReentrantLock的锁是通过公平锁和非公平锁来实现的,那么加锁和释放锁的实现逻辑也都在这两把锁中了。
-
同步锁
// 继承自AQS abstract static class Sync extends AbstractQueuedSynchronizer { private static final long serialVersionUID = -5179523762034025860L; // 加锁 abstract void lock(); // 非公平锁加锁 final boolean nonfairTryAcquire(int acquires) { // 获取当前线程 final Thread current = Thread.currentThread(); // 获取AQS的state的值 int c = getState(); if (c == 0) { // 如果state=0表示当前共享资源空闲 if (compareAndSetState(0, acquires)) { // 将当前线程设置为独占锁的拥有者 setExclusiveOwnerThread(current); return true; } } // 如果当前线程已经是锁的拥有者,则增加state的值,重入锁的概念 else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } // 加锁失败 return false; } protected final boolean tryRelease(int releases) { // 获取state的值并减去要释放的锁数量 int c = getState() - releases; // 判断当前线程是否为锁的拥有者,其实可以替换为if(!isHeldExclusively()) if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; if (c == 0) { // state==0,锁释放 free = true; // 设置拥有者为空 setExclusiveOwnerThread(null); } // 更新state setState(c); return free; } protected final boolean isHeldExclusively() { // 判断当前线程是否为锁的拥有者 return getExclusiveOwnerThread() == Thread.currentThread(); } // Condition控制对象,提供lock.newCondition()方法调用 final ConditionObject newCondition() { return new ConditionObject(); } // 获取当前锁的拥有者线程 final Thread getOwner() { return getState() == 0 ? null : getExclusiveOwnerThread(); } // 获取锁的重入次数,如果当前线程不是锁的拥有者,则返回0 final int getHoldCount() { return isHeldExclusively() ? getState() : 0; } // 锁是否被占用了 final boolean isLocked() { return getState() != 0; } private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException { s.defaultReadObject(); setState(0); // reset to unlocked state } }
Sync类是公平锁和非公平锁的基类,默认提供了非公平锁的加锁逻辑,也提供了通用的一些方法,大部分的方法也都被final修饰,不允许被重载和重写。
-
公平锁
static final class FairSync extends Sync { private static final long serialVersionUID = -3000897897090466540L; // 实现Sync的lock()抽象方法 final void lock() { // state设置为1,调用到AQS中的acquire方法 acquire(1); } // 公平锁加锁,由AQS的acquire方法调用过来 protected final boolean tryAcquire(int acquires) { // 获取当前线程 final Thread current = Thread.currentThread(); // 获取state的值 int c = getState(); // 锁未被占用 if (c == 0) { // 首先判断队列中是否有等待的线程 // 队列不为空则加锁失败,并放入队列中 // 等待队列为空的情况下,使用CAS修改state的值,成功后就是加锁成功 if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { // 当前线程设置为锁的拥有者 setExclusiveOwnerThread(current); return true; } } // 锁被占有,判断当前线程是否为当前锁的拥有者 else if (current == getExclusiveOwnerThread()) { // 重入锁机制 int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; } }
-
非公平锁
static final class NonfairSync extends Sync { private static final long serialVersionUID = 7316153563782823691L; // 加锁 final void lock() { // 非公平的关键 // 线程调用lock()后,不论等待队列中有没有等待中的线程,都申请一次加锁,CAS操作设置state=1,在原线程拥有者释放锁到队列中头结点加锁成功之间可能会加锁成功,这可以理解为排队的过程中遇到插队的 if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else // 调用AQS的acquire方法 acquire(1); } // 由AQS中的acquire方法调用回来 protected final boolean tryAcquire(int acquires) { // 因为Sync中默认实现了非公平锁,所以只要调用父类的方法即可 return nonfairTryAcquire(acquires); } }
小总结:
- 公平锁与非公平锁的锁释放步骤是一致的
- 获取锁的过程不一致,公平锁优先执行等待时间最长的线程,非公平锁让当前线程抢占,如果一直被抢占的话,队列中的等待线程可能一直都执行不了。
-
其他方法
ReentrantLock还为我们提供了一些其他的方法,方便我们的使用。
-
getQueuedThreads()
:获取等待队列中等待的线程,返回一个List<Thread>对象,按照加入队列顺序倒序public final Collection<Thread> getQueuedThreads() { ArrayList<Thread> list = new ArrayList<Thread>(); // 从尾部开始迭代 for (Node p = tail; p != null; p = p.prev) { Thread t = p.thread; if (t != null) list.add(t); } return list; }
-
tryLock()
:尝试获取锁 -
isFair()
:获取当前锁是否为公平锁,true代表公平锁 -
newCondition()
:锁控制条件类实例,类似于wait和notify/** 使用方式:ArrayBlockingQueue */ private ReentrantLock lock = new ReentrantLock(); Condition notEmpty = lock.newCondition(); Condition notFull = lock.newCondition(); public void put(E e) throws InterruptedException { checkNotNull(e); final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == items.length) notFull.await(); enqueue(e); } finally { lock.unlock(); } } private E dequeue() { final Object[] items = this.items; @SuppressWarnings("unchecked") E x = (E) items[takeIndex]; items[takeIndex] = null; if (++takeIndex == items.length) takeIndex = 0; count--; if (itrs != null) itrs.elementDequeued(); notFull.signal(); return x; } public E take() throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == 0) notEmpty.await(); return dequeue(); } finally { lock.unlock(); } } private void enqueue(E x) { final Object[] items = this.items; items[putIndex] = x; if (++putIndex == items.length) putIndex = 0; count++; notEmpty.signal(); }
-
五、共享锁CountDownLatch
-
CountDownLatch基本原理
CountDownLatch翻译一下就是"倒数门栓",可以理解为给一个门上了n把锁,然后锁一把一把的开,直到所有的锁都打开之后,门才能彻底打开。所以CountDownLatch只有一个构造器,构造器传入锁的数量,每个线程执行完一个任务就倒数一次,当所有的线程都执行完成之后,主线程才能继续往下执行,数量需要和需要执行的线程数一致,否则会出现提前释放和无法释放的情况。
public CountDownLatch(int count) { if (count < 0) throw new IllegalArgumentException("count < 0"); this.sync = new Sync(count); }
CountDownLatch主要通过await()和countDown()完成锁等待和倒数,没调用一次countDown()就将计数器减一,一般在执行任务的线程内调用,此方法不区分调用者,可以在一个线程内调用多次;await()方法是将调用该方法的线程处于等待状态,一般是主线程调用,可以在多个线程内调用,所有调用了await()方法的线程都将陷入等待,并且共享同一把锁,当CountDownLatch的锁释放完之后,所有等待的线程都同时结束等待并恢复执行。
public void await() throws InterruptedException { // 阻塞 sync.acquireSharedInterruptibly(1); } public void countDown() { // 释放共享锁 sync.releaseShared(1); }
我们发现在CountDownLatch中也是通过Sync类来控制锁的,并且Sync是CountDownLatch的内部类,我们看一下Sync的代码
private static final class Sync extends AbstractQueuedSynchronizer { private static final long serialVersionUID = 4982264981922014374L; Sync(int count) { // 初始化锁的数量 setState(count); } int getCount() { // 获取当前剩余的锁数量 return getState(); } protected int tryAcquireShared(int acquires) { // 根据剩余锁的数量决定是否加锁成功,若还有剩余锁,则返回-1,表示加锁成功,否则返回1,表示不允许加锁 return (getState() == 0) ? 1 : -1; } protected boolean tryReleaseShared(int releases) { for (;;) { int c = getState(); if (c == 0) // 锁已全部释放,不允许释放 return false; // 锁-1 int nextc = c-1; // 通过CAS设置state值,确保并发安全 if (compareAndSetState(c, nextc)) // 当nextc为0的时候表示锁已经全部释放了 return nextc == 0; } } }
-
countDown方法工作方式
tryReleaseShared()方法在AQS的releaseShared()方法中调用,方法中只有一个if判断,控制锁释放的时机,doReleaseShared()方法主要作用是唤醒调用了await()方法的线程。如果CountDownLatch是独占式的,那么当计数器为减至0时,就只有一个线程会被唤醒,这就乱套了,严重BUG。
public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { doReleaseShared(); return true; } return false; } private void doReleaseShared() { for (;;) { // 获取等待队列的头结点 Node h = head; // 头结点不为空且不是尾结点,也就是等待队列不为空 if (h != null && h != tail) { int ws = h.waitStatus; // SIGNAL表示当前节点的线程正在等待被唤醒 if (ws == Node.SIGNAL) { // 清楚当前节点的等待状态 if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) continue; // 唤醒当前节点的下一个节点 unparkSuccessor(h); } else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) continue; } // 如果h还是头结点,则说明等待队列在上面的代码执行过程中没有被其他线程处理 if (h == head) break; } } private void unparkSuccessor(Node node) { int ws = node.waitStatus; if (ws < 0) // 清除当前节点的等待状态 compareAndSetWaitStatus(node, ws, 0); // 获取当前节点的后继节点 Node s = node.next; // 后继节点不存在或者该节点中的线程已经被取消等待 if (s == null || s.waitStatus > 0) { s = null; // 队列从尾部往前遍历,找到最后一个处于等待状态的节点 for (Node t = tail; t != null && t != node; t = t.prev) if (t.waitStatus <= 0) s = t; } if (s != null) // 唤醒离当前节点最近的处于等待状态的节点线程 LockSupport.unpark(s.thread); }
至此我们了解了CountDownLatch的countDown()方法的执行逻辑,总结一下就是每次调用countDown()方法都会将state减1,直到state减至0的时候,调用doReleaseShared()方法将等待队列中的所有线程的等待状态都清除掉(waitStatus通过CAS设置为0)。
-
await()方法工作方式
await()方法调用了Sync的acquireSharedInterruptibly()方法,在该方法中判断当前线程是否具有共享执行权限
public void await() throws InterruptedException { sync.acquireSharedInterruptibly(1); } public final void acquireSharedInterruptibly(int arg) throws InterruptedException { // 当前线程是否被中断 if (Thread.interrupted()) throw new InterruptedException(); // 判断当前线程是否有执行权限,也就是校验是否还有未释放的锁 if (tryAcquireShared(arg) < 0) // 阻塞 doAcquireSharedInterruptibly(arg); } private void doAcquireSharedInterruptibly(int arg) throws InterruptedException { // 创建一个共享模式的节点 final Node node = addWaiter(Node.SHARED); boolean failed = true; try { for (;;) { // 获取当前节点的前置节点 final Node p = node.predecessor(); // 前置节点是否为head节点 if (p == head) { // 加锁,如果还有锁未释放,也就是state>0,方法返回-1,否则返回1 int r = tryAcquireShared(arg); // 锁已全部释放 if (r >= 0) { // 将当前节点设置为头结点,表示所有线程都已经执行完毕,退出for循环,可以继续await()方法之后的代码 setHeadAndPropagate(node, r); p.next = null; // help GC failed = false; return; } } // 代码执行到这里说明当前锁还未释放完,使用LockSupport.park(this);挂起当前线程 // 所有的线程都会等待在此处,在countDown()方法中被LockSupport.unpark(s.thread);唤醒后继续从这里执行 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) throw new InterruptedException(); } } finally { if (failed) cancelAcquire(node); } } private void setHeadAndPropagate(Node node, int propagate) { Node h = head; setHead(node); if (propagate > 0 || h == null || h.waitStatus < 0 || (h = head) == null || h.waitStatus < 0) { Node s = node.next; if (s == null || s.isShared()) // 释放并唤醒结束await doReleaseShared(); } }
-
使用场景
-
场景1:在每个线程内调用一次countDown()
public static void main(String[] args) throws InterruptedException { CountDownLatch latch = new CountDownLatch(3); for (int i = 0; i < 3; i++) { int fi = i; new Thread(() -> { try { Thread.sleep(fi * 1000); System.out.println(fi); } catch (InterruptedException e) { e.printStackTrace(); } finally { latch.countDown(); } }).start(); } System.out.println("before:" + System.currentTimeMillis()); latch.await(); System.out.println("after:" + System.currentTimeMillis()); }
执行结果:
0
before:1587753222118
1
2
after:1587753224118
-
场景2:在每个线程内调用三次countDown()
public static void main(String[] args) throws InterruptedException { CountDownLatch latch = new CountDownLatch(3); for (int i = 0; i < 3; i++) { int fi = i; new Thread(() -> { try { Thread.sleep(fi * 1000); System.out.println(fi); } catch (InterruptedException e) { e.printStackTrace(); } finally { latch.countDown(); latch.countDown(); latch.countDown(); } }).start(); } System.out.println("before:" + System.currentTimeMillis()); latch.await(); System.out.println("after:" + System.currentTimeMillis()); }
执行结果:
0
before:1587753292096
after:1587753292096
1
2
-
-
总结
CountDownLatch是通过一个计数器来实现的共享锁,计数器的值就是线程的数量,在主线程中使用CountDownLatch的实例方法await()阻塞所有的线程,阻塞地点在parkAndCheckInterrupt()方法中;每调用一次countDown()方法都会将计数器减1,直到计数器归零之后唤醒所有等待的线程,使await()执行结束,既然await()执行结束了,那么也就可以继续执行await()的后续代码了。
六、总结
通过CountDownLatch和ReentrantLock可以发现几个特点:
- 独占锁的使用和申请加锁线程强关联,每此只能由一个线程霸占着锁,且可以通过当前线程和锁拥有者线程对比来实现锁的重入
- 共享锁的使用和线程弱关联,每次可以执行一批线程任务,使用state来控制运行的线程数量,通过LockSupport.park()阻塞这批任务,每个线程执行完之后将state的值减1,直至归零,然后通过LockSupport.unpark()退出阻塞