上周五凌晨,线上订单系统的对账服务突然漏掉了3000多笔交易。排查发现,罪魁祸首竟然是ConcurrentHashMap里一个“线程安全”的computeIfAbsent操作——你敢信?今天我们就来扒一扒那些年Java并发集合给我们挖的深坑。
场景还原:血淋淋的生产事故
我们的对账服务需要实时合并来自Kafka的支付成功和物流发货事件。为了提升性能,我用ConcurrentHashMap缓存了订单号到合并结果的映射,核心逻辑是这样的:
ConcurrentHashMap<String, Order> orderCache = new ConcurrentHashMap<>(); void mergeEvent(OrderEvent event) { orderCache.computeIfAbsent(event.getOrderId(), id -> { Order newOrder = new Order(); newOrder.setStatus("processing"); // 初始化状态 return newOrder; }); // 后续更新操作... }看起来没问题对吧?但在QPS冲到500+时,监控突然显示有20%的订单状态卡在"processing"——明明后续更新逻辑执行了,状态却没变!
根因分析:锁的粒度与嵌套调用
ConcurrentHashMap的线程安全是通过分段锁实现的,但computeIfAbsent有个致命特点:当Key存在时完全不加锁,Key不存在时只锁当前桶。问题出在这段代码:- 线程A和线程B同时处理同一个新订单
- 线程A先进入
computeIfAbsent,发现Key不存在,锁定桶#3并开始初始化 - 线程B也调用
computeIfAbsent,此时Key在桶#3已存在但未完全初始化完成(JVM指令重排可能导致) - 线程B直接读取到未完全构造的Order对象,后续操作自然失效
更讽刺的是,这个坑在Java 8的官方文档里早有警告: > "The entire method invocation is performed atomically,but the function may be applied more than onceif attempted updates fail due to collisions."
修复方案:悲观锁还是原子引用?
错误写法(典型误区)
// 试图用双重检查锁解决(依旧有问题!) Order order = orderCache.get(orderId); if (order == null) { synchronized (this) { order = orderCache.computeIfAbsent(orderId, id -> new Order()); } }- 问题:
get操作和synchronized之间仍有竞态条件
正确解法1(完全加锁)
synchronized (orderCache) { // 全局锁影响性能 Order order = orderCache.computeIfAbsent(orderId, id -> new Order()); // 后续操作... }正确解法2(AtomicReference特性)
ConcurrentHashMap<String, AtomicReference<Order>> orderCache = new ConcurrentHashMap<>(); void mergeEvent(OrderEvent event) { AtomicReference<Order> ref = orderCache.computeIfAbsent( event.getOrderId(), k -> new AtomicReference<>(new Order()) ); ref.updateAndGet(existing -> { // 原子更新逻辑 return updatedOrder; }); }实测对比(100万次操作,8线程):
| 方案 | 耗时(ms) | 内存开销 |
|---|---|---|
| 原始错误代码 | 423 | 低 |
| 全局锁方案 | 891 | 最低 |
| AtomicReference | 517 | 高15% |
并发集合的隐藏陷阱清单
size()/isEmpty()的谎言
ConcurrentHashMap的size()实际上是遍历所有段求和,可能包含已过期的数据。需要精确计数时请用mappingCount()(返回long避免溢出)- 迭代器的弱一致性
下面代码可能在高压下死循环:
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>(); // 线程A map.put("key", "value"); // 线程B for (String key : map.keySet()) { if (key.startsWith("k")) map.remove(key); // 可能抛出ConcurrentModificationException! }正确的删除姿势:
map.keySet().removeIf(key -> key.startsWith("k"));putAll不是原子操作
即使是批量操作,ConcurrentHashMap.putAll()也是分多次put,中间状态可能被其他线程观测到
- LongAdder的伪共享
你以为ConcurrentHashMap的计数器性能很好?在早于Java 8u131的版本中,多个计数器可能落在同一缓存行,导致性能下降40%+(用@Contented注解缓解)
最佳实践:线程安全≠业务安全
经过这次教训,我总结出一条铁律:并发集合的线程安全仅保证数据结构不损坏,不保证复合操作的业务语义。对于关键业务逻辑,要么:
- 使用更底层的
AtomicReferenceFieldUpdater - 接受性能损耗换
synchronized - 干脆用
CopyOnWriteArrayList等完全拷贝的容器
你在项目里还遇到过哪些“伪线程安全”的坑?评论区聊聊你的血泪史吧。