java并发-线程池(一)

线程池是一种用于管理和复用一组工作线程的设计模式,旨在减少因频繁创建和销毁线程带来的系统开销,并提供更有效的资源管理。在Java中,线程池是通过java.util.concurrent.Executors类提供的多种工厂方法或直接使用ThreadPoolExecutor类来实现的。

在JDK5之前,java并没有提供内置的线程池支持和高级并发工具。这意味着开发者需要自己实现线程池机制来管理和复用线程,这不仅复杂而且容易出错。从JDK5开始,java引入了线程池的实现和管理工具,把工作单元和执行机制分离,工作单元包括Runnable和Callable,而执行机制由Executor框架提供。

线程池的实现原理

java线程池的实现原理可以简单理解为一个线程集合workSet和一个阻塞队列workQueue。

当有任务提交到线程池时,线程池会先将任务存入workQueue中,workSet里的线程会不断从workQueue中取出任务执行。当workQueue中没有任务时,线程池中的线程就会阻塞,直到队列中有任务就会继续取出执行。

线程池的生命周期

线程池的生命周期管理是通过其内部状态机来实现的,这些状态反映了线程池当前的操作能力和可用性。在Java中,ThreadPoolExecutor类使用了几个关键的状态来表示线程池在其生命周期中的不同阶段。

RUNNING :这是线程池的初始状态。在此状态下,线程池可以接受新的任务,并且会处理已经在队列中等待的任务。当新创建一个线程池时,它会处于RUNNING状态。

SHUTDOWN :调用shutdown()方法后,线程池会进入此状态。在SHUTDOWN状态下,线程池不会再接受新的任务,但已经开始执行的任务会继续执行下去。

STOP :调用shutdownNow()方法,线程池将转换到STOP状态。在此状态下,线程池不仅停止接受新任务,还会尝试停止当前正在执行的所有任务,并清空workQueue中的所有任务。相比SHUTDOWN,这是一个更加强制性的关闭。

TIDYING :当所有的任务都已经终止,工作的线程数为0,并且线程池正在从SHUTDOWNSTOP转向最终状态之前,线程池会进入TIDYING状态。在该状态下,terminated()钩子方法会被调用,允许开发者执行一些清理操作。

TERMINATED :在terminated()方法执行完毕之后,线程池就会进入TERMINATED状态,这意味着线程池的生命周期正式结束。

Execute原理

在文章开头说过,JDK5开始 Java 引入了线程池的实现和管理工具,并且把工作单元和执行机制分离。而Executor就是实现分离功能的一个核心接口。

execute(Runnable command)Executor接口中定义的唯一抽象方法,它在 Java 并发编程中扮演着至关重要的角色。这个方法的主要作用是提供一种机制来执行提交的任务(即实现了 Runnable 接口的对象),而不需要手动管理线程的创建、启动和管理等复杂细节。

execute的具体实现原理是 当一个任务提交到线程池之后,首先,线程池会判断当前运行的线程数量是否少于corePoolSize。如果是,则创建一个新的工作线程来执行任务。如果当前运行的线程数量等于corePoolSize,则判断BlockingQueue(任务队列)是否已满。如果没满,将任务存入队列中。如果满了,则继续创建新的线程来执行队列中的任务,当线程总数量超过maximumPoolSize,则将任务交给RejectedExecutionHandler处理。

ThreadPoolExecutor创建新线程时,通过CAS来更新线程池的状态。

核心参数

上面提到的corePoolSizemaximumPoolSize属于线程池创建的参数,这些参数决定了线程池创建时的具体实现。

csharp 复制代码
public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          RejectedExecutionHandler handler) {
    this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
         Executors.defaultThreadFactory(), handler);
}

