《Java并发编程实践》二(5):线程安全组件

这是第二部分最后一章,介绍java提供的线程安全组件;需要注意的是,由于本书比较老,只涵盖Java 1.6的并发组件,内容并不过时,但完整性有所欠缺。

第4章介绍了编写线程安全类的几种途径,其中“委托线程安全”策略基于现有的线程安全类型来构建自定义的线程安全类,是最可靠的、最常用的策略。因此本章全面地介绍一下JDK为我们提供的线程安全的组件。

同步集合(Synchronized Collections)

同步集合是基于“java监视器模式”实现的线程安全集合类型,它主要包括Vector,Hashtable,以及Collections.synchronizedXxx方法创建的集合。

同步集合的组合操作

同步集合的每个public方法都是线程安全的,但是包含多个方法的组合操作则不一定,比如经典的"put-if-absent"操作。

//map is Hashtable
table = Collections.synchronizedMap(map)
if (!table.containsKey(key)) {
	table.put(key,value);
}

上面的代码是存在竞争条件的,当前线程检查发现table里没有key,准备执行table.put时,可能有另外一个线程已经插入了同样key。之所以说该代码不一定线程安全,是因为线程安全性关乎对状态正确性的定义,上述代码的行为是否正确取决于业务场景。

再看另个一个case:

public static Object getLast(Vector list) {
	int lastIndex = list.size() - 1;
	return list.get(lastIndex);
}

getLast从Vector获取最后一个元素,这个操作在多线程下可能会抛出异常,我想这几乎不会是你期望的行为,因此该代码可判定为非线程安全。

编写线程安全的组合操作

由于同步集合明确的宣称了自己的线程安全策略:java监视器模式,因此我们是可以基于这一点来实现线程安全的组合操作的:

public static Object getLast(Vector list) {
	synchronized(list) {
		int lastIndex = list.size() - 1;
		return list.get(lastIndex);
	}
}

迭代器和ConcurrentModificationException

对集合进行遍历是最常见的组合操作,同步集合(还有其他非线程安全集合类型)的迭代器不是线程安全的。迭代器的使用是如此经典,以至于java专门设计了一种快速失败(fail-fast)策略来强化它的非线程安全性。

该策略就是,在迭代过程中,如果发现集合被篡改,就会抛出ConcurrentModificationException异常。不过该机制的实现并不十分可靠,”尽力而为“而已,因此并发的迭代有可能并不会被异常阻止。

仍然需要通过java监视器锁来保证迭代操作的线程安全性:

synchronized(list) {
	for (Object element : list) {
	 	operation(element);
	}
}

如果元素的处理操作很耗时,那么集合会被锁定很长时间,此时可以先制作集合的一份copy(copy过程仍需要加锁),再对copy进行遍历即可。

隐式的迭代

java某些常见的写法可能隐含了对集合的迭代,容易被开发者忽视:

System.out.println("DEBUG: added ten elements to " + set)

上面的代码会遍历set,还有Collection的containsAll,removeAll和retainAll操作都会对输入集合进行遍历。

并发集合类型

同步集合类型通过将所有操作变成互斥的来获得线程安全性,大家是降低程序的并发性能。java 1.5引入并发集合类型,允许多线程同时修改集合对象,极大地提升了程序的并发性能。因此,开发者应该尽量使用并发集合类型。

ConcurrentHashMap

ConcurrentHashMap是HashMap的并发版本,支持多个线程同时读写,迭代,不会有ConcurrentModificationException。

ConcurrentHashMap弱点(如果认为是弱点的话)是并不支持将map完全锁住,换句话说,没办法阻止其他线程修改这个map。因为ConcurrentHashMap实现的就是一个不断变化map类型,从这个角度说,它并不是HashMap、HashTable的一个升级版本。

因而,像size(),isEmpty()这样的方法,只是大致反映map一个瞬时状态,并不十分准确。

附加的线程安全操作

