Day36 | Java中的线程池技术

在上一篇文章Day35 | Java多线程入门中,我们使用new Thread()来创建和启动线程。

通过这个过程了解了线程的一些基本概念和基础操作。

但是在实际的开发和真实的场景中,通过前文的方式使用线程有一些明显的弊端:

开销大:频繁地创建和销毁线程会消耗大量的系统资源。线程是一个重量级资源,创建过程涉及和操作系统的交互,成本很高。

不利于管理:没办法有效地控制并发线程的数量。如果请求量很大,无限制地创建线程很可能导致内存溢出,导致系统崩溃。

功能单一:new Thread()的方式功能很有限,很难实现任务的延迟执行、周期性执行,或者获取任务的执行结果等复杂需求。

所以在现实开发的过程中,我们通常都是使用Java5就引入的Executor框架。这个框架的核心就是线程池。

为了方便理解,后续的内容我们都围绕餐馆这个生活中的案例来阐述相关的概念。

如果你是一家餐馆的老板,每天都要处理大量的订单。

如果每来一个订单就雇一个新厨师(new Thread()),那你面临的问题就是:

雇人成本高(创建线程耗资源),厨房会被挤爆(内存溢出),而且那么多的厨师,你也管不过来。

最后,餐馆可能就倒闭了。

一、什么是线程池

为了解决上面那些问题,Java5就引入了Executor框架(智能厨房),这个厨房里一支固定的厨师团队(线程),

一个订单队列(任务队列),和一个经理(线程池管理器),有订单(任务)来的时候:

如果有空闲厨师,马上就处理。

如果厨师忙不过来了,订单就先排着队。

如果队列满了,还可以临时加派厨师(但有上限)。

如果实在忙不过来了,经理会按策略拒绝新订单(比如让客户等会再来)。

Java的Executor框架就是扮演的这个经理的角色,核心组件有这些:

Executor: 顶级接口,只定义了一个execute(Runnable command)方法。

ExecutorService: Executor的子接口,也是我们最常使用的接口。它增加了线程池的生命周期管理(如shutdown()),并提供了submit()方法来提交可以返回结果的任务。

ScheduledExecutorService: ExecutorService的子接口,增加了对任务进行定时或周期性执行的支持。

二、线程池创建

我们先用Executors工具类以简单的方式创建一些线程池,类似快速便捷的租一个现成的厨房团队。

2.1 FixedThreadPool

固定人数的团队,雇佣指定数量的厨师,订单多了就排队。

java 复制代码
package com.lazy.snail.day36;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @ClassName Day36Demo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/7/21 10:53
 * @Version 1.0
 */
public class Day36Demo {
    public static void main(String[] args) {
        ExecutorService kitchen = Executors.newFixedThreadPool(3);
        for (int i = 1; i <= 5; i++) {
            final int order = i;
            kitchen.execute(() -> System.out.println("订单 " + order + " 由 " + Thread.currentThread().getName() + " 处理"));
        }
        kitchen.shutdown();
    }
}

3个线程轮流处理5个订单,多的订单排队等待。

订单队列没有上限(LinkedBlockingQueue),如果订单源源不断,可能撑爆内存。

2.2 CachedThreadPool

类似临时工,忙的时候就加点人,空闲的时候就减少点人。

java 复制代码
ExecutorService kitchen = Executors.newCachedThreadPool();

这种比较适合处理短时订单高峰的情况。

高峰期可能雇佣无数厨师(线程数可达Integer.MAX_VALUE),然后耗尽资源。

2.3 SingleThreadExecutor

只有一个厨师,所有的订单都严格按照顺序处理。

java 复制代码
ExecutorService kitchen = Executors.newSingleThreadExecutor();

这种适合需要顺序执行的任务,比如日志记录。

队列一样没有上限,可能导致内存溢出。

《阿里巴巴Java开发手册》建议避免直接使用Executors,因为默认配置可能导致内存溢出。实际使用过程中,我们应该用ThreadPoolExecutor自定义线程池,自己当经理。

三、自定义线程池

ThreadPoolExecutor可以让我们完全的掌控"厨房",能设置厨师数量、订单队列大小、处理策略等等。

3.1 核心参数

ThreadPoolExecutor的构造方法:

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

