[Java并发]ReentrantLock
可重入锁 ReentrantLock
锁对象#
为什么需要锁对象
因为我们不希望有些操作被打断,例如在银行取钱的程序中,线程A正在从账户中取钱,他会进行三个操作,
- 读取账户金额100,
- 在自己的工作内存中对账户金额-100,
- 把0写回账户金额,
但是在第1,2步中间被线程B打断,线程B执行同样的操作,把0写回账户金额,再回到A,也把0写回账户金额,这样,本来只有一百元钱的账户却可以取两次一百元。
条件对象#
为什么需要条件对象
因为有时候我们获取到了锁,但是再进行工作的过程中发现有一些条件没有得到满足
,比如在银行转账的程序中,我们抢到了锁但是却发现银行账户里的钱不够转账的,于是我们可以让当前线程暂时等待在一个条件队列
里,然后等待其他线程执行signal操作,把等待线程从条件对象的等待队列中移除,其他线程释放锁之后,等待线程就可以去重新竞争锁,如果获得了锁,就可以从之前await()的地方重新开始执行。
这里实现了一个原则,条件不够别去抢锁
,抢了也不能执行代码,白白浪费锁。所以条件对象控制的实际上是线程抢锁的资格
,而不是能不能抢到锁,singal,singalall也不是说条件满足了,而是说,现在有可能满足了,请再检查看看。
下面详细介绍
如果一个账户没有足够的资金转账,我们不希望从这样的账户转出资金。注意不能使用类似下面的代码:
if(bank.getBalance(from)>= amount)
bank.transfer(from, to, amount);
在成功地通过这个测试之后,但在调用transfer方法之前,当前线程完全有可能被中断。
if(bank.getBalance(from)>= amount)
// thread might be deactivated at this point
bank.transfer(from,to,amount);
在线程再次运行前,账户余额可能已经低于提款金额。必须确保在检查余额与转账活动之间没有其他线程修改余额。
为此,可以使用一个锁来保护这个测试和转账操作:
public void transfer(int from, int to, int amount)
{
bankLock.lock();
try{
while(accounts[from]< amount)
{
// wait
}
// transfer funds
}
finally{
bankLock.unlock();
}
}
现在,当账户中没有足够的资金时,我们应该做什么呢?我们要等待,直到另一个线程向账户中增加了资金。
但是,这个线程刚刚获得了对bankLock的排他性访问权,因此别的线程没有存款的机会。这里就要引人条件对象。一个锁对象可以有一个或多个相关联的条件对象。你可以用newCondition方法获得一个条件对象。习惯上会给每个条件对象一个合适的名字来反映它表示的条件。例如,在这里我们建立了一个条件对象来表示“资金充足”条件。
class Bank{
private Condition sufficientFunds;
public Bank(){
sufficientFunds = bankLock.newCondition();
}
}
如果 transfer方法发现资金不足,它会调用sufficientFunds.await();当前线程现在暂停,并放弃锁
。
这就允许另一个线程执行,我们希望
(仅仅只是希望,他不一定这样做)它能增加账户余额。等待获得锁的线程和已经调用了await方法的线程存在本质上的不同
(等待获得锁的线程有抢锁的资格,await()的线程没有抢锁的资格)。
一旦一个线程调用了await方法,它就进入这个条件的等待集(waitset)。当锁可用时,该线程并不会变为可运行状态
。实际上,它仍保持非活动状态,直到另一个线程在同一条件上调用signalAll
方法。
当另一个线程完成转账时,它应该调用sufficientFunds.signalAll();这个调用会重新激活等待这个条件的所有线程,请注意,激活不等于开始运行,激活只是把这个线程从条件变量的等待集中移除,只是代表可运行,也就是可以被调度器调度,当这个线程被分配时间片之后,线程会重新抢锁,抢到锁之后,会从之前wait()的地方重新开始运行。当这些线程从等待集中移出
时,它们再次成为可运行的线程,调度器最终将再次将它们激活。同时,它们会尝试重新进入该对象。一旦锁可用,它们中的某个线程将从await调用返回,得到这个锁,并从之前
暂停的地方继续执行
。
此时,线程应当再次测试条件。不能保证现在一定满足条件--signalAll方法仅仅是通知等待的线程:现在有可能满足条件,值得再次检查条件。
注释:通常,await调用应该放在如以下形式的循环中
while (!(OK to proceed))
condition.await();
补充:wait的使用
Object lock = new Object();
new Thread(() -> {
synchronized (lock) {
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
synchronized (lock) {
lock.notify();
}
最终需要有某个其他线程调用signalAll方法,这一点至关重要。当一个线程调用await时,它没有办法重新自行激活
(await()重载版本是可以设置等待时间的,时间到了就不再等待,而是自行激活,从条件队等待队列中移除,等待被调用,等待抢锁)。它寄希望于其他线程。如果没有其他线程来重新激活等待的线程,它就永远不再运行了。这将导致令人不快的死锁(deadlock)现象。如果所有其他线程都被阻塞,最后一个活动线程调用了await方法但没有先解除另外某个线程的阻塞,现在这个线程也会阻塞。此时没有线程可以解除其他线程的阻塞状态,程序会永远挂起。
应该什么时候调用 signalAll 呢?从经验上讲,只要一个对象的状态有变化,而且可能有利于等待的线程,就可以调用signalAll。例如,当一个账户余额发生改变时,就应该再给等待的线程一个机会来检查余额。在这个例子中,完成转账时,我们就会调用signalAll方法
public void transfer(int from, int to, int amount){
bankLock.lock();
try{
while (accounts[from] < amount){
sufficientfunds.await();
}
// transfer funds
sufficientFunds.signalAll();
}
finally{
bankLock.unlock();
}
}
注意 signalAll调用不会立即激活一个等待的线程。它只是解除等待线程的阻塞,使这些线程可以在当前线程释放锁之后竞争访问对象
。
另一个方法 signal 只是随机选择等待集中的一个线程,并解除这个线程的阻塞状态。这比解除所有线程的阻塞更高效,但也存在危险。
如果随机选择的线程发现自己仍然不能运行,它就会再次阻塞。如果没有其他线程再次调用signal,系统就会进入死锁。
ReentrantLock & Synchronized#
是否公平#
公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。
synchronized中的锁永远是非公平的,ReentrantLock在默认情况下也是非公平的
,但可以通过带布尔值的构造函数要求使用公平锁。不过一旦使用了公平锁,将会导致ReentrantLock的性能急剧下降,会明显影响吞吐量。
条件数量#
锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。
在synchronized中,锁对象的wait()跟它的notify()或者notifyAll()方法配合可以实现一个隐含的条件(对象内部锁),如果要和多于一个的条件关联的时候,就不得不额外添加一个锁;而ReentrantLock则无须这样做,多次调用newCondition()方法即可。
是否可中断#
等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待
的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助。
PS:注意这里的等待可中断指的是
等待锁的等待
可以中断,(reentrantlock提供了trylock方法可以设置等待时间,时间过了就不再等待,转而去处理其他事情) ,这里的等待不是等待条件变量的等待
,两种锁都可以实现等待条件变量的中断,例如设置wait(),await()都有可以设置时间参数的重载版本,释放锁,进入TIMED-WAITING状态,时间到后自动移出条件变量的等待队列。
被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入
。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出
。
是否可重入#
被synchronized修饰的同步块对同一条线程来说是可重入
的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况。
代码形式#
- synchronized 块结构锁
- ReentrantLock 非块结构
复杂程度#
- synchronized是在Java语法层面的同步,足够清晰,也足够简单。每个Java程序员都熟悉synchronized,但J.U.C中的Lock接口则并非如此。因此在只需要基础的同步功能时,更推荐synchronized。
- Lock应该确保在finally块中释放锁,否则一旦受同步保护的代码块中抛出异常(函数退出),则有可能永远不会释放持有的锁。这一点必须由程序员自己来保证,而使用synchronized的话则可以由Java虚拟机来确保即使出现异常,锁也能被自动释放。
优化潜力#
尽管在JDK 5时代ReentrantLock曾经在性能上领先过synchronized,但这已经是十多年之前的胜利了。从长远来看,Java虚拟机更容易针对synchronized来进行优化,因为Java虚拟机可以在线程和对象的元数据中记录synchronized中锁的相关信息,而使用J.U.C中的Lock的话,Java虚拟机是很难得知具体哪些锁对象是由特定线程锁持有的。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构