后端精进笔记04:线程池原理

229 阅读9分钟

一、概述

1.1 为什么要使用线程池

一言以蔽之:节约服务器资源。线程资源多宝贵啊,当然是能复用就复用。

1.2 线程池的基本组成部分

  • 线程池管理器:用于创建与管理线程池,包括创建线程池、销毁线程池、添加任务
  • 工作线程:线程池中干活的线程,没有任务时处于等待状态,可以重复利用以完成任务。
  • 任务接口:每个提交到线程池到任务必须实现的接口,相当于一个规范,规定了任务的入口、执行完的收尾工作、任务的执行状态等。
  • 任务队列:用于存放提交到线程池等任务,起到一个缓冲作用,并不是所有的任务一提交到线程池就会立即执行的。

各部分的职能大致如下图所示:

52

1.3 线程池中任务的执行过程

新建线程池时,需要指定如下几个参数:

  • 工作线程数量大小:线程池空闲时持有的线程数量(线程池刚创建时数量为0)
  • 最大线程数量:就线程池最大持有的线程数量
  • 工作队列:用于存放提交到线程池等任务,不指定大小则默认为Integer.MAX_VALUE
  • (可选)拒绝执行策略:在任务队列已满、最大线程数量已满的情况下,线程池会直接拒绝执行后面提及过来的任务,这时,我们可以人为地处理一下这些被拒绝执行的任务(可以提示用户或记录日志等)

基于上述参数以及参数说明,线程池中的任务执行可以用下图来描述:

36

1.4 线程池API

1.4.1 相关类与接口

类型 名称 描述
接口 Executor 最顶级接口,定义了执行任务的execute方法
接口 ExecutorService 继承了Executor接口,扩展了Callable、Future接口,新增了关闭方法
接口 ScheduledExecutorService 继承了ExecutorService接口,新增定义了有关定时任务的方法
实现类 ThreadPoolExecutor 最基础、最常用的线程池实现
实现类 ScheduledThreadPoolExecutor 继承了ThreadPoolExecutor类,实现了ScheduledExecutorService相关方法

1.4.2 Executors工具类

在实际写代码的时候,我们可以自己使用上面说到的ThreadPoolExecutor、ScheduledThreadPoolExecutor来创建线程池,也可以使用Executors工厂类来新建如下几种特殊的线程池:

  • Executors.newFixedThreadPool(int nThreads):创建一个固定大小、工作队列无限大的线程池,核心线程数 = 最大线程数;
  • Executors.newCachedThreadPool():核心线程数量0,最大数量Integer.MAX_VALUE,任务队列是SynchronousQueue队列,超出核心线程数量的线程存活时间:60秒
  • Executors.newSingleThreadPool():创建一个只有一个线程、工作队列无限大的线程池,所有提交过来的任务都会由这唯一的一个线程依次执行。如果执行的过程中该线程异常退出,则会新建一个线程继续执行其他的任务。
  • Executors.newScheduledThreadPool(int nThreads):创建一个固定大小、工作队列无限大、能执行定时任务的线程池,任务队列是DelayedWorkQueue延时队列,超出核心线程数量的线程存活时间:0秒

1.4.3 中止线程池

  • 优雅关闭:threadPoolExecutor.shutdown();,已在任务队列中未完成的任务将会继续执行,但新的任务将无法追加。所有任务执行完毕后,线程池才会销毁。
  • 暴力关闭:List<Runnable> shutdownNow = threadPoolExecutor.shutdownNow();,返回的是已在任务队列中未完成的任务,这些任务将会被立即中止(导致异常),线程池会被立即销毁。

二、小试牛刀:多种场景下线程池的使用

2.1 以下示例(除了定时任务相关线程池)将共用如下基础代码(给指定线程池对象提交5个任务):

