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

上篇文章中介绍了线程的概念以及使用,通过线程,我们能够提高CPU的利用率以及项目的执行效率,但是使用线程有个非常大的问题,就是如何正确同步数据的问题。在介绍线程的问题之前,这里首先对计算机内存模型做个介绍,理解了这个,才能理解线程的问题。


计算机内存模型

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

这里我们可以简单理解为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需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

画个总结图如下:


重排序

除了上面介绍的计算机内存模型,这里也介绍一下重排序。**为了让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能够很好的判断自己写的代码是否安全。


参考资料

猜你喜欢

转载自my.oschina.net/u/3863980/blog/2875247