《Java并发编程实践》三(8):线程池和任务队列

线程池的概念在第6、7章已经反复出现多次,因为Executor框架的实现需要线程池来执行任务;这一章详细介绍如何配置线程池。

任务和执行策略

前面提到,Excecutor框架将任务的提交和执行分离,实现了二者之间的解耦。但在实际项目中,任务之间可能存在关联或其他约束,并不能在任何执行策略下保证正确性。

  • 非独立任务:

独立任务可适应任何执行策略,是程序具有最好的可扩展性。在执行过程中,你可以随意地调整线程池的大小以适应环境获得更好的性能。如果任务之间存在依赖关系,必须对执行策略有所限制来避免活性失败

  • 线程封闭的任务

假如任务使用的资源采用了线程封闭的安全策略,那么这些任务就要求单线程的执行策略。

  • 影响时间敏感的任务

有些任务对时间非常敏感,比如GUI程序的事件处理任务,向线程池提一个交耗时任务,可能阻碍所有任务的响应性。

  • 使用ThreadLocal任务

ThreadLocal使得每个线程可以拥有同一数据状态的私有副本,但是对线程池内的线程来说,它不断地执行不同的任务,它的生命周期变化不定。如果要在线程池内使用ThreadLocal,ThreadLocal的的生命周期必须与任务一致,它不能作为任务之间的通讯手段。

Excecutor框架提供很灵活的执行策略配置,以支持上述需求。

线程饥饿死锁

如果任务依赖另一个线程池任务,有发生死锁的风险。比如在一个单线程的Excecutor中,一个任务向同一个线程池提交一个任务,然后等待它的完成,就必然导致死锁。

在一个大型线程池内,这种死锁也有可能发生,比如所有的执行中的任务,都在处于block状态等待某个任务,而被等待的任务正在处于任务队列中。

长时任务

一个会长时间阻塞的任务,可能阻塞整个线程池。执行中的任务必然占用一个线程,线程是线程池的资源,一旦没有可用线程,那些短任务也没有机会执行,损害了整个系统的响应性。

线程池大小

理想的线程池大小取决于任务类型和计算资源,线程池的大小不应该被硬编码,应该参考CPU核数、内存容量等进行动态配置,要考虑的因素如下:

  • 可用的CPU核数;
  • 内存容量
  • 任务的性质,CPU计算和IO等待的时间比例;
  • 任务是否受限于其它有限资源,比如JDBC连接;

CPU使用率

对于计算密集型的任务系统来说,Ncpu处理系统下达到最大的吞吐率,线程数为Ncpu+1(因为再忙碌的线程偶尔也会被缺页等原因中断,一个额外的线程是必要的)。

如果线程有大量的IO或其他阻塞操作,那么需要一个更大的线程池。为了更准确一点,你需要测量阻塞时间和CPU计算时间的比率,具体的推理过程如下:

Ncpu = CPU核数
Ucpu = 目标CPU使用率,0<=Ucpu<=1
W/C = 任务阻塞时间和计算时间的比例

在有足够任务的情况下,使CPU保持在目标使用率的线程数如下:

Nthreads = Ncpu ∗ Ucpu ∗ ( 1 + W/C)

在java里, Ncpu = Runtime.getRuntime().availableProcessors()。

资源限制

CPU不是任务使用的唯一资源,其他限制包括内存,打开文件数,socket连接数,数据库连接数等。不过对这些资源的计算相对比较简单,将总数除以每个任务平均占用数即可。

为不同的任务单设线程池

由于不同的任务的资源使用情况完全不同,最好为他们设置不同的线程池,先在不同的线程池之间做一个资源分配规划,然后再分别计算理想的线程池大小。

不过线程池的调整并不是一个要求精确度的工作,绝大多数情况下,只要别太大或太小就可以了。

ThreadPoolExecutor

ThreadPoolExecutor是java并发库提供Executor实现,它同时也是一个非常灵活、久经考验的线程池。Executors的工厂方法newCachedThreadPool, newFixedThreadPool和newScheduledThreadExecutor创建几种预定义策略的ThreadPoolExecutor对象。如果不满需求,可以直接创建ThreadPoolExecutor,并进行定制。

ThreadPoolExecutor的最完整的构造函数如下:

public ThreadPoolExecutor(int corePoolSize,
								int maximumPoolSize,
								long keepAliveTime,
								TimeUnit unit,
								BlockingQueue<Runnable> workQueue,
								ThreadFactory threadFactory,
								RejectedExecutionHandler handler) { ... }