public void testCommon(ThreadPoolExecutor threadPoolExecutor) throws Exception {
        for (int i = 0; i < 5; i++) {
            int n = i;
            threadPoolExecutor.submit(new Runnable() {
                @Override
                public void run() {
                    try {
                        System.out.println("开始执行:" + n);
                        // 这里的sleep方法并不会阻塞submit方法
                        Thread.sleep(1000L);
                        System.err.println("执行结束:" + n);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            });
            System.out.println("任务提交成功 :" + i);
        }
        // 查看线程数量,查看队列等待数量
        Thread.sleep(100L);
        System.out.println("当前线程池中的线程数量为:" + threadPoolExecutor.getPoolSize());
        System.out.println("当前任务队列中的任务数量为:" + threadPoolExecutor.getQueue().size());
        System.err.println("-------------------");
        // 等待15秒,查看线程数量和队列数量(理论上,会被超出核心线程数量的线程自动销毁)
        Thread.sleep(6000L);
        System.out.println("当前线程池中的线程数量为:" + threadPoolExecutor.getPoolSize());
        System.out.println("当前任务队列中的任务数量为:" + threadPoolExecutor.getQueue().size());
    }

2.2 线程池中的核心线程数与最大线程数

// 预计结果:线程池线程数量为2,超出数量的任务,其他的进入队列中等待被执行
private void threadPoolExecutorTest() throws Exception {
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(2, 5, 10, TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>());
        testCommon(threadPoolExecutor);
    }

执行结果:

任务提交成功 :0
开始执行:0
开始执行:1
任务提交成功 :1
任务提交成功 :2
任务提交成功 :3
任务提交成功 :4
当前线程池中的线程数量为:2
当前任务队列中的任务数量为:3
-------------------
执行结束:0
执行结束:1
开始执行:2
开始执行:3
执行结束:2
执行结束:3
开始执行:4
执行结束:4
当前线程池中的线程数量为:2
当前任务队列中的任务数量为:0

2.3 处理线程池的拒绝策略

// 预计结果:
// 1、 1个任务直接分配线程开始执行
// 2、 1个任务进入等待队列
// 3、 队列不够用,临时加开1个线程来执行任务(5秒没活干就销毁)
// 4、 队列和线程池都满了,剩下2个任务,没资源了,被拒绝执行。
// 5、 任务执行,10秒后,如果无任务可执行,销毁临时创建的1个线程
private void threadPoolExecutorTest() throws Exception {
        // 创建一个 核心线程数量为1,最大数量为2,等待队列最大是1 的线程池,也就是最大容纳3个任务。
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(1, 2, 10, TimeUnit.SECONDS,
                new LinkedBlockingQueue<Runnable>(1), new RejectedExecutionHandler() {
            @Override
            public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
                System.err.println("有任务被拒绝执行了");
            }
        });
        testCommon(threadPoolExecutor);
    }

执行结果

开始执行:0
任务提交成功 :0
任务提交成功 :1
任务提交成功 :2
开始执行:2
任务提交成功 :3
任务提交成功 :4
有任务被拒绝执行了
有任务被拒绝执行了
当前线程池中的线程数量为:2
当前任务队列中的任务数量为:1
-------------------
执行结束:0
执行结束:2
开始执行:1
执行结束:1
当前线程池中的线程数量为:2
当前任务队列中的任务数量为:0

2.4 Executors.newFixedThreadPool(int nThreads)

// 预计结果:线程池线程数量为:5,超出数量的任务,其他的进入队列中等待被执行
private void threadPoolExecutorTest() throws Exception {
        // 和Executors.newFixedThreadPool(int nThreads)一样的
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(2, 5, 0L, TimeUnit.MILLISECONDS,
                new LinkedBlockingQueue<Runnable>());
        testCommon(threadPoolExecutor);
        
    }

执行结果(同2.2):

任务提交成功 :0
开始执行:0
任务提交成功 :1
开始执行:1
任务提交成功 :2
任务提交成功 :3
任务提交成功 :4
当前线程池中的线程数量为:2
当前任务队列中的任务数量为:3
-------------------
执行结束:0
执行结束:1
开始执行:2
开始执行:3
执行结束:2
执行结束:3
开始执行:4
执行结束:4
当前线程池中的线程数量为:2
当前任务队列中的任务数量为:0

2.5 Executors.newCachedThreadPool()

// 预计结果:
// 1、 线程池线程数量为:5,超出数量的任务,其他的进入队列中等待被执行
// 2、 所有任务执行结束,10秒后,如果无任务可执行,所有线程全部被销毁,池的大小恢复为0
private void threadPoolExecutorTest() throws Exception {
        // 和Executors.newCachedThreadPool()一样的
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 10L, TimeUnit.SECONDS,
                new SynchronousQueue<Runnable>());
        testCommon(threadPoolExecutor);
        
        Thread.sleep(10000L);
        System.out.println("10秒后,再看线程池中的数量:" + threadPoolExecutor.getPoolSize());
    }

