生产实践—线程池与异步任务编排

现今的我们使用的服务器,大都是**多处理器多核**配置,资源充足。为**充分利用服务器性能**,**解耦调用线程与异步线程**,**提升响应速度**,使用**并发编程**成为了我们较好的选择。本文将就`JDK`提供的**线程池**,以文件上传的示例讲解线程池的打开方式。
## **一、线程池介绍**
JDK中提供的线程池核心实现类是`ThreadPoolExecutor`,使用***IDEA show Diagrams***查看类继承关系如下:

![1649818738(1).png](https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9be889ab859d41f781d9176354b0d899~tplv-k3u1fbpfcp-watermark.image?)

- 顶层接口`Executor`仅提供了一个`void execute(Runnable command)`方法 ,将任务定义与任务执行解耦,用户只需要定义`Runnable`任务即可。
- `ExecutorService`接口继承了`Executor`接口,在任务执行的基础上,增加了带返回的`<T> Future<T> submit(Callable<T> task)`方法,以及**批量执行异步任务**及**线程池启停**等管理功能。
- `AbstractExecutorService`实现了`ExecutorService`,作为任务模板,**串联任务执行的流程**,让下层实现类只需关注任务执行。
- `ThreadPoolExecutor`则实现了**任务管理**、**线程管理**、**线程池生命周期管理**等功能。
## **二、任务执行流程**
接下来我们通过源码看下线程池的默认执行流程:
```
...
// 获取ctl参数,高3位表示运行状态,低29位表示工作线程数
int c = ctl.get();
// 工作线程数小于核心线程数,尝试创建线程
if (workerCountOf(c) < corePoolSize) {
        // 线程数和运行状态符合预期,新增工作线程
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
   //工作线程数大于等于核心线程数,检查运行状态并尝试进入任务队列
   if (isRunning(c) && workQueue.offer(command)) {
       int recheck = ctl.get();
       // 再次检查运行状态,如果状态异常(如执行shutdownNow),则移除任务并回调拒绝策略。
       if (!isRunning(recheck) && remove(command))
           // 执行拒绝策略
           reject(command);
       // 如果工作线程为0, 则初始化一个工作线程。
       // 极限情况,刚入队时,线程都被回收。
       else if (workerCountOf(recheck) == 0)
           // 新增线程
           addWorker(null, false);
   }
    // 运行线程数大于等于核心线程数且队列已满尝试新增线程
   else if (!addWorker(command, false))
       // 新增失败执行拒绝策略
       reject(command);
}
```
流程图:

![image.png](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/0c2cb876f8994411a8d94c9e24c2e2b4~tplv-k3u1fbpfcp-watermark.image?)

当然我们这里介绍的是线程池**默认**的执行流程,这类流程适合**CPU密集型**应用,目前也有不少中间件基于`ThreadPoolExecutor`进行二次开发。例如`Tomcat`、`Netty`、`Dubbo`等都有相应的实现,`tomcat`将执行流程改为,**先将线程数提升到最大线程再进入队列**,从而**减少IO密集型应用阻塞时的资源浪费**。

## **二、自定义线程池**
### 2.1 线程池创建
JDK本身提供一些开箱即用的线程池,如`FixedThreadPool`、`CachedThreadPool`等,但参数设定固定且部分线程池使用无界队列,在系统并发量过高或程序设计出现缺陷时,极容易导致内存溢出(out of memory)或其他一些不可预知的异常。

这里我们使用`ThreadPoolExecutor`如下构造函数进行线程池的创建。
```
public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)
```
线程池创建代码如下:

