JDK源码研究——ReentrantLock浅析

从今天开始,要执行自己的学习计划了!!写正文之前,先发一下牢骚。前几天租的地方断网了,说是要进行城中村网络线路改造,把原来的所有的网线都暴力剪断了!搞得好几天上不了网。现在没有网络,生活中总感觉缺少点什么东西。虽然上网也干不了什么东西,但就是会觉得比较烦闷。以前没有网络的时候不也好好的嘛,所以说,互联网真的是已经深刻地改变了我们的生活,已经成为了生活中必不可少的东西。谷物是生活食量,而网络就是精神食量了。现在貌似也没有听说过“网瘾”这个词了,这个词在早些年可是一个标准的贬义词来着。看来人们的思维也是在时刻发生着变化的。废话有点多,进入正题!

本文简单地谈一谈从JDK1.5开始引入的java.util.concurrent(简称JUC)包下的ReentrantLock类。Reentrant的英文含义是“可重入的”,也就是说ReentrantLock表示可重入的锁。这个类是用纯的java语言来实现synchronized关键字的功能,并且补充了synchronized没有实现的部分功能。由于能力有限,只能从浅层次来对ReentrantLock进行分析。本文的主要内容如下:

  1. 浅析ReentrantLock的核心源代码;
  2. 解释一下自己所理解的公平锁和非公平锁;
  3. 把ReentrantLock和synchronized做一下简单地对比。

1 ReentrantLock的核心源代码

首先,感觉源代码的分析工作实在是不好做。贴太多代码吧,让人看得昏昏欲睡;不贴吧,光用文字和图片又说不太清楚。太深入吧,代码一层套一层,讲完
下层还得回到上层,反正我是理解不了了。准备以自己看代码的顺序和思维方式来讲讲ReentrantLock的源代码(JDK1.7),大致的分析顺序为:

  1. 代码的整体结构
  2. 类的javadoc要点
  3. 核心内部类和方法(调用层次不超过3层)。

1.1 ReentrantLock类的整体结构

首先先看一下ReentrantLock类的继承结构。类的签名如下:

1
public class ReentrantLock implements Lock, java.io.Serializable

ReentrantLock类实现Serializable接口,表示这个类是可以序列化和反序列话的,也就是说ReentrantLock对象可以保存到硬盘中,通过网络传输,或者其他的其他方式。实现了Lock接口表示这是锁的一种,Lock接口是一个独立的接口,没有继承其他接口。它定义了所有锁的一系列基本操作:

1
2
3
4
5
6
7
8
9
10
11
void lock(); // 尝试获取锁,如果没有成功,则阻塞当前线程。
void lockInterruptibly() throws InterruptedException;
boolean tryLock(); // 尝试获取锁,如果不成功,则直接放弃锁,并返回false。成功的话,则加锁,并返回true。
boolean tryLock(long time, TimeUnit unit) throws InterruptedException; // 尝试获取锁,然后等待time时间后仍然没有成功,则返回false。
void unlock(); // 释放锁
Condition newCondition(); // 创建新的Condition实例。Condition是通过Java代码实现object.wait(),object.notify()和object.notifyAll()的功能。

1.2 ReentrantLock类javadoc要点

Javadoc是JDK的重要的资料和资源,通常,类和方法的一些重要信息都会在里面提及。Javadoc里面的英文还算比较好懂,是比较经典的技术文档的写好。认真研究这些Javadoc可以提高自己的文档注释能力。写一些优雅的文档和注释,应该是一个IT人员的基本素养。写好注释和文档,你好我好他也好!

下面尝试着翻译一下ReentrantLock类javadoc。

