Loading

[Java并发]线程池

线程池参数

ThreadPoolExecutor tpe = new ThreadPoolExecutor(
    10,
    20,
    1L,
    TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(100),
    new ThreadPoolExecutor.CallerRunsPolicy());

线程池参数的含义

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

这 7 个参数分别是:

  • corePoolSize:核心线程数。
  • maximumPoolSize:最大线程数。
  • keepAliveTime:空闲线程存活时间。
  • TimeUnit:时间单位。
  • BlockingQueue:线程池任务队列。
  • ThreadFactory:创建线程的工厂。
  • RejectedExecutionHandler:拒绝策略。

corePoolSize

核心线程数:是指线程池中长期存活的线程数。
这就好比古代大户人家,会长期雇佣一些“长工”来给他们干活,这些人一般比较稳定,无论这一年的活多活少,这些人都不会被辞退,都是长期生活在大户人家的。

maximumPoolSize

最大线程数:线程池允许创建的最大线程数量,当线程池的任务队列满了之后,可以创建的最大线程数。
这是古代大户人家最多可以雇佣的人数,比如某个节日或大户人家有人过寿时,因为活太多,仅靠“长工”是完不成任务,这时就会再招聘一些“短工”一起来干活,这个最大线程数就是“长工”+“短工”的总人数,也就是招聘的人数不能超过 maximumPoolSize。

最大线程数 maximumPoolSize 的值不能小于核心线程数 corePoolSize,否则在程序运行时会报 IllegalArgumentException 非法参数异常

keepAliveTime

空闲线程存活时间,当线程池中没有任务时,会销毁一些线程,销毁的线程数=maximumPoolSize(最大线程数)-corePoolSize(核心线程数)。
还是以大户人家为例,当大户人家比较忙的时候就会雇佣一些“短工”来干活,但等干完活之后,不忙了,就会将这些“短工”辞退掉,而 keepAliveTime 就是用来描述没活之后,短工可以在大户人家待的(最长)时间。

TimeUnit

时间单位:空闲线程存活时间的描述单位,此参数是配合参数 3 使用的。
参数 3 是一个 long 类型的值,比如参数 3 传递的是 1,那么这个 1 表示的是 1 天?还是 1 小时?还是 1 秒钟?是由参数 4 说了算的。
TimeUnit 有以下 7 个值:

  • TimeUnit.DAYS:天
  • TimeUnit.HOURS:小时
  • TimeUnit.MINUTES:分
  • TimeUnit.SECONDS:秒
  • TimeUnit.MILLISECONDS:毫秒
  • TimeUnit.MICROSECONDS:微妙
  • TimeUnit.NANOSECONDS:纳秒

BlockingQueue

阻塞队列:线程池存放任务的队列,用来存储线程池的所有待执行任务。
它可以设置以下几个值:

  • ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列。
  • LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列。
  • SynchronousQueue:一个不存储元素的阻塞队列,即直接提交给线程不保持它们。
  • PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。
  • DelayQueue:一个使用优先级队列实现的无界阻塞队列,只有在延迟期满时才能从中提取元素。
  • LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。与SynchronousQueue类似,还含有非阻塞方法。
  • LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。
    比较常用的是 LinkedBlockingQueue,线程池的排队策略和 BlockingQueue 息息相关。

ThreadFactory

线程工厂:线程池创建线程时调用的工厂方法,通过此方法可以设置线程的优先级、线程命名规则以及线程类型(用户线程还是守护线程)等。
线程工厂的使用示例如下:

