香蕉大香蕉(06敬畏市场)头像
高频八股 · Java 并发编程(9):线程池(下)封面图

高频八股 · Java 并发编程(9):线程池(下)

高频八股 · Java 并发编程(9):线程池(下)——拒绝策略、调优与那些坑

写在前面

一、四种拒绝策略

当线程数到顶 + 队列也满的时候,就轮到 RejectedExecutionHandler 出场了。

JDK 内置了四种:

策略行为副作用
AbortPolicy(默认)直接抛 RejectedExecutionException不捕获就会丢任务 + 抛异常
CallerRunsPolicy交给提交任务的线程自己执行会阻塞调用方,等于"反向降速"
DiscardPolicy静默丢弃新任务丢了你也不知道
DiscardOldestPolicy丢掉队列里最老的那个,再尝试提交老任务被悄悄挤掉

怎么选?

  • 想尽快发现问题 → AbortPolicy(默认,别吞异常);
  • 想不丢任务、能接受变慢 → CallerRunsPolicy,天然限流;
  • DiscardPolicy / DiscardOldestPolicy 慎用,除非你明确允许丢任务。

实战里更推荐——自定义:

public class MyRejectHandler implements RejectedExecutionHandler {
    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        // 1. 记日志 / 打点告警
        // 2. 落库,后续补偿重试
        // 3. 或者降级:走同步链路
        log.warn("任务被拒绝,当前活跃线程 {}, 队列长度 {}",
                 e.getActiveCount(), e.getQueue().size());
    }
}

核心思路:拒绝不是终点,要留痕、要能补偿。

二、Executors 的四个"坑"

Executors 提供了几个一行创建线程池的方法,用起来很爽,但生产环境基本不建议。

方法队列 / 线程数风险
newFixedThreadPool(n)LinkedBlockingQueue(无界,默认 Integer.MAX_VALUE)任务堆积 → OOM
newSingleThreadExecutor()同样无界队列同上
newCachedThreadPool()SynchronousQueue + max = Integer.MAX_VALUE线程无限创建 → OOM
newScheduledThreadPool(n)DelayedWorkQueue(无界)任务堆积 → OOM

两个典型事故:

  1. 无界队列:任务生产速度 > 消费速度,队列无限涨,最后 OutOfMemoryError: Java heap space;
  2. 无限线程:CachedThreadPool 在高并发下一口气创建上万线程,直接打爆内存 / CPU。

结论:生产环境手动 new ThreadPoolExecutor(...),明确指定有界队列和最大线程数。

三、参数怎么调?

① 线程数

任务类型经验公式
CPU 密集型N + 1(N = CPU 核数)
IO 密集型2N 左右,更严谨是 N × (1 + 等待时间 / 计算时间)

别照抄公式。真正靠谱的方法是:压测 + 监控 + 逐步调。

② 队列容量

  • 必须是有界队列(ArrayBlockingQueue 或指定容量的 LinkedBlockingQueue);
  • 太小 → 频繁触发拒绝;太大 → 堆积、延迟高。要结合业务能接受的最大排队时长来定。

③ 线程工厂

建议自定义,给线程起个有意义的名字:

ThreadFactory factory = r -> {
    Thread t = new Thread(r);
    t.setName("order-pool-" + t.getId());
    return t;
};

出问题时,jstack 里一眼能看出是哪个池的线程,排查效率差好几倍。

④ 监控指标

线上至少要盯这几个:

  • getActiveCount():活跃线程数
  • getQueue().size():队列积压
  • getCompletedTaskCount():已完成任务数
  • getLargestPoolSize():历史峰值线程数

四、线程池最容易踩的 5 个坑

① submit() 把异常吞了

pool.submit(() -> { throw new RuntimeException("boom"); });  // 没有任何输出

submit() 的异常被封装进 Future,不调 get() 你根本发现不了。想及时暴露,用 execute(),或者在任务内部 try-catch。

② 父子任务同池,可能死锁

在一个线程池里提交任务,然后同步等待子任务结果——如果池子被父任务占满,子任务永远排不上队,直接卡死。

③ 忘了 shutdown()

线程池不关,JVM 可能不退出,也可能一直占着资源。用完记得 shutdown()(温和)或 shutdownNow()(立即)。

④ ThreadLocal 没清理

线程是复用的,ThreadLocal 里的值会跟着线程活下来,可能造成内存泄漏或数据串号。用完必须 remove()(下一期细讲)。

⑤ 拒绝策略选错,任务悄悄消失

用了 DiscardPolicy,任务丢了却毫无感知,等到业务方发现"订单没生成"已经晚了。

五、ForkJoinPool:分治 + 工作窃取

ForkJoinPool 是 JDK 7 引入的,专为可拆分的计算密集任务设计。

核心机制:工作窃取(Work-Stealing)

  • 每个线程都有自己的双端队列(Deque);
  • 线程从自己队列的头部取任务执行;
  • 空闲的线程,会去别的线程队列尾部"偷"一个任务来干。

为什么快? 因为空闲线程不会傻等,而是主动找活干,CPU 利用率更高。

用法:继承 RecursiveTask<V>(有返回值)或 RecursiveAction(无返回值):

class SumTask extends RecursiveTask<Long> {
    private final long[] arr;
    private final int start, end;

    SumTask(long[] arr, int start, int end) {
        this.arr = arr; this.start = start; this.end = end;
    }

    @Override
    protected Long compute() {
        if (end - start <= 1000) {          // 足够小,直接算
            long sum = 0;
            for (int i = start; i < end; i++) sum += arr[i];
            return sum;
        }
        int mid = (start + end) / 2;
        SumTask left = new SumTask(arr, start, mid);
        SumTask right = new SumTask(arr, mid, end);
        left.fork();                         // 拆出去
        long r = right.compute();             // 自己算一半
        return left.join() + r;               // 合并
    }
}

用在哪? 数组求和、归并排序、大文件处理这类"能拆成子问题"的场景。Java 8 的 parallelStream 底层用的就是 ForkJoinPool.commonPool()。

注意:IO 密集任务不要用 ForkJoinPool——它假设任务不会阻塞,阻塞会拖垮整个池。

六、高频面试题速答

Q1:线程池的四种拒绝策略?
AbortPolicy(抛异常,默认)、CallerRunsPolicy(调用者执行)、DiscardPolicy(静默丢弃)、DiscardOldestPolicy(丢最老的)。

Q2:为什么不推荐用 Executors 创建线程池?
Fixed/Single/Scheduled 用无界队列,可能 OOM;Cached 的最大线程数是 Integer.MAX_VALUE,可能创建过多线程。生产建议手动 new。

Q3:线程池的核心线程数怎么设?
CPU 密集 N+1,IO 密集 2N 或 N×(1+等待/计算);实际应结合压测和监控调整。

Q4:submit() 和 execute() 在异常处理上的区别?
execute() 的异常会直接抛出(线程池会打日志);submit() 把异常包在 Future 里,不 get() 就看不到。

Q5:线程池会导致死锁吗?
会。典型场景是"父任务在池内同步等待子任务",池被占满后子任务进不来,互相等待。

Q6:ForkJoinPool 的原理?
工作窃取:每个线程有独立双端队列,空闲线程从别的队列尾部偷任务,提高 CPU 利用率;适合可拆分的计算密集任务。

七、一句话总结

线程池用得对不对,就看四件事:队列有界、拒绝留痕、线程有名字、用完要关闭。 记住这句,线上 80% 的线程池事故都能避开。

(本系列持续更新中,共 10 期,敬请关注~)

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/2401_88601046/article/details/168884109

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--