Java 线程池

111 阅读7分钟

什么是线程池?

顾名思义,就是一个存放线程的池子,这个池子可以限制和管理线程资源。

线程池的好处

在工作开发过程中,线程池是经常用到的并发框架,那么合理地使用线程池可以大大提高执行速度,给我们带来以下好处:

  • 降低资源消耗。通过重复利用已创建的线程资源降低线程创建和销毁造成消耗。
  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。

线程池的使用

我们可以通过 ThreadPoolExecutor 创建线程池,创建线程池需要的一些参数:

三个重要的参数:

  1. corePoolSize(线程池的基本大小):当提交一个任务到达线程池时,线程池会创建一个线程来执行任务,即使其他空闲的线程能够执行新任务也会创建线程,等到需要执行的任务数大于线程池的基本大小时就不再创建。
  2. maximumPoolSize(线程池的最大数量):线程池允许创建的最大线程数。如果队列满了,并且已创建的线程数小于最大线程数,线程池会再创建新的线程执行任务。如果使用了无界队列这个参数是没有任何效果的,因为任务会一直添加进队列里。
  3. workQueue(任务队列):用来保存等待执行任务的阻塞队列。

其他参数:

  1. ThreadFactory(创建线程的工厂):最好给每个线程设置一个有意义的名字。
  2. keepAliveTime(线程存活的时间):当我们的线程池的数量大于 corePoolSize 时,此时没有新的任务提交,核心线程外的其他线程不会立即销毁,会等到时间超过了 keepAliveTime 才会被回收。
  3. RejectedExecutionHandler(饱和策略):当队列和线程池都满了,线程池处于饱和状态,那么必须采取一种策略去处理提交的新任务。
  4. TimeUnit(线程活动保持时间的单位):DAYS、HOURS、MINUTES、MILLISECONDS、MICROSECONDS、NANOSECONDS。

向线程池提交任务

我们可以使用两个方法向线程池提交任务,execute() 和 submit() 方法。

  • execute 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行完成。
  • submit 方法用于提交需要返回值的任务。线程池会返回一个 future 对象,通过这个对象我们可以判断任务是否执行完成。

ThreadPoolExecutor的运行状态

  • RUNNING:能够接受提交的任务,并且能够处理阻塞队列中的任务。
  • SHUTDOWN:不再接受新提交的任务,但是可以继续处理阻塞队列中的已保存的任务。
  • STOP:不再接受新提交的任务,也不处理阻塞队列中的任务,会中断处理任务的线程。
  • TIDYING:所有的任务都终止了,workerCount(有效线程数)为0。
  • TERMINATED:在 terminated() 方法执行之后进入该状态。

关闭线程池

我们可以通过调用线程池的 shutdown 或 shutdownNow 方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的 interrupt 方法来中断线程,所以无法响应中断的任务可能永远无法终止。但是它们是有区别的,shuwdownNow 方法首先将线程池的状态设置成 STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表,而 shutdown 只是将线程池的状态设置成 SHUTDOWN 状态,然后中断所有没有正在执行状态的线程。只要调用了这两个关闭方法中的任意一个,isShutdown 方法就会返回 true。当所有任务都已关闭后,才表示线程池关闭成功,这时调用 isTerminaed 方法会返回 true。

线程池的实现原理

当向线程池提交一个任务之后,处理流程如图所示:

  1. 如果当前运行的线程小于 corePoolSize(核心线程池数),则创建新线程来执行任务(需要获取全局锁)。
  2. 如果运行的线程等于或大于 corePoolSize,则将任务加入到BlockingQueue(阻塞队列)中。
  3. 如果 BlockingQueue 满了的话,则创建新线程来执行任务(需要获取全局锁)。
  4. 如果创建新线程将使当前运行的线程大于 maximumPoolSize(最大线程数),那么该任务将被拒绝,并调用RejectedExecutionHandler.rejectedExecution() 方法。

合理分配线程池

线程池大小不是设置的过大或者过小都不好,如果设置的太小的话,可能同一时间有很多任务提交,这样导致大量任务堆积在阻塞队列中,甚至出现队列满了无法处理任务的情况,如果设置的太大,大量线程争夺 CPU 资源导致频繁的上下文切换,影响效率,所以线程池的大小要合理才是最好,合理分配线程池要先分析任务的特性,我们可以从这几个方向去分析:

  • 任务的性质:CPU 密集型任务、IO密集型任务和混合型任务。
  • 任务的优先级
  • 任务的执行时间
  • 任务的依赖性:是否依赖其他系统资源,如数据库连接。

CPU 密集型任务应配置尽可能小的线程,如配置 N(CPU 核心数)+ 1。因为 IO 密集型任务的线程并不是一直在执行任务的,所以我们可以尽可能配置多的线程,当线程在处理 IO 的时候,可以把 CPU 让给其他线程使用,如配置 2 * N(CPU 核心数)。这只是一个简单的公式,IO密集型和CPU密集型的任务运行起来的情况差异非常大,实际应用中还是要根据实际情况去调整这些参数。

CPU 密集型可以简单理解为利用 CPU 的计算能力去完成一些计算,如视频编码、代码数学计算。涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待 IO 操作完成。

优先级不同的任务可以使用优先级队列 PriorityBlockingQueue 来处理。

执行时间不同的任务可以交给不同规模的线程池来处理,也可以使用优先级队列,时间短的任务先执行。

依赖数据库连接池的任务,因为线程提交 SQL 后需要等待数据库返回结果,等待的时间越长,CPU 空闲的时间也越长,那么线程数可以设置大一点,更好地利用 CPU。

线程池的监控

在系统中如果大量使用到了线程池,非常有必要对线程池进行监控,这样方便我们在出现问题时,可以快速根据线程池的情况定位问题并解决。

动手实现案例

public class ThreadPoolExecutorDemo {

    private static final int CORE_POOL_SIZE = 5;
    private static final int MAX_POOL_SIZE = 10;
    private static final int QUEUE_CAPACITY = 100;
    private static final Long KEEP_ALIVE_TIME = 1L;

    public static void main(String[] args) {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
            CORE_POOL_SIZE,
            MAX_POOL_SIZE,
            KEEP_ALIVE_TIME,
            TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(QUEUE_CAPACITY),
            new ThreadPoolExecutor.AbortPolicy());

        for (int i = 0; i < 5; i++) {
            executor.execute(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName() + " StartTime = " + new Date());
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + " EndTime = " + new Date());
                }
            });
        }
        // 线程池需要执行的任务数量
        System.out.println("线程池需要执行的任务数量:" + executor.getTaskCount());
        // 线程池里曾经创建过的最大线程数量
        System.out.println("线程池里曾经创建过的最大线程数量:" + executor.getLargestPoolSize());
        // 获取活动的线程数
        System.out.println("获取活动的线程数:" + executor.getActiveCount());
        // 线程池在运行过程中已完成的任务数量
        System.out.println("线程池在运行过程中已完成的任务数量:" + executor.getCompletedTaskCount());

        try {
            Thread.sleep(1500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("线程池在运行过程中已完成的任务数量:" + executor.getCompletedTaskCount());
        // 终止线程池
        executor.shutdown();
        while (!executor.isTerminated()) {
            System.out.println("isTerminated");
        }
    }
}

参考资料

《Java 并发编程的技术》

Java 线程池最佳实践

Java线程池实现原理及其在美团业务中的实践

推荐阅读

《Java 并发编程的技术》

Java 线程池最佳实践

Java线程池实现原理及其在美团业务中的实践

如何设置线程池参数?美团给出了一个让面试官虎躯一震的回答。