【JavaEE】多线程04—线程池/定时器

1.线程池

对于池,在学习 String类的时候,我们学习过常量池,即字符串常量池 ------ 字符串常量存放在池中,在Java程序最初构建的时候,就已经准备好了,等程序运行的时候,这样的常量也就加载到内存中了,省下了构造/销毁的开销。

计算机中,池 这个词,就只有这一个意思,表示含义都是一样的。 线程池,就是池中有许多提前准备好的线程,来备用;

1.1 为何要使用线程池?

线程池最大的好处就是减少每次启动、销毁线程的损耗。即让我们高效的创建销毁线程。

最初引入线程的原因:频繁创建销毁 进程 太慢了。随着互联网的发展,随着对性能要求的提高,现在觉得,频繁创建销毁线程,它的开销也不太能接受,因此,就有两个解决方案:

  • 线程池
  • 协程(纤程,轻量级线程)

我们主要了解线程池。

线程池,就是把线程提前创建好,放到一个地方(类似于数组),需要用到的时候随时去取,用完还回到池子中。那为何直接创建线程开销比从线程池中取线程开销更大?

这就要从操作系统的内核态讲起,一个操作系统 = 内核+配套的应用程序。其中内核包含操作系统各种的核心功能:

  1. 管理硬件设备
  2. 给软件提供稳定的运行环境

就像去银行办理某种证件,需要身份证复印件,此时你刚好没有复印,有两种方式可以复印:

  1. 自行去自助打印机复印
  2. 让柜员给你打印

其中,让柜员给你打印是不可控的,因为这个柜员可能在给你打印的途中先去上了个厕所,或者中途先去做别的事,或者还需要给别的客户也打印,总之,你就得等;但是自行打印,可以立马执行,整个过程是可控的。而 内核 就像 柜员打印,应用程序 就像 自行打印。

如果有一段代码是应用程序中自行完成的,整个执行过程是可控的,如果有一段代码,需要进入操作系统的内核中,由内核负责完成一系列的工作,是不可控的,因此,通常认为可控的过程比不可控的过程更高效

从线程池取现成的线程,纯应用程序代码就可以完成 ------ 可控,而从操作系统创建新线程,就需要操作系统内核配合完成 ------ 不可控。使用线程池,就可以省下应用程序切换到内核中运行这样的开销。

简单来说,直接创建线程是"从无到有"的完整生产过程,而线程池取线程只是"唤醒一个待命的人"。

1.2 Java标准库中的线程池

1.2.1 ThreadPoolExecutor

ThreadPoolExecutor 是标准库中提供的一个线程池类,该线程池中准备好了一些线程,这些线程时刻准备执行一些任务。

该类的核心方法是 submit(Runnable) ,++通过 Runnable 描述一段要执行的任务,然后再通过 submit 把任务放到线程池中,此时线程池中的线程就会执行这样的任务(这些线程池中的线程最终会调用 Runnable 的 run() 方法来执行任务)++。

(submit 方法是在 ExecutorService 接口中声明的,然后在AbstractExecutorService 抽象类中实现的,之后 ThreadPoolExecutor 又从父类 AbstractExecutorService 继承来的,不是自己原有的)

而构造 ThreadPoolExecutor 这个类,它的构造方法比较复杂,参数多,如下,该类有四种构造方法:

其中第四个构造方法的参数是最多的,也是最完整的,了解完第四个构造方法,其他的构造方法也相当于了解了。

1】 corePoolSize 和 maximumPoolSize

Java的线程池,里面包含几个线程,是可以动态调整的,任务多的时候,自动扩容成更多的线程,任务少的时候,把额外的线程干掉,节省资源。

核心线程和非核心线程,其实就像线程池中的两类员工:

总结

  • Java 线程池中的线程数量可以动态调整:
    • 核心线程(corePoolSize)是常驻员工,默认创建后不会主动销毁(除非设置超时回收)。线程池刚创建时,核心线程数为 0,按需创建,即懒加载。
    • 非核心线程(maximumPoolSize - corePoolSize)是临时工,任务多时创建,空闲超过 keepAliveTime 就销毁。
    • 任务少时,线程池自动收缩(干掉非核心线程);任务多时,自动扩容(增加非核心线程)。

2】 long keepAliveTime 和 TimeUnit unit

