volatile 原理

可见性问题

代码示例

public class Main {
		
  	// 共享变量
    private static boolean temp = false;

    public static void main(String[] args) {

      	// 线程1如果发现 temp 是 true 就结束
        new Thread(() -> {
            while (!temp){

            }
            System.out.println(Thread.currentThread().getName() + "结束");
        }, "thread1").start();

      	// 1 秒后
        LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(1));

      	// 线程2把 temp 改为 true
        new Thread(() -> {
            temp = true;
            System.out.println(Thread.currentThread().getName() + "修改为true");
        }, "threa2").start();
    }

}

结果是线程1不会结束,运行结果如下:

threa2修改为true

解决方案也很简单,只需要给 temp 变量增加 volatile 关键字修饰

private static volatile boolean temp = false;

产生原因

线程内部的数据不能感知到主存数据已经变化了

volatile 如何保证可见性

要知道 JMM 和 MESI 才能明白

JMM 内存模型

别和 JVM 运行时数据区搞混了,JVM 运行时数据区:程序计数器、本地方法栈、虚拟机栈、方法区、堆

JMM 是 java 内存模型,是规定了线程访问内存的工作模式,有如下指令:

  • read(读取):从主存读取数据
  • load(载入):将读取的主存数据写入工作内存(线程内部,也就是CPU缓存中)
  • use(使用):从工作内存读取数据来计算(线程内部使用变量进行运算)
  • assign(赋值):将计算好的值重新写入工作内存中(还是在线程内部,运算结束先修改内部的值)
  • store(存储):将工作内存的数据写入主内存(值写入主内存了,但是还未赋值给主内存的变量)
  • write(写入):将 store 的值写入主内存中的变量(这时主内存中的值才真正被修改)
  • lock(锁定):将主内存变量加锁,表示为线程独占状态
  • unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量

MESI 缓存一致性协议

MESI 是一种缓存一致性协议,用于多核系统中保持各个核心的缓存数据一致。它将每个缓存行(Cache Line)标记为以下四种状态之一:

  • Modified (M):缓存行已被修改,与主存不同,且只有当前核心有最新数据
  • Exclusive (E):缓存行与主存一致,且仅当前核心持有
  • Shared (S):缓存行与主存一致,但可能被多个核心共享
  • Invalid (I):缓存行无效(不可用)

核心作用:通过状态转换和总线嗅探机制,确保多核读写时数据一致,避免脏读或冲突

volatile 原理

知道了 JMM 和 MESI 这时才能搞清楚 volatile 的原理

先说说不使用 volatile 的多线程如果访问共享数据,还是使用上面的示例来分析

  • thread1 和 thread2 都在访问共享变量 temp,所以两个线程内部都会有 temp 的副本,都是通过 JMM 读取的

    • 先 read,读取主存的数据,这时是 false
    • 然后 load,线程内部就有 temp 变量了(fasle)
    • 这时两个线程就可以内部使用了(这时都是使用的各自内部的副本)
  • thread 1 无限循环判断 temp 是否是 false,如果是 true 就会跳出循环

    • 会使用 use 指令,要使用变量嘛,进行判断也是使用
    • thread1 永不结束,因为在 thread1 中 temp 本来就是 false
  • 一秒后,thread2 把值改为 true

    • 先 use
    • 然后 assign,自己内部 temp 已经是 true 了
    • 然后 store,true 已经存储在主内存中了(但是还未赋值给主内存的 temp 变量)
    • 然后 write,true 赋值给主内存的 temp
    • 线程 2 结束了

thread1 感知不到 thread2 把值改了,所以 thread1 永不结束,这就是可见性造成的问题

使用 volatile 后,当 thread2 把值改了会利用 MESI 使 thread1 的缓存数据置为 Invalid 状态

  1. thread1 是无限循环,所以 thread1 会一直使用 use 指令,
  2. 发现自己内部的变量是无效状态,所以又会重新进行 read、load、use
  3. 重新读取时 thread1 就能感知 temp 已经变化了

再深入一点就是 thread2 怎么把 threa1 内部的缓存置为 Invalid 状态?

不同处理器实现方式不一样,这个硬件相关了
inter X86 的是会发送一个 lock 指令,具体怎么证明的话就要看对应的汇编代码了,是能看到 lock 指令的
汇编不能跨平台,要想知道 lock 干了什么可以去查对应的 cpu 的汇编官方文档,inter 和 amd 都有
总结一下 lock 对应的汇编代码作用是利用 bus 总线感知数据变化,如果发生变化会使缓存失效

