JVM面试题、关键原理、JMM

发布时间 2023-09-21 22:38:51作者: 岁
  1. boolean:占用1个字节,取值为true或false。
  2. byte:占用1个字节,范围为-128到127。
  3. short:占用2个字节,范围为-32,768到32,767。
  4. int:占用4个字节,范围为-2,147,483,648到2,147,483,647。
  5. long:占用8个字节,范围为-9,223,372,036,854,775,808到9,223,372,036,854,775,807。
  6. float:占用4个字节,范围为IEEE 754单精度浮点数。
  7. double:占用8个字节,范围为IEEE 754双精度浮点数。
  8. char:占用2个字节,范围为Unicode字符。

JVM内存空间

JAVA在程序运行时,在内存中划分5片空间进行数据的存储。分别是:
1:寄存器。2:本地方法区。3:方法区。4:栈。5:堆。

方法区(Method Area) 方法区是用于存储类的结构信息,包括类的字段、方法、构造函数、静态变量、常量池等。在JVM规范中,方法区被定义为线程共享的内存区域。
堆(Heap) 堆是Java虚拟机中最大的一块内存区域,用于存储对象实例和数组。堆内存是被所有线程共享的,它在JVM启动时就被创建,并且在运行时动态扩展或缩减。
虚拟机栈(VM Stack) 虚拟机栈用于存储线程执行方法时的局部变量、操作数栈、方法参数和返回值等信息。每个线程在执行方法时都会创建一个对应的栈帧,栈帧用于存储方法的局部变量和部分运算结果。
本地方法栈(Native Method Stack) 本地方法栈与虚拟机栈类似,但是它用于执行本地方法(由外部语言编写的方法)。
程序计数器(Program Counter Register) 程序计数器是一块较小的内存区域,它用于存储当前线程正在执行的字节码指令的地址或下一条指令的地址。

堆和栈

一、先说一下最基本的要点
基本数据类型局部变量都是存放在栈内存中的,用完就消失。
new创建的实例化对象数组,是存放在堆内存中的,用完之后靠垃圾回收机制不定期自动消除。
二、先明确以上两点,以下示例就比较好理解了

示例1

main()
int x=1;
show ()
int x=2

主函数main()中定义变量int x=1,show()函数中定义变量int x=1。最后show()函数执行完毕。
以上程序执行步骤:
第1步——main()函数是程序入口,JVM先执行,在栈内存中开辟一个空间,存放int类型变量x,同时附值1。
第2步——JVM执行show()函数,在栈内存中又开辟一个新的空间,存放int类型变量x,同时附值2。
此时main空间与show空间并存,同时运行,互不影响。
第3步——show()执行完毕,变量x立即释放,空间消失。但是main()函数空间仍存在,main中的变量x仍然存在,不受影响。
示图如下:

示例2

main()
int[] x=new int[3];
x[0]=20

主函数main()中定义数组x,元素类型int,元素个数3。
以上程序执行步骤
第1步——执行int[] x=new int[3];
隐藏以下几分支
JVM执行main()函数,在栈内存中开辟一个空间,存放x变量(x变量是局部变量)。
同时,在堆内存中也开辟一个空间,存放new int[3]数组,堆内存会自动内存首地址值,如0x0045。
数组在栈内存中的地址值,会附给x,这样x也有地址值。所以,x就指向(引用)了这个数组。此时,所有元素均未附值,但都有默认初始化值0。
第2步——执行x[0]=20
即在堆内存中将20附给[0]这个数组元素。这样,数组的三个元素值分别为20,0,0
示图如下:

示例3

main()
int[] x=new int[3];
x[0]=20
x=null;

以上步骤执行步骤
第1、2步——与示例2完全一样,略。
第3步——执行x=null;
null表示空值,即x的引用数组内存地址0x0045被删除了,则不再指向栈内存中的数组。此时,堆中的数组不再被x使用了,即被视为垃圾,JVM会启动垃圾回收机制,不定时自动删除。
示图如下

示例4

