跳转至

Synchronization and Locks⚓︎

5135 个字 188 行代码 预计阅读时间 28 分钟

线程间的交互:

问题:

  • x 在两个线程之间共享
  • 当其中一个线程正在改变它时,它处于不稳定状态
  • 但在改变过程中它会进入休眠状态,而另一个线程则利用 x 进行另一次改变
  • 应该能够表明 x 处于这样一种状态,即其他线程无法访问它,并要阻止它们访问

同步区段 (synchronized section)

  • 键在于对象,而不是代码
  • 每个对象中都有一个键
  • 要执行 synchronized() 块,线程需要获取对象中的键;一旦获得了键,对象就不再拥有该键
  • 如果当线程想要执行 synchronized() 时,键不在对象中,线程将被阻塞,直到该键返回到对象中
  • 当线程离开 synchronized() 块时,键将被返回给对象

为保护数据,

  • synchronized() 不是用来保护数据的,而是为了保证同一时间只有一个线程
  • 保护数据的技巧:
    • 私有数据
    • 对数据的所有访问都是同步的
    • 关键在于数据本身

Java 中,嵌套同步(nested synchronized) 是安全的:

synchronized(a) {
    synchronized(a) {
    }
}

synchronized(a) {
    f();
}

同步方法:

void f() {
    synchronized(this) {
        ...
    }
}

synchronized void f() {
}

在同步块中抛异常:

  • 如果在同步块中抛了任何异常,都会在异常抛出之前自动释放锁
    • 防止单线程异常导致系统死锁
    • 避免资源永久占用
    • 保证系统健壮性(即使代码有 bug 也不会锁死)
  • 显式锁不会自动释放,必须在 finally 中主动释放(后面会讲)

死锁:多个线程互相等对方手里的锁,谁也动不了

synchronized(o1) {
    synchronized(o2) {
    }
}

synchronized(o2) {
    synchronized(o1) {
    }
}

死锁同时满足 4 个条件:

  1. 互斥:同一资源同一时刻只能一个线程占用
  2. 占有且等待:拿着已获得的锁继续要别的锁
  3. 不可剥夺:锁只能被持有者主动释放
  4. 循环等待:形成“环形等锁”关系

打破任一条件,死锁就不成立。

定位死锁:

  • jps -l 找到线程的 PID
  • jstack -l <PID> 查看,或 jcmd <PID> Thread.print
  • 关注信息中的 Found one Java-level deadlock
  • 关注信息中的 waiting to lock/locked 链条

避免死锁:

  • 统一锁顺序
  • 缩小锁范围,锁内不做 IO、DB、RPC、log
  • 使用可超时的锁
  • 使用并发容器 / 原子类,少自己管锁
  • 持锁时避免外部回调
  • 多使用不可变对象

线程间的管道通信(piped communication):

  • PipedInputStream
  • PipedOutputStream

生产者(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):拿不到锁时,线程并不睡觉阻塞,而是在一个死循环里反复询问“现在能拿了吗”,直到成功,才跳出循环进入临界区。

最原始的自旋锁代码:

while (!tryLock()) {  // ① 原地“自旋”
    // 空转,CPU 满载
}
critical_section();   // ② 拿到后进入临界区
unlock();