corePoolSize:核心线程数,线程池中长期保持的线程数量,即使它们处于空闲状态。

你平时雇佣的固定厨师人数,即使没订单也不会开掉这些人。

maximumPoolSize:最大线程数,线程池能够容纳的最大线程数量。

高峰期的时候,最多能够雇佣多少厨师。

keepAliveTime:线程存活时间,当线程数大于corePoolSize的时候,多余的空闲线程在被销毁前等待新任务的最长时间。

临时厨师闲着多久会被解雇。

unit:keepAliveTime的时间单位。

workQueue:任务队列,用来保存等待执行的任务的阻塞队列。

订单排队的桌子,大小有限或无限。

threadFactory:线程工厂,用来创建新线程。一般用来自定义线程名称,方便问题排查。

给每个厨师取个名字,方便追踪谁在干活。

handler:拒绝策略,当队列已满且线程数达到maximumPoolSize时,怎么处理新提交的任务。

订单太多、桌子满了怎么办?是拒绝、还是扔掉老订单、还是直接让客户自己做?

java 复制代码
package com.lazy.snail.day36;

import java.util.concurrent.*;

/**
 * @ClassName CustomThreadPool
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/7/21 11:45
 * @Version 1.0
 */
public class CustomThreadPool {
    public static void main(String[] args) {
        ExecutorService kitchen = new ThreadPoolExecutor(
                2,
                5,
                60L,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(10),
                new ThreadFactory() {
                    private int count = 0;
                    @Override
                    public Thread newThread(Runnable r) {
                        return new Thread(r, "厨师-" + count++);
                    }
                },
                new ThreadPoolExecutor.CallerRunsPolicy()
        );

        for (int i = 1; i <= 10; i++) {
            final int order = i;
            kitchen.submit(() -> {
                try {
                    Thread.sleep(1000);
                    System.out.println("订单 " + order + " 由 " + Thread.currentThread().getName() + " 处理");
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }

        kitchen.shutdown();
    }
}

上面的案例中,雇佣了2个固定厨师,最多能够容纳5个厨师,临时厨师如果空闲60秒就会被解雇。

订单的队列长度是10个,给每个厨师都起了名字,如果订单太多、桌子满了,就让客户自己做。

案例中模拟提交了10个订单由厨房处理。

"订单太多,客户自己做"解释下。当把订单数量调整到16(超过15)时,由于线程池已满,触发CallerRunsPolicy,提交任务的主线程(main)就会自己执行第16个订单的任务。

3.2 拒绝策略

RejectedExecutionHandler有四种,"订单太多,客户自己做"是其中的一种。

AbortPolicy(默认):直接抛出RejectedExecutionException异常。

餐馆老板直接把客户赶走了,明确的告诉客户,太忙了,处理不过来了。

CallerRunsPolicy:不使用线程池的线程,而是由提交任务的那个线程自己来执行。

这个策略就是上面案例中的订单太多,客户自己做。

DiscardPolicy:默默地丢弃新提交的任务,不抛异常也不执行。

老板看有新客户下单了,又忙不过来,直接就把订单丢垃圾桶了,客户根本不知道,他可能以为还在排队。

DiscardOldestPolicy:丢弃队列中最老的任务,然后尝试重新提交新任务。

老板发现桌子满了,又有新订单来了,转了一圈,把等最久的单子丢了,先安排了新订单。

3.3 任务队列

下面是一些常见的任务队列:

队列类型 特点 关联的Executors方法
ArrayBlockingQueue 有界队列,基于数组,FIFO。创建时需指定容量。 (无,需自定义创建)
LinkedBlockingQueue 无界队列(默认容量Integer.MAX_VALUE)。 newFixedThreadPool, newSingleThreadExecutor
SynchronousQueue 不存储元素的队列,每个插入操作必须等待一个移除操作。 newCachedThreadPool
PriorityBlockingQueue 带优先级的无界队列。 (无,需自定义创建)

实际开发优先使用ArrayBlockingQueue,避免任务无限堆积。

四、线程池执行流程

下面根据ThreadPoolExecutor的execute方法(openJdk17源码)大致梳理的一个线程池的执行流程。

4.1 检查核心线程

java 复制代码
if (workerCountOf(c) < corePoolSize) {
    if (addWorker(command, true)) // true 表示"核心线程"
        return;
    c = ctl.get(); // 重新读取线程池状态
}

如果workerCount<corePoolSize,尝试通过addWorker(command, true)添加核心线程。

如果成功,退出;否则,更新线程池状态,继续下一步。

4.2 任务入队

java 复制代码
if (isRunning(c) && workQueue.offer(command)) {
    int recheck = ctl.get();
    if (! isRunning(recheck) && remove(command)) // 如果线程池被关闭了,任务要移出队列并拒绝
        reject(command);
    else if (workerCountOf(recheck) == 0)        // 如果线程池中没有线程了,要创建一个非核心线程
        addWorker(null, false);
}

如果线程池是RUNNING且队列接受任务(workQueue.offer(command)),任务入队。

再一次检查状态:

如果不再是RUNNING且任务可移除,拒绝任务。

如果workerCount == 0,调用addWorker(null, false) 创建非核心线程处理队列。

4.3 检查非核心线程

java 复制代码
else if (!addWorker(command, false)) // false 表示非核心线程
    reject(command);

如果队列已满或线程池非RUNNING,尝试通过addWorker(command, false) 创建非核心线程。

如果成功,任务被分配给新线程。

4.4 拒绝任务

java 复制代码
handler.rejectedExecution(command, this);

如果addWorker(command, false) 失败,调用handler.rejectedExecution(command, this)执行拒绝策略。

上面提到的核心线程和非核心线程本质上都是Worker实例,只是在创建时机和生命周期上有区别。

五、任务提交和结果获取

在线程池中,主要有两种方式来提交任务:execute(Runnable)和submit(Callable)。

其中execute(Runnable)它只管执行,不管结果。

java 复制代码
@FunctionalInterface
public interface Runnable {
    public abstract void run();
}

Runnable接口的run()方法签名是void run(),没办法返回任何计算结果。

run()方法签名没有声明throws Exception,在任务中发生了受检异常,必须在run()方法内部用try-catch块处理掉。

任务的执行结果(成功或失败)对于提交任务的主线程来说是完全未知的。

submit(Callable)是一个更加通用的任务提交方式。

Callable可以看成是Runnable的升级版,专门为需要返回结果的异步任务而设计。

java 复制代码
@FunctionalInterface
public interface Callable<V> {
    V call() throws Exception;
}

跟Runnable相比,call()方法的返回值类型是泛型V,意味着你的任务可以返回任何类型的结果。

call()方法签名声明了throws Exception,这意味着你可以在任务中抛出受检异常,不需要在内部try-catch。

这个异常可以被任务的提交者捕获到。

当使用submit(Callable)提交任务时,方法不会阻塞,他会马上返回一个Future对象。

可以把这个返回的Future对象看成一个提货单,拿到它之后,就可以暂时去干别的事情了。

这个提货单有以下几个功能:

V get(): 这是提货的方法。你想来拿结果的时候,就调用这个方法。

如果任务已经完成,会返回Callable的call()方法计算出的结果。

如果任务还没完成,调用get()的线程会阻塞(暂停并等待),直到任务完成为止。

V get(long timeout, TimeUnit unit): get()带超时的版本。

如果在指定时间内任务还没完成,就会抛出TimeoutException,避免无限期等待。

boolean isDone(): 检查任务完成没有(无论是正常结束、异常终止还是被取消)。这是一个非阻塞方法。

boolean cancel(boolean mayInterruptIfRunning): 尝试取消任务。

全文提到的"尝试取消"、"尝试提交"、"尝试关闭"这类用词,其实是线程池设计的一个核心思想。线程池中大部分的操作都是"请求",而不是"命令"。调用者通过"请求"来表达想干什么事情,而整个线程池系统什么时候,怎么处理这个请求,取决于它自身的内部状态、规则和资源情况。而不是像"命令"一样,收到就马上、直接、强制的去执行并响应。

看一个submit(Callable)+Future的案例:

java 复制代码
package com.lazy.snail.day36;

import java.util.concurrent.*;

/**
 * @ClassName CallableFutureTest
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/7/21 16:09
 * @Version 1.0
 */
public class CallableFutureTest {
    public static void main(String[] args) {
        ExecutorService pool = new ThreadPoolExecutor(
                2, 4, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10)
        );

        Callable<Integer> task = () -> {
            Thread.sleep(1000);
            return 42;
        };

        Future<Integer> future = pool.submit(task);
        System.out.println("任务已提交...");

        try {
            Integer result = future.get();
            System.out.println("结果: " + result);
        } catch (InterruptedException e) {
            System.err.println("任务被中断: " + e.getMessage());
            Thread.currentThread().interrupt();
        } catch (ExecutionException e) {
            System.err.println("任务执行失败: " + e.getCause());
        } finally {
            pool.shutdown();
        }
    }
}

pool.submit(task)被调用之后,没有阻塞主线程,马上返回了一个Future类型的对象(我们的提货单)。

"任务已提交..."这行马上被打印出来就证明了任务提交的异步性------主线程发起了任务,但不需要等待它完成。

主线程完成了其他事情之后,调用future.get()来提货。

这个调用是阻塞的,主线程会在这里暂停,直到子线程里的task执行完返回结果42。

拿到结果之后,程序才会继续执行并打印。

六、关闭线程池

前文中我们讲过守护线程和非守护线程的区别。默认情况下,线程池创建的线程都是非守护线程。

如果你创建了一个线程池并提交了任务,即使你的main方法执行完毕,只要线程池没有被关闭,它内部的线程(即使是空闲的)也会一直存在,从而阻止JVM正常退出。

这会导致应用程序挂起,看起来就像卡住了。

ExecutorService接口给我们提供了两个核心的关闭方法:shutdown和shutdownNow

看一下二者的区别:

特性 shutdown() shutdownNow()
大喇叭宣布餐厅打烊 直接拉电闸,紧急疏散
新任务 不再接受新提交的任务。 不再接受新提交的任务。
已提交任务 等待所有已提交的任务(包括队列中的)执行完毕。 尝试中断所有正在执行的任务,清空任务队列。
返回值 void List(返回队列中还没被执行的任务列表)
调用时机 首选的、优雅的关闭方式。 作为shutdown()超时后的最后手段,或需要立即停止的场景。
状态变更 线程池进入SHUTDOWN状态。 线程池进入STOP状态。

字面上看,shutdownNow的方式也更加激进一些。

而在实际的开发中,我们肯定不能简单粗暴的关闭线程池。

下面是模拟的开发环境中关闭线程池的案例代码:

java 复制代码
package com.lazy.snail.day36;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

/**
 * @ClassName ShutdownTest
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/7/21 16:28
 * @Version 1.0
 */
public class ShutdownTest {
    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(2);

        pool.submit(() -> {
            try {
                System.out.println("任务1开始执行...");
                Thread.sleep(2000);
                System.out.println("任务1执行完毕。");
            } catch (InterruptedException e) {
                System.err.println("任务1被中断。");
                Thread.currentThread().interrupt();
            }
        });

        pool.submit(() -> {
            try {
                System.out.println("任务2开始执行...");
                Thread.sleep(8000);
                System.out.println("任务2执行完毕。");
            } catch (InterruptedException e) {
                System.err.println("任务2被中断。");
                Thread.currentThread().interrupt();
            }
        });
        
        System.out.println("主线程:发起关闭线程池请求...");
        pool.shutdown();

        try {
            System.out.println("主线程:等待最多5秒让任务结束...");
            if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
                System.err.println("主线程:超时!强制关闭线程池...");
                pool.shutdownNow();

                if (!pool.awaitTermination(1, TimeUnit.SECONDS)) {
                    System.err.println("线程池未能终止。");
                }
            }
        } catch (InterruptedException e) {
            System.err.println("主线程等待时被中断,强制关闭线程池...");
            pool.shutdownNow();
            Thread.currentThread().interrupt();
        }

        System.out.println("主线程:关闭流程结束。");
    }
}

