Java线程池详解

一、介绍

  线程我们可以使用 new 的方式去创建,但如果并发的线程很多,每个线程执行的时间又不长,这样频繁的创建线程会大大的降低系统处理的效率,因为创建和销毁进程都需要消耗资源,线程池就是用来解决类似问题。

  线程池实现了一个线程在执行完一段任务后,不销毁,继续执行下一段任务。用《Java并发编程艺术》提到线程池的优点:

  1、降低资源的消耗:使得线程可以重复使用,不需要在创建线程和销毁线程上浪费资源

  2、提高响应速度:任务到达时,线程可以不需要创建即可以执行

  3、线程的可管理性:线程是稀缺资源,如果无限制的创建会严重影响系统效率,线程池可以对线程进行管理、监控、调优。

二、Excutor框架

  Excutor框架是线程池处理线程的核心,包括创建任务,传递任务,任务的执行三个方面

1、创建任务

  执行的任务需要实现 Runnable 或者 Callable 接口,然后重写里面的 run 方法,这两个接口区别下面会写

2、传递任务

  以前执行线程都是直接创建线程然后调用 start() 方法去执行线程,现在我们需要把任务传递到线程池里面去,传递任务的核心接口就是 Excutor 接口,而它下面有几个实现的方法,如图所示:

 

 

  可以看到真正实现了功能的其实就是两个类 ThreadPoolExecutor 和 ScheduledTreadPollExecutor,而其中用的最多的就是这个 ThreadPoolExecutor ,下面会详细介绍,我们把任务通过这个方法的对象进行传递。

3、任务的执行及返回的结果

  任务传递给线程池执行完毕后,对于不同的传递方式,会有不同的返回策略,对于利用 excutor 方法传递的任务,不管执行的怎么样都不会有值传递回来,而对于 submit 方法传递的任务会返回一个 FutureTask 对象,返回用户希望接受的值。

 

Excutor 框架使用示意图如图:

 

 三、ThreadPoolExecutor 类介绍

  ThreadPoolExecutor 是线程池最为核心的一个类,而线程池为它提供了四个构造方法,我们先来看一下其中最原始的一个构造方法,其余三个都是由它衍生而来

 1  /**
 2      * 用给定的初始参数创建一个新的ThreadPoolExecutor。
 3      */
 4     public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
 5                               int maximumPoolSize,//线程池的最大线程数
 6                               long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
 7                               TimeUnit unit,//时间单位
 8                               BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
 9                               ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
10                               RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
11                                ) {
12         if (corePoolSize < 0 ||
13             maximumPoolSize <= 0 ||
14             maximumPoolSize < corePoolSize ||
15             keepAliveTime < 0)
16             throw new IllegalArgumentException();
17         if (workQueue == null || threadFactory == null || handler == null)
18             throw new NullPointerException();
19         this.corePoolSize = corePoolSize;
20         this.maximumPoolSize = maximumPoolSize;
21         this.workQueue = workQueue;
22         this.keepAliveTime = unit.toNanos(keepAliveTime);
23         this.threadFactory = threadFactory;
24         this.handler = handler;
25     }

  可以看到这里有6个参数,这些参数直接影响到线程池的效果,以下是具体分析每个参数的意义

1、corePoolSize:线程池最小创建线程的数目,默认情况下,线程池中是没有线程的,也就是当没有任务来临的时候,初始化的线程池容量为0,而最小创建线程的数目则是在有线程来临的时候,直接创建 corePoolSize 个线程

 

2、maximumPoolSize:线程池能创建的最大线程的数量,在核心线程都被占用的时候,继续申请的任务会被搁置在等待队列里面,而当等待队列满了的时候,线程池就会把线程数量创建至 maximumPoolSize 个。

 

3、workQueue:核心线程被占有时,任务被搁置在任务队列

 

4、keepAliveTime:当线程池中的线程数量大于 corePoolSize 时这个参数就会生效,即当大于 corePoolSize 的线程在经过 keepAliveTime 仍然没有任务执行,则销毁线程

 

5、unit :参数keepAliveTime的时间单位

 

6、ThreadFactory:线程工厂:主要用来创建线程,一般默认即可

 

7、handler:饱和策略,即当线程池和等待队列都达到最大负荷量时,下一个任务来临时采取的策略

 

 

饱和策略的介绍: 即如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,ThreadPoolTaskExecutor 定义一些策略:

 

  • ThreadPoolExecutor.AbortPolicy :抛出 RejectedExecutionException来拒绝新任务的处理。
  • ThreadPoolExecutor.CallerRunsPolicy :调用执行自己的线程运行任务,也就是直接在调用execute方法的线程中运行(run)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
  • ThreadPoolExecutor.DiscardPolicy :不处理新任务,直接丢弃掉。
  • ThreadPoolExecutor.DiscardOldestPolicy : 此策略将丢弃最早的未处理的任务请求。

