本章的建议可以帮助写出清晰、正确的并发程序。
66. 同步访问共享的可变数据
对数据操作的原子性和可见性要区分清楚,才能明白什么时候使用synchronized、volatile来保持数据被多个线程共享。
-
同步的全部意义
如果没有同步,一个线程的变化就不能被其他线程看到。
同步不仅可以阻止一个线程看到对象处于不一致的状态之中,还可以保证进入同步方法或同步代码块的每个线程,都看到由同一个锁保护的之前所有的修改效果。
即同步的意义有两个:互斥和可见。 -
原子性和可见性
Java语言规范保证读或写一个变量是原子的(atomic),除了long和double。即读取一个非long或double类型的变量,可以保证返回的值是某个线程保存在该变量的。
但并不保证一个线程写入的值对于另一个线程将是可见的。此时可以使用同步、或volatile关键字。 -
volatile关键字
volatile关键字不执行互斥访问,仅保证可见性。
所以当所使用的同步代码块中仅仅是为了保证变量的修改是可见的,而不是为了互斥访问,可以使用该关键字来提高性能。
即没多条语句操作变量,不需要互斥。
67. 避免过度同步
过度同步可能会导致 性能降低、死锁、甚至不确定的行为。
-
一个被同步的方法或代码块中,永远不要放弃对客户端的控制。
即在一个被同步的区域内部,不要调用设计成要被覆盖的方法。从同步区域中,外部提供的方法不知道会做什么事情,也无法控制它,可能会造成异常、死锁或者数据损坏。 -
Java提供的锁是可重入的。
同一个线程,可以进到同一个锁所保护的正在操作的资源中。可再入锁简化了多线程的面向对象程序的构造,当可能会将活性失败变成安全性失败。 -
可以通过将外来方法的调用移出同步代码块来解决这个问题。
也可以使用CopyOnWriteArrayList
并发集合,通过重新拷贝整个底层数组,在这里实现所有的写操作。由于内部数组永远不改动,因此迭代不需要锁定,速度也非常快。 -
在同步区域之外被调用的外来方法被称作
开放调用
。这种调用方式可以避免死锁,还可以增加并发性。外来方法运行时间可能会很长,如果进行同步访问,其他线程对受保护资源的访问就会遭到不必要的拒绝。 -
通常,应该在同步区域内做尽可能少的工作。
获得锁,检查共享数据,根据需要转换数据,然后释放锁。 -
过度同步的性能损失
在这个多核的时代,过度同步的实际成本并不是获取锁所花费的CPU时间,而是指失去了并行的机会、以及因为需要确保每个核都有一个一致的内存视图而导致的延迟。
另一个潜在的开销在于,它会限制VM优化代码执行的能力。 -
如果一个可变的类要并发使用,应该使这个类变成线程安全的。通过内部同步,你还可以获得明显比从外部锁定整个对象更高的并发性。
否则,就不要在内部同步,让客户端在必要的时候从外部同步。
当你不确定时,就不要同步你的类,提供文档,并清楚的说明它不是线程安全的。
总之,为了避免死锁和数据破坏,千万不要从不同区域内部调用外来方法。更一般的,要尽量限制同步区域内的工作量。
当设计一个可变类时,考虑一下它们是否应该自己完成同步操作。当有足够理由一定要在内部同步类的时候,才应该这么做,并写进文档里。
68. executor和task优先于线程
-
java.util.concurrent.Executors
类包含了静态工厂,能为你提供所需的大多数executor,还可以直接使用ThreadPoolExecutor类来创建自己的线程池,可以线程池操作的各个方面。 -
线程池的选择
如果是小程序,轻载的服务器,使用Executors.newCachedThreadPool
。
如果是负载比较大,使用Executors.newFixed
。
或者自定义以满足需求。 -
尽量避免使用线程。
现在工作单元和执行机制是分开的。Thread担当了这两个角色。
现在关键是抽象是工作单元(task,任务),任务有两种:Runnable和Callable。
执行任务的通用机制是executor service。
69. 并发工具优先于wait和notify
java.util.concurrent
中工具分成三类:Executor Framework、并发集合(Concurrent Collection)、同步器(Synchronizer)。
-
并发集合
并发集合为标准的集合接口提供了高性能的并发实现。为了提高并发性,这些实现在内部自己管理同步。
这意味着客户端无法原子的对并发集合进行方法调用。
因此有些集合接口已经通过依赖状态的修改操作进行了扩展,它将几个基本操作合并到了单个原子操作中,比如ConcurrentHashMap;
有些集合接口已经通过阻塞操作进行了扩展,它们会一直等待(阻塞)到可以成功执行为止。比如BlockingQueue。 -
除非不得已使用同步集合,则应该优先使用ConcurrentHashMap,而不是使用Collections.synchronizedMap或Hashtable。
-
同步器(Synchronizer)是一些使线程能够等待另一个线程的对象,允许它们协调动作。
最常用的同步器是CountDownLatch和Semaphore,较不常用的是CyclicBarrier和Exchanger。 -
使用wait的标准模式
/*
始终应该使用wait循环模式来调用wait方法,永远不要再循环之外调用wait方法。
循环会在等待之前和之后测试条件。
*/
while(condition is true){
obj.wait();
}
总之,直接使用wait和notify就像使用并发汇编语言进行编程一样,而concurrent包中提供了更高级的工具。
没有理由使用wait和notify,即使有,也是极少的。
70. 线程安全性的文档化
-
一个类为了可被多个线程安全的使用,必须在文档中清楚的说明他所支持的线程安全性级别。
-
线程安全性级别
- 不可变的(immutable)
这个类的实例是不可变的,所以不需要外部同步。包括String、Long、BigInteger等。 - 无条件的线程安全(unconditionally thread-safe)
这个类的实例是可变的,但是这个类有着足够的内部同步,所以它的实例可以被并发使用,无需任何外部同步。包括Random、ConcurrentHashMap等。 - 有条件的线程安全(conditionally thread-safe)
除了有些方法为进行安全的并发使用而需要外部同步之外,这种线程安全级别于无条件的相同。包括Collections.synchronized()包装返回的集合,它们的迭代器总是要求外 部同步。 - 非线程安全(not thread-safe)
这个类的实例是可变的。为了并发的使用它们,客户端必须自己使用同步来确保正确性。 - 线程对立的(thread-hostile)
该类不能安全的被多线程使用,即使已经是使用同步调用。这种类是因为没有考虑到并发性而产生的结果。Java中线程队里的类或非法很少。
- 不可变的(immutable)
-
文档中描述一个有条件的线程安全类要特别小心,必须指明哪个调用序列需要外部同步,还要指明这些序列需要的是哪一把锁。
-
私有锁对象模式只能用在无条件的线程安全类上。
总之,每个类都应该利用字斟句酌的说明或者线程安全注解,清楚的在文档中说明它的线程安全属性。
71. 慎用延迟初始化
延迟初始化是延迟到需要域的值时才将它初始化的这种行为,如果永远不需要这个值,这个域就永远不会初始化。
这种方法既适用于静态域,也适用于实例域。
-
对于延迟初始化,除非绝对必要,否则就不要这么做。
降低了初始化类或者创建实例的开销,但增加了访问被延迟初始化的域的开销,实际上可能降低了性能。
唯一的办法就是测量类在用和不使用延迟初始化时的性能差别。 -
大多数情况下,正常初始化要优先于延迟初始化。
如果出于性能的考虑,使用延迟初始化:- 对静态域,使用LoDH模式
- 对实例域,使用DCL模式
参考 单例模式
-
如果可以接受重复初始化,可以使用单重检查模式。依然加上volatile关键字。
如果不在意每个线程都重新计算域的值,并且域的类型为基本类型,且不是long或double,就可以把volatile删去,加快访问,但每个线程都会对它进行初始化。
72. 不要依赖于线程调度器
编写良好的程序不应该依赖于线程调度器,任何依赖于线程调度器来达到正确性或性能要求的程序,很有可能都是不可移植的,比如依赖于ScheduledThreadPoolExecutor等。
-
要编写健壮的、响应良好的、可移植的多线程应用程序,最好的办法是确保可运行线程的平均数量不明显多于处理器的数量。
-
保证可运行线程数量尽可能少的主要方法是,让每个线程做些有意义的工作,然后等待更多有意义的工作。
如果线程没有在做有意义的工作,就不应该运行。 -
如果一个线程无法像其他线程那样获得足够的CPU时间,那么不要调用Thread.yield来修正程序。不可移植。
Thread.yield的唯一用途是在测试期间人为的增加程序的并发性,但并不一定可行。可以使用Thread.sleep(1)代替来进行并发测试(Thread.sleep(0)会直接返回)。 -
线程优先级是Java平台上最不可移植的特征。
不要让程序的正确性依赖线程调度器,否则,这样的程序既不健壮,也不具有移植性。
作为推论,不要依赖Thread.yield或线程优先级。
73. 避免使用线程组
线程组已经过时了
线程组的初衷是作为一种隔离applet的机制(出于安全考虑),但它们从来没有履行这个承诺。
它们的安全价值已经差到根本不在Java安全模型的标准工作中提及的地步。
- 它们允许你同时把Thread的某些基本功能应用到一组线程上。
其中一些基本功能已经废弃了,剩下的也很好使用。
总之,线程组并没有提供太多有用的功能,且提供的功能都是由缺陷的。如果需要一个处理线程的逻辑组,可以使用线程池。