重入锁 ReentrantLock 和读写锁 ReentrantReadWriteLock 是两个使用很广泛的同步组件,本文将详细介绍这两种锁特性、用法以及个别方法的源码分析
支持对资源的重复加锁:已经获取锁的线程,再次调用lock()方法时,能够再次获取到锁而不被阻塞
支持获取锁时公平性和非公平性的选择:默认是非公平性的锁
synchronized关键字支持隐式的重进入,执行线程在获取了锁之后仍能连续多次的获取同一把锁而不被阻塞。ReentrantLock虽然不能隐式的重进入,但是支持显示的多次获取同一把锁。
实现重进入需要解决以下两个问题:
线程再次获取锁: 锁需要去识别获取锁的线程是否为当前占据锁的线程,如果是则再次获取锁成功
锁的最终释放: 线程重复n次获取了锁,对应的需要n次解锁,第n次解锁后,其他线程能够获取到该锁。锁的最终释放要求锁对于获取进行计数自增,释放时则计数自减,当等于0时表示锁已经成功释放
ReentrantLock 把聚合同步器的操作委托给了内部类 Sync,在构造器中会返回一个 Sync 对象,默认的是以非公平的方式获取锁,其源码如下:
// ReentrantLock 的无参构造器
public ReentrantLock() {
// 返回一个非公平的Sync
sync = new NonfairSync();
}
// 非公平获取锁的源码
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
// 得到当前线程的同步状态
int c = getState();
// 如果为0,加锁
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;
}
ReentrantLock 加了几次锁,相对应的就要释放几次锁,下面是 ReentrantLock 释放锁的方法 tryRelease(int release) 的源码:
protected final boolean tryRelease(int releases) {
// 没释放一次同步状态减1,直到为0
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;
}
公平锁: 在一组线程里,保证每个线程都能拿到锁,即线程按照发出的请求顺序获取锁,不可抢占
非公平锁: 在一组线程里,并不一定每个线程都能拿到锁,即允许线程插队获取锁,可抢占
下面是演示公平锁和非公平锁的例子:
public class TestFailUnfail {
private ReentrantLock lock;
public TestFailUnfail(boolean isFail) {
lock = new ReentrantLock(isFail);
}
public void service() {
lock.lock();
try {
System.out.println("ThreadName:" + Thread.currentThread().getName() + "获得了锁!");
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
// true : 公平锁
// false : 非公平锁
final TestFailUnfail test = new TestFailUnfail(true);
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("线程 " + Thread.currentThread().getName() + " 运行!");
test.service();
}
};
Thread[] threads = new Thread[5];
for (int i = 0; i < 5; i++) {
threads[i] = new Thread(runnable);
}
for (int i = 0; i < 5; i++) {
threads[i].start();
}
}
}
// 公平锁:线程的启动顺序和加锁的顺序一致,说明先请求的线程先获得锁
线程 Thread-0 运行!
ThreadName:Thread-0获得了锁!
线程 Thread-1 运行!
ThreadName:Thread-1获得了锁!
线程 Thread-2 运行!
ThreadName:Thread-2获得了锁!
线程 Thread-3 运行!
ThreadName:Thread-3获得了锁!
线程 Thread-4 运行!
ThreadName:Thread-4获得了锁!
// 非公平锁:线程的启动顺序和加锁的顺序不一致,说明线程对锁发生了争抢,并且抢夺成功
线程 Thread-0 运行!
线程 Thread-2 运行!
线程 Thread-1 运行!
ThreadName:Thread-2获得了锁!
线程 Thread-3 运行!
ThreadName:Thread-1获得了锁!
线程 Thread-4 运行!
ThreadName:Thread-4获得了锁!
ThreadName:Thread-0获得了锁!
ThreadName:Thread-3获得了锁!
上面的例子介绍了公平锁和非公平锁的区别,现在说说它是怎么实现的:其实要想先请求先获取,其原理不就是FIFO(先进先出)嘛,只要保证先入队列的线程先获得锁,后入队的线程等待就行,下面是公平获取锁 tryAcquire(int acquires) 的源码:
// 与 nonfairTryAcquire(int acquires) 相比,加了判断条件方法 hasQueuedPredecessors()
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;
}
}
公平锁和非公平锁的使用:
公平锁保证了锁的获取按照FIFO原则,但是却付出了大量线程切换的代价,将一个被挂起的线程恢复执行需要消耗大量时间。其适合于业务线程占用时间较长的场景,可以增加可控性。
非公平锁可能造成线程饥饿,即有一部分线程可能永远也无法获取到锁。但是其优势却很显著:只需要极少的线程切换,保证了更大的吞吐量。其适合于吞吐量要求比较高的场景(默认选择就是了)
ReentrantLock 在加锁的内存语义上与内置锁相同,此外还提供了一些其他的功能:定时的锁等待、可中断的锁等待、公平性以及非块结构的加锁等。既然ReentrantLock有如此多优越的特性,那我们是不是就要放弃使用 synchronized 呢?显然不是这样,自从JDK1.6对 synchronized 做了优化之后(偏向锁、轻量级锁等),内置锁已经不再那么的重量了。同时,内置锁有着很多优越的条件:隐式的重进入、简洁紧凑、易于使用和理解等等。
所以关于两种锁的选择策略是:在一些内置锁无法满足需求的情况下,ReentrantLock 可以作为一种高级工具。当需要一些高级功能时才应该使用 ReentrantLock,如:可定时的、可轮询的与可中断的锁获取操作,公平队列以及非块结构的锁。否则,应该优先使用 synchronized。
前面所说的锁都是独占锁(排他锁),即同一时刻只能有一个线程获取同一把锁。而读写锁是共享锁,即同一时刻可以允许多个读线程访问,在写线程访问时,其他所有的读线程和写线程阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排他锁有了很大的性能提升。
在Java并发包中提供了ReentrantReadWriteLock来实现读写锁,其特性如下:
特性 | 描述 |
---|---|
公平性选择 | 支持非公平(默认)和公平的锁获取方式,吞吐量非公平优于公平 |
重进入 | 支持重进入,读线程获取了读锁之后能再次获取读锁;写线程获取了写锁之后能再次获取写锁,同时也可以获取读锁 |
锁降级 | 遵循:获取写锁——获取读锁-释放写锁 的次序,写锁能够降级为读锁 |
下面的例子演示了使用读写锁实现缓存,缓存值存放于非线程安全的 HashMap 中,使用读锁来保证 get() 的安全性,使用写锁来保证 put() 的安全性。在执行 get() 操作时,所有的读线程都可以获得锁,但是所有的写线程均需阻塞等待;在执行 put() 操作时,只有一个写线程可以获得锁,其他的读线程和写线程均需阻塞等待,只有写锁被释放后,其他线程才可以获取锁。
public class TestReadWriteLock {
private static Map<Integer, Object> map = new HashMap<>();
private static ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
// 读锁
private static Lock readLock = rw.readLock();
// 写锁
private static Lock writeLock = rw.writeLock();
// 获取一个key对应的value
public static final Object get(Integer key) {
readLock.lock();
try {
return map.get(key);
} finally {
readLock.unlock();
}
}
// 设置key对应的value,并返回原value
public static final Object put(Integer key, Object value) {
writeLock.lock();
try {
return map.put(key, value);
} finally {
writeLock.unlock();
}
}
// 清空
public static final void clear() {
writeLock.lock();
try {
map.clear();
} finally {
writeLock.unlock();
}
}
public static void main(String[] args) {
final TestReadWriteLock test = new TestReadWriteLock();
String[] str1 = new String[5];
String[] str2 = new String[5];
Runnable runnable1 = new Runnable() {
@Override
public void run() {
for (int i = 0; i < 5; i++) {
test.put(i, "大米" + i);
}
}
};
Runnable runnable2 = new Runnable() {
@Override
public void run() {
for (int i = 0; i < 5; i++) {
str1[i] = (String) test.put(i, "蔬菜" + i);
}
}
};
Runnable runnable3 = new Runnable() {
@Override
public void run() {
for (int i = 0; i < 5; i++) {
str2[i] = (String) test.get(i);
}
}
};
Thread[] threadA = new Thread[5];
Thread[] threadB = new Thread[5];
Thread[] threadC = new Thread[5];
for (int i = 0; i < 5; i++) {
threadA[i] = new Thread(runnable1);
threadB[i] = new Thread(runnable2);
threadC[i] = new Thread(runnable3);
}
for (int i = 0; i < 5; i++) {
threadA[i].start();
threadB[i].start();
threadC[i].start();
}
for (int i = 0; i < 5; i++) {
System.out.println(str1[i]);
}
System.out.println("=========================");
for (int i = 0; i < 5; i++) {
System.out.println(str2[i]);
}
}
}
// 结果如下:
大米0
大米1
大米2
大米3
大米4
=========================
蔬菜0
蔬菜1
蔬菜2
蔬菜3
蔬菜4
读写锁也依赖于同步器来实现同步功能,读写状态就是同步器的同步器状态,同步状态表示锁被一个线程功夫获取的次数。读写锁的同步状态的维护要比独占锁复杂一些,因为读锁可以被多个线程获取,那么自然会有多个读状态需要维护。
如果在一个整型变量上维护多种状态,需要按位切割使用这个变量,读写锁将变量切分成两个部分:高16位表示读,低16位表示写
上图中读状态为2,说明获取了两次读锁;写状态为3,表示获取了3次写锁。读写锁通过位运算来确定各自的状态,假设当前同步状态值为S:
写状态等于 S & 0x0000FFFF,将高16位全部抹去。写状态增加1,等于 S+1
读状态等于 S >>>,无符号右移16位,前面补0。读状态增加1,等于 S+(1 << 16),即 S+0x00010000
结论: S不等于0时,当写状态等于0,则读状态大于0,即读锁被获取
补充Java中的移位操作符:>>, <<, >>> 针对的是二进制位
>>: 带符号右移操作符,即按照指定位数整体右移,右移几位就在最左侧补上几位(注意:正数补0,负数补1)
<<: 左移操作符,即按照指定位数整体左移,左移几位就在最右侧补上几个0
>>>: Java独有的无符号右移操作符,无论正负,都在高位补上0
写锁是一个支持重进入的独占锁,其获取同步状态方法 tryAcquire(int acquires) 的源码如下:
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c);
// 同步状态不为0
if (c != 0) {
// (Note: if c != 0 and w == 0 then shared count != 0)
// 写状态为0,没有线程获取到写锁
// 但是同步状态不为0,说明读线程正在持有锁,写线程阻塞等待
if (w == 0 || current != getExclusiveOwnerThread())
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// Reentrant acquire
// 设置同步状态
setState(c + acquires);
return true;
}
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
读锁是一个支持重进入的共享锁,它能被多个线程同时获取,其获取同步状态的方法 tryAcquireShared(int unused) 的源码如下:
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
// 如果写锁被另一线程占有,而不是当前线程占有,则获取失败
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
int r = sharedCount(c);
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
// 重进入
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
return fullTryAcquireShared(current);
}
指写锁降级为读锁:把持住当前拥有的写锁,再次获取到读锁,随后释放写锁的过程(当前线程先拥有写锁,随后释放,再获取读锁,这种分段获取锁的过程不是锁降级)。锁降级示例如下:
public void process() {
readLock.lock();
if(!update) {
// 先释放读锁
readLock.unlock();
// 锁降级从获取到写锁开始
writeLock.lock();
try {
if(!update) {
// 准备数据流程
update = true;
}
// 获取读锁,还没有释放写锁
readLock.lock();
} finally {
// 释放写锁
writeLock.unlock();
}
// 锁降级完成
}
try {
// 使用数据流程
} finally {
readLock.unlock();
}
}
锁降级的作用: 保证数据的可见性。如果当前线程不获取读锁而直接释放写锁,假设此刻另一个线程T获取了写锁并修改了数据,当前线程则无法感知到T的数据更新。如果当前线程获取了读锁,遵循锁降级的步骤,线程T会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。ReentrantReadWriteLock不支持锁升级(把持读锁、获取写锁、释放读锁),也是为了保证数据可见性,如果读锁已经被多个线程获取,其中任意线程获取了写锁并更新了数据,其更新操作对于其他线程是不可见的。
《Java并发编程的艺术》
《Java并发编程实战》