【并发编程】JAVA 内存模型 与 volatile 关键字

258 阅读8分钟

cpu 三级缓存结构 和 JAVA 内存模型

CPU 三级缓存结构

以 WINDOWS 系统 CPU(Intel(R) Core(TM) i5-8250U CPU @ 1.60GHz)为例
该 CPU 存在4个内核8个逻辑处理器,每个逻辑处理器拥有三级缓存(L1、L2、L3 缓存)

将 CPU 三级缓存抽象化成如下图示
假设存在一个如下 main 函数,CPU是如何执行的呢?

    public static void main(String[] args) {
        int a = 1;
        a = a + 1;
        System.out.println(a);
    }

我们都知道 JVM 在运行时,将我们的变量和相关方法都保存在我们的内存中,CPU运行时必然需要将数据复制到寄存器中运行。那么cpu时如何获取数据的呢?

cpu 先查询一级缓存是否存在 int a,如果没有,再查询二级缓存,然后查询三级缓存,如果缓存中均不存在,才会通过 BUS总线 连接内存,从内存中获取数据,并逐级加载到cpu缓存的缓存行中。最后再寄存器中进行计算并重新赋值。

JAVA 内存模型

JMM (JAVA 内存模型)就是基于这种规则和规范,而抽象出来的一种概念。它是一种程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

假设存在以下代码:

public static boolean flag = true;
public static void main(String[] args) {
    Thread thread_A = new Thread(new Runnable() {
        @Override
        public void run() {
            while (flag){}
            System.out.println("flag修改状态,被线程A识别到");
        }
    });
    thread_A.start();
    Thread thread_B = new Thread(new Runnable() {
        @Override
        public void run() {
            if (flag) {
                flag = false;
                System.out.println("flag 已修改");
            }
        }
    });
    thread_B.start();
}

main 函数启动后,线程A 和 线程B 也紧跟着启动.
线程A从内存中将flag属性read到BUS总线内存空间中,然后load到线程私有工作空间,并在线程A中use。由于while条件不成立,所以一直等待。
线程B同样从内存中将flag属性read到BUS总线内存空间中,然后load到线程私有工作空间,在线程B被修改为false,然后assign到工作内存中,通过store将数据复制到主内存等待写入write

JMM 数据操作八大原子操作

  1. lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
  2. unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  3. read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  4. load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
  5. use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
  6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
  7. store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
  8. write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中

并发下的可见性、原子性与有序性问题

可见性

可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。

在串行执行的代码中是不存在可见性问题的。因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。

但在多线程环境下就不一定了,根据JMM 内存模型来看线程只会操作私有工作内存里的数据,比如:上面代码中的 Thread_A 在 Thread_B 修改完flag属性并重写会共享内存 后,仍然没有感知到flag属性的变化,就是因为在 Thread_A 的私有工作内存中存在一个生效的flag,且Thread_A并不知道有其他线程修改了flag。即:flag在线程之间是不可见的。

如何解决可见性问题
volatile 关键字保证可见性。当一个共享变量被volatile修饰时,由于CPU多级缓存一致性协议的存在,会将 Thread_B 修改的flag属性马上写入共享内存,并通知 Thread_A 将它私有工作内存的 flag 属性设置为失效状态。这样Thread_A就会从内存中重写读取数据

public static volatile boolean flag = true;

原子性

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。

java 中对于基本数据类型的读写操作时具有原子性的(特例:在32位系统中,long 和 double不具备原子性。因为32位系统,每次读写32位是原子性,但是long/double是64位的,他的读写分两次完成,所以可能会出现只读写半个,另外半个被其他线程读写的情况)。

public static volatile int num = 0;
public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 20; i++) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 1000; i++) {
                        num++;
                    }
                }
            });
            thread.start();
        }
        Thread.sleep(1000);
        System.out.println(num);
}

在通过volatile修饰,排序可见性问题后,理论上 num 应该正常输出 20000 ,但是实际运行后我们发现得到的值往往少于 20000。

原因:num++相当于num=num+1,这个过程分为两步,读取 num 和 给 num 赋值。可能出现一种情况,初始num = 0,线程A 对 num进行了读取,并执行num+1 ==》num=1, 但是还没有写入共享内存(共享内存num = 0),此时线程b开启,对 num 进行了读取,读取到的数据是0,然后执行了num+1 ==》num = 1,最后两个线程都将数据写入了内存,相当于执行了两次循环,只加了1。

如何解决原子性问题
通过synchronizedLock实现原子性,synchronizedLock保证同一时刻只有一个线程执行了代码块。这也就必然没有原子性问题。

synchronized (this){
	num++;
}

