Java内存模型

1、概述

Java程序是需要运行在Java虚拟机上面的,Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

提到Java内存模型,一般指的是JDK 5 开始使用的新的内存模型,主要由JSR-133: JavaTM Memory Model and Thread Specification 描述。感兴趣的可以参看下这份PDF文档(http://www.cs.umd.edu/~pugh/java/memoryModel/jsr133.pdf

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。

image.png

这里面提到的主内存和工作内存,读者可以简单的类比成计算机内存模型中的主存和缓存的概念。特别需要注意的是,主内存和工作内存与JVM内存结构中的Java堆、栈、方法区等并不是同一个层次的内存划分,无法直接类比。《深入理解Java虚拟机》中认为,如果一定要勉强对应起来的话,从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分。工作内存则对应于虚拟机栈中的部分区域。

所以,再来总结下,JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。

2、实现

了解Java多线程的朋友都知道,在Java中提供了一系列和并发处理相关的关键字,比如volatile、synchronized、final、concurrent包等。其实这些就是Java内存模型封装了底层的实现后提供给程序员使用的一些关键字。

在开发多线程的代码的时候,我们可以直接使用synchronized等关键字来控制并发,从来就不需要关心底层的编译器优化、缓存一致性等问题。所以,Java内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。

本文并不准备把所有的关键字逐一介绍其用法,因为关于各个关键字的用法,网上有很多资料。读者可以自行学习。本文还有一个重点要介绍的就是,我们前面提到,并发编程要解决原子性、有序性和一致性的问题,我们就再来看下,在Java中,分别使用什么方式来保证。

Java内存模型是围绕着并发过程中如何处理原子性、可见性和顺序性这三个特征来设计的。

原子性

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
请分析以下哪些操作是原子性操作:

x =10;        //语句1
y = x;        //语句2
x++;          //语句3
x = x +1;    //语句4

咋一看,有些朋友可能会说上面的4个语句中的操作都是原子性操作。其实只有语句1是原子性操作,其他三个语句都不是原子性操作。

  • 语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
  • 语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
  • 同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。

所以上面4个语句只有语句1的操作具备原子性。
也就是说,只有简单的读取、赋值(而且必须是将值赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。

从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性

在Java中,为了保证原子性,提供了两个高级的字节码指令monitorentermonitorexit。这两个字节码,在Java中对应的关键字就是synchronized
因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。

可见性

可见性是指一个线程修改了一个变量的值后,其他线程立即可以感知到这个值的修改。
Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。

Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。

除了volatile,Java中的synchronizedLock两个关键字也可以实现可见性。synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

有序性

有序性从不同的角度来看是不同的。单纯单线程来看都是有序的,但到了多线程就会跟我们预想的不一样。可以这么说:如果在本线程内部观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句说的就是“线程内表现为串行的语义”,后半句指得是“指令重排序”现象和主内存与工作内存之间同步存在延迟的现象。

在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

在Java中,可以使用synchronizedvolatile来保证多线程之间操作的有序性。实现方式有所区别:

volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。

总体来看,synchronized对三种特性都有支持,虽然简单,但是如果无控制的滥用对性能就会产生较大影响

3、happens-before 原则

Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

下面就来具体介绍下happens-before原则(先行发生原则):

  • 1、程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
  • 2、锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
  • 3、volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 4、传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  • 5、线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 6、线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 7、线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
  • 8、对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
    这8条原则摘自《深入理解Java虚拟机》。

这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。

下面我们来解释一下前4条规则:

对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。

第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。

第三条规则是一条比较重要的规则,直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。

第四条规则实际上就是体现happens-before原则具备传递性。

4、volatile的原理和实现机制

前面讲述了源于volatile关键字的一些使用,下面我们来探讨一下volatile到底如何保证可见性和禁止指令重排序的。

下面这段话摘自《深入理解Java虚拟机》:
  “观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”

lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
  1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  2)当写一个volatile变量时,JMM会把该线程本地缓存中的共享变量刷新到主内存;
  3)当读一个volatile变量时,JMM会把该线程对应的本地缓存置无效,线程接下来将从主内存中读取共享变量;

5、Java指令执行的过程以及volatile修饰词作用

Java指令执行的过程:
  • 1.将变量从主存复制到线程的工作内存中;
  • 2.然后进行读操作;
  • 3.有赋值指令时进行赋值操作;
  • 4.将结果写入主存中;
    以上4步都是原子性的,但组合到一起,多线程操作时不能保证整体原子性,这也就是线程并发安全问题的原因。
当变量被volatile修饰以后:
  • 1.某一线程对volatile修饰的变量进行修改后,会强制将结果写入主存,并使其它线程缓存行失效(失效后,读操作不能从工作内存中直接读取,从步骤1开始),即保证3和4指令执行过程的整体原子性,并通知其它线程。
  • 2.禁止指令重排(代码的编写顺序和指令执行的顺序不一致),一定程度上保证了有序性。
代码解释重排序
if (singleInstance == null) { //4 第二次检查
                    singleInstance = new SingleInstance();//5 创建实例 
                }

我们以上面代码来简单解释下重排序

首先第5步代码创建了一个对象,这一行代码可以分解成3个操作:
memory = allocate();  // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
singleInstance = memory;  // 3:设置instance指向刚分配的内存地址

