volatile 详解

本文共4230字,阅读本文大概需要8~14分钟

引言

有多个线程,共享一个变量。其中一个线程修改这个变量,另一个线程读取这个值,这个时候有没有什么问题?

在实际的系统运行过程中,可能会产生一个问题。若有一个变量 i 为 0,当 Thread1 修改变量的值,把 i 修改为 1,Thread0 在一段时间内,还是读到了 i = 0,读到的仍然是一个旧值

volatile 这个东西,看似很高深,其实很简单,它是并发编程里非常常见的一种东西,在大量开源项目项目中,你会发现会大量运用 volatile 来编程。

只要开了多个线程,一定会有这种问题,某个线程修改一个变量值,其他线程要立刻感知到这个值的变化,如果使用 volatile,那么会马上感知到这个值的变化

public class VolatileDemo{
    volatile boolean running = true;
    void m() {
        System.out.println("m start");
        while(running) {
        }
        System.out.println("m end!");
    }

    public static void main(String[] args) {
        VolatileDemo t = new VolatileDemo();

        new Thread(t::m, "t1").start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        t.running = false;
    }
}

CPU 缓存模型

现代的计算机技术,内存的读写速度没什么突破,CPU 如果要频繁的读写主内存的话,会导致性能较差,计算机性能就会降低,现代的计算机为了解决这一问题,换了一种玩法,给 CPU 加了几层缓存,CPU 可以直接操作自己对应的数据缓存,不需要直接频繁的跟主内存通信,这个是现代计算机技术的一个进步,这样可以保证 CPU 的计算的效率非常的高

总线加锁机制和 MESI 缓存一致性协议

CPU 在它和主内存之间加了一层缓存,主内存的数据会被加载到 CPU 本地缓存里去

CPU 缓存模型,其实默认情况下是有问题的,特别是多线程并发运行的时候,导致各个 CPU 的本地缓存跟主内存没有同步,一个数据在各个地方可能都不一样,就会导致数据的不一致

  • 总线加锁机制

大概意思是说,某个 CPU 如果要读一个数据,会通过一个总线,对这个数据加一个锁,其他 CPU 就没法去读和写这个数据了,只有当这个 CPU 修改完成以后,其他 CPU可以读到最新的数据,这个总线加锁机制效率太差了,一旦说多个线程出现对某个共享变量的访问之后,机会导致串行化的问题,现在已经弃用了

  • MESI 缓存一致性协议

MESI 协议,可以保证在 CPU 缓存模型下,不会出现多线程并发读写变量,没有办法感知到的一整套机制

java的内存模型

java 内存模型是跟 CPU 缓存模型类似的,基于 CPU 缓存模型来建立的 java内存模型,只不过 java 内存模型是标准化的,屏蔽掉底层不同的计算机的区别

线程的工作内存和主内存

  • read(从主存读取)
  • load(将主存读取到的值写入工作内存)
  • use(从工作内存读取数据来计算)
  • assign(将计算好的值重新赋值到工作内存中)
  • store(将工作内存数据写入主存)
  • write(将store 过去的变量赋值给主存中的变量)

并发编程三大特性

在并发编程过程中,可能会产生三类问题,可见性、原子性、有序性

可见性

一个线程对共享变量的修改,另一个线程不可感知到,这就是可见性问题。

  • volatile是如何保证可见性的

如上图,只要 flag 变成了 1,然后线程不是要将 flag = 1写会工作内存吗?assign操作。此时,若 flag 变量上加了 volatile 关键字的话,那么此时会强制保证 assign 之后就立马执行 store + write,刷回到主内存里去,保证只要工作内存一旦变为 flag = 1,主内存立马变成 flag = 1

此外,如果这个变量加了 volatile 关键字的话,此时他就会让其他线程工作内存中的这个 flag 变量的缓存,使其强制过期掉,其他线程再从工作内存中读取 flag 变量的值时,发现它已经过期了,此时就会重新从主内存里加载这个flag=1

通过 volatile 关键字,可以实现的一个效果是,有一个线程修改了值,其他线程可以立马感知到这个值

原子性

如下图所示,对于一个 i++ 操作,只要是多个线程并发运行来执行这行代码,其实都是不保证原子性的,如果保证原子性,第一个线程 i++,i=1,第二个线程i++,i=2

  • volatile 不保证原子性

