细数线程池五大坑,一不小心线上就崩了

系统性能优化的几种常用手段是异步和缓存。因此我们常常使用线程池异步处理一些业务。

线程池的使用还是相对比较简单的,首先创建一个线程池,然后通过execute或submit执行任务。

但魔鬼往往藏于细节之中,稍有不慎就会出错。本文将会详细总结线程池容易出错的五大坑


一、拒绝策略参数知多少
二、拒绝策略使用不当,系统阻塞不可用
三、多任务get()异常时,结果获取有误
四、ThreadLocal与线程池搭配使用,上下文缺失
五、父子任务共用同一线程池,系统“饥饿”死锁


以下为线程池的核心流程【具体内容参考:线程池原理
细数线程池五大坑,一不小心线上就崩了

一、拒绝策略参数知多少

我们都知道,当任务过多,线程池处理不过来时会被拒绝,进入拒绝策略

public interface RejectedExecutionHandler {
    void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}

通过实现RejectedExecutionHandler,就可以作为线程池的拒绝策略使用。
目前官方提供了四种拒绝策略,分别为:

  • CallerRunsPolicy:由任务调用方执行
  • AbortPolicy:抛出异常,同样也是由任务调用方处理异常
  • DiscardPolicy:丢弃当前任务
  • DiscardOldestPolicy:丢弃队列中最老的任务,并执行当前任务

线程池有execute和submit两种方法执行任务:
execute执行我们最原始的任务;
而submit则不同,先是将我们最原始的任务封装成FutureTask任务,然后将FutureTask任务交由execute执行

线程池拒绝策略中Runnable r就是execute执行的任务,因此当使用r时就要注意它是我们最原始的任务还是FutureTask任务


二、拒绝策略使用不当,系统阻塞不可用

前面我们讲到submit方法执行任务时,线程池会先封装任务到FutureTask中,然后我们通过FutureTask的get()方法获取任务处理的结果
【具体内容参考:一张动图,彻底懂了execute和submit

Possible state transitions:
NEW -> COMPLETING -> NORMAL(任务执行完成)
NEW ->COMPLETING -> EXCEPTIONAL(任务抛出异常)
NEW -> CANCELLED(任务被取消)
NEW -> INTERRUPTING -> INTERRUPTED(任务被打断)

FutureTask在被创建时状态为NEW,任务执行到某个阶段就会修改成相应状态,直到达到最终态。

FutureTask根据状态变更来标识任务执行进度的,因此get()方法也是在状态达到最终态(任务执行成果/异常/被取消/被打断)时才能返回结果,否则挂起当前线程等待到达最终态。

问题原因:
1、当任务通过submit方法执行时,会创建FutureTask(此时状态为NEW)
2、任务被拒绝且拒绝策略为丢弃任务(DiscardOleddestPolicy或DiscardPolicy)时,任务直接被线程池丢弃(此时状态仍为NEW)
3、当执行get()方法时,由于任务一直处于NEW状态,没有达到最终态,线程会一直处于阻塞状态

解决方案:
问题原因在于:任务无法变成最终态,导致阻塞。
因此我们可以重写rejectedExecution方法,将任务置为最终态
FutureTask的cancel方法可以将任务状态置为CANCELLED或INTERRUPTED

public static RejectedExecutionHandler customDiscardPolicy () {
  return new DiscardPolicy() {
     @Override
     public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
          if (!e.isShutdown()) {
              if (r != null && r instanceof FutureTask) {
                  ((FutureTask) r).cancel(true);
               }
           }
      }
  };
}

三、多任务get()异常时,结果获取有误

submit方法中,futureTask会捕获异常,在get()时抛出。
若批量执行多个方法,且for循环get()结果时,捕获异常要在循环内,而不是循环外。否则会影响其他任务的结果输出

捕获异常在循环外,当一个任务get异常时,后续其他任务就不能再获取结果

List<TaskResult> taskResultList = new ArrayList<>();
try {
    for (Future<TaskResult> future : futureList) {
        if (future == null) {continue;}
        TaskResult result = future.get();
        taskResultList.add(result);
    }
} catch (Throwable t) {
    //这种场景下,当一个任务get异常时,后续其他任务就不能再获取结果
    LOGGER.error("任务执行异常", t);
}

