线程池这个问题,平时写业务时好像没什么存在感,很多代码里随手就是一个:
看起来也能跑任务也能异步执行线上一开始也不一定会出问题。但如果面试官问一句你们项目里的线程池是怎么用的怎么管理的这时候如果只回答一句“用Executors.newFixedThreadPool()”基本就比较危险了。因为生产环境里线程池不是简单创建几个线程来跑任务而是要控制资源、控制队列、控制拒绝策略还要能监控和调整。本文内容为什么不建议直接使用Executors常见内置线程池到底有什么问题ThreadPoolExecutor的几个核心参数怎么理解生产环境里线程池一般怎么创建项目中如何统一管理和监控线程池为什么不建议直接使用 Executors先用一张图把Executors的问题放到一起看它的风险并不只是“线程池怎么创建”而是默认参数把很多边界隐藏掉了。图里最需要关注的是两个边界队列有没有上限线程数有没有上限。这两个边界如果没有控制住任务高峰期就很容易从“异步处理”变成“异步堆积”。《阿里巴巴 Java 开发手册》中有一条比较常见的规范线程池不允许使用 Executors 去创建而是通过 ThreadPoolExecutor 的方式创建。这句话很多人都背过但不一定真正理解它的问题在哪里。我们先看一个最常见的FixedThreadPooljavaExecutorService executor Executors.newFixedThreadPool(10);从使用上看它创建了一个固定大小为 10 的线程池好像挺安全的因为线程数固定了不会无限创建线程。但问题不在线程数而在队列。newFixedThreadPool的源码如下javapublic static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueueRunnable()); }注意最后一行javanew LinkedBlockingQueueRunnable()LinkedBlockingQueue如果不指定容量默认容量是javaInteger.MAX_VALUE也就是说这个队列基本上可以认为是无界队列。如果线程池里有 10 个线程某一段时间内任务突然变多那么前 10 个任务会被线程执行后面的任务就会一直进入队列。因为队列几乎没有上限所以线程池不会拒绝任务任务只会越堆越多。如果任务生产速度一直大于消费速度最后占用的就是堆内存严重时就会导致 OOM。所以FixedThreadPool最大的问题不是“线程数固定”而是“队列没限制”。这也是为什么生产环境里一般要求使用ThreadPoolExecutor显式创建线程池把核心线程数、最大线程数、队列大小、线程工厂、拒绝策略都写清楚。几种内置线程池的问题Executors里提供了几种常见线程池FixedThreadPoolSingleThreadExecutorCachedThreadPoolScheduledThreadPool它们不是完全不能用而是不适合在生产代码里不加控制地直接用。我们分别来看一下。FixedThreadPool前面已经看过它的源码javapublic static ExecutorService newFixedThreadPool(int nThreads) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueueRunnable()); }它的参数相当于核心线程数等于最大线程数线程数固定使用无界LinkedBlockingQueue因为队列是无界的所以当核心线程都在忙时后续任务只会一直排队不会触发扩容也不容易触发拒绝策略。很多人以为固定线程池比较稳其实它只是把压力藏到了队列里。队列没满之前系统看起来都还正常等到内存撑不住时问题就已经比较严重了。SingleThreadExecutorSingleThreadExecutor的源码也很类似javapublic static ExecutorService newSingleThreadExecutor() { return new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueueRunnable()); }它只有一个工作线程后面的任务都会排队串行执行。如果只是少量后台任务问题不明显。但如果任务提交速度很快而这个单线程消费不过来任务还是会一直堆到无界队列里。所以它的问题和FixedThreadPool一样只是更隐蔽因为大家看到“单线程”时会觉得它更可控。实际上线程数是可控了队列还是不可控。CachedThreadPool再看CachedThreadPooljavapublic static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueueRunnable()); }这个线程池的特点是核心线程数为 0最大线程数是Integer.MAX_VALUE使用SynchronousQueue空闲线程 60 秒后回收SynchronousQueue比较特殊它不存任务。提交任务时必须马上有线程接收如果没有空闲线程就会创建新线程。这就带来一个问题如果任务提交很快任务执行又比较慢线程池就会不断创建新线程。线程并不是免费的。线程多了以后会带来线程栈内存占用也会带来大量上下文切换。严重时 CPU 会被切换消耗拖住内存也可能被打满。所以CachedThreadPool的风险不在队列而在线程数几乎没有上限。ScheduledThreadPoolScheduledThreadPool一般用来执行延迟任务或者周期任务。它底层使用的是延迟队列队列本身也没有一个业务意义上的容量限制。如果定时任务提交过多或者任务执行时间超过了调度周期也会出现任务堆积。比如一个任务每 1 秒调度一次但每次执行需要 5 秒如果没有控制好就容易产生积压。所以定时任务线程池也不能只关注线程数还要关注任务是否堆积、任务执行耗时是否超过周期。ThreadPoolExecutor 的几个核心参数理解ThreadPoolExecutor的参数之前最好先把任务提交后的执行顺序搞清楚。很多线程池问题都是因为误以为“最大线程数会马上生效”。这张图的关键点是核心线程满了以后任务会先进入队列只有队列也满了才会继续创建非核心线程。所以队列类型和队列容量会直接影响maximumPoolSize是否有机会发挥作用。既然不建议直接使用Executors那我们就要自己创建ThreadPoolExecutor。它常用的构造方法如下javapublic ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueueRunnable workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler);这些参数不是随便填的线程池在高峰期怎么表现基本都由它们决定。corePoolSizecorePoolSize表示核心线程数。当任务提交到线程池时如果当前线程数还没有达到corePoolSize线程池会创建新线程来执行任务。如果当前线程数已经达到corePoolSize任务就会进入队列等待。所以核心线程数太小任务容易排队核心线程数太大又会造成线程资源浪费甚至带来更多上下文切换。一般会根据任务类型来估算一个初始值。如果是 CPU 密集型任务比如计算、加密、压缩等线程数通常可以设置为textCPU 核心数 1如果是 IO 密集型任务比如访问数据库、Redis、RPC 接口、文件、网络等线程经常处于等待状态线程数可以适当多一些。常见估算公式是text线程数 CPU 核心数 * (1 IO 耗时 / CPU 耗时)不过这个公式只能给一个初始值不能当成最终答案。真正的参数还是要结合压测和线上监控来调整。maximumPoolSizemaximumPoolSize表示线程池允许创建的最大线程数。它不是一开始就生效的。线程池只有在下面几个条件都满足时才会继续创建非核心线程核心线程已经满了队列也满了当前线程数还小于maximumPoolSize这里有一个很容易被忽略的点如果使用的是无界队列那么maximumPoolSize基本就没什么机会生效。因为核心线程满了之后任务会一直进入队列而队列又几乎不会满所以线程数最多也就到corePoolSize。这也是为什么我们不建议用无界队列。无界队列不仅可能导致 OOM还会让最大线程数这个参数失去意义。keepAliveTimekeepAliveTime控制的是非核心线程的空闲存活时间。当线程池里的线程数超过corePoolSize后多出来的线程就是非核心线程。如果这些线程空闲时间超过了keepAliveTime就会被回收。默认情况下核心线程不会因为空闲而回收。如果希望核心线程也能超时回收可以这样设置javathreadPoolExecutor.allowCoreThreadTimeOut(true);不过这个配置要看场景。如果某个线程池使用频率很高核心线程频繁创建和销毁反而会增加开销。如果是低频任务或者任务波动比较大可以考虑让核心线程也支持超时回收。workQueue队列是线程池里非常关键的一个参数。它决定了任务来了以后是先排队还是扩容线程还是直接触发拒绝策略。生产环境里最重要的一点是队列最好有容量限制。ArrayBlockingQueueArrayBlockingQueue是基于数组实现的有界队列创建时必须指定容量javanew ArrayBlockingQueue(1000)它的特点是容量固定内存相对可控比较适合对稳定性要求比较高的业务线程池。缺点是生产者和消费者共用一把锁在并发非常高时吞吐一般但很多业务场景下已经够用了