重入锁ReentrantLock,顾名思义就是支持重进入的锁,它表示该锁能够支持一个线程对资源的重复加锁。除此之外,该锁还支持获取锁时的公平和非公平选择。
关于锁重入以及公平锁的理论请在 十一 .Java并发工具 此篇文章中查看。
下面将着重分析ReentrantLock如何实现重进入和公平性获取锁的特性,并通过测试来验证公平获取锁对性能的影响。
实现重进入
重进入是指任意线程在获取锁之后能够再次获取该锁而不会被锁所阻塞,该特性的实现需要解决以下两个问题:
- 线程再次获取锁。锁需要去识别获取锁的线程是否为占据锁的线程,如果是,则再次获取成功。
- 锁的最终释放。线程重复n次获取了锁,随后在第n次释放该锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行自增,计数表示当前锁被重复获取的次数,而锁被释放时,计数自减,当计数等于0时表示锁已经成功释放。
ReentrantLock是通过组合自定义同步器来实现锁的获取与释放,以非公平性(默认)实现为例,获取同步状态的代码如下所示:
> line: 196
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
> line: 126
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
// CAS更新同步状态,尝试获取锁
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// 如果当前线程已经获取了锁,增加同步值并返回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;
}
该方法增加了再次获取同步状态的处理逻辑:通过判断当前线程是否为获取锁的线程来决定获取操作是否成功,如果是获取锁的线程再次请求,则将同步状态藏家并返回true,表示同步状态获取成功。
成功获取锁的线程再次获取锁,只是增加了同步状态值,这也就要求ReentrantLock在释放同步状态时减少同步状态值,代码如下:
> line: 146
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;
}
如果该锁被获取了n次,那么前(n-1)次释放锁必须返回false,而只有同步状态完全释放了,才能返回true。可以看到,该方法将同步状态是否为0作为最终释放的条件,当同步状态为0时,将独占线程设为null,并返回true表示释放成功。
公平与非公平获取锁的区别
公平性与否是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求的绝对时间顺序,也就是FIFO。
对于前面非公平锁的nonfairTryAcquire(int acquires)
方法,只要CAS设置同步状态成功,则表示当前线程获取了锁,而公平锁不同:
> line: 213
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;
}
> line: 1549 AbstractQueuedSynchronizer#hasQueuedPredecessors()
public final boolean hasQueuedPredecessors() {
// The correctness of this depends on head being initialized
// before tail and on head.next being accurate if the current
// thread is first in queue.
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
// 当前线程处于队列头部时或者队列为空时返回false
// 如果队列中有一个线程在当前线程之前返回true
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
该方法与nonfairTryAcquire(int acquires)
的唯一区别就是判断条件多了一个hasQueuedPredecessors()
方法,即加入了同步队列中当前节点是否有前驱节点的判断,如果该方法返回true,则表示有线程比当前线程更早的请求获取锁,因此需要等待前驱线程获取并释放锁之后才能轮到它获取。
关于h.next ==null
存在的意义:
首先h!=t代表队列不为空,那为什么头节点的next字段会为null呢?
回想我们之前在AQS中说过的addWaiter(Node)
方法:
private Node addWaiter(Node mode) {
Node node = new Node(mode);
for (;;) {
Node oldTail = tail;
if (oldTail != null) {
node.setPrevRelaxed(oldTail);
if (compareAndSetTail(oldTail, node)) {
oldTail.next = node;
return node;
}
} else {
initializeSyncQueue();
}
}
}
想象一下,现在有两个线程,线程1调用了addWaiter(Node)
方法,在compareAndSetTail(oldTail, node)
调用成功后暂停,此时h != t && h.next == null
,线程2运行尝试获取锁,此时可以肯定它前面有一个比它先请求锁的线程,hasQueuedPredecessors()
方法应该返回true。如果没有这一步验证,那么它会错误的认为它是同步队列第一个线程。
下面编写一个测试来观察非公平和公平锁在获取锁时的区别,在测试用例中定义了内部类ReentrantLock2,该类主要公开了getQueuedThreads()
方法,该方法返回增在等待获取锁的线程列表,由于列表是逆序输出,为了方便观察结果,将其进行反转:
import org.junit.Test;
import util.SleepUtils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class FailAndUnfairTest {
private static Lock fairLock = new ReentrantLock2(true);
private static Lock unfairLock = new ReentrantLock2(false);
@Test
public void fair() {
testLock(fairLock);
}
@Test
public void unfair() {
testLock(unfairLock);
}
private void testLock(Lock lock) {
for(int i = 0; i < 5; ++i) {
Thread thread = new Thread(new Job(lock), "" + i);
thread.start();
}
SleepUtils.sleep(2);
}
private static class Job implements Runnable {
private Lock lock;
Job(Lock lock) {
this.lock = lock;
}
@Override
public void run() {
// 连续两次打印当前的线程和等待队列中的线程
for(int i = 0; i < 2; ++i) {
lock.lock();
try {
System.out.println("Lock by " + Thread.currentThread().getName() +
", Waiting by " + ((ReentrantLock2) lock).getQueuedThreadNames());
} finally {
lock.unlock();
}
}
}
}
private static class ReentrantLock2 extends ReentrantLock {
ReentrantLock2(boolean fair) {
super(fair);
}
@Override
public Collection getQueuedThreads() {
List threads = new ArrayList<>(super.getQueuedThreads());
Collections.reverse(threads);
return threads;
}
public Collection getQueuedThreadNames() {
List names = new ArrayList<>(8);
getQueuedThreads().forEach((s) -> names.add(s.getName()));
return names;
}
}
}
输出如下:
fair:
Lock by 0, Waiting by []
Lock by 4, Waiting by [3, 2, 1, 0]
Lock by 3, Waiting by [2, 1, 0, 4]
Lock by 2, Waiting by [1, 0, 4, 3]
Lock by 1, Waiting by [0, 4, 3, 2]
Lock by 0, Waiting by [4, 3, 2, 1]
Lock by 4, Waiting by [3, 2, 1]
Lock by 3, Waiting by [2, 1]
Lock by 2, Waiting by [1]
Lock by 1, Waiting by []
unfair
Lock by 0, Waiting by []
Lock by 0, Waiting by [3, 1, 4, 2]
Lock by 3, Waiting by [1, 4, 2]
Lock by 3, Waiting by [1, 4, 2]
Lock by 1, Waiting by [4, 2]
Lock by 1, Waiting by [4, 2]
Lock by 4, Waiting by [2]
Lock by 4, Waiting by [2]
Lock by 2, Waiting by []
Lock by 2, Waiting by []
观察表中的结果,公平性锁每次都是从同步队列的第一个节点获取到锁,而非公平性锁出现了一个线程连续获取锁的情况。为什么会出现线程连续获取锁的情况呢?回顾nonfairTryAcquire(int acquires)
方法,只要获取了同步状态即成功获取锁。在这个前提下,刚释放锁的线程再次获取同步状态的几率很大,使得其他线程只能在同步队列中等待。
持有锁的线程调用unlock()
方法后,会唤醒它的后继节点,但是它自己仍然会继续运行,由于此处会直接进入下一个循环再次尝试获取锁,所以会比唤醒的线程更快获得锁。
下面是getQueuedThreads()
的源码,这是AQS中的一个监视方法:
public final Collection getQueuedThreads() {
ArrayList list = new ArrayList<>();
// 从尾节点开始获取同步队列上的节点
// 因为在访问同步队列时可能有新节点增加,导致同步队列发生变化,
// 所以从队尾开始获取一个尽量准确的数据
for (Node p = tail; p != null; p = p.prev) {
Thread t = p.thread;
if (t != null)
list.add(t);
}
return list;
}
非公平锁可能会使得线程“饥饿”,为什么它又被设定为默认的实现呢?再次观察上面的结果,如果把每次不同线程获取到锁定义为1次切换,公平性锁在测试时进行了10次切换,而非公平锁只进行了5次切换,这说明非公平锁的开销更小。下面测试10个线程每个线程获取100000次锁,通过vmstat
统计测试运行时系统线程上下文切换的次数,运行结果如下所示:
import org.junit.Test;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* 测试公平锁与非公平锁在获取时线程的上下文切换次数
*/
public class FairAndUnfairTest2 {
private static Lock fairLock = new ReentrantLock(true);
private static Lock unfairLock = new ReentrantLock();
private static final int THREAD_NUM = 10;
private static final int ACQUIRE_COUNT = 100000;
@Test
public void fair() throws InterruptedException {
testLock(fairLock);
Thread.sleep(15000);
}
@Test
public void unfair() throws InterruptedException {
testLock(unfairLock);
Thread.sleep(15000);
}
private void testLock(Lock lock) throws InterruptedException {
for(int i = 0; i < THREAD_NUM; ++i) {
Thread thread = new Thread(() -> {
for(int j = 0; j < ACQUIRE_COUNT; ++j) {
lock.lock();
try {
} finally {
lock.unlock();
}
}
});
thread.start();
}
}
}
上面为使用公平锁时的监测数据,下面为非公平锁,观察cs这一列的数据,可以看出公平锁带来的上下文切换次数远高于非公平锁,因此开销也更大,性能降低。
下面是Java并发编程的一书中的数据(测试环境:unbuntu server 14.04 i5-3470 8GB,测试场景:10个线程,每个线程获取100000次锁):
对比项 | Fair | Unfair |
---|---|---|
切换次数(每秒间隔) | 187 | 159 |
40163 | 330 | |
350577 | 14390 | |
348637 | 159 | |
349682 | ||
349994 | ||
354223 | ||
211737 | ||
183 | ||
总共耗时ms | 5754 | 61 |
至此,ReentrantLock的主要源码已经分析完成,剩下的代码也都是一些监控方法,比较简单,就不再多解释了。