Java 内存模型

335 阅读13分钟

JMM概述

Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。

Java内存模型解决了什么问题?

它主要解决了多线程并发访问共享变量时可能出现的数据竞争问题,如原子性、可见性和有序性。

什么是数据竞争问题?

每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,线程如果想要操作主存中的某个变量,那么必须通过工作内存间接完成,主要过程是将变量从主内存拷贝的线程自己的工作内存空间,然后对变量先在工作内存中进行操作,操作完成后再将变量刷写回主内存,如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题,可能会出现意想不到的行为,例如读到脏数据、重排序、死锁等。

JMM通过规定多线程在访问共享变量时的规则,保证了多线程之间的内存访问行为的正确性,避免了这些问题的发生。

JMM主内存和工作内存

Java内存模型规定了所有变量都存储在主内存中。每条线程还有自己的工作内存,保存了该线程使用的变量的内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行。不同线程之间也无法直接相互访问对方的工作内存,线程间变量值的传递需要通过主内存完成。

image.png

关于工作线程中变量的内存副本解释:

方法中引用对象或创建对象的实例是放在堆中,栈内存储的只是对象的引用地址。但是当线程执行到真正要拿到真实对象的实例时,会根据局部表中的对象引用地址去找到主存中的真实对象,然后会将对象拷贝到自己的工作内存再操作,但是当所操作的对象是一个大对象时(1MB+)这个对象的引用、对象中某个在线程访问到的字段是有可能被复制的,但不会有虚拟机把整个对象复制一次。

内存间交互操作

Java内存模型定义了如何从主内存拷贝到工作内存、如何从工作内存同步到主内存的实现操作。有如下8种操作,且每一步操作都是原子性的。

作用于主内存变量的操作:

  • lock(锁定): 把一个变量标识为一条线程独占状态。
  • unlock(解锁): 把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取): 把一个变量的值从主内存传输到线程的工作内存中,等待load操作使用。
  • write(写入): 把store操作从工作内存中得到的变量的值放入主内存的变量中。

作用于工作内存变量的操作:

  • load(载入): 把read操作从主内存中读取到的变量的值放入工作内存的变量副本中。
  • use(使用): 把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时执行这个操作。
  • assign(赋值): 把一个从执行引擎接受的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储): 把工作内存中一个变量的值传送给主内存中,等待write操作使用。

从主内存同步到工作内存的动作执行顺序:read(读取)->load(载入)->use(使用)

从工作内存同步到主内存的动作执行顺序:assign(赋值)->store(存储)->write(写入)

  • read和load必须成对并按顺序的出现
  • store和write必须成对并按顺序的出现
  • 一个新的变量只能在主内存中“诞生”;也就是说在执行use之前必须先load,store之前必须先assign
  • 一个线程不能只存在assign没有store和write操作
  • 一个线程也不能没有执行过assign操作而进store和write操作
  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但是lock操作可以被同一个线程重复执行,重复执行后需要对变量使用相同数量的unlock操作才能解锁
  • 没有被lock过的线程不能执行unlock操作;当然也不能去执行别的线程lock的变量
  • 变量在执行unlock操作之前,必须先把此变量同步到主内存中。

由于写操作或读操作都是必须成对出现的,在Java后来的JSR-133文档中已经将这8种操作描述简化为四种:read、write、lock和unlock。但内存模型并未改变,只是改了描述。

JMM的三大特性以及如何实现的

为了解决多线程环境下并发访问共享变量可能出现的数据竞争问题,JMM定义了如下规则:

原子性

JMM保证32位以下的基本数据类型(boolean、byte、char、short、int)的读写是原子性的。对于64位的long和double类型,JMM要求虚拟机在读取或写入时,必须将其作为连续的64位操作。如果不支持这样的原子性操作,JMM要求使用锁机制(如synchronized或者Lock)来保证原子性。

可见性

JMM保证在一个线程修改了共享变量的值后,其他线程能够立即看到这个修改。

这个保证是通过使用volatile关键字实现的。volatile关键字的作用是强制所有线程在读写该变量时,都从主内存中读取或写入,而不是从线程本地缓存中读取或写入。这样可以保证一个线程对volatile变量的修改对其他线程可见。

有序性

JMM保证程序执行的顺序是按照代码的先后顺序来执行的,但是也允许编译器和处理器进行指令重排序优化,只要不改变程序的执行结果。

JMM要求编译器和处理器必须遵守一定的规则,保证在不改变程序执行结果的情况下进行指令重排序。JMM规定了一个 happens-before关系,它保证了在前面的操作对后面的操作可见,从而保证了有序性。

JMM中 happens-before 关系

happens-before(先发生于)是JMM中的一个重要概念,用于描述多线程之间的内存可见性和指令执行顺序。happens-before 关系是指在一个多线程程序中,若操作A happens-before 操作B,那么操作A在内存中的影响一定是在操作B之前可见的。

