线程池使用中要注意的问题

前言

又过去了好久,恩,最近看了很多东西,感觉知道的还是太少,有的时候觉得作为一名java开发,底层的原理是需要去了解透彻的,这也算职业素养了。所以,招聘3-5年来看也并非没有道理,是需要一定时间的使用经验和知识积累的。今年的大环境又不好,凌冬将至啊。

回归正题,其实这篇文章应该叫:我写了个bug。需求是:某个接口需要返回参数过多,反应时间慢,遂改为线程池异步调用。

1.问题与解决方法

首先放下我写的代码,然后说一下问题在哪,再粘一些线程池的原理。

有返回值的线程池使用方法(Executor + Futurn + Callable)

public class CallableUtill implements Callable<Object> { 
       public Map<String, Object> params; 
//自定义的callable类,用来封装各种类型的参数
       public CallableUtill(Map<String, Object> params) {
           this.params = params;
       } 

       @Override
       public Object call() throws Exception {
           return "";
       } 
}
import org.springframework.stereotype.Component; 
   import javax.annotation.PostConstruct;
   import java.util.Map;
   import java.util.concurrent.*; 

   @Component
   public class ExecUtill{ 
//工具类,全局线程池,项目启动即创建
       private ExecutorService exec; 
       
       public ExecutorService getExec(){
           return this.exec;
       } 

       @PostConstruct
       public void init(){

       //问题出在这里,使用了Executors的线程池创建方式
       this.exec = Executors.newFixedThreadPool(200); 
       //改为下方的创建方式即解决问题
       this.exec = new ThreadPoolExecutor(200, 200,

                   0L, TimeUnit.MILLISECONDS,

                   new LinkedBlockingQueue<Runnable>(100));
       } 
   }
//使用

    Map<String, Object> params8 = new HashMap<>();

    params8.put("userNum", userId);

    CallableUtill callable8 = new CallableUtill(params8) {

        @Override
        public Object call() throws Exception {
		 return queryManage(params);
         }
      };

      Future<Object> future8 = exec.submit(callable8); 
 //以上就是将线程submit 到线程池
 
 
 
//下面是使用get方法,获取结果
// 之所以把get方法放在一起 是因为这个获取结果的get方法是会阻塞的,不用等结果再开始下面的方法
   try {

   Map<String, Object> manageResult = (Map<String, Object>) future8.get(2, TimeUnit.SECONDS);

  manageFeeCount = (int) manageResult.get("manageFeeCount");

  sumManageFee = MapUtil.getBigDecimal(manageResult, "sumManageFee");

     } catch (Exception e) {

     logger.error("##getUserInfo##-----sumManageFee获取失败或异常");

  }

问题在于,newFixedThreadPool 创建的线程池阻塞队列LinkedBlockingQueue() 的默认长度是Integer.MaxValue,当并发上去,阻塞队列无限扩充,就会造成内存泄漏,进而内存溢出,导致gc

public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new                   LinkedBlockingQueue<Runnable>());
 }
public LinkedBlockingQueue() {
        this(Integer.MAX_VALUE);
    }

如何使用线程池提高kafka消费速度 线程池中线程卡住_线程池

这也就是为什么阿里的idea插件会提示,最好自定义创建线程池。

2.线程池的几种方式

在 java.util.concurrent 包中,提供了 ThreadPoolExecutor 的实现。

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

新任务进入线程池的执行策略

corePoolSize- 核心池大小,既然如前原理部分所述。需要注意的是在初创建线程池时线程不会立即启动,直到有任务提交才开始启动线程并逐渐时线程数目达到corePoolSize。若想一开始就创建所有核心线程需调用prestartAllCoreThreads方法。

maximumPoolSize-池中允许的最大线程数。需要注意的是当核心线程满且阻塞队列也满时才会判断当前线程数是否小于最大线程数,并决定是否创建新线程。

keepAliveTime - 当线程数大于核心时,多于的空闲线程最多存活时间

unit - keepAliveTime 参数的时间单位。

