ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Java资源隔离实战:ThreadPoolExecutor与Semaphore源码级拆解

Java资源隔离实战:ThreadPoolExecutor与Semaphore源码级拆解 我看过不少简历写“熟悉 Java 并发编程”的人很多但一聊到资源隔离大多数人只会背一句话用线程池隔离再用信号量限流。你再追问一句ThreadPoolExecutor 在提交任务时底层是怎么把任务塞进队列的Semaphore 的计数器是靠什么保证原子性的场面往往会安静三秒钟。这篇文章不打算念课本我就站在面试官和一线开发者的角度把 Java 资源隔离这件事拆透了。核心就两个类ThreadPoolExecutor 和 Semaphore配合源码逐行讲清楚顺便给出一套能直接抄进项目的组合玩法。无论你是准备面试还是正在治理线上故障这篇文章都能给你一些硬货。1. 资源隔离在解决什么问题1.1 从一次线上事故说起先讲个真实场景。订单服务要调用外部会员接口这个会员接口平时很稳但有一次大促它突然变慢单次响应从 20ms 涨到 5 秒。订单服务用的 Tomcat 默认线程池200 个线程很快就全部阻塞在这个下游调用上。结果是一个下游慢接口把整个订单服务拖到瘫痪连不依赖会员接口的库存查询、优惠券计算也跟着不可用。这就是典型的缺乏资源隔离。没有把“不同依赖、不同业务”之间的线程资源物理隔开时任何一个慢通道都可能成为系统的癌症。解决思路有两个要么给不同依赖分配独立线程池要么用信号量控制并发访问的下限和上限。这两个方案在面试里被反复问在工程里也被反复用但很多人只是听过名词没有真正理解它们的边界。1.2 线程池隔离和信号量隔离的本质区别先别急着背概念我用大白话解释。线程池隔离本质是给每个子系统或外部依赖划一块“独立的地盘”。比如 A 调用用线程池 AB 调用用线程池 BA 慢成狗最多消耗完 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 所有任务已结束即将执行 terminatedTERMINATED 完全终止。状态之间是单向流转的shutdown 会让 RUNNING 变成 SHUTDOWNshutdownNow 会让 RUNNING 或 SHUTDOWN 变成 STOP。拒绝策略是最后一个兜底环节四种内置策略各有脾气策略行为适用场景AbortPolicy直接抛 RejectedExecutionException默认策略适合明确需要感知失败的业务CallerRunsPolicy在提交任务的线程里执行被拒绝的任务想利用调用线程兜底降低请求丢弃率DiscardPolicy静默丢弃任务不重要的日志、监控类任务DiscardOldestPolicy丢弃队列头部的任务然后重新提交希望保留最核心、最新的任务时生产环境中我一般的做法是自定义拒绝策略落一条监控或者告警而不是默默丢任务或者直接抛异常这样才能及时发现问题。3. Semaphore 源码级拆解3.1 AQS 是信号量的地基Semaphore 的源码核心在内部类 Sync而 Sync 直接继承 AbstractQueuedSynchronizerAQS。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 和 CASSemaphore 的并发控制才不需要额外加 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 会调 tryReleaseSharedSemaphore 里实现为 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 FutureT submit(CallableT 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 配置参数计算与实测效果参数怎么定不能拍脑袋。我一般用排队理论的 Littles Law 做估算并发线程数约等于 QPS 乘以平均响应时间。假设目标 QPS 是 1000下游 P99 耗时 80ms那理论并发就是 1000 * 0.08 80。核心线程数取 80 到 100 之间比较合理最大线程数可以留出缓冲比如 120。队列容量取决于你愿意让请求等多久。假设容忍排队等待 200ms那队列容量大约等于 1000 * 0.2 200。如果信号量要控制“提交但未完成”的任务总量可以设为最大线程数加上队列容量也就是 120 200 320。我实际的压测经验是把 Semaphore 的 permits 设置成最大线程数加队列容量确实能保证任务不会无限堆积但在高峰期acquire 会阻塞住上游线程如果上游没有设置超时调用方可能集体卡死。所以更稳妥的方案是使用带超时的 tryAcquireif (!semaphore.tryAcquire(100, TimeUnit.MILLISECONDS)) { // 快速失败走降级逻辑 throw new ServiceUnavailableException(系统繁忙); }这样等于把信号量从“阻塞闸门”变成了“快速失败闸门”。对调用方来说拿不到许可证直接返回 503 或者提示稍后重试远比默默排队然后超时好。我曾经在压测环境对比过不加信号量时线程池队列积压 2 万任务接口响应时间从 80ms 涨到 8 秒加上信号量并开启快速失败后响应时间稳定在 100ms 左右失败率控制在 5% 以内用户体验反而好得多因为失败是立刻返回的请求不会堆积成雪崩。5. 面试连环炮与避坑实战5.1 高频追问 Top 5把我面试别人和被别人面试遇到的问题整理一下排名不分先后。corePoolSize 和 maximumPoolSize 之间线程数是怎么变化的很多人的答案是错的。真实顺序是核心线程 → 队列 → 非核心线程 → 拒绝策略。队列满是非核心线程创建的前提。ThreadPoolExecutor 的 Worker 为什么要继承 AQS简单回答是为了实现一个不可重入锁让中断空闲线程时不会误伤正在执行任务的线程。能说到这一层基本就过关了。Semaphore 和 CountDownLatch 有什么区别Semaphore 是控制并发数量的计数器可以反复 acquire/releaseCountDownLatch 是门闩只能等待指定数量的线程完成后放行不能复位。信号量能不能完全替代线程池隔离不能。信号量不创建线程不隔离线程资源如果系统只有一条线程在跑业务用信号量等于自己打自己。线程池隔离解决的是资源独占问题信号量解决的是并发上限问题。execute 提交任务时任务先入队还是先创建非核心线程先入队队列满才创建非核心线程。原因是为了复用核心线程尽量避免线程数量频繁扩展收缩。5.2 我在生产环境踩过的三个坑第一个坑是信号量许可证泄漏。早期做接口级限流时业务代码在 acquire 之后出现了异常release 写在了 try 块之外的某个分支里异常路径直接跳过 release。线上跑了三天所有线程全部阻塞在 acquire服务完全不可用。排查时 jstack 一看全是 WAITING 状态立刻意识到许可证没了。从那以后我给自己定了一条死规矩acquire 紧跟 try-finallyrelease 永远在 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 的组合核心思路不是“用两个锁工具”而是用线程池解决资源边界问题用信号量解决并发入口问题。真正理解了源码的思想才能在面试时对答如流在线上问题时临危不乱。如果你在项目里也有类似的隔离实践欢迎按这个思路去给团队做一次分享踩坑的经历就是最有说服力的教案。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表