Java并发编程实战:线程池的应用

一、任务与执行策略间的隐形耦合

1、线程饥饿死锁

当任务都是同类、独立的时候,线程池才会有最好的工作表现。如果将耗时的短期的任务混合在一起,除非线程池很大,否则会有“塞车”的风险;如果提交的任务要依赖其他的任务,除非池是无限的,否则有产生死锁的风险。如下代码所示,对于一个单线程化的Executor,一个任务将另一个任务提交到相同的Executor中,并等待新提交的任务的结果,第二个任务滞留在工作队列中,直到第一个任务完成,但是第一个任务在等待第一个任务完成,这就引发了死锁。在一个大的线程池中,如果所有线程执行的任务都阻塞在线程池中,等待着仍处于同一队列中的其他任务,也会发生死锁。

class TheadDeadlock{
	ExecutorService exec = Executors.newSingleThreadExecutor();
	class RenderPageTask implements Callable<String>{
		public String call() {
			Future<String> header,footer;
			header = exec.submit(new LoadFileTask("header.html"));
			footer = exec.submit(new LoadFileTask("footer.html"));
			String page = renderBody();
			return header.get() + page + footer.get();
		}
	}
}

2、耗时操作

如果任务由于过长的时间周期而阻塞,那么即使不可能出现死锁,线程池的响应性也会变得很差。耗时任务会造成线程池堵塞,还会延长服务时间。可以限定任务等待资源的时间来缓解耗时操作带来的影响。

二、定制线程池的大小

线程池过大:线程对稀缺的CPU和内存资源的竞争,会导致内存的高使用量,还可能耗尽资源。

线程池过小:由于存在很多可用的处理器资源却未在工作,会对吞吐量造成损失。

如果有不同类别的任务,他们拥有差别很大的行为,那么要使用多个线程池,这样每个线程池可以根据不同任务的工作负载进行调节。

对于计算密集型的任务,一个有N个处理器的系统通常使用N+1个线程的线程池来获得最优的利用率。

对于包含了I/O和其他阻塞操作的任务,不是所有的线程都会在所有的时间被调度,因此需要一个更大的线程池。

给定下列定义:

N = CPU的数量

U = 目标CPU的使用率,0 <= U <= 1

W/C = 等待时间与计算时间的比率

为保持处理器达到期望的使用率,最优的池的大小等于

Num = N * U * (1 + W/C)

可以通过Runtime.getRuntime().availableProcessors();来获得CPU的数目

CPU周期并不是唯一可以约束资源池大小的资源,其他还有:内存、文件句柄、套接字句柄和数据库连接等。计算这些类型资源池的大小:首先累加每一个任务需要的这些资源的总量,然后除以可用的总量,所得结果是池大小的上限。

三、配置ThreadPoolExecutor

1、线程的创建与销毁

核心池大小:即线程池的实现试图维护的池的大小,即使没有任务执行,池的大小也等于核心池的大小,并且直到工作队列充满前,池都不会创建更多的线程。

最大池的大小:可同时活动的线程数的上限

存活时间:如果一个线程已经闲置的时间超过了存活时间,它将成为一个被回收的候选者,如果池的大小超过了核心池的大小,线程池会终止它。

newFixedThreadPool工厂为请求的池设置了核心池的大小和最大池的大小,而且存活时间是无限的

newCachedThreadPool工厂将最大池的大小设置为Integer.MAX_VALUE,核心池的大小设置为0,超时设置为1分钟

2、管理队列任务

当新请求到达的频率超过了线程池能够处理它们的速度,这些多出来的请求会在一个由Executor管理的Runnable队列中等候。

ThreadPoolExecutor允许使用一个BlockingQueue来持有等待执行的任务。任务排队有3种基本方法:无限队列、有限队列和同步移交。

newFixedThreadPool和newSingleThreadExecutor默认使用的是一个无限的LinkedBlockingQueue。如果线程池中的所有线程都处于忙碌状态,任务将会在队列中等候。如果等候的任务超出了队列的长度,队列也会无限制地增加。

newCachedThreadPool使用SynchronousQueue,将任务直接从生产者移交给工作者线程。为了把一个元素放入到SynchronousQueue中,必须有另一个线程正在等待接受移交的任务。如果没有这样一个线程,只要当前池的大小还小于最大值,ThreadPoolExecutor就会创建一个新的线程;否则根据饱和策略,任务会被拒绝。只有当池是无限的,或者可以接受任务被拒绝,SynchronousQueue才是一个好的选择。

