jvm-java内存模型与锁优化

java内存模型与锁优化

 

 参考:

https://blog.csdn.net/xiaoxiaoyusheng2012/article/details/53143355

https://blog.csdn.net/suifeng3051/article/details/52611310

 

一、Java内存模型

       Java内存模型(Java Memory Model, JMM),用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。主要目标是定义程序中各个共享变量的访问规则。

1、主内存与工作内存

       Java内存模型规定所有(线程共享)变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存(Working Memory)。线程工作内存中保存了被该线程使用到的共享变量的拷贝副本,线程对共享变量的所有操作都必须在工作内存中进行。线程间共享变量的值的传递均需要通过主内存来完成。

       线程、主内存、工作内存三者的交互关系如下图所示:

《jvm-java内存模型与锁优化》

注:这里的主内存、工作内存与Java运行时内存区域并不是一个层次的内存划分。勉强对应的话,主内存主要对应于Java堆中对象实例数据部分,工作内存对应于虚拟机栈中的部分区域。

 

二、内存间交互操作

1、内存模型中的八种基本操作

      对于主内存与工作内存之间如何交互。java内存模型中定义了以下八种操作来完成:(注意:虚拟机实现时必须保证这里的每一种操作都是原子的、不可再分的)。

      <1>、lock(锁定):作用于主内存的变量,将一个变量标识为一条线程独占的状态。

      <2>、unlock(解锁):作用于主内存的变量,将一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

      <3>、read(读取):作用于主内存的变量,将一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

      <4>、load(载入):作用于工作内存的变量,将read操作从主内存中得到的变量值放入工作内存的变量副本中。

      <5>、use(使用):作用于工作内存的变量,将工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的字节码指令时将会执行这个操作。

      <6>、assign(赋值):作用于工作内存变量,将一个从执行引擎接收到的值赋值给工作内存的变量(每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作)。

      <7>、store(存储):作用于工作内存的变量,将工作内存中一个变量的值传送到主内存,以便随后的write操作使用。

      <8>、write(写入):作用于主内存变量,将store操作从工作内存中得到的变量的值放入主内存的变量中。

Java内存模型规定执行上述八种基本操作需要满足以下条件:

      <1>、不允许read和load、store和write操作之一单独出现;

      <2>、不允许一个线程丢弃它最近的assign操作;

      <3>、不允许一个线程无原因把数据从线程工作内存同步回主内存;

      <4>、不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量;

      <5>、一个变量在同一时刻只允许一个线程对其进行lock操作;

      <6>、如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用该变量前,需要重新执行load或assign操作初始化变量。

      <7>、如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;

      <8>、对一个变量执行unlock操作之前,必须先把此变量同步回主内存中。

 

2、volatile关键字的语义

       被定义成volatile的变量,将具备两种特性:对所有线程的“可见性” 和 禁止指令重排序优化。

       <1>、对所有线程的“可见性”是:Java保证当一条线程对volatile变量的值做修改后,新值对于其他线程来说是可以立即得知的。(通过如下规则实现:第一、线程每次使用volatile变量之前必须先从主内存刷新最新的值,用来保证能看到其他线程对该变量所做的修改后的值,第二、线程每次修改volatile变量后必须立即同步到主内存中,保证其他线程可以看到对变量的修改)。

       针对volatile变量的“可见性”特性,适合如下场景的并发控制:

[java] view plain copy
 

  1. volatile boolean shutdownRequested;  
  2.       
  3.     public void shutdown() {  
  4.         shutdownRequested = true;  
  5.     }  
  6.       
  7.     public void doWork() {  
  8.         while (!shutdownRequested) {  
  9.             // do stuff  
  10.         }  
  11.     }  

     注:上例代码中当shutdown()方法被调用时,能保证所有线程中执行的doWork()方法都立刻停下来。

     <2>、禁止指令重排列优化。普通变量不能保证变量的赋值操作顺序与程序中代码中的执行顺序一致,因为可能存在指令重排列的优化。而声明了volatile关键字的变量是“禁止指令重排列的”,可以避免这种情况。