但是,编译器和处理器可能对指令进行重排序,比如,顺序变成以下
memory = allocate();  // 1:分配对象的内存空间
instance = memory;  // 3:设置instance指向刚分配的内存地址
// 注意,此时对象还没有被初始化!
ctorInstance(memory); // 2:初始化对象

这在单线程环境下是没有问题的,
但在多线程情况下,在B线程执行第5步时,A线程执行到第4步,由于重排序的原因,B线程还没有完成instance引用的对象的初始化(B线程执行了1,3),但是A线程已经读取到singleInstance不为null,这时候就会导致空指针异常。

当我们对变量singleInstance 使用volatile修饰以后,就不会出现这种情况了,volatile关键字会禁止指令重排,从而避免了上述情况的发生,

volatile修饰词作用总结:
  • 1.某一线程对volatile修饰的变量进行修改后,会强制将结果写入主存,并使其它线程缓存行失效(失效后,读操作不能从工作内存中直接读取,从步骤1开始),即保证3和4指令执行过程的整体原子性,并通知其它线程——这里的步骤1234指的是上面的Java指令执行的过程
  • 2.禁止指令重排(代码的编写顺序和指令执行的顺序不一致),一定程度上保证了有序性。

6、使用volatile关键字的场景

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
  1)对变量的写操作不依赖于当前值(像++,--就不可以)
  2)该变量没有包含在具有其他变量的不变式中

简单的说,就是上面的2个条件需要保证操作是原子性操作(参考上面讲原子性时的四个例子,只能是把值赋值给变量的操作),才能保证使用volatile关键字的程序在并发时能够正确执行。

下面列举几个Java中使用volatile的几个场景。
1、状态标记量

volatile boolean inited =false;
//线程1:
context = loadContext();  
inited =true;         //这个赋值是原子性操作   
 
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);

2、double check(见单例模式

7、volatile和synchronized区别

    1. Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新;而synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住
    1. volatile仅能使用在变量级别,synchronized则可以使用在块、方法;
    1. volatile仅能实现变量的修改可见性和有序性,而synchronized则可以保证变量的修改可见性、原子性、有序性。
        《Java编程思想》上说,定义long或double变量时,如果使用volatile关键字,就会获得(简单的赋值与返回操作)原子性。
    1. volatile不会造成线程的阻塞,而synchronized可能会造成线程的阻塞;
    1. 当一个域的值依赖于它之前的值时,volatile就无法工作了,如n=n+1,n++等。如果某个域的值受到其他域的值的限制,那么volatile也无法工作,如Range类的lower和upper边界,必须遵循lower<=upper的限制。
    1. 使用volatile而不是synchronized的唯一安全的情况是类中只有一个可变的域。

8、synchronized和lock区别

    1. Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;
    1. synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
    1. Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
    1. 通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。
    1. Lock可以提高多个线程进行读操作的效率。
    1. 在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。所以说,在具体使用时要根据适当情况选择。

synchronized和lock的详细介绍以及底层实现见Java 锁

9、Java内存屏障

9.1 为什么会有内存屏障

每个CPU都会有自己的缓存(有的甚至L1,L2,L3),缓存的目的就是为了提高性能,避免每次都要向内存取。但是这样的弊端也很明显:不能实时的和内存发生信息交换,分在不同CPU执行的不同线程对同一个变量的缓存值不同。

那怎么解决上述问题呢?那就是用内存屏障,内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样,java通过屏蔽这些差异,统一由jvm来生成内存屏障的指令

内存屏障有两个作用:

  • 阻止屏障两侧的指令重排序;
  • 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。
9.2 内存屏障是什么

硬件层的内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障。

  • Load Barrier:在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据,即刷新处理器缓存;
  • Store Barrier:在指令后插入Store Barrier,可以让写入缓存中的最新数据更新写入主内存,让其他线程可见,即刷新处理器主存。
9.3 java内存屏障

java的内存屏障通常所谓的四种即LoadLoad,StoreStore,LoadStore,StoreLoad实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕,即保证了在屏障前的读取操作效果先于屏障后的读取操作效果发生
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕,即保证了屏障前的读操作效果先于屏障后的写操作效果发生
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见,保证了在屏障前的写操作效果先于屏障后的写操作效果发生
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见(强制把写缓冲区/高速缓存中的脏数据等写回主内存),保证了屏障前的写操作效果先于屏障后的读操作效果发生
    它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
9.4 volatile语义中的内存屏障

volatile的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:

在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;——读屏障Load Barrier
在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;——写屏障Store Barrier

由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了锁的特性。

9.4 synchronized语义中的内存屏障

Java 虚拟机会在 MonitorEnter ( 申请锁 ) 对应的机器码指令之后临界区开始之前的地方插入一个加载屏障(Load Barrier),这可以让高速缓存中的数据失效,强制重新从主内存加载数据,即刷新处理器缓存;

Java虚拟机会在 MonitorExit ( 释放锁 ) 对应的机器码指令之后插入一个存储屏障(Store Barrier),这可以让写入缓存中的最新数据更新写入主内存,让其他线程可见,即刷新处理器主存;

image.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容