workQueue - 当线程数目超过核心线程数时用于保存任务的队列。主要有3种类型的BlockingQueue可供选择:无界队列,有界队列和同步移交。将在下文中详细阐述。从参数中可以看到,此队列仅保存实现Runnable接口的任务。 别看这个参数位置很靠后,但是真的很重要,因为楼主的坑就因这个参数而起,这些细节有必要仔细了解清楚。

threadFactory - 执行程序创建新线程时使用的工厂。

handler - 阻塞队列已满且线程数达到最大值时所采取的饱和策略。java默认提供了4种饱和策略的实现方式:中止、抛弃、抛弃最旧的、调用者运行。将在下文中详细阐述。

java 自带的 Executors.new的方法 有四种
1.newCachedThreadPool
public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
}

本地跑10000个线程,sleep5秒,cpu就99%了

同样的,fixedpool,会把线程全存queue里,虽然造成内存泄漏,但是cpu使用率没有激增,不过,问题也挺大的

在newCachedThreadPool中如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。 初看该构造函数时我有这样的疑惑:核心线程池为0,那按照前面所讲的线程池策略新任务来临时无法进入核心线程池,只能进入 SynchronousQueue中进行等待,而SynchronousQueue的大小为1,那岂不是第一个任务到达时只能等待在队列中,直到第二个任务到达发现无法进入队列才能创建第一个线程? 这个问题的答案在上面讲SynchronousQueue时其实已经给出了,要将一个元素放入SynchronousQueue中,必须有另一个线程正在等待接收这个元素。因此即便SynchronousQueue一开始为空且大小为1,第一个任务也无法放入其中,因为没有线程在等待从SynchronousQueue中取走元素。因此第一个任务到达时便会创建一个新线程执行该任务

由此可知,cached的线程数是无限的,所以一旦源源不断的线程过来,就会内存溢出。

适应场景:大量的短时间异步线程

2.newFixedThreadPool
public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
 }

LinkedBlockingQueue() 的默认构造方法 ,是Integer.MAX_VALUE ,无限长的队列。而且不会清理这个队列对象。线程数量固定,使用无限大的队列

3.newScheduledThreadPool
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
        return new ScheduledThreadPoolExecutor(corePoolSize);
}

在来看看ScheduledThreadPoolExecutor()的构造函数

public ScheduledThreadPoolExecutor(int corePoolSize) {
        super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
              new DelayedWorkQueue());
}

ScheduledThreadPoolExecutor的父类即ThreadPoolExecutor,因此这里各参数含义和上面一样。值得关心的是DelayedWorkQueue这个阻塞对列,在上面没有介绍,它作为静态内部类就在ScheduledThreadPoolExecutor中进行了实现。简单的说,DelayedWorkQueue是一个无界队列,它能按一定的顺序对工作队列中的元素进行排列。

适应场景:定时调用

4.newSingleThreadExecutor
public static ScheduledExecutorService newSingleThreadScheduledExecutor() {
        return new DelegatedScheduledExecutorService
            (new ScheduledThreadPoolExecutor(1));
 }

创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。

其实就是使用装饰模式增强了ScheduledExecutorService(1)的功能,不仅确保只有一个线程顺序执行任务,也保证线程意外终止后会重新创建一个线程继续执行任务。

可选择的阻塞队列BlockingQueue详解

在重复一下新任务进入时线程池的执行策略:
如果运行的线程少于corePoolSize,则 Executor始终首选添加新的线程,而不进行排队。(如果当前运行的线程小于corePoolSize,则任务根本不会存入queue中,而是直接运行)
如果运行的线程大于等于 corePoolSize,则 Executor始终首选将请求加入队列,而不添加新的线程。

如果无法将请求加入队列,则创建新的线程,除非创建此线程超出 maximumPoolSize,在这种情况下,任务将被拒绝。

主要有3种类型的BlockingQueue:

无界队列