ThreadPoolSize()方法是ThreadPoolSize类的构造方法。方法内的参数说明了创造线程池的策略。

  • corePoolSize线程池里的核心线程数。当提交一个任务时,线程池会将当前正在运行的线程数量和corePoolSize作对比。如果小于核心线程数,则创建新的线程执行任务;如果等于核心线程数,后续提交的任务会被存入阻塞队列中,等待被执行。需要注意的是 如果执行了prestartAllCoreThreads()方法,那么线程池会提前创建指定核心线程数的线程并启动他们。
  • maximumPoolSize线程池允许的最大线程数量。当阻塞队列满了,还有任务提交进来,那么就会对比当前正在运行的线程数量和允许的最大线程数量。如果小于最大线程数量,则创建新的线程执行任务。特殊的是,当任务队列是无界队列时,即队列中可以无限存放提交的任务,maximumPoolSize参数就会失效。
  • keepAliveTime线程空闲时的存活时间。当线程没有任务执行时,该线程被允许继续存活的时间。这种情况只有当正在运行的线程数量大于corePoolSize时才会生效,超过了这个时间的空闲线程会被终止。
  • unit存活时间的单位
  • workQueue保存等待被执行的任务的阻塞队列。JDK提供了多种阻塞队列,想了解详情的可以去JDK官方手册查阅。
  • threadFactory创建线程的工厂。这个参数在构造函数中并没有写出,所以这个参数是一个可选的参数。如果选择添加,开发者可以通过自定义的工厂给每个新建的工厂设置一个有标识度的线程名。
  • handler线程的饱和策略。当无法再创建新线程来执行任务,而阻塞队列已经满了的情况下,如果继续提交任务,则需要选择一种策略来处理该任务。线程池提供了4种策略:
    • AbortPolicy直接抛出异常,默认;
    • CallerRunsPolicy用发出任务的线程来执行任务;
    • DiscardOldestPolicy丢弃阻塞队列最前面的任务,并执行当前任务;
    • DiscardPolicy直接丢弃任务;

除了上述线程池提供的策略,开发者也可以自己根据实际应用场景实现自定义的饱和策略,只需要实现RejectedExecutionHandler接口即可。

线程池配置应用

正所谓实践是检验真理的唯一标准,说了这么多理论知识,不实践一下永远也不知道这些知识是对是错。以下是一个创建线程池的例子

import 复制代码
public class ThreadPoolConfig {

    public static void main(String[] args) {
        // 自定义线程池配置
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                2,                         // 核心线程数 (corePoolSize)
                4,                         // 最大线程数 (maximumPoolSize)
                60,                        // 空闲线程存活时间 (keepAliveTime)
                TimeUnit.SECONDS,         // 时间单位
                new LinkedBlockingQueue<>(10), // 任务队列 (容量为10)
                Executors.defaultThreadFactory(), // 线程工厂
                new ThreadPoolExecutor.AbortPolicy() // 拒绝策略
        );

        // 提交任务
        for (int i = 0; i < 15; i++) {
            try {
                int taskId = i;
                executor.submit(() -> {
                    System.out.println("任务 " + taskId + " 正在执行,线程:" + Thread.currentThread().getName());
                    try {
                        Thread.sleep(1000); // 模拟任务执行
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                });
            } catch (RejectedExecutionException e) {
                System.err.println("任务 " + i + " 被拒绝,队列已满!");
            }
        }

        // 关闭线程池
        executor.shutdown();
        try {
            // 等待所有任务完成
            if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
                executor.shutdownNow(); // 强制关闭
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();
        }
    }
}

上面这个例子中,首先使用ThreadPoolExecutor创建了一个线程池。关于为何不用Executors创建线程池后面会讲。

可以看到,代码中规定了核心线程数为2,最大线程数为4,线程空闲时间规定为60秒,队列容量为10,饱和策略为默认的抛出异常。在创建完线程池后,通过一个for循环模拟 15 个任务,并在每个任务里让工作线程睡眠1秒模拟任务的执行。

运行后得到结果

