JDK 5.0中更灵活,可扩展的锁定

探讨Java中ReentrantLock与synchronized的区别与选择,分析ReentrantLock提供的额外功能,如定时锁等待、可中断锁等待等,及其在高竞争场景下的性能优势。然而,synchronized在易用性、锁释放机制和锁信息记录方面仍有优势。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

多线程和并发并不是什么新鲜事物,但是Java语言设计的一项创新是它是第一款将跨平台线程模型和形式化内存模型直接纳入语言规范的主流编程语言。 核心类库包括用于创建,启动和操纵线程的Thread类,该语言包括用于在线程之间传递并发约束的结构- synchronized volatile 。 虽然这简化了与平台无关的并发类的开发,但绝不使编写并发类变得微不足道-更加容易。

快速回顾同步

声明要同步的代码块有两个重要结果,通常称为原子性和可见性 。 原子性意味着一次只能有一个线程可以执行由给定监视对象(锁)保护的代码,从而可以防止在更新共享状态时多个线程彼此冲突。 可见度更细微; 它处理了内存缓存和编译器优化的各种问题。 通常,线程可以自由缓存变量的值,这样它们不必立即对其他线程可见(无论是在寄存器中,在特定于处理器的缓存中,还是通过指令重排序或其他编译器优化),但是如果开发人员已使用同步,如下面的代码所示,运行时将确保退出synchronized块之前一个线程对变量所做的更新将在另一个线程进入由同一监视器保护的synchronized块时立即变为可见(锁定) )。 对于volatile变量也存在类似的规则。 (请参阅相关主题有关同步和Java内存模型的详细信息。)

synchronized (lockObject) { 
  // update object state
}

因此,同步处理可确保可靠地更新多个共享变量而不会发生争用条件或数据损坏(前提是同步边界位于正确的位置),并确保正确进行同步的其他线程将看到最新的值。这些变量。 通过定义一个清晰的,跨平台的内存模型(在JDK 5.0中进行了修改,以修复初始定义中的某些错误),可以通过遵循以下简单规则来创建“编写一次,在任何地方运行”并发类:

每当您要编写接下来可能会被另一个线程读取的变量,或者正在读取可能最后被另一个线程写入的变量时,都必须进行同步。

甚至更好的是,在最近的JVM中,无竞争的同步(当另一个线程已经拥有锁时没有线程试图获取锁时)的性能代价是相当适度的。 (这并不总是正确的;早期的JVM中的同步尚未得到优化,从而产生了当时真实的,但现在的神话,即无论是否有争议,同步都会带来巨大的性能成本。)

同步改进

所以同步听起来不错,对吗? 那么,为什么JSR 166组花了很多时间来开发java.util.concurrent.lock框架? 答案很简单-同步是好的,但不是完美的。 它具有一些功能上的限制-无法中断正在等待获取锁的线程,也无法轮询锁或尝试获取锁而又不想永远等待它。 同步还要求在获取锁的同一堆栈帧中释放锁,这在大多数情况下是正确的做法(并与异常处理很好地交互),但是少数情况下存在非块结构的锁可以是一个很大的胜利。

ReentrantLock类

java.util.concurrent.lockLock框架是Lock的抽象,允许锁实现以Java类而不是语言功能实现。 它为Lock多个实现留出了空间,这些实现可能具有不同的调度算法,性能特征或锁定语义。 所述ReentrantLock类,它实现Lock ,具有相同的并发性和存储器语义synchronized ,而且还增加了功能,如锁轮询,定时锁等待,和可中断锁定等待。 此外,在竞争激烈的情况下,它提供了更好的性能。 (换句话说,当许多线程试图访问共享资源时,JVM将花费更少的时间来调度线程,而将更多的时间用于执行它们。)

可重入锁是什么意思? 简单地说,有一个与锁相关联的获取计数,并且如果持有锁的线程再次获取它,则获取计数将增加,然后需要释放两次该锁才能真正释放该锁。 这与synchronized的语义相似; 如果线程进入由该线程已拥有的监视器保护的同步块,则该线程将被允许继续执行,并且当该线程退出第二(或后续) synchronized块时,锁不会被释放,而只会被释放当它退出第一个synchronized块时,它将进入该监视器的保护范围。

在查看清单1中的代码示例时, Lock和同步之间的直接区别跳了出来-锁定必须在finally块中释放。 否则,如果受保护的代码引发异常,则该锁可能永远不会释放! 这种区别听起来似乎微不足道,但实际上,它是非常重要的。 忘记在finally块中释放锁会在程序中创建定时炸弹,当最终炸毁您的源代码时,您将很难追根溯源。 通过同步,JVM确保自动释放锁。

清单1.使用ReentrantLock保护代码块。
Lock lock = new ReentrantLock();

