type
Post
status
Published
date
Oct 12, 2025
slug
java6
summary
tags
技术探索
category
icon
password
前面几篇已经讲过线程、AQS、JMM、
volatile、synchronized 和 ReentrantLock。这篇继续往下看 Java 并发中非常常用的一组工具:
CASAtomicIntegerLongAdderConcurrentHashMapThreadLocal
它们分别解决不同问题:
CAS 解决无锁原子更新。原子类解决单变量并发更新。LongAdder解决高并发计数热点竞争。ConcurrentHashMap解决并发 Map 读写。ThreadLocal解决线程变量隔离。
这几个工具看起来分散,但底层都绕不开几个关键词:
volatileCAS分段思想局部隔离弱引用哈希冲突内存泄漏并发可见性
一、CAS 是什么?
CAS,全称 Compare And Swap,也就是比较并交换。
它的核心思想是:
更新变量之前,先判断当前值是不是我期望的旧值。如果是,说明这期间没有其他线程改过,就更新成新值。如果不是,说明已经被其他线程改过,本次更新失败,可以重试或放弃。
可以用伪代码表示:
CAS 的关键在于:比较和交换是一个原子操作。
也就是说,不会出现多个线程同时比较成功、同时写入的情况。
在 Java 中,很多原子类底层都依赖 CAS,比如:
AtomicIntegerAtomicLongAtomicReferenceAtomicStampedReferenceLongAdder的部分更新逻辑ConcurrentHashMap初始化数组和插入空桶节点时的 CAS
二、为什么需要 CAS?
先看一个普通计数问题。
如果用锁,可以这样写:
但是,如果只是更新一个变量,每次都加锁有点重。
CAS 提供了一种无锁更新方式。
它不会让线程进入阻塞,而是尝试原子更新,失败后再重试。
三、AtomicInteger 的基本用法
AtomicInteger 可以保证单个 int 变量的原子更新。AtomicInteger 适合这种场景:单变量计数。状态值原子更新。并发自增 ID。简单的 CAS 状态切换。
四、AtomicInteger 核心源码
看简化后的
AtomicInteger 核心结构。这段源码里最关键的是两个点:
value使用volatile修饰,保证可见性。更新操作使用 CAS,保证单次更新原子性。
五、getAndIncrement 的底层逻辑
getAndIncrement() 底层可以简化成下面这样:这个循环就是典型的 CAS 自旋。
可以理解为:
先读当前值。基于当前值计算新值。CAS 尝试更新。成功就返回。失败就重试。
这也是为什么
AtomicInteger 在低竞争下性能很好。它不需要让线程阻塞,也不需要操作系统挂起和唤醒线程。
六、CAS 的优点和问题
CAS 的优点很明显:
不需要加锁。不会让线程阻塞。低竞争下性能很好。很适合单变量原子更新。
但 CAS 也有问题。
主要有三个:
ABA 问题。高竞争下自旋成本高。只能天然保证单变量更新,多个变量一致性不好处理。
七、CAS 的 ABA 问题
CAS 只判断当前值是不是期望值。
如果一个值从 A 变成 B,又变回 A,CAS 会认为它没有变过。
这就是 ABA 问题。
看一个例子:
这个问题在普通计数场景里不一定严重,但在一些数据结构、账户状态、库存版本、栈节点复用场景中可能有风险。
解决思路是加版本号。
八、AtomicStampedReference 解决 ABA
AtomicStampedReference 会同时比较引用和值对应的版本号。这个例子说明:
普通 CAS 只看值。带版本号的 CAS 同时看值和版本。即使值变回去了,只要版本变化,也能识别出来。
简化理解:
ABA 问题的本质是“只比较值,不比较变化过程”。加版本号后,就能识别变化过程。
九、高竞争下 CAS 为什么可能变慢?
CAS 失败后通常会重试。
如果很多线程同时更新同一个变量,就会出现大量失败重试。
比如所有线程都在更新同一个
AtomicInteger:竞争越激烈,CAS 失败越多,CPU 消耗越明显。
所以高并发统计类计数可以考虑
LongAdder。十、LongAdder 的思想:分散热点
LongAdder 的核心思想是分散竞争。它不是让所有线程都更新同一个变量,而是把计数分散到多个槽位上。
最后求和时,把多个槽位加起来。
可以这样理解:
AtomicInteger是所有线程竞争一个点。LongAdder是多个线程分散到多个点。写入时降低竞争。读取总和时再汇总。
十一、LongAdder 简化源码理解
LongAdder 继承自 Striped64。它大致有两个核心字段:
Cell 简化结构如下:LongAdder.add() 的简化逻辑可以这样理解:sum() 的简化逻辑:LongAdder 的优点和限制:高并发写入性能好。适合统计类计数。sum()不是严格瞬时一致快照。如果需要强一致读值,不能盲目替代AtomicLong。
适合场景:
QPS 统计。请求次数统计。错误次数统计。埋点计数。监控指标累加。
十二、ConcurrentHashMap 解决了什么问题?
HashMap 不是线程安全的。多线程同时读写
HashMap,可能出现数据覆盖、结构异常、读到不一致状态等问题。早期可以用
Hashtable,但它基本是方法级别加 synchronized,锁粒度太大。ConcurrentHashMap 的目标是:支持并发读写。降低锁粒度。读操作尽量不加锁。写操作只锁局部桶。扩容时允许多个线程协助迁移。
十三、ConcurrentHashMap 的整体结构
JDK 8 之后,
ConcurrentHashMap 的核心结构可以理解为:数组。链表。红黑树。CAS。synchronized。volatile。
简化字段如下:
节点结构简化如下:
这里最重要的是:
val是volatile。next是volatile。读操作通常不加锁,也能看到较新的节点和值。写操作会结合 CAS 和局部synchronized保证安全。
十四、ConcurrentHashMap 为什么不允许 null?
ConcurrentHashMap 不允许 key 或 value 为 null。原因不是做不到,而是为了避免并发语义歧义。
比如:
如果返回
null,到底表示什么?这个 key 不存在。还是这个 key 存在,但 value 本身就是 null?
在普通
HashMap 中,可以再用 containsKey() 判断。但在并发场景中,两次操作之间 Map 可能已经被其他线程修改。
所以
ConcurrentHashMap 直接禁止 null,避免歧义。十五、ConcurrentHashMap 的 get 源码
get() 基本不加锁。简化源码如下:
get() 不加锁还能工作的原因:table是volatile。节点的val是volatile。节点的next是volatile。插入和扩容过程通过 CAS、同步块和特殊节点保证结构可见性。
这也是
ConcurrentHashMap 读性能好的原因。十六、ConcurrentHashMap 的 putVal 源码
put() 最终会进入 putVal()。简化后的核心逻辑如下:
这段源码可以拆成几种情况:
表还没初始化:先初始化。桶为空:CAS 插入。正在扩容:帮助迁移。桶不为空:锁住桶头节点,链表或红黑树内插入。插入后更新计数,必要时扩容。
十七、为什么桶为空时用 CAS,桶不为空时用 synchronized?
桶为空时,插入非常简单。
只需要把数组某个位置从
null 改成新节点。这非常适合 CAS。
桶不为空时,情况复杂很多:
可能要遍历链表。可能要更新已有 key 的 value。可能要在链表尾部插入。可能是红黑树节点。可能涉及树化。
这时只靠一个 CAS 很难保证整个结构修改安全。
所以 JDK 8 的做法是:
空桶插入用 CAS。非空桶修改用synchronized锁桶头节点。
这样锁粒度比较小,只影响同一个桶上的并发写。
十八、ConcurrentHashMap 初始化源码
initTable() 通过 sizeCtl 控制只有一个线程真正初始化数组。这段逻辑说明:
sizeCtl = -1表示正在初始化。CAS 成功的线程负责初始化。其他线程让出 CPU 或等待。初始化完成后,sizeCtl变成扩容阈值。
十九、ConcurrentHashMap 的扩容协作
ConcurrentHashMap 扩容不是只有一个线程干活。当一个线程发现正在扩容时,它可以帮忙迁移数据。
核心思想是:
多个线程一起把旧 table 的桶迁移到新 table。迁移完成的桶会放置ForwardingNode。其他线程访问到ForwardingNode时,知道这个桶已经迁移或正在迁移。读写线程可以转到新 table 或帮助迁移。
ForwardingNode 简化结构:这个设计的意义是:
扩容期间读写不需要完全停止。多个线程可以协作迁移。已迁移的桶通过ForwardingNode标记。查询遇到迁移节点时能找到新表。
二十、ConcurrentHashMap 的树化条件
当某个桶里的链表太长,会考虑树化。
关键阈值:
链表长度达到TREEIFY_THRESHOLD,通常是 8。table 长度至少达到MIN_TREEIFY_CAPACITY,通常是 64。如果 table 太小,优先扩容,而不是树化。
为什么 table 太小时优先扩容?
因为链表太长可能是数组太小导致 hash 分布拥挤。
扩容后,节点重新分布,链表可能自然变短。
简化逻辑:
二十一、ConcurrentHashMap 和 Hashtable 的区别
Hashtable 的特点是:方法级别synchronized。基本相当于锁住整张表。并发性能较差。不允许 key 或 value 为 null。
ConcurrentHashMap 的特点是:读操作大多数情况下不加锁。空桶插入用 CAS。非空桶写入锁桶头节点。扩容时支持多线程协作。JDK 8 后使用数组 + 链表 + 红黑树。不允许 key 或 value 为 null。
核心区别:
Hashtable锁粒度大。ConcurrentHashMap锁粒度小。ConcurrentHashMap更适合高并发读写场景。
二十二、ConcurrentHashMap 常见使用坑
1. 复合操作不是天然原子
下面代码不是线程安全的整体操作:
更好的方式:
2. computeIfAbsent 中不要做重操作
建议:
computeIfAbsent的函数尽量短小。不要在里面做不可控 RPC。不要在里面做慢 SQL。避免递归更新同一个 Map。复杂加载逻辑可以拆到外面并配合更明确的缓存控制。
二十三、ThreadLocal 解决什么问题?
ThreadLocal 不是用来解决共享变量竞争的。它解决的是:线程隔离。
每个线程都有自己的一份变量副本,线程之间互不影响。
常见场景:
用户上下文。TraceId。租户 ID。数据源路由。事务上下文。日期格式化对象。请求级临时变量。
示例:
两个线程使用的是同一个
ThreadLocal 对象,但取到的值不同。原因是值真正存在线程自己的
ThreadLocalMap 中。二十四、ThreadLocal 的核心结构
ThreadLocal 的关键不是 ThreadLocal 自己存值,而是 Thread 对象里有一个 ThreadLocalMap。简化结构如下:
ThreadLocal.set() 简化源码:ThreadLocal.get() 简化源码:这个设计说明:
ThreadLocal对象是 key。真正的 value 存在当前线程的ThreadLocalMap中。不同线程有不同的ThreadLocalMap。所以同一个ThreadLocal在不同线程里可以取到不同值。
二十五、ThreadLocalMap 的 Entry 为什么 key 是弱引用?
ThreadLocalMap.Entry 简化源码如下:为什么 key 要用弱引用?
如果 key 是强引用,那么只要线程不结束,
ThreadLocalMap 就一直强引用 ThreadLocal 对象。即使业务代码已经不再使用这个
ThreadLocal,它也无法被 GC。弱引用可以让不用的
ThreadLocal 对象被回收。但问题是:
key 被回收后,value 仍然是强引用。如果线程长期存活,value 可能一直留在 ThreadLocalMap 里。在线程池中,线程通常不会很快销毁。这就可能造成内存泄漏。
二十六、ThreadLocal 内存泄漏是怎么来的?
看这个场景:
泄漏链路可以这样理解:
线程池线程长期存活。线程持有ThreadLocalMap。ThreadLocalMap中的 Entry 持有 value。key 虽然是弱引用,可能已经变成 null。value 仍然是强引用,无法释放。
正确写法是用完必须
remove()。ThreadLocal 使用原则:set()后尽量在finally中remove()。尤其在线程池中必须清理。不要把大对象长期放在ThreadLocal中。不要把请求级上下文遗漏在线程池线程里。
二十七、ThreadLocalMap 如何处理哈希冲突?
ThreadLocalMap 不是普通 HashMap。它没有使用链表或红黑树处理冲突,而是使用开放寻址法。
简化理解:
根据 hash 找到数组下标。如果位置被占用,就往后找下一个位置。直到找到目标 key 或空槽位。这个过程叫线性探测。
简化代码:
线性探测简化逻辑:
这说明:
ThreadLocalMap用开放寻址解决冲突。冲突后会向后找位置。查找过程中遇到 key 为 null 的 Entry,会尝试清理。但不能完全依赖被动清理,业务代码仍然应该主动remove()。
二十八、ThreadLocal 的 hash 为什么特殊?
ThreadLocal 内部有一个 threadLocalHashCode。每创建一个
ThreadLocal,都会分配一个新的 hash 值。简化源码:
为什么要这样设计?
因为一个线程里可能有多个
ThreadLocal。如果 hash 分布不好,容易聚集在同一批槽位,线性探测成本会增加。
这个固定增量可以让
ThreadLocal 的 hash 在数组中分布更均匀。二十九、InheritableThreadLocal 是什么?
普通
ThreadLocal 只在当前线程生效。子线程默认拿不到父线程的值。
如果希望子线程创建时继承父线程变量,可以使用
InheritableThreadLocal。它的简化原理是:
子线程创建时大致会做:
这也是为什么
InheritableThreadLocal 在线程池中不可靠。线程池里的线程通常早就创建好了。
任务提交时,父线程的上下文不会自动复制进去。
三十、线程池中 ThreadLocal 的上下文串号问题
线程池会复用线程。
如果一次请求设置了
ThreadLocal,但没有清理,后面的请求可能复用同一个线程,读到上一次请求的数据。错误示例:
正确写法:
三十一、ThreadLocal 适合和不适合的场景
适合:
请求上下文。TraceId。用户 ID。租户 ID。数据源路由。线程内复用的轻量对象。框架内部上下文传递。
不适合:
跨线程传递上下文。长期保存大对象。替代方法参数传递所有数据。在线程池中使用后不清理。保存需要多个线程共享修改的数据。
尤其要记住:
ThreadLocal不是共享变量同步工具。它是线程隔离工具。
三十二、ThreadLocal 使用模板
推荐统一写成这种形式:
这种模板能避免大部分问题:
设置和清理在同一个边界。使用finally保证异常时也清理。上下文生命周期和请求生命周期一致。避免线程池复用导致脏数据。
三十三、这几个工具怎么选择?
如果是单变量并发更新:
用AtomicInteger、AtomicLong、AtomicReference。
如果是高并发统计计数:
用LongAdder。
如果是并发 Map:
用ConcurrentHashMap。复合更新用compute、computeIfAbsent、merge等原子化方法。计数场景可以结合LongAdder。
如果是线程隔离变量:
用ThreadLocal。在线程池中一定要remove()。
如果是多个变量一致性更新:
用锁。或封装成不可变对象后用AtomicReference整体替换。
三十四、总结
CAS 的核心是:
比较当前值是否等于期望值。如果相等,就原子更新。如果不相等,说明被其他线程改过,本次更新失败。失败后可以重试或放弃。
AtomicInteger 的核心是:volatile value保证可见性。CAS 保证单变量更新原子性。低竞争下性能很好。高竞争下可能大量自旋失败。
CAS 的问题包括:
ABA 问题。高竞争下 CPU 消耗高。不适合直接维护多个变量一致性。
LongAdder 的核心是:通过分散计数降低热点竞争。写多读少统计场景性能好。sum()不是严格瞬时一致快照。
ConcurrentHashMap 的核心是:读操作大多不加锁。空桶插入用 CAS。非空桶写入锁桶头节点。节点 value 和 next 使用 volatile。扩容时支持多线程协作。不允许 key 和 value 为 null。
ThreadLocal 的核心是:每个线程有自己的ThreadLocalMap。ThreadLocal对象作为 key。value 存在当前线程里。key 是弱引用,value 是强引用。在线程池中必须主动remove()。
最后可以这样记:
CAS 解决无锁原子更新。原子类解决单变量并发修改。LongAdder解决高并发计数热点。ConcurrentHashMap解决并发 Map 读写。ThreadLocal解决线程内变量隔离。
这些工具不是互相替代关系,而是分别解决不同层面的并发问题。
真正使用时,关键不是知道 API 名字,而是想清楚:
数据是不是共享的?是否需要原子更新?是否有多个变量一致性?是否只是统计计数?是否需要跨线程传递?线程是否会被线程池复用?用完之后是否需要清理?
这些问题想清楚,工具才不会用错。
分享