shutdown通知线程池进入关闭流程,不再接受新任务。

调用awaitTermination(long timeout, TimeUnit unit)方法,让当前线程(main)阻塞等待。

要么线程池中的所有任务都执行完毕,线程池完全终止。此时,方法返回true。

要么超时时间到达。此时,方法返回false。

如果awaitTermination因为超时而返回false,说明还有任务还没结束。

这个时候就只能调用shutdownNow()来尝试强制中断这些相对顽固的任务。

调用awaitTermination的线程自身也可能被中断。

我们必须捕获这个InterruptedException,在捕获后也调用shutdownNow()并恢复当前线程的中断状态。

七、线程池监控及调优

线程池给我们提供了很多运行状态信息,如果我们想要我们经营的餐馆,厨房有序、合理、高效的处理订单。

就可以通过监控这些状态信息,并及时的调整配置信息,让厨房达到一个最佳的状态。

ThreadPoolExecutor提供了一系列方法,我们可以通过这些方法监控线程池的状态,接下来我用类比的方式讲一下常用的方法:

方法 类比 含义
getActiveCount() 有多少厨师在忙 返回当前正在执行任务的线程数
getPoolSize() 当前雇了多少厨师 返回线程池中实际存在的线程数(核心+临时)
getQueue().size() 桌子上有多少订单 返回任务队列中的等待任务数
getCompletedTaskCount() 完成了多少订单 返回已完成的任务总数(近似值)
getTaskCount() 总共收到多少订单 返回提交到线程池的总任务数(包括已完成、排队和正在执行的)
getLargestPoolSize() 历史最多雇了多少厨师 返回线程池历史上同时存在的最大线程数

