
线程池的目标不是“多开一点线程”,而是给执行资源设置一条可观测、可控制的边界。线程数、队列长度和拒绝策略并不是三个独立参数:它们共同决定请求高于处理能力时,延迟会堆积在哪里,谁会被拒绝,以及系统能否恢复。
线程池的执行流程
ThreadPoolExecutor 接收到任务后,大致按这个顺序决策:核心线程未满时创建工作线程;核心线程满后尝试入队;队列满且未到最大线程数时创建非核心线程;仍然无法接纳才触发拒绝策略。
所以“把队列设得很大”并不等价于更稳定。无界队列会掩盖过载,使排队时间不断增长,最终把问题变成内存压力和超时;而最大线程数在无界队列下可能几乎没有参与机会。
线程池大小一般怎么设置
CPU 密集任务通常不应远超核心数,否则上下文切换会吞掉收益。I/O 密集任务可以容纳更多并发,但仍取决于外部依赖的延迟、连接池容量、下游限流和服务目标。
一个更有用的起点是用 Little 定律估算并发需求:并发量约等于到达率乘以平均响应时间。它给出容量讨论的量级,而不是直接给出 corePoolSize。落地时还要为突发流量、失败重试和下游抖动留出安全边界。
线程池有哪些拒绝策略
四种内置策略表达的是四种不同选择:抛出异常、静默丢弃、丢弃最旧任务、让提交者执行。CallerRunsPolicy 能形成一定背压,但如果提交者是 Web 请求线程,它也可能把入口线程拖住,放大整体延迟。
对于不能丢的异步任务,更可靠的路径通常是:明确返回“稍后处理”,将任务持久化或写入消息队列,并保证补偿和幂等;而不是寄希望于把内存队列无限拉长。
ThreadLocal 的原理和内存泄漏问题
线程池让线程长时间存活,ThreadLocal 的生命周期因此不再等于一次请求。ThreadLocalMap 的 key 是弱引用,但 value 仍可能因为工作线程持续存在而滞留。上下文、MDC、租户信息这类数据必须在 finally 中清理:
try {
context.set(requestContext);
executeTask();
} finally {
context.remove();
}
最后,把活跃线程数、队列深度、任务等待时间、拒绝次数和执行异常放进指标系统。没有这些信号,所谓“动态线程池”只是可以在线改参数,而不是可以安全运营的执行边界。
CAS 算法是什么
CAS 用“比较预期值,再尝试更新”的方式避开常规互斥。它适合短小、冲突可控的状态更新;失败后不断重试的自旋,在高冲突写热点下反而会推高 CPU。AtomicInteger 适合单个计数,LongAdder 用多个 Cell 分散竞争,适合写多读少的统计指标;若每一次读取都要作为严格决策依据,则仍需更强的一致性方案。
ABA 也是 CAS 的边界:变量从 A 变 B 又变回 A,单纯比较值无法发现中间历史。需要识别版本时,使用带版本戳的原子引用,或让业务状态本身携带递增版本号。
synchronized 和 ReentrantLock 的区别
synchronized 适合大多数简单临界区:作用域明确、异常路径自动释放,并天然可重入。ReentrantLock 的价值在于额外能力——可中断获取、超时 tryLock、公平策略和多个 Condition。
两者并不是“新旧替代关系”。当需要多条件队列、显式超时、非阻塞失败路径时,ReentrantLock 更适合;否则优先选择更少出错的 synchronized。ReentrantLock、Semaphore、CountDownLatch 等同步器背后都能看到 AQS:用一个状态值表达资源,用等待队列组织竞争与唤醒。
如何设计一个动态线程池
在线调整线程池时,corePoolSize 与 maximumPoolSize 的更新顺序不能反。扩容时先提高最大值,再提高核心值;缩容时先降低核心值,再降低最大值,避免中间状态违反 core <= max。
队列容量通常不是 JDK 原生阻塞队列可以安全热改的字段。真正的动态线程池还需要版本化配置、变更审计、指标门槛和回滚策略;“可改”只是开始,知道为什么改、改后是否改善才是闭环。
CAS 的底层实现和 ABA 问题
CAS 接收内存位置、预期值与新值:只有当前位置仍等于预期值时,更新才成功。JDK 的原子类通过 JVM 的底层能力和处理器原子指令实现这种“比较后交换”;失败并不代表异常,只代表在自己计算期间有人先完成了更新。
ABA 问题说明“值相同”不等于“状态从未变化”。例如栈顶引用从 A 改为 B 再改回 A,普通 CAS 只看到 A,无法察觉中间过程。AtomicStampedReference 在引用外再维护一个版本戳,比较时同时校验值和版本;AtomicMarkableReference 则提供较简单的标记位。选择它们之前,应先问业务是否真的需要感知中间版本,而不是把 ABA 当作所有 CAS 的必然缺陷。
原子字段更新器适用于大量对象共享同一种可原子更新字段的场景。相比每个对象再持有一个原子包装对象,AtomicIntegerFieldUpdater 可以直接更新 volatile int 字段,减少对象数量;代价是 API 更受访问修饰符、字段类型和运行时检查约束。它是性能工具,不应取代可读性更好的普通原子类。
AtomicLong 和 LongAdder 的区别
高并发下,多个线程同时更新同一个 AtomicLong 会围绕同一内存位置反复 CAS,失败线程持续自旋。LongAdder 把更新分散到多个 Cell,读取 sum() 时再聚合,因此对“写多、读少”的计数指标常有更高吞吐。
但 sum() 并不是一个冻结世界的瞬间快照。若它的返回值要参与余额判断、库存扣减或精确配额决定,就不能因为性能好而直接替代同步或数据库条件更新。把统计指标和业务账本混用,是很常见的并发设计错误。
synchronized 的底层原理和锁升级
同步代码块在字节码层面以 monitorenter 和 monitorexit 表达;异常路径也会有释放监视器的保障。同步方法则携带相应的同步标记。对实例方法,锁对象通常是 this;对静态方法,锁对象是对应的 Class 对象。锁的可重入性意味着同一线程再次进入同一 monitor 不会把自己阻塞,退出次数与进入次数匹配后才真正释放。
历史上 HotSpot 对 synchronized 做过偏向、自旋、轻量级与重量级等不同优化,具体实现会随 JDK 版本演进,偏向锁也已在较新版本中被废弃或移除。写业务代码时更应关注的是:临界区是否足够小、锁对象是否稳定、是否在持锁状态下进行慢 I/O 或调用未知外部代码。
ReentrantLock 和 AQS 的原理
ReentrantLock 实现 Lock 接口,通常应配合 try/finally 释放。它可以选择公平或非公平策略,支持 lockInterruptibly、带超时的 tryLock,并可创建多个 Condition。非公平锁允许适度插队,常能减少唤醒后再次排队的开销;公平锁降低长期饥饿的可能,却不等于端到端业务公平。
AQS 用一个 volatile 状态值表示同步资源,用变体 CLH 等待队列组织获取失败的线程。短暂竞争可自旋,长时间等待会被挂起;释放资源后再唤醒合适的后继。ReentrantLock、Semaphore、CountDownLatch 都是在这套框架上定义不同的获取与释放语义。理解 AQS 不要求手写同步器,但能帮助我们判断“这是独占、共享,还是条件等待”的问题。
ThreadLocal 为什么在线程池中容易内存泄漏
每个 Thread 内部维护自己的 ThreadLocalMap。其中 key 是 ThreadLocal 的弱引用,value 则仍可能强引用业务对象。当 ThreadLocal 实例本身不再被强引用,key 可以被回收,但在线程池工作线程长期存活时,value 未必会立刻离开;这就是常说的 stale entry 风险。
解决不是寄希望于弱引用,而是将 remove() 视为使用协议的一部分,尤其是请求上下文、用户身份、MDC、事务上下文等在线程池里传递的数据。异步任务还要明确上下文传递方式,不能假设 ThreadLocal 会自动跨线程传播。
线程池的核心参数、异常处理和任务依赖
ThreadPoolExecutor 的核心参数包括核心线程数、最大线程数、队列、空闲线程存活时间、线程工厂和拒绝策略。使用构造器显式配置的价值,不在于“写得更长”,而在于每个边界都可被审查。
Executors 的某些工厂方法隐藏了无界队列或极大的最大线程数,任务持续积压或突发创建时可能将压力推到内存。生产服务更需要一个有容量的队列、一套可解释的拒绝语义和指标。任务之间有依赖时,可以用 CompletableFuture、CountDownLatch 或消息编排表达依赖,避免在线程池任务中阻塞等待另一个同池任务而造成饥饿。
异常处理也依赖提交方式:submit 会把异常封装进 Future,需要通过 get 或统一回调观察;execute 中未捕获的运行时异常会终止当前工作线程并触发替换。无论选择哪一种,都应有统一日志、指标和告警,否则任务“消失”时很难定位。
面试总结
这一组工具解决的是不同层次的问题:CAS 管单变量竞争,锁保护不变量,AQS 组织等待,ThreadLocal 隔离上下文,线程池为执行资源设边界。把它们放在同一张图里看,才能避免“用了线程池却没有背压”“用了原子类却没有原子业务”“用了 ThreadLocal 却泄漏上下文”这类常见断层。
为什么不建议直接使用 Executors 创建线程池
工厂方法写法短,但会隐藏最关键的容量选择。固定线程池与单线程池常使用无界队列,任务持续进入时可能在内存中无限积压;缓存线程池可以在压力下创建大量线程,可能消耗掉线程栈和调度资源。并不是所有 Executors 都不能用,而是生产服务应明确看到线程数、队列与拒绝策略,而不是把它们藏在默认值里。
线程池任务抛异常后,线程会销毁吗
使用 execute 提交的任务若抛出未捕获的运行时异常,当前工作线程会结束,线程池会在需要时补充新的工作线程。使用 submit 时,异常会被封装到返回的 Future;若调用方从不 get、也没有回调或统一观察逻辑,异常就可能悄悄消失。无论使用哪种方式,业务都应定义异常记录、重试与补偿边界。
任务依赖如何处理
一个任务必须等待另一个任务完成时,不要在同一个容量很小的线程池中相互阻塞 get:这可能耗尽所有工作线程,造成线程饥饿。简单依赖可以用 CompletableFuture 组合,固定数量的并行任务可以用 CountDownLatch 或 CyclicBarrier,跨服务或需要可靠重试的依赖更适合消息队列和状态机。核心原则是让依赖关系可见,而不是把等待藏进一个 Runnable 内部。
FIELD NOTES / DISCUSS
文章讨论
读完后,欢迎留下你的补充、疑问或不同看法。
正在读取评论…