这两个参数在 Java 线程池(如 ThreadPoolExecutor)中总是成对出现,共同决定非核心线程(以及可选的超时核心线程)的空闲存活时间

示例:

java 复制代码
corePoolSize = 5
maximumPoolSize = 10
keepAliveTime = 30   // 单位配合 TimeUnit
unit = TimeUnit.SECONDS   // 那么keepAliveTime的30就表示是 30秒

解释:

  • 如果线程池有 8 个线程(3 个是非核心的),这 3 个非核心线程如果连续 30 秒没有任务可做,就会被销毁,直到线程数回到 5。
  • 当线程数已经是 5 个核心线程时,即使它们空闲 30 秒,也不会被销毁(除非设置了 allowCoreThreadTimeOut(true))。

总结 :keepAliveTime + TimeUnit 决定了线程池中"额外线程"在没有任务时的最大存活时间,超过这个时间就会被回收,以节省系统资源**。**

3】 BlockingQueue<Runnable> workQueue

BlockingQueue<Runnable> workQueue 是 Java 线程池 ThreadPoolExecutor 中用于存放等待执行任务的阻塞队列,也就是任务队列。它是连接任务提交者与工作线程之间的缓冲地带。

线程池,本质上也是一个生产者消费者模型,调用 submit 就是在生产任务,线程池中的线程就是在消费任务,那么这个工作队列/阻塞队列,就是这两者之间的一个缓冲区。

核心作用:当线程池中的线程都在忙时,新提交的任务不会立即被拒绝,而是先放入这个队列中排队,等待空闲线程来取走执行。

  • 类比:银行的柜台窗口(工作线程)在忙,新来的客户(任务)就在等候区(workQueue)排队。

总结 :workQueue 是线程池中任务的排队缓冲区,它决定了在核心线程繁忙时,新任务是继续排队还是触发线程扩容,是控制资源利用和系统负载的核心参数

关于 workQueue 的 选择,可根据实际情况决定,以下是 workQueue 可选择的队列类型:

4】ThreadFactory threadFactory

ThreadFactory threadFactory 用于控制线程池内部如何创建新线程。它是一个接口,定义了一个方法 Thread newThread(Runnable r)。

线程池需要创建线程(核心线程或非核心线程)时,并不会直接 new Thread(...),而是通过 ThreadFactory 来创建

# 工厂模式

其实 ThreadFactory 是工厂模式的一个经典应用,工厂模式,和单例模式一样也是一种设计模式。也就是说,ThreadFactory 是线程池用来创建线程的"线程工厂"。

工厂模式的核心 是:不直接使用 new 关键字创建对象,而是通过一个"工厂"对象或方法来负责对象的创建,将"对象的创建"与"对象的使用"分离开

你不需要自己 new Thread(...),而是交给一个"工厂"去创建,即调用 threadFactory.newThread(r),从而:

  • 集中管理创建逻辑
  • 方便统一修改创建方式
  • 降低代码耦合

我们可以通过一个例子来了解工厂模式:使用工厂模式,可以用来弥补构造方法的缺陷 ------ 示例,表示平面上的一个点:

  • 使用二维坐标 x和y 表示
  • 使用极坐标 r和a 表示,即x=r*cos(a) 和 y=r*sin(a) 表示

如以下,两个构造方法分别表示的是两种不同的平面上的点的表示方式,但是由于构造方法名是固定的,想要构造方法 new Point(.....) 提供不同的需求,就需要通过重载,但是有时候不一定能构成重载,就像这个例子,它们的参数类型就是一样的,重载不了:

此时,工厂模式就可以解决这个问题:

(这里使用的是工厂模式中的简单工厂 类型,该类型的工厂方法是静态方法)

不直接通过 new Point() 来创建对象,而是通过工厂方法来把构建对象 new的过程中,各种属性初始化的过程封装起来,将这些工厂方法放到专门提供这些方法的工厂类中,后续 Point 类需要创建对象,直接通过工厂类调用即可创建对象:

到这里,我们已经了解了什么是工厂模式,回到 ThreadFactory ,这个接口就是给线程类Thread 提供的一个工厂接口,可以通过 newThread()工厂方法 统一的构造并初始化线程池中的所有线程。

5】RejectedExecutionHandler handler

