java并发编程--内存模型

Posted dxj1016

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了java并发编程--内存模型相关的知识,希望对你有一定的参考价值。

1、内存模型

1.1、高速缓存

  • 程序运行过程中,将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行运算就可以直接从高速缓存中读取数据和写入数据,当运算结束之后将高速缓存中的数据刷新到主存中。
  • 例如i=i+1;执行的时候,从主存获取i的值并复制一份到高速缓存中,i+1操作结束后将高速缓存中的i的值刷新到主存中。
  • 如果多线程下执行i=i+1一开始i=0;线程1和线程2执行的时候从主存中获取到的i=0,而后,做了+1操作后,线程1和线程2中的高速缓存中的i变为了1,如果线程1结束后将结果刷新到主存,后来线程2也刷新到主存,那么i变为了1,但是正常情况下,两个线程对i做了+1操作,那么i最后应该变为2。这就是缓存不一致问题
  • 缓存一致性问题:多个线程访问的变量为共享变量

解决缓存不一致问题:

  • 通过总线加lock的方法解决
  • 通过缓存一致性协议解决

这两个都是从硬件层面上提供的方式
在这里插入图片描述

1.2、通过总线加lock(锁)的方式

  1. 过程:线程1执行程序的时候,对总线加锁,总线就当成是程序执行过程中的一条线吧,那么其他线程就被阻塞了,线程1可以读取变量,然后对他进行操作,操作结束刷新缓存到主存中,然后释放锁,其他线程才可以执行,这时候主存中的变量就是最新的值,就解决了缓存不一致的问题。
  2. 对整个内存进行加锁,由于在锁住总线期间,其他CPU无法访问内存,使其他线程无法正常执行,cpu性能严重下降

1.3、缓存一致性协议

cpu执行计算的主要流程:
在这里插入图片描述
数据加载流程:

  1. 程序中的数据从硬盘加载到内存
  2. 程序执行的时候从内存中复制一份到高速缓存
  3. cpu将缓存中的数据加载到寄存器进行运算操作
  4. 操作结束将数据放回到缓存,缓存中的数据刷新到内存中

缓存一致性协议

  • 核心思想:对单个缓存行(缓存行是缓存中数据存储的基本单元)的数据进行加锁,不影响内存中其它数据的读写。
  • 主要协议有:MSI,MESI,Synapse,Firefly及DragonProtocol等等,主要使用MESI协议

MESI分别代表缓存行数据所处的四种状态,通过对这四种状态的切换,来达到对缓存数据进行管理的目的。

MESI协议的核心思想:
保证每个线程使用到的共享变量的副本都是一致的,核心思想是:cpu写数据的时候,发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置位无效,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存的变量的缓存行是无效的,那么他就会从内存中重新读取

MESI协议四种状态
在这里插入图片描述

1.4、MESI的执行原理

1、CPU1从内存中将变量a加载到缓存中,并将变量a的状态改为E(独享),并通过总线嗅探机制对内存中变量a的操作进行嗅探
在这里插入图片描述
2、此时,CPU2读取变量a,总线嗅探机制会将CPU1中的变量a的状态置为S(共享),并将变量a加载到CPU2的缓存中,状态为S
在这里插入图片描述
3、CPU1对变量a进行修改操作,此时CPU1中的变量a会被置为M(修改)状态,而CPU2中的变量a会被通知,改为I(无效)状态,此时CPU2中的变量a做的任何修改都不会被写回内存中(高并发情况下可能出现两个CPU同时修改变量a,并同时向总线发出将各自的缓存行更改为M状态的情况,此时总线会采用相应的裁决机制进行裁决,将其中一个置为M状态,另一个置为I状态,且I状态的缓存行修改无效)
在这里插入图片描述
4、CPU1将修改后的数据写回内存,并将变量a置为E(独占)状态
在这里插入图片描述
5、此时,CPU2通过总线嗅探机制得知变量a已被修改,会重新去内存中加载变量a,同时CPU1和CPU2中的变量a都改为S状态
在这里插入图片描述
在上述过程第3步中,CPU2的变量a被置为I(无效)状态后,只是保证变量a的修改不会被写回内存,但CPU2有可能会在CPU1将变量a置为E(独占)状态之前重新读取内存中的变量a,这个取决于汇编指令是否要求CPU2重新加载内存。

