假如你是一家名叫「并发轩」的餐厅后厨主管。开业第一晚,后厨就乱成了一锅粥:十个厨师(线程)同时抢一口炒锅(共享资源),有人菜炒到一半锅被端走;食材堆在台面上你推我搡;出菜口没人管,菜品越堆越高。你急需一套管理工具——而 Java 里恰好就有一套现成的:java.util.concurrent,江湖人称 JUC。它把锁、并发容器、同步器、线程池全部封装好,就像给后厨配备了灶台通行证、分格冰箱、开工倒计时牌和一支训练有素的厨师团队。这篇文章,我们就从「并发轩」的后厨出发,把这些工具一件件拆开看明白:锁怎么用、队怎么排、冰箱怎么分格、团队怎么调度。学完你再去看高并发 Java 代码,就像老主厨扫一眼后厨——一切秩序井然。

岗位关联

JUC 是 Java 高并发服务的通行证。吃透它,你才能写出线程安全的业务代码、治理好线上并发问题,并在面试中系统性地回答锁与线程池的原理。

目录
  1. 可视化演示
  2. JUC整体结构:后厨的工具墙
  3. AQS核心原理:叫号系统的规则本
  4. ReentrantLock:可重入的灶台通行证
  5. Condition机制:等菜坐席与传菜铃
  6. 并发容器:分格冰箱里的线程安全
  7. 同步器工具:倒计时、晨会与门票
  8. 线程池:主厨的排班表
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

通过AQS等待队列、ConcurrentHashMap并发读写和线程池调度流程三个维度,展示Java并发包JUC的核心机制与工作原理。

本节配有交互动效(进入视口后自动加载,约 3KB)。

AQS等待队列与状态管理 volatile int state表示同步状态,CAS原子更新 CLH双向队列管理等待线程,Head节点持有锁 state=0(空闲) / state=1(独占) / state>1(重入) AQS是ReentrantLock、CountDownLatch等同步器的基石

JUC整体结构:后厨的工具墙

走进「并发轩」后厨,最先看到的是东墙上一整面工具墙。墙上挂四类东西:灶台通行证(锁)、分格冰箱(并发容器)、开工牌(同步器)和厨师排班表(线程池)。没学过 JUC 的人觉得这面墙眼花缭乱,但老主厨清楚,每一类工具对应一类后厨难题:锁管互斥、容器管存取、同步器管节奏、线程池管人手。

这面墙的学名就是 java.util.concurrent,从 JDK 1.5 开始随 Java 官方发布。它不是某个单一类,而是一整套并发体系:锁家族(ReentrantLock、ReadWriteLock 等)解决资源互斥;并发容器(ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue)解决线程安全的数据存取;同步器(CountDownLatch、CyclicBarrier、Semaphore)解决线程间的节奏编排;线程池(ThreadPoolExecutor)解决线程的创建、复用与回收。这些工具背后,绝大多数都站着一个共同的“规则本”——AQS。

为什么要这么大动干戈?因为多线程的收益不是无限的。当任务中可并行的部分占比为 P 时,理论加速比被阿姆达尔定律锁死:就算把厨师加到一百个,洗锅、切配这些必须串行的环节永远拖后腿。

这也解释了为什么「并发轩」的厨师不是越多越好:备料、洗锅这些串行环节(1-P)锁死了天花板。JUC 的全部价值,就是尽量把 P 做大、把互斥开销降到最低——用锁护住每一口锅,用容器管好每一份食材,让厨师们各司其职,而不是在推搡中内耗。

S(n)=1(1−P)+Pn\displaystyle S(n) = \frac{1}{(1-P) + \frac{P}{n}}
这条公式说明:并发提升的效果取决于任务里“能并行的比例”,串行部分占比越大,加再多线程也白搭。
  1. 串行时间归一化把任务在单核上执行完的总时间设为 1,作为加速比的基准。
    T(1)=1\displaystyle T(1) = 1
  2. 可并行部分被分摊占比为 P 的那部分工作可以分给 n 个核同时做,耗时从 P 缩短到 P/n。
    Pn\displaystyle \frac{P}{n}
  3. 串行部分无法缩水剩下 1-P 的工作必须按顺序完成,加多少核都不变。
    1−P\displaystyle 1-P
  4. 总耗时与加速比并行后的总耗时为两者之和,加速比等于 1 除以总耗时。
    S(n)=1(1−P)+Pn\displaystyle S(n) = \frac{1}{(1-P) + \frac{P}{n}}