synchronized方法或申明可以隐式地监视锁,可重入锁除了具备与其相同的基本行为和语义外,还附加了其他功能。
可重入锁属于最后成功加锁,但还没有释放锁的线程。如果锁不属于其他线程,则当前线程可以通过调用lock方法成功地获取锁。如果当前线程就是锁的拥有者,那么调用lock方法就可以立刻返回。这些可以通过调用isHeldByCurrentThread和getHoldCount方法来检测。
本类的构造函数能够接收一个fairness参数。如果这个参数被设置为true,则锁倾向于授予等待最久的那个线程。否则,不保证任何特定的访问顺序。如果程序中使用公平锁,当大量线程访问锁时,其吞吐量通过远小于使用非公平锁。但却有更小的时间间隔(两个线程获得锁的时间差),并且可以保证不会出现线程饥饿。然而,公平锁并不能保证线程调度的公平性。这样的话,有可能会出现同一个线程多次成功获得锁,而另外的活动线程却无法继续运行,并且当前没有持有锁的情况。
注意,如果使用不限时的tryLock方法,则不遵守公平性设置。如果锁是空闲的,使用不限时的tryLock方法可以成功地获取锁,即使还有其他的锁在等待。
一种值得推荐的用法是:在调用lock方法后,立刻接上try代码块。典型的用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
* class X {
* private final ReentrantLock lock = new ReentrantLock();
* // ...
*
* public void m() {
* lock.lock(); // block until condition holds
* try {
* // ... method body
* } finally {
* lock.unlock()
* }
* }
* }

除了实现了Lock接口外,本类还定义了isLocked和getLockQueueLength方法。此外,还定义了一些相关的protected级别的方法,方便记录和监控。
序列化这个类的实例时,与内建的锁的行为相同。即不管序列化时对象是何种状态,反序列化后其一定是未锁定的。
这种锁支持被同一个线程锁定的最大重数为2147483647。试图超过这个限制,会导致锁定时出现错误。

1.3 核心内部成员和方法分析

下面列出重要的成员变量(类)和方法。

1
2
3
4
5
6
7
private final Sync sync; // 私有的同步器类,这个类是ReentrantLock定义的内部类。final表示锁的性质一旦确定,不可更改。
abstract static class Sync extends AbstractQueuedSynchronizer // 包级别的内部静态类,它集成的AbstractQueuedSynchronizer(简称A.Q.S)类是整个J.U.C包的基础,定义了带队列的同步的器的功能。它有两个子类NonfairSync(非公平同步器)和FairSync(公平的同步器),
static final class NonfairSync extends Sync; // 非公平同步器
static final class FairSync extends Sync; // 公平同步器

如果上面的第1行代码中sync指向NonfairSync实例,表示锁是非公平锁,如果指向FairSync实例,则表示锁为非公平锁。它们的区别会在接下的内容详细解释。下面详细地分析ReentrantLock类中的主要方法,如果可以,也会试着按照自己的理解来解释为什么要这么实现。

(1) lock()方法

这个方法直接调用sync.lock()方法,这是在Sync类的一个抽象方法,需要在子类中实现。NonfairSync和FairSync是Sync的两个实现类,lock()方法的实现就体现了这个类的差异。首先看下NonfairSync的lock()方法,该方法的源代码如下:

1
2
3
4
5
6
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}

第2行代码的compareAndSetState尝试修改state(0表示锁空闲,大于0表示忙)字段的值,它直接调用UnSafe类的compareAndSwapInt(简称CAS)方法,通过硬件指令来实现安全地修改变量值。如果state修改成功,则表示当前线程成功地获得锁,于是调用setExclusiveOwnerThread方法将锁的拥有者设置为当前线程。否则,调用acquire(1)方法。这是AbstractQueuedSynchronizer类的方法,该方法的源代码如下:

1
2
3
4
5
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}

该方法首先调用tryAcquire再一次尝试加锁,这样做的目的是尽可能地提高锁的性能。假设此时加好锁空闲,那么这样做能尽快地获取锁。这个方法是AbstractQueuedSynchronizer类中的一个protected级别的方法,方法内部直接抛出UnsupportedOperationException。这是java一个比较常用的技巧,protected修饰的方法用于继承,抛出UnsupportedOperationException异常表示方法内部没有任何的逻辑代码,全靠子类自己实现。NonfairSync和FairSync类都实现了这个方法,这个方法的实现就体现了它们之间本质性的不同。先看看NonfairSync的tryAcquire的实现,它直接调用了Sync类的nonfairTryAcquire的方法,那就直接看看这个方法代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) { // 如果当前锁空闲,则直接尝试加锁。acquires可以看做是锁的重数,如果加了n重锁,则需要释放n重锁方能完全地释放锁。
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) { // 如果当前线程就是锁的拥有者,则直接加上acquires重锁即可。
int nextc = c + acquires;
if (nextc < 0) // overflow // 超过重数限制,抛出Error类型异常。超过int类型取值,则数直接变成负数。
throw new Error("Maximum lock count exceeded");
setState(nextc); // 设置新的状态
return true;
}
return false; // 不属于上面的情况,就直接返回false。
}