总结
以上就是MESI的执行原理,MESI协议只能保证并发编程中的可见性,并未解决原子性和有序性的问题,所以只靠MESI协议是无法完全解决多线程中的所有问题。

2、并发编程中的三个基本概念

在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。

2.1、原子性

  • 原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

  • 原子性是拒绝多线程操作的,无论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对他进行操作。简而言之,在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。例如a=1是原子性操作,但是a++和a+=1就 不是原子性操作,java中的原子性包括

    • 基本类型的读取和赋值操作,且复制必须是赋值给变量,变量之间的相互赋值不是原子性操作
    • 所有引用reference的赋值操作
    • java.concurrent.Atomic.*包中所有类的一些操作
  • 一个很经典的例子就是银行账户转账问题:比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。

  • 试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。

  • 所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。

  • 同样地反映到并发编程中会出现什么结果呢?

  • 举个最简单的例子,大家想一下假如为一个32位的变量赋值过程不具备原子性的话,会发生什么后果?

  • i = 9;假若一个线程执行到这个语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。

  • 那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。

2.2、可见性

  • 可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  • 在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。Java提供了volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。当然,synchronize和Lock都可以保证可见性。synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
  • 举个简单的例子,看下面这段代码:
//线程1执行的代码
int i = 0;
i = 10;

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

2.3、有序性

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

  • Java内存模型中的有序性可以总结为:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内表现为串行语义”,后半句是指“指令重排序”现象和“工作内存主主内存同步延迟”现象。

  • 在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序不会影响单线程的运行结果,但是对多线程会有影响。Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。另外,可以通过synchronized和Lock来保证有序性,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

  • 举个简单的例子,看下面这段代码:

int i = 0;               
boolean flag = false;
i = 1;                //语句1   
flag = true;          //语句2
  • 上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。

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

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

  • 但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:

int a = 10;    //语句1
int r = 2;    //语句2
a = a + 3;    //语句3
r = a*a;	 //语句4

这段代码有4个语句,那么可能的一个执行顺序是:
在这里插入图片描述那么可不可能是这个执行顺序呢: 语句2 语句1 语句4 语句3
不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。

  • 虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:
//线程1:
context = loadContext();   //语句1
inited = true;             //语句2

//线程2:
while(!inited ){
  sleep() 
}
doSomethingwithconfig(context)

上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。
从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。

3、java内存模型提供了哪些保证

3.1、原子性

x = 10;         //语句1
y = x;         //语句2
x++;           //语句3
x = x + 1;     //语句4
  • 语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
  • 语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
  • 同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。
  • 所以上面4个语句只有语句1的操作具备原子性。
  • 就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
  • 不过这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的JDK中,JVM已经保证对64位数据的读取和赋值也是原子性操作了。
  • 从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。

3.2、可见性

  • 对于可见性,Java提供了volatile关键字来保证可见性。
  • 当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
  • 而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
  • 另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

3.3、有序性

  • 在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
  • 在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理在下一节讲述)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
  • 另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

2.4、happens-before

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。(一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。)
  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作(同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。)
  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作(如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。)
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C(体现happens-before原则具备传递性。)
  5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
  9. 这8条原则摘自《深入理解Java虚拟机》。

参考资料1
参考资料2

以上是关于java并发编程--内存模型的主要内容,如果未能解决你的问题,请参考以下文章

并发编程之java内存模型

java并发编程12.java内存模型

Java并发编程Java内存模型

3.java并发编程艺术-java内存模型

Java并发编程:Java内存模型

转:Java并发编程之十六:深入Java内存模型——happen-before规则及其对DCL的分析(含代码)