Synchronization and Locks⚓︎
约 5135 个字 188 行代码 预计阅读时间 28 分钟
线程间的交互:
问题:
- x 在两个线程之间共享
- 当其中一个线程正在改变它时,它处于不稳定状态
- 但在改变过程中它会进入休眠状态,而另一个线程则利用 x 进行另一次改变
- 应该能够表明 x 处于这样一种状态,即其他线程无法访问它,并要阻止它们访问
同步区段 (synchronized section):
- 键在于对象,而不是代码
- 每个对象中都有一个键
- 要执行
synchronized()块,线程需要获取对象中的键;一旦获得了键,对象就不再拥有该键 - 如果当线程想要执行
synchronized()时,键不在对象中,线程将被阻塞,直到该键返回到对象中 - 当线程离开
synchronized()块时,键将被返回给对象
为保护数据,
synchronized()不是用来保护数据的,而是为了保证同一时间只有一个线程- 保护数据的技巧:
- 私有数据
- 对数据的所有访问都是同步的
- 关键在于数据本身
在 Java 中,嵌套同步(nested synchronized) 是安全的:
同步方法:
在同步块中抛异常:
- 如果在同步块中抛了任何异常,都会在异常抛出之前自动释放锁
- 防止单线程异常导致系统死锁
- 避免资源永久占用
- 保证系统健壮性(即使代码有 bug 也不会锁死)
- 显式锁不会自动释放,必须在
finally中主动释放(后面会讲)
死锁:多个线程互相等对方手里的锁,谁也动不了
死锁同时满足 4 个条件:
- 互斥:同一资源同一时刻只能一个线程占用
- 占有且等待:拿着已获得的锁继续要别的锁
- 不可剥夺:锁只能被持有者主动释放
- 循环等待:形成“环形等锁”关系
打破任一条件,死锁就不成立。
定位死锁:
jps -l找到线程的 PIDjstack -l <PID>查看,或jcmd <PID> Thread.print- 关注信息中的
Found one Java-level deadlock - 关注信息中的
waiting to lock/locked链条
避免死锁:
- 统一锁顺序
- 缩小锁范围,锁内不做 IO、DB、RPC、log
- 使用可超时的锁
- 使用并发容器 / 原子类,少自己管锁
- 持锁时避免外部回调
- 多使用不可变对象
线程间的管道通信(piped communication):
PipedInputStreamPipedOutputStream
生产者(producer) 和消费者(consumer) 是一种模式,分别表示生成数据和读取数据的线程。必须有一个共享变量用于传输,以及一个标志来指示数据是有效的还是已经被读取。
wait() 和 notify()
-
各类方法:
Object.wait()将运行中的线程置于等待状态Object.notify()唤醒此对象上等待线程中的一个Object.notifyAll()唤醒此对象上所有等待线程Object.wait(long ms)将使线程至少保持该毫秒数
-
Object.wait()必须在临界区内使用 - 等待线程对保护条件的判断、
Object.wait()的调用总是应该放在相应对象所引导的临界区中的一个循环语句之中 - 等待线程对保护条件的判断、
Object.wait()的执行以及目标动作的执行必须放在同一个对象(内部锁)所引导的临界区之中 Object.wait()暂停当前线程时释放的锁只是与该wait方法所属对象的内部锁(synchronized) ;当前线程所持有的其他内部锁、显式锁并不会因此而被释放Thread.sleep()不会释放内部锁
JUC(java.util.concurrent)显式锁
synchronized是内部锁,由 JVM 执行java.util.concurrent.locks.Lock接口定义了显式锁
| 锁类型 | 可重入 | 公平锁 | 悲观 / 乐观 |
|---|---|---|---|
ReentrantLock |
✅ | 可选 | 悲观 |
ReentrantReadWriteLock |
✅ | 可选 | 悲观读 / 写 |
StampedLock |
❌ ( 写可重入 ) | ❌ | 乐观读 |
悲观 vs 乐观
- 悲观锁:默认“别人一定会跟我抢”,所以先加锁再操作,全程独占
- 乐观锁:默认“大概率不会冲突”,所以先不加锁,等写回时再检查有没有被改过;若真冲突就重试
-
类比:
场景 悲观锁 乐观锁 高铁洗手间 进去立刻反馈,全程“有人” 不反锁,出来前看一眼“是否有人闯入”,有就重新排队 Git 提交 加 --lock强制串行正常 push,发现远端被改就 merge/rebase 再推 -
悲观锁 = “先占坑,后办事”;乐观锁 = “先办事,后检查”
- 特点:
| 维度 | 悲观 | 乐观 |
|---|---|---|
| 冲突概率 | 高 | 低 |
| 延迟 | 高(阻塞) | 低(无锁) |
| CPU 消耗 | 低(直接睡) | 高(冲突重试) |
| 实现难度 | 简单 | 复杂(版本号、CAS、重试) |
ReentrantLock(可重入锁
- 由最后成功锁定它的线程拥有,但尚未解锁
- 当锁不被其他线程拥有时,调用锁的线程将返回,成功获取锁
- 如果当前线程已经拥有锁,该方法将立即返回
class Counter {
private final Lock lock = new ReentrantLock();
private int count;
void incr() {
lock.lock(); // 可中断、可限时、可公平
try { count++; }
finally { lock.unlock(); }
}
}
ReentrantLock类的构造函数接受一个可选的公平性(fairness) 参数- 当设置为
true时,在竞争情况下,锁会优先授予最长等待线程访问权限 - 否则,此锁不保证任何特定的访问顺序
- 当设置为
- 使用公平锁的程序在多个线程访问时可能显示出较低的整体吞吐量(即速度更慢;通常慢得多
) ,而使用默认设置则具有较小的获取锁时间方差,并保证不会出现饥饿现象 - 然而,锁的公平性并不保证线程调度的公平性,因此使用公平锁的多个线程中的一个可能会连续多次获得该锁,而其他活动线程却没有进展且当前未持有该锁
- 同时请注意,无限期尝试获取锁的方法 tryLock 不遵循公平性设置;如果该锁可用,即使其他线程正在等待,它也会成功
ExecutorService p = Executors.newFixedThreadPool(10);
Lock fair = new ReentrantLock(true);
for (int i = 0; i < 10; i++) {
int id = i;
p.submit(() -> {
fair.lock();
System.out.println("Thread-" + id);
fair.unlock();
});
}
p.shutdown();
- 观察控制台线程编号是否严格 0 -> 9(公平)或乱序(非公平)
ExecutorService pool = Executors.newFixedThreadPool(10);
// 10 个线程顺序提交,但非公平锁允许“插队”
for (int i = 0; i < 10; i++) {
final int id = i;
pool.submit(() -> {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 抢到锁,id=" + id);
Thread.sleep(10); // 模拟临界区
} catch (InterruptedException ignored) {
} finally {
lock.unlock();
}
});
}
| 特性 | 公平锁 | 非公平锁 |
|---|---|---|
| 获取顺序 | 严格 FIFO | 允许插队 |
| 实现原理 | 检查等待队列后再尝试获取锁 | 直接尝试获取锁 |
| 吞吐量 | 较低(低 10~15%) | 较高 |
| 线程饥饿 | 不会发生 | 可能发生 |
| 上下文切换 | 较多 | 较少 |
| 适用场景 | 计费、订单等关键业务 | 高并发 API、缓存服务 |
内部锁 vs 显式锁:
- 内部锁基于代码块,无法跨越函数
- 内部锁没有公平性唤醒
- 内部锁对多重锁的支持不好
- 显式锁有 tryLock 和带超时的锁
- 显式锁支持读 - 写分离的锁
自旋锁(spinlock):拿不到锁时,线程并不睡觉阻塞,而是在一个死循环里反复询问“现在能拿了吗
最原始的自旋锁代码:
自旋锁的评价:
- 延迟低:线程不会上下文切换(~1 µs -> 100 ns)
- CPU 占满:空转时仍占用 100% CPU,多核还行,单核灾难
- 适合场景:临界区极短(几百纳秒 ~ 几微秒)且多核
Java 的自旋锁:
synchronized轻量级锁:先在用户态自旋若干次(默认 10 圈, -XX:PreBlockSpin) ,再升级为重量级锁(内核阻塞)java.util.concurrent.atomic包:AtomicInteger.compareAndSet底层是Unsafe.compareAndSwapInt,失败即立即重试,就是典型的 CAS 自旋锁
其他算法:
| 实现 | 核心思想 | 解决自旋锁痛点 |
|---|---|---|
| TicketLock | 发号排队 | 保证 FIFO,避免饥饿 |
| CLH | 链表 + 本地变量自旋 | 降低缓存一致性流量 |
| MCS | 链表 + 显式后继 | 适用于 NUMA,跨 CPU 更友好 |
缓存乒乓(cache ping-pong):多核 CPU 在缓存一致性协议(如 MESI)下出现的一种性能退化现象
- 同一个缓存行被不同核心反复改写,导致该缓存行在核心间来回“弹跳”,大量时间消耗在失效、重载、再失效的同步上,而真正有用的计算占比骤降
-
发生条件(3 个同时满足)
- 多核并行
- 共享数据落在同一缓存行(如 64 B)
- 各核心频繁写该缓存行
-
微观时间线(MESI 协议视角)
时刻 Core A 状态 Core B 状态 事件 t0 E(独占) I(无效) A 写 x, 缓存行进 A 的 L1 t1 S(共享) S(共享) B 读 x, 两份副本 t2 I(无效) M(修改) B 写 x, A 的副本被无效化 t3 M(修改) I(无效) A 再写 x, B 的副本被无效化 ... ... ... 循环往复 - 结果:缓存行像乒乓球一样在 A <--> B 之间来回飞,每次飞行都要走一次片上互连总线,延迟几十到上百纳秒,远高于 L1 命中的 1~2 ns
-
性能损失量化:对同一原子变量高频累加,2 线程即可让吞吐下降 10 倍以上;4 线程时几乎与单核速度相同,额外核心“空转”。这就是典型的真共享 (true sharing) 导致的缓存乒乓
-
工程解决手段:
手段 原理 示例 字段填充 把热点变量拆到不同缓存行 @Contended long cnt;前后各加 56 B 空字节分区 / 分槽 每个线程独占私有计数器 LongAdder的Cell[]数组批量聚合 先写线程本地缓存,最后再合并 ThreadLocal<Batch>减少写入频率 读多写少时用乐观读 StampedLock.tryOptimisticRead()
CLH 算法:
- 三个人名的缩写:Craig, Landin, Hagersten
- 是一种基于单向链表(隐式队列)的自旋锁算法,1993 年提出,专门解决多 CPU 缓存乒乓问题
- 每个线程只在本地变量上自旋,等待前驱释放锁;链表顺序保证 FIFO,先排先得
-
原始 CLH 的 伪代码(无锁版
) : -
特点:
- 每个线程只在自己的缓存行里自旋,不会访问别人的内存 -> 缓存一致性流量极低
- 链表方向是隐式反向(通过本地变量 pred 指向前面节点) ,所以不需要真实 next 指针
CLH 与 AQS 的关系:
| 维度 | 原始 CLH | AQS“变体” |
|---|---|---|
| 方向 | 单向(隐式) | 双向(显式 Node.prev/next) |
| 节点删除 | 难,只能由 GC 拖走 | 易,可主动删除 CANCELLED 节点 |
| 支持模式 | 独占自旋锁 | 独占 + 共享(读写锁) |
| 语言层 | 硬件 / 汇编 | Java 对象 |
- AQS 保留了“只在前驱状态上自旋”的思想,但把隐式链表换成显式双向链表,从而支持中断、超时、公平、共享等高级语义。因此说 AQS 使用的是“CLH 变体”
- CLH = “盯前驱自旋”的 FIFO 队列锁算法
- AQS 把它的“隐式单向”升级成“显式双向”,于是有了 Java 里高效、可扩展的锁排队机制
AQS(抽象队列同步器 (abstract queued synchronizer)
synchronized在进入对象头时自旋 + 阻塞,队列藏在 JVM 底层,Java 层看不见-
AQS 要把“排队”暴露到 Java 对象,于是自己维护一个双向链表(
Node构成的队列)static final class Node { volatile Thread thread; // 当前排队的病人 volatile Node prev; // 前驱 volatile Node next; // 后继 volatile int waitstatus; // 0 初始,SIGNAL = -1 表示“请唤醒我” boolean isShared() { ... } }- 每个线程进来先包装成
Node,再插到队尾
- 每个线程进来先包装成
-
好处:
- 可中断、可超时、可公平 ⸺ 全由 Java 代码控制
- 支持共享锁(
ReadWriteLock)和独占锁(ReentrantLock)两种模式
-
AQS 把 CPU 级 CLH 隐式队列 变成 Java 双向链表,通过 CAS 插尾、unpark 后继,实现高效、可中断、可公平的锁排队
- CLH 思想 + 双向链表实现,既保留自旋优势,又解决节点删除和共享锁需求
CAS
- “Compare-And-Swap”(比较并交换)的缩写,它是整个 JUC 并发包的“原子地基”
- 当且仅当当前值等于预期值时,才把内存值由原子地更新为新值;整个判断 + 替换动作由 CPU 指令一次性完成,期间不会被其他线程打断
-
生活例子:ATM 改密码
- 你输入旧密码 A(预期值)
- 机器发现卡内记录的当前密码也是 A -> 立即换成新密码 B
- 如果中途你女朋友已经改成 C,则第 2 步失败,你得重新查询再试——这就是 CAS 失败重试
-
最朴素的伪代码(硬件指令级别
) :bool CAS(addr, expect, update) { if (*addr == expect) { // ① 比较 *addr = update; // ② 交换 return true; // 成功 } return false; // 失败 }- CPU 保证 ① + ② 原子执行(x86 指令是
LOCK CMPXCHG)
- CPU 保证 ① + ② 原子执行(x86 指令是
-
Java 层(
sun.misc.Unsafe)o:要修改的对象offset:字段在对象内的偏移地址expect:预期当前值update:希望设置的新值- 返回
true表示成功;false说明被别的线程抢先改了
-
使用范式:自旋 + CAS
private volatile int count = 0; // 共享 public void increment() { int old, newVal; do { old = count; // 读【预期值】 newVal = old + 1; // 计算新值 } while (!UNSAFE.compareAndSwapInt(this, COUNT_OFFSET, old, newVal)); }- 失败就重新读再试,俗称自旋锁或无锁编程
-
三大优缺点
- 优点:
- 无需内核调度,用户态完成(纳秒级)
- 不会出现死锁
- 天生公平
- 缺点:
- 自旋重试浪费 CPU(冲突高时)
- 只能保证一个变量原子性
- ABA 问题(值改回去又改回来)
- 线程 A 读到 1 -> 睡一会儿 ->
CAS(1, 2)成功,但中途 1 -> 3 -> 1,逻辑上可能出错 - 解决:加版本号 ->
AtomicStampedReference<V>值 + stamp 成对 CAS)
- 线程 A 读到 1 -> 睡一会儿 ->
- 优点:
-
CAS = "expect 旧值?是就换成 new,否则重试 " 的硬件级原子指令
-
Java 用它做出无锁、不死锁、低延迟的并发工具,但得注意自旋开销和 ABA 陷阱
-
在 JDK 中的存在
- 原子类:
AtomicInteger、AtomicLong、LongAdder - 锁基础:AQS 头节点获取锁、释放锁都用 CAS 改
state - 并发容器:
ConcurrentHashMap的transfer和putVal用 CAS 初始化桶
- 原子类:
-
入队:
注意:
- 先挂 prev,再 CAS 改 tail -> 保证队列永远不断裂
- 这是“变体”所在:经典 CLH 是隐式队列(自旋在前驱字段
) ,AQS 把它显式成双向链表,方便从后往前找有效前驱
-
出队 & 唤醒(
unparkSuccessor)- 头节点
head表示正在占有资源的线程 - 当前线程释放锁时,把
head.waitStatus设为 0,并 unpark 它的next.thread被唤醒的线程接着把自身设为新head,老head出队
- 头节点
小结图
+------+ +------+ +------+
head->| Node |<---->| Node |<---->| Node |<-tail
+------+ +------+ +------+
|thread| |thread| |thread|
| prev |<-----| prev |<-----| prev |
| next |----->| next |----->| next |
+------+ +------+ +------+
waitStatus: SIGNAL 0/-1/SIGNAL 0/-1
- 所有节点自旋在前驱的
waitStatus上(经典 CLH 思想) - 双向指针让取消节点(
CANCELLED=1)能被快速剔除,避免遍历死链
与纯 CLH 比较:
| 维度 | 纯 CLH | AQS 变体 |
|---|---|---|
| 方向 | 单向自旋 | 双向链表 |
| 节点删除 | 难 | 易 (GC 取消节点 ) |
| 支持模式 | 独占 | 独占 + 共享 |
| 语言层 | CPU 指令 | Java 对象 |
AQS 核心:CLH 变体双向队列
// 伪代码 10 行
Node pred = tail;
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node; // 加入等待队列
}
- 公平锁:入队顺序唤醒
- 非公平:先 CAS 抢一次
条件(Condition
- 就是 Java 版的精准等待 / 通知⸺用来替代
Object.wait()/notify(),但功能更强 - 可分组、可中断、可限时,还能多个等待集
- 让指定的一批线程睡,醒来时只叫这一批,不会误唤醒别人
-
类比:医院叫号
synchronized+wait():大喇叭喊“下一位”,所有病人都以为轮到自己,挤到门口发现不是,继续回去坐Lock+Condition:分诊台有 3 个小屏幕(3 个条件) ,只喊对应科室的病人,不会惊动别人
-
与
wait/notify的对比:能力 对象监控器 条件 等待 / 通知 wait/notify/notifyAllawait/signal/signalAll锁对象 synchronized(this)Lock.lock()等待队列 仅 1 个 可创建多个 Condition中断响应 不支持 await()可抛InterruptedException限时等待 wait(timeout)awaitNanos/time系列公平通知 无 signal()只唤醒一个等此Condition的线程 -
Condition对象:- 调用任意一个显式锁实例的
newCondition()方法可以创建一个相应的Condition接口;Condition.await() / signal()也要求其执行线程持有创建该Condition对象的显式锁 Condition对象也被称为条件变量(condition variable) 或者条件队列(condition queue),每个Condition对象内部都维护了一个用于存储等待线程的队列(等待队列)- 设
cond1和cond2是两个不同的Condition对象- 一个线程执行
cond1.await()会导致其被暂停(线程生命周期状态变更为 WAITING)并被存入cond1的等待队列 cond1.signal()会使cond1的等待队列中的一个任意线程被唤醒cond1.signalAll()会使cond1的等待队列中的所有线程被唤醒,而cond2的等待队列中的任何一个等待线程不受此影响
- 一个线程执行
- 调用任意一个显式锁实例的
- API
java.util.concurrent.locks.Condition->Condition Lock.newCondition()await—>waitsignal—>notifysignalAll—>notifyAll
例子(生产者 - 单消费者)
class BoundedBuffer<T> {
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // 生产者等待区
private final Condition notEmpty = lock.newCondition(); // 消费者等待区
...
{
lock.lock();
try {
while (count == items.length) // 满
notFull.await(); // 生产者去 notFull 区睡觉
//...
notEmpty.signal(); // 只叫一个消费者
} finally { lock.unlock(); }
}
}
- 生产者满时只睡
notFull,消费者空时只睡notEmpty signal()精准唤醒对应科室的一个人,不会惊动另一批
-
Condition使用步骤:- 先有
Lock:Lock lock = new ReentrantLock(); - 再建
Condition:Condition cond = lock.newCondition(); - 等待
: cond.await();(必须先持有lock,否则抛IllegalMonitorStateException) - 通知:
cond.signal();或signalAll();
- 先有
-
陷阱:
- 不写
while判断等着被唤醒 -> 虚假唤醒导致逻辑错误 - 忘记
lock.unlock()-> 线程永久卡死 - 用
signal()而逻辑需要“全唤醒” -> 改用signalAll()
- 不写
ReadWriteLock(读写锁
-
ReadWriteLock维护一对关联的锁,一个用于只读操作,另一个用于写入- 只要没有写入者,读锁可以被多个读取线程同时持有
- 写锁是独占的
-
Lock readLock():返回读取锁 Lock writeLock():返回写入锁-
读 vs 写:
锁 获得条件 排他性 作用 readLock.lock()没有被写锁 允许其他线程读,不允许其他线程写 多个线程可以同时读取共享,同时禁止有人读的时候写入 writeLock.lock()没有被写锁 && 没有被读锁 禁止一切其他访问 线程独占方式写
ReentrantReadWriteLock:
- 获取顺序:此类不对锁访问施加读者或写者的优先级排序,但它支持可选的公平性策略(大致上是到达顺序策略)
- 尝试获取公平读锁(非重入)的线程将在以下情况下阻塞:如果写锁被持有,或者有一个等待的写线程
- 锁降级(lock downgrading):重入性还允许从写锁降级到读锁,通过先获取写锁,然后获取读锁,再释放写锁;然而从读锁升级到写锁是不可能的
-
饥饿演示:
new Thread(() -> { while (true) { readLock.lock(); try { // 极短临界区 + 立即再次申请,保证读锁几乎不断 System.out.println(Thread.currentThread().getName() + " 获得读锁"); Thread.sleep(1); // 1 ms 睡眠,让现象更明显 } catch (InterruptedException ignored) { } finally { readLock.unlock(); } } }, "Reader-" + id).start();- 10 个读线程不断
readLock(), 1 个写线程尝试writeLock()
- 10 个读线程不断
-
如何缓解饥饿
- 改用
StampedLock的writeLock(),它允许乐观读失败后批量更容易插队 - 或者给读逻辑增加 sleep/ 阈值,降低读锁占用密度
- 改用
StampedLock:JDK 8 引入的“三模锁”,被官方称为“锁王”。
-
它通过 64 位版本戳(stamp)管理状态,提供:
- 写锁(独占)
- 悲观读锁(共享)
- 乐观读锁(无锁)
-
乐观读的例子:
class Point { private final StampedLock sl = new StampedLock(); private double x, y; double distanceFromOrigin() { long stamp = sl.tryOptimisticRead(); // ① 乐观戳 double currentX = x, currentY = y; if (!sl.validate(stamp)) { // ② 验证失败 stamp = sl.readLock(); // ③ 升级悲观 try { currentX = x; currentY = y; } finally { sl.unlockRead(stamp); } } return Math.sqrt(currentX*currentX + currentY*currentY); } }- 乐观读成功 -> 0 锁开销
- 失败 -> 自动升级,保证数据一致性
-
内部状态:64 位 stamp 位图
- 写锁获取时
state |= WBIT(1L << 7) ,版本号+1;释放时再次+1,保证后续乐观读能感知变化 - 读锁获取时
state + 1(高位计数) ,释放时-1 - 乐观读仅读取
state快照,不做任何修改,因此完全无锁
- 写锁获取时
-
三种模式 API:
模式 获取 释放 特点 写锁 long st = writeLock()unlockWrite(st)独占,阻塞,不可重入 悲观读 long st = readLock()unlockRead(st)共享,阻塞,不可重入 乐观读 long st = tryOptimisticRead()无 无锁,需 validate(st)校验 -
锁升级:读 -> 写(可能死锁,慎用)
-
锁降级:写 -> 读(常见,安全)
-
使用注意点:
注意 说明 不可重入 同线程二次 writeLock()会死锁不支持 Condition无法像 ReentrantLock那样newCondition()中断需用 xxxInterruptibly()writeLock()不响应中断,必须用writeLockInterruptibly()乐观读必须 validate()否则读到脏数据 升级失败要自旋或重试 立即阻塞可能死锁 -
性能差异(读多写少场景,JMH 基准)
- 读占比越高,
StampedLock优势越大;写占比高则差距缩小
- 读占比越高,
原子类型:
- CAS(compare and swap)
- if-then-act
java.util.concurrent.atomic- read-modify-write
原子:
- 基本类型:
AtomicInteger,AtomicLong,AtomicBoolean - 数组:
AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray
AtomicLong 的方法:
伪共享(false sharing):
- CPU 缓存以 64 字节行(cache line)为单位读写
- 两个无关变量落在同一行,被不同核心同时写 -> 缓存行在核心间来回失效 / 重载,性能暴跌
- 现象:多线程反而比单线程慢
AtomicLong 的“悲剧”源码:
public class AtomicLong implements Serializable {
private volatile long value; // 8 字节
// ... 其他字段 ...
}
value是只有 8 字节,但整个对象通常 16~24 字节(对象头 + 对齐)- 如果两个
AtomicLong相邻分配,就落在同一 64 B 行 - 多核心同时
incrementAndGet()-> 行状态在 Modified <--> Invalid 来回跳 -> 缓存乒乓
Demo:4 线程累加 1 亿次
AtomicLong[] ac = new AtomicLong[4];
for (int i = 0; i < 4; i++) ac[i] = new AtomicLong();
// 每个线程只写自己的 ac[i]
for (int t = 0; t < 4; t++) {
final int idx = t;
new Thread(() -> {
for (int i = 0; i < 100_000_000; i++)
ac[idx].incrementAndGet();
}).start();
}
| 线程数 | 耗时 | 说明 |
|---|---|---|
| 1 | 0.28 s | 单线程无冲突 |
| 4 | 1.92 s | 多线程反而慢 7 倍 -> 伪共享实锤 |
LongAdder 的破局思路:
-
分段计数
线程先尝试
baseCAS,失败就哈希到某个Cell单独累加 -
避免行冲突 : 编译器在
Cell.value前后各填充 56 字节,保证一个Cell独占一行- 即使 64 线程同时写,也各刷各的缓存行,不再乒乓
-
求和时
sum = base + ∑cells[i].value(读操作无锁,最终一致性)
性能对比(同平台同代码
| 实现 | 4 线程 1 亿次累加 | 吞吐量提升 |
|---|---|---|
AtomicLong |
1.92 s | 1×(基准) |
LongAdder |
0.21 s | 9.1× |
手动填充 AtomicLong |
0.35 s | 5.5×(填充即可见效) |
并发容器选型速查:
- 共享计数:
LongAdder - 缓存:
CurrentHashMap - 消息队列:
LinkedBlockingQueue / ArrayBlockingQueue - 快找迭代:
CopyOnWriteArrayList
评论区