这个方法首先查看锁是否空闲,一旦空闲,则直接尝试加锁,如果加锁成功直接返回true,否则返回false。如果锁非空闲,查看当前锁的拥有者是否为当前线程本身,如果是,则在原来的基础上加上新的重数,这就是为什么这个锁叫做可重入锁。拥有锁的线程无需再次加锁即可直接进入加锁的代码区域。下面看一下FairSync的tryAcquire的实现代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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()) { // 如果当前线程就是锁的拥有者,则直接加上acquires重锁即可。
int nextc = c + acquires;
if (nextc < 0) // 超过重数限制,抛出Error类型异常。超过int类型取值,则数直接变成负数。
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false; // 不属于上面的情况,就直接返回false。
}

可以明显地看到FairSync的tryAcquire的实现与NonfairSync实现的唯一不同点就是在锁空闲时的处理方式。在锁空闲时,它不会急于抢占锁,而是先查看当前是否有线程等待,如果有,就不会去尝试加锁,那么等待队列的线程就可以获得运行机会。这与NonfairSync是完全不同的,理论上讲,NonfairSync的处理方式可能会导致等待队列里的线程永久或者很长时间无法运行,而出现线程饥饿。这两种类型的锁的区别会在接下来的内容中较为详细的介绍。

如果没有通过tryAcquire成功获取锁,则acquire方法就会调用acquireQueued(addWaiter(Node.EXCLUSIVE), arg)方法。这个方法比较复杂,也是比较独立的部分。由于本篇文章是浅析,所以不打算分析这个部分内容,搞太多东西容易让人发晕。如果真想了解这个部分的内容,可以参考这篇文章。简单点说,这个方法就是将没有成功获得锁的线程假如到等待队列,这个队列是一个阻塞队列。如果线程一直处于等待状态,直到其获得锁方可继续运行。如果没有成功的插入等待队列,则调用selfInterrupt方法直接中断当前线程。

接下来再看看FairSync的lock方法的实现。lock方法源代码如下:

1
2
3
final void lock() {
acquire(1);
}

与NonfairSync的lock方法不同的是,当前线程并不会在刚开始就尝试加锁,而是直接调用acquire(1)方法。这个方法在上面已经详细解释过了,则不赘述了。

请注意,上面的方法都是final的,表示这些方法不能被子类所覆盖。

(2)tryLock()方法

该方法尝试加锁,如果没有成功,则直接作罢。它适合那些对锁的需求不是那么强烈的场景。举个例子,假设你突然觉得肚子不舒服,于是放下手中的工作跑去厕所。不巧的是,唯一的厕所已经被别人占领了,如果你还能忍得住的话,完全可以先去工作,过一段时间再来看看。这种时候使用TryAcquire是比较合适的,它不会因为加锁不成功而阻塞当前线程,这样可以提高工作效率。源代码如下:

1
2
3
public boolean tryLock() {
return sync.nonfairTryAcquire(1);
}

可以惊讶得看到它直接调用nonfairTryAcquire而非tryAcquire方法,所以当前线程会尽可能地抢夺锁。

(3)unlock()方法

该方法直接释放锁,方法体内仅仅调用AbstractQueuedSynchronizer的release方法,那么就来直接看看AbstractQueuedSynchronizer的release方法代码如下:

1
2
3
4
5
6
7
8
9
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方法尝试释放锁,如果释放失败,则直接返回false。下面来看看tryRelease方法的源代码。

1
2
3
4
5
6
7
8
9
10
11
12
protected final boolean tryRelease(int releases) {
int c = getState() - releases; // 减去相应的重数
if (Thread.currentThread() != getExclusiveOwnerThread()) // 如果当前线程不是锁的拥有者,则抛出异常
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) { // 如果锁的state是0,则表示锁已经完全释放,将锁置为空闲状态。否则,仅仅减少锁的重数。
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}

首先判断当前要释放锁的线程是否是锁的拥有者,如果不是直接抛出IllegalMonitorStateException,通过这个异常名就知道这是什么异常了。所以说,为要抛出的异常取好名是非常重要的,好的异常名能一眼就看出问题是什么。这就说明只有拥有锁的线程才有权释放锁,这也是自然的。只有当锁的重数为0时才算真正地释放了锁,也就是所加锁重数和放锁重数是对应的。