main()
int[] x=new int[3];
int[] y=x;
y[1]=100
x=null;

以上步骤执行步骤
第1步——与示例2第1步一致,略。
第2步——执行int[] y=x,
在栈内存定义了新的数组变量内存y,同时将x的值0x0045附给了y。所以,y也指向了堆内存中的同一个数组。
第3步——执行y[1]=100
即在堆内存中将20附给[0]这个数组元素。这样,数组的三个元素值分别为0,100,0
第4步——执行x=null
则变量x不再指向栈内存中的数组了。但是,变量y仍然指向,所以数组不消失。
示图如下

示例5

Car c=new Car;
c.color="blue";
Car c1=new Car;
c1.num=5;

虽然是个对象都引用new Car,但是是两个不同的对象。每一次new,都产生不同的实体

示例6

Car c=new Car;
c.num=5;
Car c1=c;
c1.color="green";
c.run();

Car c1=c,这句话相当于将对象复制一份出来,两个对象的内存地址值一样。所以指向同一个实体,对c1的属性修改,相当于c的属性也改了。

三、栈和堆的特点
栈:

  • 函数中定义的基本类型变量,对象的引用变量都在函数的栈内存中分配。
  • 栈内存特点,数数据一执行完毕,变量会立即释放,节约内存空间。
  • 栈内存中的数据,没有默认初始化值,需要手动设置。

堆:

  • 堆内存用来存放new创建的对象和数组。
  • 堆内存中所有的实体都有内存地址值。
  • 堆内存中的实体是用来封装数据的,这些数据都有默认初始化值。
  • 堆内存中的实体不再被指向时,JVM启动垃圾回收机制,自动清除,这也是JAVA优于C++的表现之一(C++中需要程序员手动清除)。

注:
什么是局部变量:定义在函数中的变量、定义在函数中的参数上的变量、定义在for循环内部的变量

类中的方法:
当一个类的公有方法被调用时,Java虚拟机会创建一个新的堆栈帧(Stack Frame),并将其推入虚拟机栈(VM Stack)中。堆栈帧是用于存储方法的局部变量、操作数栈、方法参数和返回值等信息的数据结构。每个线程在调用方法时都会创建一个对应的堆栈帧。

美团追魂七连问

关于 Object o =new Object()

  1. 请解释一下对象的创建过程(半初始化问题)
  2. 加问DCL要不要加volatile问题(指令重排)
  3. 对象在内存中的存储布局(对象与数组的存储不同)
  4. 对象头具体包括什么(markword classpointer synchronized锁信息)
  5. 对象怎么定位(直接、间接)
  6. 对象怎么分配(栈上-线程本地-Eden-Old)
  7. Object o =new Object()在内存中占用了多少字节
  8. 新问题:为什么hotspot不适用C++对象来代表java对象

3. 对象在内存中的存储布局(对象与数组的存储不同)

image.png

  • markword 占8个字节:
    • 锁信息,
    • hashcode(),
    • GC的信息:颜色,三色标记
  • classpointer类型指针占4个字节:指向该对象属于哪个类
  • instance data实例数据:成员变量
  • padding对齐:将对象的大小补齐到8的倍数

4. 对象头具体包括什么(markword classpointer synchronized锁信息)

  • markword 占8个字节:
    • 锁信息,
    • hashcode(),
    • GC的信息:颜色,三色标记

5. 对象怎么定位

image.png

image.png

  1. 直接定位:
    1. 优点
      1. 直接访问
    2. 缺点
      1. GC需要移动对象,效率低
  2. 句柄方式:
    1. 优点
      1. 对象更小
      2. 垃圾回收效率更高
    2. 缺点
      1. 定位需要两次访问

垃圾回收,分带或者分区模型,对象会从内存中一个位置移动到另一个位置

6. 对象怎么分配

