☰
Java资源隔离实战:ThreadPoolExecutor与Semaphore源码级拆解
2026/10/10 18:21:18 网站建设 项目流程

我看过不少简历,写“熟悉 Java 并发编程”的人很多,但一聊到资源隔离,大多数人只会背一句话:用线程池隔离,再用信号量限流。你再追问一句:ThreadPoolExecutor 在提交任务时底层是怎么把任务塞进队列的?Semaphore 的计数器是靠什么保证原子性的?场面往往会安静三秒钟。

这篇文章不打算念课本,我就站在面试官和一线开发者的角度,把 Java 资源隔离这件事拆透了。核心就两个类:ThreadPoolExecutor 和 Semaphore,配合源码逐行讲清楚,顺便给出一套能直接抄进项目的组合玩法。无论你是准备面试,还是正在治理线上故障,这篇文章都能给你一些硬货。

1. 资源隔离在解决什么问题

1.1 从一次线上事故说起

先讲个真实场景。订单服务要调用外部会员接口,这个会员接口平时很稳,但有一次大促它突然变慢,单次响应从 20ms 涨到 5 秒。订单服务用的 Tomcat 默认线程池,200 个线程很快就全部阻塞在这个下游调用上。结果是:一个下游慢接口,把整个订单服务拖到瘫痪,连不依赖会员接口的库存查询、优惠券计算也跟着不可用。

这就是典型的缺乏资源隔离。没有把“不同依赖、不同业务”之间的线程资源物理隔开时,任何一个慢通道都可能成为系统的癌症。解决思路有两个:要么给不同依赖分配独立线程池,要么用信号量控制并发访问的下限和上限。这两个方案在面试里被反复问,在工程里也被反复用,但很多人只是听过名词,没有真正理解它们的边界。

1.2 线程池隔离和信号量隔离的本质区别

先别急着背概念,我用大白话解释。

线程池隔离,本质是给每个子系统或外部依赖划一块“独立的地盘”。比如 A 调用用线程池 A,B 调用用线程池 B,A 慢成狗,最多消耗完 A 池子里的线程,B 池子依然有可用线程,业务照跑。这种隔离更彻底,因为它把线程资源物理分隔开了。缺点也明显:创建多个线程池意味着更多线程、更多上下文切换、更高内存占用,而且线程池内部的任务如果长时间阻塞,池里的线程还是会被占住。

信号量隔离,则是给某个共享资源或某条调用链路设一个并发通行上限。Semaphore 本身不创建线程,它只维护一个许可证计数器,线程执行前申请许可证,执行完归还。它可以精准控制“同一时刻最多有多少个线程在跑这段代码”,但对“谁去跑这些任务”不关心。它的优势是轻量、灵活,缺点是没有真正的隔离边界,如果其他模块也在使用同一个线程池,信号量只能限制这一把闸门,挡不住别人把线程耗尽。

表格对比看起来更直观:

对比项线程池隔离信号量隔离
隔离资源线程资源并发许可数量
是否创建线程创建并管理线程不创建线程
性能开销较高,线程切换有成本较低,CAS 成本远小于线程切换
防护目标防止某个调用耗尽所有线程防止某个调用超出并发上限
典型使用场景不同下游依赖分别独立执行控制数据库连接、外部接口并发

1.3 面试官想听到的选型逻辑

面试官问“你选线程池隔离还是信号量隔离”,他不是真想听你背定义,而是想看你有没有工程判断力。

我的选型逻辑一般来说是这样:如果下游依赖质量不可控,比如第三方接口经常超时、响应时间波动巨大,优先线程池隔离,因为它能保护整个 JVM 的线程资源不被一个依赖拖垮。如果只是单纯想限制某个热点操作的并发量,比如防止数据库连接被打满、防止缓存穿透时大量请求同时打到后端,用信号量更合适,毕竟线程池的成本摆在那里,为每个接口各开一个池子,资源浪费严重。

还有一个容易被忽略的点:线程池隔离通常配合超时时间用,比如 Future.get(timeout),否则线程池再隔离,任务拿不到结果也不会释放线程,时间长了照样把池子占满。信号量隔离则可以配合 tryAcquire(timeout),拿不到许可就快速失败或降级,比无限阻塞优雅得多。

2. ThreadPoolExecutor 源码级拆解

2.1 七个构造参数,每个参数暗藏坑

