真实面经题目 · 原创解析

如果要用线程安全的数据结构,有什么替代方案?

使用线程安全数据结构时,核心不是简单把 ArrayList、HashMap 换成带锁版本,而是先判断共享状态是否真的必须共享,再按读写比例、是否需要阻塞、是否需要顺序、是否有复合操作一致性来选型。Java 中常见替代方案包括 ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue、ConcurrentLinkedQueue、同步包装器、不可变快照、ThreadLocal、分段锁、Actor 或消息队列串行化,以及在跨进程场景下使用数据库或缓存的原子能力。

出现于:阿里巴巴 · 后端开发

60 秒回答模板

我会先按场景分类,而不是直接说某一个集合一定最好。如果是高并发键值读写,优先考虑 ConcurrentHashMap;如果是读多写少的列表,考虑 CopyOnWriteArrayList;如果是生产者消费者模型,使用 BlockingQueue;如果只需要非阻塞 FIFO 队列,可以用 ConcurrentLinkedQueue;如果是旧代码低并发改造,可以用 Collections.synchronizedList 或 synchronizedMap,但要注意复合操作仍需要外部同步。进一步看,如果数据可以不共享,优先用 ThreadLocal 或局部变量;如果可以接受版本化数据,使用不可变对象加快照替换;如果有多个字段需要保持不变量,可以用显式锁、读写锁、分段锁,甚至把状态封装到单线程 Actor 或消息队列里。最后,如果一致性边界已经跨 JVM,就不能只靠 Java 集合,应该把原子性放到数据库事务、Redis 原子命令、分布式锁或幂等机制里。

考点 先判断是否必须共享
主线 按数据结构类型选择替代品
易错点 只回答 Vector、Hashtable、synchr…

深入解析

01

先判断是否必须共享

线程安全数据结构解决的是多个线程并发访问同一份内存状态的问题。但更优先的思路是减少共享:能用方法局部变量就不用成员变量,能每个线程独占就用 ThreadLocal,能通过参数传递就避免全局容器。共享状态越少,锁竞争、可见性问题和复合操作竞态就越少。

02

按数据结构类型选择替代品

如果需要 Map,常用 ConcurrentHashMap,适合高并发查询和更新;如果需要 List 且读远多于写,可以用 CopyOnWriteArrayList;如果需要 Set,可以用 ConcurrentHashMap.newKeySet 或 CopyOnWriteArraySet;如果需要有序 Map 或 Set,可以考虑 ConcurrentSkipListMap、ConcurrentSkipListSet;如果是计数器,AtomicInteger、AtomicLong、LongAdder 往往比把数值塞进集合里更合适。

03

按访问模式选择队列

生产者消费者模型通常使用 BlockingQueue,例如 ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、DelayQueue。它们能表达背压、等待、超时和容量控制。如果只是多个线程并发入队出队,不希望线程阻塞,可以考虑 ConcurrentLinkedQueue。队列类结构的优势是天然把共享写操作转化为消息传递。

04

同步包装器的边界

Collections.synchronizedList、synchronizedMap、synchronizedSet 可以把普通集合包装成同步集合,适合低并发、老代码兼容、改动成本低的场景。但它们通常是粗粒度锁,扩展性不如 concurrent 包中的专用实现。更重要的是,单个方法调用是同步的,不代表复合逻辑安全,例如先 contains 再 add、遍历时删除、先 get 再 put 这类操作仍然可能需要外部加锁。

05

不可变快照

如果数据更新频率低、读取频率高,可以使用不可变对象或不可变集合,每次写入时生成新版本并通过 volatile、AtomicReference 或安全发布替换引用。读线程只读取稳定快照,不需要锁,也不会看到半更新状态。这种方式适合配置、路由表、规则表、白名单、权限缓存等场景,但不适合高频写入的大集合。

06

复合不变量需要更高层同步

