当前位置: 首页 > news >正文

【常见错误】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()时就获取到了之前残留的数据。

下面这张流程图清晰展示了这个过程:

工作线程(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中的数据,无论正常结束还是异常结束,都要执行清除操作。

@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本身也不是原子操作,在执行过程中其他线程可能插入部分数据,造成数据混乱。

下面的时序图揭示了并发竞争的惨烈:

ConcurrentHashMap (初始900)线程2线程1ConcurrentHashMap (初始900)线程2线程1最终大小 900+100+100=1100?由于putAll过程中size变化,实际可能更多size() ->> 900size() ->> 900putAll(计算得到gap=100)putAll(也计算得到gap=100)插入100个再插入100个

解决方案:加锁保护复合操作

最简单的解决办法是对整个复合操作加锁,确保原子性:

synchronized(concurrentHashMap){intgap=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,但可能写出这样的代码:

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)操作,它在虚拟机层面保证了写入的原子性,避免了重量级锁的开销。下图对比了两种方式的执行流程:

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方法源码如下:

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开销飙升
  • 写操作延迟极高

下面这张图直观展示了频繁写时的复制灾难:

渲染错误: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实现高效原子操作,比外部加锁性能高得多。

最后的建议

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

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

http://www.jsqmd.com/news/473330/

相关文章:

  • 即梦LoRA多版本生成效果展示:动态热切换系统实测,惊艳图片一键生成
  • 零基础高效抖音评论采集工具:从数据获取到Excel分析全流程指南
  • 嵌入式设备可行吗?DeepSeek-R1低功耗部署探索
  • 立创开源ESP32迷你无人机:从PCB设计到飞控调参全流程实战指南
  • 3分钟解锁游戏素材:RPG Maker资源提取新方案
  • figmaCN插件:3分钟实现Figma全界面中文化的5大核心方案
  • 庐山派K230开发板PWM实战:从GPIO复用、蜂鸣器驱动到舵机控制
  • 3分钟掌握视频解析工具:抖音无水印视频高效提取完整方案
  • 全桥与半桥LLC谐振DC-DC变换器的设计与Simulink仿真,含开环与电压闭环仿真及电路参...
  • 网盘加速工具提升下载效率的全面指南
  • 手把手教你部署FUTURE POLICE:高精度语音解构系统快速入门
  • 解决NVIDIA显卡色彩过饱和问题:novideo_srgb色彩校准工具使用指南
  • AI辅助开发实战:彻底解决conda pyaudio安装失败的终极指南
  • [第一部分] 立创·实战派ESP32-S3开发板硬件概览与ESP-IDF开发环境搭建指南
  • 旧设备优化:利用开源工具Legacy-iOS-Kit实现环保与价值再生
  • 【ZYNQ】EBAZ4205矿板低成本改造实战:从硬件调试到Hello World
  • 【常见错误】3、线程池避坑指南:从OOM到性能优化,一文掌握线程池最佳实践
  • 纯本地视觉问答新体验:mPLUG-Owl3-2B多模态工具在个人知识管理中的创新应用
  • 【官方未公开的GC调优参数】:PHP 8.9新增gc_max_depth与gc_cycle_root_buffer_size实战指南
  • #第七届立创电赛#《枫》便携式桌面空气质量监测仪:基于N32G430与FreeRTOS的多传感器融合设计详解
  • CVPR‘26开源 | 波恩大学新作:基于通用3D先验的动态视觉SLAM,3个数据集SOTA!
  • STM32 USB OTG_FS主机模式寄存器配置与实战指南
  • 保姆级教程:Ollama运行translategemma-12b-it,翻译说明书、菜单、合同图片
  • 1. 基于STM32的1.28寸圆形IPS屏(GC9A01)驱动移植实战:软件SPI与硬件SPI双方案详解
  • FLUX.小红书极致真实V2代码实例:自定义LoRA加载与动态Scale调节脚本分享
  • 实战应用:用TranslateGemma处理外文资料,提升学习和工作效率
  • 实战指南,基于快马ai快速开发stm32农业物联网监测终端
  • 数据仓库性能优化:10个提升查询效率的实用技巧
  • Mac Mouse Fix安装渠道全解析:基于技术决策模型的最优选择指南
  • Wan2.1-umt5与Node.js全栈开发:打造实时AI聊天应用