从源码拆解ReentrantLock:为什么它能成为Java并发锁的首选?

你有没有过线上服务突然出现线程阻塞,排查半天发现是锁竞争导致的?ReentrantLock作为Java并发包中最常用的显式锁,它的内部到底是如何实现线程调度的?本文将带你深入JDK源码,拆解ReentrantLock的核心原理。
一、ReentrantLock到底是什么?为什么要使用它?
ReentrantLock是java.util.concurrent.locks包下的显式锁实现类,相比synchronized关键字,它提供了更灵活的锁操作能力:支持公平/非公平锁切换、可中断锁获取、超时锁获取、多条件变量绑定等特性。在高并发场景下,合理使用ReentrantLock可以有效优化线程调度效率,避免不必要的上下文切换。
很多开发者在使用ReentrantLock时,只知道它可以用来解决线程安全问题,但并不清楚它的底层是如何实现锁的获取、排队和唤醒的。今天我们就从JDK 1.8的源码入手,一步步拆解ReentrantLock的核心执行流程。
二、从lock()方法入手:非公平锁的源码执行全流程
我们平时使用ReentrantLock时,最常用的就是lock()和unlock()方法。我们先从lock()方法开始分析:
// ReentrantLock.java
public void lock() {
sync.lock();
}
这里的sync是ReentrantLock的内部抽象类Sync的实例,根据构造方法参数的不同,sync会被实例化为NonfairSync(非公平锁,默认)或者FairSync(公平锁)。我们先来看默认的非公平锁实现:
// NonfairSync.java
final void lock() {
// 第一步:直接通过CAS尝试修改锁状态
if (compareAndSetState(0, 1))
// CAS成功,设置当前线程为独占线程
setExclusiveOwnerThread(Thread.currentThread());
else
// CAS失败,调用AQS的acquire方法
acquire(1);
}
这段代码是非公平锁的核心体现:不管当前等待队列中是否有排队的线程,新调用lock()的线程都会先尝试直接抢占锁,而不是直接进入队列排队。这也是非公平锁吞吐量更高的原因,但会存在线程饥饿的可能性。
接下来我们看acquire(1)方法,这个方法是AQS(AbstractQueuedSynchronizer)的核心方法之一:
// AbstractQueuedSynchronizer.java
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
这个方法的逻辑可以分为三步:
1. 调用tryAcquire(arg)再次尝试获取锁
2. 如果获取失败,将当前线程封装为Node节点加入等待队列
3. 调用acquireQueued方法让线程在队列中自旋等待,直到获取锁或者被中断
我们先看tryAcquire方法的非公平实现:
// NonfairSync.java
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
// Sync.java
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 再次尝试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;
}
这段代码实现了可重入锁的核心逻辑:如果当前线程已经持有锁,那么再次获取锁时只需要增加锁的状态值,而不需要重新排队。
如果tryAcquire失败,就会调用addWaiter方法将当前线程封装为Node节点加入等待队列:
// AbstractQueuedSynchronizer.java
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// 尝试快速入队
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 快速入队失败,调用enq方法自旋入队
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;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
最后是acquireQueued方法,这是线程在队列中等待的核心逻辑:
// AbstractQueuedSynchronizer.java
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);
}
}
这个方法会让线程在队列中自旋等待,只有当节点的前驱是头节点时,才会再次尝试获取锁。如果获取失败,就会挂起当前线程,等待被前驱节点唤醒。
三、公平锁和非公平锁的核心差异
很多开发者会混淆公平锁和非公平锁的区别,其实它们的核心差异就在于抢锁的时机:
– 非公平锁:新线程调用lock()时,会先尝试直接抢锁,失败后再进入队列排队
– 公平锁:新线程调用lock()时,会先判断等待队列中是否有排队的线程,如果有就直接进入队列排队
公平锁的lock方法实现如下:
// FairSync.java
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;
}
可以看到,公平锁的tryAcquire方法多了一个hasQueuedPredecessors()判断,这个方法会检查当前队列中是否有比当前线程更早的等待线程。如果有的话,就不会尝试抢锁,直接进入队列排队。
四、unlock()方法的源码解析:锁是如何释放的?
说完了锁的获取,我们再来看锁的释放流程。unlock()方法的代码如下:
// ReentrantLock.java
public void unlock() {
sync.release(1);
}
这里的release方法是AQS的方法:
// AbstractQueuedSynchronizer.java
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
release方法会先调用tryRelease方法尝试释放锁,如果释放成功,就会唤醒队列中的后继节点。
tryRelease方法的实现如下:
// Sync.java
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
// 校验当前线程是否是独占线程
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
// 如果锁状态为0,说明完全释放了锁
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
这段代码的逻辑很简单:减少锁的状态值,如果状态值为0,就清空独占线程的引用,返回true表示锁完全释放。
如果锁完全释放,就会调用unparkSuccessor方法唤醒后继节点:
// AbstractQueuedSynchronizer.java
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);
}
这个方法会找到队列中第一个有效的后继节点,唤醒它的线程,让它重新尝试获取锁。
六、常见误区与最佳实践
在使用ReentrantLock时,有几个常见的误区需要注意:
1. 忘记释放锁:ReentrantLock不会自动释放锁,必须在finally块中调用unlock()方法,否则会导致其他线程永远无法获取锁。
2. 过度使用可重入锁:虽然可重入锁很方便,但过度嵌套会增加锁的状态值,增加锁释放的复杂度。
3. 公平锁的性能问题:公平锁虽然可以避免线程饥饿,但会增加大量的队列操作,吞吐量会比非公平锁低很多,除非业务场景要求严格的线程执行顺序。
4. 正确使用条件变量:ReentrantLock可以绑定多个Condition对象,用于实现更复杂的线程通信场景,但必须在获取锁之后才能调用Condition的await()和signal()方法。
七、总结
ReentrantLock作为Java并发包中的核心组件,其内部实现依赖于AQS队列和CAS操作。通过本文的源码拆解,我们可以看到:
– 非公平锁通过先尝试直接抢锁的方式提高了吞吐量,但可能导致线程饥饿
– 公平锁通过严格的队列顺序避免了线程饥饿,但牺牲了部分性能
– 可重入性通过记录锁的持有线程和状态值实现
– 锁的释放流程会唤醒后继节点,让队列中的线程重新尝试获取锁
掌握ReentrantLock的核心原理,可以帮助我们在高并发场景下更好地优化线程调度,解决实际开发中的线程安全问题。如果你在使用ReentrantLock时遇到过什么问题,欢迎在评论区留言交流!
夜雨聆风