Java内存模型

最后更新:2021-04-02

1.Java内存模型

java 内存模型(JMM)是一种抽象的概念,并不真实存在,它描述了一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式。试图屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果

Java内存模型把Java虚拟机内部划分为线程栈和堆。

  • Head(堆):java里的堆是一个运行时的数据区,堆是由垃圾回收机制来负责的。堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的,而且Java的垃圾回收机制也会自动的收走那些不再使用的数据。但是它也有缺点,由于是运行时动态分配内存,因此它的存取速度相对要慢一些。
  • Stack(栈):栈的优势是存取速度比堆要快,仅次于计算机里的寄存器,栈的数据是可以共享的。而栈的缺点则是存在栈中的数据的大小以及生存期必须是确定的,缺乏一些灵活性,所以栈中主要用来存储一些基本数据类型的变量,比如:int,short,long,byte,double,float,boolean,char以及对象句柄等。

每一个运行在Java虚拟机里的线程都拥有自己的线程栈。这个线程栈包含了这个线程调用的方法当前执行点相关的信息。一个线程仅能访问自己的线程栈。一个线程创建的本地变量对其它线程不可见,仅自己可见。即使两个线程执行同样的代码,这两个线程任然在在自己的线程栈中的代码来创建本地变量。因此,每个线程拥有每个本地变量的独有版本。

所有原始类型的本地变量都存放在线程栈上,因此对其它线程不可见。一个线程可能向另一个线程传递一个原始类型变量的拷贝,但是它不能共享这个原始类型变量自身。

堆上包含在Java程序中创建的所有对象,无论是哪一个对象创建的。这包括原始类型的对象版本。如果一个对象被创建然后赋值给一个局部变量,或者用来作为另一个对象的成员变量,这个对象任然是存放在堆上。

一个本地变量也可能是指向一个对象的引用,这种情况下这个保存对象引用的本地变量是存放在线程栈上的,但是对象本身则是存放在堆上的。

一个对象可能包含方法,而这些方法可能包含着本地变量,这些本地变量仍然是存放在线程栈上的。即使这些方法所属的对象是存放在堆上的。一个对象的成员变量,可能会随着所属对象而存放在堆上,不管这个成员变量是原始类型还是引用类型。静态成员变量则是随着类的定义一起存放在堆上。

存放在堆上的对象,可以被持有这个对象的引用的线程访问。当一个线程可以访问某个对象时,它也可以访问该对象的成员变量。如果两个线程同时调用同一个对象上的同一个方法,那么它们都将会访问这个方法中的成员变量,但是每一个线程都拥有这个成员变量的私有拷贝。

2.1. 主内存与工作内存

处理器上的寄存器的读写的速度比内存快几个数量级,为了解决这种速度矛盾,在它们之间加入了高速缓存。

加入高速缓存带来了一个新的问题:缓存一致性。如果多个缓存共享同一块主内存区域,那么多个缓存的数据可能会不一致,需要一些协议来解决这个问题。

所有的变量都存储在主内存中,每个线程还有自己的工作内存,工作内存存储在高速缓存或者寄存器中,保存了该线程使用的变量的主内存副本拷贝。

线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成。

2.2. 数据存储类型以及操作方式

方法中的基本类型本地变量将直接存储在工作内存的栈帧结构中;

引用类型的本地变量:引用存储在工作内存,实际存储在主内存;

成员变量、静态变量、类信息均会被存储在主内存中;

主内存共享的方式是线程各拷贝一份数据到工作内存中,操作完成后就刷新到主内存中。

2. 内存间交互操作

线程间如果要完成变成的同步和共享,必须经历下面2个步骤。

  1. 线程A必须要把线程A的工作内存更新过的变量刷新到主内存去。

  2. 线程B到主内存中去读取线程A更新过的共享变量

这些通讯操作是被JMM屏蔽的,要保证变量的线程安全共享 需要使用Java的同步块(synchonrized),或者其他并发工具。这里强调的是安全共享,在不加同步块,和并发工具的情况下,变量也是可以被共享的,只是不能保证读都最新数据,就是常说的 脏读,错读等

Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作。

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

网上找了个更完整的图

JMM规定如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序地执行store和write操作。

注意,Java内存模型只要求上述两个操作必须按顺序执行,而没有保证是连续执行。也就是说,read与load之间、store与write之间是可插入其他指令的,如对主内存中的变量a、b进行访问时,一种可能出现顺序是read a、read b、load b、load a。

Java内存模型还规定了在执行上述8种基本操作时,必须满足如下规则:

  • 不允许 read 和 load、store 和 write 操作之一单独出现
  • 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量。即就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,lock 和 unlock必须成对出现
  • 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值
  • 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。