arduino 复制代码
任务 14 被拒绝,队列已满!
任务 12 正在执行,线程:pool-1-thread-3
任务 1 正在执行,线程:pool-1-thread-2
任务 0 正在执行,线程:pool-1-thread-1
任务 13 正在执行,线程:pool-1-thread-4
任务 2 正在执行,线程:pool-1-thread-4
任务 3 正在执行,线程:pool-1-thread-2
任务 4 正在执行,线程:pool-1-thread-3
任务 5 正在执行,线程:pool-1-thread-1
任务 6 正在执行,线程:pool-1-thread-2
任务 7 正在执行,线程:pool-1-thread-1
任务 8 正在执行,线程:pool-1-thread-3
任务 9 正在执行,线程:pool-1-thread-4
任务 10 正在执行,线程:pool-1-thread-3
任务 11 正在执行,线程:pool-1-thread-1

可以看到线程最大是pool-1-thread4,这是因为在创建线程池时设置的最大线程数量为4,这意味着线程池中线程的数量最大只能是4个。同时,得到的结果中任务14被拒绝了,这是为什么?让我们一步步来分析。

根据execute的工作原理我们可以知道。当有任务提交到线程池后,线程池首先会对比正在工作的线程数量是否达到corePoolSize。如果没有达到核心线程数量,那么线程池就会创建新的线程来执行任务。在初始化时我们指定核心线程数为2,所以线程池会先创建两个线程来执行任务0,1。由于我们模拟线程执行任务时间为1秒,这个时间对于线程来说已经很长了,所以在任务0和1还没执行完成的时候,其余的任务已经提交到线程池了。

在上面讲解原理的时候我们已经知道了,当工作的线程数量已经等于corePoolSize时,后续提交到线程池的任务会被存入阻塞队列里面。我们设置了阻塞队列为有界队列,且容量为10,即这个队列一共可以存入10个任务。因此,后续2到11这十个任务会被存入阻塞队列中等待被执行。这时,阻塞队列已经满了。可是,还有12到14这3个任务需要处理,这就用到maximumPoolSize参数了。

当阻塞队列满了却还有任务提交进来,那么,在线程数量不超过maximumPoolSize的前提下,线程池会创建新的线程来执行提交的任务。由于我们已经设置了maximumPoolSize为4,而核心线程为2,所以,线程池最多还可以创建两个线程来处理任务。这样分析下来就很清楚了,12和13这两个任务被线程池接纳了。但是,现在还有一个任务啊。由于任务提交速度是很快的,前面的线程都在执行模拟的1秒钟任务,没有线程是空闲的,这时任务14提交进来队列是不能存放的,而线程也没有空闲的可以来执行它。那么,任务14只有一种结果了,那就是执行初始化时已经确定下来饱和策略了。

我们知道线程池提供了4种可选的策略:AbortPolicyCallerRunsPolicyDiscardOldestPolicyDiscardPolicy。我们选择的是AbortPolicy策略,这个策略的处理是直接抛出异常。而我们也在代码中使用了try catch来捕获异常,这也就能解释为什么任务14会被拒绝了。同时也能解释,任务12和13能在如此靠前的位置被执行了。

我们来验证一下,看看任务14是不是如所说的那样被RejectedExecutionHandler策略处理了。 在原有代码的基础上,将ThreadPoolExecutor.AbortPolicy()改为ThreadPoolExecutor.CallerRunsPolicy()。看看运行结果

arduino 复制代码
任务 12 正在执行,线程:pool-1-thread-3
任务 14 正在执行,线程:main
任务 1 正在执行,线程:pool-1-thread-2
任务 0 正在执行,线程:pool-1-thread-1
任务 13 正在执行,线程:pool-1-thread-4
任务 2 正在执行,线程:pool-1-thread-2
任务 3 正在执行,线程:pool-1-thread-3
任务 4 正在执行,线程:pool-1-thread-1
任务 5 正在执行,线程:pool-1-thread-4
任务 6 正在执行,线程:pool-1-thread-3
任务 8 正在执行,线程:pool-1-thread-4
任务 9 正在执行,线程:pool-1-thread-1
任务 7 正在执行,线程:pool-1-thread-2
任务 10 正在执行,线程:pool-1-thread-1
任务 11 正在执行,线程:pool-1-thread-4