ThreadPoolExecutor 有七个参数,很多人背得滚瓜烂熟,但真正被问到底层含义时容易翻车。逐个过一遍,重点说容易被误解的地方。

  • corePoolSize:核心线程数。默认情况下,核心线程创建后即使空闲也不会被回收。
  • maximumPoolSize:最大线程数。线程池允许存在的最大线程数量。
  • keepAliveTime:非核心线程空闲存活时间。如果 allowCoreThreadTimeOut 为 true,核心线程也会受这个参数影响。
  • unit:时间单位,和 keepAliveTime 配合使用。
  • workQueue:工作队列,存放来不及执行的任务。
  • threadFactory:线程工厂,用于创建线程,可以自定义线程名、优先级、是否守护线程。
  • handler:拒绝策略,当线程池无法接收新任务时执行的兜底方案。

第一个坑:很多人以为“任务来了,先创建核心线程,核心线程满了直接创建非核心线程”。不对,真正的顺序是:任务来了,优先创建核心线程跑;核心线程满了,任务进队列;队列也满了,才会创建非核心线程;线程数达到 maximumPoolSize 且队列还是满,触发拒绝策略。也就是说,maximumPoolSize 的线程不是随便就能创建的,队列满是一个硬前提。

第二个坑:队列类型的选择直接决定线程池行为。LinkedBlockingQueue 如果不设置容量,就是无界队列,任务永远堆在队列里,maximumPoolSize 形同虚设,因为队列永远不会满,非核心线程永远不会创建,拒绝策略也永远不会触发。SynchronousQueue 不存储任务,一个生产线程必须等待一个消费线程,适合“任务直接交给线程处理,不排队”的场景。ArrayBlockingQueue 有界,最推荐用于生产环境,因为堆多任务本质上就是堆内存风险。

2.2 execute() 提交任务的完整链路

面试最喜欢让把 execute 方法源码背出来。这里直接贴 Java 8 里的核心代码,逐行讲:

public void execute(Runnable command) { if (command == null) throw new NullPointerException(); int c = ctl.get(); if (workerCountOf(c) < corePoolSize) { if (addWorker(command, true)) return; c = ctl.get(); } if (isRunning(c) && workQueue.offer(command)) { int recheck = ctl.get(); if (!isRunning(recheck) && remove(command)) reject(command); else if (workerCountOf(recheck) == 0) addWorker(null, false); } else if (!addWorker(command, false)) { reject(command); } }

第一步,判断当前线程数是否小于核心线程数。如果是,直接 addWorker 创建核心线程执行任务,任务不进队列。addWorker 失败说明线程池已经处于非 RUNNING 状态或者线程数已经超过限制,这时要重新读取 ctl。第二步,如果线程数已经达到核心线程数,并且线程池处于 RUNNING 状态,尝试把任务放进队列。offer 是非阻塞入队,队列满了会返回 false。这里有个关键细节:任务成功入队后,还要做一次状态复查。为什么?因为任务入队和后续检查之间,可能有人调用了 shutdown,线程池状态变成 SHUTDOWN,此时不再接受新任务,但任务已经在队列里了,所以需要通过 recheck 发现状态变化,然后移除任务并触发拒绝策略。如果 recheck 时发现线程池里一个线程都没有了,比如核心线程被全部回收,还得补一个 worker 处理队列里的任务。第三步,如果入队失败,说明队列满了,尝试用 addWorker 创建非核心线程来执行任务。如果非核心线程也创建不了,说明线程数已经到了 maximumPoolSize,直接执行拒绝策略。

addWorker 里面还有一个容易被问到的细节:Worker 继承 AQS,自己实现了一个不可重入的互斥锁。很多人不理解为什么不直接用 ReentrantLock。原因是线程池在 shutdown 时要中断空闲线程,而在线程正在执行任务时又不想中断它。Worker 用 AQS 实现锁,执行任务时锁被占用,interruptIdleWorkers 拿不到锁就不会打断正在工作的线程;任务执行完释放锁,空闲线程可以被安全中断。这个设计是线程池源码里非常精妙的一笔,值得反复体会。

2.3 线程池状态机和拒绝策略的源码真相

线程池内部用 ctl 这个 AtomicInteger 同时保存线程状态和线程数量:高 3 位保存 runState,低 29 位保存 workerCount。源码里大量通过位运算来拆分这两个值,比如:

private static final int COUNT_BITS = Integer.SIZE - 3; private static final int CAPACITY = (1 << COUNT_BITS) - 1; private static int runStateOf(int c) { return c & ~CAPACITY; } private static int workerCountOf(int c) { return c & CAPACITY; }