AQS核心原理:叫号系统的规则本

晚市高峰,十个厨师都想用那口招牌炒锅。与其靠嗓门抢,不如装一个叫号系统:想用锅的先取号,到排队区等着,叫到号才上前。JUC 工具墙后面那本“叫号规则本”,就是 AQS(AbstractQueuedSynchronizer,抽象队列同步器)——几乎所有的显式锁和同步器都照着它实现。

AQS 的核心是两样东西:一个 volatile int 类型的 state 状态字段,和一条基于 CLH 变体的 FIFO 等待队列。state 表示资源被占用的程度:锁是否被持有、信号量还剩几张票;没抢到资源的线程会被封装成节点,挂到队列尾部挂起等待,而不是原地空转。

AQS 用模板方法模式定义骨架:acquire()、release() 规定整体流程,tryAcquire()、tryRelease() 留给子类填充。以 ReentrantLock 为例,加锁就是把 state 从 0 改成 1,而这一步依赖的原子操作是 CAS(Compare And Swap)——比较并交换,由 CPU 指令保证“看一眼、比一比、换一换”整个过程不可被打断。

叫号系统的好处是公平、可预测。CAS 就像一个眼疾手快的服务员:看到号码没变才敢撕票,撕完立刻登记;如果有人抢先,就重新取号。而 CLH 排队区保证了后来者不插队、不踩踏,这正是 ReentrantLock、Semaphore 等一切同步器能正常工作的地基。

CAS(state,expected,update):if state=expected then state=update\displaystyle \text{CAS}\left(state, expected, update\right): \text{if } state = expected \text{ then } state = update
CAS 是一种乐观锁思路:先假设没人抢,值没变就直接改;如果发现被改过,就重新读再试一次,避免用“锁去保护锁”。
  1. 三个参数各司其职state 是共享状态,expected 是调用前读到的那一版旧值,update 是想写入的新值。
    state, expected, update\displaystyle state,\ expected,\ update
  2. 比较前置条件只要当前 state 仍然等于 expected,就说明读完之后没人动过它。
    state=expected\displaystyle state = expected
  3. 执行原子交换满足条件就写入 update,整个过程由 CPU 指令保证原子,无法被打断。
    state:=update\displaystyle state := update
  4. 失败则重试或入队比较失败说明有人抢先改了值,CAS 返回 false,线程重新读取或进入 CLH 队列等待。
    CAS=false⇒重试或入队等待\displaystyle \text{CAS} = \text{false} \Rightarrow \text{重试或入队等待}

ReentrantLock:可重入的灶台通行证

「并发轩」最贵重的是一口百年铁锅,主厨规定谁用必须先领灶台通行证,用完归还。但有个细节:如果一位厨师炒完一道菜,还想用同一口锅接着炖汤,难道要把证还回去重新排队?当然不用。ReentrantLock 的“可重入”就是这个意思——同一个线程可以反复获取同一把锁,不会把自己锁在门外。

ReentrantLock 是 JUC 里最常用的显式锁,实现了 Lock 接口。相比 synchronized,它带来三个新能力:可中断(lockInterruptibly())、可超时(tryLock(timeout))、可公平(构造参数 fair=true)。用法上必须手动加锁、手动解锁,标准姿势是 lock() 之后立刻 try/finally,保证锁一定释放。

可重入的原理藏在 AQS 的 state 里:它不再只是 0/1 两态,而是记录“当前持锁线程重入了多少次”。同一个线程每 lock() 一次,state 加 1;每 unlock() 一次减 1;减到 0,锁才真正释放给排队区的其他线程。

通行证上的“次数”一栏帮了大忙:同一位厨师连续用锅,只需在证上记一笔;只有最后一笔划掉,证上归零,才轮到下一位。相比 synchronized 的“一喊就锁、谁抢到算谁”,ReentrantLock 能排队、能限时、能响应中断,更适合复杂的业务后厨。

