才华能力出众的ReentrantLock
主要内容
1. synchronized介绍
2. ReentrantLock介绍
3. ReentrantLock和synchronized的可伸缩性比较
4. Condition变量
5. ReentrantLock是公平的吗?
6. ReentrantLock这么完美吗?
7. 不要放弃synchronized
8. 什么时候选择ReentrantLock?
多线程和并发并不是什么新鲜事物,但是,Java是第一个把支持跨平台线程模型和内存模型直接纳入语言规范的主流编程语言。
诸如,在类库里有用于创建、启动、操作线程的Thread类,在语言特性上有用于多线程之间协作的synchronized
和volatile
。
它简化了跨平台并发程序的开发,但并不意味着编写并发应用就变得非常容易。
synchronized介绍
把一个代码块声明为synchronized
,会产生两个重要的效果,原子性
和可见性
(atomicity,visibility)。
原子性意味着同一时刻只能有一个线程执行这段被monitor对象
(锁)保护的代码,从而可以防止多线程并发修改共享变量时产生冲突。
可见性更微妙一些,它解决了由内存缓存和编译器优化造成的不确定性。
平时,线程采用自己的方式自由地存储变量,不需要关心该变量对其他线程是否立即可见(变量可能在寄存器中、处理器特定的缓存中,经过了指令重排或其他编译器优化)。
但是如果开发者使用了同步,如下面的代码所示,那么当一个线程对变量更新后,synchronized
能够保证在该线程退出同步代码块之前,该更新对之后持有相同monitor
进入同步快的线程立即可见。(volatile
变量也存在类似的规则。)
synchronized (lockObject) {
// update object state
}
因此,同步可以确保可靠地更新多个共享变量而不会发生竞态条件或数据不一致,并可以保证其他线程可以看到最新的值。
有了明确的跨平台内存模型
定义(JDK5.0中做了修改,修复了最初定义中的某些错误),就可以保证并发类可以实现”Write Once, Run Anywhere”。
并发类需遵循以下规则:如果你更新的变量可能被另一个线程读取,或者相反的,你要读取另一个线程更新的变量,都必须进行同步。
顺便提一下,在最新的JVM中(JDK5),无竞争的同步(当锁被持有时,没有其他线程试图获取锁)的性能还是不错的。
改进synchronized
所以同步听起来不错,对吗?那么,为什么JSR 166小组花了这么多时间来开发java.util.concurrent.lock
框架呢?
答案很简单,同步是好,但不够完美。它有一些功能上的限制,无法中断正在等待获取锁的线程,也无法轮询锁或者尝试获取锁而又不想一直等待。
同步还要求在获取锁的同一栈帧中释放锁,这在大多数情况下是正确的做法(并能与异常处理很好地交互),
但是在少数情况下可能更需要非块结构的锁。(原文是non-block-structured locking,是指不是synchronized代码块形式的锁)
ReentrantLock 类
java.util.concurrent.lock中的Lock框架是对锁的抽象,它允许锁作为一个普通的Java类来实现,而不是Java语言的特性(与之对应的是synchronized关键字)。
它给锁的不同实现留出了空间,你可以实现具有不同调度算法、不同性能特性的锁,甚至不同的锁的语义。
ReentrantLock类就是Lock抽象的一个实现,它具有与synchronized相同的并发性和内存语义,但是它还添加了诸如锁轮训,定时等待,以及等待可中断的特性。
此外,在竞争激烈的情况下,它有更好的性能表现。(换句话说,当多个线程尝试访问共享资源时,JVM将花费更少的时间来调度线程,而将更多的时间用于执行程序。)
那么可重入锁
(reentrant lock)是什么意思?简单地说,每个锁都有一个与之关联的计数器,如果线程再次获取它,计数器就加1,然后需要释放两次才能真正释放该锁。
这和synchronized的语义是相似的。如果线程通过已持有的monitor进入了另一个同步块(例如在一个同步方法中进入了另一个同步方法),该线程被允许执行,但是该线程退出第二个同步块时,monitor不会被释放,只有继续退出第一个同步块后,才能真正的释放monitor。
在清单1的代码示例中,Lock和synchronized最大不同就表现出来了——Lock必须在finally中显示释放。否则,如果同步的代码引发异常,则该锁可能永远不会释放!
这种区别听起来似乎微不足道,但实际上,它非常重要。忘记在finally块中释放锁会在程序中埋下定时炸弹,当它最终炸毁您的程序时,你将很难追根溯源。
然而,使用synchronized,JVM确保锁会被自动释放。
Listing 1. Protecting a block of code with ReentrantLock.
Lock lock = new ReentrantLock();
lock.lock();
try {
// update object state
}
finally {
lock.unlock();
}
另外,与当前的synchronized实现相比,ReentrantLock的实现在锁竞争下具有更好的可伸缩性。 (在将来的JVM版本中,synchronized的竞争性能可能会有所改善。)
这意味着,当多线程都争用同一个锁时,使用ReentrantLock会获得更好的吞吐量。
ReentrantLock和synchronized的可伸缩性比较
Tim Peierls(《Java并发编程实战》作者)使用简单的线性同余伪随机数生成器(PRNG)构建了一个简单的基准,用于测量synchronized与Lock的相对可伸缩性。
这个示例很好,因为每次调用nextRandom()时,PRNG实际上都会做一些实际工作,因此该基准测试是合理的,符合实际应用的,而不是通过睡眠计时模拟或不做任何事情。
在此基准测试中,我们有一个PseudoRandom接口,接口中只有一个方法nextRandom(int bound)。该接口与java.util.Random类的功能非常相似。
因为PRNG将上一次生成的数作为下一次生成随机数的输入,并且将上一次生成的数作为实例变量进行维护,所以很重要的一点是,更新该变量的代码块不能被其他线程抢占,
因此我们需要某种形式的锁来确保这一点。 (java.util.Random也是这么做的)
我们分别用ReentrantLock和synchronized实现了两个PseudoRandom。主程序会产生许多线程,每个线程都疯狂地掷骰子,然后计算不同版本每秒能够掷多少次骰子。
图1和图2中是不同线程数下的测试结果。
该基准测试并不完美,它仅在两个系统上运行(具有超线程的dual Xeon运行Linux,一个单处理器Windows系统),但应该足以表明ReentrantLock比同步具有更好的可伸缩性。
Figure 1. Throughput for synchronization and Lock, single CPU
Figure 2. Throughput (normalized) for synchronization and Lock, four CPUs
图1和图2显示了两种实现的每秒吞吐量(已标准化为1个线程同步的情况)。
可以看到,两种实现在稳态吞吐量(steady-state)上都相对较快地收敛,这通常意味着处理器已得到充分利用。
你也许已经注意到,无论哪种情况的竞争,synchronized版本的性能都会显著恶化,而Lock版本在调度开销上花费的时间要少得多,从而为更高的吞吐量和更有效的CPU利用率腾出了空间。
Condition变量
根对象(Object类)中包括一些用于跨线程通信的特殊方法-wait(), notify(), notifyAll()。
这些是高级的并发功能,很多开发人员未曾使用过它们,不过这可能是好事,因为他们的工作机制非常微妙而且容易错误使用。
幸运的是,在JDK5.0中添加了java.util.concurrent后,开发人员能使用到这些方法的情况就更少了。
notify和wait之间存在互相作用,要在一个对象上wait或notify,你必须要持有该对象的monitor(锁)。
就像Lock是synchronized
的泛化一样,Lock框架中也有notify和wait的泛化,称为Condition。
Lock对象充当了把Condition变量绑定到锁的工厂对象。与标准的wait和notify方法不同,可以给一个Lock绑定多个Condition变量。
这简化了许多并发算法的开发。例如,在Condition的JavaDoc中展示了一个例子,使用两个条件变量实现一个有界缓冲区,”not full”和 “not empty”。
与每个锁上只有一个等待集(wait set)相比,条件变量更易读且更有效。
类似于wait,notify和notifyAll,Condition的方法被命名为await,signal和signalAll,因为它们无法覆盖Object中的相应方法。
这是不公平的
如果你仔细阅读过Javadoc,会发现ReentrantLock的构造函数中有一个布尔类型的参数,让你选择是需要公平锁还是非公平锁。
公平锁是指线程得到锁的顺序与请求锁的顺序相同,先来先得。非公平锁可能会允许插入,其中某个线程可能会先于其他更早请求锁的线程得到锁。
为什么我们不希望锁都是公平的?毕竟公平是件好事,不公平是坏事,对吧?
实际上,公平锁是非常重的,并且付出了巨大的性能成本。公平即意味着比非公平锁更低的吞吐量。
默认情况下,你应该选择非公平锁,除非你的算法对正确性有严苛的要求,线程必须按照它们排队的顺序执行。
那么synchronized呢?内置的monitor锁是公平的吗?答案是,可能让你很惊讶,他们不是,从来都不是。
没有人会抱怨线程饥饿问题,因为JVM保证了所有正在等待锁的线程最终都会获取锁。
大多数情况下,保证统计学上的公平性已经足够了,而且其成本要比保证绝对公平性要低得多。
因此,默认情况下,ReentrantLock是“不公平的”,和synchronized保持一致。
图3和图4的基准测试与上面的图1、图2是一样的,只是增加了一个公平锁
(FAIR)。如你所见,“公平”不是免费的。 所以不要把“公平”作为默认值。
图3. Relative throughput for synchronization, barging Lock, and fair Lock, with four CPUs
图 4. Relative throughput for synchronization, barging Lock, and fair Lock, with single CPU
ReentrantLock这么完美吗?
看起来ReentrantLock在各个方面都比同步更好,同步能做的它都可以做(具有相同的内存和并发语义),同步不能做的它也可以做,并且在负载下具有更好的性能。
那么,我们真的应该放弃synchronized吗?还是等着之后被改进,或者甚至直接用ReentrantLock重写我们现有的synchronized代码?
实际上,有关Java编程的书籍,在多线程的章节中都采用了这种方法,将示例完全用Lock实现,对synchronized是一带而过。我认为这是一件好事。
不要放弃synchronized
尽管ReentrantLock的表现是令人印象深刻的,在同步方面有很多明显的优势,但是我认为急于将synchronized视为不推荐的功能是不明智的。
java.util.concurrent.lock中的锁类是针对高级开发者或者高级组件的。通常,除非你需要Lock的高级功能,或者你有证据(不仅是怀疑)证明使用synchronized遇到了性能瓶颈,否则你都应该坚持使用synchronized。
为什么在面对“更好”的Lock时,我显着有点保守主义?
其实与Lock相比,synchronized仍然具有一些优势。
其一,使用synchronized时你不可能忘记释放锁,因为在退出同步块时JVM帮你做了。
而在finally块中释放锁是很容易忘记的,这对程序的伤害极大。而且一旦出现问题,你很难定位到问题在哪。(这是完全不推荐初级开发人员不使用Lock的充分理由)。
另一个原因是,当JVM使用synchronized获取锁和释放锁时,会保留锁相关的信息,在进行线程dump时这些信息会包括在内。这对程序调试是无价的,因为它可以让你定位到死锁或者其他异常的根源。而Lock只是一个普通的类,JVM也不知道某个线程持有哪个Lock对象。
什么时候选择ReentrantLock?
那么,什么时候应该使用ReentrantLock?答案很简单,在你需要使用synchronized无法提供的功能时,例如定时锁、可中断锁、非块结构锁、多个条件变量、或锁轮询。
ReentrantLock还具有很好的可伸缩性,如果你确实遇到锁竞争激烈的情况,就使用它吧。
不过请记住,绝大多数synchronized块几乎从未遇过任何竞争,更不用说激烈的竞争了。
我建议你先使用synchronized,直到被证明synchronized不能满足需求了,
而不是简单的假设“ReentrantLock的性能会更好”。请记住,Lock是面向高级用户的高级工具。
真正的高手倾向于使用最简单的工具完成工作,直到他们确信简单工具已经不适合了。
一个永恒的真理,先使其正确,然后再考虑是否需要让它更快。
总结
Lock框架是synchronized的兼容替代品,它提供了许多同步未提供的功能,以及在竞争条件下更好的性能。
但是,这些优势还不足以让你总是优先选择ReentrantLock,而冷落了synchronized。
根据你的实际需求来决定是否需要ReentrantLock的魔法力量。
我想,在大多数情况下你是不需要的Lock的,因为同步可以很好的胜任,可以在所有JVM上工作,可以被更广泛的开发者理解,并且不容易出错。
译者注
文中作者多次强调了(感觉有点啰嗦,滑稽脸),非必要情况下不要使用ReentrantLock,而是优先考虑synchronized。
之所以对ReentrantLock这么保守,我想是因为这篇文章写在JDK5刚刚发布的2004年,那时候Lock对于大多数开发者还是一个陌生的东西,对其工作原理和优缺点都不太熟悉。
但是2020年的今天,JDK13都发布了,JDK8已成了主流,那么想用就用吧。
1.non-block-structured locking的说明:https://stackoverflow.com/questions/59677725/what-does-non-block-structured-locking-mean)
2.英文版原文 : https://www.ibm.com/developerworks/java/library/j-jtp10264/index.html?ca=drs-