线程安全集合只能保证集合内部结构不被并发破坏,并不自动保证业务不变量。例如库存扣减、账户转账、缓存和索引双写、两个集合之间保持一致,这些都不是单个集合方法能解决的。此时应使用 synchronized、ReentrantLock、ReadWriteLock、StampedLock、分段锁,或把相关状态封装到同一个同步边界内。

07

用架构方式规避共享内存

当并发修改逻辑复杂时,可以考虑 Actor 模型、单线程事件循环、消息队列、任务串行化等方式。多个线程不直接操作共享对象,而是把变更请求投递给一个拥有状态的执行单元,由它顺序处理。这样可以把锁竞争问题转化为吞吐、队列积压和延迟控制问题,适合订单状态机、会话状态、规则引擎等场景。

08

跨进程不能只靠本地集合

如果系统部署了多个 JVM,Java 内存中的线程安全集合只能保证单进程内安全,不能保证集群一致性。涉及全局唯一、库存扣减、余额变更、分布式限流等问题时,应使用数据库事务、唯一索引、乐观锁版本号、Redis 原子命令、Lua 脚本、消息幂等等机制,把一致性放到真正共享的存储层或协调层。

易错点

  • 只回答 Vector、Hashtable、synchronized,忽略 java.util.concurrent 中更常用的并发容器。
  • 认为用了线程安全集合就所有业务逻辑都线程安全,忽略 contains 再 add、get 再 put、遍历再删除等复合操作竞态。
  • 不区分读多写少和写多读少,在高频写入场景滥用 CopyOnWriteArrayList。
  • 把 ConcurrentHashMap 当成全局锁使用,忽略 compute、merge、putIfAbsent 等原子方法的语义。
  • 在多 JVM 或分布式场景中只依赖本地内存集合,忽略数据库、缓存、消息系统的一致性边界。
  • 使用 ThreadLocal 保存请求上下文后不清理,在线程池复用时造成数据串扰或内存泄漏。

面试官追问

ConcurrentHashMap 和 Hashtable、Collections.synchronizedMap 有什么区别?

Hashtable 和 synchronizedMap 通常是粗粒度同步,很多操作会竞争同一把锁;ConcurrentHashMap 面向高并发访问设计,读操作并发度更高,并提供 putIfAbsent、compute、merge 等原子复合方法。实际选型上,新代码高并发 Map 优先考虑 ConcurrentHashMap;老代码兼容、并发很低时同步包装器也可以接受。

为什么 CopyOnWriteArrayList 不适合写多场景?

因为它的写入不是简单原地修改,而是复制数组后再替换引用。这样读线程可以无锁读取稳定快照,但每次写入都会产生复制成本和额外内存占用。写频繁时吞吐会下降,垃圾回收压力也会增加。

线程安全集合遍历时一定安全吗?

不一定,要看集合的迭代语义。CopyOnWriteArrayList 的迭代基于快照,遍历期间不会抛并发修改异常,但看不到后续更新。ConcurrentHashMap 的迭代是弱一致的,允许并发修改,但不保证遍历结果是某一瞬间的完整快照。同步包装器在遍历时通常仍需要手动在同一把锁上同步。

如果要实现先判断不存在再插入,应该怎么做?

不要写成 containsKey 后再 put,因为两个操作之间可能被其他线程插入。ConcurrentHashMap 中应使用 putIfAbsent、computeIfAbsent 或 compute 这类原子方法。如果逻辑跨多个结构或多个字段,就需要把整个业务操作放进同一个锁、事务或串行化处理单元中。

什么时候用 BlockingQueue,什么时候用 ConcurrentLinkedQueue?

需要生产者消费者协作、容量限制、阻塞等待、超时获取时,用 BlockingQueue。只需要高并发非阻塞入队出队,不希望线程等待时,用 ConcurrentLinkedQueue。但 ConcurrentLinkedQueue 不提供天然背压,生产速度长期高于消费速度时可能导致内存压力。