前面我们认识了ReentrantLock(可重入锁)
可Java里面不止一种锁,所以下面来了解一下Java里面有什么锁
Lock接口
在Lock接口出现之前,Java的锁都是使用synchronized关键字来做的,而且此时的synchronized都是重量级锁,是调用操作系统的内核态的,从JDK1.5之后,并发包中(concurrent)就出现了Lock接口(以及相关实现类)用来实现锁的功能。
这些实现类也是实现了锁的功能,只不过与synchronized不同的是,这些实现类需要显示地进行调用,缺少了synchronized隐式获取锁的便捷性,但同时却拥有了锁获取与释放的可操作性,比较灵活
接下来我们看看有哪些接口实现了这个Lock
可以看到,实现类有熟悉的ReentrantLock,还有WrireLock与ReadLock也就是后面会讲的读写锁
下面总结一下Lock接口提供的Synchronized关键字不具备的一些关键特性
- Lock可以尝试非阻塞地获取锁,也就是自旋
- 能被中断地获取锁,在ReentrantLock里面面,我们可以看到,获取锁的线程也是可以被中断的,被中断后会抛出异常,同时将锁释放
- 超时获取锁,在指定的截止时间内如果获取不到锁,就会被挂起(ReentrantLock里面是自旋两次就会被挂起)
队列同步器
队列同步器就是我们所说的AQS,也就是AbstractQueuedSynchronized
前面我们也已经知道了,AQS依赖的就是一个底层的Node队列(由双向链表形成,用来存储需要排队的线程),同时还有一个很重要的volatile变量state,该变量是记录当前锁的状态的(0表示无人占用)
我们可以看到AQS单纯继承了AbstractOwnableSynchronizer,并没有去实现任何接口
AbstractOwnableSynchronizer
而AbstractOwnableSynchronizer构造十分简单,单单只有一个成员属性、一个序列化ID、该成员属性的get、set方法,和一个无参构造方法,所以我们只需要了解这个成员属性即可
这个成员属性是一个线程类型的,名为exclusiveOwnerThread
这个变量的作用就是保存当前是哪一个线程拥有锁
返回到队列同步器
同步器是实现锁的关键,在锁的视线中聚合同步器,利用同步器实现锁的语义,同步器里面已经封装了锁的实现方式,比如同步状态管理、线程的排队、等待与唤醒等底层操作,起到了一个很好的抽象作用,使用者根本不需要关心底层实现
同步器的实现是基于模板方法模式,只要使用者重写同步器里面的指定方法,随后将同步器组合在自定义的模块中,并调用同步器里面的模板方法,这些模板方法就会调用使用者重写的方法
比如队列同步器里面的acquire方法,这个方法是进行加锁的
可以看到其调用了tryAcquire方法
而tryAcquire方法就是子类需要去实现的,AQS并没有默认实现,只是抛出了一个异常
实现分析
接下来我们看看队列同步器也依赖什么来实现的
同步队列
同步器依赖内部的一个同步队列,一个FIFO的双向队列,在前面学习ReentrantLock中,可以知道其是尾插头出
当前线程如果获取锁失败,即同步状态失败时,同步器会将当前线程以及等待状态信息这些信息保存在一个Node结点里面,然后将其加入到队列,同时会阻塞该线程,当同步状态释放时,释放锁的线程会将首节点中的第一个不取消执行的线程唤醒,让其可以尝试获取同步状态
下面就来看看在AQS中数列中的结点究竟是怎么组成的
底层结点
状态
可以看到里面的状态总共有5种,并且线程的状态实际存储在waitStatus中
- Cancelled:这个值为1,由于在同步队列中等待的线程等待超时或者被中断,需要从同步队列中取消等待,结点一旦进入该状态就不会改变
- Signal:值为-1,代表后续结点的线程处于等待状态,如果当前状态为Signal的结点的线程释放了同步锁,还会去通知后续结点,让后续结点可以正常运行
- Condition:值为-2,代表结点在等待队列中(不是同步队列),当其他在Condition队列中的线程调用了Signal方法之后,该结点就会从等待队列进入到同步队列中,可以参与锁的争夺
- Propagate:值为-3,表示下一次共享式同步状态获取将会无条件地被传播下去
- Initial:值为0,初始状态(new的时候并没有设置状态,所以状态初始就为0)
然后还可以看到里面有prev、next,分别是前驱结点和后驱结点,用来形成队列
里面还有一个nextWaiter,这个nextWaiter可以认为是记录线程状态的,如果当前线程是独占的,那就会存入EXCLUSIVE变量,是一个null,如果当前线程是共享的,那么这个属性就是一个SHARED变量,从上面可以看到,这只是一个空结点,相当于仅仅只是记录这个线程的状态
还有一个thread,这个变量就是存储线程的
结点插入保证线程安全
对于底层的队列插入必须要保证线程安全
结点插入采用的是尾插法,那么是如何保证线程安全的呢
addWaiter就是插入的方法,可以看到其使用了CAS修改尾结点来保证线程安全,但CAS只有一次,如果失败了不就插入失败了吗?不就丢失一个结点了吗?
所以针对这个问题,addWaiter只有CAS修改尾结点成功才会直接返回结点,如果CAS失败,最终将会调用enq方法
可以看到enq方法是一个死循环,而且是一个循环调用CAS修改尾结点的,所以enq方法保证了插入的线程安全
独占式同步状态的获取与释放
获取锁的方法的源码如下
底层具体的分析回看ReentLock,写的已经够详细的了
下面就画一下流程图吧
具体的细节还是回看ReentrantLock
释放的代码如下
释放成功,就会去唤醒后面的线程,进而使后续结点可以重新尝试获取同步状态
共享式同步状态获取与释放
共享式与独占式获取的最主要的区别就是在于同一时刻能否有多个线程同时获取到锁,也就是获取到同步状态
以InnoDB的共享锁与排他锁为例
共享锁其实就是共享式同步状态,允许同时进行读取
排他锁就是独占式同步状态,一个事务在写的时候,不允许其他事务进行写甚至读
共享式同步
AQS也支持共享式同步状态的操作
共享式同步状态获取的实现源码为acquireShared方法
步骤如下
- 调用tryAcquireShared,即判断能否去获得共享锁
- 如果可以获得共享锁,调用doAcquireShared方法
tryAcquireShared
可以看到,其是一个抽象方法,这里用到的设置模式为模板模式
由于这里只讲AQS,这里就先不细讲
doAcquireShared
只有当tryAcquireShared返回值小于0的时候,才会进入这个方法,而tryAcquire的返回值对应的结果如下
- 小于0:获取共享锁失败
- 等于0:获取共享锁成功,但后面线程不可以获得共享锁
- 大于0:获取共享锁成功,且后面线程可以继续获得共享锁
可以看出,只有获取共享锁失败,才会进入这个方法,如果获取共享锁失败,那么就要自旋,而且还要通知后面的线程无法获取
源码如下,细节跟AQS的acquireQueued很像(acquireQueued就是入队自旋的)
private void doAcquireShared(int arg) {
//将获取共享锁失败的结点添加进底层线程队列中
final Node node = addWaiter(Node.SHARED);
//定义一个failed变量
//定义线程是否没有出错
boolean failed = true;
try {
//定义一个变量
//标记线程是否被挂起
boolean interrupted = false;
//死循环
for (;;) {
//获取当前结点在队列里的上一个结点
final Node p = node.predecessor();
//如果上一个结点是头结点
if (p == head) {
//头结点是正在执行的结点
//此时又尝试去获取共享锁,自旋去获取
int r = tryAcquireShared(arg);
//如果获取共享锁成功
//这里如果大于等于0,
//肯定是获取共享锁成功的,但不一定锁可以继续被后续线程获取
//可以看到,一旦获取共享锁成功,最后面就会直接return
//但也有个例外,
//假如这个线程自旋太多次,被挂起了,然后又被唤醒了,获取到了锁,
//但被唤醒是因为别的线程去中止他,那么最后将会被中止
if (r >= 0) {
//将头结点设置为当前结点(队列的头结点为拥有锁的结点)
//即使共享锁可以被多个线程拥有
//也要遵守队列中只能有正在排队的结点
//所以获得共享锁的线都会被设置为头结点
//然后清掉里面的thread
setHeadAndPropagate(node, r);
//断开之前的头结点
p.next = null; // help GC
//如果该线程此前是Park的,然后是被其他线程interrupt唤醒的
if (interrupted)
//将其挂起
//此时就挂在这里了
//线程就被中止了
selfInterrupt();
//当前线程正常执行
failed = false;
//直接返回
return;
}
}
//如果走到这里,就判断获取乐观锁失败
//这一步是判断是否还需要继续自旋
//如果不需要自旋,代表要被park掉
//后面就会调用parkAndCheckInterrupt将线程挂起终止
//这里与ReentrantLock一样,也是自旋两次
//如果判断要被挂起了,
//就让线程进入等待状态,并且此时如果有其他线程中止当前线程
//就会将interrupted修改为true
//再下一轮的抢锁,如果抢到锁,就会被终止,然后释放锁
//parkAndCheckInterrupt会将线程Park
//并且判断唤醒后的线程是不是被别人interrupt唤醒的
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
//如果有其他线程尝试终止这个线程
//当线程唤醒回来时,会先修改interrupted为true
interrupted = true;
}
} finally {、
//如果出现异常
//就在这里取消获取锁
if (failed)
cancelAcquire(node);
}
}
总结一下整个步骤
-
循环去判断上一个是不是头结点(相当于看前面的人多不多)
- 如果是头结点,那么就可以再进行抢锁
- 如果成功抢到锁,返回上一层
- 如果不是头结点,那么就判断前面的人是否已经休眠了,如果已经休眠,自己也休眠
- 如果是头结点,那么就可以再进行抢锁
shouldParkAfterFailedAcquire方法
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
//如果是休眠状态,返回true,代表可以park
if (ws == Node.SIGNAL)
/*
* This node has already set status asking a release
* to signal it, so it can safely park.
*/
return true;
//如果是取消状态
//底层队列跳过这个结点
if (ws > 0) {
/*
* Predecessor was cancelled. Skip over predecessors and
* indicate retry.
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
}
//如果是0或者propagate方法
//单纯只是将状态改为休眠,并没有实际park掉
//因为最后返回了false
else {
/*
* waitStatus must be 0 or PROPAGATE. Indicate that we
* need a signal, but don't park yet. Caller will need to
* retry to make sure it cannot acquire before parking.
*/
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
这个方法就是判断要不要继续去等待,需不需要Park
- 如果前面的线程是状态Signal,代表前面的已经park了,所以自己也要Park
- 如果前面的线程不是Signal(是0或者Propagate),那么就将前面的线程改为Signal,再CAS多一次
- 如果前面的线程是跳过的,那么就维护底层队列,将前面的线程删掉
setHeadAndPropagate
这个方法检查后面的结点是否也可以拥有共享锁,tryAcquireShared返回值如果为负值,代表当前线程获取共享锁失败;如果为0,代表当前线程获取共享锁成功,但后续线程不能获取;如果为正值,代表当前线程获取共享锁成功,且后续线程也可以获取共享锁
源码如下
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
//setHead这个方法前面已经讲过
//这个方法只是简单地将头结点设置成当前结点,但并不保留里面的线程
setHead(node);
//如果tryAcquireShared的返回值为正值
//即propagete > 0,那么即当前线程获取共享锁成功,并且后续线程也可以获得共享锁
//判断h == null是因为线程入队时,会有一个null问题,此处是解决Null问题的
//然后判断头结点的线程状态是否允许共享锁传播
//其实判断waitStatus状态小于0,是会唤醒一些不必要的线程
//因为只有waitStatus == -3即,为Propagate时,才是允许传播
//但这里是直接判断小于0,那么有一些线程休眠了,状态为-1也是会唤醒的
//不过我这里没看懂的是,为什么后面又来了一次判断头结点?
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
//获取下一个结点
Node s = node.next;
//如果下一个结点为空,或者也是来获取共享锁的
if (s == null || s.isShared())
//唤醒后续结点并且保证共享锁传播
doReleaseShared();
}
}
我们先来看一下isShared方法
这个方法是Node结点里面的方法,判断当前线程是否在以共享模式等待,即是否等待获取共享锁(通过判断其nextWaiter状态)。
此前疏忽了在addWaiter里面的对于Node使用的构造方法
可以看到,这个构造方法,就一直在维护着所有进来的线程等待的状态,而共享状态也是在这里维护着的(反过来,独占状态也要加入队列,那么独占状态也会在这里存着)
总结一下,setHeadAndPropagate
这个方法是当线程拿到锁时,那么自己就要跳出那个队列了(成为头结点)
- 跳出队列,执行setHead方法,去成为头结点
- 传播状态,唤醒后面的结点,唤醒第一个正在Park的结点,让他去可以CAS抢锁
doReleaseShared
接下来我们看看这个方法是干了什么
这个方法是唤醒后续的结点然后保证共享锁传播的
private void doReleaseShared() {
//死循环CAS,也就是自旋
for (;;) {
//获取头结点
Node h = head;
//判断队列中是否拥有结点
//如果没有那就代表队列里面没有线程等待
//不需要唤醒
if (h != null && h != tail) {
//获取头结点线程的状态
int ws = h.waitStatus;
//如果是正在休眠的,代表后续线程需要被唤醒
if (ws == Node.SIGNAL) {
//循环CAS修改其状态为正常
//因为从shouldParkAfterFailedAcquire方法里面知道
//只有前面一个线程为0或者propagate才能继续自旋
//否则就会被Park掉
//所以这里改成0,否则一唤醒马上就被Park掉了
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
//如果CAS修改成功,那么就唤醒后续线程
//这里唤醒的是最先的一个处于休眠,或者处于无条件传播共享状态的线程
//也就是唤醒后续线程
//注意这里是一个个唤醒的
//一个线程获得锁,就唤醒后面的一个线程
unparkSuccessor(h);
}
//如果头结点是正常执行的状态的
//代表后面的一个线程已经被唤醒了
//那么这个头结点线程就要变成另一个状态
//那么就将其状态修改为无条件传播共享状态
//因为当后面的线程如果获取锁失败
//CAS一次之后,就会将前面的线程状态给设置成休眠
//然后后面的线程又会被Park掉
//这里将状态改为Propagate
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
//如果头结点改变,那么就要重新进行
//如果此时又有一个线程进来,获得了锁
//会去修改头节点,执行SetHead方法
//那么此时就要重新去唤醒第一个执行的线程
//如果头结点没变,那么结束循环
if (h == head) // loop if head changed
break;
}
}
总结一下doReleaseShare(只有前面是头结点才会进入这里的)
- 判断队列里面是否有结点等待
- 如果没有结点等待,就不需要传播了
- 如果有结点等待且头结点状态为休眠的
- 先将头结点状态改成0,让后一个结点可以继续自旋至多两次,而不会判断自己应该休眠
- 然后唤醒后一个正常的结点
- 如果有结点等待且头结点状态为0的
- 将头结点状态改成Propagate,同样也是为了让后一个结点可以继续自旋至多两次
下面来说明一下这个propagate状态
这个状态有什么必要性吗?注释上说,如果第一个线程不是Signal状态,那么就需要变成PropaGate状态来确保可以继续传播,不过改为0可以自旋,改为Propagate也可以自旋,那么为什么要分开呢?
那我们就回看一下传播代码咯,传播代码就是setHeadAndPropagate方法嘛
这里再贴上来
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
//setHead这个方法前面已经讲过
//这个方法只是简单地将头结点设置成当前结点,但并不保留里面的线程
setHead(node);
//如果tryAcquireShared的返回值为正值
//即propagete > 0,那么即当前线程获取共享锁成功,并且后续线程也可以获得共享锁
//判断h == null是因为线程入队时,会有一个null问题,此处是解决Null问题的
//然后判断头结点的线程状态是否允许共享锁传播
//其实判断waitStatus状态小于0,是会唤醒一些不必要的线程
//因为只有waitStatus == -3即,为Propagate时,才是允许传播
//但这里是直接判断小于0,那么有一些线程休眠了,状态为-1也是会唤醒的
//不过我这里没看懂的是,为什么后面又来了一次判断头结点?
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
//获取下一个结点
Node s = node.next;
//如果下一个结点为空,或者也是来获取共享锁的
if (s == null || s.isShared())
//唤醒后续结点并且保证共享锁传播
doReleaseShared();
}
}
可以看到,在最后的判断,必须要原来的头结点的waitStatus小于0,才可以进行传播,如果第一个线程为0,不就后面都传播不了了吗?
也就是说,如果将头结点状态改成了0,然后下一个结点很快就拿到了锁,那么来到这里进行传播的时候,就无法通知后面的线程了,因为原先头结点的waitStaus为0,无法通过h.waitStatus小于0
共享式释放
共享锁的释放调用的方法是releaseShared
步骤如下
- 先尝试看是否可以释放锁
- 如果可以,那就唤醒后面的线程(调用的还是doReleaseShared方法)
- 返回true
- 如果不可以释放锁
- 返回false
doReleaseShared已经看过了,那么下面只看一下tryReleaseShared,看看是如何尝试进行释放锁的
tryReleaseShared
会发现,这是一个抽象方法,具体的实现由具体实现类决定