并发(三、Java内存模型)

1、JMM简介

Java内存模型(Java Memory Model,JMM),是一种抽象的模型,被定义出来屏蔽 各种硬件和操作系统的内存访问差异。

JMM定义了线程和主内存之间的抽象关系:线程之间的 共享变量 存储在 主内存(Main Memory)中,每个线程都有一个私有的 本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的 副本拷贝。本地内存又称工作内存。

JCP 定义了一种 Java内存模型,以前是在 JVM规范中,后来独立出来成了 JSR-133( Java内存模型和线程规范修订 )。

内存模型:在特定操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。

Java内存模型主要关注 JVM中把变量值存储到内存和从内存中取出变量值这样的底层细节。

在这里插入图片描述

上图为 Java内存模型的抽象图,本地内存是 JMM 的一个抽象概念,并不真实存在。实际的线程工作模型如下图所示,其中每个线程都有自己的控制器和运算器,以及一级缓存。有些架构还有一个共享的二级缓存。这些硬件组件对应着 JMM中的本地内存。

在这里插入图片描述

● Java内存模型小结

  • 所有 变量(共享变量)都存在主内存,每个线程有自己的本地内存,本地内存保存该线程用到的共享变量的副本拷贝。
  • 线程对变量的所有操作(读/写)都应该在本地内存中完成。
  • 不同现成不能互相访问本地内存,交互数据要通过主内存。

其实一直以来说的线程安全问题针对的就是主内存中的共享变量。

● 内存间交互操作抽象

  1. lock:锁定。线程1 申请到锁,把主内存中的共享变量 a 标识为 线程1 独占。
  2. read:读取。把主内存中的共享变量 a 读取到本地内存。
  3. load:载入。把 read 读取到的值放入本地内存的变量副本中。
  4. use:使用。把本地内存的变量副本传递给执行引擎。
  5. assign:赋值。将执行引擎返回的值赋值给本地内存的变量副本。
  6. store:存储。将本地内存的变量副本值传递到主内存。
  7. write:写入。把 store 存进来的数据放入主内存中的共享变量 a。
  8. unlock:解锁。线程1 释放主内存中共享变量 a 的锁。

● 内存间交互的基本规则

  1. 不允许 read 和 load 、store 和 write 操作之一单独出现,以上两对操作必须按顺序执行,但不保证连续执行。
  2. 不允许一个线程丢弃它最近的 assign 操作,即变量在本地内存改变了之后必须要把该变化同步回主内存。
  3. 不允许一个线程无原因地(没有发生任何 assign 操作)把数据从线程的本地内存同步回主内存。
  4. 一个新的共享变量只能从主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化的共享变量副本,也就是对一个共享变量副本实施 use 和 store 之前,必须先执行过了 load 和 assign。
  5. 一个共享变量在同一时刻只允许一个线程对其执行 lock 操作,但 locak 操作可被同一线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock,此共享变量才会被解锁。
  6. 如果对一个共享变量执行 lock 操作,将会清空本地内存中此变量副本的值,在执行引擎使用这个变量副本之前,需要重新执行 load。
  7. 如果一个变量没有被 lock 操作锁定,则不允许对其执行 unlock,也不能 unlock 一个被其他线程锁定的共享变量。
  8. 对一个共享变量执行 unlock 操作之前,必须先把此变量副本值同步回主内存(执行 store 和 write)

2、原子性、可见性、有序性

原子性、有序性、可见性是并发编程中非常重要的基础概念,用于描述多线程环境下的内存访问行为,JMM的很多技术都是围绕着这三大特性展开。

  • 原子性(Atomicity) :原子性是指一个操作是不可分割的,要么完全执行,要么全不执行。在多线程环境下,原子性保证了对共享变量的操作是原子的,不会被其他线程中断。
  • 可见性(Visibility) :可见性是指当一个线程对共享变量进行修改后,其他线程能够立即看到这个修改。在多线程环境下,由于每个线程都有自己的本地内 存,线程之间对共享变量的修改不一定能够及时同步到主内存和其他线程的本地内存中。可见性保证了共享变量的修改对其他线程是可见的,即能够正确读取到最新的值。
  • 有序性(Ordering) :有序性是指程序执行的顺序与代码的顺序一致。在多线程环境下,由于指令重排序和编译器优化的存在,代码的执行顺序可能与代码的编写顺序不一致。有序性保证了程序的执行顺序与代码的顺序一致,即保证了代码的串行语义。

为了保证原子性、可见性和有序性,我们可以使用同步机制(如锁、volatile关键 字、synchronized关键字等)来控制线程的访问和操作。

● 如何保证

  • 原子性:使用同步机制,如synchronized关键字、Lock接口、Atomic类等,来控制对共享变量的访问和操作,确保操作是不可分割的。
  • 可见性:J使用volatile关键字或者同步机制,如synchronized关键字、Lock接口等,来保证对共享变量的修改对其他线程是可见的。
  • 有序性:使用volatile关键字或者同步机制,如synchronized关键字、Lock接口 等,来保证程序执行的顺序与代码的顺序一致。

3、指令重排

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3 种类型。

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如图:

在这里插入图片描述

我们比较熟悉的双重校验单例模式就是一个经典的指令重排的例子,Singleton instance=new Singleton(); 对应的 JVM 指令分为三步:分配内存空间–>初始化 对象—>对象指向分配的内存空间,但是经过了编译器的指令重排序,第二步和第三 步就可能会重排序。

在这里插入图片描述

JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通 过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。


4、指令重排限制

数据依赖:如果两个操作访问同一个共享变量,且其中一个操作为写操作,那么这两个操作之间就存在数据依赖性。

  • 读后写
  • 写后写
  • 写后读

