Java内存模型(JMM)与指令重排

本文深入探讨Java内存模型(JMM)、缓存一致性、处理器优化、指令重排及happens-before规则,揭示多线程环境下Java如何确保数据的一致性和可见性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

本篇博客是博主在看过《Java并发编程的艺术》一书后,对JMM、缓存一致性、处理器优化和指令重排以及happens-before相关知识的整理,并加入了一些博主个人的理解,如若博主哪里理解有误,望大佬们指出来。

Java内存模型

Java内存模型(Java Memory Model)控制着Java线程之间的通信,它决定了一个线程对一个共享变量的写入什么时候对另外一个线程可见。实际上JMM就是一个虚拟的抽象规范模型,通过这组规范定义了程序中的多个变量的访问方式。从抽象的角度来看,JMM定义了线程之间的抽象关系:线程之间的共享数据存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),这个本地内存存储了该线程通过读/写共享变量而产生的数据副本,而线程之间要完成通信,就需要不同线程对应的本地内存与主内存做数据的修改(读/写)。

JMM抽象示意如下:
在这里插入图片描述
从JMM的抽象示意图中可以看出两个线程要进行通信,必须经过两个步骤:

  1. 线程A要将本地内存中更新过的共享变量刷新到主内存中
  2. 线程B从主内存中读取线程A更新过的共享变量。

从整体来看,这两个步骤就是线程A向编程B发送消息的过程,且这个过程一定会经过主内存。JMM其实就是通过控制各线程本地内存与主内存之间数据交互,来提供内存可见性的。

JMM 同步规定:

  1. 线程解锁前,必须把共享变量的值刷新回主内存
  2. 线程加锁前,必须读取主内存的最新值到对应线程的本地内存
  3. 加锁解锁是同一把锁

JMM的3大特性(也是多线程及高并发的设计重点):

  1. 可见性
  2. 原子性
  3. 有序性

为什么JMM会存在,直接使用内存不行吗?

JMM的出现就是为了解决下面两个问题:

  • CPU和缓存一致性
  • 处理器优化和指令重排

CPU和缓存一致性

由于CPU与物理内存的读写速度有很大的差距,为了解决CPU与物理内存的速度差异,在CPU与物理内存之间引入了高速缓存来中和这个速率差。
缓存大家都知道,就是在保存一份内存中的数据拷贝。他的特点是速度快,内存小,并且昂贵。
此时,程序的执行过程就变成了:
程序执行时,会将需要的数据从主内存中拷贝一份放在CPU的高速缓存中,CPU在计算时就会直接在高速缓存中读取/写入数据,在运算结束之后,再将高速缓存中的数据刷新到主内存中。

然而随着CPU的不断升级,一层高速缓存已经不足以支持CPU快速读取数据的需求了,进而出现了多级缓存
此时,程序的执行过程又变成了:
程序执行时,会在一级缓存中读取运算所需数据,若获取失败则在二级缓存中获取,若仍未获取到,则在三级缓存或内存中进行读取。
但是在多核CPU下又引出了一个新的问题: 缓存一致性

多核CPU中,每个处理器都有自己的多级缓存,且不同处理器之间无法直接进行交互,但是主内存只有1个,由于多核是可以并行的,那么当多个处理器运算时可能会出现多个线程同时写各自的缓存的情况,而各缓存之间的数据就有可能不同。这就是缓存一致性问题,简单点说就是每个核的缓存中对同一个数据缓存的值可能不一致。

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

处理器优化和指令重排

在执行程序时,为了提高性能,编译器和处理器常常会通过对指令做重排序,来提高程序的执行效率。

重排序分为三种:

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

JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过这些内存屏障指令来禁止特定类型的处理器重排序。

指令重排

现代处理器使用写缓冲区保存箱内存写入的数据。写缓冲区能够保证指令流水线持续执行,可以避免由于处理器停顿等待向内存写入数据而产生的延迟等等,但是每个处理器上的写缓冲区仅对当前处理器课件,这就会对内存操作的顺序产生严重影响:处理器对内存的读/写操作顺序可能并不是实际的读/写顺序。

线程A与线程B执行的代码与结果如下:

线程A线程B
代码a = 1;
x = b;
b = 2;
y = a;
结果x=y=0;

这种情况是可能的一种情况,导致这种结果的原因如下:
在这里插入图片描述
代码中我们的期望是处理器A将变量a赋值1, 同时处理器B将变量b赋值为2之后,处理器A与B再读取变量a、b的值分别赋值给x,y。
但是实际操作中,处理器执行完A1后,紧接着执行了A2这个步骤,相应的处理器B也在执行完B1后执行了B2,最后才将两个写缓冲区的值刷新到内存中,所以我们得到了x=y=0这个结果,此时,处理器A和B的内存操作顺序都被重排序了。
这里由于写缓冲区仅对所在的处理器可见,所以会导致执行顺序与实际顺序不一致。但由于现代的处理器都会使用写缓冲区,因此现代的处理器都允许对写-读操作进行重排序:
在这里插入图片描述
其中Y表示允许重排序,N表示不允许重排序。从表中可以看出sparc-TSO和X86拥有相对较强的处理器内存模型,仅对写-读操作做重排序(因为他们都使用了写缓冲区)

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定的处理器重排序,JMM把内存屏障分为4类:

屏障类型指令示例说明
LoadLoad BarriesLoad1; LoadLoad; Load2确保Load1数据的装载先于Load2及之后的装载
StoreStore BarriesStore1; StoreStore; Store2确保Store1数据对其他处理器可见,即Stroe1的结果刷新到内存的操作先于Store2及之后的存储指令
LoadStore BarriesLoad1; LoadStore; Store2确保Load1数据的装载先于Store2及之后的存储指令刷新到内存
StoreLoad BarriesStore1; StoreLoad; Load2确保Store1数据对其他处理器可见,即Stroe1的结果刷新到内存的操作先于Load2及之后的装载指令的装载
其中StoreLoad Barries是一个全能型屏障,它同时具有其他3个屏障的效果。

happens-before

从JDK5开始,Java就已经使用新的JSR-133内存模型。它使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行结果需要对另一个操作可见,那么这两个操作之间就存在happens-before关系。这里的两个操作可以存在于同一个线程内,也可以存在于不同的线程。

注意,两个操作存在happens-before关系,并不代表者前一操作一定先于后一操作执行,只要保证前一操作的执行结果对后一操作可见即可,这样说可能有些难以理解,这里举个栗子解释下:

int a = 1;		//操作a
int b = 2;		//操作b
int c = a + b;	//操作c

上面的代码存在3个happens-before关系,分别如下:

  • a happens-before b
  • b happens-before c
  • a happens-before c

其中第一条happens-before关系中,操作a与b的顺序孰先孰后并不影响最终结果,操作a的执行结果都是对操作b可见的,这也就验证了上面那句话。由于三个happens-before关系中,2和3是必需的,但是1并不是,因此,JMM会把happens-before要求禁止的重排序分成两种:

  • 会影响程序执行结果的重排序
  • 不会影响程序执行结果的重排序

JMM对于这两种重排序会做不同的处理:

  • 对于会影响程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
  • 对于不会影响程序执行结果的重排序,JMM将不做要求。

我们可以发现JMM其实一直在遵循一个基本原则:只要不改变程序的执行结果,编译器和处理器可以尽其所能地优化程序。因此,happens-before关系在本质上与as-if-serial语义是一致的:

  • as-if-serial语义保证单线程内程序的执行结果不变;happens-before关系保证正确同步的多线程程序的执行结果不被改变。
  • as-if-serial语义给编写单线程的程序员带来一种“单线程程序是按照程序顺序执行的”的错觉;happens-before关系给编写正确同步的多线程程序员带来一种“正确同步的多线程程序是按happens-before指定的顺序来执行的”的错觉

happens-before规则

  1. 程序顺序规则:一个线程中的每一个操作,happens-before于该线程中任意后续操作
  2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁
  3. volatile变量规则:对一个volatile变量的写,happens-before于随后对这个变量的读
  4. 传递性:如果A happens-before B,且B happens-before C,那么就有A happens-before C
  5. start()规则:如果线程A启动线程B,即执行ThreadB.start(),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作
  6. join()规则:如果线程A执行ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值