ConcurrentHashMap直接支持了put-if-absent, replace, 条件删除等常用的组合操作。由于我们没法锁住ConcurrentHashMap对象,所以想要添加额外的原子操作是不可能的。

CopyOnWriteArrayList

CopyOnWriteArrayList是一个支持并发访问的ArrayList,顾名思义,它是通过”写时拷贝“来获得线程安全性的。可以认为CopyOnWriteArrayList内部有一个数组,一旦有写入(或修改)发生,内部制作并发布数组一份copy;如果有并发的迭代操作,那么迭代仍然发生在老的数组上。

CopyOnWriteArrayList特别适合用作很少修改,且元素不多的集合,比如一个事件源的监听器列表,一般在完成系统初始化之后就不怎么修改。

BlockingQueue

BlockingQueue提供了可阻塞的put和take方法,以及他们的限时版本offer和poll。如果队列满了,那么put方法将会阻塞一直到有空间被释放;take方法如果发现队列空,那么被阻塞一直到有元素入队列。

BlockingQueue天然支持”生产者-消费者“模型,生产者不断地将数据put到一个BlockingQueue,消费者从BlockingQueue获取下一个数据进行消费;生产者和消费之间完全解耦和,生产这和消费者的数量也没有任何限制,且可动态变化。

打个比方,清洗盘子的工作分为两道工序,第一道是洗涤,第二道是擦干;负责洗涤的人将洗好的盘子放入一个框子;负责擦干的人从框子里取出盘子进行擦干。这是一个个典型的产生者-消费者场景,第一道工序的执行者是产生者,第二道工序的执行者是消费者,暂存盘子的框对应着BlockingQueue。

正如装盘子框子大小有限,BlockingQueue也可以是有界的,如果洗盘子的人发现框子满了,就必须暂停工作,此时要想加快工作进度,必须增加擦盘子的人数。反过来一样,如果擦盘子的人总是发现框子是空的,那么需要增加洗盘子的人数。

我们要尽量使用有界的BlockingQueue,因为我们不能假定程序实际运行环境下,总是有足够多消费者或者消费者的处理速度足够快;如果队列无界,就存在内存耗光的风险。

BlockingQueue实现

BlockingQueue有以下几个实现:

  • LinkedBlockingQueue,类似LinkedList的并发队列实现;
  • ArrayBlockingQueue, 类似ArrayList的并发队列实现;
  • PriorityBlockingQueue,可阻塞的优先级队列,不能设置元素数量限制
  • SynchronousQueue,一个没有存储空间特殊阻塞队列;

SynchronousQueue比较特殊一点,由于它没有存储空间,所以put方法会一直阻塞,直到其他线程调用take方法;以上面洗盘子场景作比喻的话,此时没有框子可用,负责第一道工序的人只能手递手把盘子交给第二道工序的人。它的效果是,任务在生产者和消费者之间直接传递,没有额外的延迟,如果没有足够多的消费者,生产者会被阻塞。

串行化的线程封闭

BlockingQueue的实现可以保证一个对象从产生这被安全地发布到消费者,这个保证使得我们可以对可变对象指定一种”串行化线程封闭“的线程安全策略。

一个对象虽然在它的声明周期内可能被多个线程访问,但是它的声明周期呈现明显的阶段性,每个阶段只有一个线程访问它;就好像它的所有权在多个线程之间传递一样。所以,只要保证这种传递是安全的,而BlockingQueue恰好保证了这一点。

在基于BlockingQueue的生产者-消费者模型中,如果生产这在将数据put进队列之后就不再访问它,消费者也不会绕过BlockingQueue去获取数据,那么并不需要数据类型是线程安全的。

通过第二章介绍的安全发布手段,也能建立起这种串行化的线程封闭特性,只不过BlockingQueue更简单,而且边界明显,不易出错。

双端队列和任务窃取

java 6增加了双端队列集合类型:Deque和BlockingDeque,双端队列允许在头和尾进行插入和移除操作,具体实现有ArrayDeque和LinkedBlockingDeque。