RejectedExecutionHandler handler ,表示拒绝策略 ,是 Java 线程池ThreadPoolExecutor 的最后一个构造参数,用于定义当线程池无法接受新任务时,应该采取什么行为 / 什么拒绝策略。

什么时候会触发拒绝策略?

  • 当线程池已经关闭或者达到饱和状态时,新提交的任务会被拒绝,触发 RejectedExecutionHandler:
    • 线程池已关闭:调用 shutdown() 或 shutdownNow() 后,再提交任务会被拒绝。
    • 线程池饱和:线程数已达到 maximumPoolSize,并且工作队列已满,此时新任务无法入队,也会被拒绝。

当 submit 把任务添加到任务队列中,任务队列是阻塞队列,队列满了,再添加就会阻塞,对于线程池来说,发现入队列操作时,队列满了,此时不会真的触发"入队列操作",不会真的阻塞,而是执行拒绝策略相关的代码

如果真的阻塞了就会使得这个线程没法干别的事情了,不是一个好选择,设想一下,这个线程要响应用户的请求,这时候阻塞了,用户迟迟拿不到请求的响应,等了很久,直观上看到的现象就是"卡了",与其说是卡了,不如直接告诉用户"失败",这就是拒绝策略要做的事情之一,不同拒绝策略行为不同。

# 四种拒绝策略

以上的"直接告诉用户失败" 就是 AbortPolicy 这种拒绝策略。

如果调用者线程调用submit 提交任务,发现队列满了且线程数已达最大值,并且当前的拒接策略是CallerRunsPolicy,那么 submit 的调用者线程就会自己去执行当前的任务,直接执行该任务的 run() 方法,而不会进入队列,也就不会由线程池的工作线程执行。


到这里,关于 ThreadPoolExecutor 这个线程池类的参数细节全部了解完毕,现在我们来使用一下这个类:

ThreadPoolExecutor 的使用示例

如以下的运行结果,当执行到第9,10任务时,此时的线程池中任务队列及最大线程数全部已经满了,由于拒绝策略是CallerRunsPolicy,那么就会让调用者线程main自行执行:

手动关闭线程池 shutdown和shutdownNow

当线程池中所有已提交的任务都执行完毕,且没有新任务提交时,工作线程会因任务队列为空而阻塞(一直阻塞等待新的任务的到来 )。这些线程默认是非守护线程/前台线程,会导致进程无法结束,因此需要手动关闭线程池。关闭方式有两种:

  1. shutdown() 温和关闭,等待所有已提交任务完成;
  2. shutdownNow() 粗暴关闭,尝试中断正在执行的任务并放弃未执行的任务。

核心差异:

无论使用哪个方法,建议都配合 awaitTermination() 来等待线程池真正终止,或者处理超时后的强制关闭逻辑。

  • shutdown() 和 shutdownNow() 只是"发起关闭"的动作,它们本身不会等待线程池真正终止。而 awaitTermination() 的作用就是阻塞当前线程,直到线程池彻底关闭(或超时、被中断)。
  • 也就是说,awaitTermination()阻塞了当前的线程,让线程池中的线程可以执行完任务,然后关闭线程,之后再执行当前的线程的后续逻辑。

awaitTermination() 提供了同步等待 能力

它让你能够:

  • 等待所有任务真正完成(shutdown 场景)
  • 等待所有线程真正退出(两种关闭方式都适用)
  • 超时控制:避免无限期等待(比如某些任务卡死)
  • 决定下一步动作:超时后可以调用 shutdownNow() 强制终止,或者记录日志、抛出异常。

1.2.2 Executors

我们会发现,ThreadPoolExecutor 这个线程池类的使用非常麻烦,所以,Java标准库中,也提供了另一个线程池类,也就是 Executors ,针对 ThreadPoolExecutor 进行进一步的封装,简化了线程池的使用,也是基于工厂模式的。

这里我们介绍它的两个比较重要的方法:

  • newFixedThreadPool ------ 该方法是 corePoolSize和 maximumPoolSize 的结合,表示核心线程数和最大线程数的值是一样,返回值是 ExecutorService,是一个静态方法:如以下的源码,在该方法的内部已经定义好了一些数据,直接使用即可:
  • newCachedThreadPool ------ 该方法表示最大线程数是一个很大的数字,即线程可以无限增加。

