做后端这几年,我见过太多“看起来莫名其妙、最后发现全是自己埋的雷”的并发故障。最典型的一次,线上刚好碰上灰度发布,用户A的购物车数据偶尔出现在用户B的账户里,整个技术组排查了一下午,缓存、网关、数据库全查了一遍,最后定位到Java并发工具类里一个ThreadLocal没清。
Java并发工具类给业务开发提供了很多便利,但便利背后全是暗礁,尤其是从ThreadLocal到ConcurrentHashMap这一路,踩坑的人一抓一大把。这篇文章想聊聊我在实际项目里遇到最多的四个高频坑,以及每个坑从现场到根因的分析思路,适合正在写业务代码的同学,也适合准备Java面试想弄懂这些“八股”背后原理的读者。
1. ThreadLocal第一坑:用完不remove,内存泄漏和数据串线一起找上门
1.1 线上事故:A的数据跑到了B的请求里
先看一段很典型的用户上下文代码,很多人项目中都有类似的类:
public class UserContext { private static final ThreadLocal<UserInfo> CURRENT = new ThreadLocal<>(); public static void set(UserInfo user) { CURRENT.set(user); } public static UserInfo get() { return CURRENT.get(); } }登录拦截器里set,业务Service里get,平时一切正常。直到某天你发现,用户B在处理订单时,居然读到了用户A的userId。第一反应是缓存串了,但实际查下来,是Tomcat线程池复用了处理用户A请求的那个线程,而UserContext里的ThreadLocal在上一次请求处理完时没有remove。
Tomcat默认的连接线程是常驻线程池,线程执行完一个请求后不会被销毁,而是回到池里等待下一次分配。于是ThreadLocal里挂着的“A的数据”就跟着线程存活了下来,一旦这个线程被复用给B,B的业务代码一调UserContext.get(),拿到的自然是A。这不是并发竞争,而是脏数据残留,根子就是“借了不还”。
1.2 根因复盘:ThreadLocal到底把值塞到了哪里
很多初学者以为ThreadLocal是把值放进了ThreadLocal对象本身,其实完全不是。每个Thread对象内部都有一个ThreadLocalMap,ThreadLocal只是充当这个map的key。set的时候大致是:
- 拿当前线程t;
- 找t身上的threadLocals字段;
- 如果不存在,就创建一个ThreadLocalMap;
- 把this作为key、要存的值作为value放进去。
get的时候反过来,先拿当前线程t,再通过getMap(t)取到t的threadLocals,最后用ThreadLocal对象作为key找到对应的Entry取出value。所以同一个ThreadLocal对象,在不同线程里get到的值天然不同,因为它本来就是每个线程各存各的。
ThreadLocalMap里的Entry是弱引用Key。Entry继承了WeakReference,key指向ThreadLocal本身,value是强引用。如果ThreadLocal对象在业务代码里不再被强引用,GC时key会被回收,但value还留在Entry里。如果当前线程是短命的一次性线程,线程死了整个Map也没了,问题不大;但线程池里的线程是长命的,value就一直被强引用在ThreadLocalMap里,无法回收,这就是内存泄漏。
就算ThreadLocal本身是静态常量一直被强引用,不remove的话value同样会累积,只是这时泄漏的表现不是key被回收,而是纯粹“没清理”。很多老项目接口耗内存越跑越高,堆里能看到大量本该是临时的上下文对象,多半就是这么来的。
1.3 为什么线程池会让这个坑无限放大
可以用一个生活化的比喻:把线程池里的线程想象成酒店的房间,请求A住进去没退房,房里的东西原封不动留给下一次入住的请求B。数据串线就这样发生了。
这个坑最恶心人的地方在于它不常现。如果线程池里有几十个线程,只有恰好被复用到同一执行流的请求才会出问题,平时测试单线程跑根本发现不了,线上是高并发偶发,压测又未必能稳定复现。我见过有团队为这个问题排查了整整两周,一开始怀疑Redis缓存key冲突,又怀疑数据库脏读,最后靠加日志才发现ThreadLocal里残留的userId。
这里要说清楚:ThreadLocal本身没有线程安全问题,它造成的串数据更像“上一任租客留下的垃圾”,而不是“多线程打架”。
1.4 正确清理姿势:finally与统一拦截
针对用户上下文这种场景,最好的办法不是靠程序员自觉记得remove,而是放到统一的Filter里收尾:
@Component public class UserContextFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { try { // 登录解析后 UserContext.set(user) chain.doFilter(request, response); } finally { UserContext.clear(); } } }clear方法内部调用ThreadLocal.remove(),把Entry从当前线程的Map中删掉,这一步才是真正的“退房”。如果业务代码里临时用到ThreadLocal,也要用try/finally包住:
ThreadLocal<Span> spanLocal = new ThreadLocal<>(); try { spanLocal.set(span); doSomething(); } finally { spanLocal.remove(); }这个习惯说出来很简单,但很多团队代码评审根本不管,直到线上出了事故才开始补。如果你想确认项目里到底哪些地方漏了清理,可以搜一下所有ThreadLocal的set调用点,逐个看生命周期是否闭环,比凭感觉找可靠得多。
2. ThreadLocal第二坑:get()的默认值谜团,以及被误当并发同步器的ThreadLocal
2.1 get()没set时不一定返回null
这个坑特别隐蔽,尤其在面试里被反复问。ThreadLocal.get()的源码逻辑是这样:
public T get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings("unchecked") T result = (T)e.value; return result; } } return setInitialValue(); }注意最后一行:如果当前线程的Map为空,或者Map里没有当前ThreadLocal对应的Entry,get()并不会直接返回null,而是会调用setInitialValue(),也就是去执行initialValue()方法,把返回值塞进Map再返回。
默认情况下initialValue()返回null,所以大家平时感觉不到。但如果子类重写了initialValue(),结果就完全不一样:
ThreadLocal<String> LOCAL = ThreadLocal.withInitial(() -> "default"); String value = LOCAL.get(); // 返回 "default",而不是 null这本身很强大,但也意味着:你的判断逻辑不能想当然地认为“get()返回null就说明之前没有set过”。有些框架就是用initialValue()给每个线程设置初始上下文,比如生成独立的traceId。如果业务里用get()==null做某个分支判断,很可能走进完全不是预期的逻辑。
2.2 getMap到底在找什么
网上很多同学搜过“threadlocal getmap”,说明这个底层细节也是高频问题。getMap(t)其实就是在取当前线程t身上的threadLocals字段,这是一个包私有字段,由ThreadLocal类的静态方法访问。
这个设计有一个关键推论:ThreadLocalMap是挂在Thread上的,挂载时机是线程第一次调用set()或get()的时候。也就是说,线程没用过某个ThreadLocal之前,它的threadLocals可能是null,用的时候才懒创建。这解释了为什么get()第一次调用时会走setInitialValue(),因为Map压根还没建。
弄明白这个,能回答很多面试连环问:
- ThreadLocal数据是全局一份还是线程一份?——准确说法是“同一个ThreadLocal对象作为key,在每个线程各自的ThreadLocalMap里存一份”。
- 两个线程共用一个ThreadLocal对象,数据会不会互串?——不会,因为Map是线程自己的,key虽然相同,value各存各的。
2.3 ThreadLocal管不了共享数据的并发竞争
这是第二坑里最容易误导人的地方。有人觉得“用了ThreadLocal就是线程安全了”,其实完全不是。ThreadLocal解决的是“这条数据我不想让多线程共享”,它通过隔离来避开竞争,而不是通过同步来保证共享数据的正确性。
举最直白的例子,计数器:
private static final ThreadLocal<Integer> counter = new ThreadLocal<>();如果每个线程各加各的,总计数永远是错的。真正要共享并累加的counter,应该用AtomicLong或者synchronized。ThreadLocal在这里解决的是“线程隔离”,不是“并发正确”。
再比如一个被多个线程共享的配置对象,你用ThreadLocal去“缓存”它,结果每个线程各持一份副本,主体配置更新了,线程里还是旧的,反而制造了新的不一致。核心判断标准很简单:数据到底是共享的还是隔离的?共享,走并发控制;隔离,才考虑ThreadLocal。
2.4 父子线程传值:InheritableThreadLocal的边界
InheritableThreadLocal可以让子线程在创建时继承父线程的ThreadLocal值。初看很美好,遇到线程池就露馅。线程池里的线程不是每次任务来都新建的,而是复用的,创建线程时的“父线程”很可能不是提交任务的线程,继承到脏值的情况经常发生。
所以企业级项目里做全链路traceId透传,一般会引入TransmittableThreadLocal这类组件,它会在任务提交时把上下文快照传递下去,提交完再恢复现场。这是另一个大类,但至少你要意识到:只有ThreadLocal是不够的,跨线程传递需要专门方案,别在没想清楚的情况下用InheritableThreadLocal硬撑。
3. ConcurrentHashMap第三坑:null禁令,get()返回null不是你想象的那个意思
3.1 为什么ConcurrentHashMap不允许null key和null value
HashMap允许一个null key和多个null value,但ConcurrentHashMap直接禁止。下面两行代码,任意一行执行都会立刻抛NullPointerException:
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>(); map.put(null, "value"); // NullPointerException map.put("key", null); // NullPointerException源码里第一步就会检查:if (key == null || value == null) throw new NullPointerException();
Doug Lea在设计时对这个限制的解释,简单说就是避免二义性。对于普通HashMap,get(key)返回null,你还可以再调一次containsKey()确认到底是key不存在还是value就是null。但在ConcurrentHashMap这种并发容器里,两次判断之间数据可能已经被其他线程改掉了,两步判断组合起来不是一个原子操作,语义上就说不清了。与其让你在并发下写出有歧义的代码,不如直接禁止null value。
null key也一样。HashMap里null key有特殊路由,ConcurrentHashMap为了保持实现简单且语义清晰,干脆拒绝null key。
3.2 get()返回null,在并发场景下不能直接当“没有”
这是很多人实际踩的坑。业务代码常见写法:
String cacheValue = cacheMap.get(key); if (cacheValue == null) { // 走数据库加载,然后 put 进 cacheMap }在没有并发的情况下这个逻辑没问题。但在ConcurrentHashMap上,如果别的线程正在并发地put同一个key,或者已经put了但你的get恰好走在写入窗口之前,你得到的null并不代表“这个key一定不存在”,只是代表“在你读的这个时刻,你还没看见它”。
更危险的是,直接把get()的返回值往下用:
int len = map.get("config").length(); // 如果get返回null,这里直接NPE很多人线上遇到“ConcurrentHashMap同时读写报null”,排查下来基本就是这种情况:CHM自己没有报错,而是业务把get()的null值拿来直接解引用,抛了NullPointerException,于是锅甩给了并发容器。
CHM在并发下是安全的,但安全指的是它内部的每个方法都是原子且线程安全的,不代表你组合起来的业务判断也安全。这个边界一定要分清。
3.3 复合操作不是原子的:判空与写入必须合并
既然两步判断非原子,那“先判断再put”这种复合操作就必须换工具。最经典的对比:
// 错误写法:判空和写入之间隔了一个并发窗口 if (map.get(key) == null) { map.put(key, value); } // 正确写法:原子复合操作 map.putIfAbsent(key, value);putIfAbsent本身就是原子的,只有key不存在时才写入,返回旧值,旧值为null表示“之前确实不存在”。这一下就把两步合并成一步,排除了并发窗口。
需要基于当前值做计算的场景,可以用compute、merge、computeIfAbsent。这三个方法是CHM在Java 8之后提供的原子复合操作,专门用来替代“get完再计算再set”这种三段式。
3.4 推荐写法与computeIfAbsent的另一个雷
computeIfAbsent也不是没有坑。它会在key不存在时执行映射函数计算默认值。有人会在映射函数里重新读或者递归调用同一个key:
map.computeIfAbsent(key, k -> { String v = map.get(k); // 在当前操作还没完成时,这里可能拿到null return map.computeIfAbsent(key, k2 -> "value"); // 高危,千万别这么写 });在JDK8里这种递归操作可能导致栈溢出或死循环,相关JDK问题后来被修复为抛IllegalStateException,JDK9以后行为更明确,但无论哪个版本,这种自引用写法都是雷。结论就是:映射函数里只做纯计算,不要再碰同一个map、同一个key,更不要递归调用computeIfAbsent。
推荐的安全写法:
String result = map.computeIfAbsent(key, k -> loadFromDB(k));如果loadFromDB的实现里又去操作同一个map,就把“初始化”和“查询”彻底拆开,宁可先putIfAbsent一个占位默认值,再单独put真实值,也不要让映射函数产生自引用。
4. ConcurrentHashMap第四坑:弱一致性,并发读写下那些“报null”的现场
4.1 弱一致性到底是什么
JavaDoc里对ConcurrentHashMap迭代器的描述是弱一致:迭代器创建之后,它反映的是“创建时或创建后某个时刻的表状态”,不保证能看到之后新增的元素,也不会因为其他线程的修改抛出ConcurrentModificationException。
这跟ArrayList的fail-fast行为完全相反。ArrayList在迭代时,另一个线程往里add,迭代器立刻抛ConcurrentModificationException,提醒你数据结构被并发修改了。ConcurrentHashMap不抛,因为它本身就是为并发设计的,它的迭代器遍历的是某个时刻的链或数组快照,新写进来的数据可能看不见,但迭代过程不会崩。
弱一致性不是bug,是性能和一致性之间的取舍。代价是,你不能依赖它做“遍历时一定看到最新数据”的假设。
4.2 现场还原:get到null的窗口期
回到热搜里那个词:“concurrenthashmap 同时读写 报null”。我之前排查过类似问题,实际发生的路径是这样的:
- 线程A执行putVal,先创建了Node节点,但还没通过CAS把它放bin数组;
- 线程B在几乎同一时刻执行get,bin数组对应位置还是null,B取回来null;
- B的代码假设get()不可能为null,直接对返回值做操作,于是NPE。
整个过程中CHM的每个方法都是线程安全的,没有任何内部异常,问题出在B的“假设”:假设读到的时刻数据一定已经写入完成。这就是并发读写碰撞的本质。
还有一种更隐蔽的情况:两个线程同时执行“先get判断,再put”的代码,都读到null,都认为自己该写,结果后写覆盖先写,业务状态丢失。它不是报错,但比报错更让人头疼。
提示:如果业务严格需要“写入后立即可见,读取绝不返回旧值”,那么在读多写少的场景下要谨慎评估CHM的弱一致性是否符合要求,必要时引入更强的同步机制或变更通知。
4.3 size()的近似值与迭代器行为
ConcurrentHashMap的size()在并发写时也不是精确值。JDK1.8里它通过baseCount和CounterCell[]来分散计数,避免所有线程都去抢一个计数变量。并发高的时候,size()返回的可能是某个时刻的近似值,因为还有线程在并发修改,计数没有立刻反映到汇总结果。
这带来一个实用建议:不要拿size()==0来判断Map为空,也不要用size()来控制业务阈值。如果需要精确统计,必须额外设计独立计数器,别指望CHM给你精确实时数字。
迭代器方面,很多人刚开始不习惯它不抛ConcurrentModificationException,以为没并发问题。其实正因为它不抛,你更要自己判断遍历期间的新增数据是否需要被处理。比如定时任务扫描CHM里的待处理任务,如果遍历时漏掉刚插入的任务,可能会造成延迟处理。
4.4 从1.7的分段锁到1.8的CAS+synchronized
理解弱一致性最好顺便看一下底层演进。
JDK1.7里ConcurrentHashMap用分段锁:内部维护一个Segment数组,默认16个Segment,每个Segment继承ReentrantLock,相当于把一个Map拆成16个小的HashTable。写操作只锁当前Segment,理论上并发度最高16。size()的计算是先无锁累加整个segment的count,连续两次modCount一致就直接返回,否则把整个Map锁住再统计,实现相对粗暴。
JDK1.8完全换掉了Segment方案,改为Node数组 + CAS + synchronized。put时,如果目标桶为空,用CAS把节点放进去;如果桶非空,锁住桶的头节点再操作链表或红黑树。锁粒度从“段”缩小到“桶”,并发度大幅提升。数据超过阈值后链表转红黑树,解决hash冲突严重的退化问题。
下面是两者的简单对比:
| 维度 | JDK 1.7 | JDK 1.8+ |
|---|---|---|
| 存储结构 | Segment数组 + HashEntry数组 | Node数组 + 链表/红黑树 |
| 锁粒度 | Segment级 | 桶级(头节点) |
| 写并发度 | 默认最多16个Segment | 理论接近桶数量 |
| 查找方式 | 二次哈希定位Segment再定位Entry | 一次哈希定位桶 |
| size计算 | Segment计数累加,冲突则全表锁 | baseCount + CounterCell分散计数 |
4.5 强一致需求别用CHM
如果业务要求读写严格一致,比如统计金额、库存强校验,就不能拿CHM当黑盒直接用。CHM适合的场景是缓存、配置、会话这种“容忍极短时间窗口的弱一致,但要求高吞吐”的场合。
真要强一致,就看具体需求:单机可以用synchronized或显式锁配合普通HashMap,跨进程只能用数据库锁或分布式事务,不是换个并发容器就能解决的。很多人忽略这一点,把弱一致性容器当成万能药,这是第四坑的深层原因。
5. 排查链路与团队规范:把四大坑变成可复现的教训
5.1 ThreadLocal类问题的排查方法
遇到串数据,第一步先看用户上下文、traceId、租户信息这类跨请求状态是不是存在ThreadLocal里,第二步找清理点。
线上排查时,可以直接jstack看线程栈,虽然ThreadLocal本身不在栈上,但你能看到业务代码在哪调用get()。更有效的是heap dump后搜索java.lang.ThreadLocal$ThreadLocalMap,看里面挂了多少Entry、value是什么类型、数量是否异常。我之前在一个长运行的服务里dump后,发现某个接口的ThreadLocalMap里挤满了本该在请求结束时删除的value,一下就定位到了没remove的Filter实现。
代码层面,搜索项目里所有ThreadLocal的set调用点,逐个确认是否闭环。凡是在请求或任务粒度set的,必须在请求或任务结束点remove,在拦截器里统一处理最省心。
5.2 ConcurrentHashMap类问题的排查方法
如果是“get到null / 数据看起来被覆盖”这类问题,第一件事不是看CHM源码,而是把业务方法里的读写序列列出来,判断是否存在复合操作。
我会按这个顺序排查:
- 找出所有put、remove、compute、get的调用点;
- 看get之后是否立刻依赖返回值做判断或计算;
- 看是否存在“先判断后有”或“先get后set”的非原子组合;
- 压测时专门增加并发读写比例,复现窗口期;
- 在关键读写点打日志,记录线程名、key、读到的值,串出时间线。
很多时候问题不是CHM本身,而是代码在并发下把“两步操作”当成了“一步操作”。
5.3 代码评审时我会重点看的并发点
现在我在团队里做代码评审,遇到并发工具类的使用,会重点问几个问题:
- ThreadLocal的set和remove是否在同一生命周期内闭环?
- 是否用get()==null进行存在性判断?
- 是否存在“先判断再写”的非原子复合操作?
- 是否依赖size()做精确计数?
- 迭代期间是否有新增数据需要处理?
- 业务能容忍弱一致吗?
这些问题列成清单,写在团队规范里,比反复布道有效得多。
5.4 顺带一起踩过的坑:SimpleDateFormat与ArrayList
既然从ThreadLocal聊到并发工具,最后顺带提两个高频兄弟坑。
SimpleDateFormat不是线程安全的,多线程共用一个实例时会解析出错误日期甚至抛NumberFormatException。常规解法是每个线程一个实例,Java 8之后推荐DateTimeFormatter,它是线程安全的,直接共享没问题。
ArrayList在并发写时可能丢数据、下标越界或者抛ConcurrentModificationException。K-V缓存大家都记得用CHM,偏偏列表容器有人继续用ArrayList裸奔。读多写少的场景可以用CopyWriteArrayList,但写多读少时要评估复制开销,真正的解法是看业务是不是允许一个线程持有独立的List然后统一合入。
踩多了会发现,大部分并发事故的根子不是工具不够好,而是我们没搞清楚它的语义边界。这四个坑背后其实是同一个逻辑:ThreadLocal用隔离换来了简单,但你要记得还回去;ConcurrentHashMap用弱一致性换来了吞吐,但你要学会接受窗口期的旧值。没有银弹,只有把每个工具的设计意图摸清楚,写业务代码时才能少一点“线上偶发”的深夜告警。希望这篇能帮你少踩几个坑。