【常见错误】1、Java并发工具类四大坑:从ThreadLocal到ConcurrentHashMap,你踩过几个?

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提供了一些原子性的复合方法,如mergecompute等,可以更优雅地实现某些操作。例如,对于统计频率的场景,可以使用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 ,如computeIfAbsentmergereplace等。
  • 优先使用无锁或细粒度锁的并发结构,避免粗粒度锁。
  • 对于计数器场景,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.synchronizedListConcurrentLinkedQueue等。
  • 不要被"无锁"等光环迷惑,一定要结合业务场景做选型。

总结:如何避开并发工具的那些坑?

通过以上四个真实案例,我们可以总结出使用并发工具时的"三大纪律八项注意":

  1. 必须理解线程模型

    即使没有显式创建线程,代码也可能运行在线程池中。ThreadLocal用完必须remove。

  2. 必须阅读官方文档

    不了解并发工具的原理和API就盲目使用,等于在悬崖边跳舞。花时间阅读JDK文档,做小实验验证,能避免80%的坑。

  3. 必须进行压力测试

    并发问题往往在低负载下难以复现,需要通过压力测试暴露潜在问题。使用JMH、Jmeter等工具模拟高并发场景,确保代码的正确性和性能。

  4. 选择合适的工具

    • 线程局部变量:ThreadLocal
    • 线程安全哈希表:ConcurrentHashMap(注意复合操作需同步)
    • 计数器:LongAdder优于AtomicLong
    • 读多写少集合:CopyOnWriteArrayList
    • 读写均衡或写多:同步包装类或并发队列
  5. 善用原子性复合方法

    computeIfAbsentmergereplace等,它们利用CAS实现高效原子操作,比外部加锁性能高得多。

最后的建议

并发编程没有银弹。每一个并发工具都有其适用边界,深入理解它们的内部机制,结合业务场景做出正确选择,才能写出既安全又高效的并发代码。

你在实际开发中还遇到过哪些并发工具的坑?欢迎在评论区分享你的故事,一起避坑!

相关推荐
Love Song残响2 小时前
MATLAB疑难杂症全攻略:从报错到优化
开发语言·matlab
weixin199701080162 小时前
货铺头商品详情页前端性能优化实战
java·前端·python
risc1234562 小时前
channel.read(dest, channelPosition) 的读取大小限制
开发语言·python
惊讶的猫2 小时前
Springboot 组件注册 条件注解
java·spring boot·后端
lauo2 小时前
dtnsbot分身网页版正式上线:开启“灵魂与肉身分离”的智能体远程控制新纪元
人工智能·智能手机·架构·开源·github
爆炒西瓜@2 小时前
springboot内存定位,提取数据库账号密码
数据库·spring boot·后端
c++之路2 小时前
Linux进程池与线程池深度解析:设计原理+实战实现(网盘项目架构)
java·linux·架构
阿里云基础软件2 小时前
当 CPU 莫名抖动时,SysOM Agent 如何 3 分钟定位元凶?
java·阿里云·智能运维·操作系统控制台·sysom