指令重排也是有一些限制的,有两个规则 happens-beforeas-if-serial 来约束。

4.1、happens-before

happens-before 定义:

  • 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么这种重排序并不非法。

happens-before 是用来保证两个操作之间的可见性。这两个操作既可以在一个线程里,也可以在不同的线程之间。

上面定义里第一条是 JMM 对程序员做出的一个逻辑保障;第二条是 JMM 对编译器、处理器进行重排序的约束原则(无论单线程还是多线程,只要不改变程序的执行结果,那么你怎么优化都没问题)。

happens-before和我们息息相关的有六大规则:

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

volatile 关键字提供了一种简单的机制来确保变量的内存可见性和防止指令重排序。详情访问 volatile。

4.2、as-if-serial

as-if-serial 语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。编译器、JVM 和处理器都必须遵守 as-if-serial 语义。

为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系, 这些操作就可能被编译器和处理器重排序。

as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器、JVM 和处理器共同编织了这么一个 “楚门的世界”:单线程程序是按程序的 “顺序” 来执行的。as- if-serial 语义使单线程情况下,我们不需要担心重排序的问题,可见性的问题。

4.3、内存屏障

内存屏障是一种 屏障指令,它使得 CPU 或 编译器 对 屏障指令 前和后 所发出的内存操作 执行一个排序的约束。也叫内存栅栏或栅栏指令。

内存屏障的能力:

  1. 阻止屏障两边的指令进行重排序。
  2. 写数据时加了屏障的话,强制把写缓冲区的数据刷回到主内存中。
  3. 读数据时加了屏障的话,强制使 本地内存和CPU高速缓存 当中缓存的数据失效,重新到主内存获取最新的数据。

内存屏障的基本分类:

  1. 读屏障(Load Barrier):在读指令之前插入读屏障,强制使 本地内存和CPU高速缓存 当中缓存的数据失效,重新到主内存获取最新的数据。
  2. 写屏障(Load Barrier):在写指令之后插入写屏障,强制把写缓冲区的数据刷回到主内存中。

JMM 的内存屏障:

  1. LoadLoad BarrierLoad1; LoadLoad; Load2,保障不重排和读屏障
    1. 禁止重排序:访问 Load2 的读操作之前,一定不会重排序到 Load1 之前。
    2. 保证 Load2 在读的时候,自己缓存内的相应数据失效,Load2 会去主内存中获取最新数据。
  2. LoadStore BarrierLoad1; LoadStore; Store2,仅保证不重排
    1. 禁止重排序:一定是 Load1 读取数据完成后,才能让 Store2 及其之后的写操作的数据被其他线程看到。
  3. StoreStore BarrierStore1; StoreStore; Store2,保证不重排和写屏障
    1. 禁止重排序:一定是 Store1 的数据写出到主内存后,才能让 Store2 及其之后的写操作的数据被其他线程看到。
    2. 保证 Store1 指令写出去的数据,会被强制刷回到主内存中。
  4. StoreLoad BarrierStore1; StoreLoad; Load2,同时保证不重排、读屏障和写屏障
    1. 禁止重排序:一定是 Store1 的数据写出到主内存后,才能让 Load2 读取数据。
    2. 同时保证 Store1 后强制把写缓冲区的数据刷回到主内存中、Load2 在读的时候自己缓存内的相应数据失效。

StoreLoad Barrier 是最重的,跟内存交互次数多、交互延迟大、消耗资源多。

这些屏障指令并不是处理器真实的执行指令,他们只是 JMM 定义出来的、跨平台的指令。

因为不同硬件实现内存屏障的方式并不相同,JMM 为了屏蔽这种底层硬件平台的不同,抽象出了这些内存屏障指令,在运行的时候,由 JVM 来为不同的平台生成相应的机器码。

这些内存屏障指令在不同的硬件平台上,可能会做一些优化,从而只支持部分的 JMM 内存屏障指令。

例如在 x86 机器上,就只有 StoreLoad Barrier 是有效的,其他的都不支持,被替换成了 nop,也就是空操作。


5、volatile

volatile 关键字在 JVM 中,相当于是一种轻量级同步机制,volatile 修饰的变量有如下特点:

  1. 保证可见性
    1. 对于一个 volatile 修饰的变量进行读操作,总能读到这个变量的最新值。
    2. 一个线程修改了 volatile 修饰的变量值的时候,那么这个变量的新值会立即刷回主内存。
    3. 一个线程去读取 volatile 修饰的变量值时,该变量在本地内存中的数据立即失效,需要重新到主内存去读最新的数据。
  2. 不保证原子性
  3. 保证有序性(禁止指令重排),也就是说要求维护 happens-before 的关系
    1. 对 volatile 变量的写入,不能重排到 写入之前的操作 之前。
    2. 对 volatile 变量的读取,不能重排到 读取之后的操作 之后。

● volatile 与普通变量之间是否允许重排

在这里插入图片描述

● volatile 怎么保证可见性

相比 synchronized 的加锁方式来解决共享变量的内存可见性问题,volatile 就是更轻量的选择,它没有上下文切换的额外开销成本。

volatile 可以确保对某个变量的更新对其他线程马上可见,一个变量被声明为 volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。当其它线程读取该共享变量 ,会从主内存重新获取最新值,而不是使用当前线 程的本地内存中的值。

● volatile 怎么保证有序性

重排序可以分为编译器重排序和处理器重排序,valatile 保证有序性,就是通过分别限制这两种类型的重排序。

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏 障来禁止特定类型的处理器重排序。

  1. 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  2. 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  3. 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  4. 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

在这里插入图片描述

在这里插入图片描述

  • 19
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

纯纯的小白

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值