即使用 Executors 中的这两个方法,可以直接创建出固定数目的线程池,和自动扩容线程数的线程池,无需像 ThreadPoolExecutor 自己手动设置参数。

示例:

看运行结果,和 ThreadPoolExecutor 一样,当线程池中的线程任务执行完后,需要手动关闭线程池,否则线程池中的线程会一直阻塞等待新的任务的到来,而不会结束:

shutdown手动关闭线程池,它不再接收新任务,但会等待所有已提交的任务(包括正在执行和队列中的)执行完毕,然后才关闭线程。因此它能保证任务全部完成,只是不会立即关闭线程:

整个进程正常退出:


不过,相比于 Executors,ThreadPoolExecutor 当然更好,因为使用 Executors ,线程数目、拒绝策略等信息都是隐式的,不好控制。

1.3 模拟实现线程池

模拟实现一个固定线程个数的线程池。

一个线程池,需要:

  1. 一个任务队列,存放待执行的任务
  2. 需要一个submit 方法,通过这个方法将 任务(Runnable) 提交 put() 到线程池任务队列中
  3. 初始化线程池,即在构造方法中,包括初始化任务队列,以及创建指定数量的工作线程并启动它们。
  4. 任务队列中的任务,通过线程池中的线程执行,随时会有新的任务被提交进来,线程就要持续不断的尝试读取任务 take(),取到了就执行 run(),没有取到则阻塞等待。
java 复制代码
class MyTheardPool {
    //任务队列
    BlockingQueue<Runnable> queue = null;

    //初始化线程池,创建固定线程个数的线程
    public MyThreadPool(int threadCount) {
        //这里使用ArrayBlockingQueue作为任务队列 - 有界队列    
        queue = new ArrayBlockingQueue<>(1000);

        //创建 threadCount 个线程
        for(int i = 0;i < threadCount; i++) {
            Thread t = new Thread(()->{
                try {
                    //让线程池中的线程执行任务,取到任务就执行,没有取到就阻塞等待
                    while(true) {
                        Ruunable task = queue.take();
                        task.run();   
                    }
                }catch(InterruptedException e) {
                    throw new RuntimeException(e);       
                }
            });
            t.start();//启动线程
        }
    }

    //提交任务到任务队列中
    public void submit(Runnable task) throws InterruptedException{
        queue.put(task);
    }
}

【以上模拟实现的线程池,其中阻塞队列BlockingQueue 已经完成了许多的工作】

示例:根据上述实现的一个线程池,现在提交任务到这个线程池中执行:

看运行结果,与 ThreadPoolExecutor 和 Executors 一样,当线程池中所有已提交的任务都执行完毕,且没有新任务提交时,工作线程会因任务队列为空而阻塞等待,这些线程都是前台线程,会导致进程无法结束:

想要使其结束,依然需要一个类似于 shutdown 的关闭线程的方法,或者直接设定线程执行完任务后设置为后台线程setDaemon(true),这样随着任务执行完毕,表示整个进程结束,自然线程池也关闭了,但是一般不这样做。

2.定时器

定时器也是软件开发中的⼀个重要组件. 类似于⼀个 "闹钟". 达到⼀个设定的时间之后, 就执行某个指定好的代码.

2.1 Java标准库中的定时器

标准库中提供了⼀个 Timer 类. Timer 类的核心方法为 schedule。

schedule 包含两个参数:第⼀个参数指定即将要执行的任务代码,第⼆个参数指定多长时间之后 执行(单位为毫秒)

正常描述任务,是Runnable接口:

但是在定时器中,稍微特殊一点,把 Runnable 封装成了 TimerTask抽象类,但是核心还是重写 run() 抽象方法:

示例:Timer 定时器确实让任务到了一个指定的时间再执行:

和线程池一样,Timer 中有一个唯一的线程 TimerThread (该线程负责从任务队列(TaskQueue)中取出任务并按计划执行,TaskQueue ------ 用于存放待执行的定时任务(TimerTask)) ,也是一个前台线程,会导致进程无法结束。Timer 中有类似于 shutdown 这样的方法 ------ cancel() 来关闭定时器。如果在构造的时候,即new Timer(true) ------ 明确指定为守护线程 / 后台线程,这样就不会阻止进程结束。

可以理解为:Timer = 一个前台线程TimerThread + 一个优先队列TaskQueue(按执行时间排序)

