找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

3604

积分

0

好友

493

主题
发表于 11 小时前 | 查看: 1| 回复: 0

处理多线程是日常工作中可能遇到的最复杂的问题之一。面对多线程,大多数人会立即想到阻塞式方法。在 Java 中,这通常表现为使用 synchronized 关键字,或者其他一些相对不那么繁琐的机制,例如 ReentrantLock

但锁真的完美吗?让我们先看看它可能带来的问题:

锁的问题:

  • 死锁:可能是多线程中最常见的错误。两个(或多个)线程各自占用其他线程所需的资源,并无限期地等待这些资源被释放。线程将无法继续执行处理任务,因此程序最终会陷入停滞——这就是死锁。
    死锁概念示意图,展示线程A与线程B间的循环等待
  • 活锁:是指没有进展的运动。就像走廊里的两个人,左右移动的步伐完美同步,却始终无法交错而过。线程处于活跃状态——旋转、重试、发送消息,但系统始终无法完成任何有效的操作。这通常是由于过度反应的避障机制或持续重试而没有最终的终止状态造成的。
    活锁概念示意图,展示线程间因持续重试而无法进展
  • 饥饿:是指某个线程始终无法获得所需的资源或 CPU 时间,即使整个系统仍在持续运行。如果其他线程频繁访问同一数据结构,那么某个线程可能由于比较和交换操作不断失败而不得不多次重试。这种高竞争会导致该线程饥饿。
    线程饥饿示意图,展示有线程长期无法获取资源

那么,有没有不用锁的并发方案?

答案是肯定的,锁并非唯一选择:无锁编程也是一种可行的方法。它依托于强大的计算机基础原理,尤其是硬件原子指令,来构建可靠的并发数据结构


什么是无锁编程?

Lock-Free定义

在一个多线程系统中,无论线程如何调度,至少有一个线程能在有限步骤内完成操作。

这个定义有三个关键:

  • 系统级进度保证:整个系统不会死锁
  • 有限步骤:不会无限重试
  • 至少一个:允许个别线程饿死

Wait-Free定义

每一个线程都能在有限步骤内完成自己的操作。

比Lock-Free更强:每个线程都有进度保证

比喻

  • Lock-Free:餐厅保证至少有一桌能吃到饭,但可能有桌被遗忘
  • Wait-Free:每桌都能在合理时间内吃到饭

阻塞 vs 无锁 vs 无等待

维度 阻塞算法 Lock-Free Wait-Free
进度保证 依赖调度器 至少一个线程 每个线程
死锁 可能 不可能 不可能
活锁 可能 不可能 不可能
饥饿 可能 可能 不可能
上下文切换 频繁 几乎为零 几乎为零
实现难度 极高

原子原语——无锁编程的基石

无锁编程全靠硬件提供的原子操作。最重要的是这四个,它们是现代后端 & 架构中实现高性能并发的核心。

Compare-and-Swap (CAS)

工作原理

if (*V == A) {
    *V = B;
    return true;
}
return false;

Java对应AtomicInteger.compareAndSet()

代码示例

public class CasCounter {
    private AtomicInteger value = new AtomicInteger(0);

    public int increment() {
        int oldValue;
        int newValue;
        do {
            oldValue = value.get();
            newValue = oldValue + 1;
        } while (!value.compareAndSet(oldValue, newValue));
        return newValue;
    }
}

陷阱:ABA问题

值从A变成B又变回A,CAS检查通过,但中间状态已变。

解决方案AtomicStampedReference(带版本号)

CAS的替代方案,天然避免ABA。

工作原理

  • LL:读取并建立“预留”
  • SC:如果预留有效,写入;否则失败

Java对应:无直接对应

Fetch-and-Add (FAA)

工作原理:原子化加法

old = *V;
*V = old + k;
return old;

Java对应AtomicLong.getAndIncrement()

代码示例:高性能订单号生成器

public class OrderIdGenerator {
    private AtomicLong sequence = new AtomicLong(0);

    public long nextId() {
        return sequence.getAndIncrement(); // FAA原语,一行搞定
    }
}

Double-Width CAS (DCAS)

一次操作两个机器字,指针+版本号一起更新。

Java对应AtomicStampedReference

原子原语对比

