面试考察点
- 能否比较
Collections.synchronizedXxx与专用并发容器。 - 是否理解锁粒度、迭代方式和原子复合 API。
- 能否按读写模式选择容器。
核心答案
同步包装器通常用一把互斥锁保护普通集合的单个方法,结构简单但竞争较大;并发集合针对访问模式设计,使用分段、CAS、写时复制或无锁算法提升并发度,并提供
putIfAbsent、compute等原子操作。
常见选择包括 ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentLinkedQueue 和各种 BlockingQueue,它们的一致性与阻塞语义并不相同。
关键差异
同步包装器遍历时通常要求调用方手动持有包装器的锁;并发集合的迭代器多为快照或弱一致。即使单个方法同步,if (!list.contains(x)) list.add(x) 仍需在同一锁内执行。
常见容器选型表
| 需求 | 常用容器 | 核心语义 |
|---|---|---|
| 高并发按键读写 | ConcurrentHashMap | 弱一致遍历、按键原子复合操作 |
| 读极多写极少的短列表 | CopyOnWriteArrayList | 写时复制、快照迭代 |
| 非阻塞 FIFO | ConcurrentLinkedQueue | CAS 链式队列,不提供容量背压 |
| 生产消费与背压 | ArrayBlockingQueue | 有界、可阻塞、容量明确 |
| 延时任务 | DelayQueue | 元素到期后才能取出,无界 |
| 优先级阻塞任务 | PriorityBlockingQueue | 按优先级取出,但默认无界 |
“线程安全集合”范围很大,是否阻塞、是否有界、迭代看什么、复合操作是否原子都不同,不能只根据类名带 Concurrent 就替换。
同步包装器的正确遍历
List<String> list = Collections.synchronizedList(new ArrayList<>());
synchronized (list) {
for (String value : list) {
consume(value);
}
}
包装器的迭代器本身没有额外加锁,调用方要锁住返回的包装器对象。若锁住原始底层 ArrayList 或另一个对象,就无法与包装器方法互斥。
复合操作与跨键约束
map.compute(accountId, (id, balance) -> balance - amount);
这可以原子更新一个键,但“从 A 扣款同时给 B 加款”涉及两个键,ConcurrentHashMap 没有跨键事务。可以按稳定顺序锁住账户、使用数据库事务,或通过单线程状态机串行处理。
同理,线程安全 List 的 size() 后 get(size - 1) 也不是一个原子动作,两个调用之间列表可能改变。API 是否线程安全必须落到完整业务操作上分析。
任务队列案例
日志异步写入若使用无界 ConcurrentLinkedQueue,磁盘变慢时生产者继续灌入,最终内存耗尽。改为有界 BlockingQueue 后,还必须定义满队列策略:阻塞业务线程、丢弃低级日志、同步降级写入,还是采样;容器只提供机制,业务必须决定取舍。
性能验证
并发容器性能取决于读写比例、键分布、对象大小和 CPU 核数。基准要包含热点键、真实临界区和竞争度,使用 JMH 或压测观察吞吐与 P99,不能用单线程循环推断并发表现。
选择原则
低并发且需要简单互斥可用同步包装器;高并发 Map 使用 ConcurrentHashMap;读多写少列表可评估 CopyOnWriteArrayList;需要背压和线程协作选择有界 BlockingQueue。
常见误区
并发容器不是“完全无锁”,也不会自动保证跨多个键或多个容器的业务原子性。错误的数据结构选择可能只是把锁竞争换成内存或重试成本。
高频追问与参考回答
追问:Vector 为什么很少推荐?
它对单个方法做同步,接口老旧且复合操作仍需额外协调。新代码通常按实际语义选择普通集合加明确锁,或专用并发容器。
追问:ConcurrentLinkedQueue 为什么不能做背压?
它是无界非阻塞队列,offer 通常不会因容量失败。消费者跟不上时元素持续堆积,必须在外部计数限流,或直接选择有界 BlockingQueue。
追问:并发容器能放可变对象吗?
可以,但容器只保护自身结构和引用发布。对象内部字段仍需不可变、锁、volatile 或其他同步保证。
追问:何时普通集合加锁更合适?
需要跨多个集合维持复杂不变量、竞争不高且临界区明确时,一把清晰的锁往往比组合多个并发容器更容易证明正确。
总结
选择线程安全集合时要同时回答访问模式、复合原子性、迭代一致性和背压需求。
机制全景图
下面把「同步集合和并发集合有什么区别?」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。
flowchart LR
A["识别并发访问模式"]
A --> B["选择映射队列或快照"]
B --> C["执行单操作原子更新"]
C --> D["建立复合操作边界"]
D --> E["观测竞争与容量"]
完整链路:从输入到结果
沿着「识别并发访问模式 → 选择映射队列或快照 → 执行单操作原子更新 → 建立复合操作边界 → 观测竞争与容量」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。
1. 识别并发访问模式
先区分读多写少、生产消费、计数聚合还是有序调度,不存在适合所有模式的“并发集合”。
2. 选择映射队列或快照
ConcurrentHashMap、BlockingQueue、CopyOnWriteArrayList 和 ConcurrentSkipListMap 分别优化不同结构与一致性。
3. 执行单操作原子更新
线程安全集合通常只保证单个 API 的原子性;compute、putIfAbsent 等组合 API 才能封装常见竞态。
4. 建立复合操作边界
跨多个键或集合的业务不变量仍需锁、不可变快照、串行化或事务协议。
5. 观测竞争与容量
容量和竞争必须可观测,无界队列或热点键会把线程安全问题转化为延迟与内存故障。
源码与实现定位
| 入口 | 阅读重点 |
|---|---|
| java.util.concurrent 包 | 按 Map/Queue/Deque/SkipList 选择 |
| BlockingQueue#put/take | 容量、条件等待和中断 |
源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。
参数配置与可复现实验
if (inflight.putIfAbsent(taskId, STARTED) == null) {
executor.execute(() -> process(taskId));
}
按真实读写比压测 ConcurrentHashMap、同步包装与快照;队列测试必须让消费者故意变慢,验证背压和拒绝。
验证步骤与预期结果
1. 固定输入和基线
先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「队列容量」为主基线,记录值应满足「按峰值恢复窗口计算」;同时保存 队列长度与等待时间、热点键更新冲突,使后续变化能够回到同一时间轴比较。
2. 从实现入口确认路径
在「java.util.concurrent 包」确认请求确实进入「按 Map/Queue/Deque/SkipList 选择」对应的实现,再沿「BlockingQueue#put/take」观察「容量、条件等待和中断」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。
3. 注入本文特有的失败模式
优先复现「用线程安全集合拼出非原子业务流程」,并把单一变量逐级放大,直到「队列容量」越过「无界或持续满载」。随后再分别验证「无界队列导致 OOM」和「映射回调执行外部慢调用」,三类故障分开执行,避免多个变量同时变化而无法归因。
4. 执行止损和根因修复
第一轮只应用「把检查再执行折叠为原子 API」,确认它能控制影响范围;第二轮应用「所有生产队列设置容量」,验证核心链路恢复;最后落实「跨键不变量改锁或单所有者」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。
5. 通过退出条件
实验只有同时满足三项才算通过:「队列容量」回到「按峰值恢复窗口计算」、「原子 API 冲突」回到「应随热键可解释」、「回调阻塞时间」回到「纯内存短操作」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。
量化基线
| 指标 | 样例基线/口径 | 风险线 | 结论 |
|---|---|---|---|
| 队列容量 | 按峰值恢复窗口计算 | 无界或持续满载 | 限流/降级 |
| 原子 API 冲突 | 应随热键可解释 | contains+put 重复 | 改 putIfAbsent |
| 回调阻塞时间 | 纯内存短操作 | 出现远程延迟 | 移出映射回调 |
这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。
事故复盘:任务系统换成并发集合后仍然重复执行
代码用 containsKey 后再 put 标记任务,两个线程都通过检查。ConcurrentHashMap 没有损坏,但两步之间存在竞态。改用 putIfAbsent 返回值决定唯一执行者,并在失败回滚时明确标记生命周期后才解决。
| 失败模式 | 首要证据 | 第一处置动作 |
|---|---|---|
| 用线程安全集合拼出非原子业务流程 | 队列长度与等待时间 | 把检查再执行折叠为原子 API |
| 无界队列导致 OOM | 热点键更新冲突 | 所有生产队列设置容量 |
| 映射回调执行外部慢调用 | 原子 API 重试次数 | 跨键不变量改锁或单所有者 |
发布与回滚检查点
- 发布前:确认「java.util.concurrent 包」对应实现和上述配置在目标版本仍然有效,并保存「队列容量」基线。
- 灰度中:同时观察 队列长度与等待时间、热点键更新冲突、原子 API 重试次数;任一指标越过表中风险线,就停止继续扩量。
- 回滚时:先执行「把检查再执行折叠为原子 API」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
- 发布后:至少覆盖一个完整峰值周期,确认「用线程安全集合拼出非原子业务流程」没有再次出现,才关闭变更观察窗口。
方案对比与选型
| 方案 | 更适合的场景 | 主要收益 | 代价与边界 |
|---|---|---|---|
| ConcurrentHashMap | 高并发键值访问 | 桶级并发与原子复合 API | 不支持跨键事务 |
| BlockingQueue | 有界生产消费与背压 | 容量、等待语义明确 | 锁竞争和队头阻塞需评估 |
| ConcurrentSkipListMap | 并发有序键与范围查询 | 有序、导航操作丰富 | O(log n) 且节点开销较高 |
选型至少带上 元素数量、读写比例、遍历方式、并发度和内存预算,并用上面的量化基线验证;未知数据应明确为待测假设。
设计边界与工程取舍
并发集合减少底层数据结构损坏风险,不替代业务并发控制;必须把不变量映射到一个原子操作、一个所有者或明确的事务边界。
工程落地遵循:先保证数据结构语义正确,再依据访问模式选择实现。回答时直接引用「java.util.concurrent 包」、配置实验和事故数据,比复述固定模板更有说服力。