面试考察点

  • 能否比较 Collections.synchronizedXxx 与专用并发容器。
  • 是否理解锁粒度、迭代方式和原子复合 API。
  • 能否按读写模式选择容器。

核心答案

同步包装器通常用一把互斥锁保护普通集合的单个方法,结构简单但竞争较大;并发集合针对访问模式设计,使用分段、CAS、写时复制或无锁算法提升并发度,并提供 putIfAbsentcompute 等原子操作。

常见选择包括 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 包」、配置实验和事故数据,比复述固定模板更有说服力。