volatile是不保证原子性的,如图,线程1变为 flag=1并写会到主内存了,线程2中工作内存,感知到被改变,会使 flag = 0过期掉,重新读到flag=1,此时线程2已经计算完flag=1,assign到主内存同样是1,最后写回到主内存

有序性

对于代码,还有一个问题是指令重排序,编译器和指令器,有的时候为了提高代码执行效率,会将指令重新排序,比如说下面的代码

flag = false;

// 线程1
prepare();     //准备资源
flag = true;

//线程2
while(!flag) {
    Thread.sleep(1000);
}
execute();    //基于准备好的资源执行操作

重新排序之后,让 flag = true 先执行了,会导致线程2 直接跳过 while 等待,执行某段代码,结果 prepare() 方法还没执行,资源还没准备好呢,此时就会导致代码逻辑出现异常

  • 基于 happens-before原则保证有序性

编译器、指令器可能会对代码重排序,但要遵守一定的规则,happens-before 原则。只要符合 happens-before 原则,那么就不能胡乱排序,如果不符合这些规则的话,那就可以自己排序

程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作

锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作

volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作,volatile变量写,再是读,必须保证是先写,再读

传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C

线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作

线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生

线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行

对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

上面这8条原则的意思很显而易见,就是程序中的代码如果满足这个条件,就一定会按照这个规则来保证指令的顺序。但是如果没满足上面的规则,那么就可能会出现指令重排,就这个意思。这8条原则是避免说出现乱七八糟扰乱秩序的指令重排,要求是这几个重要的场景下,比如是按照顺序来,但是8条规则之外,可以随意重排指令。

//线程1:
prepare();   // 准备资源
volatile flag = true; 

//线程2:
while(!flag){
  sleep()
}
execute(); // 基于准备好的资源执行操作

比如这个例子,如果用volatile来修饰flag变量,一定可以让prepare()指令在flag = true之前先执行,这就禁止了指令重排。因为volatile要求的是,volatile前面的代码一定不能指令重排到volatile变量操作后面,volatile后面的代码也不能指令重排到volatile前面。

volatile 底层原理

  • lock指令

对 volatile 修饰的变量,执行写操作的话,JVM 会发送一条 lock 前缀指令给 CPU,CPU在计算完之后会立即将这个值写会主内存,同时因为有 MESI 缓存一致性协议,所有各个 CPU 都会对总线进行嗅探,自己本地缓存中的数据是否被别人修改,如果发现修改了某个缓存的数据,那么CPU 就会将自己本地缓存的数据过期掉,然后这个CPU 上执行的线程在读取那个变量的时候,就会从住内存重现加载最新的数据

  • 内存屏障:禁止指令重排
load1:
int localVar = this,variable

load2:
int localVar = this.variable2

LoadLoad屏障:Load1;LoadLoad;Load2。确保 Load1 数据的装载先于 Load2 后所有装载指令。意思是,Load2对应的代码和Load2对应的代码,是不能指令重排的

Store1:
this.variable = 1

StoreStore屏障
Store2:
this.variable2 = 2

StoreStore屏障:Store1;StoreStore;Store2。确保Store1的数据一定刷回主存,对其他cpu可见,先于Store2以及后续指令

LoadStore屏障:Load1;LoadStore;Store2。确保Load1指令的数据装载,先于Store2以及后续指令

StoreLoad屏障:Store1;StoreLoad;Load2。确保Store1指令的数据一定刷回主存,对其他cpu可见,先于Load2以及后续指令的数据装载

对于 volatile 修饰变量的读写操作,都会加入内存屏障

每个 volatile 写操作前面,加 StoreStore屏障,禁止上面的普通写和它重排,每个 volatile 写操作后面,加StoreLoad屏障,禁止跟下面的 volatile 读/写重排

每个 volatile 读操作后面,加 LoadLoad 屏障,禁止下面的普通读和 volatile 读重排,每个 volatile 读操作后面,加LoadStore屏障,禁止下面的普通写和 volatile 读重排

posted @   小羊abc  阅读(175)  评论(1编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
点击右上角即可分享
微信分享提示