第10章 并发

第66条:同步访问共享的可变数据


你可能听说过,为了提高性能,在读或写原子数据的时候,应该避免使用同步。这个建议是非常危险而错误的。虽然语言规范保证了线程在读取原子数据的时候,

不会看到任意的数值,但是它并不保证一个线程写入的值对于另一个线程将是可见的。为了在线程之间进行可靠的通信,也为了互斥访问,同步是必要的。这归因

于Java语言规范中的内存模型(memory model),它规定了一个线程所做的变化何时以及如何变成对其他线程可见。

如果对共享的可变数据的访问不能同步,其后果将非常可怕,即使这个变量是原子可读写的。

让一个线程在短时间内修改一个数据对象,然后与其他线程共享,这是可以接受的,只同步共享对象引用的动作。然后其他线程没有进一步的同步也可以读取对象,

只要它没有再被修改。这种对象被称作事实上不可变的(effectively immutable)。将这种对象引用从一个线程传递到其他的线程被称作安全发布(safe publication)。

安全发布对象引用有许多种方法,可以将它保存在静态域中,作为类初始化的一部分;可以将它保存在volatile域、final域或者通过正常锁定访问的域中;或者可以将

它放到并发的集合中。

简而言之,当多个线程共享可变数据的时候,每个读或者写数据的线程都必须执行同步。如果没有同步,就无法保证一个线程所做的修改可以被另一个线程获知。

未能同步共享可变数据会造成程序的活性失败(liveness failure)和安全性失败(safety failure)。这样的失败是难以调试的。它们可能是间歇性的,且与时间

相关,程序的行为在不同的JVM上可能根本不同。如果只需要线程之间的交互通信,而不需要互斥,volatile修饰符就是一种可以接受的同步形式,但要正确地

使用它可能需要一些技巧。


第67条:避免过度同步


为了避免活性失败和安全性失败,在一个被同步的方法或者代码块中,永远不要放弃对客户端的控制。换句话说,在一个被同步的区域内部,不要调用设计成

要被覆盖的方法,或者是由客户端以函数对象的形式提供的方法。从包含该同步区域的角度来看,这样的方法是外来的(alien)。这个类不知道该方法会做

什么事情,也无法控制它。根据外来方法的作用,从同步区域中调用它会导致异常、死锁或者数据损坏。

通常,你应该在同步区域内做尽可能少的工作。

简而言之,为了避免死锁和数据破坏,千万不要从同步区域内部调用外来方法。更为一般地讲,要尽量限制同步区域内部的工作量。当你在设计一个可变类

的时候,要考虑一下它们是否应该自己完成同步操作。在现在这个多核的时代,这比永远不要过度同步来得更重要。只有当你有足够的理由一定要在内部

同步类的时候,才应该这么做,同时还应该将这个决定清楚地写到文档中。


第68条:executor和task优先于线程


如果想让不止一个线程来处理来自这个队列的请求,只要调用一个不同的静态工厂,这个工厂创建了一种不同的executor service,称作线程池(thread pool)。

你可以用固定或者可变数目的线程创建一个线程池。java.util.concurrent.Executors类包含了静态工厂,能为你提供所需的大多数executor。然而,如果你想来点

特别的,可以直接使用ThreadPoolExecutor类。这个类允许你控制线程池操作的几乎每个方面。

为特殊的应用程序选择executor service是很有技巧的。如果编写的是小程序,或者是轻载的服务器,使用Executors.newCachedThreadPool通常是个不错的

选择,因为它不需要配置,并且一般情况下能够正确地完成工作。但是对于大负载的服务器来说,缓存的线程池就不是很好的选择了!在缓存的线程池中,被提交

的任务没有排成队列,而是直接交给线程执行。如果没有线程可用,就创建一个新的线程。如果服务器负载得太重,以致它所有的CPU都完全被占用了,当有更多

的任务时,就会创建更多的线程,这样只会使情况变得更糟。因此,在大负载的产品服务器中,最好使用Executors.newFixedThreadPool,它为你提供了一个包含

固定线程数目的线程池,或者为了最大限度地控制它,就直接使用ThreadPoolExecutor类。

你不仅应该尽量不要编写自己的工作队列,而且还应该尽量不直接使用线程。现在关键的抽象不再是Thread了,它以前可是既充当工作单元,又是执行机制。现在

工作单元和执行机制是分开的。现在关键的抽象是工作单元,称作任务。任务有两种:Runnable及其近亲Callable(它与Runnable类似,但它会返回值)。执行

任务的通用机制是executor service。如果你从任务的角度来看问题,并让一个executor service替你执行任务,在选择适当的执行策略方面就获得了极大的灵活性。

从本质上讲,Executor Famework所做的工作是执行,犹如Collections Framework所做的工作是聚集(aggregation)一样。

Executor Framework也有一个可以代替java.util.Timer的东西,即ScheduledThreadPoolExecutor。虽然timer使用起来更加容易,但是被调度的线程池executor

更加灵活。timer只用一个线程来执行任务,这在面对长期运行的任务时,会影响到定时的准确性。如果timer唯一的线程抛出未被捕获的异常,timer就会停止

执行。被调度的线程池executor支持多个线程,并且优雅地从抛出未受检异常的任务中恢复。