有序性

对于单线程而言,我们认为代码执行都是按代码顺序依次执行的。
但对于多线程而言,则可能出现乱序现象。因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致。

如何解决有序性问题:
可以通过volatile关键字来保证一定的“有序性”,它禁止了指令重排。

Volatile

简介

Volatile 是java虚拟机提供的最轻量级的同步机制!
由Volatile修饰的变量在发生修改时,会通知所有拥有并使用它的线程更新最新的数据,防止出现数据的“脏读”,确保共享变量的可见性。

如何实现?

通过 HSDIS 将java代码翻译成汇编代码后,volatile修饰的共享变量在进行写操作的时候会加上lock前缀的指令。通过查询《IA-32架构软件开发人员手册》可以得知lock前缀指令会对总线加锁,确保通过volatile修饰的共享变量的修改是原子性的。
《IA-32架构软件开发人员手册》原文
由于早期的处理器收到lock前缀指令是总是会出发总线锁。所以在后面就引入了高速缓存锁【CPU多级缓存一致性协议(MESI)】,如果cpu存在缓存一致性协议且修改的数据只占一个缓存行,那么就会触发高速缓存锁。

CPU多级缓存一致性协议的4中状态(MESI)

M: 修改状态,表示共享变量已经被修改。
E: 独占状态,表示共享变量只有一个线程使用。
S: 共享状态,表示共享变量被多个线程使用。
I: 失效状态,表示共享变量在当前缓存行中已失效。

volatile 的工作流程

假设存在如下代码

public static volatile boolean flag = true;
public static void main(String[] args) {
    Thread thread_A = new Thread(new Runnable() {
        @Override
        public void run() {
            while (flag){}
            System.out.println("flag修改状态,被线程A识别到");
        }
    });
    thread_A.start();
    Thread thread_B = new Thread(new Runnable() {
        @Override
        public void run() {
            if (flag) {
                flag = false;
                System.out.println("flag 已修改");
            }
        }
    });
    thread_B.start();
}

下图就是flag变量在两个cpu中状态和变化 稍微解释下:

  1. 在main函数启动后,线程A 也随后启动,它从主内存将 flag变量 加载到 缓存空间,由于此时 线程B 未启动,所以当前flag状态为 E (独享状态),由于while (flag)一直为true,所以线程A 进入循环状态。
  2. 线程B 随后也启动了,将 flag变量 从 主内存 又加载了一份到缓存空间里。此时flag 为 S (共享状态),同时将 线程A 工作内存的状态设置为 S。
  3. 线程B 将缓存行中的flag加载到寄存器中,并对 flag 进行修改flag = false,并将flag的状态改为 M(修改状态)
  4. 修改完 flag 后,将 flag 写入store buffer中(cpu空出,调度其他线程),等待线程A响应 “已失效” 消息。
  5. 4进行的同时相BUS 总线发起本地写入指令,线程A 的总线嗅探机制,感知到线程B 对flag的本地写指令后,会将线程A缓存行中的flag状态设置为 I(已失效)。并向BUS总线响应 “已失效” 消息。
  6. 线程B 通过总线嗅探机制,感知到线程A响应的 “已失效” 消息,将store buffer区的flag 写回共享内存。最后线程A重新获取flag。

相信大家看完以上内容,应该明白了为什么用volatile修饰的共享变量可以保证可见性。

不知大家是否有这样的疑问,根据你上面说的volatile修饰共享变量读-修改-写操作时原子性的,那按照这样讲volatile修饰的共享变量也可以确保原子性

当然不是啦!我来举个栗子。还是这段代码。。。

public static volatile int num = 0;
public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 20; i++) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 1000; i++) {
                        num++;
                    }
                }
            });
            thread.start();
        }
        Thread.sleep(1000);
        System.out.println(num);
}

按照缓存一致性协议来看,在num++时,其他cpu会接收到一个触发高速缓存锁的cpu发起的本地写指令,并将自己cpu缓存中的num设置为失效,重新去共享内存中读取。这样看起来好像没啥问题。

但是,有一种特殊情况:假设有一个cpu(假设:cpu_X)在接收触发高速缓存锁的cpu发起的本地写指令前,已经将num加载到寄存器中了,缓存一致性协议 只会将缓存区的num设置为失效,却无法将寄存器中的对象失效。此时触发高速缓存锁的cpu进行了num++,cpu_X也进行了一次num++,此时他们得到的num是一致的,都会被写入内存,但是循环次数也相应的减少了一次。这种情况下 num++ 的总循环次数也就少了一次,num的数值也减少了。

码字不易,给哥们点个赞呗,意图升级的我万分感激~~~~