3、对于long和double型变量的特殊规则

      Java内存模型对于64位的数据类型(long和double),有所谓的非原子性协议(Nonatomic Treatment of double and long Variables)。即允许虚拟机将没有volatile修饰的64位数据读写操作划分为两次32位的操作来进行。也就是说不能保证64位数据类型的load、store、read和write这四个操作的原子性。

      注:虚拟机规范“强烈建议”虚拟机实现将64位数据读写作为原子操作对待。目前几乎所有的虚拟机实现也是这么做的。

4、原子性、可见性与有序性

        原子性(Atomicity):可以认为基本数据类型的访问读写是具备原子性的。更大范围的原子性保证是通过synchronized关键字,synchronized块之间的操作也具备原子性。

        可见性(Visibility):普通共享变量与volatile变量的区别是volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新,因此volatile能保证多线程操作时变量的可见性,而普通变量则不能保证这一点。Java中除了volatile关键字之外,还有两个关键字能实现可见性,它们是synchronized和final。final变量的“可见性”是:被final修饰的字段在构造器中一旦被初始化完成,那么在其他线程中就能看见final字段的值,因此final变量无须同步就能被其他线程正确地访问。

        有序性(Ordering):总结为:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行语义(Within-Thread As-If-Serial Semantics)”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性。

        注:synchronized的“万能”也间接造成了被滥用的局面,这通常会伴随着越来越大的性能影响。

5、先行发生原则

       “先行发生(happens-before)”是Java内存模型中定义的两项操作之间的偏虚关系,如果说操作A先行发生于操作B,其实就是说发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。

       Java内存模型支持的“先行关系”如下:

<1>、程序次序规则(Program Order Rule):在一个线程内,按照程序代码(控制流)顺序,书写在前面的操作先行发生于书写在后面的操作。

<2>、管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面(时刻)对同一个锁的lock操作。

<3>、volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面(时刻)对该变量的读操作。

<4>、线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。

<5>、线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。

<6>、线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。

<7>、对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

<8>、传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

      下面示例,演示了如何通过“先行发生”原则来判断操作是否是线程安全的:

[java] view plain copy
 

  1. private int value = 0;  
  2.       
  3.     public void setValue(int value) {  
  4.         this.value = value;  
  5.     }  
  6.       
  7.     public int getValue() {  
  8.         return value;  
  9.     }  

        假设,存在线程A和线程B,线程A先(时间上的先后)调用“setValue(1)”,然后线程B调用了同一个对象的“getValue()”,那么线程B收到的返回值是什么?

        分析:我们可以将上述代码与先行发生原则中的各项规则一一对照,由于两个方法分别由线程A和B调用,不在一个线程中,所以程序次序规则不适用;由于没有同步块,不会发生lock和unlock操作,所以管程锁定规则不适用;由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用;后面的线程启动、终止、中断规则和对象终结规则都不适用;由于没有一个适用的规则,所以传递性规则也不适用。所以结论是:上述代码不是线程安全的,线程B收到的返回值可能是0,也可能是1.

       修复上述问题,有几种方法:将getter/setter方法都定义为synchronized方法,这样就适用于管程锁定规则;或者将value定义为volatile变量,这样就适用于volatile变量规则。

       特别注意:衡量并发安全问题的时候,一切必须以先行发生原则为准,不要受到时间顺序的干扰,因为时间上的先后顺序与先行发生原则之间基本没有太大的关系。

 

三、Java与线程

1、线程的实现

        实现线程的方式主要有三种:使用内核线程实现、使用用户线程实现、使用用户线程加轻量级进程混合实现。

<1>、使用内核线程实现

       内核线程(Kernel Thread, KLT)是直接由操作系统内核(Kernel)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。

       程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(Light Weight Process, LWP),轻量级进程就是我们通常意义上所讲的线程。每个轻量级进程都由一个内核线程支持。

       这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型,如下图所示:

《jvm-java内存模型与锁优化》 

        这种实现的问题是:轻量级进程具有自己的局限性,系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换,另外轻量级进程要消耗一定的内核资源,因此一个系统支持轻量级进程的数量也是有限的。

<2>、使用用户线程实现

        广义的用户线程(User Thread, UT)是指不是内核线程的所有线程,包括轻量级进程。狭义的定义是指:完全建立在用户空间的线程库上,系统内核不能感知到线程存在的实现,用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。

       这种进程与用户线程之间1:N的关系称为一对多的线程模型,如下图所示:

《jvm-java内存模型与锁优化》

      这种实现方式的优点是:不需要系统内核支援,可以拥有更大规模的线程数量,缺点是没有系统内核支援,操作系统只把处理器资源分配到进程,所有的线程操作都需要用户程序自己处理。

<3>、混合实现

      混合实现是指将内核线程与用户线程一起使用的实现方式,用户线程仍然完全建立在用户空间下,因此可以发挥用户线程的大规模并发优势,同时轻量级进程则作为用户线程和内核线程之间的桥梁,来使用内核提供的线程调度功能和处理器映射,并且将用户线程的系统调用通过轻量级进程来完成,也大大降低了进程被阻塞的风险。

      这种用户线程与轻量级进程的数量比是不定的,是M:N的关系,如下图所示:

《jvm-java内存模型与锁优化》

注:

       对于Sun JDK来说,它的Windows版与Linux版都是使用一对一的线程模型来实现的,一条Java线程就映射到一条轻量级进程之中,因为Windows和Linux系统提供的线程模型就是一对一的。

       对于Solaris平台,由于操作系统的线程特性可以同时支持一对一(通过Bound Threads或Alternate Libthread实现)及多对多(通过LWP/ Thread Based Synchronization实现)的线程模型。因此Solaris版的JDK中有对应两个平台的专有虚拟机参数:-XX:+UseLWPSynchronization(默认值)和-XX:+UseBoundThreads来明确指定虚拟机使用哪种线程模型。

 

2、Java线程调度

      线程调度是系统为线程分配处理器使用权的过程。主要的调度方式有两种:协同式(Cooperative Threads-Scheduling)线程调度和抢占式(Preemptive Threads-Scheduling)线程调度。

      协同式调度的系统,线程的执行时间由线程本身来控制,线程执行结束会主动通知系统切换到另一个线程上去。好处是实现简单、不需要线程同步,缺点是线程执行时间不可控。

      抢占式调度的系统,线程执行时间由系统分配,线程执行时间用完后,系统切换到另一个线程来执行。优点是:线程执行时间可控。

      Java使用的线程调度是抢占式调度。

      此外,Java设置了10个级别的线程优先级。

3、线程状态切换

      Java定义了6种进程状态,任何时间点,一个线程只能有且只有其中一个状态:

      ** 新建(New):创建后尚未启动。

      ** 运行(Runnable):线程正在执行的状态(Running)或者正在等待CPU时间的状态(Ready)。

      ** 无限期等待(Waiting):不能被分配CPU时间,只能等待被其他线程显式地唤醒。以下方法会让线程陷入无限期等待状态:

             -> 没有设置Timeout参数的Object.wait()方法;

             -> 没有设置Timeout参数的Thread.join()方法;

             -> LockSupport.park()方法。

       ** 限期等待(Timed Waitting):不能被分配CPU时间,能够等待被其他线程显式地唤醒,也能够在一定时间后被系统自动唤醒。以下方式会让线程进入限期等待状态:

             -> Thread.sleep()方法;

             -> 设置了Timeout参数的Object.wait()方法;

             -> 设置了Timeout参数的Thread.join()方法;

             -> LockSupport.parkNanos()方法;

             -> LockSupport.parkUntil()方法。

       ** 阻塞(Blocked):程序等待进入同步区域的时候,处于这种状态。“阻塞状态”与“等待状态”的区别是:“阻塞状态”在等待获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。

       ** 结束(Terminated):已终止执行的状态。

      这6中状态的转换过程如下图所示:

《jvm-java内存模型与锁优化》

四、线程安全

线程安全的定义

         当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。

 

1、Java语言中的线程安全

        讨论线程安全,要限定于“多个线程之间存在共享数据访问”这个前提,因为如果一段代码根本不会与其他线程共享数据,那么从线程安全的角度上看,程序是串行执行还是多线程执行对它来说是完全没有区别的。

        Java语言中各种操作共享的数据分为五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立