public static void main(String[] args) {
    // 创建线程工厂
    ThreadFactory threadFactory = new ThreadFactory() {
        @Override
        public Thread newThread(Runnable r) {
            // 创建线程池中的线程
            Thread thread = new Thread(r);
            // 设置线程名称
            thread.setName("Thread-" + r.hashCode());
            // 设置线程优先级(最大值:10)
            thread.setPriority(Thread.MAX_PRIORITY);
            //......
            return thread;
        }
    };
    // 创建线程池
    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(10, 10, 0,
                                                                   TimeUnit.SECONDS, new LinkedBlockingQueue<>(),
                                                                   threadFactory); // 使用自定义的线程工厂
    threadPoolExecutor.submit(new Runnable() {
        @Override
        public void run() {
            Thread thread = Thread.currentThread();
            System.out.println(String.format("线程:%s,线程优先级:%d",
                                             thread.getName(), thread.getPriority()));
        }
    });
}

RejectedExecutionHandler

拒绝策略:当线程池的任务超出线程池队列可以存储的最大值之后,执行的策略。
默认的拒绝策略有以下 4 种:

AbortPolicy:拒绝并抛出异常。
CallerRunsPolicy:使用当前调用的线程来执行此任务。
DiscardOldestPolicy:抛弃队列头部(最旧)的一个任务,并执行当前任务。
DiscardPolicy:忽略并抛弃当前任务。

线程池的默认策略是 AbortPolicy 拒绝并抛出异常。

总结

  • corePoolSize:核心线程数,线程池正常情况下保持的线程数,大户人家“长工”的数量。
  • maximumPoolSize:最大线程数,当线程池繁忙时最多可以拥有的线程数,大户人家“长工”+“短工”的总数量。
  • keepAliveTime:空闲线程存活时间,没有活之后“短工”可以生存的最大时间。
  • TimeUnit:时间单位,配合参数 3 一起使用,用于描述参数 3 的时间单位。
  • BlockingQueue:线程池的任务队列,用于保存线程池待执行任务的容器。
  • ThreadFactory:线程工厂,用于创建线程池中线程的工厂方法,通过它可以设置线程的命名规则、优先级和线程类型。
  • RejectedExecutionHandler:拒绝策略,当任务量超过线程池可以保存的最大任务数时,执行的策略。

线程池状态

线程池有五种状态

image

线程池状态转换

image

任务调度是线程池的主要入口,当用户提交了一个任务,接下来这个任务将如何执行都是由这个阶段决定的。了解这部分就相当于了解了线程池的核心运行机制。

首先,所有任务的调度都是由execute方法完成的,这部分完成的工作是:检查现在线程池的运行状态、运行线程数、运行策略,决定接下来执行的流程,是直接申请线程执行,或是缓冲到队列中执行,亦或是直接拒绝该任务。其执行过程如下:

首先检测线程池运行状态,如果不是RUNNING,则直接拒绝,线程池要保证在RUNNING的状态下执行任务。
如果workerCount < corePoolSize,则创建并启动一个线程来执行新提交的任务。
如果workerCount >= corePoolSize,且线程池内的阻塞队列未满,则将任务添加到该阻塞队列中。
如果workerCount >= corePoolSize && workerCount < maximumPoolSize,且线程池内的阻塞队列已满,则创建并启动一个线程来执行新提交的任务。
如果workerCount >= maximumPoolSize,并且线程池内的阻塞队列已满, 则根据拒绝策略来处理该任务, 默认的处理方式是直接抛异常。

image

线程池中 submit()和 execute()方法有什么区别?

submit方法比execute方法多的只是将提交的任务(不管是runnable类型还是callable类型)包装成RunnableFuture然后传递给execute方法执行。
submit方法和execute方法最大的不同点在于submit方法可以获取到任务返回值或任务异常信息,execute方法不能获取任务返回值和异常信息。
RunnableFuture从名字就可以知道,他既是一个Runnable又是一个Future,所以说submit方法提交的任务被包装成RunnableFuture后,后面执行任务的时候运行的就是RunnableFuture.run()方法,所以最根本的区别在RunnableFuture.run()方法里。所以这里才是重点关注的地方。

实现callable接口的call方法可以有返回值,但是想要获得这个返回值需要在callable外面包装一层future,然后用这个future获得返回值,submmit就是将callable外面包装了一层RunnableFuture来获得返回值。

线程池原理