jdk1.8 垃圾回收 分区模型
会分成年轻代和老年代
image.png
老年代是经历了很多次垃圾回收都没有回收掉的进入老年代。

  • 标记清除:标记垃圾的位置,清空垃圾所在位置的内存
    • 缺点:内存会产生碎片
  • 复制:内存分成两块,把活着的复制到另一块,把当前这块直接全部清除,就实现了垃圾回收。
    • 优点:当对象中需要保留的对象占极少数时,回收效率很高
    • 缺点:浪费空间
  • ⭐分取模型结合上述两种:
    • 新生代用于存储新建或者较新的对象,新建对象过程中最终只会保留极少数,因此在新生代使用复制的方法。新建对象存在伊甸区,如果经过一次垃圾回收,一个对象还活着,他会复制进入survivor1(survivor1很小,因为在新生代中幸存的对象总数较少),再经历一次垃圾回收,该对象如果还活着,他会复制到survivor2,然后清除伊甸区和survivor2,之后如果还活着,会复制到survivor1,然后清除伊甸区和survivor1。

是在一个栈数据结构中,较小256k

image.png
是否进栈:

  • 逃逸分析:是否被别的对象或者方法引用,如果被他在栈中下一层的对象引用,当前对象或方法弹出,会影响下一层的对象或方法
  • 标量替换:在 JIT 阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过 JIT 优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。

对象如果足够大,也不会进栈,会直接进老年代(JVM调优中会调整多大算大)

如果不大不小会进入伊甸园区,在这之前会进行线程本地分配缓存 thread local allocation buffer。如果好多个线程进入伊甸园区,会优先进入一个专门给自己分配的一个的空间,判断自己 的空间满了再进入公共空间。

1. 对象的创建过程

image.png
image.png
对象创建分3步

  1. new:是分配空间

dup:。。。。。。(复杂)

  1. **invokespecial: **调用构造方法
  2. astore_1:建立变量与对象空间的关联
  • C++:中new一个对象,成员变量m一开始他是遗留值,即上次使用该地址空间的值。
  • Java:new的过程中成员变量 首先 全部赋值为默认值,因此他更为安全

当new对象的过程中执行到invokespecial,成员变量m才从默认值0被赋值为8

思考:程序真的是按照“顺序”执行的吗

image.png

  • 由于CPU寄存器速度比内存快很多因此为提高执行效率,CPU指令可能会乱序执行。

单线程中程序什么时候可以乱序?能保持最终一致性。(即程序运行后的结果一样,左边不能保证最终一致性)

this溢出问题

image.png
对象构建了一半,当新建对象还没执行到invokespecial时线程启动调用this.num,num=0;而不是8。

2. 加问DCL要不要加volatile问题(指令重排)

要加,必须要加
volatile能禁止指令重排序

单例模式:
设计模式
Double Check Lock

//懒汉式单例
public class LazyMan{
    private LazyMan(){
        
    }
    
    private volatile static LazyMan lazyMan;
    
    //双重检测模式的懒汉式单例 ,即DCL懒汉式
    public static LazyMan getInstance(){

        // 这一层判断不能删除,因为如果删除该判断,
        // 当同时大量线程执行该方法,那么每个线程都要上完锁才能得到对象
        // 效率极低
        if(lazyMan==null){
            synchronized(LazyMan.class){
                if(lazyMan==null){
            		lazyMan=new LazyMan();//不是一个原子性操作
                    /**
                    1.分配内存空间
                    2.执行构造方法,初始化对象
                    3.把这个对象指向这个空间
                    
                    123
                    132 A
                    	B	此时lazyMan还没有完成构造
                    **/
            
        		}
            }
        }
        
        return lazyMan;
    }
    
    //多线程下不安全
    //多线程并发
    public static void main(String[] args){
        for(int i = 0; i < 10; i++){
            new Thread(()->{
                lazyMan.getInstance();
            }).start();
        }
    }
}

image.png

synchronized中会不会发生指令重排?
lazyMan都是指向同一个内存空间
为什么第二个线程能拿到中间过程的值?
因为第二个线程进来判断lazyMan==null是在synchronized外面的,此时第一个线程已经给instance赋值,判断为false,直接返回中间过程的值。

什么是JMM