就像BlockingDeque适用于生产者-消费者模型,BlockingDeque适用于”任务窃取“模型;与生产者-消费者共享一个任务队列不同,任务窃取模型下,每个worker都有一个私有任务队列,如果一个worker完全了全部工作,那么它能从其他worker的队列末尾窃取一个任务。

”任务窃取“模型比生产者-消费者模型更具可伸缩性,因为正常情况下worker只会访问自己的队列,避免了竞争,在窃取任务时,从其他worker队列的末尾入手,也尽可能地减少了竞争。它特别适合那种生产者即消费者的的场景,worker完成一个任务,可能导致更多的任务被产生并添加到自己的队列里(比如网络爬虫),这样确保每个worker都不会空闲。

非阻塞并发Queue

JDK里面只有一个非阻的并发安全Queue,就是ConcurrentLinkedQueue。这是一个无锁化的队列,入队列和出队列的操作性能都非常好。

BlockingDeque和ConcurrentLinkedQueue之间的选择,完全取决于是否期望线程在该队列上阻塞;由于”生产者-消费者“模型下,生产线程和消费线程往往都期望这种阻塞,所以BlockingDeque在实际项目中更常见。

阻塞和中断

线程在几种情况下会阻塞或暂停,比如等待IO完成、等待获取锁,或者等待从Thread.sleep中恢复等。当一个线程阻塞,它通常进入某个阻塞状态(BLOCKED,WAITING,TIMED_WAITING)。线程陷入阻塞状态和正在执行耗时操作的区别是,前者是线程自身无法控制的,它需要等待外部条件满足。

BlockingQueue的put、take方法可抛出InterruptedException,Thread.sleep方法也如此,如果一个方法可抛出InterruptedException异常,意味这是一个阻塞方法,一旦线程被interrupt,该方法会通过抛出异常的形式中断阻塞。

Thread的interrupt机制是线程间互相协调的一种通讯机制,它本质上就是一个标记位,当线程A希望中断线程B时,线程A设置线程B的interrupt标识,仅此而已;然后就期望,线程B会在适当的时候检查interrupt标识,并结束运行。

顺着这个设计哲学,当线程阻塞在某个方法内同时被interrupt,阻塞需要提前结束,否则就无法响应interrupt;另一方面该方法也不能正常返回,否则调用者无法区分正常返回和中断,这样一来,抛出一个特定异常(InterruptedException)就是必然选择。

处理InterruptedException

发生InterruptedException异常,意味着当前线程被其他线程interrupt(记住:InterruptedException的发生一定是因为Thread.interrupt被调用);一般来说,线程是不能忽略捕获的InterruptedException异常的,我们有两种常见的应对手段:

  • 传播这个异常:当前上下文不知道如何处理,直接把异常往上继续传播;
  • 恢复异常状态:一旦InterruptedException被捕获,线程的interrupt标记会被清除,catch逻辑里可恢复该标记,让后面的代码来处理该标记。
public class TaskRunnable implements Runnable {
	BlockingQueue<Task> queue;
	public void run() {
		try {
			processTask(queue.take());
		} catch (InterruptedException e) {
			// restore interrupted status
			Thread.currentThread().interrupt();
		}
	}
}

同步器(Synchronizer)

在前面介绍的集合类型中,BlockingQueue是一个比较特殊的存在,它不仅提供一种可在并发环境下安全访问的容器,还可以协调线程之间的执行流。第二个角色有一个专有名字叫同步器,BlockingQueue是一个简单的同步器,如果它不能满足需求,还可以使用 semaphore,barrier,latche等更底层的同步器,你甚至还可以创建自定义的同步器。

所有的同步器有着一些相通的结构和属性:同步器封装了某个状态,该状态决定了当线程到达该同步器时,被允许通过或阻塞,同步器还必须提供一些操作、检查该状态的方法。

Latch(门栓)

