多线程 volatile

多线程关键字volatile

从硬件层面理解 volatile 的原理

volatile是解决多线程使用同一数据时无法保证一致性的问题。比如下面的代码

public class ClassSync implements Runnable{
  @Override
  public void run() {
    for (int j = 0; j < 10; j++) {
      System.out.println(Thread.currentThread().getName()+" i:"+i++);
    }
  }
}

// 执行
public class Main {
    public static void main(String[] args) throws InterruptedException, IOException {
        ClassSync sync = new ClassSync();
        new Thread(sync,"thread_a").start();
        new Thread(sync,"thread_b").start();
    }
}

观察输出,可知输出的数据是错误的

thread_b i:0 # b输出 0
thread_b i:1
thread_b i:2
thread_b i:3
thread_b i:4
thread_a i:0 # a输出0
thread_a i:5
thread_a i:6
thread_a i:7
thread_b i:8
thread_a i:9
thread_a i:11
thread_a i:12
thread_a i:13
thread_a i:14
thread_a i:15
thread_b i:10
thread_b i:16
thread_b i:17
thread_b i:18

Process finished with exit code 0

这种如果不能从锁来解决,那就只能从变量入手了。

铺垫:Java是一门支持多线程的语言,CPU有4核,有8核,有16核等等,在每个时刻,一个CPU内核只能执行一个线程,但这并不是说,一个CPU内核只能执行一个线程;当前线程执行完成后,操作系统会调度其他线程上CPU执行,或当前线程阻塞后,CPU会切换内核上下文给其他线程执行。

CPU缓存

大多数CPU带缓存

  • 一级缓存L1:供内核使用,每个核心都拥有2个,速度最快的缓存
  • 二级缓存L2:供内核使用,每个核心都拥有1个,存储容量比一级缓存大
  • 三级缓存L3:所有核共享使用,速度更慢,空间更大。

CPU的对数据计算速度远远高于内存的吞吐量,所以加入了缓存用以尽可能的减少CPU与内存之间数据的交互,保证CPU的高速执行。Java线程在并发执行的前提下,每个线程需要通过总线拿内存中的数据,拿数据这一动作是串行的,a线程拿完,b线程才能接着拿,这由CPU的总线锁控制。

总线:CPU总线相当于运输通道,保证内存与内核,内核之间的数据交流。内核中的线程拿数据时先到其他内核缓存查,查不到再去内存中拿。总线细分总线有3种,感兴趣可自行下去查阅资料。

MESI协议

大多数CPU使用MESI协议,这项协议用于标识缓存中数据与内存中数据状态

MESI协议

  • M:被修改,当前缓存中的数据与内存中不一致
  • E:独享,数据只缓存在内核专属缓存中,并且没有被修改
  • S:共享,代表所标识的数据已经在多个内核的专属缓存中存在,并且和主内存中数据一致
  • I:无效,标识的数据在当前内核专属缓存中无效

当A内核中的线程修改a的值时,它首先会去自己的专属缓存中拿数据,若没有,再去内存和其他内核的专属缓存(B核,C核,D核)中拿,拿到数据后存入此A核专属内存中,此时a数据是E状态,随后A内核的线程修改其值,但并未回写入内存,此时数据状态变为M,随后B内核也需改a的值,它首先会去其他各个核的专属缓存中访问一遍,发现A核的专属缓存中存在a的值,于是B核通过总线通知内存修改数据,B核把数据拷贝到自己的专属缓存中,随后通知其他核把a数据的状态变为S;B核线程修改a的值,这个时刻,B核缓存中的数据与内存不一致,B核通知内存更新,通知其他内核将它们缓存中的a的状态改为I,B核缓存内的a状态改为E,当A核线程继续修改a时,发现自己的缓存中的a是I状态,于是又会通过总线访问其他核的专属缓存和内存,发现B核有a,且状态为E,拷贝此值,并修改a的状态为E,通知B核将状态改为E;以此循环往复。

store bufferinvalidate queue

但是协议存在一个bug,那就是所有线程都是串行地交互,在状态未更新完成前,其他涉及a数据的线程不可以做其他事。这显然影响性能,所以CPU引入了store bufferinvalidate queue,让内核线程处理数据的时候,能够做其他操作。举个例子,当一个班级的同学都在投票的时候,我可以做看书,发呆这些并不影响投票的活动(打破程序的有序性)。或者用代码举例

public int add(){
 this.a = 10;
 int b = 20;
 return a+b;
}

上面这段代码被多个线程执行,所有线程都在修改a值,a就肯定是个需要通知到各个内核线程的变量,在线程等待a赋值时,它可以先做b=20这一步,这里看出在实际的底层操作中a并没有完成自己的赋值操作,而是等待更新,内核中线程先做了b=20这一步,却不能做a+b,因为这涉及到与a相关的计算

总线把需要变更的数据给到invalidate queue,CPU会慢慢同步到各内核的缓存中。

现在引发数据不一致性的导火线出现了:store bufferinvalidate queue,由于线程执行的时候,CPU是慢慢同步数据状态的,可能实时的状态没有同步到A内核的缓存里,A核内缓存中的数据被误视为最新的,随即被A核中的线程拿去执行了,错误就此产生!

为了避免这一情况,Java引入了关键字volatile 用于使store bufferinvalidate queue无效化,严格意义上也不能说是无效化,在执行运算操作前,检查store bufferinvalidate queue中没有更改通知后再进行运算操作。这不就回到了之前的串行通知了吗,是的!关键字volatile 解决了可见性和有序性,但会对性能造成影响。

store buffer:内核和缓存之间加一个store buffer,内核可以先将数据写到store buffer,同时给其他内核发送消息,然后继续做其它事情,等到收到其它内核发过来的响应消息,再将数据从store buffer移到缓存

可见性:一个线程的修改,对其他线程总是可见的
有序性:程序按照代码的先后顺序执行

posted @   勤匠  阅读(16)  评论(0编辑  收藏  举报
编辑推荐:
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现
点击右上角即可分享
微信分享提示