线程池

79 阅读10分钟

背景: 线程池是现代多线程编程的基石,它的存在主要是为了解决“直接创建线程”所带来的各种问题。


为什么需要线程池?(核心原因)

  1. 降低资源消耗(创建和销毁线程的代价高昂)
  • 线程的创建和销毁涉及到操作系统级别的操作,是一个重量级的、消耗时间和CPU资源的过程。
  • 线程池通过复用已创建的线程来处理多个任务,避免了频繁的创建和销毁,从而极大地减少了系统开销。
  1. 提高响应速度(任务可以立即被执行)
  • 当任务到达时,由于线程池中已经有现成的、空闲的线程,任务可以立即被分配执行,而不需要等待系统去创建一个新的线程。这对于低延迟要求的系统(如Web服务器)至关重要。
  1. 提高线程的可管理性
  • 资源分配可控: 线程是稀缺资源。无限制地创建线程会耗尽CPU和内存资源,导致系统崩溃。线程池可以限制并发线程的数量,根据系统的承受能力进行工作。
  • 统一管理: 线程池可以对池中的线程进行统一的分配、调优和监控。例如,可以方便地执行定时任务、周期任务,或者当工作队列过载时执行某些拒绝策略起到"缓冲垫"的作用。
  1. 提供更强大的功能
  • 线程池内置了许多实用的功能,例如:
  • 定时/周期性执行: 如 ScheduledThreadPoolExecutor。
  • 任务队列: 当所有线程都在忙碌时,新任务可以在队列中等待,从而平滑流量峰值。
  • 拒绝策略: 当队列和线程池都满了之后,可以自定义如何处理新提交的任务(如直接丢弃、抛出异常、由调用者自己执行等),保证了系统的韧性。
  1. 内存占用
  • 栈空间的隐形消耗
  1. CPU上下文切换

如果不使用线程池,直接new Thread()会有什么问题?

通过一个简单的代码对比来理解:

方式一:直接创建线程(不推荐)

public class WithoutThreadPool {
    public static void main(String[] args) {
        for (int i = 0; i < 1000; i++) {
            // 为每个任务创建一个新线程
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + " 执行任务");
                // 模拟任务处理时间
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start(); // 启动线程
        }
        // 问题:创建1000个线程开销巨大,可能导致系统资源耗尽,性能急剧下降。
    }
}

方式二:使用线程池(推荐)

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