为什么要引入JMM?

  • 在Java语言之前,C、C++等语言是直接使用物理硬件和操作系统的内存模型的,正因为这些语言直接和底层打交道,使得这些语言执行效率更高,但同时也带来了一些问题:由于不同平台上,软件和硬件都有一定差异(比如硬件厂商不同、操作系统不同),导致有可能同一个程序在一套平台上执行没问题,另一个平台上执行却得到不一样的结果,甚至报错。

  • Java语言试图定义一个Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,达到让Java程序在不同平台上都能达到一致的内存访问效果的目的,这就是Java内存模型的意义。

  • jvm内存模型

    • jvm根据是否为线程私有,将jvm内存模型分为:线程隔离的数据区线程共享的数据区
    • 线程隔离(线程私有)的数据区
      • 程序计数器
      • 虚拟机栈
      • 本地方法栈
    • 线程共享的数据区
      • 元空间(由于目前最常用的是JDK1.8,因此根据1.8进行探讨)
      • 直接内存
  • java内存模型

    • 设计Java内存模型的目的:定义程序中的变量的访问规则,即不同线程对于共享变量的存取应该遵守哪些规则。
    • 注意:JMM中的变量和JVM中的变量不同,JVM的变量包括局部变量、成员变量,方法参数等,但JMM中考虑的变量只考虑共享内存中的变量,也就是JVM中堆中的变量、元空间中的变量。
    • JMM的抽象出来的模型,虽然能够和JVM内存模型产生关联,但是建议单独进行学习。
    • 主内存:保存的是各个线程之间共享的变量。
    • 工作内存:要想读主内存的变量,首先得将主内存中的变量读取到工作内存中;要想修改主内存的变量,首先得先修改工作内存中的变量副本,然后再将变量副本写到主内存中。
    • 一句话概括工作内存和主内存的关系:线程无法直接对共享变量进行读取、修改操作,因此在线程中开辟了一块工作内存,存放共享变量的变量副本。

image.png

多线程的三大特性是什么?

  • 原子性
    • 原子性是指一系列操作组成一个集合,这个集合是不可分割的,要么同时执行,要么都不执行。
    • 比如MySQL数据库中的事务就具有原子性;基本数据类型的赋值也具有原子性(但i++操作不是原子操作)。
  • 有序性
    • 在单线程环境下,程序的执行是按照一定顺序执行的,无论何时何地执行,都能保证输出结果是一致的。在多线程环境下,在本线程内观察,所有操作是有序的;在其他线程看来,所有操作是无序的。
    • 造成上条的原因:在Java内存模型中,为了提高效率允许编译器和处理器对指令进行重排序,但指令重排序也要遵守一定规则,保证在单线程环境下,无论无何排序,最后执行结果都要是一致的,但是不保证多线程执行结果的一致性,因此多线程环境下,会造成有序性问题。
  • 可见性
    • 当一个线程修改了某个变量的值,其它线程总是能知道这个变量变化。
    • 例:对于共享变量A = 50,如果线程1对其修改成60,那么当其他线程读取A共享变量时,一定可以读取到60,而不是50。

深入探索JMM

https://zhuanlan.zhihu.com/p/423280102

JMM的内存间具体是如何进行交互的?

  • Java内存模型定义了8种操作来完成交互,如下图:

image.png

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。
  • Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
  • 如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
  • 不允许read和load、store和write操作之一单独出现。
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现。
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值。
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