<1>、不可变

       不可变(Immutable)的对象一定是线程安全的。只要一个不可变的对象被正确地构建出来,那其外部的可见状态永远也不会改变,永远也不会看到它在多个线程中处于状态不一致的状态。

       是对象不可变的方法是:将对象中带有状态的变量都声明为final。

<2>、绝对线程安全

      绝对线程安全是一个严格的定义,指一个类能够满足:“不管运行时环境如何,调用者都不需要任何额外的同步措施”。

      在Java API中标注自己是线程安全的类,大多数都不是绝对线程安全的。

<3>、相对线程安全

       相对线程安全的对象是指对该对象的单独的操作是线程安全的,但是对于一些需要特定顺序的连续调用,需要额外的同步手段来保证正确性。

      Java中大多数的线程安全类都属于这种类型。

<4>、线程兼容

       线程兼容的对象是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中安全地使用。

<5>、线程对立

       线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。

 一个线程对立的例子是Thread类的suspend()和resume()方法。

 

五、线程安全的实现方法

1、互斥同步

       互斥同步保证共享数据在同一个时刻只被一条线程使用。互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。

       在Java里面,最基本的互斥同步手段是synchronized关键字。synchronized关键字经过编译后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果synchronized关键字明确指令了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。

       对于代码简单的同步块(如被synchronized修饰的getter()或setter()方法),状态转换消耗的时间可能比用户代码执行的时间还要长。所以synchronized是Java语言中一个重量级(Heavyweight)的操作。

       此外,Java.util.concurrent包中的重入锁(ReentrantLock)也可以实现互斥同步,它有三个高级功能:等待可中断、可实现公平锁、锁可以绑定多个条件。

 

       互斥同步的主要问题是:进行线程阻塞和唤醒所带来的性能问题,这种同步被称为阻塞同步(Blocking Synchronized),属于一种悲观的并发策略(总是认为只要不去做正确的同步措施(加锁),无论共享数据是否真的会出现竞争,那就肯定会出问题)

2、非阻塞同步

      另一种同步策略是:基于冲突检测的乐观并发策略,过程是:先尝试进行操作,如果没有其他线程竞争共享资源,那么就操作成功,如果发生共享数据竞争,就产生冲突,再采用补偿措施(最常见的补偿措施是不断地重试,直到试成功为止)。这种同步不需要线程挂起,因此也称为非阻塞同步(Non-Blocking Synchronization)。

      注意,非阻塞同步的实现,需要操作和冲突检测这两个步骤具备原子性。

3、无同步方案

      如果一个方法本来就不涉及共享数据,那自然就无须任何同步措施去保证正确性。

      可重入代码(Reentrant Code):所有可重入的代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。可重入代码的共同特征是:不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。

      线程本地存储(Thread Local Storage):线程本地存储的思想是将共享数据的可见范围限制在同一个线程之内。这样不同线程之间就不存在共享数据竞争的问题了。

在Java中通过java.lang.ThreadLocal类来实现线程本地存储的功能,每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口。

 

六、锁优化

1、自旋锁与自适应自旋

       互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。如果共享数据的锁定状态持续的时间很短,那么传统互斥同步的并发性能九很差。

       自旋锁技术是让等待锁的线程不用放弃处理器执行时间,而是执行一个忙循环(自旋),来看持有锁的线程是否很快就会释放锁。

       自旋等待不能代替阻塞,首先因为自旋要求物理机有多个处理器,其次因为自旋本身要占用处理器时间,如果锁被占用的时间很短,自旋的效果会非常好,如果锁占用的时间很长,自旋就浪费掉大量的处理器资源。所以自旋等待的时间必须有一个限度,可以使用参数-XX:PreBlockSpin来更改。默认值是10次。

      JDK1.6引入了自适应的自旋锁。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省掉自旋过程,以避免浪费处理器资源。

 

2、锁消除

      锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。

例如以下源代码:

[java] view plain copy
 

  1. public String concatString(String s1, String s2, String s3) {  
  2.         return s1 + s2 + s3;  
  3.     }  

在JDK1.5之后版本会转化为StringBuilder对象的append()操作,如下:

[java] view plain copy
 

  1. public String concatString(String s1, String s2, String s3) {  
  2.         StringBuffer sb = new StringBuffer();  
  3.         sb.append(s1);  
  4.         sb.append(s2);  
  5.         sb.append(s3);  
  6.         return sb.toString();  
  7.     }  

       每个StringBuffer.append()方法中都有一个同步块,锁就是sb对象。当虚拟机发现变量sb的动态作用域被限制在concatString()方法内部,说明sb对象不是线程共享对象,因此这里的同步块是没有意义的,所以通过“锁消除”优化后,代码执行时就忽略掉append()方法内部同步锁。

3、锁粗化

       大部分情况下,我们会推荐奖同步块的作用范围限制得尽量小,这样是为了使得 需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快地拿到锁。

       有些情况下,如果一系列连续的操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中,那么即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。锁粗化会将这些锁的作用域扩大。来减少频繁地加锁和解锁

       上一段代码也存在这样的问题,通过“锁粗化”优化,会将append()方法内部的互斥锁忽略,仅仅在sb.append(s1)之前,和sb.append(s3)之后,加一次锁就可以了。

4、轻量级锁

       JDK1.6引入了轻量级锁,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的情况下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

       轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

5、偏向锁

       偏向锁也是JDK1.6中引入的一项锁优化,目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。偏向锁就是指在无竞争的情况下把整个同步都取消掉。

       偏向锁的“偏”就是“偏心”、“偏袒”的意思,这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。当有另外一个线程去尝试获取这个锁的时候,偏向模式就宣告结束,后续的同步操作就如轻量级锁那样执行。

       偏向锁可以提高带有同步但无竞争的程序的性能,但是并不是总是有效,如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。

 

 

 

 

 

Java内存模型即Java Memory Model,简称JMM。JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。

如果我们要想深入了解Java并发编程,就要先理解好Java内存模型。Java内存模型定义了多线程之间共享变量的可见性以及如何在需要的时候对共享变量进行同步。原始的Java内存模型效率并不是很理想,因此Java1.5版本对其进行了重构,现在的Java8仍沿用了Java1.5的版本。

关于并发编程

在并发编程领域,有两个关键问题:线程之间的通信和同步。

线程之间的通信

线程的通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种共享内存和消息传递。

在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信,典型的共享内存通信方式就是通过共享对象进行通信。

在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信,在java中典型的消息传递方式就是wait()和notify()。

关于Java线程之间的通信,可以参考线程之间的通信(thread signal)

线程之间的同步

同步是指程序用于控制不同线程之间操作发生相对顺序的机制。

在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。

在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

Java的并发采用的是共享内存模型

Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

Java内存模型

上面讲到了Java线程之间的通信采用的是过共享内存模型,这里提到的共享内存模型指的就是Java内存模型(简称JMM),JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

《jvm-java内存模型与锁优化》

从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。 2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。 
  • 1
  • 2
  • 3

下面通过示意图来说明这两个步骤: 
《jvm-java内存模型与锁优化》

如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。

上面也说到了,Java内存模型只是一个抽象概念,那么它在Java中具体是怎么工作的呢?为了更好的理解上Java内存模型工作方式,下面就JVM对Java内存模型的实现、硬件内存模型及它们之间的桥接做详细介绍。

JVM对Java内存模型的实现

在JVM内部,Java内存模型把内存分成了两部分:线程栈区和堆区,下图展示了Java内存模型在JVM中的逻辑视图: 
《jvm-java内存模型与锁优化》 
JVM中运行的每个线程都拥有自己的线程栈,线程栈包含了当前线程执行的方法调用相关信息,我们也把它称作调用栈。随着代码的不断执行,调用栈会不断变化。

线程栈还包含了当前方法的所有本地变量信息。一个线程只能读取自己的线程栈,也就是说,线程中的本地变量对其它线程是不可见的。即使两个线程执行的是同一段代码,它们也会各自在自己的线程栈中创建本地变量,因此,每个线程中的本地变量都会有自己的版本。

所有原始类型(boolean,byte,short,char,int,long,float,double)的本地变量都直接保存在线程栈当中,对于它们的值各个线程之间都是独立的。对于原始类型的本地变量,一个线程可以传递一个副本给另一个线程,当它们之间是无法共享的。

堆区包含了Java应用创建的所有对象信息,不管对象是哪个线程创建的,其中的对象包括原始类型的封装类(如Byte、Integer、Long等等)。不管对象是属于一个成员变量还是方法中的本地变量,它都会被存储在堆区。