3. Volatile

关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制. 当一个变量定义为volatile之后,它将具备两种特性,第一是保证此变量对所有线程的可见性. 第二个语义是禁止指令重排序优化.

这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成,例如,线程A修改一个普通变量的值。需要注意的是,volatile只是保证了可见性,很容易误解为volatile变量在各个线程中是一致的,所以基于volatile变量的运算在并发下是安全的。但是Java里面的运算并非原子操作,各个工作区的volaitile的变量可能存在不一致的情况,导致volatile变量的运算在并发下一样是不安全的。

Volatile不保证原子性

volatile读前插读屏障,写后加写屏障,避免CPU重排导致的问题,实现多线程之间数据的可见性。

x86架构为例,JVM对volatile变量的处理如下:

  • 在写volatile变量v之后,插入一个sfence。这样,sfence之前的所有store(包括写v)不会被重排序到sfence之后,sfence之后的所有store不会被重排序到sfence之前,禁用跨sfence的store重排序;且sfence之前修改的值都会被写回缓存,并标记其他CPU中的缓存失效。
  • 在读volatile变量v之前,插入一个lfence。这样,lfence之后的load(包括读v)不会被重排序到lfence之前,lfence之前的load不会被重排序到lfence之后,禁用跨lfence的load重排序;且lfence之后,会首先刷新无效缓存,从而得到最新的修改值,与sfence配合保证内存可见性。

4. 局部变量

Java线程可以拥有自己的操作数栈,程序计数器、局部变量表等资源;我们都知道,多个线程同时访问共享变量的时候,会导致数据不一致性等并发问题;但是 Java 方法里面的局部变量是不存在并发问题的。

局部变量的作用域是方法内部的,当方法执行完了,局部变量也就销毁了,也就是说局部变量应该是和方法同生共死的。

当调用方法时,会创建新的栈帧,并压入调用栈;当方法返回时,对应的栈帧就会被自动弹出。也就是说,栈帧和方法是同生共死的。

方法的调用就是压栈和出栈的过程,而在Java中的方法的局部变量又是存储在栈帧中,而每个线程都有自己独立的调用栈,所以局部变量不存在并发问题

5. 内存模型三大特性

5.1. 原子性

Java 内存模型保证了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性,例如对一个 int 类型的变量执行 assign 赋值操作,这个操作就是原子性的。但是 Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行,即 load、store、read 和 write 操作可以不具备原子性。

如果应用场景需要一个更大范围的原子性保证(经常会遇到),Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。

5.2. 可见性

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

除了volatile之外,Java还有两个关键字能实现可见性,即synchronized和final。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final字段的值。

5.3. 有序性

有序性是指:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。

前半句是指“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。

Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性

  • volatile 关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。
  • synchronized 它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。

5.3.1. 先行先发原则

如果Java内存模型中所有的有序性都仅仅靠volatile和synchronized来完成,那么有一些操作将会变得很烦琐,但是我们在编写Java并发代码的时候并没有感觉到这一点,这是因为Java语言中有一个“先行发生”(happens-before)的原则。这个原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以让一个操作无需控制就能先于另一个操作完成。

由于指令重排序的存在,两个操作之间有happen-before关系,并不意味着前一个操作必须要在后一个操作之前执行。 仅仅要求前一个操作的执行结果对于后一个操作是可见的,并且前一个操作按顺序 排在第二个操作之前。

5.3.2. 单一线程原则(程序员顺序规则)

Single Thread rule 在一个线程内,在程序前面的操作先行发生于后面的操作。

5.3.3. 管程锁定规则(监视器锁规则)

Monitor Lock Rule 一个 unlock(解锁) 操作先行发生于后面对同一个锁的 lock(加锁) 操作。

5.3.4. volatile 变量规则

Volatile Variable Rule 对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。

5.3.5. 线程启动规则

Thread Start Rule Thread 对象的start()方法调用先行发生于此线程的每一个动作。

5.3.6. 线程加入规则

Thread Join Rule Thread 对象的结束先行发生于 join() 方法返回。

5.3.7. 线程中断规则

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

5.3.8. 对象终结规则

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

5.3.9. 传递性

Transitivity 如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。

7. 参考资料

https://cloud.tencent.com/developer/article/1429393

https://www.jianshu.com/p/64240319ed60

https://mp.weixin.qq.com/s/SZl2E5NAhpYM4kKv9gyQOQ

https://mp.weixin.qq.com/s/0H9yfiYvWGQByjFT-fj-ww

Edgar

Edgar
一个略懂Java的小菜比