JMM如何保证多线程环境下的线程同步问题呢?

  • 使用volatile关键字
  • 为什么volatile关键字不能保证线程的安全性?
    • 保证了有序性和可见性。
    • 不保证原子性。
    • 综上不能保证线程的安全性。
  • volatile关键字如何保证了线程的可见性的?
    • 复习一下JMM的内存间是如何进行交互的:如果要修改共享变量的值,首先要将修改后的值assign到工作内存的变量副本中,然后进行store、write操作到主内存中,至此一个完整的修改共享变量操作结束。
    • 对于普通变量来说,当进行了assign操作后,并不一定马上将assign后的变量副本的值刷新(store、write)到主内存中。
    • 对于volatile修饰的变量来说,当进行了assign操作后,会强制将assign后变量副本的值刷新到主内存中,这样就保证了多线程之间变量的可见性。
  • 如果不使用volatile关键字修饰:
    • 在Java中造成可见性问题的原因是Java内存模型(JMM),在Java内存模型中,规定了共享变量是存放在主内存中,然后每个线程都有自己的工作内存,而线程对共享变量的操作,必须先从主内存中读到工作内存中去,至于什么时候写回到主内存是不可预知的,这就导致每个线程之间对共享变量的操作是封闭的,其他线程不可见的。
    • 因此,当线程A修改了变量α,那么线程B可能一直无法获取到变量α的新值。

注意:Jvm虚拟机中有个锁优化原则之一就是“锁粗化”,何为锁粗化,但是如果一系列的连续操作都对同一个对象反 复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥 同步操作也会导致不必要的性能损耗。 所以使用sout这种自带锁的代码循环时会一起同步外面的循环。https://zhuanlan.zhihu.com/p/250657181

public class test1 {
    private static boolean flag = true;


    public static void main(String[] args) throws InterruptedException {
        new Thread(()-> {
            while(flag) {

            }
        }).start();

        sleep(1000);
        new Thread(() -> {

            flag = false;
            System.out.println("修改了共享变量flag的值");
        }).start();

    }
}

在这个程序中,线程1读取的flag一直是自己cpu缓存的flag,对于线程2的修改没有察觉,因此,没有到主内存中去取修改后的值。

  • volatile关键字为何不能保证原子性?
    • 曾经我非常疑惑,既然在保证可见性的时候,assign后的变量副本会马上进行store、write操作,那么为什么不能保证原子性呢?原因就出在“马上
    • 在Java语言中,只有对基本数据类型的赋值、读取操作保证原子性,如 a = 1;这个语句在JVM内部就是一个原子操作。
    • 而一次修改共享变量的操作有assign、store、write,因此有可能A线程进行assign操作之后,失去了CPU执行权,B线程此时获得CPU执行权,也进行assign操作,此时进行store、write操作之后,A再获得CPU执行权,再进行store、write操作,此时就会导致线程安全问题。具体举例如下:
      • int i = 1;
        两个线程都要对i变量进行+1操作
        线程安全的前提下,i值最终应该变为3
        但当A线程进行+1操作之后,assign 2到变量副本i后(此时主内存的变量i仍然为1),线程A失去了CPU执行权,轮到线程B执行+1操作,进行assign后,继续store、write到工作内存中(此时主内存中的i变为2),此时线程A获得CPU执行权,进行store、write操作,将2赋值给i,最终两个线程执行结束之后仍然为2,而不是3。
  • volatile关键字如何保证有序性的?
    • 使用volatile关键字后可以禁止指令重排序,保证有序性。
    • 什么是指令重排序?
    • 对于我们手写的每一句代码,JVM都不能理解和执行,.java文件要先通过编译成字节码文件(.class结尾),然后通过类加载器,加载到JVM中,JVM再转换成一串01机器码,无论什么语言运行,最后都会转化称为机器码,因为计算机只认机器码。
    • 按照推理,指令执行顺序应该是和我们编写的程序一一对应的,但实际上编译器、CPU、JVM会出于优化的目的,对指令的执行顺序进行调整,这就是指令的重排序。
  • 重排序带来的好处显而易见:提高了整个程序执行的效率,但是重排序带来了哪些问题,又要如何解决呢?
    - 重排序只能保证单线程环境下结果的一致性,但是在多线程环境下会导致结果的不一致性。
    - 使用volatile关键字对变量进行修饰,可以禁止指令的重排序。
    - 栗子:

吃透Java并发:volatile是怎么保证可见性的

CPU多核缓存架构

