本部分旨在介绍多线程编程的一些注意事项,指导如何编写出清晰、正确、文档组织良好的并发程序。
本章内容导图如下:
1.同步访问共享的可变数据
同步的语义不仅包含互斥,还包含可见性,可见性保证了进入同步方法或同步代码块的每个线程,都看到由同一个锁保护的之前所有的修改效果。
Java语言规范保证读/写一个变量是原子的,除非这个变量的类型是long或double。也即是说,读取一个非long或double类型的变量,可以保证返回的值是某个线程保存在该变量中的,即使多个线程在没有同步的情况下并发地修改这个变量也是如此。虽然语言规范保证了线程在读取原子数据的时候,不会看到任意的数值,但是它并不保证一个线程写入的值对于另一个线程是可见的,即它不保证可见性。因此,为了在线程之间进行可靠的通信,也为了互斥访问,同步是必要的。
倘若仅需要获得通信效果,不需要互斥访问,仅使用volatile修饰即可,它可保证任何一个线程在读取该域的时候都将看到最近刚刚被写入的值,也即是保证可见性。
//此操作不是原子的,它包含三个动作:读、修改、写
i++
当多个线程共享可变数据的时候,每个读或者写数据的线程都必须执行同步。如果没有同步,就无法保证一个线程所做的修改可以被另一个线程获知。未能同步共享可变数据会造成程序的活性失败和安全性失败。这种失败是最难以调试的,它们是间歇性的,且与时间相关,在不同的VM上还可能根本不同。
如果只需要线程之间的交互通信,而不需要互斥,volatile修饰符就是一种可以接受的同步形式。
线程A对某变量值的修改,未能立即在线程B上体现出来,称为活性失败。
要正确地实现同步,需深入地了解volatile、synchronized、final的内存语义及实现机制。
2.避免过度同步
过度同步会导致性能降低、死锁,甚至不确定的行为。
在一个被同步的区域内部,不要调用设计成要被override的方法,或者是由客户端以函数对象的形式提供的方法。从包含该同步区域的类的角度来说,这些方法是外来的,这个类不知道该外来方法会做什么事情,也无法控制它,从同步区域中调用外来方法会导致异常、死锁、数据破坏。
通常,应该在同步区域内做尽可能少的工作。
在多核时代,过度同步的实际成本并不是指获取锁所花费的CPU时间,而是指失去了并行的机会,以及因为需要确保每个核都有一个一致的内存视图而导致的延迟。过度同步的另一项开销在于,它会限制VM优化代码执行的能力。
如果不能通过内部同步获得明显高于外部同步的并发性,就不要在内部同步,让客户在必要的时候从外部同步。
为了避免死锁和数据破坏,不要从同步区域内部调用外来方法。
要尽量限制同步区域内部的工作量。
设计一个可变类的时候,要考虑一下是否应该自己完成同步操作。只有当有足够的理由一定要在内部同步类的时候,才应该这么做。
3.executor和task优先于线程
在引入Executor之前,要使用Thread去执行工作任务,Thread既是工作单元,又是执行机制。
引入Executor后,将工作单元和执行机制分开了,现在关键的抽象是工作单元,也叫任务(Task),任务有两种:Runnable和Callable。执行任务的通用机制是Executor。
Executor和Task的框架结构为:
4.并发工具优先于wait和notify
Java1.5开始,java.util.concurrent中提供了更高级的并发工具来实现更好的并发控制,这些工具可分为三类:
1. Executor Framework
2. 并发集合
3. 同步器
Executor的类图框架如下所示:
并发集合可分为阻塞和非阻塞两类。
常见的非阻塞并发集合有:
- ConcurrentHashMap
- ConcurrentSkipListMap
- ConcurrentSkipListSet
- ConcurrentLinkedQueue
- ConcurrentLinkedDeque
- CopyOnWriteArrayList
- CopyOnWriteArraySet
常见的并发阻塞队列有:
- ArrayBlockingQueue
- LinkedBlockingQueue
- PriorityBlockingQueue
- DelayQueue
- SynchronousQueue
- LinkedTrasnsferQueue
- LinkedBlockingDeque
同步器是一些使线程能够等待另一个线程的对象,允许它们协调动作。有如下几种:
- CountDownLatch
- Semaphore
- CyclicBarrier
- Exchanger
- Phaser
理解并熟练使用这些工具类可以实现更加健壮的并发程序。
5.线程安全性的文档化
一个类为了可被多个线程安全地使用,必须在文档中清楚地说明它所支持的线程安全性级别。
常见的安全性级别有:
1. 不可变的
类的实例是不可变的,不需要外部同步。如:String、Long、BigInteger。
2. 无条件的线程安全
类的实例是可变的,但这个类有着足够的内部同步,它的实例可以被并发使用,无需任何外部同步。如:Random、ConcurrentHashMap。
3. 有条件的线程安全
除了有些方法需要外部同步外,此安全级别与无条件的线程安全相同。如:Collections.synchronized包装返回的集合,它们的迭代器iterator要求外部同步。
4. 非线程安全
类的实例是可变的,为并发使用它们,客户必须利用自己选择的外部同步器包围每个方法调用(或者调用序列)。如非并发容器类ArrayList、HashMap等。
5. 线程对立的
类不能安全地被多个线程并发使用,即使所有方法都被外部同步包围。
在文档中描述一个有条件的线程安全类要特别小心,必须指明哪个调用序列需要外部同步,还要指明为了执行这些序列,必须获得哪一把锁。
类的线程安全说明通常放在它的文档注释中,但是带有特殊线程安全属性的方法则应该在它们自己的文档注释中说明它们的属性。
每个类都应该在文档中清楚地说明它的线程安全属性。
有条件的线程安全类必须在文档中指明“哪个方法调用序列需要外部同步,以及在执行这些序列的时候要获得哪把锁”。
如果编写的是无条件的线程安全类,应该考虑使用私有锁对象来代替同步的方法。这样可以防止客户端程序和子类的不同步干扰,能够在后续的版本中灵活地对并发控制采用更加复杂的方法。
6.慎用延迟初始化
延迟初始化指的是延迟到需要域的值时才对它初始化的行为。如果永远不需要这个值,这个域就永远不会被初始化,这种方法适用于静态域,也适用于实例域。
延迟初始化就像一把双刃剑,它降低了初始化类或创建实例的开销,却增加了访问被延迟初始化的域的开销。最好的建议是:除非绝对必要,否则就不要这么做。
当有多个线程,延迟初始化是需要技巧的。如果两个或多个线程共享一个延迟初始化的域,采用某种形式的同步是很重要的,否则就可能造成严重的bug。
//正常的初始化
private final FieldType field = computeFieldValue();
//延迟初始化
private FieldType field;
synchronized FieldType getField() {
if (field == null) {
field = computeFieldValue();
}
return field;
}
静态域的初始化同上述模式一样。
如果出于性能的考虑而需要对静态域使用延迟初始化,就是用lazy initialization holder class模式,这种模式保证了类被用到的时候才会被初始化:
private static class FieldHolder {
static final FieldType field = computeFieldValue();
}
static FieldType getField() {
return FieldHolder.field;
}
如果出于性能的考虑而需要对实例域使用延迟初始化,就是用双重检查模式(double check idiom):
private volatile FieldType field;
FieldType getField() {
//result变量的作用是确保field只在已经被初始化的情况下读取一次
//同不使用局部变量相比,可提升不少性能
FieldType result = field;
if (result == null) {
synchronized(this) {
if (result == null) {
result = computeFieldValue();
}
}
}
return result;
}
如果需要延迟初始化一个可以接受重复初始化的实例域,可以使用单重检查模式:
private volatile FieldType field;
private FieldType getField() {
FieldType result = field;
if (result == null) {
field = result = computeFieldValue();
}
return result;
}
大多数的域应该正常地进行初始化,而不是延迟初始化。如果为了达到性能目标,或是为了破坏有害的初始化循环,而必须延迟初始化一个域,就可以使用相应的延迟初始化方法。
对于实例域,使用双重检查模式;
对于静态域,使用lazy initialization holder class idiom;
对于可重复初始化的实例域,也可考虑使用单重检查模式。
7.不要依赖于线程调度器
当有多个线程可以运行时,由线程调度器决定哪些线程将会运行,以及运行多长时间。这种调度策略是由OS决定的,且不同的OS策略可能完全不同,因此,编写良好的程序不应该依赖于策略的细节,以免影响程序的可移植性。
要编写健壮的、响应良好的、可移植的多线程应用程序,最好的办法是确保可运行线程的平均数量不明显多于处理器的数量。
保持可运行线程数量尽可能少的主要方法是:让每个线程做有意义的工作,然后等待更多有意义的工作。如果线程没有在做有意义的工作,就不应该运行。线程不应该一直处于忙-等(busy-wait)的状态,即反复地检查一个共享对象,以等待某些事情发生。忙-等的做法会极大地增加处理器的负担,降低了同一机器上其他线程可以完成的有用工作量。如下述CountDownLatch的忙-等实现:
//busy-wait严重影响了系统性能
public class SlowCountDownLatch {
private int count;
public SlowCountDownLatch(int count) {
if (count < 0) {
throw new IllegalArgumentException(count + " < 0");
}
this.count = count;
}
public void await() {
while(true) {
synchronized(this) {
if (count == 0) {
return;
}
}
}
}
public synchronized void countDown() {
if (count != 0) {
count--;
}
}
}
如果某些线程无法获得足够的CPU时间,不要企图通过调用Thread.yield()来修正程序,它并不能保证什么。
线程优先级是Java平台上最不可移植的特征。通过调整某些线程的优先级来改善应用程序的响应能力是不必要、不可移植的。通过调整线程的优先级来解决严重的活性问题是不合理的,在找到并修改底层的真正原因之前,问题可能会再次出现。
不要让程序的正确性依赖于线程调度器。否则,应用程序将既不健壮,也不具备可移植性。
不要依赖于Thread.yield或者线程优先级,它们仅是对调度器做些暗示,未必可靠。线程优先级可以用来提高一个已经能够正常工作的程序的服务质量,但永远不应该用来“修正”一个原本并不能工作的程序。
8.避免使用线程组
线程组的初衷是作为一种隔离applet的安全机制,但实际上,它从未完成设计之初预期的任何安全功能,并且ThreadGroup API非常脆弱,有各种缺陷,它们之所以没有被修正,是因为线程组本身已经过时了,根本没有修正的必要了。因此,在实际的开发中,要避免使用线程组。
线程组并没有提供太多有用的功能,而且它提供的许多功能还都是有缺陷的。应该忽略掉它,就当ThreadGroup不曾存在一样。
如果你正在设计的一个类需要处理线程的逻辑组,应该使用线程池executor。
网友评论