第69条:并发工具优先于wait和notify


直接使用wait和notify就像用“并发汇编语言”进行编程一样,而java.util.concurrent则提供了更高级的语言。没有理由在新代码中使用wait和notify,即使有,

也是极少的。如果你在维护使用wait和notify的代码,务必确保始终是利用标准的模式从while循环内部调用wait。一般情况下,你应该优先使用notifyAll,

而不是使用notify。如果使用notify,请一定要小心,以确保程序的活性。


第70条:线程安全性的文档化


下面的列表概括了线程安全性的几种级别。这份列表并没有涵盖所有的可能,而只是些常见的情形:

不可变的(immutable)——这个类的实例是不变的。所以,不需要外部的同步。这样的例子包括String、Long和BigInteger。

无条件的线程安全(unconditionally thread-safe)——这个类的实例是可变的,但是这个类有着足够的内部同步,所以,它的实例可以被并发使用,无需任务

外部同步。其例子包括Random和ConcurrentHashMap。

有条件的线程安全(conditionally thread-safe)——除了有些方法为进行安全的并发使用而需要外部同步之外,这种线程安全级别与无条件的线程安全相同。

这样的例子包括Collections.synchronized包装返回的集合,它们的迭代器要求外部同步。

非线程安全(not thread-safe)——这个类的实例是可变的。为了并发地使用它们,客户必须利用自己选择的外部同步包围每个方法调用(或者调用序列)

。这样的的例子包括通用的集合实现,例如ArrayList和HashMap。

线程对立的(thread-hostile)——这个类不能安全地被多个线程并发使用,即使所有的方法调用都被外部同步包围。线程对立的根源通常在于,没有

同步地修改静态数据。没有人会有意编写一个线程对立的类;这种类是因为没有考虑到并发性而产生的后果。幸运的是,在Java平台类库中,线程对立的

类或者方法非常少。System.runFinalizersOnExit方法是线程对立的,但已经被废除了。

每个类都应该利用字斟句酌的说明或者线程安全注解,清楚地在文档中说明它的线程安全属性。synchronized修饰符与这个文档毫无关系。有条件的线程

安全类必须在文档中指明“哪个方法调用序列需要外部同步,以及在执行这些序列的时候要获得哪把锁”。如果你编写的是无条件的线程安全类,就应该考虑使用

私有锁对象来代替同步的方法。这样可以防止客户端程序和子类的不同步干扰,让你能够在后续的版本中灵活地对并发控制采用更加复杂的方法。


第71条:慎用延迟初始化


延迟初始化(lazy initialization)是延迟到需要域的值时才将它初始化的这种行为。如果永远不需要这个值,这个域就永远不会被初始化。这种方法既适合于

静态域,也适用于实例域。虽然延迟初始化主要是一种优化,但它也可以用来打破类和实例初始化中的有害循环。

如果出于性能的考虑而需要对静态域使用延迟初始化,就使用lazy initialization holder class模式。这种模式(也称作initialize-demand holder class idiom)

保证了类要到被用到的时候才会被初始化。

如果出于性能的考虑而需要对实例域使用延迟初始化,就使用双重检查模式(double-check idiom)。这种模式避免了在域被初始化之后访问这个域时的

锁定开销。这种模式背后的思想是:两次检查域的值,第一次检查时没有锁定,看看这个域是否被初始化了;第二次检查时有锁定。只有当第二次检查时

表明这个域没有被初始化,才会调用computeFieldValue方法对这个域进行初始化。因为如果域已经被初始化就不会有锁定,域被声明为volatile很重要。

大多数的域应该正常地进行初始化,而不是延迟初始化。如果为了达到性能目标,或者为了破坏有害的初始化循环,而必须延迟初始化一个域,就可以

使用相应的延迟初始化方法。对于实例域,就使用双重检查模式(double-check idiom);对于静态域,则使用lazy initialization holder class idiom。

对于可以接受重复初始化的实例域,也可以考虑使用单重检查模式(single-check idiom)。


第72条:不要依赖于线程调度器


当有多个线程可以运行时,由线程调度器(thread scheduler)决定哪些线程将会运行,以及运行多长时间。任何一个合理的操作系统在做出这样的决定时,

都会努力做到公正,但是所采用的策略却大相径庭。因此,编写良好的程序不应该依赖于这种策略的细节。任何依赖于线程调度器来达到正确性或者性能

要求的程序,很有可能都是不可移植的。

线程优先级是Java平台上最不可移植的特征了。

Thread.yield的唯一用途是在测试期间人为地增加程序的并发性。

不要让应用程序的正确性依赖于线程调度器。否则,结果得到的应用程序将既不健壮,也不具有可移植性。作为推论,不要依赖Thread.yield或者线程优先级。

这些设施仅仅对调度器作些暗示。线程优先级可以用来提高一个已经能够正常工作的程序的服务质量,但永远不应该用来“修改”一个原本并不能工作的程序。


第73条:避免使用线程组


线程组并没有提供太多有用的功能,而且它们提供的许多功能还都是有缺陷的。我们最好把线程组看作是一个不成功的试验,你可以忽略掉它们,就当它们

根本不存在一样。如果你正在设计的一个类需要处理线程的逻辑组,或许就应该使用线程池executor。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值