2.java多线程——java内存模型

学习java内存模型之前呢,我们先来看看计算机的一些相关知识。

计算机理论模型和基本结构

计算机理论模型

冯诺依曼的计算机理论模型,主要由运算器、控制器、存储器、输入设备、输出设备 5 部分组成。如下图:
图片:

  • 运算器:顾名思义,主要进行计算,算术运算、逻辑运算等都由它来完成。
  • 存储器:这里存储器只是内存,不包括内存,用于存储数据、指令信息。
    控制器:控制器是是所有设备的调度中心,系统的正常运行都是有它来调配。
  • 输入设备:负责向计算机中输入数据,如鼠标、键盘等。
  • 输出设备:负责输出计算机指令执行后的数据,如显示器、打印机等。

计算机基本硬件结构

计算机主要的硬件设备及其之间的联系如下图:
图片:

  • CPU:中央处理单元
  • ALU:算数/逻辑单元
  • PC:程序计数器
  • USB:通用的串行总线

CPU 内部结构

cpu 内部主要由控制单元、存储单元、运算单元 3 个单元构成(更细一点,从实现的功能方面看,CPU 大致可分为 8 个单元)。如下图:
图片:

CPU 从内存中一条一条地取出指令和相应的数据,按指令操作码的规定,对数据进行运算处理,直到程序执行完毕为止。

cpu 工作流程:

  1. 指令寄存器从内存中读取指令,同时指令计数器把指令地址给到内存;
    控制单元发出指令给存储单元;
  2. 存储单元从内存中读取数据和操作数地址;
  3. 控制单元发出指令给运算单元;
  4. 运算单元首先从存储单元读取数据,接着进行运算处理,最后把处理结果保存到存储单元;
  5. 存储单元再把结果写回内存。

查看我当前电脑 CPU 的各种信息,如下图:
图片:

可以看到:
1 个 i7 cpu,6 个内核,每个内核可以顶 2 个处理器的处理效果,所以逻辑处理器 12 个,即 6 核 12 线程;
共有 3 级缓存,L1 级缓存最小最快,L2 缓存其次,L3 级缓存最大最慢。

JMM( java内存模型)

多核并发缓存架构

现代计算机一般都有多核,简图如下:
图片:

工作原理:

  • 数据从主内存 加载到 cpu缓存(一般有3级)再加载到cpu寄存器;
  • 开始运算处理;
  • 处理结果先写回cpu缓存,再写回主内存。

JMM概述

java多线程内存模型(JMM,Java Memory Model)跟cpu缓存模型类似,是基于cup缓存模型建立的。java线程模型是标准化的,屏蔽了底层不同计算机的区别。
JMM的3个特性:可见性、有序性、原子性。

图片:
多线程执行时,每个线程从主内存加载共享变量 存到工作内存的共享变量副本里面。即,每个线程使用的共享变量是从主内存复制的一份。

JMM数据原子操作

  • read(读取):从主内存中读取数据。
  • load(载入):将主内存中读取的数据写入到工作内存中。
  • use(使用):从工作内存中读取数据来计算。
  • assign(赋值):将计算好的值重新赋值到工作内存中。
  • store(存储):将工作内存中数据存入到主内存中。
  • write(写入):将store过去的变量值赋值给主内存中的变量。
  • lock(锁定):将主内存变量加锁,标识为线程独占状态。
  • unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量。

JMM可见性

案例

这里有一个共享变量initFlag,一个线程修改它的值了,另一个线程是否感觉到呢?

public class VolatileVisibilityTest {
    private static boolean initFlag = false;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            System.out.println("waiting data");
            while (!initFlag){}
            System.out.println("==========success");
        }).start();
        Thread.sleep(2000);
        new Thread(() -> { prepareData(); }).start();
    }
    public static void prepareData(){
        System.out.println("prepare data........");
        initFlag = true;
        System.out.println("prepare data end......");
    }
}

