美文网首页并发编程
cpu、java内存模型

cpu、java内存模型

作者: 今年五年级 | 来源:发表于2019-10-17 10:08 被阅读0次

硬件相关知识

冯诺依曼模型
1945年,冯诺依曼和其他计算机科学家提出了计算机具体实现的报告,遵循了图灵机的设计,提出用 电子元件 构造计算机,并约定用二进制进行计算和存储,将计算机基本结构分为5部分:中央处理器,内存,输入设备,输出设备,总线

多核心CPU和多个CPU

假设现在我们要设计一台计算机的处理器部分的[架构]我们有两种选择,多个单核CPU和单个多核CPU

如果我们选择多个单核CPU,那么每一个CPU都需要有较为独立的电路支持,有自己的Cache,而他们之间通过板上的总线进行通信。在这样的架构上,我们要跑一个[多线程]的程序(常见典型情况),不考虑超线程,那么每一个线程就要跑在一个独立的CPU上,线程间的所有协作都要走总线,而共享的数据更是有可能要在好几个Cache里同时存在。这样的话,总线开销相比较而言是很大的,怎么办?那么多Cache,即使我们不心疼存储能力的浪费,一致性怎么保证?如果真正做出来,还要在主板上占多块地盘,给布局布线带来更大的挑战;

如果我们选择多核单CPU,那么我们只需要一套芯片组,一套存储,多核之间通过芯片内部总线进行通信,共享使用内存。在这样的架构上,如果我们跑一个多线程的程序,那么线程间通信将比上一种情形更快。如果最终实现出来,对板上空间的占用较小,布局布线的压力也较小。
但是,如果需要同时跑多个大程序怎么办?假设俩大程序,每一个程序都好多线程还几乎用满cache,它们分时使用CPU,那在程序间切换的时候,光指令和数据的替换就要费多大事情啊!
所以呢,大部分一般咱们使用的电脑,都是单CPU多核的,比如我们配的Dell T3600,有一颗Intel Xeon E5-1650,6核,虚拟为12个逻辑核心。少部分高端人士需要更强的多任务并发能力,就会搞一个多颗多核CPU的机子,Mac Pro就可以有两颗。

  1. 多核CPU
    多核CPU即1个CPU有多个核心,可以理解为是多个CPU,这些CPU集成在一个芯片里,可以通过内部总线来交互数据,共享数据,这些CPU中分配出一个独立的核执行操作系统,每个核都有自己的寄存器,alu运算单元等(这些都是封装在cpu内部的)

  2. 多个CPU


四核处理器解读:
四核处理器即是基于单个半导体的一个处理器上拥有四个一样功能的处理器核心。换句话说,将四个物理处理器核心整合入一个核中

多核心处理器,任务执行的那一小段时间叫做时间片,任务正在执行时的状态叫运行状态,被暂停的线程任务状态叫做就绪状态,意为等待下一个属于它的时间片的到来

Java内存模型

多核心并发缓存架构

一个CPU里面有两个核心,可以理解为2个cpu,所有数据在硬盘,计算机要运行程序,cpu将会把数据加载到主内存(内存条)

很久以前的老计算机,cpu是和主内存直接交互的,根据摩尔定律:微处理器的性能每隔18个月提高一倍,或价格下降一半,但是主内存不是,所以如果cpu跟主内存直接交互,cpu速度非常快,主内存速度很慢,那么总的速度以慢的为主

解决方案:CPU高速缓存
现代计算机都会在cpu和主内存之间架设一级缓存,即cpu高速缓存

L1,L2,L3就是我们的cpu的高速缓存,即CPU缓存,严格来说在cpu内部,价格非常昂贵,其容量远小于主内存,但速度却可以接近处理器的频率,之所以加cpu缓存就是为了解决cpu和主内存运算速度不一致的问题。CPU缓存一般直接跟CPU芯片集成或位于主板总线互连的独立芯片上。

随着多核CPU的发展,CPU缓存通常分成了三个级别:L1,L2,L3。级别越小越接近CPU,所以速度也更快,同时也代表着容量越小。L1 是最接近CPU的, 它容量最小(例如:32K),速度最快,每个核上都有一个 L1 缓存,L1 缓存每个核上其实有两个 L1 缓存, 一个用于存数据的 L1d Cache(Data Cache),一个用于存指令的 L1i Cache(Instruction Cache)。L2 缓存 更大一些(例如:256K),速度要慢一些, 一般情况下每个核上都有一个独立的L2 缓存; L3 缓存是三级缓存中最大的一级(例如3MB),同时也是最慢的一级, 在同一个CPU插槽之间的核共享一个 L3 缓存。

读取数据过程。就像数据库缓存一样,首先在最快的缓存中找数据,如果缓存没有命中(Cache miss) 则往下一级找, 直到三级缓存都找不到时,向内存要数据。一次次地未命中,代表取数据消耗的时间越长。

有了高速缓存以后,先把我们的数据从硬盘上加载到主内存,再把数据从主内存加载到cpu缓存,那么cpu再做存取的时候就是跟我们的cpu缓存进行交互

JMM