可以看到,任务14被执行了,而且线程是main。这是因为CallerRunsPolicy策略的处理是让调用者的线程来处理任务。由于我们是通过main来运行这个代码的,也就是说这15个调用线程池的调用者全都是main,所以任务14当然是交给main自己来执行了。

Executor的潜在风险

前面我们创建线程池时使用的是ThreadPoolExecutor,那为什么不直接用Executor而是选择使用子类ThreadPoolExecutor呢。

Executor工具类提供了几种方法来创建线程池,但是这些方法都或多或少存在一些问题,可能会导致资源的不必要浪费。

newFixedThreadPool

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

可以看到newFixedThreadPool()方法创建的线程池corePoolSizemaximumPoolSize是一样的,这就导致了线程的数量永远不会超过corePoolSize。由于线程数量被固定,所以即使线程池中的线程处于空闲状态,它们也不会被释放,这就导致了资源的浪费。同时,maximumPoolSizekeepAliveTime这两个参数失效了。不仅如此,newFixedThreadPool()还使用了LinkedBlockingQueue,这是一个无界队列。这意味着任务可以几乎无限的存入队列(Integer.MAX_VALUE)。由于队列可以无限存入任务,这也导致了RejectedExecutionHandler饱和策略失效了。而且,当任务提交速度一直高于线程池的执行速度,队列就会不断增长,这可能会导致内存耗尽并引发OutOfMemoryError

newSingleThreadExecutor()同样也是使用无界队列,所以也可能会消耗大量内存

newCachedThreadPool

csharp 复制代码
public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}

这个方法的maximumPoolSizeInteger.MAX_VALUE,这意味着线程池可能会创建过多的线程,导致资源浪费。

newScheduledThreadPool()同样使用Integer.MAX_VALUE会导致同样的问题。

为了避免资源浪费等问题,建议在创建线程池时使用ThreadPoolExecutorScheduledThreadPoolExecutor。二者主要区别在于后者支持定时任务或周期性任务。

线程池配置建议

在配置线程池参数时,需要从任务的执行时间,任务的优先级,任务的性质等各方面考虑。对于不同性质的任务,可以使用不同规模的线程池来处理。

IO密集型

任务主要消耗IO资源,如文件的读写,网络的请求等。如果是IO密集型,建议将核心线程数设置为CPU核心数的2倍,最大线程数量设置为较大值(CPU核心数*5),队列使用无界队列,空闲时间设置为较长时间。 应对可能出现的任务堆积和线程的频繁唤醒与阻塞。

CPU密集型

任务主要消耗CPU资源,如计算,数据处理等。CPU密集型的参数配置建议将核心线程数设置为CPU核心+1,最大线程数量和核心线程数量相同,使用有界队列,设置较短的空闲时间。避免多线程竞争CPU资源,任务的堆积,资源的浪费。

参考文献

JUC线程池: ThreadPoolExecutor详解 | Java 全栈知识体系)

相关推荐
大鹏dapeng5 分钟前
Gone v2 goner/gin——试试用依赖注入的方式打开gin-gonic/gin
后端·go
tan180°36 分钟前
版本控制器Git(1)
c++·git·后端
GoGeekBaird37 分钟前
69天探索操作系统-第50天:虚拟内存管理系统
后端·操作系统
_丿丨丨_41 分钟前
Django下防御Race Condition
网络·后端·python·django
JohnYan1 小时前
工作笔记 - btop安装和使用
后端·操作系统
我愿山河人间1 小时前
Dockerfile 和 Docker Compose:容器化世界的两大神器
后端
掘金码甲哥1 小时前
golang倒腾一款简配的具有请求排队功能的并发受限服务器
后端
重庆穿山甲1 小时前
装饰器模式实战指南:动态增强Java对象的能力
后端
卑微小文1 小时前
企业级IP代理安全防护:数据泄露风险的5个关键防御点
前端·后端·算法
lovebugs1 小时前
如何保证Redis与MySQL双写一致性?分布式场景下的终极解决方案
后端·面试