如果发现排队订单越来越多(getQueue().size()接近队列容量),说明桌子不够大或厨师太少,可能需要调整配置。

以下这些建议只是作为参考:

如果任务量稳定,corePoolSize可以设置成CPU核数的1-2倍。

maximumPoolSize一般设置为CPU核数的2-4倍,避免过多线程导致上下文切换开销。

推荐ArrayBlockingQueue这个有界队列,控制订单堆积。订单量波动大时,适当增大队列,但要注意内存占用。

一般设为几十秒到几分钟,高峰过后能够快速的释放资源。

低负载的时候采用CallerRunsPolicy,让客户自己做菜,减缓提交速度。

要求高可靠性的场景使用用AbortPolicy,明确失败,触发告警。

对结果不太敏感,非关键任务的时候用DiscardPolicy或DiscardOldestPolicy,丢弃任务以保护系统。

如果任务执行得太慢,就只能优化任务逻辑,或者把大人物拆成小任务(类似ForkJoinPool的分治思想)。

还可以使用PriorityBlockingQueue优先处理重要订单。

线程池的优化不是一成不变的模板化操作,而是需要根据实际业务场景和运行时状态动态调整。真正的优化依赖于持续监控线程池的运行状态,结合业务需求和系统资源,动态调整参数才能达到最佳性能。