执行上面代码,控制打印内容如下,发现程序并没有停下来,说明第一个线程并不知道共享变量initFlag已经被改为true了,事实上第二个线程改是自己工作内存里面的变量副本,第一个线程工作内存里面的变量副本还是false。
图片:

那么如果想要第一个线程即时知道共享变量已经修改了,只要给共享变量initFlag加上volatile关键字就可以了,如下:

private volatile static boolean initFlag = false;

使用volatile前分析

共享变量没加volatiel关键字之前,工作流程图如下:
图片:

线程1

  1. 从主内存中read共享变量initFlag到线程1的工作内存中;
  2. 接着从工作内存中use共享变量去处理,进入循环。

线程2

  1. 从主内存中read共享变量initFlag到线程2的工作内存中;
  2. 从工作内存中use共享变量去处理,变量值修改为true;
  3. 把修改后的共享变量initFlag重新assign到工作内存中;
  4. 把工作内存中修改后的共享变量initFlag再store到主内存中;
  5. 把store过去的共享变量initFlag再write给主内存中的变量。

那么为什么给共享变量initFlag加上volatile关键字之后,线程1就能感知到共享变量修改了呢?就不得不提缓存一致性协议了。

缓存一致性协议(MESI)

多核CPU的情况下有多个一级缓存,如何保证缓存内部数据的一致,不让系统数据混乱。这里就引出了一个一致性的协议MESI。
MESI 是指4中状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:

状态描述监听任务
M 修改 (Modified)该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。
E 独享、互斥 (Exclusive)该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共(Shared)该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)
I 无效 (Invalid)该Cache line无效。

说明:缓存行(Cache line):缓存存储数据的单元。

注意:

对于M和E状态而言总是精确的,他们在和该缓存行的真正状态是一致的,而S状态可能是非一致的。如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。

从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务。

总结
多个cpu从主内存中读取同一个数据到各自高速缓存,当其中的某个cpu修改了缓存里的数据,该数据会马上同步回主内存,其他cpu通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效,再使用时重新从主内存中读取。
不同的处理器使用的缓存一致性协议可能不同。

缓存加锁
缓存锁的核心机制是基于缓存一致性协议来实现的,一个处理器的缓存回写到主内存会导致其他处理器的缓存无效,IA-32和Inter64处理器使用MESI实现缓存一致性协议。

使用volatile后分析

共享变量没加volatiel关键字之后,工作流程图(区别看红色字体部分)如下:
图片:

总线使用缓存一致性协议MESI,开启总线嗅探机制。
当线程2修改了共享变量initFLag的值后,立马(线程2不再往下继续执行)线程1使工作内存中的变量initFlag值失效,当线程1再使用时又会从主内存中重新读取。

votaile缓存可见性实现原理

底层实现主要是通过汇编lock前缀指令,他会锁定这块内存区域的缓存(缓存行锁定)并回写到主内存。

-I-32和Inter 64架构软件开发者手册对lock指令的解释如下:

  1. 将当前处理器缓存行的数据立即写回到系统内存。
  2. 这个写回内存的操作会引起其他cpu缓存l该内存地址的数据无效(MESI协议)。
  3. 提供内存屏障功能,使lock签订后指令不能重新排序。

上面的案例代码中使用了volatile关键字,那么看下该代码的汇编语言,看能不能找到lock指令。
在IDEA中配置VM参数:

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

