Java核心技术-并发
多线程程序在较低的层次上扩展了多任务的概念:一个程序同时执行多个任务,通常,每一个任务称为一个线程
多进程与多线程的区别本质上在于每个进程拥有自己的一套变量,而线程则共享数据。
共享变量使线程之间的通信比进程之间的通信更有效、更容易
1 什么是线程
1.1 使用线程给其他任务提供机会
下面是在一个单独的线程中执行一个任务的简单过程:
实现Runnable接口:
1.将任务代码移到实现了Runnable接口的类的run方法中。这个接口非常简单,只有一个方法:
public interface Runnable { void run(); }
由于Runnable是一个函数式接口,可以用lambda表达式建立一个实例:
Runnable r = () - > { task code };
2.由Runnable创建一个Thread对象:
Thread t = new Thread(r);
3.启动线程:
t.start();
继承Thread类:
public MyThread extends Thread { public void run() { task code; } }
MyThread1 mt = new MyThread1();
mt.start();
第二种方法已经不再推荐,应该将并行运行的任务与运行机制解耦合。如果有很多任务,要为每个任务创建一个独立的线程所付出的代价太大了(已有线程复用)。
不要调用Thread类或Runnable对象的run方法。直接调用run方法,只会执行同一个线程中的任务,而不会启动新线程。应该调用Thread.start方法。这个方法将创建一个执行run方法的新线程。
2 中断线程
*当线程的run方法执行方法体中最后一条语句后,并经由执行return语句返回时
*出现了在方法中没有捕获的异常时
线程将终止。
没有可以强制线程终止的方法。然而,interrupt方法可以用来请求终止线程,当调用interrupt方法时,线程的中断状态将被置位。这是每一个线程都具有的boolean标志,每个线程都应该不时地检查这个标志,以判断线程是否被中断。
要想弄清中断状态是否被置位,可以使用以下方法:
Thread.currentThread().isInterrupted()
如果线程被阻塞(sleep或wait),就无法检测中断状态
当在一个被阻塞的线程上调用interrupt方法时,阻塞调用将会被InterruptedException异常中断。
有两个非常类似的方法:Interrupted和isInterrupted
Interruptde方法是一个静态方法,它检测当前的线程是否被中断并清除该线程的中断状态;
isInterrupted方法是一个实例方法,可用来检测是否有线程被中断且不会改变中断状态。
在很多发布的代码中会发现InterruptedException异常会被抑制在很低的层次上,不要这样做!可以使用如下方法:
*在catch子句中调用Thread.currentThread().interrupt()来设置中断状态
*用throws InterruptedException标记方法,于是调用者可以捕获这一异常
3 线程状态
线程可以有如下6中状态:
*New(新创建)
*Runnable(可运行)
*Blocked(被阻塞)
*Waiting(等待)
*Timed waiting(计时等待)
*Terminated(被终止)
要确定一个线程的当前状态,可调用getState方法
3.1 新创建线程
当用new操作符创建一个线程时,该线程还没开始运行,这意味着它的状态是new。
3.2 可运行线程
一旦调用start方法,线程处于runnable状态。一个可运行线程可能正在运行也可能没有运行
3.3 被阻塞线程和等待线程
当线程处于被阻塞或等待状态时,它暂时不活动:
*当一个线程试图获取一个内部的对象锁,而该锁被其他线程持有,则该线程进入阻塞状态
*当线程等待另一个线程通知调度器一个条件时,它自己进入等待状态(实际上被阻塞状态与等待状态是有很大不同的)
*有几个方法有一个超时参数,调用它们导致线程进入计时等待状态
3.4 被终止的线程
线程因如下两个原因之一被终止:
*因为run方法正常退出而自然死亡
*因为一个没有捕获的异常终止了run方法而意外的死亡
图4-3展示了线程可以具有的状态以及从一个状态到另一个状态可能的转换:
4 线程属性
4.1 线程优先级
在Java程序设计语言中,每一个线程有一个优先级。默认情况下,一个线程继承它的父线程的优先级。可以使用setPriority方法设置一个线程的优先级(1-10)。
不要将程序构建为功能的正确性依赖于优先级
4.2 守护线程
可以调用t.setDaemon(true);将线程转换为守护线程。守护线程的唯一用途是为其他线程提供服务(例如,计时线程)。只剩下守护线程时,虚拟机就退出了
守护线程应该永远不去访问固有资源(文件,数据库),因为它会在任何时候甚至在一个操作的中间发生中断。
4.3 未捕获异常处理器
线程的run方法不能抛出任何受查异常,但是,非受查异常会导致线程终止。在这种情况下,线程就死亡了。
但是,不需要任何catch子句来处理可以被传播的异常。在线程死亡之前,异常被传递到一个用于未捕获异常的处理器。
用于未捕获异常的处理器必须实现了Thread.UncaughtExceptionHandler接口,这个接口只有一个方法:
void uncaughtException(Thread t,Throwable e)
可以用setUncaughtExceptionHandler方法为任何线程安装一个处理器,也可以使用Thread类的静态方法setDefaultUncaughtExceptionHandler为所有的线程安装一个默认的处理器(可以使用日志API发送未捕获异常的报告到日志文件)
如果不为独立的线程安装处理器,此时的处理器就是该线程的ThreadGroup对象。
5 同步
如果两个线程存取相同的对象,并且每一个线程都调用了一个修改该对象状态方法,将会产生竞争条件。
5.1 竞争条件的一个例子(银行账户转账)
为了避免多线程引起的对共享数据的讹误,必须学习如何同步存取。
5.2 竞争条件详解
假定两个线程同时执行指令:
accounts[to] += amount;
问题在于这不是原子操作,该指令可能被处理如下:
1)将account[to]加载到寄存器。
2)增加amount。
3将结果写回accounts[to]。
现在,假定第一个线程执行了步骤1和2,然后它被剥夺了运行权。然后第二个线程被唤醒,并修改了accounts数组中的同一项。然后线程一被换下并完成第3步。
这样,这一动作擦去了第二个线程所做的更新。
真正的问题是transfer方法的执行过程中可能会被打断,如果能够保证线程在失去控制之前方法运行完成就不会出现讹误。
5.3 锁对象
有两种机制防止代码块受并发访问的干扰
Java语言提供一个synchronized关键字达到这一目的,并且Java SE 5.0引入了ReentrantLock类。synchronized关键字自动提供一个锁以及相关的“条件”。
用ReentrantLock保护代码块:
myLock.lock(); try { critical section } finally { myLock.unlock(); }
把解锁操作放在finally子句之内是至关重要的。如果在临界区的代码抛出异常,锁必须被释放。否则其他线程将永远阻塞。
如果使用锁,就不能使用带资源的try语句。
锁是可重入的,因为线程可以重复地获得已经持有的锁。锁保持一个持有计数器来跟踪对lock方法的嵌套调用,被一个所保护的代码可以调用另一个使用相同锁的方法。
要留心临界区中的代码,不要因为异常的抛出而跳出临界区(这样会使对象可能处于一种受损的状态)
5.4 条件对象
通常,线程进入临界区,却发现在某一条件满足之后它才能执行。条件对象用来管理那些已经获得了一个锁但却不能做有用工作的线程。
例如,假设一个没有足够资金的账户作为转出账户并获得了锁时,需要等待知道另一个线程向账户中注入资金,但是由于锁的排他性,因此别的线程没有进行存款操作的机会,因此我们需要条件对象。
一个锁对象可以有一个或多个相关的条件对象。可以使用newCondition方法获得一个条件对象:
private Condition sufficientFunds=bankLock.newCondition();
sufficientFunds.await();
这样,当前线程被阻塞且放弃了锁。
等待获得锁的线程和调用await方法的线程存在本质上的不同——调用await方法的线程处于阻塞状态直到另一个线程调用同一条件上的signalAll方法为止:
sufficientFunds.signalAll();
被激活后进入可运行状态,并等待锁。
通常,对await的调用应该在如下形式的循环体中:
while(!(ok to proceed)) condition.await();
应该何时调用signalAll呢?
从经验上讲,在对象的状态有利于等待线程的方向改变时调用。(例如,当一个账户余额发生改变时,等待的线程应该有机会检查余额)
注意调用signalAll不会立即激活一个等待线程。它仅仅解除等待线程的阻塞,这些线程退出同步方法后仍然要通过竞争实现对对象的访问。
当一个线程拥有某个条件的锁时,它仅仅可以在该条件上调用await、signalAll、signal方法。
5.5 synchronized关键字
在进一步深入之前,总结一下有关锁和条件的关键之处:
*锁用来保护代码片段,任何时刻只能有一个线程执行被保护的代码
*锁可以管理试图进入被保护代码块的线程
*锁可以拥有一个或多个相关的条件对象
*每个条件对象管理那些已经进入被保护代码块但不能运行的线程
Lock和Condition接口为程序设计人员提供了高度的锁定控制,然而大多数情况下,并不需要这样的控制。
从1.0版开始,Java中的每一个对象都有一个内部锁。
如果一个方法用synchronized关键字声明,那么对象的锁将保护整个方法:
public synchronized void method() { method body }
等价于:
public void method() { this.intrinsicLock.lock(); try { method body } finally{ this. intrinsicLock.unlock(); } }
内部对象锁只有一个相关条件。wait方法添加一个线程到等待集中,notifyAll/notify方法解除等待线程的阻塞状态。
理解synchronized的关键是了解每一个对象有一个内部锁,并且该锁有一个内部条件。
将静态方法声明为synchronized也是合法的(等价于对类加锁)。如果调用这种方法,该方法获得相关的类对象的内部锁
例如,如果Bank类有一个静态同步的方法,那么该方法被调用时,Bank.class对象的锁被锁住。因此,没有其他线程可以调用同一个类的这个或任何其他的同步静态方法
内部锁和条件存在一定局限:
*不能中断一个正在试图获得锁的线程
*试图获得锁时不能设定超时
*每个锁仅有单一的条件,可能是不够的
在代码中应该使用Lock和Condition还是同步方法?
*最好既不使用Lock和Condition也不使用synchronized关键字,可以考虑使用java.util.concurrent包中的一种机制,它会为你处理所有的加锁(例如阻塞队列)
*如果synchronized关键字适合你的程序,那么请尽量使用它,这样可以减少代码量和出错的概率
*如果特别需要Lock/Condition结构提供的独有特性时,才使用Lock/Condition
5.6 同步阻塞
除了可以通过调用同步方法获得Java对象锁外,还有一种机制可以获得锁,即通过进入一个同步阻塞:
synchronized(obj) { critical section }
于是它获得obj的锁。
5.7 监视器概念
多年来,研究人员努力寻找一种方法,可以不需要程序员考虑如何加锁的情况下,就可以保证多线程的安全。最成功的解决方案之一是监视器:
*监视器是只包含私有域的类
*每个监视器类的对象有一个相关的锁
*使用该锁对所有的方法进行加锁
*该锁可以有任意多个相关条件
5.8 volatile域
现代处理器和编译器,不使用同步时出错的可能性很大:
*多处理器的计算机能够暂时在寄存器或本地内存缓冲区中保存内存中的值。结果是,运行在不同处理器上的线程可能在同一个内存位置取到不同的值
*编译器可以改变指令执行的顺序以使吞吐量最大化
如果向一个变量写入值,而这个变量接下来可能会被另一个线程读取;或者,从一个变量读值,而这个变量可能是之前被另一个线程写入的——此时必须使用同步
volatile关键字为实例域的同步访问提供了一种免锁机制,但volatile变量不能提供原子性
5.9 final变量
除了使用锁或volatile修饰符,还有一种情况可以安全地访问一个共享域,即将这个域声明为final
5.10 原子性
假设对共享变量除了赋值(原子性操作)之外并不完成其他操作,那么可以将这些共享变量声明为volatile
java.util.concurrent.atmotic包中有很多类使用了很高效的机器级指令来保证其他操作的原子性。
例如,AtomicInteger类提供了方法increamentAndGet和decrementAndGet,它们分别以原子方式将一个整数自增或自减
有很多方法可以以原子方式设置和增减值,不过,如果希望完成更复杂的更新,就必须使用CAS(compareAndSet)方法:
public static AtomicLong largest=new AtomicLong(); largest.set(Math.max(largest.get(),observed));
上面这个更新操作的代码不是原子的,应该使用CAS保证它的原子性:
do{ oldValue=largest.get(); newValue=Math.max(oldValue,observed); }while(!largest.compareAndSet(oldValue,newValue))
如果另一个线程也在更新largest,就可能阻止这个线程更新,此时会发生自旋,直到它成功地用新值替换原来的值。不过compareAndSet方法会映射到一个处理器操作,比使用锁速度快。
Java SE 8中不需要再使用这样的循环样板代码,可以提供一个lambda表达式更新变量:
largest.updateAndGet(x->Math.max(x,observed));
或 largest.accumulateAndGet(observed,Math::max); 这个方法利用一个二元操作符来合并原子值和所提供的参数
如果有大量的线程要访问相同的原子值,性能会大幅下降,因为乐观更新需要太多次重试。Java SE 8提供了LongAdder和LongAccumulator类来解决这个问题
LongAdder包括多个变量(加数),其总和为当前值(即将原变量拆分成若干个变量,其总和等于变量值。),之后由多个线程分别更新加数,最后再将所有加数求和。
LongAccumulator将这种思维推广到任意的累加操作,在构造器中可以提供这个操作以及它的零元素。要加入新值,可以调用accumulate:
LongAccumulate adder=new LongAccumulator(Long::sum,0); adder.accumulate(value);
5.11 死锁
锁和条件不能解决多线程中的所有问题。
有可能会因为每一个线程都要等待更多的资源而导致所有线程都被阻塞,这样的状态称为死锁。
Java语言中没有任何东西可以避免或打破死锁,必须仔细设计程序,以确保不会出现死锁。
5.12 线程局部变量
由前面所讨论可知,在线程间共享变量有很大的风险,有时可能要避免共享变量,使用ThreadLocal辅助类为各个线程提供各自的实例
例如,可以为每个线程构造一个SimpleDateFormat局部变量:
public static fianl ThreadLocal<SimpleDateFormat> dateFormat=ThreadLocal.withInitial(()->new SimpleDateFormat("yyyy-MM-dd"));
调用时使用:
String dateStamp=dateFormat.get().format(new Date());
或者当多个线程需要等待一个共享的随机数生成器时会很低效(Random类是线程安全的),可以使用ThreadLocal辅助类为每个线程提供一个单独的生成器
5.13 锁测试与超时
线程在调用lock方法来获得另一个线程所持有的锁的时候,很可能发生阻塞。应该更加谨慎地申请锁。tryLock方法试图申请一个锁
使用tryLock方法申请一个锁,在成功获得锁后返回true,否则,立即返回false,而且线程可以立即离开去做其他事情而不会阻塞
if (myLock.tryLock()) { try{...} finally{myLock.unlock();} } else //do something else
调用tryLock时可以使用超时参数:
if(myLock.tryLock(100,TimeUnit.MILLISECONDS))...
如果一个线程在等待获得一个锁时被中断,中断线程可能在获得锁之前一直处于阻塞状态,如果出现死锁,将无法终止。
如果调用带有超时参数的tryLock,那么线程在等待期间被中断,将抛出一个InterruptedException异常,可以打破死锁
如果一个线程被另一个线程通过调用signalAll或signal激活,或者超时时限已达到,或者线程被中断,那么await方法将返回。
5.14 读/写锁
java.util.concurrent.locks包定义了两个锁类,分别是ReentrantLock类和ReentrantReadWriteLock类,如果很多线程从一个数据结构读取数据而很少线程修改数据时,ReentrantReadWriteLock类很有用
使用读/写锁的必要步骤:
1.构造一个ReentrantReadWriteLock对象:
privtate ReentrantReadWriteLock rw1=new ReentrantReadWriteLock();
2.抽取读锁和写锁
private Lock readLock=rw1.readLock();
private Lock writeLock=rw1.writeLock();
3.对所有的获取方法加读锁
public double getTotalBalance()
{
readLock.lock();
try{...}
finally{ readLock.unLock(); }
}
4.对所有的修改方法加写锁:
public void transfer()
{
writeLock.lock();
try{...}
finally{ writeLock.unLock(); }
}
5.15 为什么弃用stop和suspend方法
stop方法天生就不安全,suspend方法会经常导致死锁。
stop方法:
stop方法会终止所有未结束的方法,包括run方法并且释放锁。因此,可能会导致对象处于不一致的状态。
在希望停止线程的时候应该中断线程,被中断的线程会在安全的时候停止。
suspend方法;
如果使用suspend挂起一个持有锁的线程,那么,该锁在恢复之前是不可用的。如果调用suspend方法的线程试图获得同一个锁,那么程序死锁:被挂起的线程等着被恢复,而将其挂起的线程等待获得锁
6 阻塞队列
前面章节介绍了Java并发程序设计基础的底层构建块,然而,对于实际编程来说,应该尽可能远离底层结构。使用由并发处理的专业人士实现的较高层次的结构要更方便、更安全。
对于许多线程问题,可以通过使用一个或多个队列以优雅且安全的方式将其形式化。
例如,考虑到银行转账程序,转账线程将转账指令对象插入一个队列中,而不是直接访问银行对象。另一个线程从队列中取出指令执行转账,只有该线程可以访问该银行对象的内部,因此不需要同步。
阻塞队列的方法分为以下3类,这取决于当队列满或空时它们的响应方式:
如果将队列当作线程管理工具来使用,将用到put和take方法
当试图向满或空队列中添加或移除时,add、remove和element操作抛出异常
当试图向满或空队列中添加或移除时,offer、poll和peek方法给出一个错误提示而不会抛出异常。
poll和peek方法返回空来指示失败,因此,不能向这些队列中插入null值。
7 线程安全的集合
如果多线程要并发地修改一个数据结构,例如散列表,那么很容易会破坏这个数据结构
7.1 高效的映射、集和队列
java.util.concurrent包提供了映射、有序集和队列的高效实现:ConcurrentHashMap、ConcurrentSkipListMap、ConcurrentSkipListSet和ConcurrentLinkedQueue
集合返回弱一致性的迭代器,这意味着迭代器不一定能反映出它们被构造之后的所有修改。
并发散列映射表,可高效的支持大量的读者和一定数量的写者(默认最多16个)
7.2 映射条目的原子更新
如果多个线程修改一个普通的HashMap,它们会破坏内部结构。有些链表可能丢失或者造成循环(例如在set引发的扩容操作)。
对于ConcurrentHashMap绝对不会发生这种情况,get和put方法不会破坏数据结构。不过由于操作序列不是原子的,所以结果不可预知:
Long oldValue=map.get(word) Long newValue=oldValue==null?1:oldValue+1; map.put(word,newValue);
可能会有另一个线程在同时更新同一个计数。
传统做法是使用replace操作(CAS实现):
do { oldValue=map.get(word); newValue=oldValue==null?1:oldValue+1; }while(!map.replace(word,oldValue,newValue));
还可以使用一个ConcurrentHashMap<String,AtomicLong>或ConcurrentHashMap<String,LongAdder>:
map.putIfAbsent(word,new LongAdder()); map.get(word).increment();
Java SE 8提供了一些可以更方便地完成原子更新的方法。调用compute方法时可以提供一个键和一个计算新值的函数:
map.compute(word,(k,v)->v==null?1:v+1)
首次增加一个键时通常需要做些特殊的处理,利用merge方法可以非常方便的做到。这个方法有一个参数表示键不存在时使用初始值。否则就会调用你提供的函数来结合原值与初始值:
map.merge(word,1L,Long::sum);
ConcurrentHashMap中不允许有null值。因为有很多方法都是用null值来指示映射中某个给定的键不存在。
7.3 对并发散列映射的批操作
批操作会遍历映射,处理遍历过程中找到元素。无须冻结当前映射的快照。除非你恰好知道批操作运行时映射不会修改,否则就要把结果看作是映射状态的一个近似。
7.4 并发视图集
假设你想要的是一个大的线程安全集而不是映射。并没有一个ConcurrentHashSet类
可以使用静态newKeySet方法生成一个Set<K>,这实际上是ConcurrentHashMap<K,Boolean>的一个包装器
Set<String> words=ConcurrentHashMap.<String>newKeySet();
如果原来有一个映射,keySet方法可以生成这个映射的键集。这个集是可变的。如果删除这个集的元素,这个键会从映射中删除。
7.5 写数组的拷贝
CopyOnWriteArrayList和CopyOnWriteArraySet是线程安全的集合,其中所有的修改线程对底层数组进行赋值(旧的迭代器拥有可能过时的视图)
7.6 并行数组算法
在Java SE 8 中,Arrays类提供了大量并行化操作。例如,Arrays.parallelSrot方法可以对一个基本类型值或对象数组排序,排序时还可以提供一个Comparator:
Arrays.parallelSort(words,Comparator.comparing(String::length));
parallelSetAll方法会用由一个函数计算得到的值填充一个数组。这个函数接收元素索引,然后计算相应位置上的值:
Arrays.parallelSetAll(values,i->i%10);
parallelPrefix方法会用对应一个给定结合操作的前缀的累加结果替换各个数组元素
7.7 较早的线程安全集合
任何集合类都可以通过使用同步包装器(synchronized)变成线程安全的:
List<E> synchArrayList = Collections.synchronizedList(new ArrayList<E>());
Map<K,V> synchHashMap=Collections.synchronizedMap(new HashMap<K,V>());
如果另一个线程可能进行修改时要对集合进行迭代,仍然需要使用“客户端”锁定:
synchronized(synchHashMap) { Iterator<K> iter=synchHashMap.keySet().iterator(); while(iter.hasNext()) ...; }
最好使用java.util.concurrent包中定义的集合,不使用同步包装器中的。
8 Callable与Future
是一种异步机制,建立一个处理长时间计算并有返回值的线程。
Callable与Runnable类似,但是有返回值。Callable接口是一个参数化的类型,只有一个方法call:
public interface Callable<V> { V call() throws Exception; }
其中的类型参数是返回值的类型。
Future接口保存异步计算的结果且具有下面的方法:
public interface Future<V> { V get() throws ...; V get(long timeout,TimeUnit unit) throws...; void cancel(boolean mayInterrupt); boolean isCancelled(); boolean isDone(); }
FutureTask包装器是一种非常便利的机制,可以将Callable转换成Future和Runnable:
Callable<Integer> myComputation=...; FutureTask<Integer> task=new FutureTask<Integer>(myComputation); Thread t=new Thread(task); t.start(); ... Integer result=task.get();
9 执行器
构建一个新的线程是有一定代价的,因为涉及与操作系统的交互,如果程序中创建了大量的生命期很短的线程,应该使用线程池。
使用线程池的好处:
*减少创建线程时带来的系统资源消耗;
*控制并发线程的数目,如果创建大量线程会大大降低性能甚至造成虚拟机崩溃
执行器类(Executor)有许多静态工厂方法用来构建线程池:
9.1 线程池
newCachedThreadPool:对于每个任务,如果有空闲线程可用,立即让它执行任务,如果没有可用的空闲线程,则创建一个新线程
newFixedThreadPool:具有固定大小的线程池,如果提交的任务数多余空闲的线程数,那么把得不到服务的任务放置到队列中。当有空闲线程时再运行它们
newSingleThreadExecutor:一个退化了的大小为1的线程池,由一个线程执行提交的任务
这三个方法返回实现了ExecutorService接口的ThreadPoolExecutor类的对象
可用下面三种方法将Runnable对象或Callable对象提交给ExecutorService:
Future<?> submit(Runnable task)——任务完成时,get方法返回null Future<T> submit(Runnable task,T result)——任务完成时,get方法返回指定的result Future<T> submit(Callable<T> task)——任务完成时,get方法返回Future对象
当用完一个线程池的时候,调用shutdown。该方法启动该池的关闭序列。被关闭的执行器不再接受新的任务。当所有任务完成以后,线程池中的线程死亡。
shutdownNow:该池取消尚未开始的所有任务并试图中断正在运行的线程。
使用线程池总结:
1) 调用Executors类中的静态方法newCachedThreadPool或newFixedThreadPool
2)调用submit提交Runnable或Callable对象
3)如果想要取消一个任务,或如果提交Callable对象,就要保存好返回的Future对象
4)当不在提交任何任务时,调用shutdown
9.2 预定执行
ScheduledExecutorService接口具有为预定执行或重复执行任务而设计的方法
Executors类的newSchedduledThreadPool和newSingleThreadScheduledExecutor方法将返回实现了ScheduledExecutorService接口的对象,可以预定Runnable或Callable在初始的延迟之后运行一次。也可以预定一个Runnable对象周期性地运行
9.3 控制任务组
前面已经了解了如何将一个执行器服务作为线程池使用,以提高执行任务的效率。
有时,使用执行器有更有实际意义的原因,控制一组相关任务。
invokeAny方法提交所有对象到一个Callable对象的集合中,并返回某个已经完成了的任务结果。
invokeAll方法提交所有对象到一个Callable对象的集合中,并返回一个Future对象的列表,代表所有任务的解决方案:
List<Callable<T>> tasks=...; List<Future<T>> results=executor.invokeAll(tasks); for(Future<T> result:results) processFurther(result.get());
这个方法的缺点是如果第一个任务恰巧执行花去了很多时间,则可能不得不进行等待
将结果按可获得的顺序保存起来更有实际意义,可以用ExecutorCompletionService来进行排列:
ExecutorCompletionService<T> service=new ExecutorCompletionService<>(executor); for(Callable<T> task:tasks) service.submit(task); for(int i=0;i<task.size();i++) processFurther(service.take().get());
该方法首先获得一个执行器。然后构建一个ExecutorCompletionService,提交任务给完成服务(completion service)。该服务管理Future对象的阻塞队列,其中包含已经提交的任务的执行结果。
9.4 Fork-Join框架
Java SE 7中新引入了fork-join框架,用来支持可能对每个处理器内核分别使用一个线程来完成计算密集型任务
例如,想统计一个数组中有多少个元素满足某个特定的属性。可以将这个数组一分为二,分别对这两部分进行统计,再将结果相加
要采用框架可用的一种方式完成这种递归计算,需要提供一个扩展RecursiveTask<T>的类(如果计算会生成一个类型为T的结果)或者提供一个扩展RecursiveAction的类(如果不生成任何结果)。再覆盖compute方法来生成并调用子任务,然后合并结果:
class Counter extends RecursiveTask<Integer> { ... protected Integer compute() { if ( to - from < THRESHOLD) { solve problem directly } else { int mid = (from+to) /2; Counter frist = new Counter(values,from,mid,filter); Counter second = new Counter(values,mid,to,filter); invokeAll(first,second); return first.join()+second.join(); } } }
这里,invokeAll方法接收到很多任务并阻塞,直到所有这些任务都已经完成。join方法将生成结果。我们对每个子任务应用join,并返回其总和。
在后台,fork-join框架使用了一种有效的智能方法来平衡可用线程的工作负载,这种方法称为工作密取。
9.5 可完成Future
处理非阻塞调用的传统方法是使用事件处理器,程序员为任务完成后要出现的动作注册一个处理器。
Java SE 8 的CompletableFuture类提供了一种候选方法。与事件处理器不同,“可完成future”可以“组合”
例如,假设我们希望从一个Web页面抽取所有链接来建立一个网络爬虫,下面假设有这样一个方法:
public void CompletableFuture<String> readPage(Url url)
Web页面可用时这会生成这个页面的文本。如果方法:
public static List<URL> getLinks(String page)
生成一个HTML页面中的URL,可以调度当页面可用时再调用这个方法:
CompletableFuture<String> contents = readPage(url);
CompletableFuture<List<URL>> links = contents.thenApply(Parser::getLinks);
thenApply方法不会阻塞。它会返回另一个future。第一个future完成时,其结果会提供给getLinks方法,这个方法的返回值就是最终的结果。
利用可完成future,可以指定你希望做什么,以及希望以什么顺序执行这些工作。当然,这不会立即发生,不过重要的是所有代码都放在一处。
10 同步器
java.util.concurrent包包含了几个能帮助人们管理相互合作的线程集的类。这些机制具有为线程之间的共用集结点模式提供的“预置功能”
10.1 信号量
从概念上讲,信号量管理许多的许可证。为了通过信号量,线程通过调用acquire请求许可。其实没有实际的许可对象,信号量仅维护一个计数。许可的数目是固定的,从而限制了通过的线程数量
10.2 倒计时门栓
一个倒计时门栓(CountDownLatch)让一个线程集等待直到计数变为0。倒计时门栓是一次性的。一旦计数为0,就不能再重用了。
10.3 障栅
CyclicBarrier类实现了一个集结点称为障栅。
考虑大量线程运行在一次计算的不同部分的情形。当所有部分都准备好时,需要把结界组合在一起。当一个线程完成了它的那部分任务后,我们让它运行到障栅处。一旦所有的线程都达到了这个障栅,障栅就撤销,线程就可以继续运行。
障栅被称为是循环的,因为可以在所有等待线程被释放后被重用。在这一点上有别于CountDownLatch,CountDownLatch只能被使用一次。
10.4 交换器
当两个线程在同一个数据缓冲区的两个实例上工作的时候,就可以使用交换器(Exchanger)。典型的情况是,一个线程向缓冲区填入数据,另一个线程消耗这个数据。当它们都完成以后,相互交换缓冲区。
10.5 同步队列
同步队列是一种将生产者与消费者线程配对的机制。当一个线程调用SynchronousQueue的put方法时,它会阻塞直到另一个线程调用take方法为止,反之亦然。与交换器不同,数据仅仅沿一个方向传递,从生产者到消费者