另外,使用有限队列比如ArrayBlockingQueue或者有限的LinkedBlockingQueue以及PriorityBlockingQueue有助于避免资源耗尽的情况发生,但是它又引入了新问题:当队列已满后,新的任务怎么办?有很多饱和策略可以处理这个问题。对于一个有界队列,队列的长度与吃的长度必须一起调节。一个大队列加一个小池,可以控制对内存和CPU的使用,还可以减少上下文切换。

当任务彼此独立时,有限线程池或者有限工作队列的使用是合理的。倘若任务之间相互依赖,有限的线程池或队列就可能引起线程饥饿死锁,就要使用一个无限的池配置。

3、饱和策略

当有限的等待队列填满后,饱和策略开始起作用。ThreadPoolExecutor的饱和策略可以通过调用setRejectedExecutionHandler来修改。JDK提供了几种不同的RejectedExecutionHandler的实现:

(1)AbortPolicy:默认的“中止”策略会引起execute抛出未检查的RejectedExecutionException,调用者可以捕获这个异常,然后编写能满足自己需求的处理代码。

(2)DiscardPolicy:“遗弃”策略会放弃这个任务

(3)DiscardOldestPolicy:“遗弃最旧的”策略选择丢弃的任务是本应该接下来就执行的任务,然后尝试重新提交新任务。如果工作队列是优先级队列,那么它选择丢弃的是优先级最高的任务,所以优先级队列和“遗弃最旧的”策略不能一块使用

(4)CallerRunsPolicy:“调用者运行”策略会把一下任务推回到调用者那里,以此减缓新任务流。当所有线程都被占用,工作队列已充满后,下一个任务会在主线程中执行。主线程调用execute执行这个任务。因为这将花费一些时间,所以主线程在一段时间内不能提交任何任务。同时这也给了工作者线程时间来追赶进度。这期间主线程也不会调用accept,所以外来的请求不会出现在应用程序中,而会在TCP层的队列中等候。如果持续高负载的话,最终会由TCP层判断它的连接请求队列是否已经排满,如果已满就开始丢弃请求任务。

(5)使用Semaphore来遏制任务的提交,使用一个非受限队列,设置Semaphore的限制范围等于池的大小加上你希望允许可排队的任务数量。如下代码所示:

class BoundedExecutor{
	private final Executor exec;
	private final Semaphore semaphore;
	public BoundedExecutor(Executor exec,int bound){
		this.exec = exec;
		this.semaphore = new Semaphore(bound);
	}
	public void submitTask(final Runnable task) throws InterruptedException{
		semaphore.acquire();
		try {
			exec.execute(new Runnable() {
				@Override
				public void run() {
					// TODO Auto-generated method stub
					try{
						task.run();
					}finally{
						semaphore.release();
					}
				}
			});
		} catch (RejectedExecutionException e) {
			// TODO Auto-generated catch block
			semaphore.release();
		}
	}
}

四、扩展ThreadPoolExecutor

ThreadPoolExecutor提供了几个函数让子类去覆写:beforeExecute、afterExecute和terminate。执行任务的线程会调用函数beforeExecute、afterExecute,用于添加日志、时序、监视器或统计信息收集的功能。无论任务是正常从run返回,还是抛出一个异常,afterExecute都会被调用。如果beforeExecute抛出一个RuntimeException,任务将不被执行,afterExecute也不会被调用。但所有的任务都已完成且所有工作者线程也已经关闭后,会执行terminate,terminate可以用来释放Executor在生命周期里分配到的资源,还可以发出通知、记录日志或者完成统计信息。如下代码可以使用这三个函数提供日志和计时功能

class TimingThreadPool extends ThreadPoolExecutor{
	private final ThreadLocal<Long> startTime = new ThreadLocal<Long>();
	private final Logger log = Logger.getLogger("TimingThreadPool");
	private final AtomicLong numTasks = new AtomicLong();
	private final AtomicLong totalTime = new AtomicLong();
	
	protected void beforeExecute(Thread t,Runnable r){
		super.beforeExecute(t,r);
		log.fine(String.format("Thread %s: start %s",t,r));
		startTime.set(System.nanoTime());
	}
	protected void afterExecute(Runnable r,Throwable t){
		try{
			long endTime = System.nanoTime();
			long taskTime = endTime - startTime.get();
			numTasks.incrementAndGet();
			totalTime.addAndGet(taskTime);
			log.fine(String.format("Thread %s: end %s, time=%dns",t,r,taskTime));
		}finally{
			super.afterExecute(r,t);
		}
	}
	protected void terminated(){
		try{
			log.info(String.format("Terminated: time=%dns",totalTime.get()/numTasks.get()));
		}finally{
			super.terminated();
		}
	}
}







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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值