线程池的工作过程

  1. 最开始,线程池中的没有线程在运行
  2. 向线程池提交任务,
  3. 线程池不断创建核心线程来执行这些任务,即使有其他空闲线程能够执行新来的任务, 也会继续创建线程,直到核心线程数到达上限
  4. 继续向线程池提交任务
  5. 新提交的任务会保存在阻塞任务队列,直到阻塞任务队列到达上限
  6. 继续向线程池提交任务,
  7. 线程池创建非核心线程来执行这些任务,直到总线程数到达上限
  8. 继续向线程池提交任务,
  9. 线程池采用四种拒绝策略
    1. 直接抛出异常,
    2. 直接拒绝执行,
    3. 删掉阻塞队列最前面的任务,让当前任务上去执行,
    4. 让调用者自己去执行

参考资料1

参考资料2

关闭线程池

关闭线程池是一个重要的操作,它确保线程池不再接受新的任务,并且在已提交的任务完成后,逐渐关闭所有线程。Java中的 ExecutorService 接口提供了两个方法来关闭线程池:

  1. shutdown() 方法:

    • 调用 shutdown() 方法会平缓地关闭线程池。它将禁止新的任务被提交,但会等待已提交的任务(包括正在执行和在队列中的任务)执行完成。
    • 一旦所有任务完成,线程池就会终止,且不再接受新的任务。
    executorService.shutdown();
    
  2. shutdownNow() 方法:

    • 调用 shutdownNow() 方法会尝试立即停止所有正在执行的任务,并停止等待执行的任务。这可能会导致一些任务被中断,因为它们可能正在执行一些阻塞操作。
    • shutdown() 不同,shutdownNow() 不会等待任务执行完成,而是立即返回一个包含尚未执行的任务的列表。
    List<Runnable> remainingTasks = executorService.shutdownNow();
    

一般来说,首选使用 shutdown() 方法,因为它提供了平缓的关闭过程,允许已提交的任务得到充分的执行时间。只有在需要尽快停止并清理线程池时,才考虑使用 shutdownNow() 方法。

需要注意的是,一旦线程池关闭,就不能再向其提交新的任务。如果需要重新使用线程池,必须重新创建一个新的线程池实例。

为什么线程池不允许使用Executors去创建? 推荐方式是什么?

线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。 说明:Executors各个方法的弊端:

  • newFixedThreadPool和newSingleThreadExecutor:   主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。
  • newCachedThreadPool和newScheduledThreadPool:   主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。

原文链接:https://pdai.tech/md/java/thread/java-thread-x-juc-executor-ThreadPoolExecutor.html

四种线程池

java中的四种线程池

  • 单例线程池:Executors.newSingleThreadExecutor();
  • 缓存线程池:Executors.newCachedThreadPool();
  • 定长线程池:Executors.newFixedThreadPool(nThreads);
  • 定时线程池:Executors.newScheduledThreadPool(corePoolSize);
  1. 单例线程池:
    单例线程池,顾名思义就是线程池中只有一个工作线程执行任务。执行的是串行操作,按照顺序执行。如果有其他请求任务时,此时线程没有空闲,就会将此任务放到队列中等待执行。那它的队列其实是一个无限大的队列,如果有大量的请求任务时,一个线程的执行速度是有限的,就有可能造成队列积压,甚至于导致OOM的问题。
  2. 缓存线程池:
    这种线程池内部没有核心线程,在有新的任务的时候,如果存在空闲线程的话就用空闲线程来执行任务,如果没有空闲线程的话,就新建一个线程来执行任务,这个最大线程数是接近无限大的。缓存线程池的优点是可以灵活的伸缩线程数,但是它存在的问题是有可能在大量请求的时候,创建了过多的线程,导致的效率低下,甚至OOM。
  3. 定长线程池:
    定长线程池的核心线程数等于最大线程数,所以它的线程数是固定的,是我们通过传参来进行设置的。当任务大于核心线程数的时候,就会放到队列中等待。
    定长线程池需要注意的问题和单例线程池类似,他的队列也是无限大的,也是有可能造成队列积压,以至于导致OOM的问题,所以,我们在使用时要合理的设置线程数。
  4. 定时线程池:
    定时线程池可以指定时间的执行周期,指定一段时间去调用一次。任务队列会根据任务延时时间的不同进行排序,延时时间越短地就排在队列的前面,先被获取执行。也就是优先级队列,这种队列是用堆来构建的,在此不做详述。