2.2 模拟实现定时器

模拟实现一个定时器,需要:

    1. MyTaskTimer 类:表示一个任务,TaskTimer在源码中是一个抽象类,继承于 Runnable 接口,并继承了 run 方法,我们基于抽象类的方式实现 MyTaskTimer,这样的定义虽然确实可以,但是写起来有点麻烦,还有另一种写法:
    • TaskTimer本身是继承于 Runnable的,那么我们直接使用这个父类,直接就将MyTaskTimer 定义为一个普通的类,将Runnable 接口作为该类的一个成员,表示一个任务,然后写一个run 方法,调用 Runnable 的run 方法,用来后续执行任务;
    • 然后再定义一个变量 time ,表示 记录任务要执行的时刻,在构造方法中去初始化 Runnable 这个任务 和 time。
    1. MyTimer 类:自实现的Timer类,模拟实现 Timer类中重要的 TimerThread 工作线程类 和 TaskQueue 任务队列类,以及 schedule 提交任务的方法。
    • 关于任务队列:用于存放 任务MyTaskTimer ,由于所有任务都是定时执行的,有的快有的慢,也就是说,这些任务是按照时间顺序先后执行的(时间早的先执行,小根堆),因此,我们需要一个优先级队列PriorityQueue,来表示任务队列
      • 注意:**a).**该优先级队列是存放任务 MyTaskTimer的,因此队列中的元素类型是 MyTaskTimer,也就是泛型参数是MyTaskTimer
      • **b).**需要设置好 PriorityQueue 排序的规则,是通过时间排序,那么需要实现Comparable接口 (或者Comparator类),去重写compareTo方法,定义好排序方式,由于元素类型是 MyTaskTimer,那么就由 MyTaskTimer 类去实现该接口,然后重写compareTo方法。
    • 关于 schedule 方法:定时将任务提交到任务队列中,在提交之前,需要将任务的描述 Runnable task 和指定执行的时间 time 设置出来,也就是初始化 MyTaskTimer 构造方法,将描述好的任务和指定的时间传入,实例化MyTaskTimer对象,相当于创建好了一个任务,再将这个任务提交,也就是 通过PriorityQueue的 offer 方法提交任务到队列。
      • 注意:计算机记录当前时间 是通过 时间戳 来记录的,当我们设置某一个任务要在30分钟后执行,即为当前时间的30分钟后执行,那么 time 应该表示为当前时刻 + 30 ,而当前时间的获取通过 **System.currentTimeMiller()**获取,即:
      • ++time = System.currentTimeMiller() + 30++
    • 关于工作线程:负责执行队列中的任务,将工作线程的启动放在定时器的构造方法中,保证定时器创建后该工作线程即可使用,具备执行任务的能力。
      • 注意:任务队列中的每个任务都是定时执行的,也就是说,当 当前时间 < time 时,此时的任务还不可以执行,需要等时间到了再执行,即每次先 peek 一下任务,判断是否到了执行该任务的时机,如果是,那么调用run 方法执行任务,再将执行好的任务从队列中 poll出去。

重点:

  1. 与定时器的任务队列PriorityQueue不同的是,线程池的任务队列BlockingQueue中的 put 和 take 方法是线程安全的,且它们都是带有阻塞的方法,且它们可以相互唤醒,前面模拟实现线程池的时候,我提过一嘴,阻塞队列BlockingQueue 已经完成了模拟实现线程池许多的工作,说的就是这些。
  2. 但是,PriorityQueue 并不是一个阻塞队列,它的 offer 和 poll 方法是线程不安全的 ,这也就意味着,我们在进行入队列/出队列的过程中,需要自己手动 加锁synchronized ,来保证线程安全;其次,当队列空了,或者满了,由于 offer 和 poll 并不带有阻塞,这意味着它们不会阻塞等待,也不可以去相互唤醒 ,因此,我们需要去手动添加 wait 和 notify 方法,来保证它们之间可以阻塞等待以及相互唤醒。
  3. 还有就是,由于PriorityQueue 为空时,并不会阻塞等待,因此,当线程启动后想要执行任务时,如果发现队列为空,需要 wait 阻塞等待,直到有任务通过 schedule 提交到队列,则就可以通过 notify 去唤醒 wait ,使其可以继续执行。任务可以执行的时机是到了它执行的时间,那么当 当前的时间小于执行时间时,可以超时等待 wait(执行时间 - 当前时间) 一定时间,时间一到,就可以立即执行任务了。