Latch相当于一个大门,当大门关闭时,所有经过该大门的人都不允许通过,一旦大门打开,所有人都可以通过;而且大门一旦打开,就不能再关闭。

因此Latch适用与等待某个条件达成的同步场景,比如,计算流程等待某个资源ready再继续;或某些服务等所依赖的服务完成启动后再启动;

CountDownLatch是一个更加灵活的Latch版本,就好比一道需要转动多次才能打开的大门,每次达成一个条件就执行一下CountDownLatch.countDown(),当countDown次数到达预定值时大门打开。

public class TestCountDownLatch {
	public void timeTasks(int nThreads, final Runnable task) throws InterruptedException 	{
		final CountDownLatch taskComplete = new CountDownLatch(nThreads);
		for (int i = 0; i < nThreads; i++) {
			Thread t = new Thread() {
				public void run() {
					try {
						task.run();
					} finally {
						taskComplete.countDown();
					}
				};
			t.start();
		}
		taskComplete.await();
	}
}

上面的代码展示了CountDownLatch的用法,主线程通过多个子线程来执行任务,子线程通过CountDownLatch来通知任务完成,主线程在该CountDownLatch上等待全部任务完成。

FutureTask

FutureTask的行为也类似latch,它实现Future接口,代表一个可执行、能返回执行结果的Runnable。FutureTask可能处于三种状态之一:等待执行,执行中,执行完成;完成可能是正常完成,也可能是被取消,还有可能异常结束;一旦FutureTask完成,就永远处于该状态。

Future.get方法的行取决于任务的状态,如果任务已经完成,get方法立即返回,否则阻塞直到任务完成。

下面看一个示例:

public class Preloader {
	private final FutureTask<ProductInfo> future =
			new FutureTask<ProductInfo>(new Callable<ProductInfo>() {
				public ProductInfo call() throws DataLoadException {
					return loadProductInfo();
				}
		});
	
	public staic void main(String[] args) {
		executor.submmit(future);
		ProductInfo info = future.get();
		...
	}
}

上面的FutureTask实例包裹一个Callable对象,后者的实现代码执行了一个耗时的加载操作loadProductInfo。主线程用一个executor来执行FutureTask,执行future.get()来等待加载结果。值得注意的是,FutureTask保证了加载结果从任务线程发布到主线程。

上面的代码忽略了异常处理,FutureTask.get()方法可抛出InterruptedException和ExecutionException,前者不再赘述,后者包裹了Callable执行过程中抛出的异常。

更常见的情景是主线程创建一个FutureTask列表,然后等待所有任务完成。

信号量(Semaphore)

信号量用于控制并发访问某个资源的数量,它就像管理一组许可证一样,初始化一定的数量,活动代码在访问相关资源之前,先获取许可证,使用完了在释放许可证。如果当前的许可证已经耗尽,那么获取许可证的操作将会被阻塞。

信号量特别适合用来实现类似资源池这样的设计模式,Semaphore的初始值设置为资源数量:

public class ResourcePool
{
	private Semaphore semaphore = new Semaphore(x);
	
	public Resource get() throws InterruptedException {
		semaphore.acquire();
		return fetchResource();
	}
	
