面试题:线程池
线程池这东西,说简单也简单,说复杂也能绕进死胡同。我刚入职那会儿,听到“线程池”三个字,脑子里就浮现出一群线程泡在热水里泡澡,谁来了就蹦出来干活。现在回头看,当时那个画面属实抽象了点,但居然也挺贴切——线程池的核心思想,还真就是“热水永远烧着,有活干就上,干完继续泡着等下一波”。
今天咱们就聊聊线程池到底是个什么玩意儿,它是怎么运作的,为什么说它是 Java 并发编程里的“中流砥柱”。放心,我不整那些死板的术语,全程口语化输出,看完你保准能明白这货怎么回事,甚至能在代码里整出点花活。
一、线程池到底是个啥?
我们先来点通俗的。
打个比方,线程就是工人,任务就是活儿,那你现在是老板,你有一堆活要安排人干。
你要是每来一单活,就临时去街上招个工人回来,等他干完再遣散,这时间成本高得吓人;如果活不稳定,工人你请也不是,不请也不是,工资白给了。
线程池这时候就像一个工人中介,你提前准备好了一批“熟练工”,把他们都安置在一个“候工区”里(也就是线程池),有活就安排他们上,干完回座继续等着。来活频繁的时候还能叫点临时工,但你自己得设个上限,防止人太多挤爆现场(OOM)。
这就是线程池的本质:提前准备好线程资源,复用线程,控制线程数量,避免频繁创建销毁线程带来的开销和不确定性。
二、为啥大家都在用线程池?
如果你还在写 new Thread(() -> doSomething()).start()
这种代码,得,我告诉你个现实:你这写法不仅不优雅,还很“原始”。在高并发场景下直接 new 线程,简直是 CPU 杀手,内存爆破器。
线程的创建、上下文切换、销毁这些操作都不是白来的,尤其是高并发下,一不留神就会造成系统性能雪崩。线程池就是来解决这个问题的。
线程池的好处主要有这几个:
-
省资源:线程创建和销毁本身就是系统开销,复用线程自然更节能环保;
-
响应快:活一来,线程现成的,马上上手;
-
可控性强:线程数量你说了算,活太多还可以设置队列或者拒绝策略,避免被“榨干”。
说白了,线程池就是你手下那批能打硬仗、讲效率、可管理的兵。
三、线程池怎么玩?
ThreadPoolExecutor
是 Java 线程池的灵魂人物,如果说 Executors
是个糖衣炮弹,那 ThreadPoolExecutor
就是你亲手定制的钢铁侠,配置自由、逻辑清晰。
它的构造方法长得像这样:
public ThreadPoolExecutor(
int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler
)
每个参数都不是摆设,我们一个个扒开讲。
核心线程数(corePoolSize)
这部分线程是线程池的“固定班底”,也就是工地上的常驻工人。只要有活,他们就上,没活他们也在等,除非你手动让他们“闲了就走”(用 allowCoreThreadTimeOut(true)
设置)。
最大线程数(maximumPoolSize)
这是线程池能招的最多的“工人总数”。一旦核心线程都忙不过来了,而且任务队列也塞满了,那只能临时招人(非核心线程)来帮忙,但你得设个上线,不然工地炸锅。
存活时间(keepAliveTime + unit)
非核心线程的“饭点时间”。如果他们干完活没事做,等的时间超过这个值就自动撤了,回老家种地去了。
队列(workQueue)
这个队列就是活的等待区。核心线程忙不过来了,任务就先排队;队列也满了?那就看能不能临时多招人;实在不行?那就用拒绝策略处理。
线程工厂(threadFactory)
就是你招聘用的“招人平台”,默认就行,一般不动它,除非你有特殊需求,比如给每个线程设置个名字啥的。
拒绝策略(handler)
真滴没线程用了,任务也排不进队列了,那咋整?这时候就得看你的“拒绝策略”了。
-
AbortPolicy
(默认):直接抛异常,任务别想跑; -
CallerRunsPolicy
:你主线程干; -
DiscardPolicy
:悄无声息丢掉; -
DiscardOldestPolicy
:把队列里最早的任务扔掉,给新任务让路。
四、不要用 Executors
创建线程池
你可能见过这些写法:
Executors.newFixedThreadPool(10);
Executors.newSingleThreadExecutor();
Executors.newCachedThreadPool();
写起来确实爽,一行搞定。但是!《阿里巴巴 Java 开发手册》都告诉你别这么玩。
为啥?因为你根本不知道背后线程池的参数长啥样,尤其是队列容量和最大线程数这两个关键项,全都给你默认了:
比如说 newFixedThreadPool()
背后的代码其实是:
new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
LinkedBlockingQueue
默认大小是多少?Integer.MAX_VALUE
!
你以为你只创建了 10 个线程,其实你可能把任务排到队列里去了几百万个,JVM 笑嘻嘻地飘走,剩你拿着 OOM 的日志发呆。
所以,正确的做法是:用 ThreadPoolExecutor
自己配参数,明确每一个线程池的行为逻辑,不给 JVM 留“自由发挥”的空间。
五、核心线程会被回收吗?
默认不会。核心线程的设计目的就是“长期蹲守”,但你可以通过:
executor.allowCoreThreadTimeOut(true);
来让它们“闲了就撤”。前提是你得先设置 keepAliveTime
大于 0。
这点特别适用于那种“周期性任务高峰”的场景,比如说每天下午三点有一波流量,那你可以让核心线程在平时也别白吃饭,等够时间就回收。
六、线程是怎么从队列里取活干的?
这个细节很有意思。
线程池内部维护了一个 Worker
类,它其实就是包了一层的线程,不停地从队列里拿活:
-
如果你没设置
allowCoreThreadTimeOut
,或者这个线程是核心线程,它会用take()
—— 就是一直接收,干等也等; -
如果是非核心线程,那就用
poll()
—— 超时没任务就走人。
核心线程的“死等”和非核心线程的“限时等”机制,其实也保证了线程池的弹性。
源码大概长这样:
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
这就是线程池自我调节的地方。根据你的配置自动判断用多少人,保留多少人,走得也不含糊。
七、实战建议
写线程池时,强烈建议你自己手动配置,比如这样:
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // 核心线程数
8, // 最大线程数
60, // 非核心线程最大空闲时间
TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100), // 有界队列
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy() // 拒绝策略
);
这个配置的含义是:我先招 4 个工人,有活他们先上;活太多了,最多扩到 8 个;任务先放队列,最多排 100 个;再多?我直接抛异常,你自己看着办。
这样配出来的线程池行为全在你掌控之中,不会突然因为队列太大或线程太多而崩掉。
写在最后
线程池这东西,看着像是 Java 高并发编程里的一个“基础组件”,但它的作用远比你想象的大。很多人写代码只关心业务逻辑,线程池就随手一写,结果一上线,内存飙了、CPU 爆了,然后一脸懵逼地看着报警信息说:“不是用了线程池吗?”
用线程池不是目的,用好线程池才是正道。
搞清楚线程池是怎么运作的,能省下你无数个线上救火的夜晚。