lock(): state=state+1;unlock(): state=state−1\displaystyle lock(): \ state = state + 1; \qquad unlock(): \ state = state - 1
锁的“重入次数”就像通行证上的登记次数:进来一次记一笔,出去一次划一笔,划完了才轮得到下一个人。
  1. state 兼任重入计数AQS 的 state 在这里不是简单的 0/1,而是当前线程的重入深度。
    state=holdCount\displaystyle state = \text{holdCount}
  2. 加锁深度加一当前线程每次成功 lock,state 加 1,同时把持锁线程记为当前线程。
    state:=state+1\displaystyle state := state + 1
  3. 解锁深度减一每次 unlock 把 state 减 1,只有减到 0 时才真正释放锁并唤醒等待者。
    state:=state−1\displaystyle state := state - 1
  4. 可重入的边界state 大于 0 时,只有持锁线程自己才能继续重入,其他线程只能在队列里等待。
    owner=Thread.currentThread()\displaystyle \text{owner} = \text{Thread.currentThread()}

Condition机制:等菜坐席与传菜铃

后厨除了炒锅,还有两个“等待区”:等食材的厨师们,和等出菜口空位的传菜员。他们不能干瞪眼,需要有人喊“食材到了!”或“出菜口空了!”。Condition 就是 Lock 配套的“传菜铃”——一个 Lock 可以 new 出多个 Condition,每一条对应一个独立的等待坐席。

synchronized 的 wait/notify 只能在同一个监视器上等待,粒度太粗;而一个 ReentrantLock 可以同时维护 notFull 和 notEmpty 两条队伍,分别管理“生产者等空位”和“消费者等食材”。这正是阻塞队列 ArrayBlockingQueue 的实现基础,也是比 synchronized 更细腻的地方。

调用 condition.await() 的线程会先释放锁,然后进入该 Condition 的等待队列挂起;signal() 唤醒这条队伍里的第一个线程,signalAll() 唤醒全部。被唤醒的线程要重新参与锁竞争,拿到锁之后才从 await() 返回继续执行。注意:await/signal 必须是在持有对应 Lock 的前提下调用,否则会抛 IllegalMonitorStateException。

两条等待坐席互不干扰,是 JUC 比 synchronized 细腻的关键。传菜铃一响,队伍里最前面那位先来后到地起身;等食材的不会去抢出菜口的空位。等的人各等各的,叫的人各叫各的,一来一回之间,后厨的流水线始终保持有条不紊。

buffer.size()≤0⇒await(notEmpty);buffer.size()≥capacity⇒await(notFull)\displaystyle buffer.size() \leq 0 \Rightarrow \text{await}(notEmpty); \qquad buffer.size() \geq capacity \Rightarrow \text{await}(notFull)
生产者与消费者各等各的:没有食材时消费者等 notEmpty,没有空位时生产者等 notFull,互不干扰,由传菜铃精准叫醒对应队伍。
  1. 消费者侧条件缓冲区里没有食材时,消费者线程在 notEmpty 坐席等待,不能取出。
    buffer.size()≤0⇒await(notEmpty)\displaystyle buffer.size() \leq 0 \Rightarrow await(notEmpty)
  2. 生产者侧条件缓冲区已满时,生产者线程在 notFull 坐席等待,不能放入。
    buffer.size()≥capacity⇒await(notFull)\displaystyle buffer.size() \geq capacity \Rightarrow await(notFull)
  3. 配对唤醒生产者放入后 signal(notEmpty),消费者取走后 signal(notFull),各叫醒一条队伍的头一位。
    signal(notEmpty), signal(notFull)\displaystyle signal(notEmpty),\ signal(notFull)

并发容器:分格冰箱里的线程安全

食材必须分格存放:蔬菜一格、肉类一格、酱料一格。如果所有食材堆在一个大冷柜里,一个人翻找时其他人只能干等。ConcurrentHashMap 就是这样的“分格冰箱”——把整张哈希表切成很多桶,不同线程操作不同桶时互不干扰,大大提升了并发度。

早期的并发安全方案是 Hashtable,给整张表加一把大锁,所有线程串行访问,效率极低。JDK 7 的 ConcurrentHashMap 引入“分段锁”:把表分成若干段,每段一把锁,只有同一段的操作才竞争。JDK 8 进一步把锁粒度细化到桶(bucket),用 CAS + synchronized 只锁链表的头节点,锁竞争进一步降低——这就是它从“分段锁”演进到“桶锁”的历程。

