上文问题:为什么在DCL单例模式下,加了synchronized锁,代码块退出后,还要禁止指令重排序? 难道不是在持有锁的线程内,等重排序完之后,才会释放锁吗?
在DCL单例模式下, 使用volatile是保证指令重排序,创建对象分为三步:第一,给对象分配内存空间,第二给对象初始化,第三变量与内存空间建立链接,其中第二第三步不存在数据依赖,固可以重排序,当线程2判断if(instance==null),这个等号是比较内存地址值的,判断当前变量的内存地址值是不是为null,这里就涉及了字节码知识,当store指令执行时,会保证将线程内部的信息给写回主内存中,固线程1将内存地址和变量建联后,写回了主内存中,线程二在判断时恰好不为null,但对象还未初始化,线程二在get的时候可能会报错。
大纲内容
-
基础概念
-
ReentrantLock源码分析
-
lock()
-
unlock()
-
条件等待队列
-
生产者-消费者Demo
-
问题1:为什么在unparkSuccessor()方法中,如果线程B是无效的,代码是从尾向前查找有效节点呢?
基础概念
ReentrantLock是实现了AQS框架,跟synchronized关键字一样,是保证在并发场景下,同一时间只能有一个线程访问临界资源,保证了线程不安全性问题,ReentrantLock是手动加锁和手动释放锁,有一个阻塞队列和多个条件等待队列组成,支持加锁的公平性。
AQS框架的特点:一个同步阻塞队列和多个条件等待队列。Synchronized的特点:一个同步阻塞队列和一个条件等待队列。
AQS在内部维护了被volatile修饰的state变量,通过getState(),setState(),compareAndSetState()三个方法来控制state的改变。
来看看AQS中最重要的几个字段
private volatile int state;
当state=0时,跟synchronized底层的Monitor对象中的count字段一样,标识当前锁处于空闲状态,其他线程可以尝试来获取锁。
当state=1时,表明有线程已经持有锁,其他线程只能阻塞在同步阻塞队列中等待。ReentrantLock锁是支持可重入的。
private transient Thread exclusiveOwnerThread;
跟synchronized中的owner字段一样,表示当前哪个线程持有该锁的使用权
总结:当state=0时,表示当前锁处于空闲状态,当线程A使用lock()成功时,会调用tryAcquire()把该锁的state进行自增+1,其他线程去lock()都会被队列给阻塞住,同时入同步阻塞队列,只有当线程线程A释放完锁,并且state=0时,其他线程才能尝试去获取锁。
来看看AQS中Node中最重要的字段,
//锁的状态
volatile int waitStatus;
//当前锁的引用,即这个锁是谁
volatile Thread thread;
waitStatus=-3,表示当前节点处于共享模式下,前继结点会唤醒后续的所有结点。
waitStatus=-2,表示当前节点位于条件等待队列上,当持有锁的线程调用Condition中的waite()方法时,当前线程会从同步阻塞队列移动至条件等待队列中,同时释放锁,等待获取同步锁。
waitStatus=-1,最重要的,当节点状态为-1时,说明当前线程节点有能力去唤醒后续的一个节点。 waitStatus=0,默认值,未发生锁争取。
watStatus=1时,表示当前节点争夺锁资源取消了,在中断/超时的情况下,会将节点的状态修改为1,处于该状态的节点后续都不会再发生变化。
总结:当wait为负数时,是有效等待状态,为正数,说明线程节点处于无效状态。
ReentrantLock源码分析 Lock()
假设场景:A,B,C三个线程尝试去抢ReentrantLock锁,锁是如何控制互斥性的。
ReentrantLock lock = new ReentrantLock();
lock.tryLock();
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
默认是使用的非公平锁,如果想使用公平锁,则构造函数中传入True即可。
当线程A调用lock()方法时, 使用CAS算法来设置state的值,当设置成功时,则说明线程A获取锁成功,同时拥有锁使用权的线程设置成当前线程A,
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
核心:此时线程B和线程C尝试CAS算法修改state的值设置失败,固会走到acquire(1)中。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
对于线程B而言,首先会尝试调用tryAcquire()方法,目的是为了想当一次舔狗,(因为每个线程持有锁的时间并不会很长),想看看线程A有没有释放完,如果线程B当舔狗成功了,导致拿到了线程A释放的锁,返回true,后续的判断逻辑也不会走。
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
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;
}
线程B当了一次舔狗,会进行两层判断:
1:首先拿到state的最新值,判断是否为0,如果为0,说明线程A已经释放了锁,线程B调用cas算法来修改state的值,如果修改成功,则把锁的使用权设置成当前线程B,返回true,说明线程B获取锁成功。
2:ReentrantLock是支持可重入的,如果state最新值不为0,判断当前持有锁的线程是不是当前线程,如果是的,则state自增+1,同时返回true。3:如果判断都不是,说明线程B获取锁失败了,返回false,固才会走后面的判断
小知识点:if(A&&B),如果A为false,if后面的B判断是不会走的,如果A为true,后续的B判断才会执行。if(A||B),如果A为true,B都不用判断,如果A为flase,B才会判断。
锁的使用权线程和当前锁的线程相比较,跟synchronized中的偏向锁拿MarkWord中的线程Id和当前锁的线程Id相比较,异曲同工之妙。
回头看线程B调用的方法
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
//第一个条件: tryAcquire(arg)返回的是false,
//固会走到下一个条件判断acquireQueued(addWait(Node.EXCLUSIVE),arg)))
//第二个条件: 分解成以下两小步解析
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
addWaiter(Node node)方法中, 参数是我们的Node节点,前面也介绍了这个Node中存在waitStatus和thread两个重要的属性。此方法的目的是:将获取锁失败的线程封装成Node节点,加入同步阻塞队列(双向链表)中等待,同时返回当前Node节点。
1:将当前线程Thread.currentThead()封装成一个Node节点,标注是互斥Node.EXCLUSIVEW状态,(AQS的节点有两类,一类是互斥,一类是共享)
2:线程B第一次进来,tail是默认值为null,所以会先执行enq(Node)方法
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
3:第一次循环,tail为null,所以会初始化一个空Node节点作为头节点,然后将tail指向头结点
第二次循环,t先指向tail节点,把线程B中的前驱节点设置为t,同时使用cas算法将node节点设置成尾节点,同时返回线程B节点\
4:当addWaiter()方法执行完后,返回的封装Node节点,调用acquireQueued(Node node,int arg)
// node节点为3中返回的节点,当前返回的是加入链表中的Node线程B,
// arg为1。
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
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);
}
}
acquireQueued()目的是为了再一次当舔狗,当线程B的前驱节点若为head节点,(因为线程持有锁的时间很短,还是想再次看看线程A又没有释放锁),线程B尝试去获取锁,如果获取成功,则返回false。
假设线程A没有释放锁,会执行shouldParkAfterFailedAcquire(p, node)和parkAndCheckInterrupt()两个方法。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
return true;
if (ws > 0) {
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
shouldParkAfterFailedAcquire(p, node)方法是判断线程B的前驱节点的waitStatus是不是为-1.如果为-1,则返回true,线程B初次进入这个方法,他的前驱节点默认是0,所以会走else中的方法,用cas算法把前驱结点的waitStatus状态改为-1.
当waitStatus为-1时,代表着当前节点能唤醒后续的第一个尾节点,其实可以把第一个空的头节点假设为获取锁的线程A,把他的waitStatus设置为-1,当他释放锁时,才能唤醒后续的第一个尾节点,这样可能好理解。
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
public static void park(Object blocker) {
Thread t = Thread.currentThread();
setBlocker(t, blocker);
UNSAFE.park(false, 0L);
setBlocker(t, null);
}
如果设置当前节点的前驱节点的waitStatus状态为-1时,则park住当前线程B。
到这里,线程B才结束了,线程C的流程跟线程B的流程是一样的,简述一下线程C的流程1:线程C调用tryAcquire()方法,主要是判断state的状态是否为0,持有锁的线程者是否是自己(线程C),假设线程A没有释放完,固返回false,走2的流程。
这里存在一个公平锁和非公平锁的小知识点,假设线程C拿到了锁,此不是对线程B中的线程不公平,线程B辛辛苦苦入队列,反而给线程C拿到了。公平锁会先去检查队列中有没有等待的Node节点,如果有,自己乖乖先入队等着。
2:调用addWaiter(Node node)方法,将线程C封装成Node节点,加入队列中,同时返回当前线程C节点,这里就不用初始化enq(node),假设线程B还在队列中,线程C会直接用尾插法插在线程B的后面。
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
3:调用acquireQueued(Node node)方法,判断线程C的前驱节点是不是头结点 ,由于线程C的前驱节点是线程B,固会直接走到shouldParkAfterFailedAcquire(p, node),尝试把线程B的waitStatus状态设置成-1.同时再第二次循环时调用parkAndCheckInterrupt()阻塞线程C。\
这里有个小细节:为什么我要提起两次循环调用这个概念,因为shouldParkAfterFailedAcquire(p,
node)中把前驱节点设置成了-1后,返回false出去的,但acquireQueued中是死循环,还是想继续当一次舔狗,但这次如果还没有获取成功,才会parkAndCheckInterrupt()阻塞线程C。最终阻塞队列中的结果
unLock()
还是以上面结果为场景,其实线程A获取锁,线程B和线程C在阻塞队列中等待
public void unlock() {
sync.release(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;
}
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
1:当线程A想要释放锁unlock(),底层先会调用tryRelease()方法,只有持有锁的线程才能去释放锁,否则会抛出异常,如果线程A多次重入,则tryRelease()调用多次自减1,当c==0时,设置state为0,同时设置持有锁线程为null,返回true。
2:当线程A成功释放锁,取队列中的头结点,若头节点状态不为空,会执行unparkSuccessor()方法
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);
}
取头结点的waitStatus状态,如果为负数,说明是有效等待状态,则尝试把头节点的waitStatus设置为0,找到头结点的尾节点线程B,如果线程B节点为空或者线程B是无效等待状态(只有超时或中断,才会为无效等待状态)。则尝试找线程C,假设线程B有效,则唤醒线程B,假设线程B无效,线程C有效,则唤醒线程C,
注意在lock中的acquireQueued()方法,是个死循环,线程B只是在死循环中被park住了,此时唤醒线程B,则线程B在acquireQueue()方法中被唤醒,又可以执行死循环,然后尝试取获取锁。
问题1:为什么在unparkSuccessor()方法中,如果线程B是无效的,代码是从尾向前查找有效节点呢?
未获取锁的线程入队时,每次都是使用prev来强关联队列中已存在的节点,可以让prev前驱链保证强一致。