原子类与ThreadLocal
开篇:不用锁也能线程安全?
想象你在银行柜台,100 个人同时往同一个账户存 1 块钱。如果每个人都要"查余额 -> 加 1 -> 写回去",中间不锁柜台,最终余额几乎不可能是 100。这就是经典的并发写问题。
加锁当然能解决,但锁意味着排队,排队意味着慢。有没有不排队的方案?JDK 给了我们两条路:
- 原子类:基于 CAS 硬件指令,在不加锁的前提下完成"读-改-写"三步的原子操作。
- ThreadLocal:干脆不共享,每个线程自己玩自己的副本,连冲突都不存在。
本文就来拆解这两大武器的原理、坑点和最佳实践。
一、原子类家族
原子类位于 java.util.concurrent.atomic 包,按操作对象可以分为四大类:
| 分类 | 代表类 | 适用场景 |
|---|---|---|
| 基本类型 | AtomicInteger / AtomicLong / AtomicBoolean | 对单个 int/long/boolean 做原子操作 |
| 数组类型 | AtomicIntegerArray / AtomicLongArray | 对数组中某个下标元素做原子操作 |
| 引用类型 | AtomicReference / AtomicStampedReference | 对一个对象引用做原子替换,后者可解决 ABA |
| 字段更新器 | AtomicIntegerFieldUpdater 等 | 对已有类的某个 volatile 字段做原子操作 |
1.1 基本类型原子类
以 AtomicInteger 为例,核心 API 非常直觉:
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // +1 并返回新值
counter.getAndAdd(5); // 先返回旧值,再 +5
counter.compareAndSet(6, 100); // 如果当前是 6,就改成 100底层实现依赖 Unsafe 类提供的 CAS 操作:
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}Unsafe.getAndAddInt 是一个 CAS 自旋循环:读取当前值,计算新值,用 CAS 尝试写入,失败就重来。整个过程不需要加锁。
100 个线程各加 5000 次,最终一定是 500000:
AtomicInteger counter = new AtomicInteger();
CountDownLatch latch = new CountDownLatch(100);
for (int i = 0; i < 100; i++) {
new Thread(() -> {
for (int j = 0; j < 5000; j++) counter.incrementAndGet();
latch.countDown();
}).start();
}
latch.await();
System.out.println(counter.get()); // 5000001.2 数组类型原子类
当你需要对数组中某个位置的元素做原子更新时,使用 AtomicIntegerArray:
AtomicIntegerArray arr = new AtomicIntegerArray(5); // 5个元素,初始都是0
arr.getAndSet(0, 100); // 把下标0设为100,返回旧值0
arr.getAndIncrement(1); // 下标1自增
arr.compareAndSet(2, 0, 42); // 下标2如果是0就改成42它在内部对每个元素的偏移量做了计算,保证每个下标的操作互不干扰。
1.3 引用类型原子类与 ABA 问题
AtomicReference 可以对任意对象引用做 CAS 替换:
AtomicReference<User> ref = new AtomicReference<>(userA);
ref.compareAndSet(userA, userB); // 如果当前是 userA,就换成 userB但 CAS 有个隐患:值从 A 改成 B 又改回 A,CAS 看到的还是 A,认为"没被改过"。这就是 ABA 问题。
打个比方:你桌上放了一杯水,你出去了。同事把水喝了,又倒了一杯新的放回去。你回来看到杯子里还是有水,以为没人动过。但其实已经不是同一杯水了。
解法是 AtomicStampedReference,每次修改都带一个版本号(stamp):
// 初始值100,初始版本号1
AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 1);
int stamp = ref.getStamp();
// CAS时同时校验值和版本号
ref.compareAndSet(100, 101, stamp, stamp + 1);线程 t3 做了 ABA 操作(100 -> 101 -> 100),但每次都递增了版本号。当线程 t4 拿着旧版本号来 CAS 时,版本号不匹配,操作失败。这就彻底解决了 ABA 问题。
如果只关心"有没有被改过"而不在乎改了几次,可以用 AtomicMarkableReference,它把版本号简化成了一个 boolean 标记。
1.4 字段更新器
有时候你不想把整个变量换成 AtomicInteger,只想对已有类的某个字段做原子操作。字段更新器就是干这个的,但有两个硬性要求:
- 字段必须用
public volatile修饰。 - 通过静态方法
newUpdater()创建更新器。
class BankAccount {
public volatile int money = 0;
static final AtomicIntegerFieldUpdater<BankAccount> UPDATER =
AtomicIntegerFieldUpdater.newUpdater(BankAccount.class, "money");
public void deposit() { UPDATER.incrementAndGet(this); }
}这样就能在不改变原有数据结构的前提下,获得原子性保证。
还有一个 AtomicReferenceFieldUpdater,可以对引用类型的字段做原子操作。典型场景是"多线程下只初始化一次":
class MyService {
public volatile Boolean initialized = Boolean.FALSE;
static final AtomicReferenceFieldUpdater<MyService, Boolean> UPDATER =
AtomicReferenceFieldUpdater.newUpdater(MyService.class, Boolean.class, "initialized");
public void init() {
if (UPDATER.compareAndSet(this, Boolean.FALSE, Boolean.TRUE)) {
// 只有第一个线程能进来
doInit();
}
}
}二、LongAdder vs AtomicLong
2.1 AtomicLong 的瓶颈
AtomicLong 的 incrementAndGet() 底层是一个 CAS 自旋:多个线程同时 CAS 同一个 value,只有一个能成功,其余全部重试。线程越多,重试越多,性能下降明显。
就像一个停车场只有一个入口,高峰期所有车排成一条长龙,只有前面的车停好了后面才能进。
2.2 LongAdder 的分段思想
LongAdder 的思路类似 ConcurrentHashMap 的分段锁:把一个 value 拆成多个槽(Cell),每个线程对自己命中的槽做 CAS,最后 sum() 时把所有槽的值加起来。
┌─── Cell[0] ← Thread-1 CAS
LongAdder ────┼─── Cell[1] ← Thread-2 CAS
├─── Cell[2] ← Thread-3 CAS
└─── base ← 无竞争时直接操作LongAdder 继承自 Striped64,核心属性只有两个:
transient volatile Cell[] cells; // 分段数组
transient volatile long base; // 基础值运行机制:
- 无竞争时:直接对 base 做 CAS,和 AtomicLong 一样快。
- 出现竞争时:初始化 Cell 数组,对线程 ID 取 hash,映射到某个 Cell,后续该线程只对自己的 Cell 做 CAS。
- 求和时:
sum()方法遍历 base + 所有 Cell 的值求和。
来看 add() 方法的核心逻辑:
public void add(long x) {
Cell[] as; long b, v; int m; Cell a;
if ((as = cells) != null || !casBase(b = base, b + x)) {
// cells 已经初始化,或者 base 的 CAS 失败(说明有竞争)
boolean uncontended = true;
if (as == null || (m = as.length - 1) < 0 ||
(a = as[getProbe() & m]) == null ||
!(uncontended = a.cas(v = a.value, v + x)))
longAccumulate(x, null, uncontended);
}
}简单说:先试 base,失败就走 Cell,Cell 也失败就进入 longAccumulate 做扩容和重试。
2.3 LongAccumulator
LongAdder 只能做加法,LongAccumulator 更灵活,可以自定义二元运算:
// 累乘器,初始值为2
LongAccumulator acc = new LongAccumulator((x, y) -> x * y, 2);
acc.accumulate(3); // 2 * 3 = 6
acc.accumulate(4); // 6 * 4 = 24底层原理和 LongAdder 完全一样,只是把加法换成了自定义的 LongBinaryOperator。
2.4 性能对比
50 个线程各执行 100 万次自增,典型结果如下:
| 方式 | 耗时 |
|---|---|
| synchronized | ~3500ms |
| AtomicLong | ~1200ms |
| LongAccumulator | ~150ms |
| LongAdder | ~140ms |
LongAdder 快了将近一个数量级。
2.5 如何选择?
| 场景 | 推荐 |
|---|---|
| 需要精确的实时值(如全局序列号) | AtomicLong |
| 统计型计数(如 QPS、点赞数、阅读量) | LongAdder |
| 需要自定义累积运算 | LongAccumulator |
LongAdder 的 sum() 在并发更新时可能不精确(因为求和期间其他线程还在往 Cell 里写),所以对精确度有严格要求的场景还是用 AtomicLong。
三、ThreadLocal:线程的私有空间
3.1 是什么?解决什么问题?
ThreadLocal 不是为了解决线程共享问题,而是为了隔离:每个线程持有自己的一份变量副本,互不干扰。
打个比方:ThreadLocal 不是"大家排队用一个工具",而是"每人发一把自己的工具"。这样根本不存在抢的问题。
典型场景:
- 用户身份上下文:在 Filter 中
set(user),后续任意 Service 中get()就能拿到,不用一路传参。 - SimpleDateFormat:它不是线程安全的,每个线程通过 ThreadLocal 持有自己的实例。
- 数据库 Session:Hibernate/MyBatis 用 ThreadLocal 保证每个线程一个 Session。
- traceId 透传:分布式链路追踪中保存本次请求的 traceId。
- PageHelper 分页:MyBatis 的分页插件把页码和页大小存在 ThreadLocal 中。
3.2 基本用法
// 初始化方式一:withInitial
ThreadLocal<SimpleDateFormat> sdf = ThreadLocal.withInitial(
() -> new SimpleDateFormat("yyyy-MM-dd")
);
// 使用
String formatted = sdf.get().format(new Date());
// 用完清理
sdf.remove();3.3 底层原理
每个 Thread 对象内部都有一个 ThreadLocalMap:
Thread
└── ThreadLocalMap
├── Entry(threadLocalA → valueA)
├── Entry(threadLocalB → valueB)
└── ...// Thread 类中的成员变量
ThreadLocal.ThreadLocalMap threadLocals = null;ThreadLocalMap 是 ThreadLocal 的静态内部类,内部维护了一个 Entry 数组。Entry 的 key 是 ThreadLocal 对象本身,value 是你 set() 进去的值。
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // key 是弱引用
value = v; // value 是强引用
}
}调用流程:
threadLocal.set(value)-- 拿到当前线程的 ThreadLocalMap,以 this(即 threadLocal 对象)为 key 存入 value。threadLocal.get()-- 拿到当前线程的 ThreadLocalMap,以 this 为 key 取出 value。- 每个线程只访问自己的 Map,天然线程安全,无需加锁。
ThreadLocalMap 使用线性探测法解决 hash 冲突(不是链表法),初始容量 16,扩容阈值为容量的 2/3。
3.4 内存泄漏问题
为什么 key 要用弱引用?
考虑这个场景:一个方法内创建了 ThreadLocal 并 set 了值,方法结束后栈上的 ThreadLocal 引用被回收。如果 ThreadLocalMap 中 key 是强引用,那 ThreadLocal 对象就永远无法被 GC,因为 Map 中还有一条引用链指向它。用弱引用后,下次 GC 时 key 就能被回收,变成 null。
但这只解决了一半问题。
key 被回收变成 null 后,value 依然被 Entry 强引用着。引用链是:
Thread Ref → Thread → ThreadLocalMap → Entry → value只要线程不死(线程池中线程复用),value 就永远不会被回收。这就是内存泄漏的根源。
ThreadLocal 在 get() / set() / remove() 时会通过 expungeStaleEntry、cleanSomeSlots、replaceStaleEntry 三个方法顺便清理 key 为 null 的 Entry,但这是"碰运气"的被动清理。
最佳实践:用完必须在 finally 中调用 remove()。
private static final ThreadLocal<User> USER_CONTEXT = new ThreadLocal<>();
public void doFilter(request, response, chain) {
try {
USER_CONTEXT.set(parseUser(request));
chain.doFilter(request, response);
} finally {
USER_CONTEXT.remove(); // 绝不能省
}
}3.5 InheritableThreadLocal 与 TransmittableThreadLocal
InheritableThreadLocal 能让子线程在创建时继承父线程的值。原理是在 Thread.init() 阶段检查父线程是否有 inheritableThreadLocals,如果有就复制一份给子线程。
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);但它有个致命限制:继承发生在线程创建时,如果子线程是线程池里复用的旧线程,就拿不到父线程的最新值了。
TransmittableThreadLocal(TTL) 是阿里开源的解决方案,彻底解决了线程池场景。它不依赖线程创建时的继承,而是通过装饰任务的方式:
- 任务提交到线程池前:捕获当前线程的上下文快照。
- 任务执行时:将快照还原到工作线程。
- 任务执行完:清除还原的上下文,恢复工作线程原有状态。
TransmittableThreadLocal<String> ctx = new TransmittableThreadLocal<>();
ctx.set("value-from-parent");
// 方式一:包装任务
Runnable ttlTask = TtlRunnable.get(() -> {
System.out.println(ctx.get()); // "value-from-parent"
});
executorService.submit(ttlTask);
// 方式二:包装线程池(更推荐)
ExecutorService ttlPool = TtlExecutors.getTtlExecutorService(originalPool);
ttlPool.submit(() -> {
System.out.println(ctx.get()); // "value-from-parent"
});TTL 常用于分布式链路追踪(traceId 透传)、全链路压测(流量标记)、日志上下文传递等场景。
四、常见面试题精选
Q1:LongAdder 和 AtomicLong 的区别?
AtomicLong 多线程 CAS 同一个 value,竞争激烈时大量自旋重试,性能下降。LongAdder 继承 Striped64,将 value 拆成 base + Cell 数组,各线程分散到不同 Cell 做 CAS,求和时再汇总。LongAdder 在高并发下性能远超 AtomicLong,但 sum() 在并发更新时可能不精确。统计型计数用 LongAdder,需要精确实时值(如序列号生成)用 AtomicLong。
Q2:ThreadLocal 为什么会导致内存泄漏?如何解决?
ThreadLocalMap 的 key 是弱引用,外部引用消失后 key 会被 GC 变成 null,但 value 仍被 Entry 强引用。在线程池场景下线程不会销毁,从 Thread 到 ThreadLocalMap 到 Entry 到 value 的引用链一直存在,value 就永远不会被回收。解决方案:使用完毕后在 finally 中调用 remove()。JDK 25 引入的 ScopedValue 从设计层面避免了这个问题,如果面试时主动提一句会加分。
Q3:ThreadLocal 的应用场景有哪些?
两大用途:
- 解决并发安全:如每线程独享 SimpleDateFormat 实例。
- 线程内传递数据避免层层传参:如用户身份上下文、traceId、PageHelper 分页参数、数据库 Session。
本质就是利用了"线程隔离"特性:前者让每个线程拥有独立变量副本避免竞争,后者让数据在同一个线程的执行链路中随取随用。
Q4:有了 InheritableThreadLocal 为什么还需要 TransmittableThreadLocal?
InheritableThreadLocal 的继承发生在子线程创建时(Thread.init()),线程池中的线程是预先创建好并复用的,后续提交的任务无法触发继承。TTL 通过装饰 Runnable/Callable 的方式,在任务提交前捕获上下文、执行时还原、执行完清除,彻底解决线程池场景下的上下文传递问题。
Q5:线程池中使用 ThreadLocal 有什么风险?
线程被复用不会销毁,ThreadLocal 的 value 会一直驻留在线程的 ThreadLocalMap 中。随着线程不断复用、不断往 Map 里塞新值而不清理,最终可能导致 OOM。另外还可能出现"脏数据"问题:上一个任务 set 的值,下一个任务如果不 set 就 get,会拿到上一个任务遗留的旧值。核心应对手段就是用完必须 remove()。
小结
| 工具 | 核心思想 | 典型场景 |
|---|---|---|
| AtomicInteger/Long | CAS 自旋 | 少量线程的原子计数 |
| AtomicStampedReference | CAS + 版本号 | 解决 ABA 问题 |
| LongAdder | 分段 CAS + 汇总 | 高并发统计计数 |
| ThreadLocal | 线程隔离 | 上下文传递、线程安全的成员变量 |
| TransmittableThreadLocal | 装饰任务 + 上下文快照 | 线程池场景下的上下文传递 |
记住三条铁律:
- 高并发计数选 LongAdder,不要死守 AtomicLong。
- ThreadLocal 用完必须 remove(),尤其在线程池中。
- 线程池跨线程传上下文用 TTL,不要指望 InheritableThreadLocal。
附录:原子类底层 CAS 详解
CAS 三要素
CAS(Compare And Swap)是一条 CPU 原语,包含三个操作数:
- V:内存地址中的当前值
- E:期望值(你认为它现在应该是什么)
- N:新值(你想把它改成什么)
执行时:如果 V == E,就把 V 改成 N,返回 true;否则什么都不做,返回 false。整个过程是原子的,不会被其他线程打断。
CAS 的两个问题
ABA 问题:前面已经讲过,用 AtomicStampedReference 的版本号解决。
自旋开销:CAS 失败后通常会在循环中重试(自旋),如果竞争激烈,大量线程长时间自旋会浪费 CPU。解决思路有两个:一是限制自旋次数(超过就挂起线程),二是分散竞争热点(LongAdder 的 Cell 分段方案)。
CAS 在 JDK 中的位置
Java 中 CAS 操作封装在 sun.misc.Unsafe 类中,关键方法包括:
compareAndSwapInt(Object, offset, expect, update)compareAndSwapLong(Object, offset, expect, update)compareAndSwapObject(Object, offset, expect, update)
Unsafe 不是给应用开发者用的,而是给 JDK 内部使用。AtomicInteger、AQS 等都是基于 Unsafe 实现的。JDK 9 之后推荐使用 VarHandle 替代 Unsafe。
volatile 与 CAS 的关系
CAS 保证的是"读-比较-写"的原子性,但它不保证可见性。所以原子类的 value 字段都用 volatile 修饰,保证一个线程 CAS 写入后,其他线程能立即看到最新值。两者配合才构成了无锁并发编程的完整方案。
关于 CAS 与 volatile 的配合,详见 锁与synchronized 中的可见性分析部分。