Java并发编程学习二:Java线程的带来的问题与内存模型(JMM)

上篇文章中介绍了线程的概念以及使用,经过线程,咱们可以提升CPU的利用率以及项目的执行效率,可是使用线程有个很是大的问题,就是如何正确同步数据的问题。在介绍线程的问题以前,这里首先对计算机内存模型作个介绍,理解了这个,才能理解线程的问题。java


计算机内存模型

计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程当中,确定涉及到数据的读取和写入。CPU从主存中读取数据,操做后再往主存中存取数据,每次执行都涉及的I/O操做,而因为CPU的执行速度跟主存的读取速度不是在一个量级的上,若是直接主存上进行读/写数据,因为CPU执行速度更快,就会致使CPU须要进行等待主存执行后再进行,这样就会减缓计算机的处理速度。为了处理这种状况,计算机在CPU与主存之间,增长了高速缓存的概念,《深刻了解计算机系统》一书中给出了一个存储器层次结构的示例:c++

这里咱们能够简单理解为CPU-高速缓存-主存三个大层次的概念,当程序在运行过程当中,CPU会将运算须要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就能够直接从它的高速缓存读取数据和向其中写入数据,当运算结束以后,再将高速缓存中的数据刷新到主存当中。程序员

经过这种方式可以大大增长计算机的响应速度,可是也会引入一个问题,这个问题咱们在平常开发中就会遇到,缓存的不一致性问题。缓存一致性的问题在单线程中是不存在的,可是考虑到多线程的状况,在多核CPU中,每条线程可能运行于不一样的CPU中,所以每一个线程运行时有本身的高速缓存,那么在数据操做完毕后将数据返回主存的时候,该按照谁的缓存来存储呢?如何去保证**缓存一致性**,针对该问题,计算机提供了两种方式来解决:编程

  1. 经过在总线加LOCK#锁的方式数组

  2. 经过缓存一致性协议缓存

这2种方式都是硬件层面上提供的方式。这种两种方式介绍以下:安全

摘抄自百度百科多线程

在早期的CPU当中,是经过在总线上加LOCK#锁的形式来解决缓存不一致的问题。由于CPU和其余部件进行通讯都是经过总线来进行的,若是对总线加LOCK#锁的话,也就是说阻塞了其余CPU对其余部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。在总线上发出了LCOK#锁的信号,那么只有等待这段代码彻底执行完毕以后,其余CPU才能从其内存读取变量,而后进行相应的操做。这样就解决了缓存不一致的问题。并发

可是因为在锁住总线期间,其余CPU没法访问内存,会致使效率低下。所以出现了第二种解决方案,经过缓存一致性协议来解决缓存一致性问题。最出名的就是Intel 的MESI协议,MESI协议保证了每一个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,若是发现操做的变量是共享变量,即在其余CPU中也存在该变量的副本,会发出信号通知其余CPU将该变量的缓存行置为无效状态,所以当其余CPU须要读取这个变量时,发现本身缓存中缓存该变量的缓存行是无效的,那么它就会从内存从新读取。app

画个总结图以下:


重排序

除了上面介绍的计算机内存模型,这里也介绍一下重排序。**为了让CPU的内部运算单元可以尽可能的被充分利用,CPU会对输入的代码进行乱序执行(Out-Of-Order)优化,CPU在计算以后会将乱序执行的结果重组,保证与顺序执行的结果一致,可是不能保证程序各个语句的前后计算顺序与代码输入顺序一致。**所以,若是存在一个计算任务依赖另外一个计算任务的中间结果,那么顺序性不能依靠代码的前后顺序来保证。


Java中的重排序

在Java中对于指令重排序发生的过程以下:

能够看到,从咱们写的代码变成计算机可执行的指令的过程当中进行了一系列的指令重排序的工做,指令重排序的意义在于:JVM能根据处理器的特性,充分利用多级缓存,多核等进行适当的指令重排序,使程序在保证业务运行的同时,充分利用CPU的执行特色,最大的发挥机器的性能。那么何时容许进行重排序呢?

当指令执行之间没有数据依赖的时候,能够容许JVM进行重排序。JVM中明确规定了为了保证程序的执行结果不会改变,须要遵循as-if-serial语义,即编译器和处理器不会对存在数据依赖关系的操做作重排序,由于这种重排序会改变执行结果。

