深入理解Java内存模型(JMM)

社区Java

https://8.130.45.15:3002/#/chat/1002

前言

CPU都有自己的L1、L2、L3缓存,CPU会将常用的数据,从主内存同步到缓存中,以此来提高数据的访问速度。如果CPU修改了缓存中的数据,就会从缓存更新到主内存中。

缓存一致性

如今,我们使用的电脑都是多个CPU,当多个CPU同时修改了一个数据时,那么主内存中数据要以谁的为准,这就容易造成缓存不一致的情况。

picture.image

假如主存中的i为0,每个CPU缓存中的i也为0。3个CPU都对i进行了+1操作,按理说最后主存的i应该为3,但是实际i为1、2、3都有可能。所以,系统为了保证多CPU之间的缓存一致性,针对内存和缓存之间的数据读写,就制定了一些协议。

picture.image

缓存一致性协议保证了多CPU下的数据一致性,最后在主存中i=3。

刚开始是通过在总线加LOCK#锁的方式实现缓存一致性,但会阻塞其他cpu访问内存,所以intel提出了MESI协议:

  1. 在多处理器下,为保证各个处理器的缓存是一致的,每个处理器都会通过嗅探在总线上传播的数据来检查自己缓存的值是否过期
  2. 当处理器发现自己缓存行对应的地址被修改,就会将当前处理器的缓存行设置为无效状态
  3. 当处理器对这个数据进行读写的时候,会重新把数据从内存中读取到处理器缓存中

所以缓存一致性协议还要保障数据的可见性。

内存模型

那什么是内存模型呢?

内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的抽象过程。
--《深入理解Java虚拟机》

可以理解为内存模型就是缓存一致性协议中,对于多CPU缓存和主存之间实现读写一致性的实现。

既然系统自带内存模型,为什么Java还有自己的内存模型?

不同处理器架构和系统架构可能会使用不同的缓存一致性协议,例如常见的x86架构使用的MESI协议,ARM架构使用MESIF或者MOESI协议,IBM Power架构的MECI。Java为了屏蔽不同架构之间对内存访问的差异性,保证Java的平台无关性,所以就制定Java内存模型(Java Memory Model, JMM)。

Java内存模型

上面讲到缓存一致性协议是为了保证多CPU下的缓存与主存数据一致性,那么JMM就是为了保证Java多线程下的线程工作内存和主存之间的数据一致性。

picture.image

这里的Java线程就相当于CPU,工作内存就相当于CPU的缓存,通过JMM来实现与缓存与主内存之间的数据一致性。

在学习jvm内存结构的时候,我们知道每个线程都有自己的虚拟机栈,而线程的工作内存就是存放在该线程的虚拟机栈中,用于存储线程私有的数据。而主内存对应的就是多线程共享的Heap存放对象的数据部分。线程在执行过程中会将共享变量从主内存加载到自己的工作内存中进行操作,然后再将结果写回主内存。

为了保证多线程之间的数据一致性,JMM定义了8种原子操作来实现一致性。

picture.image

在执行操作时要满足以下规则:

  1. read/load、store/write必须成对出现,且顺序执行
  2. 不允许线程丢弃最近的assign操作,即工作内存中变量修改之后必须同步到主内存
  3. 不允许线程无原因地(没有assign操作)将变量工作内存同步到主内存
  4. 变量只能在主内存中诞生,并且必须在工作内存中初始化才能使用。即use、store之前必须经过load和assign
  5. 一个变量在同一时刻只能被一个线程lock。但一个线程可以lock多次。几次lock,只有对应次数的unlock变量才能解锁
  6. 一个变量被lock后,会清空所有工作内存中此变量的值。再次使用需要重新load、assign来初始化
  7. 一个变量未被lock,则不允许对它执行unlock,也不允许unlock其他线程lock的变量
  8. 一个变量unlock前,必须先把此变量同步到主内存中(store、write)

既然JMM规定了多线程工作内存和主存之间的操作规则,那么在Java中具体是如何实现的。

volatile

Java中的关键字volatile实现了可见性禁止指令重排序,这里只讲可见性。

定义一个volatile变量,通过汇编来看看volatile是如何保证可见性的。

public class VolatileTest {
    volatile static int a;
    public static void main(String[] args) {
        a = 2;
    }
}

输出并查看汇编:

picture.image

movabs将class加载到rsi中,mov $0x2,%edi是将2移动到edi中,mov %edi,0x68(%rsi) 是将edi中的值(2)存放在rsi指定的内存地址(对应的是变量a),这里可以理解为a=2的赋值操作。然后在addl前添加了lock前缀,将最新的a写到主存中。

在x86架构中,lock前缀指令会引发缓存行的写入主存。当一个处理器执行一个带有lock前缀的指令时,它会将修改后的数据写回到内存中,并会锁定总线,防止其他处理器同时访问该内存位置,从而确保操作的原子性。这个就相当于对缓存中的变量做了做了一次storewrite操作。

如果a没有使用volatile修饰,查看汇编:

picture.image

汇编指令中没有lock,movl直接将rsi寄存器中的变量a更新成了2,而没有加载到主存。

应用场景

volatile适用于两种场景:

  1. 对变量的写入操作不依赖变量的当前值,或确保只有单线程更新变量值
  2. 该变量不与其他状态变量共同参与不变性条件中

怎么理解呢?写一段代码如下:

volatile int a = 0;
int b = 1;
a = 1;
a++;
int a = b + 1;

可见性就是当一个线程修改了变量的值,其他线程能立即得知修改。当a=1修改a之后,就会被其他线程获取到a最新的值,这就是可见性。

场景1就对应着a++操作,a++拆解开其实是a + 1 = 2和 a = 2是两步操作,所以++不是原子操作,且a++依赖本身a的值,所以如果多线程修改a的话,保证不了可见性。

picture.image

场景2对应b + 1,a的值依赖于b,多线程之间无法保证b的可见性,所以无法保证在b + 1时的b是最新值。

所以这个时候就需要通过加锁来实现原子性。我们最常见的锁就是synchronized。

0
0
0
0
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论