Java并发类库
- Executor 是一个基础的接口,其初衷是将任务提交和任务执行细节解耦。
- ExecutorService 接口继承 Executor 接口,不仅提供 service 的管理功能,比如 shutdown 等方法,也提供了更加全面的提交任务机制。
- Executors 提供了各种方便的静态工厂方法(不推荐使用!)。
- Java 标准类库提供了几种基础实现:
- ThreadPoolExecutor:最基础的线程池
- ScheduledThreadPoolExecutor:ThreadPoolExecutor 的扩展,主要增加了调度逻辑
- ForkJoinPool:为 ForkJoinTask 定制的线程池,与通常意义的线程池有所不同
线程池的创建
开发规范:【强制】手动声明线程池。
正例:手动 new ThreadPoolExecutor 来创建线程池
ThreadPoolExecutor threadPool = new ThreadPoolExecutor( 2, 5, 5, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10), new ThreadFactoryBuilder().setNameFormat("demo-threadpool-%d").get(), new ThreadPoolExecutor.AbortPolicy());
反例:使用 Java 中的 Executors 类定义的一些快捷工具方法来创建线程池。
ThreadPoolExecutor threadPool = (ThreadPoolExecutor) Executors.newFixedThreadPool(4);
ThreadPoolExecutor threadPool = (ThreadPoolExecutor) Executors.newCachedThreadPool();
原因:
- 需要根据自己的场景、并发情况来评估线程池的几个核心参数,包括核心线程数、最大线程数、线程回收策略、工作队列的类型、拒绝策略,确保线程池的工作行为符合需求,一般都需要设置有界的工作队列和可控的线程数。
- 应该为自定义线程池指定有意义的名称,以方便排查问题。当出现线程数量暴增、线程死锁、线程占用大量 CPU、线程执行出现异常等问题时,我们往往会抓取线程栈。此时,有意义的线程名称,就可以方便我们定位问题。
线程池的监控
线程池除非是触发了拒绝策略,否则压力再大都不会抛出一个异常。如果我们能提前观察到线程池队列的积压,或者线程数量的快速膨胀,往往可以提早发现并解决问题。
监控demo:每秒输出一次线程池的基本内部信息,包括线程数、活跃线程数、完成了多少任务,以及队列中还有多少积压任务等信息。
private void printStats(ThreadPoolExecutor threadPool) {
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
log.info("=========================");
log.info("Pool Size: {}", threadPool.getPoolSize());
log.info("Active Threads: {}", threadPool.getActiveCount());
log.info("Number of Tasks Completed: {}", threadPool.getCompletedTaskCount());
log.info("Number of Tasks in Queue: {}", threadPool.getQueue().size());
log.info("=========================");
}, 0, 1, TimeUnit.SECONDS);
}
线程池的工作方式
默认的工作行为:
- 不会初始化 corePoolSize 个线程,有任务来了才创建工作线程;
- 当核心线程满了之后不会立即扩容线程池,而是把任务堆积到工作队列中;
- 当工作队列满了后扩容线程池,一直到线程个数达到 maximumPoolSize 为止;
- 如果队列已满且达到了最大线程后还有任务进来,按照拒绝策略处理;
- 当线程数大于核心线程数时,线程等待 keepAliveTime 后还是没有任务需要处理的话,收缩线程到核心线程数。
Java 线程池是先用工作队列来存放来不及处理的任务,满了之后再扩容线程池。当我们的工作队列设置得很大时,最大线程数这个参数显得没有意义,因为队列很难满,或者到满的时候再去扩容线程池已经于事无补了。
线程池的混用策略
- 不能盲目复用线程池混用线程,因为别人定义的线程池属性不一定适合你的任务,特别注意 IO 绑定的任务和 CPU 绑定的任务对于线程池属性的偏好。
- 如果希望减少任务间的相互干扰,考虑按需使用隔离的线程池,一个应用中有 5 个以内的线程池都可以认为是正常的。
要根据任务的“轻重缓急”来指定线程池的核心参数:
- 对于执行比较慢、数量不大的 IO 任务,或许要考虑更多的线程数,而不需要太大的队列。
- 对于吞吐量较大的计算型任务,线程数量不宜过多,可以是 CPU 核数或核数 *2(理由是,线程一定调度到某个 CPU 进行执行,如果任务本身是 CPU 绑定的任务,那么过多的线程只会增加线程切换的开销,并不能提升吞吐量),但可能需要较长的队列来做缓冲。
一个坑
Java 8 的 parallel stream 功能,可以让我们很方便地并行处理集合中的元素,其背后是共享同一个 ForkJoinPool,默认并行度是 CPU 核数 -1。对于 CPU 绑定的任务来说,使用这样的配置比较合适,但如果集合操作涉及同步 IO 操作的话(比如数据库操作、外部服务调用等),建议自定义一个 ForkJoinPool(或普通线程池)。