lock.lock();
try { 
  // update object state
}
finally {
  lock.unlock(); 
}

另外,与当前的同步实现相比, ReentrantLock的实现在竞争下具有更大的可伸缩性。 (在将来的JVM版本中,同步的竞争性能可能会有所改善。)这意味着,当许多线程都争用同一个锁时,使用ReentrantLock ,总吞吐量通常会好于与synchronized

比较ReentrantLock和同步的可伸缩性

蒂姆·皮尔斯(Tim Peierls)使用简单的线性同余伪随机数生成器(PRNG)构建了一个简单的基准,用于测量synchronizedLock的相对可伸缩性。 这个示例很好,因为每次调用nextRandom()时PRNG实际上都会做一些实际的工作,因此该基准实际上是在测量synchronizedLock的合理,实际应用,而不是计时伪造或不做任何事情的代码(例如许多所谓的基准。)

在此基准测试中,我们具有PseudoRandom的接口,该接口具有单个方法nextRandom(int bound) 。 该接口与java.util.Random类的功能非常相似。 因为PRNG在生成下一个随机数时会将生成的最后一个数字用作输入,并且将生成的最后一个数字作为实例变量进行维护,所以更新此状态的代码部分不要被其他线程抢占,这一点很重要,因此我们使用某种形式的锁定来确保这一点。 ( java.util.Random类也这样做。)我们构造了PseudoRandom两个实现; 一种使用同步,另一种使用java.util.concurrent.ReentrantLock 。 驱动程序产生许多线程,每个线程疯狂地掷骰子,然后计算不同版本每秒可以执行多少次掷骰。 图1和图2中针对不同数量的线程总结了结果。该基准测试并不完美,它仅在两个系统上运行(具有超线程的双Xeon运行Linux,一个单处理器Windows系统),但应该好足以表明, ReentrantLock具有可扩展性优势synchronized

图1.同步和锁定的吞吐量,单个CPU
图1.同步和锁定的吞吐量,单个CPU
图2.同步和锁定(四个CPU)的吞吐量(标准化)
图2.同步和锁定(四个CPU)的吞吐量(标准化)

图1和图2中的图表显示了各种实现的每秒调用吞吐量(已标准化为1线程synchronized情况)。 每个实现都在稳态吞吐量上相对较快地收敛,这通常意味着处理器已得到充分利用,并且将其一定比例的时间用于实际工作(计算随机数),并将一定比例的时间用于调度开销。 您会注意到,面对任何争用,同步版本的性能都会大大恶化,而Lock版本在调度开销上花费的时间要少得多,从而为更高的吞吐量和更有效的CPU利用率腾出了空间。

条件变量

Object类包括一些用于跨线程通信的特殊方法wait()notify()notifyAll() 。 这些是高级的并发功能,许多开发人员从未使用过它们-可能很好,因为它们非常微妙并且易于错误使用。 幸运的是,在JDK 5.0中添加了java.util.concurrent ,开发人员需要使用这些方法的情况就更少了。

通知和锁定之间存在相互作用-要在对象上waitnotify ,您必须持有该对象的锁。 就像Lock是同步的概括一样, Lock框架也包含了名为Conditionwaitnotify的概括。 一个Lock对象充当绑定到该锁的条件变量的工厂对象,并且与标准的waitnotify方法不同,与给定Lock关联的条件变量可以不止一个。 这简化了许多并发算法的开发。 例如,用于Condition的Javadoc显示了一个使用两个条件变量“不完整”和“不为空”的有界缓冲区实现的示例,与每个锁只有一个等待集的等效实现相比,它更易读(且更有效)。 与waitnotifynotifyAll相似的Condition方法被命名为awaitsignalsignalAll ,因为它们无法覆盖Object的相应方法。

这不公平

如果你仔细阅读Javadoc中,你会看到的参数的构造函数一个ReentrantLock是一个布尔值,让你选择是否要公平或不公平的锁。 公平锁是一种线程以其要求的顺序获取锁的公平锁。 不公平的锁可能允许插入,其中某个线程有时可以在另一个先请求它的线程之前获得一个锁。

为什么我们不希望使所有锁公平? 毕竟公平是好事,不公平是坏事,对吧? (每当孩子们想提出一个决定时,几乎肯定会出现“那不公平”的想法并非偶然。我们认为公平是很重要的,他们也知道。)实际上,锁的公平保证是非常强大的,并且付出了巨大的性能成本。 确保公平所需的簿记和同步意味着竞争的公平锁将比不公平锁具有更低的吞吐量。 默认情况下,您应该将fair设置为false除非对算法的正确性至关重要的是,线程必须按照它们排队的顺序进行服务。