队列大小无限制,常用的为无界的LinkedBlockingQueue,使用该队列做为阻塞队列时要尤其当心,当任务耗时较长时可能会导致大量新任务在队列中堆积最终导致OOM。阅读代码发现,Executors.newFixedThreadPool 采用就是 LinkedBlockingQueue,而楼主踩到的就是这个坑,当QPS很高,发送数据很大,大量的任务被添加到这个无界LinkedBlockingQueue 中,导致cpu和内存飙升服务器挂掉。

有界队列

常用的有两类,一类是遵循FIFO原则的队列如ArrayBlockingQueue与有界的LinkedBlockingQueue,另一类是优先级队列如PriorityBlockingQueue。PriorityBlockingQueue中的优先级由任务的Comparator决定。
使用有界队列时队列大小需和线程池大小互相配合,线程池较小有界队列较大时可减少内存消耗,降低cpu使用率和上下文切换,但是可能会限制系统吞吐量。

在我们的修复方案中,选择的就是这个类型的队列,虽然会有部分任务被丢失,但是我们线上是排序日志搜集任务,所以对部分对丢失是可以容忍的。

同步移交队列

如果不希望任务在队列中等待而是希望将任务直接移交给工作线程,可使用SynchronousQueue作为等待队列。SynchronousQueue不是一个真正的队列,而是一种线程之间移交的机制。要将一个元素放入SynchronousQueue中,必须有另一个线程正在等待接收这个元素。只有在使用无界线程池或者有饱和策略时才建议使用该队列。

可选择的饱和策略RejectedExecutionHandler详解

handler详解(自定义handler)

使用方法

ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 2, 0, 
        TimeUnit.MICROSECONDS, 
        new LinkedBlockingDeque<Runnable>(2), 
        new ThreadPoolExecutor.AbortPolicy());

1.AbortPolicy中止策略

public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            throw new RejectedExecutionException("Task " + r.toString() +
                                                 " rejected from " +
                                                 e.toString());
 }

该策略是默认饱和策略

使用该策略时在饱和时会抛出RejectedExecutionException(继承自RuntimeException),调用者可捕获该异常自行处理。

2.DiscardPolicy抛弃策略

public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
}

不做任何处理,直接抛弃任务

3.DiscardOldestPolicy抛弃旧任务策略

public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            if (!e.isShutdown()) {
                e.getQueue().poll();
                e.execute(r);
            }
}

如代码,先将阻塞队列中的头元素出队抛弃,再尝试提交任务。如果此时阻塞队列使用PriorityBlockingQueue优先级队列,将会导致优先级最高的任务被抛弃,因此不建议将该种策略配合优先级队列使用。

4.CallerRunsPolicy调用者运行

public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            if (!e.isShutdown()) {
                r.run();
            }
}

既不抛弃任务也不抛出异常,直接运行任务的run方法,换言之将任务回退给调用者来直接运行。使用该策略时线程池饱和后将由调用线程池的主线程自己来执行任务,因此在执行任务的这段时间里主线程无法再提交新任务,从而使线程池中工作线程有时间将正在处理的任务处理完成。

最后阐述下,新线程加入是的逻辑步骤:

我们的任务提交到线程池之后又是按照什么样的规则去运行呢?OK,它们遵循如下规则:

1.execute一个线程之后,如果线程池中的线程数未达到核心线程数,则会立马启用一个核心线程去执行

2.execute一个线程之后,如果线程池中的线程数已经达到核心线程数,且workQueue未满,则将新线程放入workQueue中等待执行

3.execute一个线程之后,如果线程池中的线程数已经达到核心线程数但未超过非核心线程数,且workQueue已满,则开启一个非核心线程来执行任务

4.execute一个线程之后,如果线程池中的线程数已经超过非核心线程数,则按照Hanlder策略做对应的方案,拒绝、交给调用线程处理等。

使用executors创建线程池有四个,single、fixed、catched、secheduled,这四种线程池都存在可能导致OOM问题,

single和fixed虽然限制了线程个数,但是允许线程等待的队列最大个数却是Integer.MAX_VALUE,可能会堆积大量的请求,导致OOM;

catched和secheduled方式,直接线程个数就是Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。