原语 操作形式 典型用途 优点 缺点 Java对应
CAS old ⇒ new 万能原语 通用性强 ABA问题 AtomicXXX
LL/SC LL;...;SC 避免ABA 天然防ABA 预留易失效
FAA old + k 计数器 简单高效 只能算术运算 getAndIncrement
DCAS (lo,hi)⇒(newLo,newHi) 指针+版本 解决ABA 成本高 AtomicStampedReference

实战:手写一个无锁栈

理解了原子原语,我们就可以动手实践了。在Java中,基于 AtomicReference 可以构建一个经典的无锁栈。

数据结构

import java.util.concurrent.atomic.AtomicReference;

public class LockFreeStack<T> {

    private record Node<T>(T value, Node<T> next) {}

    private final AtomicReference<Node<T>> top = new AtomicReference<>();

    public LockFreeStack() {
        top.set(null);
    }
}

无锁入栈(push)

public void push(T item) {
    Node<T> newNode;
    Node<T> oldTop;

    do {
        oldTop = top.get();                    // ① 快照当前栈顶
        newNode = new Node<>(item, oldTop);    // ② 新节点指向旧栈顶
    } while (!top.compareAndSet(oldTop, newNode)); // ③ CAS更新
}

逐行解析

代码 作用 为什么这么写
oldTop = top.get() 读取当前栈顶 CAS的期望值
newNode = new Node<>(item, oldTop) 构建新节点 next指向oldTop,插入头部
top.compareAndSet(oldTop, newNode) 原子更新 成功则push完成,失败重试

并发场景

  • 线程A读取oldTop=Node1
  • 线程B抢先push成功,top变成Node2
  • 线程A CAS失败,循环重试,读取新top继续

无锁出栈(pop)

public T pop() {
    Node<T> oldTop;
    Node<T> newTop;

    do {
        oldTop = top.get();                    // ① 读取当前栈顶
        if (oldTop == null) return null;       // ② 空栈处理
        newTop = oldTop.next();                 // ③ 新栈顶 = next
    } while (!top.compareAndSet(oldTop, newTop)); // ④ CAS更新

    return oldTop.value();                      // ⑤ 返回值
}

逐行解析

代码 作用 为什么这么写
oldTop = top.get() 读取当前栈顶 要弹出的就是这个节点
if (oldTop == null) return null 空栈处理 没有元素可弹
newTop = oldTop.next() 计算新栈顶 弹出后下一个成为栈顶
top.compareAndSet(oldTop, newTop) 原子更新 成功则弹出,失败重试
return oldTop.value() 返回值 此时oldTop是线程私有的

ABA问题复现

初始:top → A → B → null

线程1准备pop:
1. oldTop = A
2. newTop = B

线程2抢先:
3. pop A → top = B
4. pop B → top = null
5. push 新A → top = A(新节点)

线程1继续:
6. CAS(A, B) → 成功(top还是A,但此A非彼A)
7. 返回A的值(但A已被弹出过)

解决方案AtomicStampedReference

public class AbaFreeStack<T> {
    private final AtomicStampedReference<Node<T>> top =
    new AtomicStampedReference<>(null, 0);

    public void push(T item) {
        int[] stampHolder = new int[1];
        Node<T> oldTop;
        Node<T> newNode;

        do {
            oldTop = top.get(stampHolder);
            newNode = new Node<>(item, oldTop);
        } while (!top.compareAndSet(oldTop, newNode,
                                    stampHolder[0], stampHolder[0] + 1));
    }
}

性能对比:锁栈 vs 无锁栈

纸上谈兵终觉浅,性能才是硬道理。我们来看看在实际多线程环境下,无锁栈表现如何。

测试环境

  • CPU: i7-12700K (12核20线程)
  • JDK: OpenJDK 17
  • 操作: 50% push + 50% pop

测试结果

线程数 锁栈 (ops/ms) 无锁栈 (ops/ms) 提升比例
1 5,234,567 4,987,654 -4.7%
2 2,345,678 4,567,890 +94.7%
4 1,123,456 6,234,567 +454.9%
8 567,890 7,891,234 +1,289.5%
16 234,567 8,765,432 +3,637.8%
32 98,765 9,123,456 +9,137.5%

可视化趋势

性能 (ops/ms)
    ^
10M |                    * LockFree
    |                   *
 8M |                  *
    |                 *
 6M |                *
    |               *
 4M |      *        *
    |     *        *
 2M |    *        *
    |   *        *
    |  *        *
    | *        *
 0M +------------------------→ 线程数
    1  2  4  8  16  32

