Java-ReentrantLock原理及解析
一般的锁都是配合synchronized使用的,实际上在java.util.concurrent.locks还提供了其他几个锁的实现,拥有更加强大的功能和更好的性能。
锁的分类
可重入锁
可重入锁:任意线程在获取该锁后能够再次获取锁时不会被阻塞。当前线程恶可以反复加锁,但必须释放同样多次数的锁,否则会导致锁不会释放。可以避免
死锁
原理
通过组合自定义同步器(AQS)实现锁的获取与释放
- 再次进行
lock(),需要判断当前是否为已获得锁的线程,如果是,计数+1 - 执行
unlock(),计数-1
在释放锁后,如果计数不为0,就会导致程序卡死。
分类
synchronized修饰的方法或代码块ReentrantLock
公平锁与非公平锁
公平锁
多个线程按照申请锁的先后顺序获取锁。内部持有一个等待队列,按照FIFO取出线程获取锁。
实现:ReentrantLock(true)
非公平锁
多个线程不是按照申请锁的先后顺序去获取锁。
非公平锁的性能高于公平锁,但是可能发生线程饥饿(某个线程长时间无法获得锁)。
这里需要注意:**公平并不等于整体效率更高。**公平锁减少了插队,线程获取锁的顺序更稳定、更可预期,但也会增加排队等待和上下文切换成本;非公平锁虽然可能让某些线程等待更久,却往往能得到更高的整体吞吐量,这也是ReentrantLock默认采用非公平模式的原因。
实现:synchronized和ReentrantLock(false)默认非公平
读写锁和排他锁
读写锁
同一时刻允许多个读线程访问。分为了读锁和写锁,读锁允许多个线程获取读锁,访问同一个资源;写锁只允许一个线程获取写锁,不允许同时访问同一资源。
在读多写少的情况下,大大提高了性能。
即使用读写锁,在写线程访问时,所有读线程和其他写线程都会被阻塞。
实现:ReentrantReadWhiteLock
排他锁
同一时刻只允许一个线程访问。
实现:ReentrantLock、synchronized
死锁
两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞现象,若无外力作用,他们都将无法推进下去。
死锁形成必须要求四个条件:
互斥条件:一个资源每次只能被一个线程使用
请求与保持条件:一个线程引请求资源而阻塞时,对已获得的资源保持不放
不剥夺条件:线程已获得的资源,在未使用完之前,不能强行剥夺
循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系
Lock接口
在Java中锁是用来控制多个线程访问共享资源的方式。在Java SE5.0之后新增Lock接口。提供了与
synchronized关键字类似的同步功能,只是在使用时需要显式的获取和释放锁,缺点就是无法像synchronized可以隐式的释放锁,但是可以自由操作获取锁和释放锁。
synchronized的不足之处
- 如果只是只读操作,应该多线程一起执行会更好,但是
synchronized在同一时间只能一个线程执行。 synchronized无法知道线程是否获取锁,而且无法主动进行释放锁- 使用
synchronized获取锁后,如果发生阻塞,就会导致所有线程等待锁释放
提供方法
lock()-获取锁
执行时,如果锁处于空闲状态,当前线程获得锁。如果锁已被其他线程持有,将禁用当前线程,直到该线程获取锁。
不会响应中断,直到获取锁成功才会进行响应。
lockInterruptibly()-获取锁,响应中断
获取锁时,优先响应中断,而不是先去进行获取。
tryLock()-非阻塞获取锁
非阻塞获取锁,立即返回获取锁结果,
true-成功,false-失败
tryLock(time,unit)-指定时间获取锁
指定时间获取锁,会响应中断
time内获取锁立即返回truetime内线程中断会立即返回获取锁结果time时间结束后,立即返回获取锁结果
把这几个获取锁方法放在一起看,会更容易理解它们分别解决什么等待策略问题:
lock():一直等待,直到真正拿到锁为止。lockInterruptibly():也会等待,但等待过程中优先响应中断。tryLock():完全不等待,立即返回是否拿到锁。tryLock(time, unit):在给定时间内尝试等待,超时就放弃。
unlock()-释放锁
当前线程释放持有锁,锁只能由持有者释放,如果并未持有锁,执行解锁方法,就会抛出异常。
newCondition()-获取锁条件
返回该锁的
Condition实例,实现多线程通信。该组件会与当前锁绑定,当前线程只有获取了锁,才能调用组件的await()方法,调用后,线程释放锁。
如果把它和synchronized体系做类比,会更好理解:
Condition.await()类似于Object.wait()Condition.signal()类似于Object.notify()Condition.signalAll()类似于Object.notifyAll()
不同点在于:Object监视器天然只有一组等待队列,而ReentrantLock可以通过多次调用newCondition()拆出多个条件队列,让不同等待原因的线程分开管理。
进一步结合ReentrantLock来看,Condition.await()并不是简单“睡一会儿”,而是会:
- 先保存并释放当前线程持有的锁状态
- 进入对应的条件队列等待
- 被
signal()后再转移回同步队列 - 重新竞争锁成功后,才真正从
await()返回
所以被signal()不等于线程立刻继续执行,重新拿到锁才是等待结束的真正标志。
ReentrantLock
一个可重入的互斥锁,具备一样的线程重入特性
ReentrantLock底层并不是自己从零维护完整的排队、唤醒和状态流转逻辑,而是建立在AQS(AbstractQueuedSynchronizer)之上。可以把它理解成:ReentrantLock负责定义“这是一把怎样的锁”,而AQS负责提供“线程如何排队、如何竞争、如何被唤醒”的通用同步框架。
在独占模式下,AQS里的state通常会被用来表示当前锁的持有状态和重入次数:第一次获取锁时把状态从0改成1,同一线程重入时继续累加,释放锁时再逐步减回去,直到恢复为0才表示真正释放完成。
如果继续往实现层看,ReentrantLock内部通常会拆成两种同步器实现:
FairSync:公平锁实现NonfairSync:非公平锁实现
它们都建立在同一套AQS独占同步骨架之上,真正的差异主要体现在“获取锁之前是否允许先抢一次”和“是否严格遵循队列顺序”。
非公平锁之所以吞吐量通常更高,一个关键原因就是:在进入同步队列之前,它往往会先做一次快速CAS抢锁。如果此时锁刚好空闲,当前线程可以直接拿到锁,而不必老老实实先排队;只有抢占失败后,才会进入AQS队列等待。
如果把tryAcquire()的主线抽出来看,逻辑通常可以概括成三步:
state == 0:说明当前没有线程持有锁,此时尝试CAS占有锁- 当前线程已经是持有者:走重入逻辑,让
state继续累加 - 其他线程持有锁:本次获取失败,进入后续排队等待逻辑
而释放锁时的tryRelease()主线则刚好对应回来:
- 先检查当前线程是不是锁持有者
- 是的话就把重入次数减1
- 只有当
state真正减到0时,才表示锁被彻底释放,可以去唤醒后继节点
这也是可重入锁最容易忽略的地方:unlock一次不一定真的释放锁,只有把重入层数全部退回去才算彻底释放。
特性
- 尝试获得锁
- 获取到锁的线程能够响应中断
读写锁
ReentrantLock是完全互斥排他的,这样其实效率不高
使用方式
1 | |
这里把unlock()放在finally里并不是一种书写习惯上的偏好,而是必须的保护措施。因为ReentrantLock不像synchronized那样在代码异常退出时由JVM自动释放锁;如果线程拿到锁后中途抛异常又没有执行unlock(),其他线程就可能一直阻塞在这把锁上。
相比synchronized增加了一些高级功能:
等待可中断:当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,去操作其他事情。
公平锁:
多个线程在等待同一个锁时,必须按照申请锁的时间来依次获得锁。synchronized是非公平锁,即在锁被释放时,任何一个等待锁的线程都有机会获得锁。这样就有可能会产生 饥饿现象(有些线程可能永远无法获得锁)。ReenTrantLock默认非公平锁,在构造时修改参数即可变为公平锁。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
43public class LockFairTest implements Runnable {
//true为公平锁 false为非公平锁 默认false
private static Lock lock = new ReentrantLock(true);
AtomicInteger iii = new AtomicInteger(0);
@Override
public void run() {
while (iii.get() < 20) {
lock.lock();
iii.getAndIncrement();
try {
System.err.println(Thread.currentThread().getName() + "获得锁");
} finally {
lock.unlock();
}
}
}
public static void main(String[] args) {
LockFairTest test = new LockFairTest();
Thread t1 = new Thread(test);
Thread t2 = new Thread(test);
Thread t3 = new Thread(test);
Thread t4 = new Thread(test);
t1.start();
t2.start();
t3.start();
t4.start();
}
}
输出结果:
公平锁:
Thread-0获得锁
Thread-1获得锁
Thread-2获得锁
Thread-3获得锁
非公平锁:
Thread-2获得锁
Thread-2获得锁
Thread-2获得锁
Thread-2获得锁锁绑定多个条件:一个
ReenTrantLock对象可以通过多次调用newCondition()同时绑定多个Condition对象。在synchronized只能实现一个隐含的条件,要多关联只能额外添加锁。在实际使用时,
signal()和signalAll()也需要区分:- 一次条件变化只需要唤醒一个等待线程时,优先使用
signal() - 条件变化可能影响整批线程,或者无法精确判断该唤醒哪个线程时,再考虑
signalAll()
如果每次都无脑使用
signalAll(),往往会带来更多无效唤醒和额外锁竞争。- 一次条件变化只需要唤醒一个等待线程时,优先使用
适用场景
ReentrantLock相比synchronized更适合下面这些场景:
- 需要可中断地获取锁
- 需要带超时地尝试获取锁
- 需要多个条件队列表达复杂线程协作
- 需要更明确地控制加锁、解锁时机
如果只是很简单的同步块保护,而且并不需要这些额外能力,那么synchronized往往已经足够,代码也会更直接。
常见误用
lock()之后忘记在finally里unlock()- 没有持有锁就调用
Condition.await()/signal() - 误以为公平锁一定更先进、更高效
- 在很简单的场景里过度使用
ReentrantLock,徒增代码复杂度 - 把“线程被signal唤醒”误解成“线程已经拿到锁并开始执行”
总结
- Lock类可以实现线程同步,获得锁需要执行
lock,释放锁使用unlock - Lock分为公平锁(按照顺序)和不公平锁(不按顺序)
- Lock还有读锁和写锁。读读共享,写写互斥,读写互斥。
自定义重入锁
1 | |