下载hsdis-amd64.dll库(下载地址:https://github.com/atzhangsan/file_loaded),下载之后放到$JAVA_HOME\jre\bin\server下,如下图:
图片:
再次执行上面代码(VolatileVisibilityTest),控制台打印出该段代码的汇编语言,查找lock,在代码27行修改共享变量值时加锁,汇编语句前面有lock。如下图:
图片:

JMM有序性

案例

执行下面一段代码,是否存在a=1,b=1这样情况呢?

public class VolatileOrderTest {
    static int a = 0, b = 0;
    static int x = 0, y = 0;
    public static void main(String[] args) throws InterruptedException {
        for(int i = 0;;i++){
            i++;
            x = 0; y = 0;
            a = 0; b = 0;
            Thread one = new Thread(() -> {
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                a = y;
                x = 1;
            });
            Thread two = new Thread(() -> {
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                b = x;
                y = 1;
            });
            one.start();
            two.start();
            one.join();
            two.join();
            System.out.println("第" + i + "次 a=" + a + " b=" + b);
            if (a==1 && b==1) break;
        }
    }
}

执行控制台打印如下:
图片:
可以发现循环到7万多次的时候,出现了结果为:a=1,b=1的情况,这说明:
线程one中x=1;比a=y;先执行,线程two中y=1;比b=x;先执行,两个线程代码实际的执行顺序都发生了变化,这就是指令重排现象。

什么是指令重排

java语言规范规定JVM线程内部维持顺序话语义。即,只要程序的最终结果与它顺序化执行结果相同,那么指令的执行顺序可以与代码顺序不一致,此过程叫做指令的重排。

指令重排序,在不影响单线程程序执行结果的前提下,计算机为了最大限度的发挥机器性能,会对机器指令进行重排序优化。
一段代码一般要经过这几步的重排序,如下图:
图片:

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

指令重排的前提

是不是所有的代码都可能发生指令重排呢?当然不是的,只有满足一定的条件,符合一些规则才会发生。

as-if-serial

as-if-serial语义的意思是:不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

例如:

int i = 0;
int j = i + 1; //下面这条语句就依赖上面的语句,不会发生指令重排
happens-before

只靠synchronized(保证原子性)和valatile(保证可见性、有序性)来保证JMM的可见性、有序性、原子性、有序性,那么编写并发程序会显得十分麻烦,幸运的是从JDK1.5开始,Java使用新的JSR-133内存模型,提供了happens-before原则来辅助保证程序执行的原子性,可见性以及有序性问题,它是判断数据是否存在竞争,线程是否安全的依据,happens-before原则内容如下:

  1. 程序顺序规则:在一个线程内必须保证语义串行性也就是说按照代码顺序执行。

  2. 锁规则:解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前. 也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须再解锁动作之后(同一个锁)。

  3. volatile规则:volatile变量的写,先发生于读。这保证了volatile变量的可见性。简单的理解就是,volatile变量每次被线程访问时都强迫从主内存中读取该变量的值。而当改变量发生变
    化时,又会强迫把最新的值刷回到主内存,任何时刻,不同线程总是能看到该变量的最新值。

  4. 线程启动规则:线程的start()方法先于它的每一个动作,即如果线程A在 执行线程B的start()方法之前 修改了共享变量的值,那么当线程B在执行start()方法时,线程A对共享变量的修改对线程B可见

  5. 传递性:A先于(happens-before)B,B先于(happens-before)C,那么必然A先于(happens-before)C

  6. 线程终止规则:线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改对线程A可见。

  7. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。也就是说,响应中断一定发生在发起中断之后。

  8. 对象终结规则
    就是一个对象的初始化的完成,也就是构造函数执行的结束一定先于它的finalize()方法。

  9. 管程锁定规则: 一个线程获取到锁后,它能看到前一个获取到锁的线程所有的操作结果。
    主要含义是:无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果!(管程是一种通用的同步原语,synchronized就是管程的实现)

双重检测锁DCL真的安全吗

在《阿里巴巴开发手册》并发处理章节中说到,双重检测锁是存在隐患的,建议给静态属性加上volatiel关键字,如下图:
图片:

那么为什么是不安全的呢?我们来刨析下,看下面这段DCL的示例代码:

public class DoubleCheckLockSingleton {
    private static DoubleCheckLockSingleton instance = null;
    private DoubleCheckLockSingleton(){}
    public static DoubleCheckLockSingleton getInstance(){
        if(instance == null){
            synchronized (DoubleCheckLockSingleton.class){
                if(instance == null){
                    instance = new DoubleCheckLockSingleton();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        DoubleCheckLockSingleton instance = DoubleCheckLockSingleton.getInstance();
//        System.out.println(instance);
    }
}

这段代码编译后,我们来看看它的字节码文件内容,在target目录下找到该类的.class文件,执行命令:javap -c DoubleCheckLockSingleton .class,可以看到这个类的字节码文件,我们重点来看getInstance()方法。如下图:
图片:

getstatic、ifnonnull、astore_0等可以查看指令手册https://blog.youkuaiyun.com/weixin_41968788/article/details/105517552。

可以看到17行,开始创建对象了,一般创建对象要分为好几步(加载类、分配内存、初始化,设置对象头,执行init()方法);
21行,执行init方法包括调用构造函数;
24行,把创建好的对象赋给静态属性;
那么21行代码可以和24行交换顺序进行指令重排吗?答案是可以的,不违反as-if-serial和happens-before原则,即,21行和24行是可以交换顺序的。
单线程场景下,这样是没问题的,不会影响执行结果。

那么在多线程场景下下,如果先执行了24行的putstatic,把对象赋值给了静态属性,这时候对象还没有彻底创建完成(半初始化状态),其他线程判断这个属性是否为null,这时侯静态属性不是null,就会被另一个线程拿去使用,则可能会产生意想不到的错误结果。

这就是为什么说DCL在并发场景下是存在隐患的,可以给静态属性加上volatile关键字,阻止指令重排。

内存屏障

内存屏障规范

jvm规范定义的内存屏障

屏障类型指令示例说明
LoadLoadLoad1;LoadLoad;Load2保证Load1的读取操作在Load2及后续操作之前执行
StoreStoreStore1;StoreStore;Store2在Store2及其后的写操作执行前,保证Store1的写操作已经刷新到主内存
LoadStoreLoad1;LoadStore;Store2在Store2及其后的写操作执行前,保证Load1的读操作已经执行结束
StoreLoadStore1;StoreLoad;Load2保证Store1的写操作已刷新到主内存之后,Load2及其后的读操作才能执行

示例
a = 2; // volatile写,a为volatile变量
StoreStore屏障
a = 1; // volatile写
StoreLoad屏障
b = a; // volatile读
LoadLoad 屏障

不同CPU硬件对于JVM的内存屏障规范实现指令不一样,Intel CPU硬件级内存屏障实现指令:

  • Ifence:是一种LoadBarrier读屏障,实现LoadLoad屏障
  • sfence:是一种StoreBarier写屏障,实现StoreStore屏障
  • mfence:是一种全能型屏障,具备Ifence和sfence的能力,具有所有屏障能力

JVM底层简化了内存屏障硬件的指令实现
lock前缀:lock指令不是一种内存屏障,但是它能完成类似内存屏障的功能。

jvm底层源码

这里我们来看下openjdk,源码获取地址:https://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/87ee5ee27509,也可以参考周志明老师的《深入理解java虚拟机》中的1.6章节。
拿到源码后,打开如下:
图片:

查看\hotspot-87ee5ee27509\src\os_cpu\linux_x86\vm\orderAccess_linux_x86.inline.hpp文件
图片:

这就是jvm底层volatile如何实现内存屏障的。

JMM原子性

原子性:一个操作不可分割的,不可分离的。举个简单例子,对于变量x,进行加1,然后取到值,这一个过程尽管简单,但是却不具备原子性,因为我们要先读取x,之后进行计算,然后重新写入,其实是几个步骤。如果仅仅对x进行赋值,那么则可以认为是原子的。

对于基本数据类型,他们的读取行为通常是原子操作,但是对于long和double是64位,则在线程操作过程中,不一定是原子操作,线程A在获取数据时,可能只限操作前32位,轮到线程B操作时,则可能会读到后面的32位。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值