以上就是ReentrantLock类中的常用方法的实现,当然ReentrantLock中有一些其他的方法,这些方法实现都比较简单。

2 再谈公平锁和非公平锁

从上面的源代码的分析中其实就可以看出来公平锁和非公平锁的区别在哪里,本节试着用日常化的语言和实例再来谈谈它们的区别。

非公平锁是直接尝试加锁,一旦成功,当前线程就是锁的拥有者(有可能会导致队列里的等待线程一直拿不到锁);公平锁要先判断等待队列是否有等待线程,如果是,则当前线程不加锁而进入等待队列,那么队首线程就有机会获得锁。公平性体现在这!!

举一个编造的例子。三国时蜀国封5虎将,分别为关羽、张飞、赵云、马超和黄忠。蜀主刘备分别为它们进行受封典礼,每次受封一名。关羽因留守荆州,姗姗入川。首先张飞进店受封,其余3人在殿外等候。张飞受封完毕,老蒋黄忠正要进殿,关羽急冲冲赶到,大喊一声:“慢!”。
“让我先进去”,关羽对黄忠说。
“凭啥?”,黄忠不悦,对关羽说。
“吾是陛下结拜兄弟,征战多年,立下汗马功劳。其余各位均是当世英雄,汝,败军之将耳,何德何能,竟能与吾平起平坐?!”
于是两人争执,均要先入。那么问题来了,谁应该先进去受封?
(1)如果刘备说,你们自己抢,强者先入。那么这就是不公平的。
(2)如果说,殿外等得时间最久者先入。那么就是公平的。

3 ReentrantLock和synchronized的对比

ReentrantLock被设计为synchronized的Java实现,除了实现了synchronized原来的功能和语义之外,还添加了其他的额外的功能。下面来对他们进行一下对比,看看它们的适用场景。

(1)字节码

