文章目录
- 第一部分 基础知识
- 第一部分小结
- 第二部分 结构化并发应用程序
- 第三部分 活跃性、性能与测试
- 第四部分 高级主题
简介:书中从 并发性和线程安全性的基本概念出发,介绍了如何使用类库提供的基本并发构建块,用于避免并发危险、构造线程安全的类以及验证线程安全的规则,
-
如何使用类库提供的基本并发构建块,用于避免并发危险;
-
构造线程安全的类以及验证线程安全的规则
-
如何将小的线程安全类组合成更大的线程安全类;
-
如何利用线程来提高并发程序的吞吐量,
-
如何识别可并行执行的任务,
-
如何提高单线程的子系统的响应性,
-
如何确保并发程序执行预期任务,
-
如何提高并发代码的性能和可伸缩性,
-
一些高级主题
第一部分 基础知识
第1章 简介
1.1 并发简史
促成计算机由串行向并行发展的原因:
-
资源利用率:比如任务调度如I/O时,CPU不要处于等待,而是处理其他任务;
-
公平性:多个用户应该平等的享受计算机资源;
-
便利性:比如,多个程序相互通信,要求程序之间是并发执行。
1.2 线程的优势
-
发挥多处理器强大能力;
-
建模的简单性:对于不同类型的任务,通过专门线程处理某一阶段的任务,可以以串行模型设计业务,简单了建模;
-
异步事件的简化处理;
-
响应更灵敏的用户界面。
1.3 线程带来的风险
-
安全性问题
-
安全性:永远不发生糟糕的事情
-
原因:多个线程修改共享的变量,导致结果不可预测;
-
解决方案:同步处理原子性操作。
-
-
活跃性问题
- 活跃性:某件正确的事最终发生,活跃性问题比如线程A无限等待线程B不会释放的资源,导致A阻塞,A等待后处理的语句永远不会发生。
- 原因:依赖于不同线程的事件发生时序。
-
性能问题
- 性能:正确的事情尽快发生
- 性能问题:服务时间过长、响应不灵敏等
1.4 线程无处不在(框架线程或类线程并发注意点)
- Timer类:TimeTask将在Timer管理的线程中执行,而不是由应用程序管理,如果某个TimeTask访问了应用程序中其他线程访问的数据,那么不仅TimeTask需要以线程安全的方式来访问数据,其他类也必须采用线程安全的方式来访问数据。通常实现这个目标,最简单的方式要确保TimeTask访问的对象本身是线程安全的,从而就能把线程安全性封装在共享对象内部;
- Servlet和JSP:Servlet、JSP、以及在ServletContext和HttpSession等容器中保存的Servlet过滤器和对象等,都必须是线程安全的。
- RMI:远程对象必须注意两个线程安全性问题,正确地协同在多个对象中共享的状态,以及对远程对象本身状态的访问。与Servlet相同,RMI对象应该做好被多个线程同时调用的准备,并且必须确保他们自身的线程安全性。
第2章 线程安全性
- 要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的同时可变的状态的访问;
- 线程安全解决的方案:
- 不在线程之间共享该状态变量:ThreadLocal类;
- 将状态变量修改为不可变的变量;
- 在访问变量时采用同步机制
2.1 什么是线程安全性
- 一个对象可以被多个线程调用,就要注意该对象的线程安全性;
- 无状态对象一定是线程安全的。
2.2 原子性
-
竞态条件:某个计算的正确性取决于多个线程的交替执行时序时,通俗点说,基于一种可能失效的观察结果来做出判断或者执行某个计算,正确的结果取决于运气。
-
常见的竞态条件类型先检查后执行操作,如以下常见的实例:单例和延迟初始化
-
JUC包原子类管理对象状态
2.3 加锁机制
多个原子性引用如果有相互制约关系,并不能保证该类是线程安全的,如下
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-J4FeFVFj-1648810808293)(https://cdn.jsdelivr.net/gh/huangjt520/Image-host@master/picBook/2.5av87uwgbe00.webp)]
内置锁:Synchronized关键字
可重入锁:获取锁的操作粒度是“线程”而不是调用
/**
* @author huangjt
* @ClassName: Wight
* @Description:
* @Date 2022/3/11 9:53
*/
public class Wight {
public synchronized void dosomething(){
System.out.println("Wight的this is : "+ this.toString());
}
}
class SubWight extends Wight{
@Override
public synchronized void dosomething() {
System.out.println("SubWight 的 this is : "+ this.toString());
System.out.println("SubWight 的 super is : "+ super.toString());
super.dosomething();
}
public static void main(String[] args) {
SubWight subWight = new SubWight();
subWight.dosomething();
}
}
/***************结果为**********************/
/*
SubWight 的 this is : SubWight@1540e19d
SubWight 的 super is : SubWight@1540e19d
Wight的this is : SubWight@1540e19d
*/
由以上结果联系JVM内存结构可知,
- 子类调用父类的方法,引用都是子类的引用;
- 栈帧顶部super存取有父类方法区中的信息,也就是子类初始化的时候并不创建父类的实例,子类可以调用父类的方法。
用锁来保护状态
- 对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护
活跃性与性能性
- 评估线程安全、简单性、和性能,中间取得平衡
- 当执行时间较长的计算或者无法快速完成的操作时(如IO),一定不要持有锁。
第3章 对象的共享
3.1 可见性
由于指令重排序和虚拟机线程读取机制,可能造成读取到共享可变变量的失效数据,如下
-
失效数据
-
非原子性的64位操作:虚拟机允许将64位的读操作和写操作分解为两个32位的操作。因此,共享可变的long和double同步,用volatile关键字或者用锁保护起来
-
加锁与可见性
-
Volatile变量:禁止指令重排序和取值从主内存取,只能保证可见性,不能保证原子性,使用的时候需谨慎,常用使用场景如下
3.2 发布与逸出
-
发布:将对象能够在当前作用域之外的代码中使用;
-
逸出:当某个不应该发布的对象被发布时,这种情况就被称为逸出。常见的是构造器this逸出,因为初始化的时候还没有实例,this引用为空。所以不要再构造器中用隐式this引用
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CwIjl12E-1648810808295)(https://cdn.jsdelivr.net/gh/huangjt520/Image-host@master/picBook/使用工厂方法防止隐式逸出.2wqp11xr2me0.webp)]
3.3 线程封闭
-
Ad-hoc线程封闭:完全由程序实现承担。
-
栈封闭:只有通过局部变量才能访问对象
-
ThreadLocal类
3.4 对象的不变性
- 对象创建以后其状态不能修改
- 对象的所有域都是final类型;
- 对象是正常创建的。
3.5 安全发布
-
不正常的发布
-
安全发布的常用模式
-
原则
- 在静态初始化函数中初始化一个对象引用;
- 将对象的引用保存到volatile类型的域或者AtomicReferance对象中;
- 将对象的引用保存到某个正常的构造对象的final类型域中;
- 将对象的引用保存到一个由锁保护的域中。
-
JUC库作出的保证:
- 通过一个将一个键或者值放入HashTable、synchronizedMap或者ConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程;
- 通过将某个元素放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或synchronizedSet中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程;
- 通过将某个元素放入 BlockingQueue 或者 ConcurrentLinkedQueue 中,可以将该元素安全 地发布到任何从这些队列中访问该元素的线程;
- 类库中的其他数据传递机制(例如 Future 和 Exchanger) 同样能实现安全发布,在介绍这 些机制时将讨论它们的安全发布功能。
-
通常发布静态构造的对象,最安全简单的方式是使用静态的初始化器
public static Holder hold = new Holder();
-
事实不可变对象
-
可变对象:安全发布后的每次对象访问都需要同步机制
-
安全地共享变量
- 线程封闭:线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改;
- 只读共享
- 线程安全共享
- 保护对象:被保护的对象只能通过持有特定的锁来访问
-
第4章 对象的组合
4.1 设计线程安全的类
-
设计线程安全类的考虑要素
- 找出构成对象状态的所有变量
- 找出约束状态变量的不变性条件
- 建立对象状态的并发访问管理策略
-
收集同步需求
- 状态空间越小, 就越容易判断线程的状态。final 类型的域使用得越多, 就越能简化对象可能状态的分析过程;
- 在操作中还会包含一些后验条件来判断状态迁移是否是有效的;当下一个状态需要依赖当前状态时, 这个操作就必须是一个复合操作;
- 如果在一个不变性条件中包含多个变量, 那么在执行任何访问相关变量的操作时, 都必须持有保护这些变扯的锁。
-
依赖状态的操作
- 如果在某个操作中包含有基于状态的先验条件,那么这个操作就称为依赖状态的操作,如删除某个队列元素时,必须先保证队列不为空;
- 要想实现某个等待先验条件为真时才执行的操作,一种更简单的方法是通过现有的库类(如阻塞队列Blocking Queue)信号量(Semaphore))来实现依赖状态的行为;
-
状态的所有权
- 状态变量的所有权将决定采用何种加锁协议来维持变量状态的完整性。所有权意味着控制权;
- 如果发布了某个可变对象的引用,那么就不再拥有独立的控制权,最多是“共享控制权”。
4.2 实例封闭
-
你可以确保该对象只能由单个线程访问(线程封闭),或者通过一个锁来保护对该对象的所有访问。
-
封装简化了线程安全类的实现过程,它提供了一种实例封闭机制,当一个对象被封装到另一个对象中时,能够访问被封装对象的所有代码路径都是已知的。
-
通过将封装机制与合适的加锁策略结合起来,可以确保以线程安全的方式来使用非线程安全的对象;
-
将数据封装在对象内部,可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁;
-
被封闭对象一定不能超过他们既定的作用域。
-
对象可以封闭在类的一 个实例(例如作为类 的一个私有成员)中, 或者封闭在某个作用域内(例如作为一个局部变扯), 再或者封闭在线程内(例如在某个线程中将对象从一个方法传递到另一 个方法, 而不是在多个线程之间共享该对象)。
-
通过封闭与加锁实现线程安全例子:1.私有;2.不可变;3.加锁访问
-
封闭机制更易于构造线程安全的类,因为当封闭类的状态时,在分析类的线程安全性时就无须检查整个程序。
-
监视器模式:将监视器对象的所有可变状态都封装起来,并由对象自己的内置锁来保护
-
Java监视器模式仅仅是一种编写代码的约定,对于任何一种锁对象,只要自始至终都使用该锁对象,都可以用来保护对象的状态。
-
监视器模式例子
-
使用私有的锁对象而不是对象的内置锁可以将锁封装起来,使客户代码无法得到锁,避免活跃性问题。
-
-
监视器模式例子二
4.3 线程安全性委托
-
线程安全性委托:委托给线程安全或者不可变状态
-
我们还可以将线程安全性委托给多个状态变量,只要这些变量是彼此独立,即组合而成的类并不会在其包含的多个状态变量上增加任何不变性条件。
-
如果一个状态变量是线程安全的,并且没有任何不变性条件来约束它的值,在变量的操作上也不存在任何不允许的状态转换,那么就可以安全地发布这个变量
-
发布底层的状态变量
4.4 在现有的线程安全类中添加功能
-
Java类库包含许多有用的“基础模块”类。通常,我们应该优先选择重用这些现有的类而不是创建新的类;
-
方式:
-
要添加一个新的原子操作,最安全的方法是修改原始的类,但这通常无法做到,因为可能无法访问或修改源代码,修改前还需要了解原始类的同步机制;
-
另一种方法是扩展这个类,增加原子操作方法;扩展操作比直接操作原始类更加脆弱,如果原始类改变同步策略,将会有大麻烦
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UPxZapE2-1648810808299)(https://cdn.jsdelivr.net/gh/huangjt520/Image-host@master/picBook/线程安全类添加功能.45dtouyctvy0.webp)]
-
客户端加锁机制:扩展类,并在操作列表上加锁,而不是加内置锁
-
错误客户端同步方式
-
客户端正确加锁机制
-
通过添加一个原子操作来扩展类是脆弱的,因为它将类的加锁代码分布到多个类中(容易造成死锁).然而,客户端加锁却更加脆弱,因为它将类的加锁代码放到与类完全无关的其他类中,当在那些并不承诺遵循加锁策略的类上使用客户端加锁时,要特别小心。
-
-
更好的方法——组合
-
暂时没看懂
-
-
4.5 将同步策略文档化
第5章 基础构建模块
第4章介绍了构造线程安全类时采用的一些技术,例如将线程安全性委托给现有的线程安全类。委托是创建线程安全类的一个最有效的策略:只需让现有的线程安全类管理所有的状态即可。
Java平台类库包含了丰富的并发基础构建模块,例如线程安全的容器类以及各种用于协调多个相互协作的线程控制流的同步工具类(Synchronizer)。本章将介绍其中一些最有用的并发构建模块,特别是在Java 5.0和Java 6中引入的一些新模块,以及在使用这些模块来构造并发应用程序时的一些常用模式。
5.1 同步容器类
-
同步容器类的问题:复合操作可能不是原子性的
-
Vector容器可能导致混乱的复合操作
public static Object getLast(Vector list){ int lastIndex=list.size()-1; return list.get(lastIndex); }
-
Vector客户端委派加锁机制
public static Object getLast(Vector list){ synchronized(list){ int lastIndex=list.size()-1; return list.get(lastIndex); } }
-
-
迭代器与ConcurrentModificationException
- 及时失败策略(fail-fast):将计数器的变化与容器关联起来,如果在迭代期间计数器被修改,那么hasNext或next将抛出ConcurrentModificationException
-
隐藏迭代器
-
虽然加锁可以防止迭代器抛出ConcurrentModificationException,但你必须要记住在所有对共享容器进行迭代的地方都需要加锁。实际情况要更加复杂,因为在某些情况下,迭代器会隐藏起来
-
隐藏迭代器实例
public class HiddenIterator{ @GuardedBy("this")private final Set<Integer>set=new HashSet<Integer>(); public synchronized void add(Integer i){ set.add(i);} public synchronized void remove(Integer i){ set.remove(i);} public void addTenThings(){ Random r=new Random(); for(int i=0;i<10;i++)add(r.nextInt()); //toString隐式调用集合的迭代器 System.out.println("DEBUG:added ten elements to"+set); } }
-
如果状态与保护它的同步代码之间相隔越远,那么开发人员就越容易忘记在访问状态时使用正确的同步。如果HiddenIterator用synchronizedSet来包装HashSet,并且对同步代码进行封装,那么就不会发生这种错误。
-
5.2 并发容器
-
同步容器与并发容器
- 同步容器缺点:为了同步导致所有对容器的状态访问都串行化,严重影响了性能。
- 通过并发容器来代替同步容器,可以极大地提高伸缩性并降低风险
-
容器与替代:
- ConcurrentHashMap
- CopyOnWriteArrayList:用于遍历操作为主要操作的情况下代替同步的List;
- BlockingQueue:增加了可阻塞的插入和获取等操作。如果队列为空,获取操作将阻塞,直到队列有值;如果队列已满,插入操作将阻塞,直到队列不满。
- ConcurrentSkipListMap
- ConcurrentSkipListSet
-
ConcurrentHashMap
-
与HashMap一样,ConcurrentHashMap也是一个基于散列的Map,但它使用了一种完全不同的加锁策略来提供更高的并发性和伸缩性:分段锁。
-
在这种机制中,任意数量的读取线程可以并发地访问Map,执行读取操作的线程和执行写入操作的线程可以并发地访问Map,并且一定数量的写入线程可以并发地修改Map
-
ConcurrentHashMap与其他并发容器一起增强了同步容器类:它们提供的迭代器不会抛出ConcurrentModificationException,因此不需要在迭代过程中对容器加锁
-
ConcurrentHashMap返回的迭代器具有弱一致性(Weakly Consistent),而并非“及时失败”。弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有的元素,并可以(但是不保证)在迭代器被构造后将修改操作反映给容器。
-
对于一些需要在整个Map上进行计算的方法,例如size和isEmpty,这些方法的语义被略微减弱了以反映容器的并发特性。
-
额外的Map操作:比较插入、比较删除、比较替代
public interface ConcurrentMap<K, V>extends Map<K, V>{ //仅当K没有相应的映射值时才插入V putIfAbsent(K key, V value); //仅当K被映射到V时才移除 boolean remove(K key, V value); //仅当K被映射到oldValue时才替换为newValue boolean replace(K key, V oldValue, V newValue); //仅当K被映射到某个值时才替换为newValueV replace(K key, V newValue); }
-
-
CopyOnWriteArrayList:CopyOnWriteArrayList用于替代同步List,在某些情况下它提供了更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制
- “写入时复制(Copy-On-Write)”容器的线程安全性在于,只要正确地发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步的同步。
- 在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。
- “写入时复制”容器的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的起始位置,由于它不会被修改,因此在对其进行同步时只需确保数组内容的可见性。
- 显然,每当修改容器时都会复制底层数组,这需要一定的开销,特别是当容器的规模较大时。仅当迭代操作远远多于修改操作时,才应该使用“写入时复制”容器。
5.3 阻塞队列与生产者-消费者模式
-
阻塞队列提供了可阻塞的put和take方法,以及支持定时的offer和poll方法(不阻塞,返回结果用于资源管理);
-
如果队列已经满了,那么put方法将阻塞直到有空间可用;如果队列为空,那么take方法将会阻塞直到有元素可用.
-
队列可以是有界的也可以是无界的,无界队列永远都不会充满,因此无界队列上的put方法也永远不会阻塞。
-
阻塞队列支持生产者-消费者这种设计模式。
-
在构建高可靠的应用程序时,有界队列是一种强大的资源管理工具:它们能抑制并防止产生过多的工作项,使应用程序在负荷过载的情况下变得更加健壮。
-
如果阻塞队列并不完全符合设计需求,那么还可以通过**信号量(Semaphore)**来创建其他的阻塞数据结构(请参见5.5.3节)。
-
阻塞队列具体实现
- LinkedBlockingQueue
- ArrayBlockingQueue
- PriorityBlockingQueue
- SynchronousQueue:维护一组线程
-
示例1:桌面搜索
-
生产者:将符合条件的文件名称放入队列;消费者:为符合条件的文件建立索引
-
生产者代码示例
public class FileCrawler implements Runnable{ private final BlockingQueue<File>fileQueue; private final FileFilter fileFilter; private final File root; …… public void run() { try{ crawl(root); }catch(InterruptedException e){ Thread.currentThread().interrupt(); } } //生产者:将符合条件的文件名称放入阻塞队列 private void crawl(File root)throws InterruptedException{ File[]entries=root.listFiles(fileFilter); if(entries!=null){ for(File entry:entries) if(entry.isDirectory()) crawl(entry); else if(!alreadyIndexed(entry)) fileQueue.put(entry); } } }
-
消费者代码示例
public class Indexer implements Runnable{ private final BlockingQueue<File>queue; public Indexer(BlockingQueue<File>queue){ this.queue=queue; } public void run(){ try{ while(true) indexFile(queue.take()); }catch(InterruptedException e){ Thread.currentThread().interrupt(); } } }
-
生产者-消费者模式提供了一种适合线程的方法将桌面搜索问题分解为更简单的组件。将文件遍历与建立索引等功能分解为独立的操作,比将所有功能都放到一个操作中实现有着更高的代码可读性和可重用性:每个操作只需完成一个任务,并且阻塞队列将负责所有的控制流,因此每个功能的代码都更加简单和清晰。
-
-
串行线程封闭
- 线程封闭对象只能由单个线程拥有,但可以通过安全地发布该对象来“转移”所有权;在转移所有权后,也只有另一个线程能获得这个对象的访问权限,并且发布对象的线程不会再访问它;这种安全的发布确保了对象状态对于新的所有者来说是可见的,并且由于最初的所有者不会再访问它,因此对象将被封闭在新的线程中。新的所有者线程可以对该对象做任意修改,因为它具有独占的访问权。
- 对于可变对象,生产者-消费者这种设计与阻塞队列一起,促进了串行线程封闭,从而将对象所有权从生产者交付给消费者。
- 我们也可以使用其他发布机制来传递可变对象的所有权,但必须确保只有一个线程能接受被转移的对象;而且原线程转移对象所有权后不对对象进行操作(比如通过线程池机制就可以安全的转移对象所有权)
-
双端队列与工作密取
- Java 6增加了两种容器类型,Deque(发音为“deck”)和BlockingDeque,它们分别对Queue和BlockingQueue进行了扩展。
- Deque是一个双端队列,实现了在队列头和队列尾的高效插入和移除。具体实现包括ArrayDeque和LinkedBlockingDeque。
- 正如阻塞队列适用于生产者-消费者模式,双端队列同样适用于另一种相关模式,即工作密取(Work Stealing)
- 在生产者-消费者设计中,所有消费者有一个共享的工作队列,而在工作密取设计中,每个消费者都有各自的双端队列。如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者双端队列末尾秘密地获取工作。
- 密取工作模式比传统的生产者-消费者模式具有更高的可伸缩性,这是因为工作者线程不会在单个共享的任务队列上发生竞争。在大多数时候,它们都只是访问自己的双端队列,从而极大地减少了竞争。当工作者线程需要访问另一个队列时,它会从队列的尾部而不是从头部获取工作,因此进一步降低了队列上的竞争程度。
- 工作密取非常适用于既是消费者也是生产者问题——当执行某个工作时可能导致出现更多的工作。例如,在网页爬虫程序中处理一个页面时,通常会发现有更多的页面需要处理。类似的还有许多搜索图的算法,例如在垃圾回收阶段对堆进行标记,都可以通过工作密取机制来实现高效并行
- 当双端队列为空时,它会在另一个线程的队列队尾查找新的任务,从而确保每个线程都保持忙碌状态。
5.4 阻塞方法和中断方法
-
当某方法抛出Interrupted-Exception时,表示该方法是一个阻塞方法,如果这个方法被中断,那么它将努力提前结束阻塞状态。
-
Thread提供了interrupt方法,用于中断线程或者查询线程是否已经被中断。每个线程都有一个布尔类型的属性,表示线程的中断状态,当中断线程时将设置这个状态。
-
中断是一种协作机制。一个线程不能强制其他线程停止正在执行的操作而去执行其他的操作。当线程A中断B时,A仅仅是要求B在执行到某个可以暂停的地方停止正在执行的操作——前提是如果线程B愿意停止下来。
-
当在代码中调用了一个将抛出InterruptedException异常的方法时,你自己的方法也就变成了一个阻塞方法,并且必须要处理对中断的响应
-
传递InterruptedException。避开这个异常通常是最明智的策略——只需把InterruptedException传递给方法的调用者。传递InterruptedException的方法包括,根本不捕获该异常,或者捕获该异常,然后在执行某种简单的清理工作后再次抛出这个异常。
-
恢复中断。有时候不能抛出InterruptedException,例如当代码是Runnable的一部分时。在这些情况下,必须捕获InterruptedException,并通过调用当前线程上的interrupt方法恢复中断状态,这样在调用栈中更高层的代码将看到引发了一个中断,恢复中断状态以避免屏蔽中断
public class TaskRunnable implements Runnable{ BlockingQueue<Task>queue; …… public void run(){ try{ processTask(queue.take());} catch(InterruptedException e){ //恢复被中断的状态 Thread.currentThread().interrupt(); } } }
-
-
在出现InterruptedException时不应该做的事情是,捕获它但不做出任何响应。只有在一种特殊的情况中才能屏蔽中断,即对Thread进行扩展,并且能控制调用栈上所有更高层的代码。
5.5 同步工具类
- 在容器类中,阻塞队列是一种独特的类:它们不仅能作为保存对象的容器,还能协调生产者和消费者等线程之间的控制流。
- 同步工具类可以是任何一个对象,只要它根据其自身的状态来协调线程的控制流。阻塞队列可以作为同步工具类,其他类型的同步工具类还包括信号量(Semaphore)、栅栏(Barrier)以及闭锁(Latch)。
- 所有的同步工具类都包含一些特定的结构化属性:它们封装了一些状态,这些状态将决定执行同步工具类的线程是继续执行还是等待,此外还提供了一些方法对状态进行操作,以及另一些方法用于高效地等待同步工具类进入到预期状态。
5.5.1 闭锁
-
闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁可以用来确保某些活动直到其他活动都完成后才继续执行。
-
闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门会打开并允许所有的线程通过。当闭锁到达结束状态后,将不会再改变状态,因此这扇门将永远保持打开状态。
-
适用场景
- 确保某个计算在其需要的所有资源都被初始化之后才继续执行。二元闭锁(包括两个状态)可以用来表示“资源R已经被初始化”,而所有需要R的操作都必须先在这个闭锁上等待
- 确保某个服务在其依赖的所有其他服务都已经启动之后才启动。每个服务都有一个相关的二元闭锁。当启动服务S时,将首先在S依赖的其他服务的闭锁上等待,在所有依赖的服务都启动后会释放闭锁S,这样其他依赖S的服务才能继续执行
- 等待直到某个操作的所有参与者(例如,在多玩家游戏中的所有玩家)都就绪再继续执行。在这种情况中,当所有玩家都准备就绪时,闭锁将到达结束状态。
-
CountDownLatch是一种灵活的闭锁实现,可以在上述各种情况中使用,它可以使一个或多个线程等待一组事件发生。
-
闭锁状态包括一个计数器,该计数器被初始化为一个正数,表示需要等待的事件数量;
-
countDown方法递减计数器,表示有一个事件已经发生了,而await方法等待计数器达到零,这表示所有需要等待的事件都已经发生。如果计数器的值非零,那么await会一直阻塞直到计数器为零,或者等待中的线程中断,或者等待超时。
-
利用闭锁并发执行任务和等待最后一个线程完成任务后操作示例:
public class TestHarness{ public long timeTasks(int nThreads, final Runnable task)throws InterruptedException{ //闭锁开始门,初始化为1 final CountDownLatch startGate=new CountDownLatch(1); //闭锁结束门,初始化为线程数量 final CountDownLatch endGate=new CountDownLatch(nThreads); for(int i=0;i<nThreads;i++){ Thread t=new Thread(){ public void run(){ try{ //线程等待初始门为0,也就是所有线程准备就绪(初始化) startGate.await(); try{ //线程任务 task.run(); }finally{ //线程任务执行完毕的时候,将结束门递减 endGate.countDown(); } }catch(InterruptedException ignored){ } } }; //启动线程 t.start(); } long start=System.nanoTime(); //所有线程都准备完毕,将开始门递减,让所有线程结束等待状态,开始并发执行任务 startGate.countDown(); //结束门等待所有线程全部执行任务结束(即最后一个线程将结束门递减到0) endGate.await(); long end=System.nanoTime(); //记录执行时间 return end-start; } }
- 为什么要在TestHarness中使用闭锁,而不是在线程创建后就立即启动?或许,我们希望测试n个线程并发执行某个任务时需要的时间。如果在创建线程后立即启动它们,那么先启动的线程将“领先”后启动的线程,并且活跃线程数量会随着时间的推移而增加或减少,竞争程度也在不断发生变化。
- 启动门将使得主线程能够同时释放所有工作线程,而结束门则使主线程能够等待最后一个线程执行完成,而不是顺序地等待每个线程执行完成。
-
5.5.2 FutureTask
-
FutureTask也可以用做闭锁。(FutureTask实现了Future语义,表示一种抽象的可生成结果的计算;
-
FutureTask表示的计算是通过Callable来实现的,相当于一种可生成结果的Runnable,并且可以处于以下3种状态:等待运行(Waiting to run),正在运行(Running)和运行完成(Completed).
-
“执行完成”表示计算的所有可能结束方式,包括正常结束、由于取消而结束和由于异常而结束等。当FutureTask进入完成状态后,它会永远停止在这个状态上。
-
Future.get的行为取决于任务的状态。如果任务已经完成,那么get会立即返回结果,否则get将阻塞直到任务进入完成状态,然后返回结果或者抛出异常。
-
FutureTask将计算结果从执行计算的线程传递到获取这个结果的线程,而FutureTask的规范确保了这种传递过程能实现结果的安全发布。
-
FutureTask在Executor框架中表示异步任务,此外还可以用来表示一些时间较长的计算,这些计算可以在使用计算结果之前启动.通过提前启动计算,可以减少在等待结果时需要的时间。示例如下:
public class Preloader{ //future 相当于一个中继,对上承接另一个线程的结果,对下传递这另一个线程的执行任务 private final FutureTask<ProductInfo> future=new FutureTask<ProductInfo>(new Callable<ProductInfo>(){ public ProductInfo call()throws DataLoadException{ return loadProductInfo(); } }); private final Thread thread=new Thread(future); public void start(){ thread.start(); } public ProductInfo get()throws DataLoadException, InterruptedException{ try{ return future.get(); }catch(ExecutionException e){ Throwable cause=e.getCause(); if(cause instanceof DataLoadException) throw(DataLoadException)cause; else throw launderThrowable(cause); } } }
- 由于在构造函数或静态初始化方法中启动线程并不是一种好方法,因此提供了一个start方法来启动线程。当程序随后需要ProductInfo时,可以调用get方法,如果数据已经加载,那么将返回这些数据,否则将等待加载完成后再返回。
- Callable表示的任务可以抛出受检查的或未受检查的异常,并且任何代码都可能抛出一个Error。无论任务代码抛出什么异常,都会被封装到一个ExecutionException中,并在Future.get中被重新抛出。
5.5.3 信号量
-
计数信号量(Counting Semaphore)用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。计数信号量还可以用来实现某种资源池,或者对容器施加边界。
-
Semaphore中管理着一组虚拟的许可(permit),许可的初始数量可通过构造函数来指定。在执行操作时可以首先获得许可(只要还有剩余的许可),并在使用以后释放许可。
-
如果没有许可,那么acquire将阻塞直到有许可(或者直到被中断或者操作超时)。release方法将返回一个许可给信号量。
-
Semaphore可以用于实现资源池,例如数据库连接池。我们可以构造一个固定长度的资源池,当池为空时,请求资源将会失败,但你真正希望看到的行为是阻塞而不是失败,并且当池非空时解除阻塞。如果将Semaphore的计数值初始化为池的大小,并在从池中获取一个资源之前首先调用acquire方法获取一个许可,在将资源返回给池之后调用release释放许可,那么acquire将一直阻塞直到资源池不为空。
-
同样,你也可以使用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; } }
- 信号量的计数值会初始化为容器容量的最大值。add操作在向底层容器中添加一个元素之前,首先要获取一个许可。如果add操作没有添加任何元素,那么会立刻释放许可;
- 同样,remove操作释放一个许可,使更多的元素能够添加到容器中。
- 底层的Set实现并不知道关于边界的任何信息,这是由BoundedHashSet来处理的。
- [1]在这种实现中不包含真正的许可对象,并且Semaphore也不会将许可与线程关联起来,因此在一个线程中获得的许可可以在另一个线程中释放可以将acquire操作视为是消费一个许可,而release操作是创建一个许可,Semaphore并不受限于它在创建时的初始许可数量。
5.5.4 栅栏
-
我们已经看到通过闭锁来启动一组相关的操作,或者等待一组相关的操作结束。闭锁是一次性对象,一旦进入终止状态,就不能被重置;
-
栅栏(Barrier)类似于闭锁,它能阻塞一组线程直到某个事件发生[CPJ 4,4.3]。栅栏与闭锁的关键区别在于,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。
-
栅栏用于实现一些协议,例如几个家庭决定在某个地方集合:“所有人6:00在麦当劳碰头,到了以后要等其他人,之后再讨论下一步要做的事情。”
-
CyclicBarrier可以使一定数量的参与方反复地在栅栏位置汇集,它在并行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列相互独立的子问题。当线程到达栅栏位置时将调用await方法,这个方法将阻塞直到所有线程都到达栅栏位置。如果所有线程都到达了栅栏位置,那么栅栏将打开,此时所有线程都被释放,而栅栏将被重置以便下次使用。如果对await的调用超时,或者await阻塞的线程被中断,那么栅栏就被认为是打破了,所有阻塞的await调用都将终止并抛出BrokenBarrierException。
-
如果成功地通过栅栏,那么await将为每个线程返回一个唯一的到达索引号,我们可以利用这些索引来“选举”产生一个领导线程,并在下一次迭代中由该领导线程执行一些特殊的工作。
-
CyclicBarrier还可以使你将一个栅栏操作传递给构造函数,这是一个Runnable,当成功通过栅栏时会(在一个子任务线程中)执行它,但在阻塞线程被释放之前是不能执行的。
-
在模拟程序中通常需要使用栅栏,例如某个步骤中的计算可以并行执行,但必须等到该步骤中的所有计算都执行完毕才能进入下一个步骤。
-
在程序清单5-15的CellularAutomata中给出了如何通过栅栏来计算细胞的自动化模拟,例如Conway的生命游戏(Gardner,1970)
public class CellularAutomata{ private final Board mainBoard; private final CyclicBarrier barrier; private final Worker[]workers; public CellularAutomata(Board board){ this.mainBoard=board; //获得得用线程数 int count=Runtime.getRuntime().availableProcessors(); this.barrier=new CyclicBarrier(count,new Runnable(){ //汇总值 public void run(){ mainBoard.commitNewValues(); } }); this.workers=new Worker[count]; for(int i=0;i<count;i++) //将大问题分解成小问题并行解决 workers[i]=new Worker(mainBoard.getSubBoard(count, i)); } private class Worker implements Runnable{ private final Board board; public Worker(Board board){ this.board=board; } public void run(){ while(!board.hasConverged()){ for(int x=0;x<board.getMaxX();x++) for(int y=0;y<board.getMaxY();y++) board.setNewValue(x, y,computeValue(x, y)); try{ barrier.await(); }catch(InterruptedException ex){ return; }catch(BrokenBarrierException ex){ return; } } } } public void start(){ for(int i=0;i<workers.length;i++) new Thread(workers[i]).start(); mainBoard.waitForConvergence(); } }
- 合理的做法是,将问题分解成一定数量的子问题,为每个子问题分配一个线程来进行求解,之后再将所有的结果合并起来。CellularAutomata将问题分解为Ncpu个子问题,其中Ncpu等于可用CPU的数量,并将每个子问题分配给一个线程。[插图]在每个步骤中,工作线程都为各自子问题中的所有细胞计算新值。当所有工作线程都到达栅栏时,栅栏会把这些新值提交给数据模型。在栅栏的操作执行完以后,工作线程将开始下一步的计算,包括调用isDone方法来判断是否需要进行下一次迭代。
-
另一种形式的栅栏是Exchanger,它是一种两方(Two-Party)栅栏,各方在栅栏位置上交换数据[CPJ 3.4.3]。当两方执行不对称的操作时,Exchanger会非常有用,例如当一个线程向缓冲区写入数据,而另一个线程从缓冲区中读取数据。这些线程可以使用Exchanger来汇合,并将满的缓冲区与空的缓冲区交换。当两个线程通过Exchanger交换对象时,这种交换就把这两个对象安全地发布给另一方。
5.6 构建高效且可伸缩的结果缓存
-
使用HashMap和同步机制来初始化缓存
public interface Computable<A, V>{ V compute(A arg)throws InterruptedException; } public class ExpensiveFunctionimplements Computable<String, BigInteger>{ public BigInteger compute(String arg){ //在经过长时间的计算后 return new BigInteger(arg); } } //第一次尝试 public class Memoizer1<A, V>implements Computable<A, V>{ //用HashMap当做缓存器 @GuardedBy("this")private final Map<A, V>cache=new HashMap<A, V>(); private final Computable<A, V>c; public Memoizer1(Computable<A, V>c){ this.c=c; } //悲观加同步锁 public synchronized V compute(A arg)throws InterruptedException{ //从缓存获取 V result=cache.get(arg); if(result==null){ result=c.compute(arg); cache.put(arg, result); } return result; } }
-
Memoizer1使用HashMap来保存之前计算的结果。compute方法将首先检查需要的结果是否已经在缓存中,如果存在则返回之前计算的值。否则,将把计算结果缓存在HashMap中,然后再返回。
-
HashMap不是线程安全的,因此要确保两个线程不会同时访问HashMap,Memoizer1采用了一种保守的方法,即对整个compute方法进行同步。这种方法能确保线程安全性,但会带来一个明显的可伸缩性问题:每次只有一个线程能够执行compute。如果另一个线程正在计算结果,那么其他调用compute的线程可能被阻塞很长时间。如果有多个线程在排队等待还未计算出的结果,那么compute方法的计算时间可能比没有“记忆”操作的计算时间更长。在图5-2中给出了当多个线程使用这种方法中的“记忆”操作时发生的情况。不被推荐
-
-
优化:使用ConcurrentHashMap替代HashMap
public class Memoizer2<A, V>implements Computable<A, V>{ private final Map<A, V>cache=new ConcurrentHashMap<A, V>(); private final Computable<A, V>c; public Memoizer2(Computable<A, V>c){ this.c=c; } public V compute(A arg)throws InterruptedException{ //存在典型的“比较-更新”问题 V result=cache.get(arg); if(result==null){ result=c.compute(arg); cache.put(arg, result); } return result; } }
- Memoizer2比Memoizerl有着更好的并发行为:多线程可以并发地使用它;
- 但它在作为缓存时仍然存在一些不足——当两个线程同时调用compute时存在一个漏洞,可能会导致计算得到相同的值。在使用memoization的情况下,这只会带来低效,因为缓存的作用是避免相同的数据被计算多次。但对于更通用的缓存机制来说,这种情况将更为糟糕。对于只提供单次初始化的对象缓存来说,这个漏洞就会带来安全风险。
- Memoizer2的问题在于,如果某个线程启动了一个开销很大的计算,而其他线程并不知道这个计算正在进行,那么很可能会重复这个计算。
- 我们希望通过某种方法来表达“线程X正在计算f(27)”这种情况,这样当另一个线程查找f(27)时,它能够知道最高效的方法是等待线程X计算结束,然后再去查询缓存“f(27)的结果是多少?
-
进一步优化:基于FutureTask的Memoizing封装器
public class Memoizer3<A, V>implements Computable<A, V>{ //用FutureTask异步代替V private final Map<A, Future<V>>cache=new ConcurrentHashMap<A, Future<V>>(); private final Computable<A, V>c; public Memoizer3(Computable<A, V>c){ this.c=c; } public V compute(final A arg)throws InterruptedException{ //新线程进来,get会发现另一线程正在计算,导致阻塞等待计算完毕 //同时有新问题,如果两个线程进来,同时发现没有另一个线程在计算,两个线程并发执行,只不过比上一次并发的颗粒度更小而已 Future<V>f=cache.get(arg); //如果没找到,并发压入缓存 if(f==null){ Callable<V>eval=new Callable<V>(){ public V call()throws InterruptedException{ return c.compute(arg); } }; FutureTask<V>ft=new FutureTask<V>(eval); f=ft; cache.put(arg, ft); ft.run(); //在这里将调用c.compute } try{ return f.get(); }catch(ExecutionException e){ throw launderThrowable(e.getCause()); } } }
-
Memoizer3将用于缓存值的Map重新定义为ConcurrentHashMap<A, Future<V>>,替换原来的ConcurrentHashMap<A, V>。Memoizer3首先检查某个相应的计算是否已经开始(Memoizer2与之相反,它首先判断某个计算是否已经完成)。如果还没有启动,那么就创建一个FutureTask,并注册到Map中,然后启动计算:如果已经启动,那么等待现有计算的结果。结果可能很快会得到,也可能还在运算过程中,但这对于Future.get的调用者来说是透明的。
-
Memoizer3的实现几乎是完美的:它表现出了非常好的并发性(基本上是源于ConcurrentHashMap高效的并发性),若结果已经计算出来,那么将立即返回。如果其他线程正在计算该结果,那么新到的线程将一直等待这个结果被计算出来。
-
它只有一个缺陷,即仍然存在两个线程计算出相同值的漏洞。这个漏洞的发生概率要远小于Memoizer2中发生的概率,但由于compute方法中的if代码块仍然是非原子(nonatomic)的“先检查再执行”操作,因此两个线程仍有可能在同一时间内调用compute来计算相同的值,即二者都没有在缓存中找到期望的值,因此都开始计算。这个错误的执行时序如图5-4所示。
-
-
进一步优化:Memoizer3中存在这个问题的原因是,复合操作(“若没有则添加”)是在底层的Map对象上执行的,而这个对象无法通过加锁来确保原子性。程序清单5-19中的Memoizer使用了ConcurrentMap中的原子方法putIfAbsent,避免了Memoizer3的漏洞。
public class Memoizer<A, V>implements Computable<A, V>{ private final ConcurrentMap<A, Future<V>>cache=new ConcurrentHashMap<A, Future<V>>(); private final Computable<A, V>c; public Memoizer(Computable<A, V>c){ this.c=c; } public V compute(final A arg)throws InterruptedException{ while(true){ Future<V>f=cache.get(arg); if(f==null){ Callable<V>eval=new Callable<V>(){ public V call()throws InterruptedException{ return c.compute(arg); } }; FutureTask<V>ft=new FutureTask<V>(eval); //如果不存在就添加这一步将currentMap上锁 f=cache.putIfAbsent(arg, ft); //返回null表示放入成功,放入成功启动任务 if(f==null){ f=ft; ft.run(); } } try{ return f.get(); }catch(CancellationException e){ cache.remove(arg, f); }catch(ExecutionException e){ throw launderThrowable(e.getCause()); } } } }
-
当缓存的是Future而不是值时,将导致缓存污染(Cache Pollution)问题:如果某个计算被取消或者失败,那么在计算这个结果时将指明计算过程被取消或者失败。为了避免这种情况,如果Memoizer发现计算被取消,那么将把Future从缓存中移除。如果检测到RuntimeException,那么也会移除Future,这样将来的计算才可能成功。
-
Memoizer同样没有解决缓存逾期的问题,但它可以通过使用FutureTask的子类来解决,在子类中为每个结果指定一个逾期时间,并定期扫描缓存中逾期的元素。(同样,它也没有解决缓存清理的问题,即移除旧的计算结果以便为新的计算结果腾出空间,从而使缓存不会消耗过多的内存。)
第一部分小结
- 可变状态是至关重要的:所有的并发问题都可以归结为如何协调对并发状态的访问。可变状态越少,就越容易确保线程安全性。
- 尽量将域声明为final类型,除非需要它们是可变的。
- 不可变对象一定是线程安全的:不可变对象能极大地降低并发编程的复杂性。它们更为简单而且安全,可以任意共享而无须使用加锁或保护性复制等机制。
- 封装有助于管理复杂性:在编写线程安全的程序时,虽然可以将所有数据都保存在全局变量中,但为什么要这样做?将数据封装在对象中,更易于维持不变性条件:将同步机制封装在对象中,更易于遵循同步策略。
- 用锁来保护每个可变变量。
- 当保护同一个不变性条件中的所有变量时,要使用同一个锁。
- 在执行复合操作期间,要持有锁。
- 如果从多个线程中访问同一个可变变量时没有同步机制,那么程序会出现问题。
- 不要故作聪明地推断出不需要使用同步。
- 在设计过程中考虑线程安全,或者在文档中明确地指出它不是线程安全的。
- 将同步策略文档化。
第二部分 结构化并发应用程序
第6章 任务执行
大多数并发应用程序都是围绕“任务执行(Task Execution)”来构造的:任务通常是一些抽象的且离散的工作单元。通过把应用程序的工作分解到多个任务中,可以简化程序的组织结构,提供一种自然的事务边界来优化错误恢复过程,以及提供一种自然的并行工作结构来提升并发性。
6.1 在线程中执行任务
- 当围绕“任务执行”来设计应用程序结构时,第一步就是要找出清晰的任务边界。
- 在理想情况下,各个任务之间是相互独立的:任务并不依赖于其他任务的状态、结果或边界效应。独立性有助于实现并发,因为如果存在足够多的处理资源,那么这些独立的任务都可以并行执行。
- 为了在调度与负载均衡等过程中实现更高的灵活性,每项任务还应该表示应用程序的一小部分处理能力。
- 应用程序提供商希望程序支持尽可能多的用户,从而降低每个用户的服务成本,而用户则希望获得尽快的响应。而且,当负荷过载时,应用程序的性能应该是逐渐降低,而不是直接失败。要实现上述目标,应该选择清晰的任务边界以及明确的任务执行策略。
- 大多数服务器应用程序都提供了一种自然的任务边界选择方式:以独立的客户请求为边界。Web服务器、邮件服务器、文件服务器、EJB容器以及数据库服务器等,这些服务器都能通过网络接受远程客户的连接请求。将独立的请求作为任务边界,既可以实现任务的独立性,又可以实现合理的任务规模。
6.1.1 串行地执行任务
-
最简单的策略就是在单个线程中串行地执行各项任务。
-
串行的Web服务器例子:
class SingleThreadWebServer{ public static void main(String[]args)throws IOException{ ServerSocket socket=new ServerSocket(80); while(true){ Socket connection=socket.accept(); handleRequest(connection); } } }
-
SingleThreadWebServer很简单,且在理论上是正确的,但在实际生产环境中的执行性能却很糟糕,因为它每次只能处理一个请求.
-
主线程在接受连接与处理相关请求等操作之间不断地交替运行。当服务器正在处理请求时,新到来的连接必须等待直到请求处理完成,然后服务器将再次调用accept。
-
-
在单线程的服务器中,阻塞不仅会推迟当前请求的完成时间,而且还将彻底阻止等待中的请求被处理。如果请求阻塞的时间过长,用户将认为服务器是不可用的,因为服务器看似失去了响应。
-
同时,服务器的资源利用率非常低,因为当单线程在等待I/O操作完成时,CPU将处于空闲状态。
6.1.2 显示地为任务创建线程
-
通过为每个请求创建一个新的线程来提供服务,从而实现更高的响应性(不要这么做),如下所示:
class ThreadPerTaskWebServer{ public static void main(String[]args)throws IOException{ ServerSocket socket=new ServerSocket(80); while(true){ fnal Socket connection=socket.accept(); Runnable task=new Runnable(){ public void run(){ handleRequest(connection); } }; new Thread(task).start(); } } }
- ThreadPerTaskWebServer在结构上类似于前面的单线程版本——主线程仍然不断地交替执行“接受外部连接”与“分发请求”等操作。区别在于,对于每个连接,主循环都将创建一个新线程来处理请求,而不是在主循环中进行处理。
-
比较串行与并行的三个结论
- 任务处理过程从主线程中分离出来,使得主循环能够更快地重新等待下一个到来的连接。这使得程序在完成前面的请求之前可以接受新的请求,从而提高响应性。(接受与处理分离);
- 任务可以并行处理,从而能同时服务多个请求。如果有多个处理器,或者任务由于某种原因被阻塞,例如等待I/O完成、获取锁或者资源可用性等,程序的吞吐量将得到提高。
- 任务处理代码必须是线程安全的,因为当有多个任务时会并发地调用这段代码。
-
在正常负载情况下,“为每个任务分配一个线程”的方法能提升串行执行的性能。只要请求的到达速率不超出服务器的请求处理能力,那么这种方法可以同时带来更快的响应性和更高的吞吐率。
6.1.3 无限制创造线程的不足
- 线程生命周期的开销非常高:
- 线程的创建与销毁并不是没有代价的。根据平台的不同,实际的开销也有所不同,但线程的创建过程都会需要时间,延迟处理的请求,并且需要JVM和操作系统提供一些辅助操作。
- 资源消耗:
- 活跃的线程会消耗系统资源,尤其是内存。
- 如果可运行的线程数量多于可用处理器的数量,那么有些线程将闲置。
- 大量空闲的线程会占用许多内存,给垃圾回收器带来压力,而且大量线程在竞争CPU资源时还将产生其他的性能开销。
- 稳定性:
- 在可创建线程的数量上存在一个限制。这个限制值将随着平台的不同而不同,并且受多个因素制约,包括JVM的启动参数、Thread构造函数中请求的栈大小,以及底层操作系统对线程的限制等[插图]。如果破坏了这些限制,那么很可能抛出OutOfMemoryError异常,要想从这种错误中恢复过来是非常危险的,更简单的办法是通过构造程序来避免超出这些限制。
- 在一定的范围内,增加线程可以提高系统的吞吐率,但如果超出了这个范围,再创建更多的线程只会降低程序的执行速度,并且如果过多地创建一个线程,那么整个应用程序将崩溃。
- 与其他的并发危险一样,在原型设计和开发阶段,无限制地创建线程或许还能较好地运行,但在应用程序部署后并处于高负载下运行时,才会有问题不断地暴露出来。因此,某个恶意的用户或者过多的用户,都会使Web服务器的负载达到某个阈值,从而使服务器崩溃。如果服务器需要提供高可用性,并且在高负载情况下能平缓地降低性能,那么这将是一个严重的故障。
6.2 Executor框架
-
线程池简化了线程的管理工作,并且java.util.concurrent提供了一种灵活的线程池实现作为Executor框架的一部分。
-
在Java类库中,任务执行的主要抽象不是Thread,而是Executor,Executor接口如下:
public interface Executor{ void execute(Runnable command); }
-
Executor的实现还提供了对生命周期的支持,以及统计信息收集、应用程序管理机制和性能监视等机制。
-
Executor基于生产者-消费者模式,提交任务的操作相当于生产者(生成待完成的工作单元),执行任务的线程则相当于消费者(执行完这些工作单元)
6.2.1 示例:基于Executor的Web服务器
class TaskExecutionWebServer{
private static final int NTHREADS=100;
private static fnal Executor exec=Executors.newFixedThreadPool(NTHREADS);
public static void main(String[]args)throws IOException{
ServerSocket socket=new ServerSocket(80);
while(true){
final Socket connection=socket.accept();
Runnable task=new Runnable(){
public void run(){
handleRequest(connection);
}
};
exec.execute(task);
}
}
}
- 在TaskExecutionWebServer中,通过使用Executor,将请求处理任务的提交与任务的实际执行解耦开来,并且只需采用另一种不同的Executor实现,就可以改变服务器的行为;
- 改变Executor实现或配置所带来的影响要远远小于改变任务提交方式带来的影响.
- 通常,Executor的配置是一次性的,因此在部署阶段可以完成,而提交任务的代码却会不断地扩散到整个程序中,增加了修改的难度。
6.2.2 执行策略
- 通过将任务的提交与执行解耦开来,从而无须太大的困难就可以为某种类型的任务指定和修改执行策略。
- 在执行策略中定义了任务执行的“What、Where、When、How”等方面,包括:
- 在什么(What)线程中执行任务;
- 任务按照什么(What)顺序执行(FIFO、LIFO、优先级)?
- 有多少个(How Many)任务能并发执行?
- 在队列中有多少个(How Many)任务在等待执行?
- 如果系统由于过载而需要拒绝一个任务,那么应该选择哪一个(Which)任务?另外,如何(How)通知应用程序有任务被拒绝?
- 在执行一个任务之前或之后,应该进行哪些(What)动作?
- 各种执行策略都是一种资源管理工具,最佳策略取决于可用的计算资源以及对服务质量的需求。
- 通过限制并发任务的数量,可以确保应用程序不会由于资源耗尽而失败,或者由于在稀缺资源上发生竞争而严重影响性能。
- 通过将任务的提交与任务的执行策略分离开来,有助于在部署阶段选择与可用硬件资源最匹配的执行策略。
- 每当看到下面这种形式的代码时:new Thread(runnable).start()并且你希望获得一种更灵活的执行策略时,请考虑使用Executor来代替Thread。
6.2.3 线程池
- “在线程池中执行任务”比“为每个任务分配一个线程”优势更多:
- 通过重用现有的线程而不是创建新线程,可以在处理多个请求时分摊在线程创建和销毁过程中产生的巨大开销。
- 当请求到达时,工作线程通常已经存在,因此不会由于等待创建线程而延迟任务的执行,从而提高了响应性。
- 通过适当调整线程池的大小,可以创建足够多的线程以便使处理器保持忙碌状态,同时还可以防止过多线程相互竞争资源而使应用程序耗尽内存或失败。
- 类库提供了一个灵活的线程池以及一些有用的默认配置。可以通过调用Executors中的静态工厂方法之一来创建一个线程池:
- newFixedThreadPool:newFixedThreadPool将创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程池的最大数量,这时线程池的规模将不再变化(如果某个线程由于发生了未预期的Exception而结束,那么线程池会补充一个新的线程)。
- newCachedThreadPool:newCachedThreadPool将创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求时,那么将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模不存在任何限制。
- newSingleThreadExecutor。newSingleThreadExecutor是一个单线程的Executor,它创建单个工作者线程来执行任务,如果这个线程异常结束,会创建另一个线程来替代。newSingleThreadExecutor能确保依照任务在队列中的顺序来串行执行(例如FIFO、LIFO、优先级)。
- newScheduledThreadPool:newScheduledThreadPool创建了一个固定长度的线程池,而且以延迟或定时的方式来执行任务,类似于Timer。
- 建数千个线程来争夺有限的CPU和内存资源,因此服务器的性能将平缓地降低。通过使用Executor,可以实现各种调优、管理、监视、记录日志、错误报告和其他功能,如果不使用任务执行框架,那么要增加这些功能是非常困难的。
6.2.4 Executor的生命周期
-
JVM只有在所有(非守护)线程全部终止后才会退出。因此,如果无法正确地关闭Executor,那么JVM将无法结束。
-
由于Executor以异步方式来执行任务,因此在任何时刻,之前提交任务的状态不是立即可见的。有些任务可能已经完成,有些可能正在运行,而其他的任务可能在队列中等待执行。
-
当关闭应用程序时,可能采用最平缓的关闭形式(完成所有已经启动的任务,并且不再接受任何新的任务),也可能采用最粗暴的关闭形式(直接关掉机房的电源),以及其他各种可能的形式。
-
既然Executor是为应用程序提供服务的,因而它们也是可关闭的(无论采用平缓的方式还是粗暴的方式),并将在关闭操作中受影响的任务的状态反馈给应用程序。
-
为了解决执行服务的生命周期问题,Executor扩展了ExecutorService接口,添加了一些用于生命周期管理的方法(同时还有一些用于任务提交的便利方法):
//ExecutorService中的生命周期管理方法 public interface ExecutorService extends Executor{ void shutdown(); List<Runnable>shutdownNow(); boolean isShutdown(); boolean isTerminated(); boolean awaitTermination(long timeout, TimeUnit unit)throws InterruptedException; //……其他用于任务提交的便利方法 }
-
ExecutorService的生命周期有3种状态:运行、关闭和已终止。
-
ExecutorService在初始创建时处于运行状态。shutdown方法将执行平缓的关闭过程:不再接受新的任务,同时等待已经提交的任务执行完成——包括那些还未开始执行的任务。shutdownNow方法将执行粗暴的关闭过程:它将尝试取消所有运行中的任务,并且不再启动队列中尚未开始执行的任务。
-
在ExecutorService关闭后提交的任务将由“拒绝执行处理器(RejectedExecution Handler)”来处理(请参见8.3.3节),它会抛弃任务,或者使得execute方法抛出一个未检查的Rejected-ExecutionException。
-
等所有任务都完成后,ExecutorService将转入终止状态。可以调用awaitTermination来等待ExecutorService到达终止状态,或者通过调用isTerminated来轮询ExecutorService是否已经终止。通常在调用awaitTermination之后会立即调用shutdown,从而产生同步地关闭ExecutorService的效果。
6.2.5 延迟任务与周期任务
- Timer类负责管理延迟任务(“在100ms后执行该任务”)以及周期任务(“每l0ms执行一次该任务”)。
- 然而,Timer存在一些缺陷,因此应该考虑使用ScheduledThreadPoolExecutor来代替它:
- Timer在执行所有定时任务时只会创建一个线程。如果某个任务的执行时间过长,那么将破坏其他TimerTask的定时精确性;
- 如果TimerTask抛出了一个未检查的异常,那么Timer将表现出糟糕的行为。Timer线程并不捕获异常,因此当TimerTask抛出未检查的异常时将终止定时线程。这种情况下,Timer也不会恢复线程的执行,而是会错误地认为整个Timer都被取消了。因此,已经被调度但尚未执行的TimerTask将不会再执行,新的任务也不能被调度。(这个问题称之为“线程泄漏[Thread Leakage]”。
- 如果要构建自己的调度服务,那么可以使用DelayQueue,它实现了BlockingQueue,并为ScheduledThreadPoolExecutor提供调度功能。
- Timer支持基于绝对时间而不是相对时间的调度机制,因此任务的执行对系统时钟变化很敏感,而ScheduledThreadPoolExecutor只支持基于相对时间的调度。
6.3 找出可利用的并行性
6.3.1 示例:串行的页面渲染器
6.3.2 携带结果的任务Callable与Future
-
Executor框架使用Runnable作为其基本的任务表示形式。Runnable是一种有很大局限的抽象,虽然run能写入到日志文件或者将结果放入某个共享的数据结构,但它不能返回一个值或抛出一个受检查的异常。
-
许多任务实际上都是存在延迟的计算——执行数据库查询,从网络上获取资源,或者计算某个复杂的功能。对于这些任务,Callable是一种更好的抽象:它认为主入口点(即call)将返回一个值,并可能抛出一个异常。
-
Executor执行的任务有4个生命周期阶段:创建、提交、开始和完成。由于有些任务可能要执行很长的时间,因此通常希望能够取消这些任务。
-
在Executor框架中,已提交但尚未开始的任务可以取消,但对于那些已经开始执行的任务,只有当它们能响应中断时,才能取消。取消一个已经完成的任务不会有任何影响。
-
Future表示一个任务的生命周期,并提供了相应的方法来判断是否已经完成或取消,以及获取任务的结果和取消任务等。
-
get方法的行为取决于任务的状态(尚未开始、正在运行、已完成)。如果任务已经完成,那么get会立即返回或者抛出一个Exception,如果任务没有完成,那么get将阻塞并直到任务完成。如果任务抛出了异常,那么get将该异常封装为ExecutionException并重新抛出。如果任务被取消,那么get将抛出CancellationException。如果get抛出了ExecutionException,那么可以通过getCause来获得被封装的初始异常。
-
Callable与Future接口
public interface Callable<V>{ V call()throws Exception; } public interface Future<V>{ boolean cancel(boolean mayInterruptIfRunning); boolean isCancelled(); boolean isDone(); V get()throws InterruptedException, ExecutionException,CancellationException; V get(long timeout, TimeUnit unit)throws InterruptedException, ExecutionException,CancellationException, TimeoutException; }
-
可以通过许多种方法创建一个Future来描述任务。ExecutorService中的所有submit方法都将返回一个Future,从而将一个Runnable或Callable提交给Executor,并得到一个Future用来获得任务的执行结果或者取消任务。还可以显式地为某个指定的Runnable或Callable实例化一个FutureTask。(由于FutureTask实现了Runnable,因此可以将它提交给Executor来执行,或者直接调用它的run方法。)
-
要使用Callable来表示无返回值的任务,可使用Callable<Void>。
6.3.3 示例:使用Future实现页面渲染器
- 首先将渲染过程分解为两个任务,一个是渲染所有的文本,另一个是下载所有的图像。(因为其中一个任务是CPU密集型,而另一个任务是I/O密集型,因此这种方法即使在单CPU系统上也能提升性能。)
6.3.4 在异构任务并行化中存在的局限
- 在上个示例中,我们尝试并行地执行两个不同类型的任务——下载图像与渲染页面。然而,通过对异构任务进行并行化来获得重大的性能提升是很困难的。
- 当人数增加时,如何确保他们能帮忙而不是妨碍其他人工作,或者在重新分配工作时,并不是容易的事情。如果没有在相似的任务之间找出细粒度的并行性,那么这种方法带来的好处将减少。
- 如果渲染文本的速度远远高于下载图像的速度(可能性很大),那么程序的最终性能与串行执行时的性能差别不大,而代码却变得更复杂了。
- 因此,虽然做了许多工作来并发执行异构任务以提高并发度,但从中获得的并发性却是十分有限的。
- 只有当大量相互独立且同构的任务可以并发进行处理时,才能体现出将程序的工作负载分配到多个任务中带来的真正性能提升。
6.3.5 CompletionService:Executor与BlockingQueue
- 如果向Executor提交了一组计算任务,并且希望在计算完成后获得结果,那么可以保留与每个任务关联的Future,然后反复使用get方法.同时将参数timeout指定为0,从而通过轮询来判断任务是否完成。
- 这种方法虽然可行,但却有些繁琐。幸运的是,还有一种更好的方法:完成服务(CompletionService)。
- CompletionService将Executor和BlockingQueue的功能融合在一起。你可以将Callable任务提交给它来执行,然后使用类似于队列操作的take和poll等方法来获得已完成的结果,而这些结果会在完成时将被封装为Future。
- ExecutorCompletionService实现了CompletionService,并将计算部分委托给一个Executor。
- ExecutorCompletionService的实现非常简单。在构造函数中创建一个BlockingQueue来保存计算完成的结果。当计算完成时,调用Future-Task中的done方法。当提交某个任务时,该任务将首先包装为一个QueueingFuture,这是FutureTask的一个子类,然后再改写子类的done方法,并将结果放入BlockingQueue中。
6.3.6 示例:使用CompletionService实现页面渲染
-
可以通过CompletionService从两个方面来提高页面渲染器的性能:缩短总运行时间以及提高响应性。
-
为每一幅图像的下载都创建一个独立任务,并在线程池中执行它们,从而将串行的下载过程转换为并行的过程:这将减少下载所有图像的总时间。
-
此外,通过从CompletionService中获取结果以及使每张图片在下载完成后立刻显示出来,能使用户获得一个更加动态和更高响应性的用户界面
-
代码示例
public class Renderer{ private final ExecutorService executor; Renderer(ExecutorService executor){ this.executor=executor; } void renderPage(CharSequence source){ //获取文件信息 List<ImageInfo>info=scanForImageInfo(source); CompletionService<ImageData>completionService=new ExecutorCompletionService<ImageData>(executor); for(