JDK 8 的 ConcurrentHashMap 底层是“数组 + 链表 + 红黑树”:默认容量 16,链表长度超过 8 且数组长度不小于 64 时树化,扩容阈值由加载因子决定。其他并发容器也各有所长:CopyOnWriteArrayList 适合读多写少,写时复制整份数组;BlockingQueue 系列用锁加条件队列实现生产者消费者管道。

分格冰箱让厨师各取所需,不用再为一格食材排队。但冰箱再大也有装满的时候(扩容阈值),而且“先看再拿”这种两步操作(读-改-写)依然需要额外小心——JUC 提供了 merge、computeIfAbsent 等原子方法,相当于“看一眼、拿走、登记”一气呵成,没人能从中插一脚。

threshold=capacity×loadFactor\displaystyle threshold = capacity \times loadFactor
加载因子 0.75 的意思是“冰箱装到七成五就该换更大的了”,否则桶里的链表太长,查找会变慢。
  1. capacity 桶数量哈希表底层数组长度,默认 16,表示有 16 个桶位。
    capacity=16\displaystyle capacity = 16
  2. loadFactor 加载因子默认 0.75,表示元素占桶位比例达到七成五时触发扩容。
    loadFactor=0.75\displaystyle loadFactor = 0.75
  3. threshold 扩容阈值元素个数达到 12 就扩容,保证链表不至于过长。
    threshold=16×0.75=12\displaystyle threshold = 16 \times 0.75 = 12
  4. 扩容翻倍新数组长度为原来的两倍,元素重新散列,桶位更多、查询更快。
    capacitynew=capacity×2\displaystyle capacity_{new} = capacity \times 2

同步器工具:倒计时、晨会与门票

后厨除了炒菜,还有三类节奏要管:开业前所有人必须到岗;每天晨会大家要等齐了才开始备菜;热菜区一次只允许两个人进入。这三件事,正好对应同步器三件套——CountDownLatch(倒计时门闩)、CyclicBarrier(循环屏障)、Semaphore(信号量)。

CountDownLatch 像一个倒计时牌:初始化一个计数 N,主线程调用 await() 阻塞,其他线程每完成一件事就 countDown() 一次;计数归零时门闩打开,所有等待者一起放行。它是一次性的,用完即弃。CyclicBarrier 则像晨会集合:N 个线程各自到达后互相等待,全部到齐才一起出发,而且能循环使用——晨会开完,明天还能再开一轮。

Semaphore 像限流门票:构造时给 permit 数量,acquire() 拿一张票,release() 归还一张;没票的线程阻塞排队。它常用来控制并发数,比如数据库连接池最多 N 个连接、接口同时处理的请求数。三者底层都建立在 AQS 的共享模式之上:CountDownLatch 把 state 当剩余计数,CyclicBarrier 则用 ReentrantLock 加 Condition 实现“等齐”。

倒计时牌保证“人齐了才开张”,晨会保证“谁也不落单”,门票保证“场面不失控”。这三种节奏在微服务启动、批处理并发、接口限流中天天上演——理解了它们的语义区别,你才能在后厨里精确地说出“现在该用哪块牌”。

countDown(): count=count−1;await(): block if count>0\displaystyle countDown(): \ count = count - 1; \qquad await(): \ block \ \text{if} \ count > 0
CountDownLatch 的语义是“我等所有人干完活”;CyclicBarrier 的语义是“我们互相等齐再出发”。前者是门闩,后者是聚会。
  1. count 是剩余计数初始化时由构造参数设为 N,底层存储在 AQS 的 state。
    count=N\displaystyle count = N
  2. countDown 减少计数每个线程完成一个前置任务就调用一次,计数减 1。
    count=count−1\displaystyle count = count - 1
  3. await 检查并阻塞计数大于 0 时调用方挂起,最后一次 countDown 将计数归零时唤醒所有等待者。
    count=0⇒唤醒所有等待线程\displaystyle count = 0 \Rightarrow \text{唤醒所有等待线程}
  4. 一次性与循环的差异CountDownLatch 归零后不能重置;CyclicBarrier 到达后自动重置,可以循环使用。
    CyclicBarrier.reset()\displaystyle CyclicBarrier.reset()