线程池的使用方式

  1. 单例线程池:
public static void main(String[] args) {
        ExecutorService executorService = Executors.newSingleThreadExecutor();
        executorService.submit(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程执行");
            }
        });
    }
  1. 缓存线程池:
public static void main(String[] args) {
        ExecutorService executorService = Executors.newCachedThreadPool();
        executorService.submit(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程执行");
            }
        });
    }
  1. 定长线程池:
public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(5); //指定线程数
        executorService.submit(new Runnable() {
            @Override
            public void run() {
                System.out.println("线程执行");
            }
        });
    }
  1. 定时线程池:
public static void main(String[] args) {
        ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(2);
        scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {

            }
        },1,3, TimeUnit.DAYS);
    }
public static void main(String[] args) {
        ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(2);
        scheduledExecutorService.scheduleWithFixedDelay(new Runnable() {
            @Override
            public void run() {

            }
        },1,3, TimeUnit.DAYS);
    }

我们可以看到除定时线程池外,其他的线程池执行方法大致相同。而定时线程池想要实现定时,则需要使用scheduleAtFixedRate方法或者scheduleWithFixedDelay方法。我们先来看看这两个方法的参数,两个方法的参数其实是一样的,依照顺序分别是:Runnable对象、第一次执行的延迟时间、两次执行间隔的延迟时间、时间单位。
那这两个方法有什么区别呢?
在 Java 中,scheduleAtFixedRatescheduleWithFixedDelay 都是 ScheduledExecutorService 接口提供的方法,用于执行周期性任务。它们之间的主要区别在于任务的执行方式:

  1. scheduleAtFixedRate 方法

    • scheduleAtFixedRate 方法会按照固定的速率执行任务,即无论任务执行是否完成,都会按照指定的间隔时间重复执行任务。
    • 如果任务执行时间超过间隔时间,那么下一个任务会立即执行(不考虑任务执行时间),因此可能出现任务重叠的情况。
  2. scheduleWithFixedDelay 方法

    • scheduleWithFixedDelay 方法会在每次任务执行完成后,等待指定的延迟时间,然后再执行下一个任务。
    • 这样确保了任务之间有固定的延迟时间,不会出现任务重叠的情况。

下面是一个简单的示例代码,演示 scheduleAtFixedRatescheduleWithFixedDelay 方法的区别:

import java.util.concurrent.*;

public class ScheduledThreadPoolExample {
    public static void main(String[] args) {
        ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);

        // 使用 scheduleAtFixedRate 方法
        executor.scheduleAtFixedRate(() -> System.out.println("Fixed Rate Task"), 0, 2, TimeUnit.SECONDS);

        // 使用 scheduleWithFixedDelay 方法
        executor.scheduleWithFixedDelay(() -> System.out.println("Fixed Delay Task"), 0, 2, TimeUnit.SECONDS);
    }
}

在这个示例中,scheduleAtFixedRate 方法和 scheduleWithFixedDelay 方法都会每隔 2 秒执行一个任务,但是它们在任务之间的执行方式上有所不同。scheduleAtFixedRate 方法会按照固定的速率执行任务,而 scheduleWithFixedDelay 方法会在任务执行完成后等待固定的延迟时间再执行下一个任务。

线程池参数大小设置多少才合理

Java线程池的核心数设置应根据实际应用场景、服务器硬件配置以及工作负载的特性进行合理配置。合理设置线程池的核心数可以提升应用程序的性能、减少资源浪费,同时防止系统因线程过多而出现性能瓶颈。

