当前位置: 首页 > news >正文

线程安全的产生以及解决方案

线程安全

原子性(Atomicity)、可见性(Visibility)、有序性(Ordering) 是保证线程安全的三大核心要素 —— 线程安全问题的本质,几乎都是这三个特性中的一个或多个被破坏导致的。

  • 操作不会被 “中途打断”(原子性);
  • 操作结果能被其他线程 “及时看见”(可见性);
  • 操作顺序符合 “语义逻辑”(有序性)。

可见性:

可见性问题指:一个线程对共享变量的修改,其他线程可能无法立即看到,甚至永远看不到

为什么影响线程安全?
若可见性被破坏,线程会基于 “旧值” 做决策或修改,导致逻辑错误。

现代 CPU 为提升效率,引入了多级缓存(L1、L2、L3),线程的 “工作内存” 本质是 CPU 缓存的抽象。当线程修改变量时:

  • 步骤 1:修改 CPU 缓存中的副本(工作内存)。
  • 步骤 2:CPU 会在 “合适的时机”(而非立即)将缓存中的新值刷新到主内存(如缓存满了、发生缓存一致性协议触发时)。

这就导致:若线程 A 刚修改了变量但未刷新到主内存,线程 B 从主内存读取的仍是旧值,出现可见性问题。

可见性问题的产生

当线程 A 修改了共享变量 A 时,会先更新自己的工作内存,再异步刷新到主内存(这个过程有延迟)。若此时线程 B 读取变量 A,可能直接从自己的工作内存中获取未被更新的老值(因为线程 A 的修改还未同步到主内存,或线程 B 未从主内存重新加载),导致两个线程看到的变量值不一致。