这招非常实用,用一个 int 原子变量同时管理两个字段,避免加锁。线程池共有五种状态:RUNNING 接收新任务并处理队列任务;SHUTDOWN 不接收新任务但继续处理队列任务;STOP 不接收新任务,不处理队列任务,中断正在执行的任务;TIDYING 所有任务已结束,即将执行 terminated;TERMINATED 完全终止。状态之间是单向流转的,shutdown 会让 RUNNING 变成 SHUTDOWN,shutdownNow 会让 RUNNING 或 SHUTDOWN 变成 STOP。

拒绝策略是最后一个兜底环节,四种内置策略各有脾气:

策略行为适用场景
AbortPolicy直接抛 RejectedExecutionException默认策略,适合明确需要感知失败的业务
CallerRunsPolicy在提交任务的线程里执行被拒绝的任务想利用调用线程兜底,降低请求丢弃率
DiscardPolicy静默丢弃任务不重要的日志、监控类任务
DiscardOldestPolicy丢弃队列头部的任务,然后重新提交希望保留最核心、最新的任务时

生产环境中我一般的做法是自定义拒绝策略,落一条监控或者告警,而不是默默丢任务或者直接抛异常,这样才能及时发现问题。

3. Semaphore 源码级拆解

3.1 AQS 是信号量的地基

Semaphore 的源码核心在内部类 Sync,而 Sync 直接继承 AbstractQueuedSynchronizer(AQS)。AQS 是 Java 并发包的基石组件,它提供了一个 volatile int state 作为同步状态,加上一个 FIFO 的等待队列。Semaphore 的 permits 就存在这个 state 里面:state 表示当前剩余许可证数量。所有对许可证的获取和释放,本质上都是对 state 的原子操作。

看 NonfairSync 的 tryAcquireShared 源码:

final int nonfairTryAcquireShared(int acquires) { for (;;) { int available = getState(); int remaining = available - acquires; if (remaining < 0 || compareAndSetState(available, remaining)) return remaining; } }

这里用了一个自旋 CAS:先读当前剩余许可证,减掉申请的许可证数,如果剩余数量小于 0,说明许可证不够,直接返回负数;如果足够,就用 CAS 把 state 更新成新值。如果 CAS 失败,说明有其他线程同时修改了 state,就重新循环再试。CAS 是 CPU 指令级的原子操作,比加锁轻量得多。正因为有了 AQS 和 CAS,Semaphore 的并发控制才不需要额外加 synchronized。

3.2 acquire/release 到底做了什么

Semaphore 的 acquire 方法有多个变体:无参 acquire 会响应中断,acquireUninterruptibly 不响应中断,tryAcquire 立即返回,tryAcquire(timeout, unit) 支持超时等待。核心流程都一样,以 acquire 为例:

public void acquire() throws InterruptedException { sync.acquireSharedInterruptibly(1); }

AQS 的 acquireSharedInterruptibly 会先尝试 tryAcquireShared,只要返回负数就说明当前线程需要排队等待,于是把当前线程包装成 Node 放进等待队列,然后通过 LockSupport.park 挂起。当其他线程执行 release 时,会重新尝试让出许可证,然后唤醒队列头部的等待线程。

release 方法代码如下:

public void release() { sync.releaseShared(1); }

AQS 的 releaseShared 会调 tryReleaseShared,Semaphore 里实现为 CAS 把 state 加回去,然后唤醒阻塞中的线程。这里有个特别容易踩的坑:Semaphore 的许可证不归属任何具体线程。A 线程 acquire 之后,B 线程也可以把这个许可证 release 出来。所以代码里必须保证谁申请、谁释放,而且释放操作要放在 finally 里,否则任务抛异常,许可证就永久丢失了,最终所有线程都会阻塞在 acquire 上。

3.3 公平模式与非公平模式的选择

Semaphore 构造时可以传公平标志,比如 new Semaphore(10, true)。公平和非公平的差别在 tryAcquireShared 的实现上。

公平版会多一个判断:

protected int tryAcquireShared(int acquires) { for (;;) { if (hasQueuedPredecessors()) return -1; int available = getState(); int remaining = available - acquires; if (remaining < 0 || compareAndSetState(available, remaining)) return remaining; } }

hasQueuedPredecessors 用来检查等待队列里有没有排在前面的线程。如果有,当前线程哪怕许可证足够也不能直接拿,必须老实排队。非公平版则不管队列,先抢一把再说,抢不到再排队。

实际选型的话,我建议大部分场景用非公平模式,吞吐量更高。公平模式适合对响应顺序有严格要求的场景,但代价是线程频繁被挂起唤醒,性能下降明显。还有一个折中方案是使用 tryAcquire 而不是阻塞式 acquire,让拿不到许可证的业务快速走降级逻辑,而不是排队排到超时。