java线程之间通信是通过JMM控制的!
java线程内存模型跟CPU缓存模型类似,是基于cpu缓存模型来建立的,java线程内存模型是标准化的,屏蔽掉了底层不同计算机的区别
假设当前是多核cpu,有多个线程在同时运行,每个线程运行在不同的cpu上,我们假设他是并行执行的

多个线程来读取共享变量(比如static变量,或者一个对象的公共实例变量),首先是把主内存中的公共变量加载到各自线程的工作内存,然后在每个线程里面做自己的运算,如果线程A把initFlag改为true了,线程B很可能感知不到这个变化,还是false

这个线程的工作内存跟cpu的高速缓存很类似,为了提高运算速度,会为每个线程搞一个工作内存,虽然提高了性能,但是也给程序带来了可能出现bug的问题

注意:本地内存并不真实存在,它是个JMM内存模型的抽象概念,狭义理解为CPU高速缓存器和寄存器。

寄存器是中央处理器内的组成部份。它跟CPU有关。寄存器是有限存贮容量的高速存贮部件,它们可用来暂存指令、数据和位址。在中央处理器的控制部件中,包含的寄存器有指令寄存器(IR)和程序计数器(PC)。在中央处理器的算术及逻辑部件中,包含的寄存器有累加器(ACC)。

可见性测试

public class VisualTest {

    public static volatile boolean initFlag = false;

    public static void main(String[] args) {
        Thread t1= new Thread(() -> {
//            System.out.println("等待数据");
            while(!initFlag){

            }
//            System.out.println("处理结束");
        });

        t1.start();

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

        Thread t2= new Thread(() -> {
            prepareData();
        });

        t2.start();
    }

    public static void prepareData(){
        System.out.println("准备数据中...");
        initFlag = true;
        System.out.println("准备数据结束");
    }

}

按道理来说static是共享的,第二个线程修改了flag变量,第一个程序按理说应该退出,但是结果是


没有退出打印处理结束,原因是一号线程没有感知到二号线程对flag变量的值的修改,要让他感知到,需要在flag前面加上volatile,下面我们加上volatile再进行测试,发现程序成功退出


JMM内存原子操作

关于主内存与工作内存之间的交互协议,即一个变量如何从主内存拷贝到工作内存。如何从工作内存同步到主内存中的实现细节。java内存模型定义了8种操作来完成

下面结合jmm内存原子操作剖析一下没加volatile之前为什么会出现线程操作不可见问题


注意:上图右边应该是线程2,图标记错了

静态变量在类加载的时候就赋值了,false,主内存就已经有静态变量这个值
线程1会将主内存中的flag和值拷贝到工作内存一份,然后再进行一些操作,线程1对工作内存中的变量副本的运算use就是取反判断,然后在cpu那里做where死循环,也就是第一个线程一直卡在cpu那里一直空转

线程2将主内存的flag和值拷贝到工作内存一份,然后线程2对工作内存中的变量副本执行运算use操作,在上面的例子中就是修改他的值,修改完后,将新的值assign赋值回线程2的工作内存,然后再将这个新的值写到主内存,最后执行write操作,将这新值赋值给主内存中的initFlag(上面图中的store那一步,实际上已经指向的那个initFlag=true进入了主内存里面,上面放在外面是为了方便演示)

但是问题是线程1还卡在where那里死循环,它并没有获取到这个改变,导致程序无法退出

JMM缓存不一致问题解决方案

  1. 总线加锁(类似于加了一把悲观锁)
    操作系统提供了总线锁定的机制。前端总线(也叫CPU总线)是所有CPU与芯片组连接的主干道,负责CPU与外界所有部件的通信,包括高速缓存、内存、北桥,其控制总线向各个部件发送控制信号、通过地址总线发送地址信号指定其要访问的部件、通过数据总线双向传输。在CPU1要做 i++操作的时候,其在总线上发出一个LOCK#信号,其他处理器就不能操作缓存了该共享变量内存地址的缓存,也就是阻塞了其他CPU,使该处理器可以独享此共享内存。

只要有一个线程先读取到主内存中的共享变量到工作副本以后,就给主内存这个共享变量加锁,直到那个线程使用结束,释放锁,其他等待的线程要获取这个共享变量的话是获取不到的
如果多个线程不同时读取这个共享变量的话,他们是可以并行操作的,一旦他们读取共享变量的话initFlag的时候,会在主内存那里排队,谁先拿到这个变量,就在这里加一把锁

那么第二个线程能获取到锁的时候,这个值必定是修改过的,但是这种做法无疑非常影响性能,现代计算机采用MESI缓存一致性来解决JMM缓存不一致问题

  1. mesi缓存一致性协议
状态 描述
M(Modified) 这行数据有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中
E(Exclusive) 这行数据有效,数据和内存中的数据一致,数据只存在于本Cache中
S(Shared) 这行数据有效,数据和内存中的数据一致,数据存在于很多Cache中
I(Invalid) 这行数据无效

我们的cpu跟我们的主内存之间交互,最后都是通过总线,总线是用来连接多个硬件(cpu,主内存,主板)的组件