自旋锁的评价:

  • 延迟低:线程不会上下文切换(~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 空字节
    分区 / 分槽 每个线程独占私有计数器 LongAdderCell[] 数组
    批量聚合 先写线程本地缓存,最后再合并 ThreadLocal<Batch>
    减少写入频率 读多写少时用乐观读 StampedLock.tryOptimisticRead()

CLH 算法

  • 三个人名的缩写:Craig, Landin, Hagersten
  • 是一种基于单向链表(隐式队列)的自旋锁算法,1993 年提出,专门解决多 CPU 缓存乒乓问题
  • 每个线程只在本地变量上自旋,等待前驱释放锁;链表顺序保证 FIFO,先排先得
  • 原始 CLH 的 伪代码(无锁版

    // 全局队尾指针,原子更新
    Atomic<Node> tail;
    
    // 线程本地变量
    Node myNode = new Node();              // 自己的节点
    Node pred = tail.getAndSet(myNode);    // 插到队尾,同时拿到前驱
    
    // 自旋等待前驱释放
    while (pred.locked) { /* 空转 */ }
    
    // 临界区 ...
    myNode.locked = false;                 // 释放锁,唤醒后继
    
  • 特点:

    • 每个线程只在自己的缓存行里自旋,不会访问别人的内存 -> 缓存一致性流量极低
    • 链表方向是隐式反向(通过本地变量 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 改密码

    1. 你输入旧密码 A(预期值)
    2. 机器发现卡内记录的当前密码也是 A -> 立即换成新密码 B
    3. 如果中途你女朋友已经改成 C,则第 2 步失败,你得重新查询再试——这就是 CAS 失败重试
  • 最朴素的伪代码(硬件指令级别

    bool CAS(addr, expect, update) {
        if (*addr == expect) {     // ① 比较
            *addr = update;        // ② 交换
            return true; // 成功
        }
        return false; // 失败
    }
    
    • CPU 保证 ① + ② 原子执行(x86 指令是 LOCK CMPXCHG
  • Java 层(sun.misc.Unsafe

    boolean compareAndSwapInt(Object o, long offset, int expect, int update);
    
    • 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
  • CAS = "expect 旧值?是就换成 new,否则重试 " 的硬件级原子指令

  • Java 用它做出无锁、不死锁、低延迟的并发工具,但得注意自旋开销和 ABA 陷阱

  • JDK 中的存在

    • 原子类AtomicIntegerAtomicLongLongAdder
    • 锁基础AQS 头节点获取锁、释放锁都用 CAS state
    • 并发容器ConcurrentHashMaptransferputVal 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/notifyAll await/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 对象内部都维护了一个用于存储等待线程的队列(等待队列)
    • cond1cond2 是两个不同的 Condition 对象
      • 一个线程执行 cond1.await() 会导致其被暂停(线程生命周期状态变更为 WAITING)并被存入 cond1 的等待队列
      • cond1.signal() 会使 cond1 的等待队列中的一个任意线程被唤醒
      • cond1.signalAll() 会使 cond1 的等待队列中的所有线程被唤醒,而 cond2 的等待队列中的任何一个等待线程不受此影响

  • API
    • java.util.concurrent.locks.Condition -> Condition Lock.newCondition()
    • await —> wait
    • signal —> notify
    • signalAll —> 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 使用步骤:

    1. 先有 LockLock lock = new ReentrantLock();
    2. 再建 ConditionCondition cond = lock.newCondition();
    3. 等待cond.await();(必须先持有 lock,否则抛 IllegalMonitorStateException
    4. 通知: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()
    jstack <pid> | grep -A 10 "Writer"
    
  • 如何缓解饥饿

    • 改用 StampedLockwriteLock(),它允许乐观读失败后批量更容易插队
    • 或者给读逻辑增加 sleep/ 阈值,降低读锁占用密度

StampedLockJDK 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 位图

    [ 高位:读计数 ] [ 第 8 位:写标志 ] [ 低 7 位:写重入+版本 ]
    
    • 写锁获取时 state |= WBIT1L << 7,版本号 +1;释放时再次 +1,保证后续乐观读能感知变化
    • 读锁获取时 state + 1 (高位计数,释放时 -1
    • 乐观读仅读取 state 快照,不做任何修改,因此完全无锁
  • 三种模式 API

    模式 获取 释放 特点
    写锁 long st = writeLock() unlockWrite(st) 独占,阻塞,不可重入
    悲观读 long st = readLock() unlockRead(st) 共享,阻塞,不可重入
    乐观读 long st = tryOptimisticRead() 无锁,需 validate(st) 校验
  • 锁升级:读 -> 写(可能死锁,慎用)

    long stamp = lock.readLock();
    try {
        while (x == 0) {
            long ws = lock.tryConvertToWriteLock(stamp);    // 尝试升级
            if (ws != 0L) {     // 升级成功
                stamp = ws;
                x = newX;
                break;
            } else { // 升级失败
                lock.unlockRead(stamp);
                stamp = lock.writeLock();   // 直接抢写锁
            }
        }
    } finally {
        lock.unlock(stamp);
    }
    
  • 锁降级:写 -> 读(常见,安全)

    long stamp = lock.writeLock();
    try {
        x = newX;
        stamp = lock.tryConvertToReadLock(stamp); // 降级
    } finally {
        lock.unlock(stamp);
    }
    
  • 使用注意点:

    注意 说明
    不可重入 同线程二次 writeLock() 会死锁
    不支持 Condition 无法像 ReentrantLock 那样 newCondition()
    中断需用 xxxInterruptibly() writeLock() 不响应中断,必须用 writeLockInterruptibly()
    乐观读必须 validate() 否则读到脏数据
    升级失败要自旋或重试 立即阻塞可能死锁
  • 性能差异(读多写少场景,JMH 基准)

    吞吐量(ops/us) StampedLock  ReentrantReadWriteLock
    10 读 1 写         ~850               ~420
    1 读 1 写          ~180               ~170
    
    • 读占比越高,StampedLock 优势越大;写占比高则差距缩小

原子类型:

  • CAS(compare and swap)
    • if-then-act
  • java.util.concurrent.atomic
    • read-modify-write

原子:

  • 基本类型:AtomicIntegerAtomicLongAtomicBoolean
  • 数组:AtomicIntegerArrayAtomicLongArrayAtomicReferenceArray

AtomicLong 的方法:

get()
getAndIncrement()
getAndDecrement()
incrementAndGet()
decrementAndGet()
set()

伪共享(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 的破局思路:

  • 分段计数

    transient volatile Cell[] cells;   // 每个 Cell 一个计数器
    

    线程先尝试base CAS,失败就哈希到某个 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

评论区

如果大家有什么问题或想法,欢迎在下方留言~