显式锁
梳理 Java 显式锁:ReentrantLock、tryLock、可中断锁、公平锁、ReadWriteLock 和 StampedLock 乐观读的使用场景。
Java中的锁可以分为内置锁和显式锁。内置锁就是synchronized,显式锁则是Java 5引入的Lock接口及其实现。
Lock vs. synchronized
显式锁之所以是显式的,是因为锁的获取与释放都是需要显式调用的。
相同点
显式锁和内置锁必须有相同的内存可见性语义,但是在使用方式、性能等方面未必相同。
显式锁中的ReentrantLock和synchronized一样,都提供了可重入的语义。
不同点
这些不同点就是引入显式锁的原因。
- flexible:lock可以用在不同的方法中,
lock()/unlock(),内置锁只能在获取锁的代码块中释放; - 公平:synchronized lock释放之后所有线程都可以一起抢,lock可以支持更多的设置,比如通过设置fairness property让等的最久的线程优先获得锁;
- 非阻塞:synchronized如果得不到锁,会阻塞。lock可以使用
tryLock(),如果得不到锁,也不会阻塞; - 可中断:等待synchronized锁的线程不可被中断,lock可以使用
lockInterruptibly(),允许被中断;
如何选择
那么在Lock和synchronized之间,如何做出选择?
性能
Lock不能取代synchronized。Java5的时候synchronized和lock性能差很多,但是Java6的时候已经没啥差别了,因为1.6对synchronized进行了一系列锁优化。
安全性
synchronized更好,出了代码块锁就自动释放了。但是lock必须在finally里手动释放,这就像定时炸弹,万一写漏了,代码可能依然能正常运行,但爆发并发问题是迟早的事。
从安全的角度来看,synchronized更安全,所以显式锁不可能取代内置锁。
synchronized不能做到的
synchronized和ReentrantLock默认都是可重入的(锁的获取以线程为单位,而不是以调用为单位),非公平的(大家一起抢)。
Lock接口本身没有规定实现的可重入性。但是它最常用的实现类ReentrantLock是一种可重入的实现。
但是,正如上面的不同点所描述的,lock可跨代码块使用、可实现公平队列、可中断、可定时tryLock(timeout)、非阻塞所以可轮询while (true) { tryLock },即使获取锁失败,线程也不会阻塞(类似于CAS或者乐观锁),当前线程还在继续运行,可以决定是否继续,这些都可能是使用显式锁的原因。
flowchart TD
Need["需要同步共享状态"]
Basic{"只需要进入/退出\n临界区吗?"}
Sync["优先 synchronized\n简单且自动释放"]
Advanced{"需要高级能力吗?"}
Try["tryLock / timeout\n避免死等或实现降级"]
Interrupt["lockInterruptibly\n支持取消任务"]
Fair["公平锁\n减少饥饿但牺牲吞吐"]
RW{"读多写少吗?"}
RWL["ReadWriteLock\n读读并发"]
Stamped["StampedLock\n乐观读 + 校验"]
Need --> Basic
Basic -->|"是"| Sync
Basic -->|"否"| Advanced
Advanced --> Try
Advanced --> Interrupt
Advanced --> Fair
Advanced --> RW
RW -->|"读多写少,但能接受悲观读"| RWL
RW -->|"读很多、写较少,能处理校验失败"| Stamped
style Sync fill:#e8f5e9,stroke:#51cf66
style Try fill:#e3f2fd,stroke:#4dabf7
style Interrupt fill:#e3f2fd,stroke:#4dabf7
style Fair fill:#fff3bf,stroke:#f59f00
style Stamped fill:#ffe3e3,stroke:#ff6b6b
所以选择锁的时候,不要上来就问“哪个高级”。先问自己:我是不是只需要最普通的互斥?如果是,synchronized反而是更稳的默认答案。
Lock
和内置锁功能类似的显式锁。
API
lock()/unlock(): 阻塞式获取锁,显式释放锁;lockInterruptibly():可中断lock();boolean tryLock():非阻塞lock(),返回boolean,代表是否成功获取锁。即使失败,也不阻塞线程;tryLock(timeout):有限阻塞可中断lock(),在timeout时间内阻塞,或者成功获取锁返回,或者被中断抛异常。或者时间到返回false;newCondition():获取Condition,见后文;
JDK里一般阻塞的代码都会实现为可中断的。但synchronized和lock()就是阻塞且不可中断的,这是很不友好的,比如你永远无法中断一个等待内置锁的线程。
基础用法
lock必须unlock,能保证一定做到的方式就是放入finally:
1
2
3
4
5
6
7
Lock lock = ...;
lock.lock();
try {
// access to the shared resource
} finally {
lock.unlock();
}
tryLock也必须unlock:
1
2
3
4
5
6
7
8
9
10
Lock lock = ...;
if (lock.tryLock()) {
try {
// manipulate protected state
} finally {
lock.unlock();
}
} else {
// perform alternative actions
}
高级使用场景
使用tryLock避免死锁
内置锁中,如果出现了死锁,唯一的恢复方法是重启程序: 当两个线程都得不到第二个锁时,线程都阻塞被挂起,同时又不释放已获取的锁,所以两个线程永远都没有继续执行下去的可能。
防止死锁的唯一方法是避免出现不一致的锁顺序,比如将两个锁对象按照hash值的大小排序,先尝试获取hash值较小的对象的锁。
但是在显式锁中,可以使用tryLock获取两个锁:如果不能获得第二个锁,线程并不会被阻塞(这就是和内置锁的最大区别),因此可以决定释放掉已获得的第一个锁,并重新尝试依次获取两个锁。
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
private static Random rnd = new Random();
public boolean transferMoney(Account fromAcct,
Account toAcct,
DollarAmount amount,
long timeout,
TimeUnit unit)
throws InsufficientFundsException, InterruptedException {
long fixedDelay = getFixedDelayComponentNanos(timeout, unit);
long randMod = getRandomDelayModulusNanos(timeout, unit);
long stopTime = System.nanoTime() + unit.toNanos(timeout);
while (true) {
if (fromAcct.lock.tryLock()) {
try {
// 如果能获取另一个锁
if (toAcct.lock.tryLock()) {
try {
if (fromAcct.getBalance().compareTo(amount) < 0)
throw new InsufficientFundsException();
else {
fromAcct.debit(amount);
toAcct.credit(amount);
return true;
}
} finally {
toAcct.lock.unlock();
}
}
} finally {
// 线程依然活着,可以把第一个锁释放掉
fromAcct.lock.unlock();
}
}
// 超时退出,并返回false
if (System.nanoTime() < stopTime) {
return false;
}
// 适当停顿一下,避免一刻不停地重新获取锁
NANOSECONDS.sleep(fixedDelay + rnd.nextLong() % randMod);
}
}
定时锁
使用tryLock(timeout)实现tryXXX,即在一定时间内完成某操作(操作时间很短,主要是等待操作的时间,需要可控),否则就失败。
相比之下,synchronized内置锁在请求锁开始后,无法取消。
可中断锁
同样内置锁无法被中断(阻塞操作,且不响应中断,这体验太差了……),这使得实现可取消任务变得复杂。
但是tryLock(time)和lockInterruptibly都是可以响应中断的。使用他们设计一个阻塞调用方法时,可以通过中断机制终止方法执行。
公平锁
ReentrantLock默认使用的是非公平策略。除非显式使用公平策略:new ReentrantLock(true)。
在实现上,当请求锁的线程到来时:
- 公平锁:如果有人在用锁,或者队列里有排队的人,你就去排队;
- 非公平锁:只有有人在用,你才会被放进队列,否则可以直接用锁,相当于插队了;
公平锁的性能要比非公平锁差很多。公平锁性能差的原因:恢复一个被挂起的线程与该线程真正开始运行之间存在着严重的延迟。这个间隙可以完成一个其他线程。
- 线程: 可能队列头的那个线程还没醒来,先让我用用锁不行吗?我能在它醒来之前结束的!
ReentrantLock(true): 不给,排队去。
但是如果持有锁的时间相对较长,应该使用公平锁。因为间隙不足以完成一个线程,插队几乎并不会带来吞吐量的提升。
注意:不管用不用公平锁,tryLock都拒绝公平。即使有其他线程在等,tryLock也会在锁可用时获取锁。
WHY?因为tryLock不排队!就是试着去获取一下锁(插队了),获取不到拉倒。可以参考
ReentrantLock#tryLock()。
ReadWriteLock
ReentrantLock实现的是标准的互斥锁,每次最多一个线程持有锁。过于悲观,过于保守,过于强硬。比如如果两个线程都是读,则没必要互斥执行。
所以需要一个读写锁分开的实现,以保证读读操作不互斥。
API
readLock():获取读锁。如果没有线程获取写锁,则任意线程可以获得读锁;writeLock():获取写锁。如果没有线程在读写,那么仅有一个线程可以获得写锁;
Note: ReadWriteLock不是Lock的子接口。
使用场景
使用读写锁实现一个同步HashMap:
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
ReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
Map<String, String> syncMap = new HashMap();
put(k, v) {
writeLock.lock();
try {
syncMap.put(k, v);
} finally {
writeLock.unlock();
}
}
get(k) {
readLock.lock();
try {
return syncMap.get(k);
} finally {
readLock.unlock();
}
}
...
一些考量因素
- 如果write lock被释放,应该选取等待队列里的读线程还是写线程?还是说按请求顺序决定?
- 如果锁由读线程持有,写线程等待,同时又来了读线程,要不要允许插队?如果允许,可能会导致写线程饥饿问题(一直被插队,永远无法被执行);
- 一个线程持有写入锁,能否直接在不释放锁的前提下降级为读取锁;
- 同样读取锁能否升级为写入锁?
- 读取锁和写入锁是否可重入?
ReentrantReadWriteLock的实现:
- read和write lock都可重入;
- 默认非公平锁,所以访问顺序不确定。也可以使用公平锁,按照请求顺序获取锁;
- write lock可降级;
- read lock不可升级,因为可能会死锁(两个read lock同时尝试升级,谁都不释放read lock);
StampedLock - 升级版ReadWriteLock
@since Java 8
StampedLock是ReentrantReadWriteLock的一种替代品。ReentrantReadWriteLock的读锁虽然是共享锁,但本质上是个悲观锁,读的时候别的线程无法获取写锁。StampedLock可以提供乐观读(read)锁,即读的时候无锁,只有写才有锁。这样一来,读的过程中别的线程可以获取写锁写入,导致数据变动了。所以StampedLock读之前要获取当前“版本”,读之后都要验证一下“版本”是否变动。如果变动,说明有人写入了,读的数据不准了,需要再重新读一次。
乐观读锁:我估计读的过程中大概率不会有人写入,赌一把,猜读完不会被改。所以读完检查一下,真的改了?认栽,再读,或者使用read lock加锁读。没改?爽歪歪,又没加锁白嫖了一次!显然不加锁的乐观读效率更高。
Ref:
为什么使用StampedLock
使用ReadWriteLock的缺陷:
- 饥饿:有可能使某些线程饥饿。使用公平锁可以避免饥饿,但是吞吐就下来了。毕竟公平锁效率不如非公平锁。
StampedLock的好处:
- 不加读锁,读取效率更高;
- 读的过程中,别的线程随时写。写线程不会长时间阻塞导致饥饿。
所以StampedLock相比ReentrantReadWriteLock,更进一步适配读多写少的场景。
它和ReentrantLock/ReentrantReadWriteLock的另一个不同,不是可重入锁。所以如果一个线程获取了一个锁,又要获取锁,但是锁只有一个,(即使它已经拥有这个锁)就死锁了。
使用
写
写肯定是悲观写,所以和ReentrantReadWriteLock的写没区别。
读
先乐观读。读完校验一下发现被改了,看情况,可以再乐观读一下,也可以获取读锁,悲观读,这样就和ReentrantReadWriteLock的读过程一样了。
- 乐观读;
- 校验读的过程中是否被改了;
- 如果被改了,考虑继续乐观读,或者悲观读。如果没有,那就结束了。
flowchart TD
Start["tryOptimisticRead()\n拿到 stamp"]
Read["无锁读取共享字段"]
Validate{"validate(stamp)\n期间有写锁吗?"}
Return["直接返回读取结果"]
Pessimistic["获取 readLock()\n悲观读一次"]
Unlock["unlockRead(stamp)"]
Start --> Read --> Validate
Validate -->|"没有写入"| Return
Validate -->|"发生写入,读到的可能是拼接值"| Pessimistic --> Unlock --> Return
style Start fill:#e3f2fd,stroke:#4dabf7
style Validate fill:#fff3bf,stroke:#f59f00
style Return fill:#e8f5e9,stroke:#51cf66
style Pessimistic fill:#ffe3e3,stroke:#ff6b6b
StampedLock的乐观读不是“读一定正确”,而是“先读,读完再确认这期间没人写”。这就是它和普通读锁最大的差别。
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
public class Point {
private final StampedLock stampedLock = new StampedLock();
private double x;
private double y;
public void move(double deltaX, double deltaY) {
long stamp = stampedLock.writeLock(); // 获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp); // 释放写锁
}
}
public double distanceFromOrigin() {
long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
// 注意下面两行代码不是原子操作
// 假设x,y = (100,200)
double currentX = x;
// 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
double currentY = y;
// 此处已读取到y,如果没有写入,读取是正确的(100,200)
// 如果有写入,读取是错误的(100,400)
if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
stamp = stampedLock.readLock(); // 获取一个悲观读锁
try {
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp); // 释放悲观读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
三种模式
StampedLock的本质就是将读细分成了乐观读和悲观读:
- read:就是普通read lock;
- optimistic read:乐观读(仅限读)。这是比普通ReadWriteLock多的部分;
- write:普通write lock;
所以比ReentrantReadWriteLock多出来的一步就是乐观读后的校验操作:validate(timestamp)。
乐观锁用法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
StampedLock rwlock = new StampedLock();
long stamp = rwlock.tryOptimisticRead();
// 此处做一些非原子读操作
...
// 至关重要的校验
if (rwlock.validate(stamp)) {
return counter;
} else {
long readStamp = rwlock.readLock();
try {
return counter;
} finally {
rwlock.unlock(readStamp);
}
}
普通 StampedLock 写锁用法:
1
2
3
4
5
6
7
StampedLock lock = ...;
long stamp = lock.writeLock();
try {
// access to the shared resource
} finally {
lock.unlockWrite(stamp);
}
凭证 & 校验
stamp:锁的凭证。有两个功能。
- 想解锁必须有凭证;
- 另外就是验证:乐观锁返回的stamp,会在乐观锁被写锁破坏时失效。乐观锁读完要去验证stamp是否还有效
validate(stamp),从而确定读取的数据是否有效。
校验是通过锁标记与相关常量进行位运算、比较来校验锁状态,在校验逻辑之前,会通过Unsafe的loadFence方法加入一个load内存屏障,目的是避免copy共享变量到工作内存和StampedLock.validate中锁状态校验运算发生重排序,导致锁状态校验不准确的问题。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* Returns true if the lock has not been exclusively acquired
* since issuance of the given stamp. Always returns false if the
* stamp is zero. Always returns true if the stamp represents a
* currently held lock. Invoking this method with a value not
* obtained from {@link #tryOptimisticRead} or a locking method
* for this lock has no defined effect or result.
*
* @param stamp a stamp
* @return {@code true} if the lock has not been exclusively acquired
* since issuance of the given stamp; else false
*/
public boolean validate(long stamp) {
U.loadFence();
return (stamp & SBITS) == (state & SBITS);
}
ref: