1..5java面试题:线程池

线程池是 Java 面试中必考且最能拉开差距的知识点。老练的 Java 工程师不仅能讲清楚参数,还能结合源码执行流程、生产调优经验、监控与坑点进行深入阐述。下面我用"核心原理 → 参数拆解 → 工作流程 → 实战案例 → 调优与监控 → 常见陷阱"这条线,帮你彻底吃透。


一、为什么必须用线程池?

  • 降低资源消耗:复用已创建的线程,减少线程创建、销毁的开销。
  • 提高响应速度:任务到达时,无需等待线程创建即可立即执行。
  • 提高线程的可管理性:统一分配、监控和调优,防止无限制创建线程导致 OOM。
  • 提供更强大的执行控制:执行、排队、拒绝策略、定时执行等。

二、线程池的核心构造参数(ThreadPoolExecutor)

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

2.1 核心参数详解

  • corePoolSize :常驻线程数,即使空闲也不回收(除非 allowCoreThreadTimeOut(true))。
  • maximumPoolSize :线程池允许的最大线程数。
    线程数 = core + 当队列满后额外创建的线程,但总量 ≤ max。
  • keepAliveTime + unit:超出 core 的线程如果空闲超过此时间,会被回收。
  • workQueue:存储等待执行的任务,是线程池吞吐量的关键。
  • threadFactory:自定义线程命名、守护、优先级等,便于监控排错。
  • handler:当线程数到达 max 且队列满时,对新任务的拒绝策略。

三、线程池的工作流程(源码级理解)

当提交一个任务 execute(Runnable command) 时:

  1. 当前线程数 < corePoolSize
    → 直接创建新线程执行任务(即使有空闲核心线程也会优先创建达到 core)。
  2. 当前线程数 ≥ corePoolSize
    → 尝试将任务放入 workQueue 排队。
  3. 队列已满
    → 创建新线程(非 core)执行任务,直到达到 maximumPoolSize。
  4. 线程数 = max 且队列满
    → 执行拒绝策略。

面试官追问细节

  • 为什么核心线程满了不立即创建非核心线程,而是先入队?
    答:为了缓冲突发流量,减少线程创建销毁的开销,除非队列设定为容量极小的(如 SynchronousQueue)则不等。
  • 什么时候会回收非核心线程?
    答:当 getPoolSize() > corePoolSize 且空闲超过 keepAliveTime,回收直到线程数回到 core。

四、阻塞队列选型与实战案例

4.1 常见队列对比

队列 结构 容量 适用场景
SynchronousQueue 无存储,一对一交接 0 请求量平稳,把任务直接交给线程,拒绝策略容易触发
LinkedBlockingQueue 链表 Integer.MAX_VALUE(默认) 固定线程数 + 无界缓冲,但可能 OOM
ArrayBlockingQueue 数组 必须指定容量 有限缓冲,配合有界队列 + 拒绝策略更安全
DelayQueue 优先级堆 无界 延时任务调度
PriorityBlockingQueue 优先级堆 无界 按优先级执行,需任务实现 Comparable

血泪教训

Executors.newFixedThreadPool(10) 底层用的是 LinkedBlockingQueue 无界队列,队列无限增长会 OOM。所以生产严禁直接使用 Executors 的四个工厂方法

4.2 案例一:自定义线程池(安全高效)

java 复制代码
ThreadPoolExecutor pool = new ThreadPoolExecutor(
    4,                                    // core
    8,                                    // max
    60L, TimeUnit.SECONDS,                // 空闲回收
    new ArrayBlockingQueue<>(200),        // 有界队列,防止 OOM
    new ThreadFactory() {
        private final AtomicInteger count = new AtomicInteger(1);
        @Override
        public Thread newThread(Runnable r) {
            Thread t = new Thread(r, "order-pool-" + count.getAndIncrement());
            t.setDaemon(false);           // 非守护,确保任务执行完
            return t;
        }
    },
    new ThreadPoolExecutor.CallerRunsPolicy()  // 拒绝策略:交给主线程执行,防止丢任务
);

五、拒绝策略与场景选择

策略 行为 适用场景
AbortPolicy(默认) RejectedExecutionException 必须通知上游,记录异常
CallerRunsPolicy 由提交任务的线程执行 防丢任务,可降低流量
DiscardPolicy 静默丢弃新任务 不重要的日志、统计
DiscardOldestPolicy 丢弃队列头部最旧任务,重试提交 只保留最新数据,如实时性高的任务
自定义策略 实现 RejectedExecutionHandler,可记录日志、告警、降级到 MQ 等 需要精细控制时

案例:自定义拒绝策略 + 告警

java 复制代码
public class AlertRejectedHandler implements RejectedExecutionHandler {
    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        log.error("线程池任务被拒绝,当前活跃线程: {}, 队列大小: {}", 
                  executor.getActiveCount(), executor.getQueue().size());
        // 尝试重新入队列或降级到 MQ
        // 如果仍失败则丢弃或抛异常
    }
}

六、如何合理设置线程数?

6.1 经典公式

  • CPU 密集型 :线程数 = CPU 核心数 + 1
    例:加密解密、复杂计算。
  • I/O 密集型 :线程数 = CPU 核心数 * 2 或
    线程数 = CPU核心数 * (1 + 平均等待时间/平均处理时间)
    例:数据库查询、网络调用。
  • 混合型:拆分成两个线程池,根据任务耗时比例分配。

6.2 实际案例

在订单处理微服务中,既有 CPU 密集的规则计算,也有大量 DB 和远程调用。

我们拆分两个池:

  • 计算池:core = Ncpu, max = Ncpu+2, SynchronousQueue
  • 业务池:core = 10, max = 50, ArrayBlockingQueue(500),处理大部分 I/O 操作。

最终都需要压测验证,公式只是起点。


七、线程池的监控(生产必备)

7.1 指标采集

通过 ThreadPoolExecutor 提供的 getter 方法暴露给 Prometheus 或日志。

java 复制代码
public void printPoolStats(ThreadPoolExecutor pool) {
    log.info("核心线程数: {}, 最大线程数: {}, 当前线程数: {}, 活跃线程数: {}, " +
             "队列中任务数: {}, 已完成任务数: {}, 拒绝任务数: {}",
             pool.getCorePoolSize(), pool.getMaximumPoolSize(), pool.getPoolSize(),
             pool.getActiveCount(), pool.getQueue().size(), 
             pool.getCompletedTaskCount(), 
             // 需要自定义包装才能获取拒绝计数
    );
}

7.2 监控指标面板

  • 队列积压:队列中等待任务数持续上升,说明消费能力不足,需扩容。
  • 活跃线程占比:长期接近 max,且队列不空,考虑增加 max 或提升机器。
  • 拒绝次数:发生拒绝说明线程池容量不足或下游出了问题。

7.3 实战案例:动态调整线程池

使用 Apollo/Nacos 等配置中心动态更新 core/max,结合 setCorePoolSize()setMaximumPoolSize() 实时生效。


八、线程池的优雅关闭

java 复制代码
public void shutdownGracefully(ThreadPoolExecutor pool) {
    pool.shutdown(); // 不再接收新任务,已提交的任务继续执行
    try {
        if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
            pool.shutdownNow(); // 尝试中断所有任务
            if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
                log.error("线程池未关闭成功,部分任务可能丢失");
            }
        }
    } catch (InterruptedException e) {
        pool.shutdownNow();
        Thread.currentThread().interrupt();
    }
}

注意shutdownNow 不保证能停止正在执行的任务,只是尝试中断,业务代码需响应中断。


九、常见陷阱与血泪史

  1. 用 Executors 创建线程池导致 OOM
    newFixedThreadPool / newSingleThreadExecutor 无界队列,newCachedThreadPool 最大线程数为 Integer.MAX_VALUE,都危险。

  2. 线程池中线程的异常被吞掉
    execute 提交的 Runnable 异常会导致线程消亡,任务丢失,必须用 submit + Future.get() 或自定义 UncaughtExceptionHandler

    java 复制代码
    t.setUncaughtExceptionHandler((thread, ex) -> log.error("线程异常", ex));
  3. submit 后不 get 异常被吞
    submit 返回的 Future,如果不调用 get 且任务抛出异常,你完全不知道。

  4. 线程池里用 ThreadLocal
    线程复用导致值污染,必须在任务结束时 remove()

  5. tomcat / web 容器共享线程池误区
    不要随意使用 servlet 容器线程池处理长耗时任务,应自定义线程池剥离。


十、面试串联话术(建议背诵)

"线程池的核心是一个 ThreadPoolExecutor,我理解它的工作流程是优先创建核心线程、满核心入队列、队列满开最大线程、最后拒绝。生产上我绝对不用 Executors 的快捷方法,而是用 ArrayBlockingQueue 做有界缓冲、自定义 ThreadFactory 命名线程、CallerRunsPolicy 或自定义拒绝策略保证任务不丢失。

线程数设置上,CPU 密集型用 CPU核数+1,I/O 密集型适当放大并压测验证。监控方面,我会定期采集队列大小、活跃线程数、拒绝次数到 Prometheus 并配置告警。

实际项目中我遇到过因 LinkedBlockingQueue 无界导致 OOM 的事故,后来全部替换为有界队列并动态调整参数。也踩过 submit 异常被吞、ThreadLocal 残留的坑,所以现在任务异常全部捕获记录,线程工厂设置 UncaughtExceptionHandler,并在 finally 块清理 ThreadLocal。"

这套话术结合了理论、源码流程、调优和真坑,能让面试官确认你是真正的线程池专家。