Java内存模型分析

内存模型的概念:

在讲内存模型前,我们来谈谈硬件的效率与一致性的问题

计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。

由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,

而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,

会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。

也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,

那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,

再将高速缓存中的数据刷新到主存当中。举个简单的例子,比如下面的这段代码:

int i = 1 ;
i = i + 1;

执行这句代码的线程首先会去物理主存中把 i 的值读取到,然后复制到CPU的高速缓存中,然后CPU在运算的时候就可以直接从高速缓存中

拿到值进行快速的计算,算完之后CPU再把结果丢到高速缓存中去,然后再刷新到主存中;

  这样的代码在单线程的情况下运行是完成没有问题的,但是在多线程的情况下会怎么样呢?

  可能会有这样的结果:第一个线程先从主存中读到了 i 的值,然后进行了+1的操作使 i = 2了, 此时它还没有将结果返回到主存中去,但是,第二个线程来  了,第二个线程读到的 i 的值是1 ,然后它在进行 + 1 ,结果为2 然后把结果返回给了主存, 在主存中,两个线程都问主存要了 i 的值,但是他们给的结果  都是2;按道理 i 应该是3 的; 这就是缓存不一致的问题了;

为了解决这个问题, 需要各个CPU处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作;出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

在多线程编程的环境下,我们常常遇到以下三个问题:原子性,可见性,有序性;

1、原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。 关于原子性有一个很经典的例子就是银行账户转账问题: 比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。

如果原子性没有得到保证,A的账号上扣了钱,但是B的账户却没有加钱,这两个人是不是都去银行找麻烦去了?

举一个多线程的例子:给一个32位的变量进行赋值,如果原子性没有得到保证会发生什么情况?

i = 1 ;

假若一个线程执行到这个语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。 那么就可能发生一种情况:当将低16位数值写入之后,此时又有一个线程去读取i的值,那么读取到的就是错误的数据。

2、可见性:可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修 改的值。

    如果不能保证可见性,我们来看看会发生什么问题:

//线程一执行的代码
int i = 1;
i = 100;

//线程二执行的代码
int j = i;

假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =100这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为100,那么在CPU1的高速缓存当中i的值变为100了,却没有立即写入到主存当中。此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是100.这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。

3、有序性:即程序执行的顺序按照代码的先后顺序执行。

我们来看下面这个例子:

int i = 0;
boolean  flag = false;
int i = 10; //语句1
flag = true;  //语句2

这段代码先定义了两个变量,然后语句1和语句2 给这两个变量进行赋值;代码语句1在语句2前面,那语句1一定在语句2前面执行吗?答案是:不一定。 这里可能会发生指令冲排序;

      指令重排序:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后       顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。

显然单线程的情况下,不会出现问题,多线程的情况下,问题又来了;看例子:

//线程1
resourse =  config.load(); //语句1:假设是加载一个文件完成初始化;
boolean flag = true;//语句2 

//线程2
if(flag=false){
    Thread.sleep(1000);
}
add();

语句1跟语句2没有数据之间的依赖性,可能就会发生重排序;我们希望的是先加载完了再改变 变量 flag,结果可能是语句2先执行,语句1后执行,

这就可能发生文件还没加载完,后面的代码就执行了,逻辑没问题的话,发生异常是肯定的事。

从上面的解释来看:指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。 也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

2、Java内存模型;

在前面谈到了一些关于内存模型以及并发编程中可能会出现的一些问题。下面我们来看一下Java内存模型,

Java虚拟机规范中视图定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致

的内存访问效果,再次之前,主流程序语言(如C/C++)直接使用物理硬件和操作系统的内存模型,因此,会有雨不同平台上的内存模型的差异,有可能导致程序在一套平台上并发完成正常,而在另外一套平台上并发访问却经常出错,因此在某些场景就必须针对不同平台来编写程序;

      定义Java内存模型并非一件容易的事情,这个模型必须定义得足够严谨,才能让Java的并发内存访问操作不会产生歧义,但是,也必须定义得粗狗宽松,使得虚拟机的实现有足够的自由空间去利用硬件的各种特性来获取更好的执行速度,经过长时间的验证和修补,在JDK1.5发布后,Java内存模型已经成熟和完成起来了。

----摘自 深入理解Java虚拟机;

 Java内存模型规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。并且每个线程不能访问其他线程的工作内存。

我们来看看Java的内存模型是怎样对 原子性、可见性以及有序性提供保证的?

1、原子性:

Java规定基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。

提个问题,下面四个操作哪些是原子性的操作?

int a = 1;//语句1
int j = a;//语句2
i++;//语句3
int b = a + 1;//语句4

语句1:Java规定基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,:可得 语句1肯定是原子操作;

语句2:首先线程先得到a的值这是原子操作,然后再把a的值赋值给j,这也是原子操作,但是两个原子操作加起来就不是原子操作了;

语句3:先得到 i 的值,然后进行 ++ 操作 ,然后再将结果返回到主存,显然不是原子操作了;

语句4:同上,肯定不是原子操作;

可以看出,Java只对于基本数据类型的变量的读取和赋值操作是原子性操作,其他情况都不是原子操作了。要想保持其他行为是原子操作提供了两个方法,一个是使用synchronized关键字和使用Lock锁来实现;

2、可见性:

Java提供了volatile关键字、synchronized关键字和Lock锁来保证可见性。 volatile:当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。synchronized关键字和Lock锁:能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中,因此也能保证可见性;

3、有序性:

在Java提供了volatile关键字、synchronized关键字和Lock锁来保证有序性。原理同上;

如果看完了有所收获,欢迎帮忙点下顶,告诉我我写的东西还是有用的。谢谢

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券