学海无涯,差不多得了,了解到这里还要咋样?面试官你还要深入问吗?那就反口一句你说说为啥1+1=2?

有序性问题

代码示例

public class Main {

    static int x = 0, y = 0;
    static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {

        HashSet<String> res = new HashSet<>();

        for (int i = 0; i < 1000000; i++) {
            x = 0;
            y = 0;
            a = 0;
            b = 0;

            Thread thread1 = new Thread(() -> {
                a = y;
                x = 1;
            }, "thread1");

            Thread thread2 = new Thread(() -> {
                b = x;
                y = 1;
            }, "thread2");

            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();

            res.add("a=" + a + ", b=" + b); // 两个 join 的作用是:运行这行代码时两个线程都执行结束了
        }
      
      	System.out.println(res);
    }

}

thread1 和 thread2 会并行,两个线程运行结束后再添加结果到 set 中,然后输出 set,分析下代码:

  1. thread1 先运行结束,结果是:a=0,b=1,set 保存 a=0,b=1
  2. thread2 先运行结束,结果是:b=0,a=1,set 保存 a=1,b=0
  3. thread1 和 thread2 不是先后运行,并行运行,运行到中途另一个线程运行
    1. thread1 运行到 a=y 这时 a=0(thread1 不执行了),接着 thread2 运行到 b=y 这时 b=0
    2. 然后 thread1 和 thread2 继续执行,这时谁先谁后不重要了,因为a、b 已经去完值了
    3. set 保存 a=0,b=0

所以运行结果就是三种情况 [ a=0,b=1a=1,b=0a=0,b=0]

但是程序真正运行结果还有第四种情况 a=1、b=1,感觉这是不可能出现的,因为要 a =1 必须要 thread2 先运行结束,但是 thread2 如果先运行结束那么 b 就是 0。这就是有序性的问题

产生原因

计算机真正运行的指令顺序可能不是我们编写代码的顺序

graph LR; s1[JAVA源代码] --> s2[编译器优化重排序] s2 --> s3[指令集并行重排序] s3 --> s4[内存系统重排序]

上面的示例可能会重排序成这样:

Thread thread1 = new Thread(() -> {
		x = 1;
    a = y;
}, "thread1");

Thread thread2 = new Thread(() -> {
    y = 1;
    b = x;
}, "thread2");

天塌了?计算机要是把代码重排序不就太乱了吗,执行顺序不是代码顺序,还能写代码吗?

也没那么严重,计算机会遵循两个原则的前提下重排序:as-if-serialhappens-before

为什么会重排序?,计算机有自己的一套优化逻辑,计算机觉得我这样优化后效率更高
就好比 mysql 优化器,mysql 觉得自己写的 sql 和优化后的 sql 查询结果一致,但是优化后的 sql 查询效率更高

volatile 如何保证有序性

as-if-serial 原则

顺序一致原则,指不管怎么重排序,要保证单线程的结果是一致的

Thread thread1 = new Thread(() -> {
		x = 1;
    a = y;
}, "thread1");

上面的代码先执行 a = y; 再执行 x = 1; 在单线程情况下结果是不影响的,这里就有可能重排序

如果改成下面这样,明显有依赖关系了,重排序后肯定结果不一致,所以就不会重排序

Thread thread1 = new Thread(() -> {
		x = a;
    a = y;
}, "thread1");

为什么保证单线程结果一致而不是多线程呢?笛卡尔积,好几十个线程的情况下,等你处理好黄花菜都凉了

happens-before 原则

某些代码一定要发生于另一些代码之前,内容如下:

  • 程序顺序规则:一个线程内必须保证语义串行性
  • 锁规则:一个 unlock 操作先于后续对同一个锁的 lock 操作
  • volatile 变量规则:对一个 volatile 变量的写操作先发生于读操作,简单点理解就是:
    1)当 volatile 变量每次读都强迫从主内存中读
    2)当 volatile 变量每次写都强迫刷回主内存(其实是使其他线程的缓存失效)
  • 线程启动规则
  • 线程终止规则
  • 线程中断规则
  • 对象终结规则
  • 传递性规则