```
/**
 * @author winsonWu
 * @Description: thread pool creating configuration
 * @date Date : 2021.04.13 16:00
 */
@Configuration
public class ThreadPoolCreator {

    /**
     * 核心线程数
     */
    private static int corePoolSize = Runtime.getRuntime().availableProcessors() + 1;

    /**
     * 最大线程数 避免内存交换 设置为核心核心线程数
     */
    private static int maximumPoolSize = corePoolSize;

    /**
     * 最大空闲时间
     */
    private static long keepAliveTime = 3;

    /**
     * 最大空闲时间单位
     */
    private static TimeUnit unit = TimeUnit.MINUTES;

    /**
     * 使用有界队列,避免内存溢出
     */
    private static BlockingQueue<Runnable> workQueue = new LinkedBlockingDeque<>(500);

    /**
     * 线程工厂,这里我们使用可命名的线程工厂,方便业务区分以及生产问题排查。
     */
    private static ThreadFactory threadFactory = new NamedThreadFactory("taskResolver");

    /**
     * 拒绝策略 根据业务选择或者自定义
     */
    private static RejectedExecutionHandler handler = new ThreadPoolExecutor.AbortPolicy();

    @Bean
    public ThreadPoolExecutor threadPoolExecutor(){
        return new ThreadPoolExecutor(
                corePoolSize,
                maximumPoolSize,
                keepAliveTime, unit,
                workQueue,
                threadFactory,
                handler);
    }
}
```
### 2.2 **核心线程数配置**
并发任务一般分为**CPU密集型任务**,**IO密集型任务**两类。

**CPU密集型任务**,需要CPU进行复杂、高密度的运算。这种类型的任务不能创建过多的线程,否则将会频繁引起**上文切换**,降低资源使用率,降低任务处理速度;  **IO密集型任务**,线程则不会对CPU资源要求过于苛刻,可能大部分时间**阻塞在IO**,增加线程数量可以提高并发度,尽可能多处理任务。一般经验化配置:

```
CPU密集型 N + 1 但尽量不超过操作系统核数2倍
IO密集型 2N + 1 
N为服务器核数。
```
生产环境,建议具体设置根据**压测**结果决定。

### 2.3 **阻塞队列**

**阻塞队列(BlockingQueue)** 在队列为空时,获取元素的线程会阻塞,等待队列变为非空。当队列满时,存储元素的线程会阻塞,等待队列被获取消费,天然支持线程池这类生产消费者模型。常见阻塞队列如下:
![image.png](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/80418d86a39a44d6977ba42520df21db~tplv-k3u1fbpfcp-watermark.image?)
大多数场景,我们使用`LinkedBlockingQueue`即可解决,这里我们也选择使用`LinkedBlockingQueue`。

### 2.4 **拒绝策略**
默认情况下,线程池阻塞队列已满且线程池已达到最大线程数,会执行拒绝策略,JDK也为我们内置了四种拒绝策略:

![image.png](https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/9a385a73de894d8dbfaa26fa5a0fd29a~tplv-k3u1fbpfcp-watermark.image?)
我们这里以两个场景举例说明一下,
- 场景一:系统监控。通过`自定义注解`与`AOP`解析需要监控的接口并获取其出入参,然后通过异步任务将日志存储到HDFS。
    
    日志采集这种场景,少量的数据丢失对业务影响并不大,因而我们可以配置核心线程数为1,以降低对服务器资源的占用,阻塞队列容量可以根据压测结果适当增加,拒绝策略则使用`DiscardPolicy`或`AbortPolicy`,但要注意,如果使用`AbortPolicy`,`execute(...)`方法使用**Try Catch**或**UncaughtExceptionHandler(不推荐)** 进行异常处理,`submit(...)`方法使用`Future.get()`获取异常进行处理。
- 场景二:消息队列消费。消息堆积可以通过异步线程进行批量处理。
    
    这种场景,数据不能丢失,因此我们采用`CallerRunsPolicy`让调用线程执行消息处理逻辑。但这种方式会对调用线程执行的业务产生影响,更好的方式可以采用**自定义拒绝策略**进行持久化或者放入队列。我们先看下`CallerRunsPolicy`拒绝策略的实现:
     ```
    public static class CallerRunsPolicy implements RejectedExecutionHandler {
        /**
         * Creates a {@code CallerRunsPolicy}.
         */
        public CallerRunsPolicy() { }

        /**
         * Executes task r in the caller's thread, unless the executor
         * has been shut down, in which case the task is discarded.
         *
         * @param r the runnable task requested to be executed
         * @param e the executor attempting to execute this task
         */
        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            if (!e.isShutdown()) {
                r.run();
            }
        }
    }
    ```
    可以看到我们自定义拒绝策略,只需要实现`RejectedExecutionHandler`接口,并覆写`rejectedExecution(Runnable r, ThreadPoolExecutor e)`,自定义示例如下:
    ```
    /**
     * @Author winsonWu
     * @Description: 持久化拒绝策略
     * @date Date : 2022.04.14 9:38
     **/
    public class DataBaseStoragePolicy implements RejectedExecutionHandler {
        @Override
        public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
            // todo duration or something
        }
    }
    ```