具体来说,JMM定义了如下规则来确定happens-before关系:

  1. 程序顺序规则:即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
  2. valatile变量规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性。
  3. 传递性规则:A先于B ,B先于C,那么A必然先于C。
  4. 锁规则:对于一个锁的解锁操作,必然先发生于后续对改锁的加锁操作。这个规则保证了锁释放对后续获取操作是可见的。
  5. 线程启动规则:线程的start()方法先于它的每一个动作,且在一个线程中,如果线程A启动了线程B,那么线程A中的操作先发生于线程B中的操作。
  6. 线程终止规则:线程的所有操作先于线程的终结,且在一个线程中,如果线程A终止了线程B,那么线程B中的操作先发生于线程A中的操作。
  7. 线程中断规则:如果线程A中断了线程B,那么线程A中的操作先发生于线程B中的操作。
  8. 对象终结规则:对象的构造函数执行,先发生与finalize()方法。

Volatile关键字

Volatile是Java提供的轻量级同步工具,它能保证可见性和做到禁止指令重排做到有序性,但是它不能保证原子性

Volatile的可见性

当一个线程修改了一个volatile变量的值后,其他线程能够立即看到这个修改。这个保证是通过使用内存屏障来实现的,它将修改操作与读操作之间的重排序限制在了内存屏障之前。

Volatile为什么不能保证原子性

事实上来说,Volatile是可以保证32位以下的基本数据类型的读写操作是原子性的,但是对于64位或复合操作(如i++)等情况,Volatile并不能保证原子性。

当多个线程同时对volatile变量进行写操作时,这些写操作虽然是互相可见的,但是由于缺乏同步机制,它们之间的执行顺序是不确定的,因此可能会发生线程安全问题。

举个栗子:

volatile int count = 0;
​
void increase() {
   for (int i = 0; i < 1000; i++) {
       count++;
  }
}
​

假设有两个线程同时调用increase方法来增加count变量的值,由于count变量是volatile类型,每个线程对count的写操作都会立即对其他线程可见。但是,由于count变量的自增操作不是一个原子操作,即分为“读取-加1-写回”三个步骤,因此会出现以下两种情况:

  1. 线程1读取count的值为0,执行自增操作并写回count的值为1,此时线程2也读取count的值为0,执行自增操作并写回count的值为1,最终count的值为1,而不是期望的2。
  2. 线程1读取count的值为0,执行自增操作并写回count的值为1,此时线程2读取count的值为1,执行自增操作并写回count的值为2,最终count的值为2,符合预期。

因此,尽管count变量是volatile类型,但它并不能保证在多线程环境下的原子性,需要使用锁机制(如synchronized)或者使用JUC原子包下的原子类来保证线程安全。

Volatile禁止指令重排序与内存屏障

Volatile关键字另一个作用是禁止编译器或处理器对指令重排优化从而多到有序性。如何实现的关键在于内存屏障(Memory Barrier)。

内存屏障也叫内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。

编译器会在编译时对代码进行重排和优化,为了保证内存屏障的正确性,编译器需要将内存屏障插入到编译后的代码中。编译器插入的内存屏障是一个特殊的指令,它会告诉处理器在执行屏障之前或之后对内存进行特定的操作。对于读操作,编译器会在读操作之前插入读屏障,以确保读操作读取到的是最新的值。对于写操作,编译器会在写操作之后插入写屏障,以确保写操作对其他线程是可见的。

处理器也会执行内存屏障指令。在执行内存屏障之前,处理器会将所有之前的内存操作刷新到内存中,以确保之前的操作对其他线程是可见的在执行内存屏障之后,处理器会禁止之后的内存操作重排到屏障之前的内存操作之前,从而确保指令的有序性和可见性

Java中的内存屏障主要是通过volatilesynchronizedLockAtomic类等方式来实现的。例如,在使用volatile关键字修饰变量时,JVM会在编译时插入读屏障和写屏障,以确保volatile变量的可见性、原子性和有序性。

具体来说volatile是如何实现内存屏障的呢?

举个栗子,这是一个b

public class Singleton{
 private valotile static Singleton singleton;
 private Singleton(){}
 public static Singleton getInstance(){
    if(singleton == null){
         synchronized(Singleton.class){
               if(singleton == null){
                     singleton = new Singleton();
              }
        }
    }
}
}

为什么需要添加valotile关键字,原因在于虚拟机或CPU会在不影响结果的前提下对指令进行重排序优化,比如singleton= new Singleton();可以分为以下3步完成(伪代码)

memory = allocate(); //1.分配对象内存空间
singleton(memory);    //2.初始化对象
singleton = memory;   //3.设置singleton指向刚分配的内存地址,此时singleton != null

其中由于步骤2、3互相不影响结果,所以如果没有禁止重排序的话,可能会被重排序如下

memory = allocate(); //1.分配对象内存空间
singleton = memory;   //3.设置singleton指向刚分配的内存地址,此时singleton != null
singleton(memory);    //2.初始化对象

在单线程环境下,这样优化是没有问题的,但是在多线程并发访问的时候,可能就会出现当一个线程走到第二步的时候,另一个线程判断singleton不为空时会返回true的结果,但实际未初始化完成。

当一个变量被声明为volatile时,Java编译器会在编译时对读写操作进行优化,以确保读写操作的顺序和可见性。在读操作之前,编译器会插入读屏障;在写操作之后,编译器会插入写屏障。

除了volatile之外,Java中还有其他类型的内存屏障。例如,在使用synchronized关键字同步方法或代码块时,JVM会在进入和退出临界区域时插入读屏障和写屏障,以确保线程间的同步和可见性;在使用Lock类进行加锁时,Lock接口提供了一系列方法来控制内存屏障的执行顺序和范围,以适应不同的同步场景。