结语

看完本文,我们已经从new Thread()跨越到了线程池。

线程池其实也并不高大上,他跟数据库连接池和HTTP连接池的核心思想一样。

都是一种池化技术,本质就是通过复用昂贵资源,来降低单次获取该资源的开销,并对资源进行统一管理,从而提高系统整体的性能和稳定性。

不管什么池,都可以把它看成一个容器,用来存放资源,都有获取、归还资源的方法。

只是因为管理的资源不同,对于核心数量、最大数量、空闲超时、拒绝策略等参数的管理策略不同而已。

下一篇预告

Day37 | 线程安全与synchronized

如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!

相关推荐
sheji34162 小时前
【开题答辩全过程】以大学校园点餐系统为例,包含答辩的问题和答案
java
嘻哈baby2 小时前
Go context详解:超时控制与请求链路追踪
后端
苏婳6662 小时前
Java---SSH(MVC)面试题
java·ssh·mvc
叶 落2 小时前
[Maven 基础课程]13_Maven 私服的使用
java·maven
历程里程碑2 小时前
滑动窗口秒解LeetCode字母异位词
java·c语言·开发语言·数据结构·c++·算法·leetcode
计算机学姐2 小时前
基于SpringBoot的智能家教服务平台【2026最新】
java·spring boot·后端·mysql·spring·java-ee·intellij-idea
思成Codes2 小时前
Go 语言中数组与切片的本质区别
开发语言·后端·golang
Gofarlic_oms13 小时前
Cadence许可证全生命周期数据治理方案
java·大数据·运维·开发语言·人工智能·安全·自动化