ReentrantLock是java concurrent包提供的一种锁实现。不同于synchronized,ReentrantLock是从代码层面实现同步的。
图1 reentrantLock的类层次结构图
Lock定义了锁的接口规范。
ReentrantLock实现了Lock接口。
AbstractQueuedSynchronizer中以队列的形式实现线程之间的同步。
ReentrantLock的方法都依赖于AbstractQueuedSynchronizer的实现。
Lock接口定义了如下方法:
图2 lock接口规范
1、lock()方法的实现
进入lock()方法,发现其内部调用的是sync.lock();
public void lock() {
sync.lock();
}
sync是在ReentrantLock的构造函数中实现的。其中fair参数的不同可实现公平锁和非公平锁。由于在锁释放的阶段,锁处于无线程占有的状态,此时其他线程和在队列中等待的线程都可以抢占该锁,从而出现公平锁和非公平锁的区别。
非公平锁:当锁处于无线程占有的状态,此时其他线程和在队列中等待的线程都可以抢占该锁。
公平锁:当锁处于无线程占有的状态,在其他线程抢占该锁的时候,都需要先进入队列中等待。
本文以非公平锁NonfairSync的sync实例进行分析。
public ReentrantLock() {
sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
sync = (fair)? new FairSync() : new NonfairSync();
}
由图1可知,NonfairSync继承自Sync,因此也继承了AbstractQueuedSynchronizer中的所有方法实现。接着进入NonfairSync的lock()方法。
final void lock() {
// 利用cas置状态位,如果成功,则表示占有锁成功
if (compareAndSetState(0, 1))
// 记录当前线程为锁拥有者
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
在lock方法中,利用cas实现ReentrantLock的状态置位(cas即compare and swap,它是CPU的指令,因此赋值操作都是原子性的)。如果成功,则表示占有锁成功,并记录当前线程为锁拥有者。当占有锁失败,则调用acquire(1)方法继续处理。
public final void acquire(int arg) {
//尝试获得锁,如果失败,则加入到队列中进行等待
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
acquire()是AbstractQueuedSynchronizer的方法。它首先会调用tryAcquire()去尝试获得锁,如果获得锁失败,则将当前线程加入到CLH队列中进行等待。tryAcquire()方法在NonfairSync中有实现,但最终调用的还是Sync中的nonfairTryAcquire()方法。
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
// 获得状态
int c = getState();
// 如果状态为0,则表示该锁未被其他线程占有
if (c == 0) {
// 此时要再次利用cas去尝试占有锁
if (compareAndSetState(0, acquires)) {
// 标记当前线程为锁拥有者
setExclusiveOwnerThread(current);
return true;
}
}
// 如果当前线程已经占有了,则state + 1,记录占有次数
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
// 此时无需利用cas去赋值,因为该锁肯定被当前线程占有
setState(nextc);
return true;
}
return false;
}
在nonfairTryAcquire()中,首先会去获得锁的状态,如果为0,则表示锁未被其他线程占有,此时会利用cas去尝试将锁的状态置位,并标记当前线程为锁拥有者;如果锁的状态大于0,则会判断锁是否被当前线程占有,如果是,则state + 1,这也是为什么lock()的次数要和unlock()次数对等;如果占有锁失败,则返回false。
在nonfairTryAcquire()返回false的情况下,会继续调用acquireQueued(addWaiter(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;
}
private Node enq(final Node node) {
// CAS方法有可能失败,因此要循环调用,直到当前线程的节点加入到队列中
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
Node h = new Node(); // Dummy header,头节点为虚拟节点
h.next = node;
node.prev = h;
if (compareAndSetHead(h)) {
tail = node;
return h;
}
}
else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
addWaiter()是AbstactQueuedSynchronizer的方法,会以节点的形式来标记当前线程,并加入到尾节点中。enq()方法是在节点加入到尾节点失败的情况下,通过for(;;)循环反复调用cas方法,直到节点加入成功。由于enq()方法是非线程安全的,所以在增加节点的时候,需要使用cas设置head节点和tail节点。此时添加成功的结点状态为Node.EXCLUSIVE。
在节点加入到队列成功之后,会接着调用acquireQueued()方法去尝试获得锁。
final boolean acquireQueued(final Node node, int arg) {
try {
boolean interrupted = false;
for (;;) {
// 获得前一个节点
final Node p = node.predecessor();
// 如果前一个节点是头结点,那么直接去尝试获得锁
// 因为其他线程有可能随时会释放锁,没必要Park等待
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} catch (RuntimeException ex) {
cancelAcquire(node);
throw ex;
}
}
在acquireQueued()方法中,会利用for (;;)一直去获得锁,如果前一个节点为head节点,则表示可以直接尝试去获得锁了,因为占用锁的线程随时都有可能去释放锁并且该线程是被unpark唤醒的CLH队列中的第一个节点,获得锁成功后返回。
如果该线程的节点在CLH队列中比较靠后或者获得锁失败,即其他线程依然占用着锁,则会接着调用shouldParkAfterFailedAcquire()方法来阻塞当前线程,以让出CPU资源。在阻塞线程之前,会执行一些额外的操作以提高CLH队列的性能。由于队列中前面的节点有可能在等待过程中被取消掉了,因此当前线程的节点需要提前,并将前一个节点置状态位为SIGNAL,表示可以阻塞当前节点。因此该函数在判断到前一个节点为SIGNAL时,直接返回true即可。此处虽然存在对CLH队列的同步操作,但由于局部变量节点肯定是不一样的,所以对CLH队列操作是线程安全的。由于在compareAndSetWaitStatus(pred, ws, Node.SIGNAL)执行之前可能发生pred节点抢占锁成功或pred节点被取消掉,因此此处需要返回false以允许该节点可以抢占锁。
当shouldParkAfterFailedAcquire()返回true时,会进入parkAndCheckInterrupt()方法。parkAndCheckInterrupt()方法最终调用safe.park()阻塞该线程,以免该线程在等待过程中无线循环消耗cpu资源。至此,当前线程便被park了。那么线程何时被unpark,这将在unlock()方法中进行。
这里有一个小细节需要注意,在线程被唤醒之后,会调用Thread.interrupted()将线程中断状态置位为false,然后记录下中断状态并返回上层函数去抛出异常。我想这样设计的目的是为了可以让该线程可以完成抢占锁的操作,从而可以使当前节点称为CLH的虚拟头节点。
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) {
// 如果前面的节点是CANCELLED状态,则一直提前
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
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);
}
2、unlock()方法的实现
同lock()方法,unlock()方法依然调用的是sync.release(1)。
public final boolean release(int arg) {
// 释放锁
if (tryRelease(arg)) {
Node h = head;
// 此处有个疑问,为什么需要判断h.waitStatus != 0
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;
}
可以看到,tryRelease()方法实现了锁的释放,逻辑上即是将锁的状态置为0。当释放锁成功之后,通常情况下不需要唤醒队列中线程,因此队列中总是有一个线程处于活跃状态。
总结:
ReentrantLock的锁资源以state状态描述,利用CAS则实现对锁资源的抢占,并通过一个CLH队列阻塞所有竞争线程,在后续则逐个唤醒等待中的竞争线程。ReentrantLock继承AQS完全从代码层面实现了java的同步机制,相对于synchronized,更容易实现对各类锁的扩展。同时,AbstractQueuedSynchronizer中的Condition配合ReentrantLock使用,实现了wait/notify的功能。
自旋锁可以使线程在没有取得锁的时候,不被挂起,而转去执行一个空循环,(即所谓的自旋,就是自己执行空循环),若在若干个空循环后,线程如果可以获得锁,则继续执行。若线程依然不能获得锁,才会被挂起。
使用自旋锁后,线程被挂起的几率相对减少,线程执行的连贯性相对加强。因此,对于那些锁竞争不是很激烈,锁占用时间很短的并发线程,具有一定的积极意义,但对于锁竞争激烈,单线程锁占用很长时间的并发程序,自旋锁在自旋等待后,往往毅然无法获得对应的锁,不仅仅白白浪费了CPU时间,最终还是免不了被挂起的操作 ,反而浪费了系统的资源。
可能引起的问题:
1.过多占据CPU时间:如果锁的当前持有者长时间不释放该锁,那么等待者将长时间的占据cpu时间片,导致CPU资源的浪费,因此可以设定一个时间,当锁持有者超过这个时间不释放锁时,等待者会放弃CPU时间片阻塞;
2.死锁问题:试想一下,有一个线程连续两次试图获得自旋锁(比如在递归程序中),第一次这个线程获得了该锁,当第二次试图加锁的时候,检测到锁已被占用(其实是被自己占用),那么这时,线程会一直等待自己释放该锁,而不能继续执行,这样就引起了死锁。因此递归程序使用自旋锁应该遵循以下原则:递归程序决不能在持有自旋锁时调用它自己,也决不能在递归调用时试图获得相同的自旋锁。
可重入锁,也叫做递归锁,指的是同一线程 外层函数获得锁之后 ,内层递归函数仍然有获取该锁的代码,但不受影响。
在JAVA环境下 ReentrantLock 和Synchronized都是可重入锁
Synchronized代表一种声明式编程思维, 程序员更多的是表达一种同步声明 , 由]ava系统负责具体实现, 程序员不知道其实现细节; 显式锁代表一种命令式编程思维, 程序员 实现所有细节.声明式编程的好处除了简单 , 还在于性能 , 在较新版本的Java上 , ReentrantLock 和Synchronized的性能是接近的 , Java编译器和虚拟机可 以 不断优化Synchronized的实现, 比如自动分析Synchronized的使用 , 对于没有锁竞争的场景, 自动省略对锁获取/释放的调用.
简单总结下 , 能用 Synchronized就用Synchronized, 不满足要求 时再考虑ReentrantLock
互斥锁, 指的是一次最多只能有一个线程持有的锁。如Java的Lock
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)