线程池:主厨的排班表

后厨最考验功力的是排班。每来一桌客人都临时雇一个厨师,成本高、管理乱;只固定三个人,晚市高峰又忙不过来。主厨的解决方案是:养几个固定厨师(核心线程),忙起来再临时招人(最大线程),还忙就让客人排队(任务队列),实在接不下就拒单(拒绝策略)。这就是 ThreadPoolExecutor。

ThreadPoolExecutor 有七个参数,最重要的是四个:corePoolSize(核心线程数,常驻厨师)、maximumPoolSize(最大线程数,最多可招的人数)、workQueue(任务队列,等位区容量)、handler(拒绝策略,接不下怎么办)。执行流程一句话:先核心线程,再任务队列,再扩到最大线程,最后拒绝。

四种拒绝策略各有性格:AbortPolicy 直接抛异常(最有原则);CallerRunsPolicy 让提交任务的线程自己干(最负责);DiscardPolicy 悄悄丢弃(最省事);DiscardOldestPolicy 丢弃队首最旧任务(优化等待)。至于线程数怎么定,业界有一个经验公式:CPU 密集型线程数接近核数;IO 密集型因为线程大量时间在等 IO,可以多设。

排班表是后厨的命脉:核心厨师不轻易辞退(核心线程常驻),临时工空闲太久就辞退(keepAliveTime),排队区满了才扩招。这套机制避免了“每来一桌雇一个厨子”的浪费,也避免了无限招人导致后厨人挤人——线程太多时,光上下文切换就能把 CPU 拖垮。

Nthreads=NCPU×(1+WC)\displaystyle N_{threads} = N_{CPU} \times \left(1 + \frac{W}{C}\right)
W 是等待耗时、C 是计算耗时。等待占比越高,越需要更多线程来“填补空闲”,这就是 IO 密集型服务线程数可以更大的原因。
  1. N_CPU 是物理核数机器真正能同时并行执行的线程数上限。
    NCPU\displaystyle N_{CPU}
  2. W 是等待耗时线程因 IO、网络等原因阻塞等待的时间。
    W=等待耗时\displaystyle W = \text{等待耗时}
  3. C 是计算耗时线程真正占用 CPU 进行计算的时间。
    C=计算耗时\displaystyle C = \text{计算耗时}
  4. 等待占比决定倍数W/C 越大说明线程越闲,需要更多线程填补等待;CPU 密集型 W≈0,线程数约等于核数。
    Nthreads=NCPU×(1+WC)\displaystyle N_{threads} = N_{CPU} \times \left(1 + \frac{W}{C}\right)

AI 应用场景

大模型在线推理服务的并发治理

阶段:模型部署与在线推理服务

解决的问题:大模型推理时显存和计算资源昂贵,请求无限涌入会导致 GPU 内存溢出或排队雪崩。传统做法是“来一个处理一个”,没有上限,服务极易被打垮。用 ThreadPoolExecutor 管理推理线程,配合 Semaphore 做令牌限流,可以把并发推理路数严格控制在 GPU 能承受的范围内。

具体案例:某 LLM 服务用核心线程数等于 GPU 卡数的两倍、最大线程数为四倍的线程池执行 forward 计算,再用 Semaphore(4) 做全局限流——同一时刻最多 4 个推理请求在跑,超出的请求进入队列等待或直接返回 429,GPU 不再 OOM。

AI 特征与模型元数据缓存

阶段:特征工程与在线 Serving

解决的问题:线上推理每个请求都要查特征、查模型版本,直接使用 HashMap 会被并发写破坏结构,每次加锁又太慢。ConcurrentHashMap 提供线程安全的读写且并发度高,非常适合做特征缓存。

具体案例:广告点击率预估服务把用户特征存入 ConcurrentHashMap,key 是用户 ID,value 是特征向量;模型版本列表用 CopyOnWriteArrayList 存储,支持线上无锁热切换——新版本发布时写入新数组,旧数组等读完了自动回收。

训练数据流水线同步

阶段:数据准备与分布式训练

解决的问题:分布式训练前要把多个数据分片下载并预处理,全部就绪才能开训;训练中数据加载(生产者)与 GPU 计算(消费者)速度不匹配,需要缓冲管道。用 CountDownLatch 等所有分片就绪,用 BlockingQueue 做生产消费缓冲。

