文章目录
同步容器类
同步容器类包括Vector和hashtable,还包括由Collections.synchronizedXxx等工厂方法创建的封装器类。这些类实现线程安全的方式是:将他们的状态封装起来,并对每个公有方法进行同步,使得每次只有一个线程能访问容器的状态。
同步容器类的问题
同步容器类都是线程安全的,但在某些情况下可能需要额外的客户端加锁来保护复合操作。包括:迭代、跳转、条件运算(例如:若没有则添加)。
在同步容器类中,这些复合操作在没有客户端加锁的情况下仍然是线程安全的,但当其他线程并发的修改容器时,他们可能会表现出意料之外的行为。
例如:
public static Object getLast(Vector list) {
int lastIndex = list.size() - 1;
return list.get(lastIndex);
}
public static void deleteLast(Vector list) {
int lastIndex = list.size() - 1;
list.remove(lastIndex);
}
在多线程调用时就会出错。
我们可以采用给客户端加锁的方式
public static Object getLast(Vector list) {
synchronized(list) {
int lastIndex = list.size() - 1;
return list.get(lastIndex);
}
}
public static void deleteLast(Vector list) {
synchronized(list) {
int lastIndex = list.size() - 1;
list.remove(lastIndex);
}
}
迭代器和ConcurrentModificationException
在设计同步容器类的迭代器时没有考虑到并发修改的问题,并且他们表现出的行为是“fail-fast”的,这意味着当在迭代过程中容器被修改,就会抛出ConcurrentModificationException.
这种及时失败的迭代器并不是一种完备的处理机制,只能作为并发问题的错误指示器,它采用的实现方式是,将计数器的变化和容器关联起来:如果在迭代期间计数器被修改,hasNext或next将抛出ConcurrentModificationException.这是一种设计上的权衡,从而降低并发修改操作的检测代码对程序性能的影响。
List<Widget> list = Collections.synchronizedList(new ArrayList<Widget>());
//可能抛出ConcurrentModificationException
for(Widget w : list) {
doSomething(x);
}
上面这段代码无法确保list在迭代时不被修改,如果需要确保list无法修改,一种做法是这样的:
List<Widget> list = Collections.synchronizedList(new ArrayList<Widget>());
synchronized(list) {
for(Widget w : list) {
doSomething(x);
}
}
这同时会带来一些问题,如果迭代没有完成,其他线程无法访问容器。如果容器规模很大或者doSomething很久那么锁的竞争会非常激烈,如果许多线程都在迭代锁的释放,那么将极大的降低吞吐量和CPU的利用率。
如果不希望在迭代期间对容器加锁,一种替代方法是“克隆”容器,并在副本上进行迭代。由于副本被封闭在线程内,因此其他线程不会再迭代期间对其进行修改,这样就避免了ConcurrentModificationException(在克隆过程中仍然需要对容器进行加锁)
隐藏迭代器
虽然加锁可以防止迭代器抛出ConcurrentModificationException,但要记住在所有对共享容器进行迭代的地方都需要加锁。
public class HiddenIterator {
private final Set<Integer> set = new HashSet<>();
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);
}
System.out.println("DEBUG: added ten elements to " + set);
}
}
在这段代码中没有显式的调用set的迭代方法,但是在println里隐式的调用了set的toString(),这个方法隐式的对set的元素进行迭代。
这里如果要避免抛出ConcurrentModificationException,那么使用Collections.synchornizedSet()
是一个好方法。
并发容器
同步容器将所有对容器状态的访问都串行化以实现他们的线程安全性,这种方法的代价是严重降低了并发性,当多个线程竞争容器的锁时,吞吐量将严重减低。
通过并发容器来代替同步容器,极大的提高伸缩性并降低风险
ConcurrentHashMap
同步容器类在执行每个操作期间都持有一个锁。
ConcurrentHashMap和其他并发容器一起增强了同步容器类:它们提供的迭代器不会抛出ConcurrentModificationException,因此不需要在迭代过程对容器进行加锁。
ConcurrentHashMap返回的迭代器具有弱一致性(weakly consistent),而并非“fast-fail".弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有的元素,并可以在迭代器被构造后将修改操作反映给容器。
只有当应用程序需要加锁Map以进行独占访问时,才应该放弃使用ConcurrentHashMap.
额外的原子Map操作
由于ConcurrentHashMap不能被加锁来执行独占访问,因此我们无法使用客户端加锁来创建新的原子操作。一些常用的符合操作都已经实现为原子操作并且在ConcurrentMap接口中声明,如“putIfAbsent","remove if equal"等。如果你需要在现有的同步Map中添加这样的功能,那么可以考虑使用ConcurrentMap.
接口ConcurrentMap包括以下方法:
getOrDefault
forEach
putIfAbsent
remove
replace
replace
replaceAll
computeIfAbsent
computeIfPresent
compute
merge
CopyOnWriteArrayList
CopyOnWriteArrayList用于替换同步list,在某些情况下它提供了更好的并发性能,并且在迭代期间不需要对容器进行加锁或复制。类似的也有CopyOnWriteArraySet。
“CopyOnWrite” 写入时复制容器的线程安全性在于,只要正确的发布一个事实不可变的对象,那么在访问该对象时就不再需要进一步的同步。在每次修改时,都会创建并重新发布一个新的容器副本,从而实现可变性。“写入时复制”容器的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的起始位置,由于它不会被修改,因此在对其进行同步时只需要确保数组内容的可见性。因此多个线程可以同时对这个容器进行迭代,而不会彼此干扰或者与修改容器的线程相互干扰。
"CopyOnWrite"容器返回的迭代器不会抛出ConcurrentModificationException,并且返回的元素和迭代器创建时的元素完全一致,而不必考虑之后修改操作所带来的影响。
显然,每当修改容器时会复制底层数组,这需要一定的开销,特别是容器规模较大时。仅当迭代操作远远多于修改操作时,从应该使用该类容器。
阻塞队列和生产者-消费者模式
阻塞队列提供了可阻塞的put和take方法,以及支持定时的offer和poll方法。如果队列满了,put将阻塞到有空间可用;如果队列为空,那么take将会阻塞到有元素可用。
在构建高可靠的应用程序时,有界队列是一种强大的资源管理工具:它们能抑制并防止产生过多的工作项,使应用程序在负荷过载的情况下变得更加健壮。
实例:桌面搜索
// 生产者,搜索文件并将他们放入工作队列。
public class FileClawer 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) {
File[] entries = root.listFiles(fileFilter);
if(entries != null) {
for(File entry : entries) {
if(entry.isDirectory()) {
crawl(entry);
} else if(!alreadyIndexed(entry)){
fileQueue.put(entry);
}
}
}
}
}
//消费者,将队列里的file取出并建立索引
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();
}
}
}
//启动类
public static void startIndexing(File[] roots) {
BlockingQueue<File> queue = new LinkedBlockingQueue<File>(BOUND);
FileFilter filter = new FileFilter() {
public boolean accept(File file) {
return true;
}
}
for(File root : roots) {
new Thread(new FileCrawler(queue,filter,root)).start();
}
for(int i = 0;i < N_CONSUMERS;i++) {
new Thread(new Indexer(queue)).start();
}
}
串行线程封闭
对于可变对象,生产者-消费者这种设计和阻塞队列一起,促进了串行线程封闭,从而将对象所有权从生产者交付给消费者。线程封闭对象只能由单个线程拥有,但可以通过安全的发布该对象来“转移”所有权。在转移所有权后,也只有另一个线程能获得这个对象的访问权限,并且发布对象的线程不会再访问它。这种安全的发布确保了对象状态对于新的所有者来说是可见的,并且由于最初的所有者不会再访问它,因此对象会被封闭在新的线程中,新的所有者线程可以对该对象做任意修改,因为它具有独占的访问权。
对象池利用了串行线程封闭,将对象“借给”一个请求线程。主要对象池包含足够的内部同步来安全的发布池中的对象,并且只要客户代码本身不会发布池中的对象,或者在将对象返回给对象后就不再使用它,那么就可以安全的在线程之间传递所有权。
我们也可以使用其他发布机制来传递可变对象的所有权,但必须确保只有一个线程能接受被转移的对象。
双端队列和工作密取
正如阻塞队列适用于生产者-消费者设计模式,双端队列适用于 工作密取。
每个消费者都有各自的双端队列,如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其他消费者队列末尾秘密获取工作。
工作密取非常适用于既是消费者又是生产者问题——当执行某个工作时可能导致出现更多的工作。例如,在网页爬虫程序中处理一个页面时,通常会发现有更多页面需要处理。
阻塞方法和中断方法
线程可能会阻塞或者暂停执行,原因有多种:等待IO结束,等待获得一个锁,等待从Thread.sleep()中醒来,等待另一个线程的计算结果。阻塞操作和执行时间很长的普通操作的差别在于:被阻塞的线程必须等待某个不受它控制的事件发生后才能继续执行。
Thread提供了interrupt方法,用于中断线程或者查询线程是否已经被中断。
中断是一种协作机制。一个线程不能强制其他线程停止正在执行的操作而去执行其他的操作。
当在代码中调用了一个将抛出InterruptedException异常的方法时,你自己的方法也就成了一个阻塞方法,并且必须要处理对中断的响应。通常由两种做法:
- 传递InterruptedException。避开这个异常通常是最明智的策略——只需要把这个异常传递给方法的调用者。包括:根本不捕获该异常,或者捕获该异常然后执行某种简单的清理工作后再次抛出这个异常。
- 恢复中断。有时候不能抛出InterruptedException,例如当代码是Runnable的一部分时,在这些情况下,必须捕获该异常,并通过读取线程的interrupt方法恢复中断状态。
public class TaskRunnable implements Runnable {
BlockingQueue<Task> queue;
public void run() {
try{
processTask(queue.take());
} catch(InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
同步工具类
同步工具类可以是任何一个对象,只要它根据自身的状态来协调线程的控制流。阻塞队列可以作为工具类,其他类型的同步工具类还包括信号量(Semaphore)、栅栏(Barrier)、闭锁(Latch)。
闭锁
闭锁是一种同步工具类,可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门:在闭锁到达结束状态之前,这扇门一直是关闭的,并且没有任何线程能通过,当到达结束状态时,这扇门将永远保持打开状态。
闭锁可以确保某些活动直到其他活动都完成后才继续执行,例如:
- 确保某个计算在其需要的所有资源都被初始化之后才继续执行。二元闭锁(包括两个状态)可以用来表示“资源R已经被初始化”,而所有需要R的操作都必须先在这个闭锁上等待。
- 确保某个服务在其依赖的所有服务都已启动后再启动。
- 等待直到某个操作的所有参与者都就绪再继续执行。
CountDownLatch是一种闭锁实现,它可以使一个或多个线程等待一组事件发生。闭锁状态包括一个计数器,该计数器初始化为一个正数,表示需要等待的事件数量。countDown方法递减计数器,表示一个事件已经发生了,而await方法等待计数器达到0,这表示所有需要等待的事件都已经发生。如果计数器的值非0,那么await会一直阻塞直到计数器为0或者等待中的线程中断,或者等待超时。
public class TestHarness {
public long timeTasks(int nThreads,final Runnable task) throws InterruptedException{
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 {
startGate.await();
try {
task.run();
} finally {
endGate.countDown();
}
} catch (InterruptedException ignored) {
}
}
}
t.start();
}
long start = System.nanoTime();
startGate.countDown();
endGate.await();
long end = System.nanoTime();
return end - start;
}
}
启动门使主线程能够同时释放所有工作线程,而结束门则使主线程能够等待最后一个线程执行完成,而不是顺序的等待每个线程执行完成。
FutureTask
FutureTask也可以用作闭锁。FutureTask表示计算是通过Callable来实现的,相当于一种可生成结果的Runnable,并且可以处于以下3中状态:等待运行,正在运行,运行完成。执行完成表示计算的所有可能结束方式包括正常结束,由于取消而结束,由于异常而结束等。当FutureTask进入完成状态后,它会永远停在这个状态上。
Future.get的行为取决于任务的状态。如果任务已经完成get立即返回结果,否则get将阻塞直到任务进入完成状态,然后返回结果或者抛出异常。FutureTask将计算结果从执行计算的线程传递到获取这个结果的线程,而FutureTask的规范确保了这种传递能实现结果的安全发布。
FutureTask在Executor框架中表示异步任务,此外还可以用来表示一些时间较长的计算,这些计算可以在使用计算结果之前启动。
public class Preloader {
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);
}
}
}
public static RuntimeException launderThrowable(Throwable t) {
if(t instanceof RuntimeException) {
return (RuntimeException) t;
} else if (t instanceof Error) {
throw (Error) t;
} else {
throw new IllegalStateException("Not unchecked " , t);
}
}
}
Preloader创建了一个FutureTask,其中包含从数据库加载产品信息的任务,以及一个执行运算的线程。由于在构造函数或静态初始化中启动线程并不是一种好方法,因此提供了一个start方法来启动线程。当程序随后需要ProductInfo时,可以调用get方法,如果数据已经加载,那么将返回这些数据,否则将等待加载完成再返回。
Callable表示的任务可以抛出受查异常或为受查异常,并且任何代码都可能抛出一个Error。无论任务代码抛出什么异常都会被封装到一个ExecutionExecution中,并在Future.get中被重新抛出。
在Preloader中,当get方法抛出ExecutionException时,可能是以下三种情况之一:Callable抛出的受查异常,RuntimeExecution,以及Error。
信号量
计数信号量用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。还可以用来实现某种资源池,或者对容器施加边界。
Semaphore中管理着一组虚拟的许可(permit),许可的初始数量有构造函数来指定,在执行操作时先获取许可,在结束使用时释放许可。如果没有许可,acquire将阻塞到有许可(或者知道被中断或者操作超时)。release方法将返回一个许可。构造一个超时值为1的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;
}
}
栅栏
栅栏和闭锁类似,但是有所区别,所有线程必须同时到达栅栏位置,才能继续执行。闭锁用来等待事件,而栅栏用来等待其他线程。栅栏可以用来实现一些协议。
CyclicBarrier可以使一定数量的参与方反复的在栅栏位置汇集,他在并行迭代算法中非常有用:这种算法通常将一个问题拆分成一系列相互独立的子问题。当线程到达栅栏位置时调用await反复,这个方法将阻塞知道所有线程都到达栅栏位置。如果所有线程都到达了栅栏位置,你们栅栏将打开,此时所有线程都被释放,而栅栏将被重置以便下次使用。如果对await调用超时,或者await阻塞的线程被中断,你们栅栏就被认为是打破了,所有阻塞的await调用都将终止并抛出BrokenBarrierException.如果成功通过栅栏,你们await将为每个线程返回一个唯一的到达索引号,我们可以利用这些索引来“选举”产生一个领导线程,并在下一次迭代中由该领导线程执行一些特殊工作。CyclicBarrier还可以使你将一个栅栏操作传递给构造函数,这是一个Runnable,当成功通过栅栏时会执行它,但在阻塞线程被释放之前是不能执行的。
生命游戏:
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(worders[i]).start();
}
mainBoard.waitForConvergence();
}
}
另一种形式的栅栏是Exchanger,它是一种两方栅栏,各方在栅栏位置上交换数据。当两方执行不对称的操作时,Exchanger会非常有用,例如当一个线程向缓冲区写入数据,而另外一个线程从缓冲区读取数据。这些线程可以使用Exchanger来汇合,并将满的缓冲区和空的缓冲区交换。当两个线程通过Exchanger交换对象时,这种交换就把这两个对象安全的发布给另一方。
数据交换的时机取决于应用程序的响应需求。最简单的方案是,当缓冲区被填满时,由填充任务进行交换,当缓冲区为空时,有清空任务进行交换。
构建高效且可伸缩的结果缓存
public interface Computable<A,V> {
V compute(A arg) throws InterruptedException;
}
public class ExpensiveFunction implements Computable<String,BigInteger> {
public BigInteger compute(String arg) {
return new BigInteger(arg);
}
}
public class Memoizerl<A,V> implements Computable<A,V> {
private final Map<A,V> cache = new HashMap<>();
private final Computable<A,V> c;
public Memorizerl(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;
}
}
在该程序中Computable<A,V>接口声明了一个函数Computable,其输入类型为A,输出类型为V。在ExpensiveFunction中实现的Computable,需要很长的时间来计算结果,我们将创建一个Computable包装器,帮助记住之前的计算结果,并将缓存过程封装起来(这项技术被称为“记忆”)
在该程序中,hashmap不是线程安全的,因此使用synchronized对整个compute方法进行同步确保线程安全性。但这会带来一个明显的可伸缩性问题:每次只有一个线程能执行compute。
我们可以很容易的联想到使用ConcurrentHashMap
public class Memoizerl<A,V> implements Computable<A,V> {
private final Map<A,V> cache = new ConcurrentHashMap<>();
private final Computable<A,V> c;
public Memorizerl(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;
}
}
多线程可以并发的使用它,但它作为缓存时仍然存在一些不足——当两个线程同时调用compute时存在一个漏洞可能会导致计算得到相同的值。作为缓存的作用是避免相同的数据被计算多次。我们继续改进
public class Memoizerl<A,V> implements Computable<A,V> {
private final Map<A,Future<V>> cache = new ConcurrentHashMap<>();
private final Computable<A,V> c;
public Memorizerl(Computable<A,V> c) {
this.c = c;
}
public synchronized V compute(A arg) throws InterruptedException {
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();
}
try {
return f.get();
} catch(ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
}
虽然这个实现已经很好了:若结果已经计算出来,那么立即返回,如果结果还在计算,那么新到的线程将一直等待这个结果被计算出来。但是仍然存在两个线程计算出相同值的漏洞。
问题存在的原因是,复合操作是在底层的Map对象上进行的,而这个对象无法通过加锁来确保原子性。
public class Memoizerl<A,V> implements Computable<A,V> {
private final ConcurrentHashMap<A,Future<V>> cache = new ConcurrentHashMap<>();
private final Computable<A,V> c;
public Memorizerl(Computable<A,V> c) {
this.c = c;
}
public synchronized V compute(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);
f = ft;
cache.putIfAbsent(arg,ft);
ft.run();
}
try {
return f.get();
} catch(CancellationException e) {
cache.remove(arg,f);
} catch(ExecutionException e) {
throw launderThrowable(e.getCause());
}
}
}
}
当缓存的是Future而不是值时,将会导致缓存污染问题(Cache Pollution):如果某个计算被取消或者失败,那么在计算这个结果时将指明计算过程被取消或者失败。为了避免这种情况我们调用了cache.remove
在上面的程序中,我们同样没有解决缓存逾期的问题,但它可以通过FutureTask的子类来解决,在子类中为每个结果指定一个逾期时间,并定期扫描缓存中逾期的元素。