执行结果:

开始执行:0
任务提交成功 :0
任务提交成功 :1
开始执行:1
任务提交成功 :2
开始执行:2
开始执行:3
任务提交成功 :3
开始执行:4
任务提交成功 :4
当前线程池中的线程数量为:5
当前任务队列中的任务数量为:0
-------------------
执行结束:4
执行结束:2
执行结束:3
执行结束:1
执行结束:0
当前线程池中的线程数量为:5
当前任务队列中的任务数量为:0
10秒后,再看线程池中的数量:0

2.6 Executors.newScheduledThreadPool(int nThreads)

2.6.1 基础使用

private void threadPoolExecutorTest() throws Exception {
        // 和Executors.newScheduledThreadPool()一样的
        ScheduledThreadPoolExecutor threadPoolExecutor = new ScheduledThreadPoolExecutor(5);
        threadPoolExecutor.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("任务被执行,现在时间:" + System.currentTimeMillis());
            }
        }, 3000, TimeUnit.MILLISECONDS);
        System.out.println(
                "定时任务,提交成功,时间是:" + System.currentTimeMillis() + ", 当前线程池中线程数量:" + threadPoolExecutor.getPoolSize());
        // 预计结果:任务在3秒后被执行一次
    }

执行结果:

定时任务,提交成功,时间是:1584029368832, 当前线程池中线程数量:1
任务被执行,现在时间:1584029371833

2.6.2 scheduleAtFixedRate

private void threadPoolExecutorTest() throws Exception {
        ScheduledThreadPoolExecutor threadPoolExecutor = new ScheduledThreadPoolExecutor(5);
        // 提交后,2秒后开始第一次执行,之后每间隔1秒,固定执行一次(如果发现上次执行还未完毕,则等待完毕,完毕后立刻执行)。也就是说这个代码中是,3秒钟执行一次(计算方式:每次执行三秒,间隔时间1秒,执行结束后马上开始下一次执行,无需等待)
        threadPoolExecutor.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(3000L);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("任务-1 被执行,现在时间:" + System.currentTimeMillis());
            }
        }, 2000, 1000, TimeUnit.MILLISECONDS);
}

2.6.3 scheduleAtFixedDelay

private void threadPoolExecutorTest() throws Exception {
        ScheduledThreadPoolExecutor threadPoolExecutor = new ScheduledThreadPoolExecutor(5);
        // 提交后,2秒后开始第一次执行,之后每间隔1秒,固定执行一次(如果发现上次执行还未完毕,则等待完毕,等上一次执行完毕后再开始计时,等待1秒);也就是说这个代码钟的效果看到的是:4秒执行一次。 (计算方式:每次执行3秒,间隔时间1秒,执行完以后再等待1秒,所以是 3+1)
        threadPoolExecutor.scheduleWithFixedDelay(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(3000L);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("任务-2 被执行,现在时间:" + System.currentTimeMillis());
            }
        }, 2000, 1000, TimeUnit.MILLISECONDS);
}

三、关于如何确定合适的线程数量

这里提供一种基础的思路,根据业务类型来确定:

  • 计算型任务(占用的是CPU资源):需要线程执行大量计算的,消耗的大部分资源是CPU资源,可以设定为CPU核心数量的1~2倍
  • IO型任务(占用的是硬盘、网络等资源):需要线程执行大量的读写等耗时操作而非大量的数值计算,则数值可以适当地放大一些,具体可以根据实际的IO阻塞时常来定(例如Tomcat的默认线程数是200)