具体案例:多卡训练启动脚本里,每个数据加载线程完成分片处理后调用 countDown(),主线程 await() 等计数归零后统一拉起训练进程;训练中的数据队列用 ArrayBlockingQueue(1024) 缓存样本,加载线程负责放、GPU 线程负责取,队满自动阻塞、队空自动等待。

Python 演示

python69 行
"""Mini thread pool: imitate JUC ThreadPoolExecutor scheduling flow.   core threads -> task queue -> expand threads -> reject""" import randomimport threadingimport timefrom queue import Queue  class MiniThreadPool:    """Use four core params to simulate ThreadPoolExecutor"""     def __init__(self, core_size, max_size, queue_capacity):        self.core_size = core_size        self.max_size = max_size        self.queue_capacity = queue_capacity        self.task_queue = Queue(queue_capacity)        self.worker_count = 0        self.rejected_count = 0        self._guard = threading.Lock()     def submit(self, task):        """Submit a task, follow JUC priority: core -> queue -> expand -> reject"""        while True:            with self._guard:                if self.worker_count < self.core_size:                    self.worker_count += 1                    threading.Thread(target=self._worker_loop, args=(task,)).start()                    return            try:                self.task_queue.put_nowait(task)                return            except Exception:                with self._guard:                    if self.worker_count < self.max_size:                        self.worker_count += 1                        threading.Thread(target=self._worker_loop, args=(task,)).start()                        return                    self.rejected_count += 1                    print("[reject] queue is full and workers hit max")                    return     def _worker_loop(self, first_task):        """Handle first task, then pull more tasks from the queue"""        task = first_task        while task is not None:            task()            try:                task = self.task_queue.get_nowait()            except Exception:                task = None        with self._guard:            self.worker_count -= 1  def cook_task(name):    """Create a cooking task with random duration"""    def task():        time.sleep(random.uniform(0.02, 0.1))        print(f"chef {name} finished a dish")    return task  if __name__ == "__main__":    pool = MiniThreadPool(core_size=2, max_size=4, queue_capacity=2)    for i in range(12):        pool.submit(cook_task(i))    time.sleep(2)    print(f"done, rejected={pool.rejected_count}")
目的:用 Python 线程复刻 JUC ThreadPoolExecutor 的核心调度策略,演示核心线程优先、队列缓冲、超限扩招、满员拒单的完整过程。
  1. self.task_queue = Queue(queue_capacity)等位区,对应 JUC 的 workQueue 阻塞队列,容量满了会抛异常。
  2. if self.worker_count < self.core_size:第一步:核心线程没满,直接新开一个厨师干活。
  3. self.task_queue.put_nowait(task)第二步:核心满了,任务进队列排队。
  4. if self.worker_count < self.max_size:第三步:队列也满了且还没到最大线程数,扩招临时工。
  5. self.rejected_count += 1第四步:达到最大线程数,任务被拒绝,对应 AbortPolicy。
  6. threading.Thread(target=self._worker_loop, args=(task,)).start()新厨师上岗,开始执行第一个任务。
  7. task = self.task_queue.get_nowait()干完手里的活,再从队列取新任务,对应线程池 worker 的循环取活。

常见误区

误区synchronized 写法简单还能自动释放锁,ReentrantLock 要手动 unlock 很容易忘记,我项目里根本不用它,不也跑得好好的?

正解synchronized 无法响应中断、无法设置超时、无法公平排队,而且只有一个隐式的等待区。ReentrantLock 提供 tryLock(timeout)、lockInterruptibly()、公平锁和多个 Condition,对“抢锁不能无限等”和“精细控制等待队列”的高并发场景不可或缺。就像灶台通行证可以设有效期、可以排队,而 synchronized 是“谁嗓门大谁先用”,各有用武之地。

为何常见:日常业务中 synchronized 确实够用且更简单,看不到 ReentrantLock 在中间件、连接池、框架源码里的大量应用,容易得出“多余”的结论。

误区ConcurrentHashMap 不是线程安全的吗?那我 `get` 完再 `put`,两步操作合起来不也安全吗?