一个双核CPU架构可以如下图所示:
image.png
首先需要明确的一点是,计算机实际上是分为多级缓存的,因为读取缓存的数据性能十分快

  1. 当CPU1需要读取共享变量的值a时,首先会找缓存(即L1、L2、L3三级高速缓存),看看这个值是不是在L1。
  2. 很明显,缓存没办法给CPU1它想要的数据,于是只能去主内存读取共享变量的值
  3. 缓存得到共享变量的值之后,把数据交给寄存器,但是缓存留了个心眼,它把a的值存了起来,这样下次别的线程再需要a的值时,就不用再去主内存问了

至此,一次完整的数据访问流程走完了。L1和L2、L3都是高速缓存,从高速缓存和主内存读取数据的速度完全是两个概念。所以才会有主内存和缓存的设计。

写数据时刷新内存

针对上述模型,当CPU1读取完数据后,假如对数据进行了修改,那么它会将缓存 —> 主内存的顺序将修改后的数据刷新一遍,完成对数据的更新。
从读到写这一整个流程看起来似乎都是完美的,而且每次修改都把数据重新写回到主内存,讲道理不会有问题啊?
实际上问题正是出在这个看似完美的读写操作中:对于CPU1来说的确是完美的,但如果这时候CPU2来插一脚呢?我们思考下面这个流程:

  1. CPU1读取数据a=1,CPU1的缓存中都有数据a的副本
  2. CPU2也执行读取操作,同样CPU2也有数据a=1的副本
  3. CPU1修改数据a=2,同时CPU1的缓存以及主内存a=2
  4. CPU2再次读取a,但是CPU2在缓存中命中数据,此时a=1

问题到这里已经很明显了,CPU2并不知道CPU1改变了共享变量的值,因此造成了不可见问题。

缓存一致性协议

为了解决这个问题,在早期的CPU当中,是通过在总线上直接加锁的形式来解决缓存不一致的问题。
但是正如Java中Synchronized一样,直接加锁太粗暴了,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。很明显这样做是不可取的。
所以就出现了缓存一致性协议。缓存一致性协议有MSI,MESI,MOSI,Synapse,Firefly及DragonProtocol等等。

MESI协议

最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。

  1. Modify(修改):当缓存行中的数据被修改时,该缓存行置为M状态
  2. Exclusive(独占):当只有一个缓存行使用某个数据时,置为E状态
  3. Shared(共享):当其他CPU中也读取某数据到缓存行时,所有持有该数据的缓存行置为S状态
  4. Invalid(无效):当某个缓存行数据修改时,其他持有该数据的缓存行置为I状态

它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
而这其中,监听和通知又基于总线嗅探机制来完成。

总线嗅探机制

嗅探机制其实就是一个监听器,回到我们刚才的流程,如果是加入MESI缓存一致性协议和总线嗅探机制之后:

  1. CPU1读取数据a=1,CPU1的缓存中都有数据a的副本,该缓存行置为(E)状态
  2. CPU2也执行读取操作,同样CPU2也有数据a=1的副本,此时总线嗅探到CPU1也有该数据,则CPU1、CPU2两个缓存行都置为(S)状态
  3. CPU1修改数据a=2,CPU1的缓存以及主内存a=2,同时CPU1的缓存行置为(S)状态,总线发出通知,CPU2的缓存行置为(I)状态
  4. CPU2再次读取a,虽然CPU2在缓存中命中数据a=1,但是发现状态为(I),因此直接丢弃该数据,去主内存获取最新数据

当我们使用volatile关键字修饰某个变量之后,就相当于告诉CPU:我这个变量需要使用MESI和总线嗅探机制处理。从而也就保证了可见性。

指令重排序

在加入MESI和总线嗅探机制后,当CPU2发现当前缓存行数据无效时,会丢弃该数据,并前往主内存获取最新数据。
但是这里又会产生一个问题:CPU1把数据刷回主内存是需要时间的,假如CPU2在主内存拿数据时,CPU1还没有把数据刷回来呢?
很明显,CPU2不会把资源浪费在这里傻等。它会先跳过和该数据有关的语句,继续处理后面的逻辑。
比如说如下代码:

a = 1;
b = 2;
b++;

