图 1 和图 2 中的图表以每秒调用数为单位显示了吞吐率,把不同的实现调整到 1 线程 synchronized
的情况。每个实现都相对迅速地集中在某个稳定状态的吞吐率上,该状态通常要求处理器得到充分利用,把大多数的处理器时间都花在处理实际工作(计算机随机数)上,只有小部分时间花在了线程调度开支上。您会注意到,synchronized 版本在处理任何类型的争用时,表现都相当差,而Lock
版本在调度的开支上花的时间相当少,从而为更高的吞吐率留下空间,实现了更有效的 CPU 利用。
根类 Object
包含某些特殊的方法,用来在线程的 wait()
、 notify()
和notifyAll()
之间进行通信。这些是高级的并发性特性,许多开发人员从来没有用过它们 —— 这可能是件好事,因为它们相当微妙,很容易使用不当。幸运的是,随着 JDK 5.0 中引入java.util.concurrent
,开发人员几乎更加没有什么地方需要使用这些方法了。
通知与锁定之间有一个交互 —— 为了在对象上 wait
或 notify
,您必须持有该对象的锁。就像 Lock
是同步的概括一样, Lock
框架包含了对 wait
和 notify
的概括,这个概括叫作条件(Condition)
。 Lock
对象则充当绑定到这个锁的条件变量的工厂对象,与标准的 wait
和notify
方法不同,对于指定的 Lock
,可以有不止一个条件变量与它关联。这样就简化了许多并发算法的开发。例如,条件(Condition)
的 Javadoc 显示了一个有界缓冲区实现的示例,该示例使用了两个条件变量,“not full”和“not empty”,它比每个 lock 只用一个 wait 设置的实现方式可读性要好一些(而且更有效)。Condition
的方法与 wait
、 notify
和 notifyAll
方法类似,分别命名为await
、 signal
和 signalAll
,因为它们不能覆盖 Object
上的对应方法。
如果查看 Javadoc,您会看到, ReentrantLock
构造器的一个参数是 boolean 值,它允许您选择想要一个 公平(fair)锁,还是一个 不公平(unfair)锁。公平锁使线程按照请求锁的顺序依次获得锁;而不公平锁则允许讨价还价,在这种情况下,线程有时可以比先请求锁的其他线程先得到锁。
为什么我们不让所有的锁都公平呢?毕竟,公平是好事,不公平是不好的,不是吗?(当孩子们想要一个决定时,总会叫嚷“这不公平”。我们认为公平非常重要,孩子们也知道。)在现实中,公平保证了锁是非常健壮的锁,有很大的性能成本。要确保公平所需要的记帐(bookkeeping)和同步,就意味着被争夺的公平锁要比不公平锁的吞吐率更低。作为默认设置,应当把公平设置为false
,除非公平对您的算法至关重要,需要严格按照线程排队的顺序对其进行服务。
那么同步又如何呢?内置的监控器锁是公平的吗?答案令许多人感到大吃一惊,它们是不公平的,而且永远都是不公平的。但是没有人抱怨过线程饥渴,因为 JVM 保证了所有线程最终都会得到它们所等候的锁。确保统计上的公平性,对多数情况来说,这就已经足够了,而这花费的成本则要比绝对的公平保证的低得多。所以,默认情况下ReentrantLock
是“不公平”的,这一事实只是把同步中一直是事件的东西表面化而已。如果您在同步的时候并不介意这一点,那么在 ReentrantLock
时也不必为它担心。
图 3 和图 4 包含与 图 1和 图 2 相同的数据,只是添加了一个数据集,用来进行随机数基准检测,这次检测使用了公平锁,而不是默认的协商锁。正如您能看到的,公平是有代价的。如果您需要公平,就必须付出代价,但是请不要把它作为您的默认选择。
看起来 ReentrantLock
无论在哪方面都比 synchronized
好 —— 所有 synchronized
能做的,它都能做,它拥有与 synchronized
相同的内存和并发性语义,还拥有 synchronized
所没有的特性,在负荷下还拥有更好的性能。那么,我们是不是应当忘记 synchronized
,不再把它当作已经已经得到优化的好主意呢?或者甚至用ReentrantLock
重写我们现有的 synchronized
代码?实际上,几本 Java 编程方面介绍性的书籍在它们多线程的章节中就采用了这种方法,完全用Lock
来做示例,只把 synchronized 当作历史。但我觉得这是把好事做得太过了。
虽然 ReentrantLock
是个非常动人的实现,相对 synchronized 来说,它有一些重要的优势,但是我认为急于把 synchronized 视若敝屣,绝对是个严重的错误。java.util.concurrent.lock
中的锁定类是用于高级用户和高级情况的工具 。一般来说,除非您对 Lock
的某个高级特性有明确的需要,或者有明确的证据(而不是仅仅是怀疑)表明在特定情况下,同步已经成为可伸缩性的瓶颈,否则还是应当继续使用 synchronized。
为什么我在一个显然“更好的”实现的使用上主张保守呢?因为对于 java.util.concurrent.lock
中的锁定类来说,synchronized 仍然有一些优势。比如,在使用 synchronized 的时候,不能忘记释放锁;在退出synchronized
块时,JVM 会为您做这件事。您很容易忘记用 finally
块释放锁,这对程序非常有害。您的程序能够通过测试,但会在实际工作中出现死锁,那时会很难指出原因(这也是为什么根本不让初级开发人员使用Lock
的一个好理由。)
另一个原因是因为,当 JVM 用 synchronized 管理锁定请求和释放时,JVM 在生成线程转储时能够包括锁定信息。这些对调试非常有价值,因为它们能标识死锁或者其他异常行为的来源。Lock
类只是普通的类,JVM 不知道具体哪个线程拥有 Lock
对象。而且,几乎每个开发人员都熟悉 synchronized,它可以在 JVM 的所有版本中工作。在 JDK 5.0 成为标准(从现在开始可能需要两年)之前,使用Lock
类将意味着要利用的特性不是每个 JVM 都有的,而且不是每个开发人员都熟悉的。
什么时候选择用 ReentrantLock 代替 synchronized
既然如此,我们什么时候才应该使用 ReentrantLock
呢?答案非常简单 —— 在确实需要一些 synchronized 所没有的特性的时候,比如时间锁等候、可中断锁等候、无块结构锁、多个条件变量或者锁投票。ReentrantLock
还具有可伸缩性的好处,应当在高度争用的情况下使用它,但是请记住,大多数 synchronized 块几乎从来没有出现过争用,所以可以把高度争用放在一边。我建议用 synchronized 开发,直到确实证明 synchronized 不合适,而不要仅仅是假设如果使用ReentrantLock
“性能会更好”。请记住,这些是供高级用户使用的高级工具。(而且,真正的高级用户喜欢选择能够找到的最简单工具,直到他们认为简单的工具不适用为止。)。一如既往,首先要把事情做好,然后再考虑是不是有必要做得更快。
Lock
框架是同步的兼容替代品,它提供了 synchronized
没有提供的许多特性,它的实现在争用下提供了更好的性能。但是,这些明显存在的好处,还不足以成为用ReentrantLock
代替 synchronized
的理由。相反,应当根据您是否 需要ReentrantLock
的能力来作出选择。大多数情况下,您不应当选择它 —— synchronized 工作得很好,可以在所有 JVM 上工作,更多的开发人员了解它,而且不太容易出错。只有在真正需要Lock
的时候才用它。在这些情况下,您会很高兴拥有这款工具。