引言
之前我们说了java的synchronized同步关键字,解析了jdk1.6之后对其进行一系列优化原理分析,今天我们聊一聊java中另一个锁ReentrantLock。
synchronized同步关键字实现同步或者说独占是通过JVM底层进行系统调用操作Mutex Lock(互斥锁)实现的,而ReentrantLock则是通过上层代码实现,某种意义上可以说是锁的软实现(当然还是需要通过调用大量的Unsafe类进行CAS操作,最终还是要调用JVM的native方法)。所以不涉及操作系统层面的系统调用不存在线程切换和内核区和用户区的切换的开销,在jdk1.6之前性能是比synchronized好的。按照惯例,我们先看ReentrantLock怎么用。
public void test() {
ReentrantLock lock = new ReentrantLock();
try{
lock.lock();
}finally {
lock.unlock();
}
}
AQS
看ReentrantLock的源码我们会看到它是基于AbstractQueuedSynchronizer
实现的,而AbstractQueuedSynchronizer
是一个抽象的工具类,提供了独占或者共享某一个状态的工具,而独占正是Lock的核心需求,所以我们可以在ReentrantLock、Semaphore、CountDownLatch中看到它的身影。我们先看看AQS的全局变量声明。
//定义了一个Node内部类
static final class Node{}
private transient volatile Node head;
/**
* Tail of the wait queue, lazily initialized. Modified only via
* method enq to add new wait node.
*/
private transient volatile Node tail;
/**
* The synchronization state.
*/
//同步状态,大于0:线程已获得锁、0:无锁状态
private volatile int state;
AbstractQueuedSynchronizer
提供一个FIFO的双向队列存放等待的线程并且使用一个volatile的state变量来保证同一时刻只有一个线程在临界区内。换句话说就是通过一个变量来记录锁的状态。接着看看这个存放等待锁的线程队列。
//共享模式
static final Node SHARED = new Node();
//独占模式
static final Node EXCLUSIVE = null;
//如果当前node的waitStatus是CANCELLED状态那么该node代表将放弃锁竞争,将会直接被删除
static final int CANCELLED = 1;
//如果当前node的waitStatus是SIGNAL状态代表该node的线程将会被挂起(parking)
static final int SIGNAL = -1;
//如果为CONDITION状态代表该线程在等待调度
static final int CONDITION = -2;
/**
* waitStatus value to indicate the next acquireShared should
* unconditionally propagate
*/
static final int PROPAGATE = -3;
volatile int waitStatus;
volatile Node prev;
volatile Node next;
volatile Thread thread;
Node nextWaiter;
单独看AQS其实很难理解,我们进到ReentrantLock里面面具体锁的实现中看看ReentrantLock到底是怎么配合AQS来实现独占锁的。
ReentrantLock实现原理
ReentrantLock并非是直接继承AbstractQueuedSynchronizer
而是让Sync
继承AQS,ReentrantLock持有Sync
对象,ReentrantLock内部的类结构如下
+-------------------------------+
| AbstractQueuedSynchronizer |
+--------------^----------------+
|
|
+------------------------------+
| Sync |
+--------^----------^----------+
| |
| |
+-----------------------+ +-----------------------+
| NonfairSync | | FairSync |
+-----------------------+ +-----------------------+
在ReentrantLock中分公平锁和非公平锁,默认使用非公平锁,你可以通过构造函数传入一个boolean类型的参数来决定使用公平锁还是非公平锁。
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
按照上的用法,我们先进入到FairSync中看lock方法final void lock() { acquire(1); }
,
代码很简单,就一行,看方法名大概就知道是获取锁的意思,再进入这个acquire方法里面。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
//获取当前锁的状态
int c = getState();
//如果当前是无锁状态,那么直接判断是否有前驱结点,如果没有前驱结点说
//明没有等待获取锁的线程,可以直接尝试获取锁
if (c == 0) {
//判断是否有前驱结点
if (compareAndSetState(0, acquires)) {
//将当前线程设置为exclusiveOwnerThread,锁被当前线程持有 setExclusiveOwnerThread(current);
return true;
}
}
//如果锁就是被当前线程持有,就是线程重入了,那么直接更新state值
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
这段代码很简单,先尝试获取锁,尝试获取锁的具体实现是先拿到state的值,如果是state==0,说明当前没有线程获取锁,再看看现在队列里面有没有等待的线程,如果没有那么直接尝试CAS将state设置为1,如果且将线程设为自己。如果state不等于0但是当前exclusiveOwnerThread是自己那么直接更新当前state值,实现重入。这里面使用了java中短路的特性,如果tryAcquire成功后面的acquireQueued就不会被执行了。如果失败了意味着当前锁已经被别的线程持有,那么就需要添加一个waiter结点来存放当前尝试获取锁的线程,接下来我们先看看addWaiter方法。
//在ReentrantLock中mode都是独占的,所以mode是null
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) {
//先把新加的结点的前驱赋值给原来的tail结点,因prev结点和next结点是volatile修饰的,所以直接设值是线程安全的,所以只有在设置tail结点需要CAS操作
node.prev = pred;
//CAS把新结点设置tail结点
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
//运行到这里说明线程等待队列还是空的
enq(node);
return node;
}
/**
*1、这里通过一个死循环方式调用CAS,即使有高并发的场景,无限循环将会最终成功把当前线程追加到队尾
* 2、第一次循环tail肯定为null,则会初始化一个默认的node,并将head=tail指向该node
* 3、第二次循环的时候,会将当前node追加到1中创建的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;
}
}
}
}
在添加waiter时先新建一个node元素,如果尾部结点为不为空,那先将node的前驱指向tail,再尝试使用CAS方式将当前要添加的node设为tail结点,这里值得注意的是只有将新加入的node设为tail结点时用了CAS操作,原因是Node的成员变量prev
和next
被volatile修饰了,直接进行设值是线程安全的,想了解volatile的原理可以看我之前写得《如何正确使用volatile》。如果tail结点为空那么说明现在队列还是空的,需要初始化,这里使用延迟加载的方式,等到在添加结点的时候再初始化。接下来再进acquireQueued方法。
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
//中断标志
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
//又一次判断此时node的前驱结点是否为头结点,如果是头结点说明之前获得锁的线程已经执行完了,此时尝试拿锁
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 static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
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;
} 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;
}
这个方法分几步:
- 判断当前结点的前驱是否为头结点,如果是头结点那么说明它有资格去竞争锁,那么再执行一次tryAcquire尝试获取锁,如果获取锁成功那么直接将当前node设为头结点。
- 如果当前结点的前驱不是头结点那么再看结点线程取锁失败后要不要挂起,因为在ReentrantLock中Node结点初始化时并没有初始化waitStatus,
- 如果前驱结点不是头结点,会进入
shouldParkAfterFailedAcquire
方法,而该方法将当前结点的前驱结点的waitStatus设为SIGNAL,等到第二次循环时将线程挂起。
从上面的代码来看,ReentrantLock开发人员是多么不希望尽可能让新进来的线程直接通过CAS操作就拿到锁,在对线程挂起前进行了三次的tryAcquire操作,这也从侧面体现了挂起线程和对线程的唤起操作涉及到线程切换是多么的消耗性能。
看完公平锁,让我们看看非公平锁,到底公平锁公平在哪里。同样,我们先从加锁圆头看是看lock.lock()
方法。
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
和公平锁不一样,只要有线程请求锁,立马CAS一把尝试拿锁,如果成功就拿到了锁,并不会讲究先来后到,竞争到锁就是非公平锁的唯一目的。如果CAS设值state失败说明竞争锁失败,才走acquire分支,而acquire方法在非公平锁中的实现也有点不同。
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;
}
可以看到,非公平锁里面的nonfairTryAcquire
方法和公平锁tryAcquire唯一的区别就是非公平锁不会判断当前是否有线程在队列中等待锁,直接CAS一把锁,简直是只要发现当前无锁状态直接就去抢,这也体现了非公平性。
锁释放
按我们上面说的当多个线程尝试获取锁时,只有一个线程可以拿到锁,而其他的线程会放到双向队列挂起等待拿到锁的线程运行结束后释放锁。接下来看看锁释放最后一步。
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) {
//释放锁将state-1
int c = getState() - releases;
//若当前线程与独占锁线程不一致,抛出异常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
//只有state=0时才算真正释放锁,独占锁线程字段清空
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
//更新state值
setState(c);
return free;
}
锁释放在公平锁和非公平锁上都是统一实现,调用父类AQS的实现,毕竟公平和不公平的点是在获取锁上,现在锁拿到了,执行完都是要释放的。锁释放很简单:
- 更新state值,每次release都是state-1,直到state等于0才算真正释放锁,毕竟我们是可重入的锁。
- state等于0后把exclusiveOwnerThread字段置空,腾出位置。
- 找到头结点的后继结点,进行unpark操作唤醒头结点的后继结点,通知它可以起来竞争锁了。
总结
经过ReentrantLock的源码分析,我们直到可重入锁是基于AbstractQueuedSynchronizer
实现的,通过使用CAS操作一个状态值state
完成线程的独占,实现同一时刻只有一个线程可以获得锁,而其他竞争失败的线程封装成一个个Node
,将Node
存放到维护的双向队列里面,通过忙循环的方式将竞争失败的线程进行parking操作,将这些线程挂起,直到获得锁的线程执行完成,退出临界区,队列的头结点才会被唤醒,继续竞争锁。对于ReentrantLock来说具有公平锁和非公平锁的概念,公平锁顾名思义获取锁是公平的,按照FIFO(先进先出),每次只有头结点有机会竞争锁,而非公平锁则是直接竞争不管是不是头结点。ReentrantLock默认是使用非公平锁的,毕竟非公平锁若有新线程进入有可能不需要挂起就可以拿到锁,性能上比公平锁要好,毕竟要更细粒度的控制肯定要更多资源支持的。
和synchronized相比,ReentrantLock优势很明显,ReentrantLock支持可中断加锁,判断线程中断标志位,如果中断状态下可以抛出中断异常,并且ReentrantLock支持获取锁超时,并且支持condition,对生产者消费者模式的实现可以更为优雅,ReentrantLock对synchronized是一个很好的扩展。