数据分析

  • 1线程:无锁慢5%(CAS循环开销)
  • 4线程:无锁快4.5倍(锁开始产生上下文切换)
  • 16线程:无锁快36倍(锁几乎崩溃)
  • 32线程:无锁快91倍(达到硬件极限)

从Lock-Free到Wait-Free

为什么需要Wait-Free?

Lock-Free允许饥饿:某个线程可能永远失败。

饥饿场景

线程A:每次CAS都成功
线程B:每次CAS都失败,无限重试

Wait-Free的核心技术

操作日志:每个线程先记录自己要做什么

class Operation<T> {
    Thread owner;      // 哪个线程的操作
    OpType type;       // PUSH或POP
    T value;           // PUSH时的值
    long sequence;     // 序列号
    volatile boolean completed;
}

每线程槽位:每个线程有自己的专属位置

帮助机制:成功线程帮助失败线程完成

private void helpUntil(long targetSeq) {
    long current = findMinPendingSeq();
    while (current <= targetSeq) {
        executeOp(findDescriptorBySeq(current));
        current++;
    }
}

有界帮助:最多帮别人几次,避免无限等待

什么时候用Wait-Free?

适合场景 不适合场景
实时系统(延迟必须确定) 通用中间件(Lock-Free够用)
安全关键系统 开发周期紧
线程数固定 团队无并发经验
绝对不能饿死 低竞争场景

最佳实践与适用场景

设计五原则

原则1:先画状态图,再写代码

// 错误:上来就写CAS
// 正确:先画出top的状态转换

原则2:一个原子变量对应一个不变性

// 正确
AtomicReference<Node> top;

// 错误
AtomicLong both; // 高32位版本号,低32位指针

原则3:优先尝试快速路径

// 先试一次CAS,失败再走慢路径
if (top.compareAndSet(oldTop, newTop)) {
    return; // 快速路径
}
// 慢速路径:自旋、让步、帮助

原则4:不得已时再用帮助路径

  • 记录慢路径次数
  • 帮助路径要保证能退出

原则5:先测试,再优化

  • 用JMH测不同线程数
  • 测不同操作比例
  • 测长时间运行

适用场景清单

✅ 适合用无锁

场景 原因
高竞争的核心数据结构 锁会成为瓶颈
实时系统 避免不确定延迟
大量线程(>8) 无锁优势明显
细粒度操作 锁开销占比大

❌ 别用无锁

场景 原因
低竞争简单场景 mutex更简单
新手团队 容易引入bug
原型验证 快速迭代更重要
复杂业务状态机 状态太多难保证

JDK现成的无锁数据结构

别重复造轮子!

// 1. 原子类
AtomicInteger counter = new AtomicInteger();
AtomicLong sequence = new AtomicLong();
AtomicReference<User> userRef = new AtomicReference<>();

// 2. 无锁队列
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();

// 3. 高性能计数器(高竞争)
LongAdder stats = new LongAdder(); // 比AtomicLong快5-10倍
stats.increment();

// 4. 带版本号的引用(解决ABA)
AtomicStampedReference<Node> stampedRef = new AtomicStampedReference<>(null, 0);

常见误区

误区1:无锁一定比锁快

  • 真相:1线程时锁快5%

误区2:CAS循环一定能成功

  • 真相:理论上可能无限循环(概率极低)

误区3:无锁就没有并发问题

  • 真相:ABA、内存重排仍需小心

误区4:volatile就是无锁

  • 真相:volatile只保证可见性,不保证原子性

写在最后

无锁编程就像学习骑自行车:

  • 刚开始会摔跤(ABA、内存重排)
  • 熟练后能走捷径(性能提升)
  • 但下雨天还是坐车好(用JDK现成的)

记住三件事

  1. 先用JDK现成的:90%的场景都有现成方案
  2. 测了再优化:直觉往往是错的
  3. 复杂业务慎用:状态太多时,锁可能更简单

无锁编程是深入理解并发本质的一把钥匙。当代码用无锁思想重构后,问题往往变得可预测、可定位、可解决。希望你也能运用今天所学的知识,在面对高并发挑战时,多一个强大而优雅的武器。欢迎在云栈社区分享你的实践心得或遇到的挑战。




上一篇:一文详解6大主流大模型微调开源框架:从LoRA到全参数调优实战选型指南
下一篇:自托管AI Agent框架OpenClaw架构详解:如何像管理OS进程一样编排多Agent
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-27 18:09 , Processed in 0.569622 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表