【常见错误】1、Java并发工具类四大坑:从ThreadLocal到ConcurrentHashMap,你踩过几个?
Java并发工具类四大坑:从ThreadLocal到ConcurrentHashMap,你踩过几个?
用了并发工具,就以为线程安全高枕无忧了?别天真了!这四大陷阱让你的代码在并发环境下“翻车”于无形。
引言:并发工具不是万能药
在多线程编程中,Java提供了丰富的并发工具类库,从ThreadLocal到ConcurrentHashMap,再到CopyOnWriteArrayList,它们极大地简化了并发程序的开发。然而,工具虽好,用不对却可能带来灾难性后果。
很多开发者在代码审核时会有这样的言论:
- “把HashMap换成ConcurrentHashMap,并发问题就解决了!”
- “用ThreadLocal存储用户信息,线程之间绝对隔离!”
- “听说CopyOnWriteArrayList是无锁的,性能更好,我们用它吧!”
这些看似合理的结论,在实践中往往经不起推敲。没有充分理解并发工具的使用场景、内部原理和局限性,盲目使用只会引入更隐蔽的Bug。
今天,我将结合真实的生产案例,带你深入剖析四个最常见的并发工具使用误区,并用流程图直观展示问题根源。读完本文,你将能避开这些“坑”,真正发挥并发工具的威力。
坑一:ThreadLocal在线程池中“串号”
现象:用户信息张冠李戴
某业务同学反馈,线上偶尔出现“获取到的用户信息是别人的”诡异问题。查看代码发现,他使用了ThreadLocal来缓存当前登录用户信息,逻辑大致如下:
privatestaticfinalThreadLocal<Integer>currentUser=ThreadLocal.withInitial(()->null);@GetMapping("wrong")publicMapwrong(@RequestParam("userId")IntegeruserId){Stringbefore=Thread.currentThread().getName()+":"+currentUser.get();currentUser.set(userId);Stringafter=Thread.currentThread().getName()+":"+currentUser.get();Mapresult=newHashMap();result.put("before",before);result.put("after",after);returnresult;}按照直觉,第一次获取的值始终应为null,但实际运行结果却令人大跌眼镜:当用户2请求时,第一次获取到的竟是用户1的信息!
原因:线程池重用导致数据残留
问题的关键在于:程序运行在Tomcat中,而Tomcat使用线程池处理请求。线程池会重用固定的几个线程。当第一个请求处理完后,线程并没有销毁,ThreadLocal中存储的用户信息仍然存在。第二个请求复用了同一个线程,调用currentUser.get()时就获取到了之前残留的数据。
下面这张流程图清晰展示了这个过程:
解决方案:显式清除ThreadLocal
正确做法是在请求处理结束后,显式删除ThreadLocal中的数据,无论正常结束还是异常结束,都要执行清除操作。
@GetMapping("right")publicMapright(@RequestParam("userId")IntegeruserId){Stringbefore=Thread.currentThread().getName()+":"+currentUser.get();currentUser.set(userId);try{Stringafter=Thread.currentThread().getName()+":"+currentUser.get();Mapresult=newHashMap();result.put("before",before);result.put("after",after);returnresult;}finally{currentUser.remove();// 必须清理}}通过finally块确保每次请求结束后ThreadLocal都被清空,从而避免线程重用带来的数据污染。
经验教训
- 认清线程模型:在Web容器、线程池等环境中,线程是重用的,不能想当然地认为“线程即请求”。
- ThreadLocal使用规范:用完后务必remove,尤其是Web应用中使用ThreadLocal存储请求上下文时。
坑二:ConcurrentHashMap复合操作非原子性
现象:数据填充“过载”
某开发人员想要往一个已有900个元素的ConcurrentHashMap中补充100个元素,由10个线程并发执行。他自信地写下以下代码:
ConcurrentHashMap<String,Long>concurrentHashMap=getData(ITEM_COUNT-100);// 初始900个元素ForkJoinPoolforkJoinPool=newForkJoinPool(THREAD_COUNT);forkJoinPool.execute(()->IntStream.rangeClosed(1,10).parallel().forEach(i->{intgap=ITEM_COUNT-concurrentHashMap.size();// 计算还需多少元素concurrentHashMap.putAll(getData(gap));// 补充元素}));运行后却发现:Map最终大小竟然变成了1536,远超预期的1000,而且日志中出现了负的gap值。
原因:size()与putAll()之间缺乏原子性
ConcurrentHashMap保证单个操作的线程安全,但多个操作组合在一起时并不具备原子性。上述代码中,线程A执行完size()后,线程B可能已经往Map中添加了元素,导致线程A计算出的gap值过时。更糟的是,putAll本身也不是原子操作,在执行过程中其他线程可能插入部分数据,造成数据混乱。
下面的时序图揭示了并发竞争的惨烈:
解决方案:加锁保护复合操作
最简单的解决办法是对整个复合操作加锁,确保原子性:
synchronized(concurrentHashMap){intgap=ITEM_COUNT-concurrentHashMap.size();concurrentHashMap.putAll(getData(gap));}加锁后,多个线程串行执行,size()和putAll之间不会被打断,最终Map大小稳定在1000。
进阶思考:利用原子性API
加锁虽然解决问题,但会降低并发度。ConcurrentHashMap提供了一些原子性的复合方法,如merge、compute等,可以更优雅地实现某些操作。例如,对于统计频率的场景,可以使用computeIfAbsent配合LongAdder,我们在下一个坑中详细展开。
经验教训
- ConcurrentHashMap只保证单个方法原子性,多个方法组合仍需外部同步。
- 聚合方法(size、isEmpty等)返回值在并发下仅作参考,不能用于流程控制。
- 需要原子复合操作时,优先考虑使用ConcurrentHashMap提供的原子性方法。
坑三:未充分利用CAS方法,性能损失10倍
现象:正确但低效的统计
再看一个常见场景:统计10个Key出现的次数,10个线程并发累加1000万次。有经验的开发者会使用ConcurrentHashMap,但可能写出这样的代码:
ConcurrentHashMap<String,Long>freqs=newConcurrentHashMap<>();forkJoinPool.execute(()->IntStream.rangeClosed(1,LOOP_COUNT).parallel().forEach(i->{Stringkey="item"+ThreadLocalRandom.current().nextInt(ITEM_COUNT);synchronized(freqs){if(freqs.containsKey(key)){freqs.put(key,freqs.get(key)+1);}else{freqs.put(key,1L);}}}));这段代码在功能上完全正确,但性能却非常糟糕——每次累加都需要对整个Map加锁,并发度极低。
优化:computeIfAbsent + LongAdder
充分利用ConcurrentHashMap的原子性方法,可以将代码简化为一行,且性能提升10倍以上:
ConcurrentHashMap<String,LongAdder>freqs=newConcurrentHashMap<>();forkJoinPool.execute(()->IntStream.rangeClosed(1,LOOP_COUNT).parallel().forEach(i->{Stringkey="item"+ThreadLocalRandom.current().nextInt(ITEM_COUNT);freqs.computeIfAbsent(key,k->newLongAdder()).increment();}));为什么这么快?computeIfAbsent内部使用了CAS(Compare-And-Swap)操作,它在虚拟机层面保证了写入的原子性,避免了重量级锁的开销。下图对比了两种方式的执行流程:
从图中可以看出,加锁方式在每次累加时都会锁住整个Map,导致线程阻塞;而computeIfAbsent仅在Key不存在时进行一次CAS插入,后续累加直接通过LongAdder的CAS操作完成,粒度更细,并发度更高。
性能实测
分别运行10万次累加,测试结果如下:
| 方式 | 耗时 |
|---|---|
| 加锁方式 | 约2536 ms |
| computeIfAbsent+LongAdder | 约256 ms |
性能提升近10倍!这正是充分理解并发工具特性的回报。
经验教训
- 熟悉并发工具的原子性API,如
computeIfAbsent、merge、replace等。 - 优先使用无锁或细粒度锁的并发结构,避免粗粒度锁。
- 对于计数器场景,LongAdder比AtomicLong更适合高并发,因为它采用了分段累加的思想。
坑四:CopyOnWriteArrayList在不适用场景下性能崩盘
现象:缓存写入比数据库还慢
某团队使用CopyOnWriteArrayList缓存大量数据,却发现修改数据时操作本地缓存竟然比写数据库还慢。排查发现,缓存数据频繁更新,而CopyOnWriteArrayList的每次修改都会复制整个底层数组。
原理:写时复制机制
CopyOnWriteArrayList的线程安全实现非常“朴素”:所有修改操作(add、set、remove等)都会复制一份新数组,在新数组上进行修改,然后用新数组替换旧数组。读操作则不加锁,直接访问原数组。
这种机制保证了读操作的极致性能和无锁并发,但付出的代价是写操作的内存开销和时间开销巨大。其add方法源码如下:
publicbooleanadd(Ee){synchronized(lock){Object[]elements=getArray();intlen=elements.length;Object[]newElements=Arrays.copyOf(elements,len+1);newElements[len]=e;setArray(newElements);returntrue;}}每次add都会创建一个长度+1的新数组,并复制全部元素。
适用场景:读多写极少
CopyOnWriteArrayList的设计目标场景是:读操作远多于写操作,且写操作很少。例如黑名单列表、配置信息缓存等。在这些场景下,写操作的复制成本可以被读操作的无锁高并发摊平。
但如果写操作频繁,频繁复制数组将导致:
- 频繁的Young GC乃至Old GC
- CPU开销飙升
- 写操作延迟极高
下面这张图直观展示了频繁写时的复制灾难:
性能对比
通过一个简单测试对比CopyOnWriteArrayList和同步包装的ArrayList(Collections.synchronizedList(new ArrayList<>()))的读写性能。
写性能测试(10万次并发add):
Write:copyOnWriteArrayList 耗时 6344 ms Write:synchronizedList 耗时 78 msCopyOnWriteArrayList慢了81倍!
读性能测试(100万次并发get):
Read:copyOnWriteArrayList 耗时 125 ms Read:synchronizedList 耗时 724 msCopyOnWriteArrayList快了5.8倍。
数据清晰地表明:没有万能的并发容器,只有适合场景的容器。
经验教训
- 了解并发容器的设计哲学,CopyOnWriteArrayList适用于读多写极少的场景。
- 如果读写比例均衡或有大量写操作,应选用其他线程安全的List,如
Collections.synchronizedList或ConcurrentLinkedQueue等。 - 不要被“无锁”等光环迷惑,一定要结合业务场景做选型。
总结:如何避开并发工具的那些坑?
通过以上四个真实案例,我们可以总结出使用并发工具时的“三大纪律八项注意”:
必须理解线程模型
即使没有显式创建线程,代码也可能运行在线程池中。ThreadLocal用完必须remove。必须阅读官方文档
不了解并发工具的原理和API就盲目使用,等于在悬崖边跳舞。花时间阅读JDK文档,做小实验验证,能避免80%的坑。必须进行压力测试
并发问题往往在低负载下难以复现,需要通过压力测试暴露潜在问题。使用JMH、Jmeter等工具模拟高并发场景,确保代码的正确性和性能。选择合适的工具
- 线程局部变量:ThreadLocal
- 线程安全哈希表:ConcurrentHashMap(注意复合操作需同步)
- 计数器:LongAdder优于AtomicLong
- 读多写少集合:CopyOnWriteArrayList
- 读写均衡或写多:同步包装类或并发队列
善用原子性复合方法
如computeIfAbsent、merge、replace等,它们利用CAS实现高效原子操作,比外部加锁性能高得多。
最后的建议
并发编程没有银弹。每一个并发工具都有其适用边界,深入理解它们的内部机制,结合业务场景做出正确选择,才能写出既安全又高效的并发代码。
你在实际开发中还遇到过哪些并发工具的坑?欢迎在评论区分享你的故事,一起避坑!