首先来看一下使用ReentrantLock的示例源代码及其编译后的字节码,直接看到字节码的第 行的代码,通过invokeinterface指令调用lock方法。invokeinterface指令是java语言调用接口方法的指令,说明ReentrantLock其实就是一个普通的Java类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ByteReentrantLock {
private Lock reentrantLock = new ReentrantLock();
public void test() {
reentrantLock.lock();
try {
} catch (Exception e) {
} finally {
reentrantLock.unlock();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class demo.blog.reentrant2sync.ByteReentrantLock {
public demo.blog.reentrant2sync.ByteReentrantLock();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":
()V
4: aload_0
5: new #2 // class java/util/concurrent/locks/
ReentrantLock
8: dup
9: invokespecial #3 // Method java/util/concurrent/locks
/ReentrantLock."<init>":()V
12: putfield #4 // Field reentrantLock:Ljava/util/co
ncurrent/locks/Lock;
15: return
public void test();
Code:
0: aload_0
1: getfield #4 // Field reentrantLock:Ljava/util/co
ncurrent/locks/Lock;
4: invokeinterface #5, 1 // InterfaceMethod java/util/concurr
ent/locks/Lock.lock:()V
9: aload_0
10: getfield #4 // Field reentrantLock:Ljava/util/co
ncurrent/locks/Lock;
13: invokeinterface #6, 1 // InterfaceMethod java/util/concurr
ent/locks/Lock.unlock:()V
18: goto 33
21: astore_1
22: aload_0
23: getfield #4 // Field reentrantLock:Ljava/util/co
ncurrent/locks/Lock;
26: invokeinterface #6, 1 // InterfaceMethod java/util/concurr
ent/locks/Lock.unlock:()V
31: aload_1
32: athrow
33: return
Exception table:
from to target type
21 22 21 any
}

接下来看一下使用synchronized关键词时的示例代码及其编译后的字节码。直接看字节码的第 行代码,使用了monitorenter指令来锁定同步块,然后再使用monitorexit退出同步块。所以,synchronized关键词是通过jvm运行时特殊指令来实现的。这与ReentrantLock的普通Java实现是不同的。

1
2
3
4
5
6
7
8
public class ByteSync {
public void test() {
synchronized (this) {
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public demo.blog.reentrant2sync.ByteSync();
Code:
0: aload_0
1: invokespecial #1 // Meth
()V
4: return
public void test();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_1
5: monitorexit
6: goto 14
9: astore_2
10: aload_1
11: monitorexit
12: aload_2
13: athrow
14: return
Exception table:
from to target type
4 6 9 any
9 12 9 any
}

(2)jvm支持

ReentrantLock的lock方法和unlock方法必须要成对出现的,否则会导致永久锁死。同时,为了确保unlock一定能执行,应该要将其置于finally代码块内,否则如果因出现异常而导致锁没有及时释放,后果不堪设想。ReentrantLock使用时一定要特别谨慎,否则很有可能会出现线程死锁。由于ReentrantLock就是一个普通的Java对象,那么它是可以被传递到其他的方法中的。所以ReentrantLock的锁定和解锁是可以跨方法的,或者更底层一点说是可以跨栈帧的。

synchronized关键词使用jvm指令来实现,如果synchronized代码块内出现异常,则jvm会帮我们自动解锁,不需要编程者做额外的工作,这大大减少了编码量和简化了编程的难度,同时降低了死锁的风险。可以看到monitorenter和monitorexit指令也是成对出现的,但是它们限定在同一个方法内。synchronized包裹的代码块不能跨不同的方法,也就是说synchronized是不能跨栈帧的。这制约了synchronized的适用场景,如果需要跨栈帧加锁和解锁,synchronized是不合适的。

(3)性能

由于笔者并没有Java大规模并发的实践经历,所以有关它们的性能差距只能借用别人的实验。据资料说,ReetrantLock在大规模并发的场景下性能优于synchronized,而在并发不是那么大的场景下,synchronized的性能比较高。由于没有大规模并发的条件,于是笔者借助PC做了一下小规模的并发。测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
/**
* 比较ReetrantLock与synchronized的性能区别。
* @author xialei(xialei199023@163.com)
* @version v1.0 2015-9-27上午11:12:20
*/
public class ReetrantLockTest {
private int threadNum = 10;
private CountDownLatch reentrantLockCdl = new CountDownLatch(threadNum);
private CountDownLatch synchronizedCdl = new CountDownLatch(threadNum);
private Lock lock = new ReentrantLock();
public void doReetrantLockTest() {
long start = System.currentTimeMillis();
for (int i = 0; i < threadNum; i++) {
new Thread() {
public void run() {
lock.lock();
try {
doBusiness(reentrantLockCdl);
} finally {
lock.unlock();
}
};
}.start();
}
try {
reentrantLockCdl.await();
long end = System.currentTimeMillis();
System.out.println("ReetrantLock use time : "+ (end - start));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void doSynchronized() {
long start = System.currentTimeMillis();
for (int i = 0; i < threadNum; i++) {
new Thread() {
public void run() {
synchronized (this) {
doBusiness(synchronizedCdl);
}
};
}.start();
}
try {
synchronizedCdl.await();
long end = System.currentTimeMillis();
System.out.println("synchronized use time : "+ (end - start));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void doBusiness(CountDownLatch latch) {
for (long i = 0l; i < 10000000l; i++) {
//System.out.println(Thread.currentThread().getName());
}
latch.countDown();
}
public static void main(String[] args) {
ReetrantLockTest test = new ReetrantLockTest();
test.doReetrantLockTest();
test.doSynchronized();
}
}

测试结果如下表所示(时间单位为毫秒):

线程并发数 ReentrantLock synchronized
1 26 23
10 218 112
50 1107 645
100 2208 1152
200 4281 2409
400 8678 4400
600 12937 6610
800 17072 8868

可以看到在小规模并发下synchronized的性能大概是ReentrantLock的2倍,这主要归功于官方对synchronized在性能上的不断完善,通过诸如偏向锁、轻量级锁等优化措施保证在小规模并发下synchronized的性能。实际上,Java官网也直接在推荐使用synchronized来做多线程同步,毕竟synchronized才是真正属于Java语言本身的。

4 总结

本文着重分析了JDK1.7中的ReentrantLock源代码,包括它的Javadoc和重要的方法。通过源代码的分析,找到了公平锁和非公平锁的差异之处。ReentrantLock和synchronized是实现类似功能的不同实现,本文最后分别在字节码、jvm支持和性能这3个方面对它们进行的对比,并且说明了它们的适用场景。第一次写这种源代码的分析文章,实在是拿不准轻重,希望通过不断的写提高自己的写作能力。

参考资料

Java中的ReentrantLock和synchronized两种锁定机制的对比

AQS的原理浅析