// 举例锁规则
ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lock();
// TODO 业务
reentrantLock.unlock();

reentrantLock.lock();
// TODO 业务
reentrantLock.unlock();


// 不能重排序为
ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lock();
reentrantLock.lock(); // 结果可能没问题,就锁重入嘛,但是不会这样重排序
// TODO 业务
reentrantLock.unlock();
// TODO 业务
reentrantLock.unlock();

DCL 双检查锁案例

// 使用volatile关键字修饰单例实例
// 1. 保证可见性:当一个线程完成instance的初始化后,其他线程能立即看到
// 2. 禁止指令重排序:防止new Singleton()的指令被重排序导致其他线程获取到未初始化的对象
private static volatile Singleton instance;

// 私有构造函数,防止外部通过new创建实例
// 这是单例模式的关键,确保只能通过getInstance()方法获取唯一实例
private Singleton() {}

// 获取单例实例的公共静态方法
public static Singleton getInstance() {
    // 第一次检查(无锁检查)
    // 这个检查是为了提高性能:如果instance已经初始化,就直接返回,避免进入同步块
    if (instance == null) {
        // 同步代码块,锁定Singleton类对象
        // 使用类对象作为锁,确保在多线程环境下只有一个线程能进入
        synchronized (Singleton.class) {
            // 第二次检查(有锁检查)
            // 这个检查是必要的,因为可能有多个线程同时通过了第一次检查
            // 在等待锁的过程中,其他线程可能已经完成了初始化
            if (instance == null) {
                // 创建单例实例
                // 这一行代码实际上包含三个步骤:
                // 1. 分配内存空间
                // 2. 初始化对象
                // 3. 将引用指向分配的内存地址
                // 如果没有volatile,2和3可能会被重排序,导致其他线程获取到未初始化的对象
                instance = new Singleton();
            }
        }
    }
    // 返回单例实例
    return instance;
}

不加 volatile 对于 instance = new Singleton(); 这行代码来说可能先分配对象赋值给 instance,然后再初始化

问题是为什么加了 volatile 就能保证赋值给 instance 的对象是已经初始化过的呢?内存屏障

内存屏障

先说说内存屏障是什么,下面的示例中加的 标识 就是内存屏障

Thread thread1 = new Thread(() -> {
    a = y;
    // 标识(和操作系统提前约定好,碰到这个标识时,这个标识的前后的代码不要重排序)
    x = 1;
}, "thread1");

java 规范定义的 4 种内存屏障如下:

屏障 指令示例 解释
LoadLoad Load1; LoadLoad; Load2; 保证 Load1 先完成然后再 Load2
StoreStore Store1; StoreStore; Store2; 保证 Store1 先完成然后再 Store2
LoadStore Load; LoadStore; Store; 保证 Load 先完成然后再 Store
StoreLoad Store; StoreLoad; Load; 保证 Store 先完成然后再 Load

示例

int x = 0;
int y = 0;

// 比如想要 a = x; b = y; 不重排序,因为都是读操作,所以可以在中间加入 LoadLoad 屏障来保证
int a = x; // load
LoadLoad
int b = y; // load

// 都是写操作,加 StoreStore 屏障
x = 1;
StoreStore
y = 1;

// 先读后写,加 LoadStore 屏障
int c = x;
LoadStore
y = 2;

// 先写后读,加 StoreLoad 屏障
x = 2;
LoadStore
int d = y;

volatile 原理

  1. 前面说了代码可能在遵循 as-if-serialhappens-before 原则的前提下重排序
  2. JDK 规范中明确了:对于 volatile 变量的读写操作还要添加不同的内存屏障

以上两点保证了 volatile 变量的有序性,再回到 DCL 的问题 “为什么加了 volatile 就能保证赋值给 instance 的对象是已经初始化过的?” 就应该能明白了

因为对于 volatile 的变量修改和读会加内存屏障,所以不会重排序,所以赋值给 instance 的对象是初始化过的

在底层一点就要去看 jvm 源码了(Oracle 的 JDK 源码不公开,要看的话只能看 open JDK 的源码),JVM 是 C++ 写的,再深一点找到 C++ 对应的汇编代码,能看到是加了 lock 指令,别再深究了,“阿祖,收手吧”

posted @ 2024-08-30 13:39  CyrusHuang  阅读(17)  评论(0)    收藏  举报