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