【面经——Java内存模型(JMM)】讲故事一样讲述JMM

对于java内存模型,我们需要了解的无非是几个问题:什么是java内存模型?为什么要有java内存模型?该怎么使用这个java内存模型?使用的时候它又会产生什么问题?能否解决,该如何解决呢?
下面,我将从它的出现的原因开始讲述上面几个问题。

为什么要有java内存模型及java内存模型是什么?

java内存模型,首先它是一个模型,并不是真是存在的。它从java的层面定义了主存,工作内存的抽象概念,而它的底层对应着cpu寄存器、缓存、硬件内存、cpu指令优化等;
下图就是java内存模型,可以看到,里面有java层面的内存、缓存、线程和计算机层面的硬件内存、缓存、总线、cpu等;
在这里插入图片描述其实jmm的存在,它就是为了使得程序员面对一个更简单的内存模型,而不至于去面对那些非常复杂的物理控件去编程,它方便了我们程序员的开发工作。

该怎么去使用这个模型呢?它会产生什么样的问题?

对于它的使用,无非就是我们在编程过程中编写过的给定一个变量或者申请一个空间,然后在方法中对这个变量或者成员进行操作。
那这样操作为什么就等于使用了java内存模型了呢?有这个问题的同学,可能是你java虚拟机掌握的不是太好,我大致来讲一下(了解jvm的同学可以掠过):我们在类中定义的全局变量(如:public int a = 10;)都是在方法区存着,定义的对象(如:public String one = new String();)到目前为止都是在java堆上存着,而类中的每个方法会对应着该线程的虚拟机栈中的一个栈帧,在一个方法中想要操作一个成员,那它对应的栈帧中肯定有一个指针来指向这个成员。所以说,我们在方法中对于成员的操作事实上就是采用了java的内存模型,因为虚拟机栈属于线程私有的,也就是每个线程都会有一个独一无二的虚拟机栈。所以每个方法都会有一个线程来“装它”。而方法区和堆都属于存储空间,共同叫做主内存。
起初,java内存模型是没有缓存这么一说的,他只有工作内存和主内存,如下图:
在这里插入图片描述每次,工作内存要从主内存中获取数据,然后在cpu中进行操作,但是我们都知道,cpu的速度是非常快的,而在内存中读取数据相对于cpu来说是非常慢的,那么假如cpu操作完数据后,读取的新数据还没有读取完成,那就会造成cpu进行忙等待,等待读取成功才能继续运行,这是非常浪费cpu的!于是,就出现了我们的高速缓存,
在这里插入图片描述高速缓存的读取速度远比读取主内存的速度快,于是,计算机每次先把数据读取到高速缓存中,然后cpu直接去高速缓存中读,这样会提高cpu的效率。 也正是由于高速缓存的存在,出现了所谓的可见性问题。

JMM中主要存在三个问题,原子性,有序性,可见性。

下面来说一下可见性问题!

因为高速缓存的存在,我们每次读取都是从高速缓存中读取。假如,现在有两个线程,分别要对主内存中的int a = 0;成员进行 ++ 和 - - 操作,
在这里插入图片描述按理说,进行完++和–操作,a的值应该还为0,但是,由于线程都是从高速缓存中读取,每个高速缓存中的a都是0,对a的++和–操作都是对各自缓存中的a,然后有可能两个线程都往回写数据的时候,数据发生了覆盖,最终的结果就不是0,这就是可见性问题。 它的根源就是因为两次读取的数据是不一致的。
volatile关键字可以保证可见性问题,也就是,每次读都是从主内存中读,而不是缓存中读,那么每次读取的数据就是主内存中的数据,这样一来,虽然解决了读取数据不一致的问题,但是,因为每次都需要从主内存中读数据,那就会使得cpu有可能进行忙等待,所以,对于volatile关键字的使用应该尽可能少用。但是,这就没有问题了么?volatile关键字可以保证可见性,但是不能保证原子性,我们用几个线程对主内存中的数据进行读操作,肯定是没有问题的,但是,如果几个线程同时要对主内存的数据进行写操作,那还是会出问题,原因就是volatile不保证原子性。 这就是原子性问题。

下面来说一下原子性问题!

举个例子,还是刚才的两个线程分别进行a++和a- -操作,这次虽然读取数据要从主内存中读取,但是,当线程一执行完a++操作后,把结果往主内存写之前,线程二读取主内存中的a=0,对a进行- -,这样一来,当两个线程都把各自的结果同步到主内存中的时候,就会发生覆盖,而最终的结果肯定不是0。所以想要保证主内存中最后的结果的正确性,要与synchronized锁来配合使用,保证操作的原子性。

对于volatile解决可见性的本质,可以从三个方面来讲述:
1.软件层面:避免从缓存中读取数据,而是每次都从主内存中读取,但是,volatile使用的太过频繁的话,会降低cpu的效率(原因前面已经叙述了)
2.JVM层面:java虚拟机通过六个指令来读取数据(read load assign use store write),现在把读取和写入改为从主内存中读取和写入,从而保证可见性问题。
在这里插入图片描述3.硬件层面:通过cpu总线嗅探机制和MESI缓存一致性协议来保证可见性。我们都知道,数据是通过总线进行传输的,每个cpu都有一个总线嗅探机制,专门用来嗅探总线中的数据,当察觉到总线中的数据发生改变时,比如原来a=0,进行++操作后a=1,总线嗅探机制感知到后,立马把当前线程的a的值设为无效,然后再去主内存中获取最新的值,继续回到cpu重新进行操作。