当线程2把共享变量initFlag的值修改了以后,当他同步回主内存的时候要通过总线(数据最终是要通过总线传回主内存的),而不管有多少个cpu,都会对总线嗅探(监听),监听的是他感兴趣的数据,比如线程1的工作内存中有initFlag这个数据,那他就会监听我们这个变量的改变,一旦线程2对变量的更改通过总线的时候,线程1就会监听到这个变量的改变,然后让自己工作内存中的这个变量失效

然后线程1的cpu读这个值的时候,发现这个变量的内存地址里面空掉了,没有任何值了,那么他会马上从主内存里进行read操作,这时候这个值已经改变了,就可以正常进行了

volatile

volatile缓存可见性原理

底层通过汇编lock前缀指令来实现
lock指令完成两件事:

  1. 立即将当前处理器工作内存中的数据写回到主内存
  2. 这个写回内存的操作会引起在其他内存中缓存了该内存地址的数据无效(MESI协议)

直接点击volatile是看不了源码的,因为volatile关键字底层不是java实现的,是C,如果底层是java代码实现的,你直接是可以点进去的,我们通过查看上述 VisualTest代码的底层汇编语言(低级语言,写起来复杂,但是可以看出来很多底层的逻辑)是怎么做的

不管是java还是c语言,要真正运行,都会先变成汇编语言(这是可以看的),再变成机器码(0,1无法看懂)
需要下载的工具:hsdis-amd64.dll
然后放到要使用的jdk下面的jre的bin目录下面

然后,在idea配置项目启动的vm options

-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VisualTest.prepareData

启动项目打印如下

可以看到有一lock字段的行,其中这个对应的是jvm的指令码

这行汇编代码做的就是对CPU高速缓存里面也就是工作内存里面的变量的值进行赋值,即assign操作,将修改的true赋值给工作内存里面的变量

前面加了lock的汇编指令,前缀指令,为汇编语言的关键字,一但进行assign操作以后,会马上接着执行,cpu硬件会马上将值同步回主内存,不管线程2还有没有其他代码执行

如果共享变量没有加volatile,那么前面不会有lock关键字,你不会知道他什么时候把值写到主内存,不会把改变马上同步回主内存,而是等线程2的其他代码执行完了再同步回主内存

可能出现的问题点
数据进入总线,通过mesi缓存一致性协议,将线程1工作内存中的变量副本失效,但是可能存在一种情况:
线程2修改的数据在进入总线还未同步到主内存的时候,这时候initFlag还是false,线程1就从主内存又去读取,那么此时又将没改变的数据读取到了线程1的工作内存

Volatile是怎么解决的?
答案:volatile是加了一个缓存锁,但是力度非常小,之前总线加锁机制是将锁加载read之前,但是volatile加锁不是在read之前,而是在store之前

至于unlock是在store完,write完再去unlock操作,这个操作实际上就是为了确保赋值,执行时间非常非常少,几乎可以忽略,内存中给变量赋值,少说也可以有几十万次,甚至上百万次

总结:volatile实现由3重含义

  1. 马上把数据同步回主内存
  2. 触发mesi缓存一致性协议让本地内存中变量失效
  3. 重新获取的时候检查主内存中变量是否加锁,没有锁的时候才

注意:常识:两个线程之间是不能直接交互的,必须通过主内存,不是说一个线程的修改能直接被另一个线程马上看到,这个说法是错误的,如下图中的蓝X

原子性案例

volatile只能保证可见性有序性,不能保证原子性的原因
案例:

public class AtomicityTest {
    public static volatile int num=0;
    public static void increase(){
        num++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < threads.length; i++) {
            threads[i]=new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int j=0;j<1000;j++)
                        increase();
                }
            });
            threads[i].start();
        }

        for(Thread t:threads)
            t.join();

        System.out.println(num);
    }
}

每次循环都new了一个线程,让他去执行,每个线程里面都去做1000次for循环,对共享变量num的值进行++操作,每个线程+1000次,10个线程执行1w次,最后
打印num
结果是小于等于10000的

问题:为什么上面volatile没起作用,出现了小于10000的现象?

线程 1对num==0,进行++操作以后assign操作将修改的1写回到工作内存,还没来得及写到主内存,连总线都没到的时候。线程2等不及了,也在cpu那边对从工作内存读取到的num进行++操作,也将工作内存中的值变成1了,那这个时候就会出现什么情况?

线程1将修改往主内存写的时候,通过总线的时候,经过mesi缓存一致性协议,将线程2的工作内存中的变量num(此时的值为1了已经)的值设置为失效

这导致的严重后果就是:线程2之前的这个++操作已经丢失了,本来应该等于2,结果变成了1,

正常情况应该是线程1的修改写到主内存,线程2再进行++操作,这样的话就没问题

如果线程1能马上写回到主内存,然后mesi协议清理掉线程2的值,然后线程2读到1,才进行++操作,可惜上面出问题是因为,线程2没等到线程1的修改写到主内存的时候,线程2已经进行++操作

不能保证原子操作就是因为++的操作可能存在丢失,所以会出现有时候是10000,有时候小于10000

相关文章

网友评论

    本文标题:cpu、java内存模型

    本文链接:https://www.haomeiwen.com/subject/obcmmctx.html