	public void return(Resource res) throws InterruptedException {
		returnResource(res);
		semaphore.release();
	}
}

Barrier(栅栏)

Barrier用来协调一组线程的执行步调,当某个线程调用Barrier.wait(),它被阻塞在这里;直到所有线程(预定义的线程个数)都执行到这个语句,所有线程一起恢复继续往前执行。

CyclicBarrier是JDK并发库提供的实现,它可以重复使用,一个CyclicBarrier被通过的同时被重置,于是这组线程可重用cyclicBarrier在下个约定执行点再次汇合。

Barrier可以用于实现多个线程需要不断互相等待的场景,就好像”你和朋友约定在麦当劳门口碰面,然后再决定接下来去哪?"。

另外一个Barrier实现是Exchanger,它是一个二元的Barrier,专门用于两个线程在某个执行点交换数据。他可用来实现类似OpenGL双缓冲区渲染机制:生产者线程准备好的缓冲数据对象后,将其放入Exchanger,消费者线程,处理完另一缓冲数据后,也将其放入Exchanger;在这个交汇点Exchanger交换这两个缓冲数据对象,然后生产者和消费者可继续执行。

《Java Concurrency in Practice》一书的锁相关内容单独成章,高级主题部分第十三章。

一个高效、可伸缩的缓存系统

几乎所有的应用系统都会用到某种形式的缓存,复用之前的计算结果能够节约CPU,提高系统的响应性,代价是一些额外的内存消耗。但是一个弱鸡的缓存系统实现,可能会把计算性能瓶颈点转换为可伸缩性瓶颈;换句话说,这个缓存在单线程下确实能改善性能,但是在多核机器上无法通过提升线程数来提升它的性能。

可伸缩性是一种对软件系统计算处理能力的设计指标,高可伸缩性代表一种设计弹性,通过很少的改动甚至只是硬件设备的添置,就能实现整个系统处理能力的线性增长,实现高吞吐量和低延迟。

这一节设计一个完整的缓存系统,并逐步通过本章学习的技术来改善它的并发性能。

问题描述

我们正在开发一个多线程的软件系统,该系统内有一个需要频繁执行且非常耗时的计算任务:通过字符串计算一个整型值。

这个计算任务的接口定义Computablen如下:

public interface Computable<A, V> {
	V compute(A arg) throws InterruptedException;
}

Memoizer1

缓存系统的第一个实现版本Memoizer1:

public class Memoizer1<A, V> implements Computable<A, V> {

	@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来缓存计算结果,HashMap是非线程安全的,于是Memoizer1使用了java监视器模式。

Memoizer1可可伸缩性很差,因为compute方法是原子的,只能有一个线程能进入,因此无论机器有多少个处理器,都无法实现并行计算。更加糟糕的是,即使对某个输入参数数值,Memoizer1已经缓存了计算结果,也有可能被阻塞而不能立即返回。

Memoizer2

Memoizer2尝试通过ConcurrentHashMap来解决Memoizer1的缺点。

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看起来很好,compute方法不再有阻塞的可能,唯一的缺点是compute方法内,有一个”check-and-act"组合操作,因此存在竞争条件,一个线程如果正在对某个参数执行计算,另一个线程并不知道,于是可能进行一个重复的计算。

Memoizer3

Memoizer3通过Future来改善Memoizer2的问题:

public class Memoizer3<A, V> implements Computable<A, 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 {
		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(); // call to c.compute happens here
		}
		try {
			return f.get();
		} catch (ExecutionException e) {
			throw launderThrowable(e.getCause());
		}
	}
}

Memoizer3在ConcurrentHashMap存储的是Future对象,而不是计算结果;特别需要注意的是:compute方法在缓存没有命中的时候,首先创建FutureTask放入cache,然后再执行计算任务(ft.run());如果此刻,其他线程使用相同参数执行compute方法,会调用同一个FutureTask对象的get方法,等待该计算任务的完成。

经过上面的分析,大家可能已经发现,Memoizer3并没有完全消除重复计算的风险,只是相比Memoizer2,大大缩小了风险窗口。如果重复计算本身没有坏的副作用,Memoizer3完全可以在实际项目中运用。

Memoizer4

ConcurrentHashMap提供了原子方法:putIfAbsent,使我们可以进一步改善Memoizer3以臻完美。

public class Memoizer3<A, V> implements Computable<A, 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 {
		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 = cache.putIfAbsent(arg, ft);
			if (f==null) {
				f=ft;
				ft.run(); // call to c.compute happens here
			}
		}
		try {
			return f.get();
		} catch (ExecutionException e) {
			throw launderThrowable(e.getCause());
		}
	}
}

通过putIfAbsent我们消除了”check-and-act“的副作用了,只有当FutureTask成功进入ConcurrentHashMap之后,我们才执行计算。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值