下图展示了调用栈和本地变量都存储在栈区,对象都存储在堆区: 
《jvm-java内存模型与锁优化》 
一个本地变量如果是原始类型,那么它会被完全存储到栈区。 
一个本地变量也有可能是一个对象的引用,这种情况下,这个本地引用会被存储到栈中,但是对象本身仍然存储在堆区。

对于一个对象的成员方法,这些方法中包含本地变量,仍需要存储在栈区,即使它们所属的对象在堆区。 
对于一个对象的成员变量,不管它是原始类型还是包装类型,都会被存储到堆区。

Static类型的变量以及类本身相关信息都会随着类本身存储在堆区。

堆中的对象可以被多线程共享。如果一个线程获得一个对象的应用,它便可访问这个对象的成员变量。如果两个线程同时调用了同一个对象的同一个方法,那么这两个线程便可同时访问这个对象的成员变量,但是对于本地变量,每个线程都会拷贝一份到自己的线程栈中。

下图展示了上面描述的过程: 
《jvm-java内存模型与锁优化》

硬件内存架构

不管是什么内存模型,最终还是运行在计算机硬件上的,所以我们有必要了解计算机硬件内存架构,下图就简单描述了当代计算机硬件内存架构: 
《jvm-java内存模型与锁优化》

现代计算机一般都有2个以上CPU,而且每个CPU还有可能包含多个核心。因此,如果我们的应用是多线程的话,这些线程可能会在各个CPU核心中并行运行。

在CPU内部有一组CPU寄存器,也就是CPU的储存器。CPU操作寄存器的速度要比操作计算机主存快的多。在主存和CPU寄存器之间还存在一个CPU缓存,CPU操作CPU缓存的速度快于主存但慢于CPU寄存器。某些CPU可能有多个缓存层(一级缓存和二级缓存)。计算机的主存也称作RAM,所有的CPU都能够访问主存,而且主存比上面提到的缓存和寄存器大很多。

当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存,进而在读取CPU缓存到寄存器。当CPU需要写数据到主存时,同样会先flush寄存器到CPU缓存,然后再在某些节点把缓存数据flush到主存。

Java内存模型和硬件架构之间的桥接

正如上面讲到的,Java内存模型和硬件内存架构并不一致。硬件内存架构中并没有区分栈和堆,从硬件上看,不管是栈还是堆,大部分数据都会存到主存中,当然一部分栈和堆的数据也有可能会存到CPU寄存器中,如下图所示,Java内存模型和计算机硬件内存架构是一个交叉关系: 
《jvm-java内存模型与锁优化》
当对象和变量存储到计算机的各个内存区域时,必然会面临一些问题,其中最主要的两个问题是:

1. 共享对象对各个线程的可见性 2. 共享对象的竞争现象 
  • 1
  • 2
  • 3

共享对象的可见性

当多个线程同时操作同一个共享对象时,如果没有合理的使用volatile和synchronization关键字,一个线程对共享对象的更新有可能导致其它线程不可见。

想象一下我们的共享对象存储在主存,一个CPU中的线程读取主存数据到CPU缓存,然后对共享对象做了更改,但CPU缓存中的更改后的对象还没有flush到主存,此时线程对共享对象的更改对其它CPU中的线程是不可见的。最终就是每个线程最终都会拷贝共享对象,而且拷贝的对象位于不同的CPU缓存中。

下图展示了上面描述的过程。左边CPU中运行的线程从主存中拷贝共享对象obj到它的CPU缓存,把对象obj的count变量改为2。但这个变更对运行在右边CPU中的线程不可见,因为这个更改还没有flush到主存中: 
《jvm-java内存模型与锁优化》 
要解决共享对象可见性这个问题,我们可以使用java volatile关键字。 Java’s volatile keyword. volatile 关键字可以保证变量会直接从主存读取,而对变量的更新也会直接写到主存。volatile原理是基于CPU内存屏障指令实现的,后面会讲到。

竞争现象

如果多个线程共享一个对象,如果它们同时修改这个共享对象,这就产生了竞争现象。