as-if-serial语义的意思指:无论怎么重排序(编译器和处理器为了提升并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵照as-if-serial语义。

上述说到了数据依赖这个概念,简单而言就是当下一个指令的操做结果依赖于上面执行过的某一个指令时,这时候两个指令就产生了数据依赖,宏观的从代码层面上表述以下:

a++;
b=getCacheNum(a);
c++;

代码中b的值依赖于a的值,因此b=getCacheNum(a)一系列指令必须等到a++操做完成后才能进行,而c++这句代码并无须要依赖于b或者a,进行,那么c++这句代码执行的指令是容许进行重排序的。数据依赖发生的条件以下:

针对单线程模型而言,即便JVM进行了指令重排序的优化工做,最后的输出结果应该跟顺序执行代码的结果一致,而针对不一样处理器之间和不一样线程之间,数据依赖性的问题不被编译器和处理器考虑的。在多线程环境下,指令重排序的优化可能会对程序的执行形成不可预期的影响,简单看个Demo:

public class Test{
	boolean over;
	int i=0;
	int a;
	
	public void testA(){
		a=2;
		over=true;
	}
	
	public void testB(){
		if(over){
			i=a*2;
		}
		
	}


}

这里testA()方法在线程A中调用,testB()在线程B中调用,因为testA()中不涉及数据依赖的调用,因此容许指令重排序,因此testA()可能执行的实际顺序以下:

public void testA(){
		
		over=true;
		a=2;
	}

那么在testB()中因为over已经为true了,可是a的值还没被设置成2,因此获得的结果并非咱们预期的结果。testB()这里还涉及一个控制依赖的问题,有兴趣的能够看这篇文章

那么,因为指令重排序的引出的多线程中的问题,咱们须要怎么解决呢?解决方案就是内存屏障的概念了。

内存屏障

内存屏障能够禁止特定类型处理器的重排序,从而让程序按咱们预想的流程去执行,经过阻止屏障两边的指令重排序来避免编译器和硬件的不正确优化而提出的一种解决办法。在Java层上的表现Volatile,Sychronized关键字,在硬件层上因为不一样的操做系统的不一样,实现方式也不一样,这里也不深究啦。因为内存屏障涉及的内容更可能是底层的知识,因此这里就暂时介绍到这了

更详细的文章能够查阅该篇以及这篇


线程的安全性

线程的安全性问题逃不开物理硬件,上面咱们描述了那么多其实就是解释了线程的使用问题:多线程如何保证数据在工做内存以及主存中传递的正确性。在Java层面上,要理解这个问题,咱们仍是从Java的内存模型开始看起。


Java内存模型

上面讨论了因为因为高速缓存的引入, 可能致使工做内存与主存的数据错误的问题,这个问题全部的编程语言都会遇到的,而在Java语言中,JVM为了屏蔽掉各个操做系统的实现差别,也提出了Java内存模型(JMM)的概念。

在Java内存模型中规定全部的变量(这里指实例变量,静态字段,数组对象,但不包括局部对象和方法参数,这二者线程私有)存储在主内存中(类比物理主存)。在每条工做线程当中还存在着工做内存(类比高速缓存),工做内存中保存了被该线程使用的变量的副本,线程对全部操做(读取,赋值等)都经过工做内存进行,不直接读取主存对象操做。不一样线程直接也没法访问对象工做内存中的变量,线程间变量的值传递均经过主存来完成,三者的关系图以下:

该模型跟物理计算机的模型基本一致

https://blog.csdn.net/weixin_36795183/article/details/79420771

内存之间的交互

首先这里总结一下工做内存与主存的交互协议,即一个变量如何从主存拷贝到工做内存,再从工做内存同步回到主存中的细节。JMM在交互协议中定义了8种操做,这8中操做每一个都是原子性(long与double有例外状况)的:

  • lock:做用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock:做用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才能被其余线程访问。
  • read:做用于主内存的变量,它把一个变量的值从主内存传输到线程的工做内存中,以便随后的load动做使用。
  • load:做用于工做内存的变量,它把read操做从主内存中获得的变量值放入到工做内存变量副本中。
  • use:做用于工做内存的变量,它把工做内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个须要使用到变量的值的字节码指令时会执行这个操做。
  • assign:做用于工做内存的变量,它把一个从执行引擎收到的值赋给工做内存的变量。每当虚拟机遇到给变量赋值的字节码指令时会执行这个操做。
  • store:做用于工做内存的变量,它把工做内存中一个变量值传送到主内存中。以便随后的write操做。
  • write:做用于主内存的变量,它把store操做从工做内存中获得的变量的值,放入主内存的变量中。

上述操做的一个基本流程以下展现(图片出处):

当一个线程锁定某个变量时候,最直接的体现就是使用sychronized,若是进行赋值的操做,那么流程就如上所示。

除此以外,JMM中还规定上述操做的顺序性(注意,不是连续性)的要求:

  • 若是把一个变量从主存复制到工做内存,要顺序的执行read和load操做。
  • 若是把一个变量从工做内存同步回主存,则须要顺序执行sotre和write操做。

保证顺序性而不是保证连续性,如主内存对变量a,b访问时候,一种可能的状况是read a,read b,load b,load a。JMM还规定了在执行这8种操做的时候,须要知足以下规则:

  • 不容许read和load、store和write操做之一单独出现。即不容许一个变量从主内存读取了但工做内存不接受。或者从工做内存发起回写了但主内存不接受的状况。
  • 不容许一个线程丢弃它的最近的assign操做。即变量在工做内存改变了后必须把该变化同步到主内存中。
  • 不容许没有发生任何的assign操做就把数据同步到主内存中。
  • 一个新的变量只能在主内存中诞生,工做内存要使用或者赋值。必需要通过load或assign操做。
  • 一个变量在同一时刻只容许一条线程进行lock操做,但lock操做能够被同一线程重复执行屡次,屡次执行lock后,只有执行相同次数的unlock操做,变量才会被解锁。
  • 若是对一个变量进行lock操做后,那将会清空工做内存中此变量的值,在执行引擎使用这个变量前,须要从新执行load或assign操做。
  • 若是一个变量事先没有被lock操做锁定,那就不容许对它进行unlock操做。也不容许去unlock一个被其余线程锁定的变量。
  • 对一个变量执行unLock操做以前,必需要把次变量同步到主内存中(执行store,write操做)。

这些内容只要在网上查其实都能找到,这里为了方便往后查阅,便直接摘抄了一下,原文来自《深刻理解Java虚拟机》。然而这块的东西有点略偏与底层的东西了,总的来说,JMM模型是围绕着并发过程当中如何处理原子性,可见性和有序性三个特征创建的,因此上述的内容旨在增强理解。接下来就看下可见性,原子性,有序性吧。

Java的可见性,原子性,有序性

原子性表示操做不可拆分,即一个操做或者多个操做要么所有执行而且执行的过程不会被任何因素打断,要么就都不执行。Java中的基本数据类型的访问读写是原子性的(可是long和double不必定,在某些32位的系统上,long和double可能分红两部分连续的32位数据存储。),除了基本数据类型,经过添加sychronized关键字可以保证一段操做的原子性,反编译Java代码可发现加入sychronized关键字的代码段,其中会增长对应关键字monitorenter和monitorexit来保证,以下:

public void test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: getfield      #3                  // Field obj:Ljava/lang/Object;
         4: dup
         5: astore_1
         6: monitorenter
         7: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        10: ldc           #5                  // String Heelo
        12: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        15: aload_1
        16: monitorexit
        17: goto          25
        20: astore_2
        21: aload_1
        22: monitorexit
        23: aload_2
        24: athrow
        25: return

这两个字节码指令对应底层的lock和unlock指令。

可见性指的是一个线程当前修改了共享变量的值,其余线程可以当即得知这个修改。在Java内存模型当中的表示就是在线程A中修改了一个共享变量的值,修改后当即从A的工做内存中同步给了主内存更新值,同时其余线程每次使用该共享变量值时,保证从主内存中获取,可见性表明性的修饰符就是volatile,除此以外还有synchronized以及final。

synchronized就不谈了,final关键字的可见性是指被final修饰的字段在构造器中一旦初始化完成,而且构造器没有把"this"的引用传递出去(this引用逃逸是一件很危险的事情,其余线程有可能经过这个引用访问到“初始化了一半”的对象),那在其余线程中就能看见final字段的值。

有序性总结一句话就是,若是本线程内观察,全部的操做都是有序的;若是在一个线程中观察另外一个线程,全部的操做都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“工做内存与主内存同步延迟”现象

Happens-Before原则

上面讲述了关于JMM中工做原理以及三个性质保证线程的安全性,因为上述内容稍显有点抽象,因此在JSR-133规范中定义了Happens-Before原则,即先行发生原则来程序员参考,屏蔽底层细节,根据先行发生原则,开发者能够很容易的写出线程安全的代码。Happens-Before原则以下:

  • 单线程happen-before原则:在同一个线程中,书写在前面的操做happen-before后面的操做。
  • 锁的happen-before原则:同一个锁的unlock操做happen-before此锁的lock操做。
  • volatile的happen-before原则:对一个volatile变量的写操做happen-before对此变量的任意操做(固然也包括写操做了)。
  • happen-before的传递性原则:若是A操做 happen-before B操做,B操做happen-before C操做,那么A操做happen-before C操做。
  • 线程启动的happen-before原则:同一个线程的start方法happen-before此线程的其它方法。
  • 线程中断的happen-before原则:对线程interrupt方法的调用happen-before被中断线程的检测到中断发送的代码。
  • 线程终结的happen-before原则:线程中的全部操做都happen-before线程的终止检测。
  • 对象建立的happen-before原则:一个对象的初始化完成先于他的finalize方法调用。

这些原则理解了就很简单了,平常开发时在涉及多线程方面的工做时,根据先行发生原则,咱们可以应付诸多的并发问题了。


Ok,这里本章节的内容就讲解完毕了,这里总结下。因为高速缓存的引入致使了多线程在并发时候的缓存不一致的问题,咱们在开发的时候,线程安全的设计须要考虑知足有序性,原子性以及可见性,而且经过happen-before可以很好的判断本身写的代码是否安全。


参考资料

相关文章
相关标签/搜索