我们并不希望每一次内存访问都进行分析以确保程序是线程安全的,而是希望将一些现有的线程安全组件组合为更大规模的组件或程序。
1、对象的组合
1、设计线程安全的类,包含三要素:
找出构成对象状态的所有变量;
找出约束状态变量的不变性条件;
建立对象状态的并发访问管理策略;
所谓的不可变条件可以理解为保持状态变量之间关系的一种约束,比如a<b,那么在并发中我们也必须保证该不变性条件。
要确保类的线程安全性,就需要确保它的不变性条件不会在并发访问中被破坏。
2、仅当一个变量参与到包含其他状态变量的不变性条件时,才可以声明为volatile类型。
3、如果某个类含有复合操作(比如if..),那么仅靠现有的线程安全组件并不足以实现线程安全性。在这种情况下,这个类必须提供自己的加锁机制以保证这些复合操作都是原子操作。
举例:
public class NumberRange{
//不变性条件:lower<upper
private final AtomicInteger lower = new AtomicInteger(0);
private final AtomicInteger upper = new AtomicInteger(0);
public void setLower(int i){
//注意:不安全的“先检查后执行”
if(i>upper.get())
throw new IllegalArgumentException("can not set to"+i+"> upper");
lower.set(i);
}
public void setUpper(int i){
//注意:不安全的“先检查后执行”
if(i<lower.get())
throw new IllegalArgumentException("can not set to"+i+"< lower");
upper.set(i);
}
}
如果一个线程调用setLower(5),而另一个线程调用setUpper(4),那么在一些错误的执行时序中,这两个调用都会被通过,并且都能设置成功,得到的结果将是一个无效的状态。
虽然AtomicInteger是线程安全的,但经过组合得到的类却不是。因此,可以通过加锁机制来维护不变性条件以确保线程安全性。
4、客户端加锁机制
public class ListHelper<E>{
public List<E> list = Collections.synchronizedList(new ArrayList<E>());
......
public synchronized boolean putIfAbsent(E x){
boolean absent = !list.contains(x);
if (absent){
list.add(x);
}
return absent;
}
}
上述示例不是线程安全的,为什么?原因是在错误的锁上进行了同步。无论List使用哪一个锁来保护它的状态,可以确定的是,这个锁并不是ListHelper上的锁。ListHelper只是带来了同步的假象,尽管所有的链表都被声明为synchronized,但却使用了不同的锁,这意味着putIfAbsent相对于List的其他操作并不是原子的,因此也就无法确保当putIfAbsent执行时,另一个线程不会修改链表。
解决方案:客户端加锁,即对于使用某个对象X的客户端代码,使用X本身用于保护其状态的锁来保护这段客户端代码。
public class ListHelper<E>{
public List<E> list = Collections.synchronizedList(new ArrayList<E>());
......
public boolean putIfAbsent(E x){
synchronized (list){
boolean absent = !list.contains(x);
if (absent){
list.add(x);
}
return absent;
}
}
}
2、基础构建模块
2.1、同步容器类
同步容器类包括Vector和HashTable,二者是早期JDK的一部分,此外还包括在JDK1.2中增加的一些功能相似的类,这些同步的封装器类都是由Collections.SynchronizedXxxx等工厂方法创建的。这些类实现线程安全的方式是:将它们的状态封装起来,对每一个公有方法都进行同步,使得每次只有一个线程能访问容器的状态。
同步容器类都是线程安全的,但在某些情况下可能需要额外的客户端加锁来保护复合操作。容器上常见的复合操作包括:迭代(反复访问元素,直到遍历完容器中所有的元素)、跳转(根据指定顺序找到当前元素的下一个元素)以及条件运算(例如“若没有则添加”)。在同步容器类中,这些复合操作在没有客户端加锁的情况下仍然是线程安全的,但当其他线程并发地修改容器时,它们可能会出现意料之外的行为。
public static Object getLast(Vector vector){
int lastIndex = vector.size()-1;
return vector.get(lastIndex);
}
public static Object deleteLast(Vector vector){
int lastIndex = vector.size()-1;
return vector.remove(lastIndex);
}
虽然Vector是线程安全的,但是在多线程的环境中,上述操作并非线程安全的,相反会抛出异常:ArrayIndexOutOfBoundException异常。因此需要额外的客户端加锁来保护复合操作:
public static Object getLast(Vector vector){
synchronized (vector) {
int lastIndex = vector.size()-1;
return vector.get(lastIndex);
}
}
public static Object deleteLast(Vector vector){
synchronized (vector) {
int lastIndex = vector.size()-1;
return vector.remove(lastIndex);
}
}
2.2、迭代器与ConcurrentModificationException
许多“现代”的容器类也并没有消除复合操作中的问题。无论是直接迭代还是在Java5中引入的for-each循环语法中,对容器类进行迭代的标准方式都是使用Iterator。然而如果有其他线程并发的修改容器,那么即使是使用迭代器也无法避免在迭代期间对容器加锁。在设计同步容器的迭代类的时候并没有考虑到并发修改的问题,并且他们表现的行为是“及时失败”的。这意味着,当它们发现容器在迭代过程中被修改时,就会抛出一个ConcurrentModificationException异常。这种“及时失败”的迭代器并不是一种完备的处理机制,而只是“善意地”提示并发错误,因此只能作为并发问题的预警提示器。与Vector一样,要想避免出现ConcurrentModificationException,就必须在迭代过程中持有容器的锁。
List<Widget> widgetList = Collections.synchronizedList(new ArrayList<>());
......
for(Widget w : widgetList){
doSomething(w);
}
有的开发人员可能并不想在容器上进行加锁,那么一种替代方法就是“克隆”容器,并在副本上迭代,由于副本被封闭在线程内,因此其他线程不会对其进行修改操作,这样就 避免了抛出ConcurrentModificationException异常。注意:在克隆容器时,有可能存在显著的性能开销。这取决于容器的大小,容器元素上的执行工作等。另外一个需要了解的是在单线程环境仍然可能抛出ConcurrentModificationException异常,当对象直接从容器中删除,而不是通过Iterator.remove删除时,就会抛出这个异常。
2.3、并发容器
Java5.0提供了多种并发容器类来改进同步容器类的性能。同步容器类将所有对容器状态的访问串行化,以实现它们的线程安全性,这种方法的代价是严重降低并发性,当多个线程竞争容器的锁时,吞吐量将严重减少。
另一方面,并发容器是针对多个线程并发访问设计的,在Java5.0中增加了ConcurrentHashMap,用来替换同步且基于散列的Map,以及CopyOnWriteArrayList,用于在遍历操作为主要操作情况下代替同步的List。在新的Concurrent接口中,新增加了对一些复合操作的支持,例如“若没有则添加”、替换以及有条件删除等【通过并发容器来代替同步容器,可以极大的提高伸缩性并降低风险】
Java5.0还提供了另外两个新的容器:Queue和BlockingQueue。Queue用于临时保存一组待处理的元素。它提供了几种实现,包括ConcurrentLinkedQueue,这是一个传统的先进先出队列,以及PriorityQueue,这是一个(非并发的)优先队列。Queue上的操作不会阻塞,如果队列为空,那么获取元素的操作将返回空值。虽然可以用List模拟Queue的行为 -- 事实上,正是通过LinkedList来实现Queue的,但还需要一个Queue的类,因为它能去掉List的随机访问,从而实现高并发。
BlockingQueue扩展了Queue,增加了可阻塞的插入和获取等操作。如果队列为空,获取元素的操作将一直阻塞,直到队列中出现一个可用的元素。如果队列已满(对于有界队列来说),那么插入元素的操作将一直阻塞,直到队列中出现可用的空间。在“生产者-消费者”这种设计模式中,阻塞队列是非常有用的。
正如ConcurrentHashMap中用来代替基于散列的同步Map,Java6也引入了ConcurrentSkipLstMap和ConcurrentSkipListSet,分别作为同步的SortedMap和SortedSet的并发替代品(例如用synchronizedMap包装的TreeMap或TreeSet)。
2.3.1、ConcurrentHashMap
同步容器类在执行每个操作期间都持有一个锁。在一些操作中,比如HashMap,在某些情况下,某些糟糕的散列函数可能会把散列表变为线性表,当遍历很长的链表并且在某些或者全部元素上调用equals方法时,会花费很长的时间,而其他线程在这段时间内都不能访问这个容器。
与HashMap一样,ConcurrentHashMap也是一个基于散列的Map,但它使用了一种完全不同的加锁策略来提供更高的并发性和可伸缩性。ConcurrentHashMap并不是将每一个方法都在同一个锁上同步并使得每次只能有一个线程访问容器,而是使用了一种粒度更细的加锁机制来实现更大程度的共享,这种机制被称为分段锁。在这种机制中,任意数量的读取线程可以并发访问Map,执行读取操作的线程和执行写入操作的线程可以并发地访问Map,并且一定数量的写入线程可以并发的修改Map。ConcurrentHashMap带来的结果是,在并发访问环境下将实现更高的吞吐量,而在单线程环境中只损失非常小的性能。
ConcurrentHashMap和其他并发容器类一起增强了同步容器类:它们提供的迭代器不会抛出ConcurrentModificationException,因此不需要再迭代过程中对容器加锁。。ConcurrentHashMap返回的迭代器具有弱一致性,而并非“及时失败”。弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有的元素,并可以(但不保证)在迭代器被构造后将修改操作反馈给容器。
在ConcurrentHashMap中没有实现对Map加锁以提供独占式访问。在HashTable和SynchronizedMap中,获取Map的锁能防止其他线程访问这个Map。与HashTable和SynchronizedMap相比,ConcurrentHashMap有着更多的优势和更少的劣势。因此,在大多数情况下,应当用ConcurrentHashMap来替代同步Map能进一步提高代码的可伸缩性。只有当应用程序需要加锁Map以进行独占式访问时,才应该放弃使用COncurrentHashMap。
public interface ConcurrentMap<K, V> extends Map<K, V> {
/**
* 仅当K没有相应的映射值时才插入(如果有则添加)
*/
V putIfAbsent(K key, V value);
/**
* 仅当key被映射到value时才移除(若相等则移除)
*/
boolean remove(Object key, Object value);
/**
* 仅当k被映射到oldValue时才替换为newValue(若相等则替换)
*/
boolean replace(K key, V oldValue, V newValue);
/**
* 仅当k被映射到某个值时才替换为value(若相等则替换)
*/
V replace(K key, V value);
}
对于一些复合式操作,例如“若相等则移除”、“若没有则添加”、“若相等则替换”等,ConcurrentHashMap都已经实现了原子操作。如果你需要在现有的同步Map中添加这样的功能,那么很可能意味着你要考虑使用ConcurrentHashMap了。
2.3.2、CopyOnWriteArrayList
CopyOnWriteArrayList用于替代同步List,在某些情况下它能提供更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制(类似的,CopyOnWriteArraySet的作用是替换同步Set)。“写入时复制”容器的线程安全性在于,只要正确的发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步的同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。“写入时复制”容器的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的起始位置,由于它不会被修改,因此在对其进行同步时只需保留确保数组内容的可见性。因此,多个线程可以同时对这个容器进行迭代,而不会彼此干扰或者与修改容器的线程相互干扰。“写入时复制”容器返回的迭代器不会抛出ConcurrentModificationException,并且返回的元素与迭代器创建时的元素完全一致,而不必考虑之后修改操作所带来的影响。
显然,每当修改容器的时候都会复制底层数组,这需要一定的开销。特别是当容器的规模较大时,仅当迭代器操作远远多于修改操作时,才应该使用“写入时复制”容器。
2.4、阻塞队列
阻塞队列提供了可阻塞的take和put方法,以及支持定时的offer和poll方法。BlockingQueue简化了生产者-消费者设计的实现过程,它支持任意数量的生产者和消费者。一种常见的生产者-消费者模式就是线程池与工作队列的组合,在Executor任务执行框架中就体现了这种模式。
BlockingQueue子类:LinkedBlockingQueue、ArrayBlockingQueue、SynchronousQueue。前两者不用说了,很简单。说下第三个SynchronousQueue实际上它不是一个真正的队列,因为它不会为元素维护存储空间。与其他队列不同的是,它维护一组线程,这些线程在等待着把元素移入或移除队列。比如以洗盘子的比喻为例,那么就相当于没有盘架,而是将洗好的盘子直接放入到下一个空闲的烘干机中。这种队列的实现方式看似奇怪,但由于可以直接交付工作,从而降低了将数据从生产者移动到消费者的延迟。(在传统的队列中,在一个工作单元交付之前,必须通过串行方式首先完成入列或者出列等操作)。因为SynchronousQueue没有存储功能,因此put和take会一直阻塞,直到有一个线程已经准备好参与到交付过程中。
在java.uil.concurrent中实现的各种阻塞队列都包含足够的内部同步机制,从而安全地将对象从生产者线程发布到消费者线程。
2.4.1、双端队列与工作密取
Java6增加了两种容器类型:Deque和BlockingDeque,它们分别对Queue和BlockingQueue进行了扩展。Deque是一个双端队列,实现了在队列头和队列尾的高效插入和删除操作。具体实现包括ArrayDeque和LinkedBlockingDeque。
正如阻塞队列适用于生产者-消费者模式,双端队列同样适用于另外一种模式,即工作密取模式。在生产者-消费者设计中,所有消费者有一个共享的工作队列,而在工作密取设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列的尾部秘密获取工作。相对于生产者-消费者模式,工作密取具有更高的可伸缩性,这是因为工作者线程不会在单个共享的任务队列中发生竞争。当双端队列为空时,它会在另一个线程的队列尾部查找新的任务,从而确保每个线程都保持忙碌状态。
2.5、阻塞方法与中断方法
线程可能发生阻塞或暂停执行,原因有多种:等待I/O操作结束,等待获取一个锁等。BlockingQueue的put和take等方法会抛出受检查异常,这与类库中其他一些方法的做法相同,例如Thread.sleep。当某方法抛出Interrupted-Exception时,表示该方法是一个阻塞方法。如果这个方法被中断。那么它将努力提前结束阻塞状态。
Thread提供了intercept方法,用于中断线程或者查询线程是否已经被中断。每个线程都有一个布尔类型的属性,表示线程的中断状态,当中断线程时将设置这个状态。中断是一种协作机制。一个线程不能强制其他线程停止正在执行的操作而去执行其他的操作。当线程A中断线程B,A仅仅是要求B在执行到某一个可以暂停的地方停止正在执行的操作-前提是如果线程B愿意停止下来。最常用的中断的情况就是取消某个操作。方法对中断请求点的响应度越高,就越容易及时取消那些执行时间很长的操作。
2.6、同步工具类
在容器类中,阻塞队列是一种独特的类:它们不仅能作为保存对象的容器,还能协调生产者和消费者等线程之间的控制流,因为take和put等方法将阻塞,直到队列达到期望的状态(队列既非满,也非空)。
同步工具类除了阻塞队列,还包括信号量(Semaphore)、栅栏(Barrier)以及闭锁(Latch,比如CountDownLatch、FutureTask)。在平台类库中还包含其他一些同步工具的类,如果这些类还无法满足需要,那么就需要创建自己的同步工具类。
2.6.1、闭锁
闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门:在闭锁到达结束状态前,这扇门一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门会打开并允许所有的线程通过。当闭锁到达结束状态之后,将不会再改变状态,因此这扇门将永远保持打开状态。
闭锁可以用来确定某些活动直到其他活动都完成才继续执行。
CountDownLatch是一种灵活的闭锁实现,闭锁状态包括一个计数器,该计数器被初始化为一个正数,表示需要等待的事件数量。countDown方法递减计数器,表示一个事件已经发生了,而await方法等待计数器达到零,这表示所有需要等待的事件都已经发生。如果计数器的值非零,那么await会一直阻塞到计数器为零,或者等待中的线程中断,或者等待超时。
public void timeTask(int nThreads,final Runnable task){
final CountDownLatch start = new CountDownLatch(1);
final CountDownLatch end = new CountDownLatch(nThreads);
for(int i=0;i<nThreads;i++){
Thread t = new Thread(new Runnable() {
@Override
public void run() {
try{
try{
start.await();
task.run();
}finally{
end.countDown();
}
}catch(Exception e){
e.printStackTrace();
}
}
});
t.start();
}
start.countDown();//启动门同时释放所有工作线程
try {
end.await();//结束门,等待最后一个线程执行完成
} catch (InterruptedException e) {
e.printStackTrace();
}
}
FutureTask(FutureTask实现了Future语义,表示一种抽象的可生成结果的计算)也可以作为闭锁。FutureTask表示的计算是通过Callable实现的,相当于一种可生成结果的Runnable,并且可处于以下三种状态:等待运行、正在运行、运行完成。“执行完成”表示计算的所有可能结束方式,包括正常结束、由于取消或者异常而结束。当FutureTask进入完成状态之后,将永远的停止在这个状态。
Future.get()的行为取决于任务的状态。如果任务已经完成,那么get将立即返回,否则get将阻塞直到任务进入完成状态,然后返回结果或抛出异常。FutureTask将计算结果从执行计算的线程传递到获取这个结果的线程,而FutureTask的规范确保了这种传递过程能实现结果的安全发布。
FutureTask在Executor框架中表示异步任务,此外还可以用来表示一些时间较长的计算,这些计算可以在使用计算结果之前启动。
private final FutureTask<ProductInfo> future = new FutureTask<ProductInfo>(new Callable<ProductInfo>() {
@Override
public ProductInfo call() throws Exception {
return null;
}
});
private final Thread thread = new Thread(future);
public void start(){
thread.start();
}
public ProductInfo get(){
try{
return future.get();
}catch (ExecutionException e){
e.printStackTrace();
}
}
2.6.2、信号量
计数信号量(Counting Semaphore)用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。
SemapPhore中管理了一组虚拟的许可,许可的初始数量可以通过构造函数来指定。在执行操作时可以首先获取许可(只要还有剩余的许可),并在使用以后释放许可。如果没有许可,那么acquire将阻塞直到有许可(或者被中断或者操作超时)。release方法将返回一个许可给信号量。计算信号量的一种简化形式是二值信号量,即初始值为1的Semaphore。二值信号量可以用作互斥体,并具备不可重入的加锁语义:谁拥有了这个唯一的许可,谁就拥有了互斥锁。
Semaphore可以用于实现资源池,例如数据库连接池。同样,你也可以使用Semaphore将任何一种容器变成有界阻塞容器。
public class BoundedHashSet<T>{
private final Set<T> set;
private final Semaphore sem;
public BoundedHashSet(int bound){
this.set = Collections.synchronizedSet(new HashSet<T>());
sem = new Semaphore(bound);
}
public boolean add(T o) throws InterruptedException{
sem.acquire();
boolean wasAdded = false;
try{
wasAdded = set.add(o);
return wasAdded;
}finally {
if (!wasAdded){
sem.release();
}
}
}
public boolean remove(Object o){
boolean wasRemoved = set.remove(o);
if (wasRemoved)
sem.release();
return wasRemoved;
}
}
2.6.3、栅栏
闭锁是一次性对象,一旦进入终止状态,就不能被重置。
栅栏类似于闭锁,它能阻塞一组线程直到某个事件发生。栅栏和闭锁的关键区别在于,所有线程必须同时到达栅栏,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。
CyclicBarrier可以使一定数量的参与方反复地在栅栏位置汇集,它在并行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列相互独立的子问题。当线程到达栅栏位置时,将调用await方法,这个方法将阻塞直到所有的线程都到达栅栏位置。如果所有的线程都到达栅栏位置,那么栅栏将打开,此时将释放所有的线程,而栅栏将被重置以便再次使用。如果对await的调用超时,或者await阻塞的线程被中断,那么栅栏就被认为是打破的。所有阻塞的await调用都将终止并抛出BrokenBarrierException。如果成功地通过栅栏,那么await将为每个线程返回一个唯一的达到索引号,我们可以利用这些索引来“选举”产生一个领导线程,并在下一次迭代中有该领导线程执行一些特殊的操作。
CyclicBarrier还可以使你将一个栅栏操作传递给构造函数,这是一个Runnable,当成功通过栅栏时会执行它,但在阻塞线程被释放前是不能执行的。
public class Beer {
public static void main(String[] args) {
final int count = 5;
final CyclicBarrier barrier = new CyclicBarrier(count, new Runnable() {
@Override
public void run() {
System.out.println("drink beer!");
}
});
// they do not have to start at the same time...
for (int i = 0; i < count; i++) {
new Thread(new Worker(i, barrier)).start();
}
}
}
class Worker implements Runnable {
final int id;
final CyclicBarrier barrier;
public Worker(final int id, final CyclicBarrier barrier) {
this.id = id;
this.barrier = barrier;
}
@Override
public void run() {
try {
this.barrier.await();
} catch (InterruptedException e) {
e.printStackTrace();
} catch (BrokenBarrierException e) {
e.printStackTrace();
}
}
}
再次说明CountDownLatch和CyclicBarrier区别:CountDownLatch是等待事件的发生(countDown->0),而CyclicBarrier则是等待所有的线程到达(即count=5,5个线程都到达了[调用了barrier.await],则执行我们传递给CyclicBarrier的Runnable)。
另一种形式的栅栏时ExChanger,它是一种两方的栅栏,在栅栏位置上交换数据。当两方执行不对称的操作时,ExChanger非常有用。例如当一个线程向缓冲区写数据,而另一个线程则向缓冲区读数据。这些线程可以使用ExChanger来汇合,并将满的缓冲区与空的缓冲区交换。当两个线程通过Exchanger交换对象时,这种交换就把这两个对象安全发布给另一方。