如下图所示,线程A和线程B共享一个对象obj。假设线程A从主存读取Obj.count变量到自己的CPU缓存,同时,线程B也读取了Obj.count变量到它的CPU缓存,并且这两个线程都对Obj.count做了加1操作。此时,Obj.count加1操作被执行了两次,不过都在不同的CPU缓存中。

如果这两个加1操作是串行执行的,那么Obj.count变量便会在原始值上加2,最终主存中的Obj.count的值会是3。然而下图中两个加1操作是并行的,不管是线程A还是线程B先flush计算结果到主存,最终主存中的Obj.count只会增加1次变成2,尽管一共有两次加1操作。 
《jvm-java内存模型与锁优化》

要解决上面的问题我们可以使用java synchronized代码块。synchronized代码块可以保证同一个时刻只能有一个线程进入代码竞争区,synchronized代码块也能保证代码块中所有变量都将会从主存中读,当线程退出代码块时,对所有变量的更新将会flush到主存,不管这些变量是不是volatile类型的。

volatile和 synchronized区别

详细请见 volatile和synchronized的区别

支撑Java内存模型的基础原理

指令重排序

在执行程序时,为了提高性能,编译器和处理器会对指令做重排序。但是,JMM确保在不同的编译器和不同的处理器平台之上,通过插入特定类型的Memory Barrier来禁止特定类型的编译器重排序和处理器重排序,为上层提供一致的内存可见性保证。

  1. 编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序:如果不存l在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序:处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

数据依赖性

如果两个操作访问同一个变量,其中一个为写操作,此时这两个操作之间存在数据依赖性。 
编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序,即不会重排序。

as-if-serial

不管怎么重排序,单线程下的执行结果不能被改变,编译器、runtime和处理器都必须遵守as-if-serial语义。

内存屏障(Memory Barrier )

上面讲到了,通过内存屏障可以禁止特定类型处理器的重排序,从而让程序按我们预想的流程去执行。内存屏障,又称内存栅栏,是一个CPU指令,基本上它是一条这样的指令:

  1. 保证特定操作的执行顺序。
  2. 影响某些数据(或则是某条指令的执行结果)的内存可见性。

编译器和CPU能够重排序指令,保证最终相同的结果,尝试优化性能。插入一条Memory Barrier会告诉编译器和CPU:不管什么指令都不能和这条Memory Barrier指令重排序。

Memory Barrier所做的另外一件事是强制刷出各种CPU cache,如一个Write-Barrier(写入屏障)将刷出所有在Barrier之前写入 cache 的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。

这和java有什么关系?上面java内存模型中讲到的volatile是基于Memory Barrier实现的。

如果一个变量是volatile修饰的,JMM会在写入这个字段之后插进一个Write-Barrier指令,并在读这个字段之前插入一个Read-Barrier指令。这意味着,如果写入一个volatile变量,就可以保证:

  1. 一个线程写入变量a后,任何线程访问该变量都会拿到最新值。
  2. 在写入变量a之前的写入操作,其更新的数据对于其他线程也是可见的。因为Memory Barrier会刷出cache中的所有先前的写入。

happens-before

从jdk5开始,java使用新的JSR-133内存模型,基于happens-before的概念来阐述操作之间的内存可见性。

在JMM中,如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系,这个的两个操作既可以在同一个线程,也可以在不同的两个线程中。

与程序员密切相关的happens-before规则如下:

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中任意的后续操作。
  2. 监视器锁规则:对一个锁的解锁操作,happens-before于随后对这个锁的加锁操作。
  3. volatile域规则:对一个volatile域的写操作,happens-before于任意线程后续对这个volatile域的读。
  4. 传递性规则:如果 A happens-before B,且 B happens-before C,那么A happens-before C。

注意:两个操作之间具有happens-before关系,并不意味前一个操作必须要在后一个操作之前执行!仅仅要求前一个操作的执行结果,对于后一个操作是可见的,且前一个操作按顺序排在后一个操作之前。

参考文档 : 
1. http://www.infoq.com/cn/articles/java-memory-model-1 
2. http://www.jianshu.com/p/d3fda02d4cae

    原文作者:java内存模型
    原文地址: http://www.cnblogs.com/xuwc/p/8726940.html
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