### 2.5 **线程预热与回收**


- **线程预热**
   
   如果可预见服务器启动后就会产生大量请求,如拒绝策略模块中提到的堆积消息处理的场景,我们可以使用线程池预热,提前创建核心线程,以提升服务相应速度。在`ThreadPoolExecutor`中有三个方法:
   
     ```
    // 启动一个核心线程
    public boolean prestartCoreThread() {
        return workerCountOf(ctl.get()) < corePoolSize &&
            addWorker(null, true);
    }
    ```
    ```
    // 启动所有核心线程
    public int prestartAllCoreThreads() {
        int n = 0;
        while (addWorker(null, true))
            ++n;
        return n;
    }
    ```
    ```
    // 保证至少一个核心线程启动
    void ensurePrestart() {
        int wc = workerCountOf(ctl.get());
        if (wc < corePoolSize)
            addWorker(null, true);
        else if (wc == 0)
            addWorker(null, false);
    }
    ```

- **线程回收**

    默认情况下,当`workerCount`大于`corePoolSize`的时候,空闲线程的空闲时间超过了`keepAliveTime`所设置的时间,线程池就会自动回收该线程,另外核心线程数如果设置`allowCoreThreadTimeOut`参数,也同样可以被回收,以提高资源使用率。
### 2.6 **线程池监控**
`ThreadPoolExecutor`自身提供了一些状态查询方法,可以获取一些线程池状态信息,我们修改前面的Bean定义来看一下,相关方法在代码注释中已经写出:
```
...
@Bean
public ThreadPoolExecutor threadPoolExecutor(){
    return new ThreadPoolExecutor(
            corePoolSize,
            maximumPoolSize,
            keepAliveTime, unit,
            workQueue,
            threadFactory,
            handler){

        // 设定任务前执行动作
        @Override
        protected void beforeExecute(Thread t, Runnable r) {
            // 获取线程池大小
            System.out.println("线程池大小:" + this.getPoolSize());
            // 获取核心线程数
            System.out.println("核心线程数:" + this.getCorePoolSize());
            // 获取最大线程数
            System.out.println("最大线程数:" + this.getLargestPoolSize());
            // 获取活跃线程数
            System.out.println("活跃线程数:" + this.getActiveCount());
        }

        // 设定任务后执行动作
        @Override
        protected void afterExecute(Runnable r, Throwable t) {
            // 获取活跃线程数
            System.out.println("活跃线程数:" + this.getActiveCount());
            // 获取任务数
            System.out.println("任务数:" + this.getTaskCount());
        }

        // 设定线程池终止执行动作
        @Override
        protected void terminated() {
            // 获取已完成任务数
            System.out.println("已完成任务数:" + this.getCompletedTaskCount());
        }
    };
}
```

### 2.7 **线程池生命周期**
线程池主要有五种状态,代码定义如下:
```
private static final int RUNNING    = -1 << COUNT_BITS;
private static final int SHUTDOWN   =  0 << COUNT_BITS;
private static final int STOP       =  1 << COUNT_BITS;
private static final int TIDYING    =  2 << COUNT_BITS;
private static final int TERMINATED =  3 << COUNT_BITS;
```
状态转换如下:

![image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/18e44d0a7ef24b2e942d648fdf8a2ff1~tplv-k3u1fbpfcp-watermark.image?)
这里需要注意的是我们执行`shutdown()`方法后,不再接收新任务,但会处理阻塞队列中剩余的任务,而`shutdownNow()`方法,不再接收新任务的同时也会中断阻塞队列中剩余的任务。
## **三、线程池实践**
### 3.1 **基础实践**
前面我们已经定义好了线程池,我们先来尝试下基础方法的使用:
```
@Resource
private ThreadPoolExecutor threadPoolExecutor;

@Test
public void testMultiThread() throws InterruptedException {
    // 线程池预热,提前启动所有核心线程
    threadPoolExecutor.prestartAllCoreThreads();
    StopWatch stopwatch = new StopWatch("线程池测试");
    stopwatch.start("execute");
    // execute(e(Runnable command)
    CountDownLatch forExecute = new CountDownLatch(1);
    threadPoolExecutor.execute(() -> {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            System.out.println("interrupted ignore");
        }
        System.out.println("execute(Runnable command) test");
        forExecute.countDown();
    });
    forExecute.await();
    stopwatch.stop();
    stopwatch.start("submit");
    // submit(Runnable command)
    CountDownLatch forSubmit = new CountDownLatch(1);
    final Future<String> future = threadPoolExecutor.submit(() -> {
        System.out.println("submit(Runnable command) test");
        forSubmit.countDown();
        return "submit(Runnable command) test";
        });
    try {
        final String result = future.get();
        System.out.println("result: " + result);
    } catch (ExecutionException e) {
        //todo 自定义异常处理
    }
    forSubmit.await();
    stopwatch.stop();
    System.out.println(stopwatch.prettyPrint());
}
```
执行结果如下:

![1649913889(1).png](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/bd7c8c25f5a64c9889da92cdd141be5d~tplv-k3u1fbpfcp-watermark.image?)


前面已经讲过`execute(e(Runnable command)`和`submit(Runnable command)`的区别,这里就不再赘述。另外可以看到我们使用CountDownLatch来进行线程协同,execute执行结束后,submit才开始执行。

### 3.2 **文件上传实践**
接下来我们通过一个文件上传的功能来演示使用`CompletableFuture`来进行异步任务编排。我们要实现的业务描述如下:

1. **实现批量文件上传**
2. **文件上传完成后返回文件名称与文件ID列表**
3. **出现异常打印日志到控制台(演示,生产环境可以自定义)**

实现代码如下:

**定义返回对象:**
```
@Data
public class FileEntry implements Serializable {

    /**
     * 文件ID
     */
    private String fileId;

    /**
     * 文件名
     */
    private String fileName;

}
```
**文件上传逻辑:**
```
/**
 * 测试代码,这里我们直接上传到固定目录
 * @param eachFile
 * @return
 */
private FileEntry createFileEntry(MultipartFile eachFile){
    // 生成文件ID
    String fileId = UUID.randomUUID().toString().replace("-", "");
    File desFile = new File(FILE_LOCATION   + fileId + "_" + eachFile.getOriginalFilename());
    try {
        eachFile.transferTo(desFile);
    } catch (IOException e) {
        throw new BizException("文件上传失败");
    }
    // 文件上传成功,构建返回参数
    FileEntry fileEntry = new FileEntry();
    fileEntry.setFileName(eachFile.getOriginalFilename());
    fileEntry.setFileId(fileId);
    return fileEntry;
}
```
**主逻辑:**
```
/**
 * 文件上传
 * @param files
 */
public ArrayList<FileEntry> uploadFile(MultipartFile[] files){
    // 初始化返回值
    ArrayList<FileEntry> fileEntryList = new ArrayList<>(files.length);
    List<CompletableFuture<FileEntry>> futureList = new ArrayList<>(files.length);
    for (MultipartFile eachFile : files){
        // 使用之前定义的线程池执行文件上传逻辑
        CompletableFuture<FileEntry> future = CompletableFuture.supplyAsync(() -> 
        createFileEntry(eachFile), threadPoolExecutor);
        // 添加到future列表
        futureList.add(future);
    }
    CompletableFuture<Void> fileUploadFuture = CompletableFuture
            .allOf(futureList.toArray(new CompletableFuture[futureList.size()]))
            .whenComplete((v, t) -> futureList.forEach(future -> {
                // 添加返回结果到返回值列表
                fileEntryList.add(future.getNow(null));
            }))
            .exceptionally(exception -> {
                // todo 自定义逻辑
                System.out.println("error occurred:" + exception.getMessage());
                return null;
            });
    // 阻塞主线程,等待文件全部上传
    fileUploadFuture.join();
    // 返回entry列表
    return fileEntryList;
}
```
至此,我们简化逻辑实现完成,`CompletableFuture`用法可查阅参考资料部分。

## **四、参考资料**
- [明心见性-如何由表及里精通线程池设计与原理](https://juejin.cn/post/6988792385488551944/)
- [琳琅满目-细数CompletableFuture的那些花式玩法](https://juejin.cn/post/6996943652832411684/)
- [Java线程池实现原理及其在美团业务中的实践](https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html)
- [原生线程池这么强大,Tomcat 为何还需扩展线程池?](https://www.shuzhiduo.com/A/l1dyZ3xxze/)
 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值