java 复制代码
class MyTaskTime implement Comparable<MyTaskTimer>{
    private Runnable task;//描述任务

    //通过Runnable中的run方法执行任务
    public void run() {
        task.run();
    }

    private long time;//记录任务要执行的时刻
    
    public long getTime() {
        return time;
    }

    public MyTaskTime(Runnanle task,long time) {
        this.task = task;
        this.time = time;
    }
    
    @Override
    public int compareTo(MyTaskTimer o) {
        return (int) (this.time - o.time);//小根堆
    }
}

class MyTimer {
    //任务队列
    PriorityQueue<MyTaskTimer> queue = new PriorityQueue<>();

    public void schedule(Runnanle task,long delay) {
        //手动加锁
        synchronized(this) {
            MyTaskTimer taskTimer = new MyTaskTimer(task,System.currentTimeMillis() + delay);
            queue.offer(taskTimer);
            this.noyify();//队列不为空,唤醒阻塞
        }
    }

    //工作线程的启动
    public MyTimer() {
        Thread t = new Thread(()->{
            try {
                where(true) {
                //手动加锁
                    synchronized(this) {
                        //取出队首元素
                        while(queue.isEmpty()) { //队列为空,阻塞等待
                            this.wait();
                        }
                        TaskTimer task = queue.peek();//先获取,而不是直接poll,是因为任务得执行了才能移除,此时该任务的执行时间不一定到了
                        if(System.currentTimeMillis() < task.getTime()) {
                            //当前任务时间如果比系统时间大,说明任务执行的时机未到,阻塞等待到任务执行的时机
                            this.wait(task.getTime()-System.currentTimeMillis());
                        }else {
                            //时间到了,执行任务
                            task.run();
                            queue.poll();
                        }
                    }
                }
            } catch(InterruptedException e) {
                e.printStackTrace();
            }
        });
        t.start();//启动线程
    }
}

示例:根据上述实现的定时器,提交任务到这个定时器中执行:

2.3 Exectuors

标准库中的Timer 和 模拟实现的 MyTimer 差不多,都是使用一个线程,负责扫描队首元素,并执行的,如果是任务少,或者任务时间分散,都无所谓,但是如果任务多,或者任务时间集中,一个线程就可能执行不过来;

那么可以结合线程池,创建多个线程,负责执行任务队列中的任务,一个线程负责扫描,扫描到需要执行的任务,添加到另一个线程池的任务队列中,由多个线程负责执行。

在Java标准库中,Executors 这个类中,就有一个方法 newScheduledThreadPool() ,它就创建了一个带有线程池的定时器


由于定时器 是一个非常重要的组件,在分布式系统中,把定时器专门提取出来,封装成一个单独的服务器,和消息队列很像;分布式系统中,有很多的服务器,如果只有一个类,意味着所有服务器都需要执行这一套同样的逻辑,比较复杂,如果进行调整,所有的都要改,但是提取出来,做一个独立的服务器,大家都去调用,如果未来有升级调整,也便于分配给这个定时任务服务器单独的硬件资源。

相关推荐
Makoto_Kimur2 小时前
Spring用了哪些设计模式?
java·spring·设计模式
阿巴斯甜2 小时前
UnaryOperator的使用:
java
曼岛_2 小时前
[逆向工程]160个CrackMe入门实战之Andrnalin.2解析(九)
java·数据库·microsoft·逆向
阿丰资源2 小时前
Java项目基于SpringBoot+Vue前后端分离在线商城系统(附源码)
java·vue.js·spring boot
历程里程碑2 小时前
MySQL视图:虚拟表的实战技巧
java·开发语言·数据库·c++·sql·mysql·adb
SamDeepThinking2 小时前
从DDD的仓储层反向依赖,理解DIP、IOC和DI
java·后端·架构
小Y._2 小时前
JVM垃圾回收算法与调优实战
java·jvm·性能调优·gc
喜欢流萤吖~2 小时前
Nacos 配置中心:微服务的配置管家
java·运维·微服务
逻辑驱动的ken2 小时前
Java高频面试考点场景题10
java·开发语言·深度学习·求职招聘·春招