线程池核心数的合理设置取决于任务的类型,即任务是 CPU密集型 还是 IO密集型。

CPU密集型任务

CPU密集型任务是指需要大量计算资源的任务,如数学计算、数据处理、加密等。这类任务主要消耗CPU资源,通常不需要等待外部资源(如网络、磁盘I/O等)。

  • 最佳线程数:对于CPU密集型任务,最佳线程数通常设置为 CPU核心数 + 1

  • 原因:线程数不需要过多,因为CPU核心数决定了并行计算的上限。多出的1个线程是为了在偶尔出现线程切换、I/O等待时利用CPU资源。过多的线程数会造成上下文切换开销,性能反而下降。上下文切换是指多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换

  • 公式:核心线程数 = CPU核心数 + 1

IO密集型任务

IO密集型任务是指需要频繁等待外部资源的任务,如数据库访问、文件读写、网络请求等。这类任务在运行过程中,线程大部分时间处于等待状态,因此CPU负载不高。

  • 最佳线程数:对于IO密集型任务,线程数应该大于CPU核心数,通常设置为 2倍的CPU核心数 或 (CPU核心数 / 期望的线程阻塞比) 。

  • 原因:IO密集型任务频繁等待,因此可以通过增加线程数来提高CPU的利用率,确保在等待I/O操作时其他线程能够充分利用CPU进行其他任务。

  • 公式:核心线程数 = CPU核心数 * (1 + (I/O等待时间 / 计算时间)) 或者简单设置为:核心线程数 = CPU核心数 * 2

在Java中可以通过以下方式获取系统的CPU核心数:
int cpuCores = Runtime.getRuntime().availableProcessors();

注意:当今服务器硬件资源配置有1个cpu核心2个线程的说法,比如说“4核8线程 8核16线程....”,这是不是意味着我们cpu密集型的线程池核心线程数大小应该设置为2*cpuCores + 1了呢?其实不然

在 4核8线程 的 CPU 上,Java 线程池核心数的设置取决于任务的性质(CPU密集型 vs IO密集型)以及 CPU 资源的实际利用。推荐设置为 4 + 1 = 5 而不是 8 + 1 = 9 是因为 CPU密集型任务 最有效利用的是 物理核心,而不是逻辑线程。下面详细说明原因。

物理核心 vs 逻辑线程

物理核心 是 CPU 的实际处理单元,每个核心可以独立执行一个任务。
逻辑线程(或虚拟核心) 是通过 超线程技术 实现的,超线程允许每个物理核心同时处理两个线程的任务,但这并不意味着两个线程同时得到完整的 CPU 资源。超线程只是通过利用 CPU 空闲时间片提高并发性,并不能提高每个线程的计算性能。

物理核心数决定了 CPU 密集型任务的效率:在 CPU 密集型任务中,线程的执行时间完全依赖 CPU 的运算能力。一个物理核心同时只能执行一个计算密集型线程。如果超出了物理核心的数量,多个线程会开始竞争 CPU 资源,导致上下文切换,增加了不必要的开销和延迟。因此,4 个物理核心 正好可以处理 4 个计算密集型任务,推荐加 1 是为了应对偶尔的任务阻塞或线程等待情况,这样即使一个线程在等待,CPU 仍然能继续执行其他线程的计算任务。

超线程的作用:超线程技术 主要用于提高 CPU 利用率,在某些线程发生等待(例如 I/O 等待)时,让物理核心在其他空闲时间执行另一个线程的任务。然而,对于 CPU密集型 任务,由于每个线程始终需要完全占用物理核心进行计算,超线程的作用有限。超线程的两个逻辑线程并不会提高 CPU 计算能力,只是提高了任务调度的并行度。在 CPU 密集型场景下,超线程不会显著提升性能,过多线程反而可能导致性能下降

posted @   Duancf  阅读(3)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
点击右上角即可分享
微信分享提示