同步呢? 内置显示器锁是否公平? 答案是,让很多人感到惊讶的是,它们不是,而且从来没有。 没人抱怨线程匮乏,因为JVM确保最终所有线程都将被授予等待中的锁。 通常,大多数情况下,统计公平性保证就足够了,并且其成本要比确定性公平性保证低得多。 因此,默认情况下ReentrantLock是“不公平的”,这一事实只是简单地做出了对同步始终是正确的事情。 如果您不担心同步的情况,那么不必为ReentrantLock担心。

图3和图4包含与图1图2相同的数据,并为我们的随机数基准的新版本提供了额外的数据集,该数据集使用了公平锁而不是默认的插入锁。 如您所见,公平不是免费的。 如果需要,请付款,但不要将其设为默认值。

图3.具有四个CPU的同步,插入锁定和公平锁定的相对吞吐量
图3.具有四个CPU的同步,插入锁定和公平锁定的相对吞吐量
图4.使用单个CPU进行同步,插入锁定和公平锁定的相对吞吐量
图4.使用单个CPU进行同步,插入锁定和公平锁定的相对吞吐量

各个方面都更好吗?

看起来ReentrantLock在各个方面都比synchronized更好,它可以完成synchronized所做的所有事情,具有相同的内存和并发语义,具有synchronized没有的功能,并且在负载下具有更好的性能。 那么,我们是否应该忘掉synchronized ,而将其丢给大量后来被改进的好主意呢? 甚至用ReentrantLock重写我们现有的synchronized代码? 实际上,关于Java编程的几本入门书籍在其有关多线程的章节中都采用了这种方法,将示例完全以Lock方式进行了介绍,而仅在传递时提及了同步。 我认为这太好了。

还不算同步

尽管ReentrantLock是一个非常令人印象深刻的实现,并且在同步方面具有一些明显的优势,但我认为急于将同步视为不推荐使用的功能是一个严重的错误。 java.util.concurrent.lock中的锁定类是针对高级用户和情况的高级工具 。 通常,除非您特别需要Lock一项高级功能,或者您有证据(不仅只是怀疑)证明在这种特定情况下同步是可伸缩性瓶颈,否则您应该坚持使用同步。

为什么我在采用明显“更好”的实现方式时提倡这种保守主义? 与java.util.concurrent.lock的锁定类相比,同步仍然具有一些优势。 一方面,使用同步是不可能忘记释放锁的。 当您退出synchronized块时,JVM会为您执行此操作。 很容易忘记使用finally块来释放锁,这对程序造成极大的损害。 您的程序将通过其测试并在现场锁定,并且很难弄清原因(这是完全不让初级开发人员完全使用Lock的充分理由)。

另一个原因是因为当JVM使用同步管理锁获取和释放时,JVM能够在生成线程转储时包括锁信息。 这些对于调试很有价值,因为它们可以识别死锁或其他意外行为的根源。 Lock类只是普通类,并且JVM尚不知道特定线程拥有哪些Lock对象。 此外,几乎每个Java开发人员都熟悉同步,并且同步适用于所有版本的JVM。 直到JDK 5.0成为标准(可能至少从现在起两年后),使用Lock类将意味着利用每个JVM上都不存在并且每个开发人员都不熟悉的功能。

何时选择ReentrantLock over同步

那么,什么时候应该使用ReentrantLock ? 答案很简单-在您实际需要它提供同步不希望的东西时使用它,例如定时锁等待,可中断锁等待,非块结构锁,多个条件变量或锁轮询。 ReentrantLock还具有可伸缩性的好处,如果您实际遇到竞争激烈的情况,则应使用ReentrantLock ,但请记住,绝大多数同步块几乎从未表现出任何竞争,更不用说竞争激烈了。 我建议您进行同步开发,直到证明同步不充分为止,而不是简单地假设您使用ReentrantLock会认为“性能会更好”。 请记住,这些是面向高级用户的高级工具。 (而且,真正的高级用户倾向于确信可以找到的最简单的工具,直到他们确信简单的工具是不合适的。)和往常一样,首先使其正确,然后再担心是否必须使其更快。

摘要

Lock框架是同步的兼容替代品,它提供了许多功能,不提供synchronized ,以及实现提供竞争下更好的性能。 但是,这些明显的好处的存在不足以始终偏爱ReentrantLock而不是synchronized 。 取而代之的是,根据是否需要 ReentrantLock的力量来做出决定。 在大多数情况下,您将不会-同步工作得很好,可以在所有JVM上工作,被更广泛的开发人员所理解,并且不易出错。 在您真正需要时保存Lock 。 在这种情况下,您会很高兴的。


翻译自: https://www.ibm.com/developerworks/java/library/j-jtp10264/index.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值