因此在循环内捕获异常,各个任务互相不受影响

List<TaskResult> taskResultList = new ArrayList<>();
for (Future<TaskResult> future : futureList) {
    try {
        if (future == null) {continue;}
        TaskResult result = future.get();
        taskResultList.add(result);
    } catch (Throwable t) {
        LOGGER.error("任务执行异常", t);
    }
}

四、ThreadLocal与线程池搭配使用,上下文缺失

ThreadLocal的使用一般都是这几个方法:

private final static ThreadLocal<CacheInfo> cacheInfoThreadLocal = new ThreadLocal<CacheInfo>();
​
cacheInfoThreadLocal.set(cacheInfo);
cacheInfoThreadLocal.get();
cacheInfoThreadLocal.remove();

为防止内存泄漏,在使用完ThreadLocal后都会调用remove()清除数据

问题描述:
1、当任务需要调用方线程的ThreadLocal信息时,通用方式就是将调用方ThreadLocal信息赋值到执行任务的线程中,在任务执行结束后调用remove()清除数据
2、同时任务恰好被线程池拒绝,且使用的拒绝策略是CallerRunsPolicy时,任务会被调用方线程执行。
3、若此时任务执行结束后仍调用remove()清除数据,清除的就会是调用方的ThreadLocal数据。
调用方ThreadLocal数据被清除,数据丢失在工作中将会是灾难性的。

解决方案:
问题出现的原因是任务由于被拒绝,导致误删除了调用方ThreadLocal数据
因此可以在任务执行时判断执行线程是否为调用方线程。
若是则不用set()复制和remove()清空数据

public abstract class ParallelCallableTask<V> implements Callable<V> {
    //调用方线程名称
    private String mainThreadName;
    
    public ParallelCallableTask() {
        mainThreadName = Thread.currentThread().getName();
    }
​
    @Override
    public V call() throws Exception {
        //是否为同一线程
        boolean sameThread = sameThread();
        return proccess(sameThread);
    }
​
    /**判断 调用方线程 和 执行线程 是否为同一线程*/
    private boolean sameThread () {
        String curThreadName = Thread.currentThread().getName();
        return curThreadName.equals(mainThreadName);
    }
    
    //任务重写这个方法并根据sameThread判断是否需要set和remove调用方线程的ThreadLocal数据
    public abstract V proccess(boolean sameThread);
}

待执行的任务通过重写process方法,并根据sameThread判断是否和主线程一致,一致则不重复设置相同的threadLocal和删除threadLocal


五、父子任务共用同一线程池,系统“饥饿”死锁

A方法调用B方法,AB方法称为父子任务。

当他们都被同一个线程池执行时,一定条件下会出现以下场景:
1、父任务获取到线程池线程执行,而子任务则被暂存到队列中
2、当父任务占满了线程池所有的线程,等待子任务返回结果后,结束父任务
3、此时子任务由于在队列中,一直不能等到线程来处理,导致不能从队列中释放
4、父子任务互相等待,从而造成“饥饿”死锁

我们举一个简单例子:

假设线程池参数设置为:核心和最大线程数为1,队列容量为1

A方法内调用B方法:
A() {
   B();
}

现在父子任务都被同一个线程池进行调用,整个流程为(如图所示):
细数线程池五大坑,一不小心线上就崩了

1、线程池创建核心线程,并执行A方法
2、执行到B方法时,将B交给线程池执行,由于没有多余线程,因此暂存队列
3、A任务等待B任务执行完,B任务等待A任务释放线程。从而互相等待,造成“饥饿”死锁

解决方案:

问题原因在于互相等待,因此只要保证类似的父子任务不要被同一线程池执行即可

------The End------

如果这个办法对您有用,或者您希望持续关注,也可以扫描下方二维码或者在微信公众号中搜索【码路无涯】

细数线程池五大坑,一不小心线上就崩了

上一篇:Java中的引用类型和使用场景


下一篇:【架构师面试-JUC并发编程-10】-ThreadLocal