线程的创建和销毁

corePoolSize,maximumPoolSize和keepAliveTime这几个参数空了线程的创建和销毁策略。

  • corePoolSize:线程池想要维持的目标线程数;

    • 不会一启动就创建这么多线程,而是随着任务的进入逐步创建
    • 一旦数量足够,尽量不再创建新的线程
    • 该数量范围内的线程也不会主动销毁
  • maximumPoolSize:线程池的最大线程数

    • 这个是线程池最大的线程数,当任务很多,把任务队列塞满了,此时会创建额外的线程来工作,总数不会超过maximumPoolSize
  • keepAliveTime:线程保活时间

    • corePoolSize之外的线程空闲超过该时间,会被销毁。

任务队列

上面的参数控制了线程池可并发执行的任务数,当任务提交的速度超过执行的速度,那么会放入一个队列中,workQueue参数控制了任务队列的策略:

  • 队列是有限的还是无限的?
  • 队列中的任务优先级如何?

队列容量

Executors.newFixedThreadPool和newSingleThreadExecutor创建的线程池使用无限的LinkedBlockingQueue。更合理的方式是使用一个容量受限的任务队列,如ArrayBlockingQueue。

SynchronousQueue是一个特殊队列,它没有内部存储空间,如果使用它来做任务队列,效果就是提交一个任务要求立即有线程来执行它,否则提交请求被拒绝。所以使用SynchronousQueue,要么线程池是无限大的,要么任务被拒绝是可接受的。Executors.newCachedThreadPool创建的线程池使用了SynchronousQueue,它的线程数被配置为无限的。

任务优先级

FIFO队列,LinkedBlockingQueue和ArrayBlockingQueue,按任务提交的顺序来执行它们。PriorityBlockingQueue支持按特定的优先级来调度任务。

饱和策略

如果一个有限的任务度列被填满,此时饱和策略派上用场。饱和策略用RejectedExecutionHandler接口来表达,即上面
ThreadPoolExecutor构造方法的最后一个参数。JAVA并发库提供了集中默认的饱和策略实现:

  • AbortPolicy:默认策略,任务提交抛出RejectedExecutionException异常;
  • DiscardPolicy:默默地丢弃新提交的任务;
  • DiscardOldestPolicy:丢弃任务队列中下一个被执行的任务;
  • CallerRunsPolicy:在任务提交线程执行任务;

CallerRunsPolicy实现了一种限流机制,在线程池饱和时,让提交任务的线程来执行任务,它就无暇继续提交新任务了。

注意,ThreadPoolExecutor没有任何一种策略能够让提交任务的方法阻塞,直至任务队列不满。

ThreadFactory

ThreadPoolExecutor创建线程使用ThreadFactory,我们提供自己的ThreadFactory实现有以下几个可能的动机:

  • 为线程设置UncaughtExceptionHandler;
  • 使用自定义的Thread类;
  • 修改Thread优先级;
  • 给Thread一个更友好的命名;

ThreadPoolExecutor动态修改配置

ThreadPoolExecutor的很多配置可以在运行过程中动态修改,比如corePoolSize,maximumPoolSize等,自己去看看有哪些setter方法即可。

Executors有一个工厂方法unconfigurableExecutorService能够将现有的ThreadPoolExecutor包裹为不可修改的。Executors.newSingleThreadExecutor方法使用了这种方式,因为单线程的线程池不会并发执行任务,将其动态修改为多线程版本破坏了这个策略,因此不被允许。

继承ThreadPoolExecutor

ThreadPoolExecutor被设计为可以继承的,提供了若干hook方法供覆盖:

  • beforeExecute:在任务线程执行,如果该方法抛出RuntimeException异常,任务被取消,afterExecute也不会被调用;
  • afterExecute:在任务线程执行,任务正常执行完毕或抛出异常,都会调用;
  • terminated:线程池关闭调用,意味着所有任务执行完毕,所有工作线程被销毁。

通过这些hook方法,可实现诸如统计、日志的功能,下面的TimingThreadPool实现任务的计时统计:

public 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);
		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: avg time=%dns",totalTime.get() / numTasks.get()));
		} finally {
			super.terminated();
		}
	}
}

总结

Executor框架是一个强大且灵活的并发任务执行框架,它提供了非常细粒度的配置选项,不同的配置项组合能够形成不同的任务执行策略和线程管理策略。但是请注意,并非任意配置选项组合都能很好地工作,如果配置不当,会产生非常怪异的行为。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值