假如第一条语句需要等待CPU1数据刷新,那么CPU2可能就会先回来执行后面两条语句。因为对于CPU2来说,先执行后面两条语句不会对最终结果造成任何影响。
但是多线程环境下就会出现问题。关于指令重排序,我们放到内存屏障来讲。

一些可能让你困惑的问题

依旧是一开始的代码,假如我们把TI线程循环的内容改成如下:

Thread t1 = new Thread(new Runnable() {
    @Override
    public void run() {
        while (a == 0) {
            System.out.println(a);
        }
        System.out.println("T1得知a = 1");
    }
});

或者如下:

Thread t1 = new Thread(new Runnable() {
    @Override
    public void run() {
        while (a == 0) {
            try {
                Thread.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("T1得知a = 1");
    }
});

此时变量a没有使用volatile修饰。
但是运行结果会让你匪夷所思:程序正常结束,a变量对T1居然可见了!

while在作怪?

这是为什么呢?难道是因为在while循环中加了代码导致的?
那我们加个变量b再来试试:

Thread t1 = new Thread(new Runnable() {
    @Override
    public void run() {
        while (a == 0) {
            b++;
        }
        System.out.println("T1得知a = 1");
    }
});

这次运行结果T1又没办法感知a的变化了,也就是说,并不是while中有代码就会发生可见的现象。
那么真正的原因究竟是什么呢?

勤奋的CPU

这是一个很有趣的现象,有些人认为是因为println方法加了synchronized的原因。的确,锁机制保证了每次执行都会把共享内存中的数据同步到工作内存中。
但Thread.sleep方法并没有加呀?
真正的原因在于,CPU是很勤奋的,如果它发现自己有空闲的时间,就会主动去主内存里更新自己缓存中的数据。
而Thread.sleep方法对于CPU来说,会给它“喘息”的时间,让它有空去把缓存里的数据去主内存刷新一下。
而后面的b++操作几乎没有给CPU任何机会休息,也就没办法去刷新缓存中的数据信息。

对比原子性和可见性

原子性代码

volatile int i=1;

// 线程1
i=i+1;

// 线程2
i=i+1;

// 结果
i=2   //或者i=3 

加了 volatile修饰 ,线程2也有可能在线程1只assign之后就从缓存中获取值了。

可见性代码

boolean flag=0

// 线程1
while(flag==0){
}

// 线程2
flag=1

// 线程1 start ,sleep(1000),线程2 start




flag

  • 没加 volatile时由于线程1只会从缓存读旧数据,因此一直是在循环中
  • 加了volatile,线程2会根据缓存一致性协议以及总线嗅探机制告诉线程1缓存失效,while循环过程中从主存获取flag的新值

如果是在单核cpu中就会立马跳出循环

总结

事实上,我们的JMM模型就是类比CPU多核缓存架构的,它的作用是屏蔽掉了底层不同计算机的区别
JMM不是真实存在的,只是一个抽象的概念。volatile也是借助MESI缓存一致性协议和总线嗅探机制才得以完成
此外,当CPU不支持缓存一致性协议时,还是需要依靠总线加锁的形式来保证线程安全
本文到这里就结束了,感谢大家看到最后,记得点赞加关注哦,如有不对之处还请多多指正。

Volatile缓存可见性实现原理

底层实现通过,它会锁定这块内存区域的缓存(缓存行锁定)
IA-32和Intel 64架构软件开发者手册对lock指令的解释:
1) 会将当前处理器缓存行的数据立即写回到系统内存。
2) 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效(MESI协议)。
3)提供内存屏障功能,使lock前后指令不能重排序。

写屏障就是在写操作之后加入写屏障,CPU 就会等待所有的写操作刷入对面的失效队列
存屏障就是在读之前加入读屏障,CPU 就需要把失效队列里所有消息都消费完再去读

. Java程序汇编代码查看
-server -Xcomp -XX:+UnlockDiagnosticVMOptions.-XX:+PrintAssembly -XX:CompileCommand=compileonly,VolatileVisibilityTest.prepareData
图片.png
***