Java多线程-java内存模型

1.JMM

java内存模型:JMM是一组规范,需要各个JVM的实现来遵守JMM规范,以便于开发者可以利用这些规范,更方便地开发多线程程序,如果没有这样的一个JMM内存模型来规范,那么很可能经过了不同JVM的不同规则的重排序之后,导致不同的虚拟机上运行的结果不一样,那是很大的问题。

JMM最重要的3点内容是:原子性,可见性,有序性。

jvm内存结构:堆、虚拟机栈、方法区、本地方法栈、程序计数器

java对象模型:java对象自身的存储模型,是一个逻辑抽象。

JMM的抽象:主内存和工作内存(这仅仅是JMM中的概念)

主存和工作内存的关系

  1. 所有的变量都存储在主内存中,同时每个线程也有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝。

  2. 线程不能直接读写主内存中的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中。

  3. 主内存是多个线程共享的,但线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成。

  4. 所有的共享变量存在于主内存中,每个线程有自己的本地内存,而且线程读写共享数据也是通过本地内存交换的,所以才导致了可见性问题。

volatile、synchronized、Lock等的原理都是JMM

2.原子性

保证指令不会收到线程上下文切换的影响。最明白的体现就是synchronized,线程拿着锁,其他线程不能访问临界区的代码。

3.可见性

保证指令不会受到CPU缓存的影响。

如下代码中子线程不会停止运行,因为main函数对run值的修改对子线程不可见。为什么会不可见呢?是因为每当子线程执行的时候都会去主内存中读取run的值,即时编译器觉得太麻烦了,就将run的值缓存到线程的工作内存中,每次从这里读取,因此主线程修改了主内存中run的值,并不会改变工作内存中run的值。

1
2
3
4
5
6
7
8
9
10
11
12
static Boolean run = true;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
while (run) {
//如果run为真,则一直执行
}
}).start();

Thread.sleep(1000);
System.out.println("改变run的值为false");
run = false;
}

如何解决?

在变量前面加上volatile关键字(易变),这样子线程就不会将run缓存到工作内存中了,而是每次都需要去主内存中读取。

volatile是一种同步机制,比synchronized或者Lock相关类更轻量,因为使用volatile并不会发生上下文切换等开销很大的行为。

volatile它可以用来修饰成员变量静态成员变量,它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量就是直接操作主存。

volatile最好用在一个线程写,多个线程读的情况,因为它无法保证原子性。

synchronized语句块既可以保证代码块的原子性,也可以保证代码块内变量的可见性,但全店是synchronized属于重量级操作,性能低。

4.有序性

保证指令不会受到CPU指令并行优化的影响。

指令重排序是因为CPU底层会将指令并行处理,以达到最大效率。流水线模式

JVM 在不影响正确性的前提下,会调整语句的执行顺序,进行指令重排。多线程下『指令重排』会影响正确性

volatile 修饰的变量,可以禁用指令重排,保证有序性。

5.volatile原理

5.1 volatile可见性底层如何实现

不同的物理CPU硬件所提供的内存屏障指令差异很大, JMM定义了一套相对独立的内存屏障指令,用于屏蔽不同硬件的差异,然后在volatile关键字中包含这些指令。然后在不同的硬件平台上,JMM内存屏障指令会要求jvm为不同平台生成相应的硬件层的内存屏障指令。

比如在X86处理器上,volatile被JVM编译过之后,它的汇编代码中会被插入一条lock addl 前缀指令,来实现全屏障的目的。

lock addl指令的作用:

  1. 将当前CPU缓存行(在JMM中是工作内存)的数据立即写回系统内存(在JMM中是主存)

  2. lock指令会引起在其他CPU中缓存了该内存地址的数据无效

  3. lock前缀指令禁止指令重排(作为内存屏障)

5.2 重排序

重排序

主要是CPU重排序导致的数据不一致问题。

  • 指令级重排序

    在不影响执行结果的情况下,CPU内核采用ILP指令级并行运算技术来将多条指令重叠执行,前提是指令之间不存在数据依赖性。

    编译器和CPU都遵循As-if-Serial规则,即可以保证在单核CPU执行下他们不会对有数据依赖的指令进行重排序,但是多核并发的情况下,CPU的一个内核无法分辨其他内核上指令序列的数据依赖关系,因此可能会乱序执行。

  • 内存系统重排序

    内存重排序实际上并不是真的相关操作被排序了,而是因为CPU引入缓存还没来得及刷新导致。

    每个CPU都有自己的缓存,为了提高共享变量的写操作,CPU把整个操作变成异步的了,如果写入操作还没来的及同步到其它CPU,就有可能发生其它CPU读取到的是旧的值,因此看起来这条指令还没执行一样。

    内存重排序这里引用https://cloud.tencent.com/developer/article/18571747

所以要加入内存屏障来禁止多线程环境下的指令重排


硬件层面的内存屏障是如何实现的?

读屏障:指令前插入读屏障,可以让高速缓存中的数据失效,强制重新从主存加载数据,并且,读屏障会告诉CPU和编译器,先于这个屏障的指令必须先执行。

写屏障:指令后插入写屏障指令能让能让高速缓存中的最新数据更新到主存,让其他线程可见,并且写屏障会告诉CPU和编译器,后于这个屏障的指令必须后执行。

全屏障:读屏障+写屏障。

先于这个屏障的指令必须先执行,后于这个屏障的指令必须后执行,即禁止屏障两侧的指令重排。

高速缓存的最新数据写回到主存,其他核心的高速缓存中的数据会失效,强制从主存中加载数据。

volatile关键字修饰的变量,它的读屏障加在这个变量所在行的前面,写屏障加在这个变量所在行的后面。

在X86处理器上,lock前缀指令、mfence指令具有全屏障功能。


volatile的底层实现原理是内存屏障

保证可见性

  • 在读取用volatile修饰的变量时,在这一行前面加上读屏障,则jvm对共享变量的读取,加载的是主存中的最新数据。
  • 在给用volatile修饰的变量赋值时,在这一行后面加上写屏障,则对共享变量的改动赋值等都会同步到主存中。

另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

保证有序性

  • 在读取用volatile修饰的变量时,在这一行前面加上读屏障,则读屏障会确保指令重排时,不会将读屏障之后的代码排在读屏障之前。
  • 在给用volatile修饰的变量赋值时,在这一行后面加上写屏障,则写屏障会保证指令重排时,不会将写屏障之前的代码排在写屏障之后。

另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中;并禁止上面的普通写和volatile写进行重排序。
StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序
LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序
LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序

Load是读屏障,Store是写屏障

还有一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次使用之前都从主内存刷新。

5.3 为什么volatile不保证原子性?

现在有两个线程,都对value自增1,最终结果应该是2,但是实际上并不是。

他们都将value=0读取到了自己的工作内存,然后线程A将value改为1,但还没有进行写入主存,此时线程B拿到时间片,将value改为1并写入主存,再到线程A,它将脏数据1写入到了主存,最终value的值是1

5.4 Happens-Before规则

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

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

  • 程序次序规则(As-if-serial规则):同一个线程内,按照代码顺序,对于有依赖关系的语句,书写在前面的操作先行发生于书写在后面的操作,保证了单线程下的有序性。
  • 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,如果线程A执行了线程B的Thread.join并成功,那线程B的任意操作都先行于线程A执行的Thread.join。
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

https://www.cnblogs.com/dolphin0520/p/3920373.html


Java多线程-java内存模型
https://vickkkyz.fun/2022/04/18/Java/JUC/JMM/
作者
Vickkkyz
发布于
2022年4月18日
许可协议