对于解决原子性的本质,从两个方面来讲述:
1.软件层面,对操作进行加锁,保证每次只有一个线程来访问该变量;
2.JVM层面,通过在六个操作执行前后进行上锁,保证原子性。只有锁解开,才能进行读取操作。
在这里插入图片描述

下面来讲一下有序性问题:

在java中,存在JTI(即时编译器),它会自动将指令进行重排序, 因为指令重排序,所以会产生有序性问题。 那为什么要进行指令重排序呢?
先来看一下指令重排序的定义:重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。
上述定义太过官方了。我的理解是,指令不会按照我们编写代码的顺序去执行,而是重新进行个排序,按照重新排序的顺序执行。举个例子:
在这里插入图片描述上述四条语句,在ASCII码层面,我们的直观感受是按照我们编写的顺序,先给a赋值,再给b赋值。但是,因为指令重排序的存在,他有可能先给b赋值,再给a赋值。(当然,肯定不会先给b赋值,然后再定义b成员,必须得先有b成员后,才能给b成员赋值)。
**为什么要指令重排序呢?**我从汇编的层面来解释一下。
假如我要执行一段代码,他们的逻辑编号为:IF ID EX MEM WB(也就是IF ID EX MEM WB这五个指令分别代表了一条语句)。那么现在假如对这段代码重复执行四次,不进行指令重排序的话,也就是顺序执行四遍,表示如下:
在这里插入图片描述如果进行指令重排序(这里展示最优的指令重排序),
在这里插入图片描述可以看到,不进行指令重排序的话,需要执行20个时间片段,但是进行了指令重排序的话,只需要8个时间片就够了,指令重排序可以大大提高效率。(学过一点汇编知识的同学应该能很容易理解)

再在jvm中,JTI为了提高代码的执行效率,会自动将指令进行重排序。但是,某些情况下,指令重排序会产生问题,举例如下:
在这里插入图片描述上面这个例子,是一个One类的one成员初始化过程,当我们调用newInstance()的时候,可以对one成员进行初始化。对于one = new One();这条语句,按正常逻辑来讲,应该会涉及到在java堆申请空间,会执行方法区的无参构造,执行完无参构造后再将申请到的空间的地址值赋值给one。但是,假如没有禁止指令重排序的话,有可能无参构造执行了一半或者无参构造还没有执行,就进行one的赋值,因为无参构造里面的代码和申请地址再将地址赋值回去,这些在汇编层面都会涉及到很多的指令,因为指令重排序的存在,有可能构造方法执行一半就将地址赋值指令执行了(也就是此时的one已经有值了)。此时,假如有另外一个线程也执行了该方法,直接判断one==null不成立,直接return one,那此时的one已经有值(指向了一个地址),但是它的无参构造方法还没有执行完毕,这是错误的。
这就是指令重排序带来的问题。所以,有些情况下,我们需要避免指令重排序,那volatile关键字就可以避免指令重排序。

它是怎么避免指令重排序的呢?(对于有序性问题的解决,本质上是通过内存屏障)
volatile通过内存屏障来避免指令重排序。内存屏障分为:读屏障和写屏障。
读屏障:在该屏障之后对共享变量的读取、加载的是主存中最新的数据。
写屏障:在该屏障之前对共享变量的改动都同步到主内存当中。
我的理解是,读屏障之后,写屏障之前,所有的涉及到的volatile修饰的成员的操作都是按照原有顺序执行的。
对于刚才所提到的例子,在one成员前加入volatile关键字:
在这里插入图片描述它的执行流程是,当执行到one = new One();这条语句的时候,因为这条语句是一条写操作,if(one == null)是读操作,在他们之间的语句,也就是new One(),都是保证顺序执行的,同一个时间片,只能执行一条语句。那么,就不会出现构造方法还未执行,就直接把地址赋值给one的现象。这就是volatile可以避免指令重排序的原因。

到此,我已经解决了我开头就写到的几个问题。
总结一下:
JMM内存模型主要解决了三个问题:原子性,可见性,有序性。因为高速缓存的存在,所以会产生原子性和可见性的问题,主要还是可见性的问题。因为JTI的指令重排序,所以会产生有序性的问题。
一般而言,只有涉及到多线程情况下,才会出现上面的问题。而volatile和synchronized也是我们解决多线程并发问题的关键。我觉得我们不应该是去死记硬背,应该先分析要解决的场合,看看是否真正的有并发问题,在考虑是否需要解决。比如,定义一个成员,有多个线程去“读”该成员,只有一个线程去修改该成员,那,肯定要对该成员加volatile关键字来保证每次读到的都是最新的值,但是,对于读操作和写操作,就没有必要去加锁,因为根本不会涉及到写的并发问题。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值