正解线程安全只保证单个方法内部不会被破坏,但“先读后写”的复合操作在多线程下会出现覆盖丢失。比如两个线程同时 get 到空值,又各自 put 自己算好的结果,其中一个结果就被悄悄覆盖了。正确做法是使用 compute、computeIfAbsent、merge,让“读-算-写”变成原子操作——就像“看冰格里有没有肉、再决定补货”这两步必须一气呵成,中间不许别人插手。

为何常见:把“方法级线程安全”误解成“业务级线程安全”,忽略了复合操作同样需要原子性保护。

误区系统变慢是因为线程不够用,把 `maximumPoolSize` 调到 1000 肯定没错,反正任务多。

正解线程越多,CPU 在保存和恢复线程上下文上的开销越大。当活跃线程数超过 CPU 核数,多出来的线程其实都在排队抢 CPU。CPU 密集型任务线程数约等于核数;IO 密集型按公式 核数 × (1 + W/C) 估算。盲目加大线程池只会让吞吐量下降,就像后厨塞进一百个厨师,光互相躲人就累得半死,炒菜效率反而更低。

为何常见:直觉上“人多好办事”,忽略了上下文切换成本和共享资源(CPU、内存、锁)的物理极限。

误区这俩都是让线程“等一等”再继续,用哪个都一样吧?我项目里混着用也没出过问题。

正解CountDownLatch 是“倒计时门闩”,N 个线程各自 countDown,等待者等到计数归零才放行,一次性使用;CyclicBarrier 是“集合屏障”,N 个线程互相等待,全部到齐后一起出发,且可以循环使用。两者的语义完全不同:一个是“我等你把活干完”,一个是“我们互相等齐再出发”。选错了,代码的并发行为会和预期相差甚远,且难以排查。

为何常见:两者都在做“等待”,表象相似,底层实现也不同(Latch 基于 AQS 共享模式,Barrier 基于 ReentrantLock + Condition),不深入原理就容易混淆。

面试题

Q1. ReentrantLock 的“可重入”是指什么?

单选题 · easy

  • A.同一个线程可以重复获取同一把锁← 正确答案
  • B.多个线程可以同时获取同一把锁
  • C.锁可以被不同线程交替持有
  • D.一个线程只能持有一把锁

答案:A。可重入的意思是持锁线程自己可以再次进入临界区而不被自己阻塞,A 正确。B 是共享锁的语义,C 是普通互斥,D 与重入无关。就像同一位厨师可以凭通行证连续用同一口锅,不用先还证再排队。

Q2. 关于 AQS(AbstractQueuedSynchronizer),下列说法正确的有?

多选题 · mid

  • A.用 volatile 状态字段记录锁状态← 正确答案
  • B.用 CLH 队列管理所有等待线程← 正确答案
  • C.同时支持独占与共享两种模式← 正确答案
  • D.所有等待线程均自旋且不阻塞

答案:ABC。AQS 核心就是 volatile int state 加 CLH 变体等待队列,支持独占(锁)和共享(信号量、门闩)两种模式,ABC 正确。等待线程获取锁失败后会挂起(park)而不是一直自旋空转,D 错误。AQS 就像叫号系统的规则本:排队、叫号、放行都有规矩。

Q3. ThreadPoolExecutor 提交任务后,正确的执行顺序是?

单选题 · mid

  • A.核心线程、任务队列、扩增线程、拒绝← 正确答案
  • B.扩增线程、任务队列、核心线程、拒绝
  • C.任务队列、核心线程、扩增线程、拒绝
  • D.核心线程、扩增线程、任务队列、拒绝

答案:A。标准顺序是:先尝试核心线程,核心满了任务进队列,队列满了才扩增线程到最大值,达到最大值后执行拒绝策略。这就像后厨先让固定厨师干,忙不过来就让客人排队,队也排不下才临时招人,招满就只能拒单。

Q4. 关于 JDK 8 的 ConcurrentHashMap,下列说法正确的有?

多选题 · hard

  • A.采用 CAS 配合 synchronized 保证并发安全← 正确答案
  • B.沿用了老的分段锁设计
  • C.底层为数组加链表与红黑树← 正确答案
  • D.所有数据共享同一把大锁