// 共享变量
private static boolean flag = false;// 线程1:修改flag
new Thread(() -> {flag = true; // 修改工作内存中的flag,尚未同步到主内存System.out.println("线程1已修改flag为true");
}).start();// 线程2:读取flag
new Thread(() -> {while (!flag) { // 可能一直读取自己工作内存中的老值(false),陷入死循环// 等待flag变为true}System.out.println("线程2检测到flag为true");
}).start();

线程 2 可能永远看不到线程 1 对 flag 的修改,因为 flag 的更新未及时同步到主内存,或线程 2 未重新从主内存加载。

volatile解决:

volatile 保证可见性的核心逻辑是:强制线程对变量的读写操作直接与主内存交互,跳过工作内存(CPU 缓存)的缓存优化,具体通过以下两步实现:

  1. 写操作时:立即刷新到主内存,并使其他线程的缓存失效
    当线程修改一个 volatile 变量时,JVM 会触发两个动作:

    • 强制将工作内存中该变量的新值立即刷新到主内存(不等待 CPU 缓存的 “合适时机”)。
    • 通过 CPU 的缓存一致性协议(如 MESI 协议),通知其他线程中该变量的缓存副本失效(其他线程再读取时必须从主内存重新加载)。

    类比:volatile 变量的写操作相当于 “写完立即把笔记本内容抄回公共白板,并擦掉其他人笔记本上的旧内容”。

  2. 读操作时:必须从主内存重新加载
    当线程读取 volatile 变量时,JVM 会强制线程放弃工作内存中的缓存副本,直接从主内存加载最新值。

    类比:volatile 变量的读操作相当于 “每次看内容前,都先扔掉自己的笔记本,重新从公共白板抄最新内容”。

private static volatile boolean flag = false; // 用volatile修饰// 线程1修改后,会立即刷新到主内存,并使线程2的缓存失效
// 线程2读取时,会从主内存重新加载,感知到flag的最新值

synchronized解决

核心机制是加锁和解锁时的内存同步操作

  1. 加锁时(进入同步块)
    线程会清空自己的工作内存,并从主内存重新加载共享变量的最新值到工作内存。
    (类比:线程进入同步块前,先把自己的 “笔记本” 清空,重新从 “公共白板” 抄最新内容。)

  2. 解锁时(退出同步块)
    线程会将工作内存中修改后的共享变量值强制刷新到主内存
    (类比:线程退出同步块时,必须把 “笔记本” 的修改立即抄回 “公共白板”。)

  3. happens-before 原则
    对同一个锁,解锁操作 happens-before 后续的加锁操作。即:前一个线程解锁时刷新到主内存的变量值,后一个线程加锁时必然能从主内存读到这个最新值。

// 共享变量(无volatile)
private static boolean flag = false;public static void main(String[] args) {// 线程1:修改flagnew Thread(() -> {synchronized (Test.class) { // 加锁:从主内存加载flag(初始false)flag = true; // 修改工作内存中的flag} // 解锁:将flag=true刷新到主内存}).start();// 线程2:读取flagnew Thread(() -> {while (true) {synchronized (Test.class) { // 加锁:从主内存加载flag的最新值if (flag) {System.out.println("线程2读取到flag=true");break;}} // 解锁:无修改,不影响}}).start();
}
  • 线程 1 解锁时,flag=true 被强制刷新到主内存。
  • 线程 2 每次加锁时,都会从主内存重新加载 flag,因此必然能感知到 flag 的修改,最终退出循环。

有序性

有序性指程序执行顺序符合代码的 “语义逻辑顺序”,避免编译器 / CPU 的指令重排序破坏线程间的依赖关系。

为什么影响线程安全?
重排序可能打破线程间的 “操作先后依赖”,导致基于顺序的逻辑判断失效。

// 共享变量
private static int a = 0;
private static boolean flag = false;// 线程1:先初始化a,再标记flag
new Thread(() -> {a = 1;      // 操作1:初始化aflag = true; // 操作2:标记a已初始化
}).start();// 线程2:基于flag判断a是否可用
new Thread(() -> {if (flag) { // 若flag=true,认为a已初始化System.out.println(a); // 可能输出0(因重排序)}
}).start();

若线程 1 的操作 1 和操作 2 被重排序(先执行 flag=true,再执行 a=1),线程 2 会在 a 未初始化时读取,输出 0(不符合预期),破坏线程安全。

volatile解决有序性:

volatile 通过在变量的读写操作前后插入内存屏障(Memory Barrier) 来禁止特定类型的重排序,从而保证有序性。内存屏障是一种特殊的指令,它会阻止编译器和 CPU 对屏障两侧的指令进行重排序。

volatile 内存屏障的具体规则

操作类型内存屏障插入位置作用
写操作(v = x)写操作前插入 StoreStore 屏障禁止当前写操作与之前的其他写操作重排序(确保之前的写操作先于当前写操作执行)。
写操作后插入 StoreLoad 屏障禁止当前写操作与之后的读 / 写操作重排序(确保当前写操作完成后,再执行后续操作)。
读操作(x = v)读操作前插入 LoadLoad 屏障禁止当前读操作与之前的其他读操作重排序(确保之前的读操作先于当前读操作执行)。
读操作后插入 LoadStore 屏障禁止当前读操作与之后的写操作重排序(确保当前读操作完成后,再执行后续写操作)。

这些屏障的核心作用是 “隔离屏障两侧的指令”,确保 volatile 变量的读写操作不会与其他指令 “交叉执行”。

synchronized解决有序性: 

synchronized 通过 “happens-before 原则” 和 “同步块的边界约束” 保证有序性。其核心逻辑是:同步块内的操作会被视为一个 “不可分割的整体”,不会与同步块外的操作重排序,且后续线程进入同步块时,能看到之前同步块内的所有操作结果。

  1. 同步块内的操作不会被重排序到块外
    JMM 规定:编译器和 CPU 不得将同步块内的指令重排序到同步块外部(无论是进入块前还是退出块后)。例如:

    synchronized (lock) { // 加锁a = 1;    // 同步块内操作1flag = true; // 同步块内操作2
    } // 解锁

    编译器和 CPU 不能将 a=1 或 flag=true 重排序到 synchronized 块外部,确保同步块内的操作顺序严格按代码执行。

  2. happens-before 关系保证跨线程可见性与顺序性
    JMM 的 happens-before 原则规定:对同一个锁的解锁操作 happens-before 后续的加锁操作。即:

    • 线程 A 退出同步块(解锁)时,其在同步块内的所有操作(如 a=1flag=true)都会被刷新到主内存。
    • 线程 B 进入同步块(加锁)时,会从主内存加载所有变量的最新值,因此能看到线程 A 在同步块内的所有操作结果。

    这种关系确保了:线程 A 的操作 “先行发生于” 线程 B 的操作,两者的执行顺序在逻辑上是有序的。

原子性:

原子性指一个操作(或多个操作的组合)要么全部执行,要么全部不执行,不会被其他线程 “打断”,中间状态不会被暴露

典型案例:非原子操作的风险

以 count++ 为例,这是一个看似简单的操作,但在底层会被拆分为 3 个步骤:

  1. 读取:从主内存读取 count 的当前值到线程的工作内存。
  2. 修改:在工作内存中对 count 加 1。
  3. 写入:将修改后的值刷新回主内存。

当两个线程同时执行 count++ 时,可能出现以下交叉执行的情况:

  • 线程 A 读取 count=0 → 线程 B 读取 count=0(此时两者都在步骤 1)。
  • 线程 A 加 1 后 count=1 → 线程 B 加 1 后 count=1(步骤 2)。
  • 线程 A 写入主内存 count=1 → 线程 B 写入主内存 count=1(步骤 3)。

基于锁机制解决:

private int count = 0;// 用synchronized修饰方法,保证count++的原子性
public synchronized void increment() {count++; // 复合操作被synchronized保护,不会被其他线程中断
}

线程进入 synchronized 方法 / 块时必须获取锁,执行完成后释放锁。同一时间只有一个线程能持有锁,确保临界区内的操作不会被其他线程打断。

使用原子类:

CAS 机制Atomic 系列类):通过硬件指令实现无锁原子操作,适合简单场景,性能更优。

http://www.dtcms.com/a/340233.html

相关文章:

  • 记一次pnpm start启动异常
  • 学习设计模式《二十三》——桥接模式
  • 算法实战入门第二篇:链表结构与五大经典应用
  • 如何制作免费的比特币冷钱包
  • C++中的 Eigen库使用
  • 机器学习算法核心总结
  • AI全栈工程师:重塑软件开发全生命周期的未来革命
  • Nginx目录结构与配置文件全解析
  • 3-1〔OSCP ◈ 研记〕❘ WEB应用攻击▸理论概述 OWASP
  • 【LeetCode 热题 100】279. 完全平方数——(解法三)空间优化
  • Windows 中的“计数器”
  • ASP.NET 使用redis 存储session 负载机器共享会话状态
  • 【39页PPT】大模型DeepSeek在运维场景中的应用(附下载方式)
  • RabbitMQ:消息转化器
  • 高通 XR 系列芯介绍
  • 【论文阅读】SIMBA: single-cell embedding along with features(2)
  • ansible playbook 实战案例roles | 实现基于firewalld添加端口
  • ansible playbook 实战案例roles | 实现基于 IHS 的 AWStats 访问监控系统
  • Milvus 可观测性最佳实践
  • 分布式唯一 ID 生成方案
  • 基于 Uniapp 的医疗挂号系统开发经验分享
  • 苹果XR芯片介绍
  • 关于uniappx注意点1 - 鸿蒙app
  • XR(AR/VR/MR)芯片方案,Soc VS “MCU+协处理器”?
  • 橙武低代码 + AI:制造业场景中的设计思考
  • AI-调查研究-56-机器人 技术迭代:从液压驱动到AI协作的进化之路
  • AR 虚实叠加技术在工业设备运维中的实现流程方案
  • CSS 定位的核心属性:position
  • 北京-4年功能测试2年空窗-报培训班学测开-第七十六天-入职第一天
  • 面向AI应用的新一代迷你电脑架构解析 ——Qotom Q51251AI