一、Lock类
Lock简介
在 Lock 接口出现之前,Java 中的应用程序对于多线程的并发安全处理只能基于synchronized 关键字来解决。但是 synchronized 在有些场景中会存在一些短板,也就是它并不适合于所有的并发场景。但是在 Java5 以后,Lock 的出现可以解决synchronized 在某些场景中的短板,它比 synchronized 更加灵活。
Lock的实现
Lock 本质上是一个接口,它定义了释放锁和获得锁的抽象方法,定义成接口就意味着它定义了锁的一个标准规范,也同时意味着锁应该具有不同实现。
我们看java.util.concurrent.locks包下,就提供了几种锁:
ReentrantLock:表示重入锁,它是唯一一个实现了 Lock 接口的类。重入锁指的是线程在获得锁之后,再次获取该锁不需要阻塞,而是直接关联一次计数器增加重入次数。
ReentrantReadWriteLock:重入读写锁,它实现了 ReadWriteLock 接口,在这个类中维护了两个锁,一个是 ReadLock,一个是WriteLock,他们都分别实现了 Lock接口。读写锁是一种适合读多写少的场景下解决线程安全问题的工具,基本原则是: 读和读不互斥、读和写互斥、写和写互斥。也就是说涉及到影响数据变化的操作都会存在互斥。
StampedLock: stampedLock 是 JDK8 引入的新的锁机制,可以简单认为是读写锁的一个改进版本,读写锁虽然通过分离读和写的功能使得读和读之间可以完全并发,但是读和写是有冲突的,如果大量的读线程存在,可能会引起写线程的饥饿。stampedLock 是一种乐观的读策略,使得乐观锁完全不会阻塞写线程。
Lock的类关系图
二、ReentrantLock 重入锁
重入锁,表示支持重新进入的锁,也就是说,如果当前线程 t1 通过调用 lock 方法获取了锁之后,再次调用 lock,是不会再阻塞去获取锁的,直接增加重试次数就行了。synchronized 和 ReentrantLock 都是可重入锁。
重入锁的设计目的
比如下面这个示例:
public class LockDemo {
private static Lock lock = new ReentrantLock();
public void demo01(){
try {
lock.lock();
demo02();
}finally {
lock.unlock();
}
}
public void demo02(){
try {
lock.lock();
System.out.println("测试重入锁");
}finally {
lock.unlock();
}
}
}
假设不允许重入,调用 demo01 方法获得了当前的对象锁,然后在这个方法中再去调用
demo02,demo02 中的存在同一个锁,这个时候当前线程会因为无法获得demo02 的锁而阻塞,就会产生死锁。重入锁的设计目的是避免线程的死锁。
三、ReentrantLock 的实现原理
从锁的作用我们可以知道锁的基本原理是,基于将多线程并行任务通过某一种机制实现线程的串行执行,从而达到线程安全性的目的。那么在 ReentrantLock 中,也一定会存在这样的需要去解决的问题。但是在多线程竞争重入锁时,竞争失败的线程是存放在哪儿呢,又是如何实现阻塞以及被唤醒的呢?
AQS登场
线程是放在AQS队列里面的,关于AQS队列,前面有博文进行了介绍和源码解读,可参考理解,这里不再赘述。
Sync类
我们平时加锁都是通过lock()方法进行加锁的,我们具体看一下lock方法是怎么实现的,源码如下:
public void lock() {
sync.lock();
}
我们看到是通过sync.lock()方法实现的,那么sync又是什么呢?通过ReentrantLock源码我们可以看到如下:
private final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer {
// 抽象的lock方法
abstract void lock();
...
}
在ReentrantLock类中有一个Sync抽象内部类,而且这个内部类继承了AbstractQueuedSynchronizer(AQS)。
公平锁和非公平锁(FairSync/NonfairSync )
Sync里面定义了一个抽象的lock()方法,抽象方法肯定有具体的实现,我们可以发现,源码中还有两个内部类继承了Sync,实现了lock()方法,代码如下
/**
* Sync object for non-fair locks
*/
// 非公平锁
static final class NonfairSync extends Sync {
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
// 非公平锁获取锁方法
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
/**
* Sync object for fair locks
*/
// 公平锁
static final class FairSync extends Sync {
final void lock() {
acquire(1);
}
// 公平锁获取锁方法
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
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;
}
}
什么是公平锁、非公平锁?
其实很简单,锁的公平性是相对于获取锁的顺序而言的。公平锁就是意味着先来的线程先得到锁,后来的线程后得到锁,非公平锁就是后来的线程可能先得到锁。
我们从代码来看他们的差异,在FairSync和NonfairSync类中都有一个lock()和tryAcquire()方法,如上面的代码,看一下这个两个方法的实现的差异
非公平锁在获取锁的时候,会先通过 CAS 进行抢占,而公平锁则不会
公平锁在判断条件多了hasQueuedPredecessors()方法,也就是加入了[同步队列中当前节点是否有前驱节点]的判断,如果该方法返回 true,则表示有线程比当前线程更早地请求获取锁,因此需要等待前驱线程获取并释放锁之后才能继续获取锁
下面我们以非公平锁的实现来解读ReentrantLock源码
加锁过程
lock方法
final void lock() {
// 先通过CAS尝试去加锁,我们可以看到,加锁其实就是把state字段从0改成1
if (compareAndSetState(0, 1))
// CAS加锁成功,把当前线程设置为独占状态
setExclusiveOwnerThread(Thread.currentThread());
else
// 如果加锁不成功,就调用AQS中的acquire()方法去竞争锁
acquire(1);
}
acquire方法
acquire 是 AQS 中的方法,如果 CAS 操作未能成功,说明 state 已经不为 0,此时继续 acquire(1)操作
➢ 大家思考一下,acquire 方法中的 1 的参数是用来做什么呢?
这个方法的主要逻辑是
- 通过 tryAcquire 尝试获取独占锁,如果成功返回 true,失败返回 false
- 如果 tryAcquire 失败,则会通过 addWaiter 方法将当前线程封装成 Node 添加到 AQS 队列尾部
- acquireQueued,将 Node 作为参数,通过自旋去尝试获取锁。
public final void acquire(int arg) {
if (!tryAcquire(arg) && // 先尝试去竞争锁
// 如果没有竞争到锁,就先添加到等待队列
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
// 如果线程被中断了,在这一步进行中断的处理
// 就是响应parkAndCheckInterrupt返回的interrupted状态
selfInterrupt();
}
tryAcquire()方法
这个方法的作用是尝试获取锁,如果成功返回 true,不成功返回 false
它是重写 AQS 类中的 tryAcquire 方法,并且大家仔细看一下 AQS 中 tryAcquire方法的定义,并没有实现,而是抛出异常。按照一般的思维模式,既然是一个不实现的模版方法,那应该定义成 abstract,让子类来实现呀?大家可以想想为什么
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
nonfairTryAcquire方法
- 获取当前线程,判断当前的锁的状态
- 如果 state=0 表示当前是无锁状态,通过 cas 更新 state 状态的值
- 当前线程是属于重入,则增加重入次数
final boolean nonfairTryAcquire(int acquires) {
// 获取当前线程
final Thread current = Thread.currentThread();
// 获取锁状态
int c = getState();
// c==0表示没有加锁
if (c == 0) {
//cas 替换 state 的值,cas 成功表示获取锁成功
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;
}
addWaiter方法
当 tryAcquire 方法获取锁失败以后,则会先调用 addWaiter 将当前线程封装成Node.
入参 mode 表示当前节点的状态,传递的参数是 Node.EXCLUSIVE,表示独占状态。意味着重入锁用到了 AQS 的独占锁功能
- 将当前线程封装成 Node
- 当前链表中的 tail 节点是否为空,如果不为空,则通过 cas 操作把当前线程的node 添加到 AQS 队列
- 如果为空或者 cas 失败,调用 enq 将节点添加到 AQS 队列
private Node addWaiter(Node mode) {
// 将当前线程封装成node,并且设置mode为独占模式
Node node = new Node(Thread.currentThread(), mode);
// 将tail赋值给pred变量
Node pred = tail;
// 如果pred不为null,表示当前队列里面有等待线程,下面操作就是把当前这个新的node节点放到队列最后
if (pred != null) {
// 将当前节点的prev指针指向pred
node.prev = pred;
// 通过CAS设置tail指向当前的node
if (compareAndSetTail(pred, node)) {
// pred的next指针指向node
pred.next = node;
// 返回当前节点
return node;
}
}
// 如果pred是null,表示当前队列未初始化,则通过enq方法初始化队列,并把node添加到队列,这个方法在AQS文章中分析过,不再多说
enq(node);
// 返回当前节点
return node;
}
假设 3 个线程来争抢锁,那么截止到 enq 方法运行结束之后,或者调用 addwaiter方法结束后,AQS 中的链表结构图
acquireQueued方法
通过 addWaiter 方法把线程添加到链表后,会接着把 Node 作为参数传递给acquireQueued 方法,去竞争锁
- 获取当前节点的 prev 节点
- 如果 prev 节点为 head 节点,那么它就有资格去争抢锁,调用 tryAcquire 抢占锁
- 抢占锁成功以后,把获得锁的节点设置为 head,并且移除原来的初始化 head节点
- 如果获得锁失败,则根据 waitStatus 决定是否需要挂起线程
- 最后,通过 cancelAcquire 取消获得锁的操作
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
// 设置线程中断状态
boolean interrupted = false;
// 自旋
for (;;) {
// 获取当前节点的前一个节点
final Node p = node.predecessor();
// 如果前一个节点是head节点,就再去竞争锁,如果tryAcquire返回false,则线程会在当前位置挂起,获得锁后,会继续从这里执行
if (p == head && tryAcquire(arg)) {
// 如果获取到锁,把当前节点设置为head节点,setHead方法里面会把当前节点的prev指针指向null,
// private void setHead(Node node) {
// head = node;
// node.thread = null;
// node.prev = null;
// }
setHead(node);
// 把原来的head节点的next指针指向null,到这一步,将相当于把原来的节点从队列中删除了
p.next = null; // help GC
failed = false;
return interrupted;
}
// ThreadA 可能还没释放锁,使得ThreadB 在执行 tryAcquire 时会返回false;shouldParkAfterFailedAcquire从方法名称可以看出,意思是获取锁失败应该阻塞;
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt()) // 如果shouldParkAfterFailedAcquire反回true,就挂起当前线程,并且判断是否被中断,
// 如果parkAndCheckInterrupt反回true,表示线程被中断过,则interrupted=true,抛到上层去响应中断
interrupted = true;
}
} finally {
if (failed)
// 回滚
cancelAcquire(node);
}
}
shouldParkAfterFailedAcquire方法
如果 ThreadA 的锁还没有释放的情况下,ThreadB 和 ThreadC 来争抢锁肯定是会失败,那么失败以后会调用shouldParkAfterFailedAcquire 方法
这个方法的主要作用是,通过 Node 的状态来判断,ThreadA 竞争锁失败以后是否应该被挂起。
- 如果 ThreadA 的 pred 节点状态为 SIGNAL,那就表示可以放心挂起当前线程
- 通过循环扫描链表把 CANCELLED 状态的节点移除
- 修改 pred 节点的状态为 SIGNAL,返回 false.
返回 false 时,也就是不需要挂起,返回 true,则需要调用 parkAndCheckInterrupt挂起当前线程
// pred 表示当前节点的prev节点
// node 当前节点
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 获取前置节点的状态
int ws = pred.waitStatus;
// 如果前置节点的状态是SIGNAL,则把当前线程挂起就好
if (ws == Node.SIGNAL)
return true;
// 获取前置节点的状态>0,表示是Cance状态,此时线程是超时(timeOut)状态,是无效的,永远不会被唤醒了,
// static final int CANCELLED = 1;
// static final int SIGNAL = -1;
// static final int CONDITION = -2;
// static final int PROPAGATE = -3;
//ws 大于 0,意味着 prev 节点取消了排队,直接移除这个节点就行
if (ws > 0) {
// 前驱节点的ws > 0,说明ws = Cancelled,表示前驱线程被取消,从前驱节点继续往前遍历,从双向列表中移除 CANCELLED 的节点
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else { // 这种情况表示前驱节点的 ws = 0 或者 ws = PROPAGATE,不能挂起线程,更改状态为SIGNAL
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
parkAndCheckInterrupt方法
使用 LockSupport.park 挂起当前线程编程 WATING 状态
Thread.interrupted,返回当前线程是否被其他线程触发过中断请求,也就是thread.interrupt(); 如果有触发过中断请求,那么这个方法会返回当前的中断标识true,并且对中断标识进行复位标识已经响应过了中断请求。如果返回 true,意味着在 acquire 方法中会执行 selfInterrupt()。
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
通过 acquireQueued 方法来竞争锁,如果 ThreadA 还在执行中没有释放锁的话,意味着 ThreadB 和 ThreadC 只能挂起了。
释放锁过程
unlock()方法
public void unlock() {
// 调用AQS的release方法
sync.release(1);
}
release方法
public final boolean release(int arg) {
// 尝试释放锁
if (tryRelease(arg)) {
// 释放锁成功,将head节点赋值给h
Node h = head;
// 如果h
if (h != null && h.waitStatus != 0)
// 唤醒节点
unparkSuccessor(h);
return true;
}
return false;
}
tryRelease方法
protected final boolean tryRelease(int releases) {
// 获取锁状态,然后减去unlock次数,因为是重入锁,所以state可以是>1的,所得的c表示锁的重入次数
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
// 释放锁成功标志默认设置为false
boolean free = false;
// 判断c==0,当为0时,表示释放锁
if (c == 0) {
// 释放锁成功标志设置为true
free = true;
// 设置独占线程(占有锁的线程)为null,以方便其他线程获得锁
setExclusiveOwnerThread(null);
}
// 设置锁状态(锁的重入次数)
setState(c);
// 返回释放锁成功标识
return free;
}
unparkSuccessor方法
// 注意此时参数node是head节点
private void unparkSuccessor(Node node) {
// 获取当前node的状态,
int ws = node.waitStatus;
// ws<0,表示是处于signal状态,是需要被被唤醒的
if (ws < 0)
// 通过CAS尝试更改锁状态
compareAndSetWaitStatus(node, ws, 0);
//
Node s = node.next;
// 如果下一个节点为 null 或者 status>0 表示 cancelled 状态.
// 否则直接跳过
if (s == null || s.waitStatus > 0) {
// 表示waitStatus=0,没有处于等待状态
s = null;
// 通过从尾部节点开始扫描,找到距离 head 最近的一个waitStatus<=0 的节点
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
// unpark是用来唤醒指定线程
LockSupport.unpark(s.thread);
}
通过锁的释放,原本的结构就发生了一些变化。head 节点的 waitStatus 变成了 0,ThreadB 被唤醒
原本挂起的线程会继续执行
通过 ReentrantLock.unlock,原本挂起的线程被唤醒以后继续执行,应该从哪里执行大家还有印象吧。 原来被挂起的线程是在 acquireQueued 方法中,所以被唤醒以后继续从这个方法开始执行
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
// 自旋
for (;;) {
final Node p = node.predecessor();
// 如果前一个节点是head节点,就再去竞争锁,如果tryAcquire返回false,则线程会在当前位置挂起,获得锁后,会继续从这里执行
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null;
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
这个方法前面已经完整分析过了,我们只关注一下 ThreadB 被唤醒以后的执行流程。
由于 ThreadB 的 prev 节点指向的是 head,并且 ThreadA 已经释放了锁。所以这个时候调用 tryAcquire 方法时,可以顺利获取到锁
- 把 ThreadB 节点当成 head
- 把原 head 节点的 next 节点指向为 nul
四、总结
整个加锁和释放锁的过程可以抽象为下面一个流程图
本文是综合自己的认识和参考各类资料(书本及网上资料)编写,若有侵权请联系作者,所有内容仅代表个人认知观点,如有错误,欢迎校正; 邮箱:1354518382@qq.com 博客地址:https://blog.csdn.net/qq_35576976/