四、实现一个简单的线程池

1、创建任务

class worker implements Runnable{
    @Override
    public void run(){
        System.out.println(Thread.currentThread().getName());
    }
    
}

2、创建线程池,并将任务传递进去

 

 1 public class Test {
 2     private static final int corePoolSize = 4;
 3     private static final int maximumPoolSize = 6;
 4     private static final long keepAliveTime = 2;
 5     private static final TimeUnit unit = TimeUnit.SECONDS;
 6     private static final int QueueSize = 5;//将参数设定为固定值
 7     public static void main(String[] args) {
 8         ThreadPoolExecutor executor = new ThreadPoolExecutor(
 9                 corePoolSize,
10                 maximumPoolSize,
11                 keepAliveTime,
12                 unit,
13                 new ArrayBlockingQueue<>(QueueSize),
14                 new ThreadPoolExecutor.CallerRunsPolicy()
15         );//构造线程池
16         for(int i = 0; i < 10; i++){
17             worker w = new worker();
18             executor.execute(w);
19         }//传入10个任务
20         executor.shutdown();//关闭线程池
21         while(!executor.isTerminated()){
22     }
23         System.out.println("finish");
24 }}

 

3、运行结果

 1 pool-1-thread-4
 2 pool-1-thread-2
 3 pool-1-thread-3
 4 pool-1-thread-1
 5 pool-1-thread-5
 6 pool-1-thread-1
 7 pool-1-thread-4
 8 pool-1-thread-5
 9 pool-1-thread-3
10 pool-1-thread-2
11 finish

  我们可以根据运行的结果就知道我传进去的 10 个任务是通过 5 个线程复用所得到。

五、线程池相关问题

1、线程池状态

在ThreadPoolExecutor中定义了一个volatile变量,另外定义了几个static final变量表示线程池的各个状态:

1 volatile int runState;
2 static final int RUNNING    = 0;
3 static final int SHUTDOWN   = 1;
4 static final int STOP       = 2;
5 static final int TERMINATED = 3;

  runState表示当前线程池的状态,它是一个volatile变量用来保证线程之间的可见性;

  下面的几个static final变量表示runState可能的几个取值。

  当创建线程池后,初始时,线程池处于RUNNING状态;

  如果调用了shutdown()方法,则线程池处于SHUTDOWN状态,此时线程池不能够接受新的任务,它会等待所有任务执行完毕;

  如果调用了shutdownNow()方法,则线程池处于STOP状态,此时线程池不能接受新的任务,并且会去尝试终止正在执行的任务;

  当线程池处于SHUTDOWN或STOP状态,并且所有工作线程已经销毁,任务缓存队列已经清空或执行结束后,线程池被设置为TERMINATED状态。

2、线程池处理任务的策略

  • 如果当前线程池中的线程数目小于corePoolSize,则每来一个任务,就会创建一个线程去执行这个任务;
  • 如果当前线程池中的线程数目>=corePoolSize,则每来一个任务,会尝试将其添加到任务缓存队列当中,若添加成功,则该任务会等待空闲线程将其取出去执行;若添加失败(一般来说是任务缓存队列已满),则会尝试创建新的线程去执行这个任务;
  • 如果当前线程池中的线程数目达到maximumPoolSize,则会采取任务拒绝策略进行处理;
  • 如果线程池中的线程数量大于 corePoolSize时,如果某线程空闲时间超过keepAliveTime,线程将被终止,直至线程池中的线程数目不大于corePoolSize;如果允许为核心池中的线程设置存活时间,那么核心池中的线程空闲时间超过keepAliveTime,线程也会被终止。

 3、Runnable 和 Callable的区别

   Runnable:

@FunctionalInterface
public interface Runnable {
   /**
    * 被线程执行,没有返回值也无法抛出异常
    */
    public abstract void run();//单纯的run
}

  Callable:

1 @FunctionalInterface
2 public interface Callable<V> {
3     /**
4      * 计算结果,或在无法这样做时抛出异常。
5      * @return 计算得出的结果
6      * @throws 如果无法计算结果,则抛出异常
7      */
8     V call() throws Exception;//无法计算则抛出异常
9 }

 

4、Excutor 和 Submit的区别

  Excutor用于提交不需要返回值的任务,线程在执行完后既不会返回结果,也不会抛出异常

 

  Submit则是用于提交需要返回值的任务,在线程执行完Submit提交的任务后,会返回一个 future 对象,这个 future 对象可以用来判断任务是否执行成功,并且可以用 future 的get()方法来获取其返回值,如:

 1 ExecutorService executorService = Executors.newFixedThreadPool(3);
 2 
 3 Future<String> submit = executorService.submit(() -> {
 4     try {
 5         Thread.sleep(5000L);
 6     } catch (InterruptedException e) {
 7         e.printStackTrace();
 8     }
 9     return "abc";//带有返回值
10 });
11 
12 String s = submit.get();//get返回值
13 System.out.println(s);
14 executorService.shutdown();

 

 六、几种线程池比较