答案:AC。JDK 8 用 CAS 处理无竞争的操作,用 synchronized 锁桶的头节点,底层是数组加链表加红黑树,AC 正确。分段锁是 JDK 7 的设计,JDK 8 已放弃,B 错误;所有数据共享一把大锁是 Hashtable 的做法,D 错误。就像冰箱从“几个大抽屉各上一把锁”进化到“每个小格子都能单独锁”。

Q5. CountDownLatch 的核心用途是?

单选题 · easy

  • A.倒计数到零一次性放行所有等待者← 正确答案
  • B.每轮任务完成后计数自动重置归零
  • C.让多个线程互相等待集合后再出发
  • D.限制同时访问共享资源的线程数量

答案:A。CountDownLatch 是倒计时门闩,计数归零后一次性放行所有 await 的线程,且不能重置,A 正确。B 是循环语义,属于 CyclicBarrier;C 是屏障的互相等待;D 是 Semaphore 的限流语义。

Q6. CyclicBarrier 与 CountDownLatch 最大的区别是?

单选题 · mid

  • A.屏障可以重复使用且线程间互相等待← 正确答案
  • B.门闩可以重复使用且线程间互相等待
  • C.两者功能与实现原理上完全相同
  • D.屏障只能使用一次且不能循环重置

答案:A。CyclicBarrier 的屏障在到达后自动重置,可循环使用,且所有参与线程互相等待到齐再出发;CountDownLatch 是别人干活、你等计数,一次性。A 正确,BCD 均描述错误。就像晨会可以每天开(屏障),而开业倒计时牌只倒计时一次(门闩)。

Q7. 关于 Condition 的 await 方法,下列说法正确的是?

单选题 · easy

  • A.await 类似于 wait 且可配多个条件队列← 正确答案
  • B.await 与 wait 之间没有任何对应关系
  • C.await 只能与 synchronized 搭配使用
  • D.wait 内部拥有多个独立的等待队列

答案:A。Condition.await() 对应 Object.wait(),但一个 Lock 可以创建多个 Condition,各自管理独立的等待队列,A 正确。await 必须搭配 Lock 而非 synchronized,C 错误;wait 只有隐式的一个等待队列,D 错误。

Q8. 下列哪些是 ThreadPoolExecutor 常见的拒绝策略?

多选题 · mid

  • A.抛出异常直接放弃该任务← 正确答案
  • B.由提交任务的线程自行执行← 正确答案
  • C.丢弃队列中最旧的一个任务← 正确答案
  • D.无限扩大线程数量继续执行

答案:ABC。AbortPolicy 抛异常、CallerRunsPolicy 由提交者执行、DiscardOldestPolicy 丢弃队首最旧任务,ABC 都是标准拒绝策略。D 不符合线程池设计——maximumPoolSize 是硬上限,不会无限扩招。就像后厨满员时:要么拒单、要么让点菜服务员自己炒、要么扔掉最旧的单子,但绝不会无限塞人。

Q9. 对于 IO 密集型任务的线程池,核心线程数通常如何估算?

单选题 · hard

  • A.直接等于 CPU 的核心数量
  • B.等于 CPU 核心数的两倍
  • C.按核数乘一加等待与计算比← 正确答案
  • D.线程数越多性能就一定越好

答案:C。经验公式是 N_threads = N_CPU × (1 + W/C),IO 密集型任务 W 远大于 C,所以线程数要明显大于核数,C 正确。A 适合 CPU 密集型;B 是一个粗略经验值,不是“通常估算”的标准答案;D 忽略了上下文切换开销,错误。

Q10. 关于 ReentrantLock 的公平锁与非公平锁,下列说法正确的有?

多选题 · extreme

  • A.非公平锁的吞吐通常更高一些← 正确答案
  • B.公平锁严格按排队的顺序获锁← 正确答案
  • C.非公平锁可能导致线程饥饿← 正确答案
  • D.公平锁能完全避免线程切换

答案:ABC。非公平锁允许新线程“插队”竞争,减少唤醒开销,吞吐通常更高,A 正确;公平锁按 CLH 队列顺序获取,B 正确;非公平锁极端情况下后到者反复插队,老线程可能长期拿不到锁,C 正确。公平锁只是排队更公平,唤醒等待线程依然会发生上下文切换,D 错误。就像排队买票:允许插队时柜台利用率高,但老实排队的人可能一直轮不上。