4. ThreadPoolExecutor + Semaphore 组合玩法

4.1 最常见的组合缺陷:无界队列把信号量架空

很多人说“我用线程池隔离 + 信号量限流”,但代码一写,全错。最常见的是把 Semaphore 放在任务内部:

executor.execute(() -> { semaphore.acquire(); try { // 真实业务 } finally { semaphore.release(); } });

这种写法的问题在于:任务已经提交进了线程池队列,信号量只是控制任务内部真正执行的并发数。如果队列是无界的,任务会疯狂堆积,信号量再小也拦不住内存被打爆。更严重的是,线程池的拒绝策略在这种情况下永远不会被触发,因为任务永远有地方排队。信号量确实限制了同时执行的任务数,但没有限制积压任务数。

所以信号量必须放在提交之前,在任务还没进队列时就完成限流。

4.2 更合理的组合姿势:信号量包在线程池外面

我推荐的做法是自定义一个 ExecutorService 包装类,把信号量的 acquire 放在 execute 之前:

public class SemaphoreExecutorService implements ExecutorService { private final ExecutorService delegate; private final Semaphore semaphore; public SemaphoreExecutorService(ExecutorService delegate, int permits) { this.delegate = delegate; this.semaphore = new Semaphore(permits); } @Override public void execute(Runnable command) { semaphore.acquire(); try { delegate.execute(() -> { try { command.run(); } finally { semaphore.release(); } }); } catch (RejectedExecutionException e) { semaphore.release(); throw e; } } @Override public <T> Future<T> submit(Callable<T> task) { semaphore.acquire(); try { return delegate.submit(() -> { try { return task.call(); } finally { semaphore.release(); } }); } catch (RejectedExecutionException e) { semaphore.release(); throw e; } } }

这样做的效果是:信号量限制的是“已经提交到线程池但还没执行完的任务总数”,而不是“正在执行的任务数”。任务一旦提交成功,许可证就被占用,直到任务真正执行完才释放。线程池的队列里最多积压 permits 减去核心线程数的任务量,内存风险被控制住了。如果线程池已经关闭,拒绝策略触发,必须顺手释放许可证,否则调用方线程在 acquire 上越等越久。

细节上要特别注意:submit 方法返回的 Future,在异常时其实已经在 finally 里释放了许可证,但外部拿到的 Future 会抛出 ExecutionException,调用方需要捕获并决定是否重试。重试的话,会再次 acquire,相当于把流量重新放进闸门,这是合理的降级策略。

4.3 配置参数计算与实测效果

参数怎么定?不能拍脑袋。我一般用排队理论的 Little's Law 做估算:并发线程数约等于 QPS 乘以平均响应时间。假设目标 QPS 是 1000,下游 P99 耗时 80ms,那理论并发就是 1000 * 0.08 = 80。核心线程数取 80 到 100 之间比较合理,最大线程数可以留出缓冲,比如 120。队列容量取决于你愿意让请求等多久。假设容忍排队等待 200ms,那队列容量大约等于 1000 * 0.2 = 200。如果信号量要控制“提交但未完成”的任务总量,可以设为最大线程数加上队列容量,也就是 120 + 200 = 320。

我实际的压测经验是:把 Semaphore 的 permits 设置成最大线程数加队列容量,确实能保证任务不会无限堆积;但在高峰期,acquire 会阻塞住上游线程,如果上游没有设置超时,调用方可能集体卡死。所以更稳妥的方案是使用带超时的 tryAcquire:

if (!semaphore.tryAcquire(100, TimeUnit.MILLISECONDS)) { // 快速失败,走降级逻辑 throw new ServiceUnavailableException("系统繁忙"); }

这样等于把信号量从“阻塞闸门”变成了“快速失败闸门”。对调用方来说,拿不到许可证直接返回 503 或者提示稍后重试,远比默默排队然后超时好。我曾经在压测环境对比过:不加信号量时,线程池队列积压 2 万任务,接口响应时间从 80ms 涨到 8 秒;加上信号量并开启快速失败后,响应时间稳定在 100ms 左右,失败率控制在 5% 以内,用户体验反而好得多,因为失败是立刻返回的,请求不会堆积成雪崩。

5. 面试连环炮与避坑实战

5.1 高频追问 Top 5

把我面试别人和被别人面试遇到的问题整理一下,排名不分先后。

  1. corePoolSize 和 maximumPoolSize 之间,线程数是怎么变化的?很多人的答案是错的。真实顺序是:核心线程 → 队列 → 非核心线程 → 拒绝策略。队列满是非核心线程创建的前提。
  2. ThreadPoolExecutor 的 Worker 为什么要继承 AQS?简单回答是为了实现一个不可重入锁,让中断空闲线程时不会误伤正在执行任务的线程。能说到这一层,基本就过关了。
  3. Semaphore 和 CountDownLatch 有什么区别?Semaphore 是控制并发数量的计数器,可以反复 acquire/release;CountDownLatch 是门闩,只能等待指定数量的线程完成后放行,不能复位。
  4. 信号量能不能完全替代线程池隔离?不能。信号量不创建线程,不隔离线程资源,如果系统只有一条线程在跑业务,用信号量等于自己打自己。线程池隔离解决的是资源独占问题,信号量解决的是并发上限问题。
  5. execute 提交任务时,任务先入队还是先创建非核心线程?先入队,队列满才创建非核心线程。原因是为了复用核心线程,尽量避免线程数量频繁扩展收缩。

5.2 我在生产环境踩过的三个坑

第一个坑是信号量许可证泄漏。早期做接口级限流时,业务代码在 acquire 之后出现了异常,release 写在了 try 块之外的某个分支里,异常路径直接跳过 release。线上跑了三天,所有线程全部阻塞在 acquire,服务完全不可用。排查时 jstack 一看全是 WAITING 状态,立刻意识到许可证没了。从那以后我给自己定了一条死规矩:acquire 紧跟 try-finally,release 永远在 finally 里。

第二个坑是使用无界队列配合线程池隔离。当时为了性能,用了默认容量的 LinkedBlockingQueue,结果某个慢依赖把队列积压到几万个任务,内存蹭蹭往上涨。最后通过对堆转储的分析,发现大量 Runnable 对象堆积成串。后来统一换成了有界队列,并且把拒绝策略改成自定义策略,记录告警而不是抛异常。

第三个坑是 CallerRunsPolicy 和信号量叠加的隐藏问题。如果信号量包在提交之前,而线程池的拒绝策略是 CallerRunsPolicy,被拒绝的任务会在调用方线程里同步执行。这意味着调用方线程既要走 acquire 等待许可证,又要负责跑任务,非常容易导致调用方线程阻塞,然后调用链路上游的资源也被占用。所以组合弹窗时,要么用 CallerRunsPolicy 但把信号量的 acquire 放在包装层之外并且使用 tryAcquire,要么干脆用自定义拒绝策略做降级。

5.3 排查资源耗尽问题的工具与方法

线上遇到线程池被打满或者信号量全部被占用时,我最常用的排查手段是 jstack。直接看线程栈:

jstack <pid>

在 dump 文件中搜索线程池名称或者 ThreadPoolExecutor 相关的关键字,可以看到哪些线程卡在 acquire 上,哪些线程在执行任务。如果大量线程处于 WAITING 状态,并且栈顶指向 AbstractQueuedSynchronizer 的 parkAndCheckInterrupt,说明信号量许可证已经耗尽。如果线程处于 RUNNABLE 但堆栈里任务执行时间异常久,说明某个下游调用没有超时保护。

线程池自身的监控指标也不能少。ThreadPoolExecutor 提供了 getActiveCount、getQueue().size()、getCompletedTaskCount 等方法,可以接入 Micrometer 或 Prometheus 做实时监控。我习惯同时监控三个核心指标:活跃线程数是否逼近 maximumPoolSize、队列大小是否持续上涨、任务拒绝次数是否大于零。这三个指标任何一项异常,都说明资源隔离策略需要重新调整。

还有一个诊断利器是 Arthas,线上环境不用重启就能执行:

thread -n 3

可以看到 CPU 占用最高的线程栈,快速定位是哪个业务类在占线程。排查信号量问题时,还可以用 Arthas 的 watch 命令观察 acquire 方法的调用频率和阻塞时间,判断限流是否提前触发。

最后分享一个小技巧:给线程池起名字。用 ThreadFactory 自定义线程名,比如 OrderService-Pool-1,线上查 jstack 时一眼就能看出是哪个业务链路出了问题,不用对着 thread-12 这种默认名字猜半天。一个合格的 Java 开发者,应该把这种细节刻进肌肉记忆。

说到底,ThreadPoolExecutor 和 Semaphore 的组合,核心思路不是“用两个锁工具”,而是用线程池解决资源边界问题,用信号量解决并发入口问题。真正理解了源码的思想,才能在面试时对答如流,在线上问题时临危不乱。如果你在项目里也有类似的隔离实践,欢迎按这个思路去给团队做一次分享,踩坑的经历就是最有说服力的教案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询