1、FixThreadPool

  称为可重用固定线程池,其源码:

 

1    /**
2      * 创建一个可重用固定数量线程的线程池
3      */
4     public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
5         return new ThreadPoolExecutor(nThreads, nThreads,
6                                       0L, TimeUnit.MILLISECONDS,
7                                       new LinkedBlockingQueue<Runnable>(),
8                                       threadFactory);
9     }

  从源码可以看出,这个这个线程池的核心线程数 和 最大线程数量都设置为 nThreads ,这个参数是我们在创建线程池的时候传递的

  其运行示意图,来源自《Java并发编程的艺术》:

 

 

 

上图说明:

  1. 如果当前运行的线程数小于 corePoolSize, 如果再来新任务的话,就创建新的线程来执行任务;
  2. 当前运行的线程数等于 corePoolSize 后, 如果再来新任务的话,会将任务加入 LinkedBlockingQueue;
  3. 线程池中的线程执行完 手头的任务后,会在循环中反复从 LinkedBlockingQueue 中获取任务来执行;

 

 使用FixThreadPool的缺点

   在 FixThreadPool 中的等待队列是使用的 LinkedBlockingQueue,这是一个无界队列,最大的容量为 Integer.MAX_VALUE,可以无限制的接受任务,从而导致线程池的最大线程数参数生无效的,在核心线程全部被占有时,新来的任务就会被安置在无界队列中,当任务很多的时候,线程池没有拒绝任务的策略,就可能导致OOM(内存不足)

  1. 当线程池中的线程数达到核心线程数后,新任务将在无界队列中等待,因此线程池中的线程数不会超过 corePoolSize;
  2. 由于使用无界队列时 最大线程数 将是一个无效参数,因为不可能存在任务队列满的情况。所以,通过创建 的源码可以看出创建的 FixThreadPool 的 核心线程数最大线程数 被设置为同一个值。
  3. 运行中的 FixThreadPool(未执行 shutdown()或 shutdownow())不会拒绝任务,在任务比较多的时候会导致 OOM(内存溢出)。

 

2、SingleThreadExcutor

 1  /**
 2      *返回只有一个线程的线程池
 3      */
 4     public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
 5         return new FinalizableDelegatedExecutorService
 6             (new ThreadPoolExecutor(1, 1,
 7                                     0L, TimeUnit.MILLISECONDS,
 8                                     new LinkedBlockingQueue<Runnable>(),
 9                                     threadFactory));
10     }

  可以看到特点是只有一个核心线程

 

 

 

 SingleThreadExcutor的缺点

  同样的其等待队列也是用的 LinkedBlockingQueue 无界队列,同样也可能出现 OOM。

3、CachedThreadPool

  /**
     * 创建一个线程池,根据需要创建新线程,但会在先前构建的线程可用时重用它。
     */
    public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>(),
                                      threadFactory);
    }

  从源码可以看到,其核心线程数为0,但最大线程数目为 Integer.MAX_VALUE,即是无界,其只要来任务需要线程就会创建线程,极端情况会造成线程耗尽的情况

 

其执行过程是当有任务传递给线程池会先查看现有的线程是否够,如果不够,则会创建新的线程。 其缺点就是可能创建大量线程,导致OOM

 

七、线程池大小的设置

 

线程池数量的确定一直是困扰着程序员的一个难题,大部分程序员在设定线程池大小的时候就是随心而定。

 

很多人甚至可能都会觉得把线程池配置过大一点比较好!我觉得这明显是有问题的。就拿我们生活中非常常见的一例子来说:并不是人多就能把事情做好,增加了沟通交流成本。你本来一件事情只需要 3 个人做,你硬是拉来了 6 个人,会提升做事效率嘛?我想并不会。 线程数量过多的影响也是和我们分配多少人做事情一样,对于多线程这个场景来说主要是增加了上下文切换成本。不清楚什么是上下文切换的话,以看我下面的介绍。

 

上下文切换:

多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。

 

类比于实现世界中的人类通过合作做某件事情,我们可以肯定的一点是线程池大小设置过大或者过小都会有问题,合适的才是最好。

如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的! CPU 根本没有得到充分利用。

但是,如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。

 

 

 

 

 

 

  

posted @ 2022-03-06 20:07  空心小木头  阅读(7373)  评论(0编辑  收藏  举报