public class WithThreadPool {
    public static void main(String[] args) {
        // 创建一个固定大小为10的线程池
        ExecutorService executor = Executors.newFixedThreadPool(10);

        for (int i = 0; i < 1000; i++) {
            // 将任务提交给线程池,而不是创建新线程
            executor.execute(() -> {
                System.out.println(Thread.currentThread().getName() + " 执行任务");
                // 模拟任务处理时间
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
        
        // 关闭线程池(不再接受新任务,等待所有已提交任务完成)
        executor.shutdown();
        // 优势:只用10个线程就处理了1000个任务,资源消耗稳定,系统压力小。
    }
}

总结:

特性直接创建线程使用线程池
资源消耗高(频繁创建/销毁)低(线程复用)
响应速度慢(需等待线程创建)快(线程已就绪)
可管理性差(数量不可控)好(数量可控,可管理)
功能支持弱(需自己实现)强(内置队列、定时、拒绝策略)

线程上下文切换指的是什么?

举例:工作中经常会经历这样的情况:"放下手头工作 -> 记住状态 -> 切换到另一项工作 -> 恢复之前状态"

在操作系统中,操作系统为了能够同时运行多个线程,需要保存当前正在执行的线程的状态,并恢复另一个线程的状态,然后将CPU的控制权交给这个新线程的过程。

这个被保存和恢复的"状态",就是上下文;整个过程就是上下文切换。

"上下文"具体包含什么?

上下文是线程运行时的一个"快照",包含了线程恢复运行所必需的所有信息,主要包括:

  1. 线程ID和状态:线程的标识符和当前状态(如运行、就绪、阻塞)。
  2. 程序计数器:指向线程下一条要执行的指令的地址。这是最关键的信息之一,没有它,线程就不知道从哪里继续。
  3. CPU寄存器的值:包括通用寄存器(如EAX, EBX)、栈指针寄存器等。这些寄存器存放着线程计算过程中的中间结果。
  4. 内存管理信息:如页表、段表等(对于进程切换更重要,但线程也涉及部分内存状态)。
  5. I/O状态信息:分配给线程的已打开文件、I/O设备等。
  6. 栈信息:线程的调用栈,记录了函数调用的层次和局部变量。

为什么上下文切换是昂贵的?(开销来源)

上下文切换本身并不做任何"有用"的工作(比如计算1+1),它纯粹是系统开销。其昂贵性体现在:

  1. 直接CPU时间消耗:
  • 保存和恢复上下文需要执行大量的内存写入和读取操作(将寄存器内容写入内存,再从内存读入新的上下文)。
  • 这需要消耗几十到上千个CPU时钟周期。
  1. 间接性能损失(更关键):
  • 缓存失效:现代CPU依赖多级缓存来加速内存访问。当一个线程被切换走后,它留在CPU缓存(L1, L2, L3)中的数据很可能对新线程是无用的。新线程运行时,需要重新将需要的数据加载到缓存中,这个过程非常缓慢,导致缓存命中率下降,这是最大的性能杀手。
  • TLB失效:类似地,转换后备缓冲器(TLB,用于加速虚拟地址到物理地址的转换)也可能被清空或失效,导致地址翻译变慢。

简单来说: 线程就像在CPU这个"办公桌"上工作的人。频繁切换线程,就像不停地让不同的人使用同一张办公桌。每个人上来都要先花时间把自己的文件(上下文)铺开,同时还要把前一个人的文件收走。更糟糕的是,前一个人留在抽屉(CPU缓存)里的文件对新来的人基本没用,新来的人需要花大量时间重新整理抽屉,找到自己需要的文件。

什么情况下会触发上下文切换?

  1. 时间片用完:操作系统为每个线程分配一个固定的CPU时间片(如10-100ms),用完后就会强制切换,以保证公平性。
  2. 线程主动让出CPU:如调用 Thread.yield(), sleep(), wait() 等方法。
  3. I/O操作或等待锁:线程进行磁盘读写、网络请求或尝试获取一个已被占用的锁时,会被阻塞,操作系统会立即切换到另一个就绪的线程。
  4. 被更高优先级的线程抢占:如果一个高优先级的线程变为可运行状态,它可能会抢占当前正在运行的低优先级线程。

如何减少上下文切换?(与线程池的关系)

这正好回答了"为什么需要线程池"的另一个深层原因。

· 线程池通过控制线程数量,避免了创建海量线程。线程数量越多,操作系统调度器需要管理的就越多,切换就越频繁。 · 线程池使用工作队列来缓存任务。当任务到达时,如果所有线程都在忙,任务会在队列中等待,而不是立即创建一个新线程。这保证了活跃的线程数稳定在一个合理的水平,从而极大地减少了不必要的上下文切换。

总结

线程上下文切换是操作系统实现多任务并发的核心技术,但它带来了显著的性能开销。理解它的原理和代价,有助于我们编写更高效的并发程序,而线程池正是为了应对这种开销而生的关键工具。


一. Java线程池架构深度剖析

1.1 核心接口与类关系图

Executor → ExecutorService → AbstractExecutorService → ThreadPoolExecutor
                     ↓
              ScheduledExecutorService

1.2 ThreadPoolExecutor的七大核心参数

public ThreadPoolExecutor(
    int corePoolSize,      // 核心线程数
    int maximumPoolSize,   // 最大线程数
    long keepAliveTime,    // 空闲线程存活时间
    TimeUnit unit,         // 时间单位
    BlockingQueue<Runnable> workQueue, // 工作队列
    ThreadFactory threadFactory,       // 线程工厂
    RejectedExecutionHandler handler   // 拒绝策略
)

二. 线程池工作原理:状态机与执行流程

2.1 线程池的5种状态

  • RUNNING:接受新任务,处理队列任务
  • SHUTDOWN:不接受新任务,处理队列任务
  • STOP:不接受新任务,不处理队列任务,中断进行中任务
  • TIDYING:所有任务终止,workerCount为0
  • TERMINATED:terminated()方法执行完成

2.2 任务执行流程图解

graph TD
    A[提交新任务 execute] --> B{当前工作线程数 < 核心线程数?}
    B -- 是 --> C[创建新的核心线程执行]
    B -- 否 --> D{任务队列未满?<br>workQueue.offer 成功?}
    D -- 是, 入队成功 --> E[任务进入阻塞队列等待]
    D -- 否, 队列已满 --> F{当前线程数 < 最大线程数?}
    F -- 是 --> G[创建新的非核心线程执行]
    F -- 否 --> H[触发拒绝策略]

三、线程池的核心组件详解

3.1 工作队列(BlockingQueue)选型指南

// 1. 有界队列 - 控制资源,防止内存溢出
new ArrayBlockingQueue<>(1000);

// 2. 无界队列 - 可能导致内存溢出
new LinkedBlockingQueue<>(); 

// 3. 同步移交队列 - 高吞吐场景
new SynchronousQueue<>();

// 4. 优先级队列 - 任务优先级调度
new PriorityBlockingQueue<>();

3.2 拒绝策略的四种武器

  • AbortPolicy:直接抛出RejectedExecutionException
  • CallerRunsPolicy:由调用者线程执行任务
  • DiscardPolicy:静默丢弃任务
  • DiscardOldestPolicy:丢弃队列中最老的任务

3.3 线程工厂:线程的"身份证"

// 自定义线程工厂最佳实践
public class NamedThreadFactory implements ThreadFactory {
    private final String namePrefix;
    private final AtomicInteger threadNumber = new AtomicInteger(1);
    
    public Thread newThread(Runnable r) {
        Thread t = new Thread(r, namePrefix + "-" + threadNumber.getAndIncrement());
        t.setDaemon(false);
        t.setPriority(Thread.NORM_PRIORITY);
        return t;
    }
}

四、实战配置:不同场景的线程池调优

4.1 CPU密集型任务

// 核心数 + 1,避免过多上下文切换
int corePoolSize = Runtime.getRuntime().availableProcessors() + 1;

4.2 IO密集型任务

// CPU核心数 * (1 + 平均等待时间/平均计算时间)
// 通常为CPU核心数的2-3倍
int corePoolSize = Runtime.getRuntime().availableProcessors() * 2;

4.3 混合型任务的最佳实践

// 动态调整策略示例
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    10,  // 根据业务压力动态调整
    50, 
    60L, TimeUnit.SECONDS,
    new ResizableCapacityLinkedBlockingQueue<>(1000)
);

五、Executors工厂类的"甜蜜陷阱"

5.1 常用工厂方法分析

// 1. FixedThreadPool - 可能导致队列积压
Executors.newFixedThreadPool(10);

// 2. CachedThreadPool - 可能创建过多线程  
Executors.newCachedThreadPool();

// 3. SingleThreadExecutor - 顺序执行保证
Executors.newSingleThreadExecutor();

// 4. ScheduledThreadPool - 定时任务专用
Executors.newScheduledThreadPool(5);

5.2 阿里巴巴开发规约为什么禁止使用Executors?

· 无界队列导致的内存溢出风险 · 线程数量不受控的系统稳定性问题

六、高级特性:超越基础用法

6.1 监控与指标收集

// 线程池监控关键指标
executor.getPoolSize();        // 当前线程数
executor.getActiveCount();     // 活跃线程数  
executor.getCompletedTaskCount(); // 完成任务数
executor.getQueue().size();    // 队列中任务数

6.2 优雅关闭策略

// 平滑关闭:不再接受新任务,等待现有任务完成
executor.shutdown();

// 强制关闭:尝试停止所有正在执行的任务
executor.shutdownNow();

// 混合关闭:先shutdown,超时后shutdownNow
executor.shutdown();
if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
    executor.shutdownNow();
}

6.3 线程池的预热技巧

// 预先启动核心线程,减少首次请求延迟
executor.prestartAllCoreThreads();

七、源码探秘:ThreadPoolExecutor的设计之美

7.1 状态控制的位运算魔法

// 使用AtomicInteger的ctl同时存储线程数和工作状态
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

// 高3位表示状态,低29位表示线程数量
private static final int COUNT_BITS = Integer.SIZE - 3;

7.2 Worker内部类的精巧设计

  • 继承AQS实现不可重入锁
  • 封装线程执行的生命周期管理

这里推荐一篇文章:juejin.cn/post/713718…

思考题:

  1. 如何知道线程池中一个线程的任务执行完成?