高并发学习之03JMM(Java内存模型)

1.硬件层面上的概览

要想了解Java中的内存模型必须要先知道CPU和内存的关系

1.1.CPU的多级缓存

线程是 CPU 调度的最小单元,线程涉及的目的最终仍然是更充分的利用计算机处理的效能,但是绝大部分的运算任务不能只依靠处理器“计算”就能完成,处理器还需要与内存交互,比如读取运算数据、存储运算结果,这个 I/O 操作是很难消除的。而由于计算机的存储设备与处理器的运算速度差距非常大,所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存之中。
CPU多级缓存架构图
高速缓存从下到上越接近 CPU 速度越快,同时容量也越小。现在大部分的处理器都有二级或者三级缓存,从下到上依次为 L3 cache, L2 cache, L1 cache. 缓存又可以分为指令缓存和数据缓存,指令缓存用来缓存程序的代码,数据缓存用来缓存程序的数据

  • L1 Cache,一级缓存,本地 core 的缓存,分成 32K 的数据缓存 L1d 和 32k 指令缓存 L1i,访问 L1 需要 3cycles,耗时大约 1ns;
  • L2 Cache,二级缓存,本地 core 的缓存,被设计为 L1 缓存与共享的 L3 缓存
    之间的缓冲,大小为 256K,访问 L2 需要 12cycles,耗时大约 3ns;
  • L3 Cache,三级缓存,在同插槽的所有 core 共享 L3 缓存,分为多个 2M 的
    段,访问 L3 需要 38cycles,耗时大约 12ns;
1.2.缓存一致性问题

CPU-0 读取主存的数据,缓存到 CPU-0 的高速缓存中,CPU-1 也做了同样的事情,而 CPU-1 把 count 的值修改成了 2,并且同步到 CPU-1 的高速缓存,但是这个修改以后的值并没有写入到主存中,CPU-0 访问该字节,由于缓存没有更新,所以仍然是之前的值,就会导致数据不一致的问题引发这个问题的原因是因为多核心 CPU 情况下存在指令并行执行,而各个CPU 核心之间的数据不共享从而导致缓存一致性问题,为了解决这个问题,CPU 生产厂商提供了相应的解决方案

1.2.1 总线锁

当一个 CPU 对其缓存中的数据进行操作的时候,往总线中发送一个 Lock 信号。其他处理器的请求将会被阻塞,那么该处理器可以独占共享内存。总线锁相当于把 CPU 和内存之间的通信锁住了,所以这种方式会导致 CPU 的性能下降,所以 P6 系列以后的处理器,出现了另外一种方式,就是缓存锁。

1.2.2 缓存锁

如果缓存在处理器缓存行中的内存区域在 LOCK 操作期间被锁定,当它执行锁操作回写内存时,处理不在总线上声明 LOCK 信号,而是修改内部的缓存地址,然后通过缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域的数据,当其他处理器回写已经被锁定的缓存行的数据时会导致该缓存行无效。所以如果声明了 CPU 的锁机制,会生成一个 LOCK 指令,会产生两个作用:

  1. Lock 前缀指令会引起引起处理器缓存回写到内存,在 P6 以后的处理器中,LOCK 信号一般不锁总线,而是锁缓存
  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效
1.2.3 缓存一致性协议

处理器上有一套完整的协议,来保证 Cache 的一致性,比较经典的应该就是MESI 协议了,它的方法是在 CPU 缓存中保存一个标记位,这个标记为有四种
状态

  • M(Modified) 修改缓存,当前 CPU 缓存已经被修改,表示已经和内存中的
    数据不一致了
  • I(Invalid) 失效缓存,说明 CPU 的缓存已经不能使用了
  • E(Exclusive) 独占缓存,当前 cpu 的缓存和内存中数据保持一直,而且其他处理器没有缓存该数据
  • S(Shared) 共享缓存,数据和内存中数据一致,并且该数据存在多个 cpu缓存中

每个 Core 的 Cache 控制器不仅知道自己的读写操作,也监听其它 Cache 的读写操作,嗅探(snooping)"协议
CPU 的读取会遵循几个原则

  1. 如果缓存的状态是 I,那么就从内存中读取,否则直接从缓存读取
  2. 如果缓存处于 M 或者 E 的 CPU 嗅探到其他 CPU 有读的操作,就把自己的缓存写入到内存,并把自己的状态设置为 S
  3. 只有缓存状态是 M 或 E 的时候,CPU 才可以修改缓存中的数据,修改后,缓
    存状态变为 MC
1.3. CPU优化-运行时指令重排

除了增加高速缓存以为,为了更充分利用处理器内内部的运算单元,处理器可能会对输入的代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果充足,保证该结果与顺序执行的结果一直,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,这个是处理器的优化执行。
重排序分3种类型。

  • 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句
    的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level
    Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应
    机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上
    去可能是在乱序执行。
    重排序的顺序
    CPU指令重排并非随便重排,需要遵守as-if-serial语义。
    as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)
    程序的执行结果不能被改变。编译器,运行时和处理器都必须遵守as-if-serial语义。
    也就是说:编译器和处理器不会对存在数据依赖关系的操作做重排序。
    举个简单的例子:
public class Demo1{
    public static void main(String[] args){
        int x = 500; //1
        int y = 100;	//2
        int a = x / y; //3
        int b = 50;  //4
        System.out.println(a + b); //5
    }
}

这段代码中通过编译器进行字节码重排序优化,顺序会是 1->2>4>3>5

1.3.1 带来的两个问题
  1. CPU高速缓存下有一个问题:
    缓存中的数据与主内存的数据并不是实时同步的,各CPU(或CPU核心)间缓存的数据也不是实时同步。在同一个时间点,各CPU所看到同一内存地址的数据的值可能是不一致的。
  2. CPU执行指令重排序优化下有一个问题:
    虽然遵守了as-if-serial语义,单仅在单CPU自己执行的情况下能保证结果正确。多核多线程中,指令逻辑无法分辨因果关联,可能出现乱序执行,导致程序运行结果错误。
1.3.2 内存屏障(Memory Barrier)

处理器提供了两个内存屏障指令(Memory Barrier)用于解决上述两个问题:

  • 写内存屏障(Store Memory Barrier):在指令后插入Store Barrier,能让写入缓存中的最新
    数据更新写入主内存,让其他线程可见。
    强制写入主内存,这种显示调用,CPU就不会因为性能考虑而去对指令重排。
    如图:所有在storestore barrier指令之后的store指令,都必须在storestore barrier屏障之前的指令执行完后再被执行。写内存屏障
  • 读内存屏障(Load Memory Barrier):在指令前插入Load Barrier,可以让高速缓存中的数
    据失效,强制从新从主内存加载数据。
    强制读取主内存内容,让CPU缓存与主内存保持一致,避免了缓存导致的一致性问题
    如图:强制所有在load barrier读屏障之后的load指令,都在loadbarrier屏障之后执行
    读内存屏障

2.JMM

前面说的和硬件有关的概念你可能听得有点蒙,还不知道他到底和软件有啥关系,其实原子性、可见性、有序性问题,是我们抽象出来的概念,他们的核心本质就是刚刚提到的缓存一致性问题、处理器优化问题导致的指令重排序问题。比如缓存一致性就导致可见性问题、处理器的乱序执行会导致原子性问题、指令重排会导致有序性问题。为了解决这些问题,所以在 JVM 中引入了 JMM 的概念。

2.1JMM(Java 内存模型)

内存模型定义了共享内存系统中多线程程序读写操作行为的规范,来屏蔽各种硬件和操作系统的内存访问差异,来实现 Java 程序在各个平台下都能达到一致的内存访问效果。Java 内存模型的主要目标是定义程序中各个变量的访问规则,也就是在虚拟机中将变量存储到内存以及从内存中取出变量(这里的变量,指的是共享变量,也就是实例对象、静态字段、数组对象等存储在堆内存中的变量。而对于局部变量这类的,属于线程私有,不会被共享)这类的底层细节。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了 CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的可见性、原子性和有序性。
内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。
Java 内存模型定义了线程和内存的交互方式,在 JMM 抽象模型中,分为主内存、工作内存
JMM内存空间
主内存是所有线程共享的,工作内存是每个线程独有的。线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,不能直接读写主内存中的变量。并且不同的线程之间无法访问对方工作内存中的变量,线程间的变量值的传递都需要通过主内存来完成,他们三者的交互关系如下:
线程与工作内存和主内存关系
所以,总的来说,JMM 是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目的是保证并发编程场景中的原子性、可见性和有序性.

2.2 JVM 与JMM关系

jvm内存模型和java内存模型是两回事。 java内存模型是为了解决多线程对共享数据访问保持一致性,即规定了jvm怎么协调虚拟内存和主内存关系

对于JMM与JVM本身的内存模型,参照《深入理解Java虚拟机》周志明的解释,这两者本没有关系。如果一定要勉强对应,那从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域,。从更低层次上说,主内存就是物理内存,而为了获取更好的执行速度,虚拟机(甚至是硬件系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为运行时主要访问—读写的是工作内存。

2.3 JMM与硬件内存关系

JMM可以在硬件CPU缓存中也可以在主内存中,JMM本身就是为了解决多线程对共享数据访问保持一致性,即规定了jvm怎么协调虚拟内存和主内存关系。

2.4 JMM怎么解决原子性、可见性、有序性的问题?

在Java中提供了一系列和并发处理相关的关键字,比如volatile、Synchronized、final、J.U.C等,这些就是Java内存模型封装了底层的实现后提供给开发人员使用的关键字,在开发多线程代码的时候,我们可以直接使用synchronized等关键词来控制并发,使得我们不需要关心底层的编译器优化、缓存一致性的问题了,所以在Java内存模型中,除了定义了一套规范,还提供了开放的指令在底层进行封装后,提供给开发人员使用。

  • 原子性保障
    在java中提供了两个高级的字节码指令monitorenter和monitorexit,在Java中对应的Synchronized来保证代码块内的操作是原子的
  • 可见性
    Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。除了volatile,Java中的synchronized和final两个关键字也可以实现可见性
  • 有序性
    在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性。实现方式有所区别:volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。
2.5 JMM内存屏障

在JMM中把内存屏障指令分为4类,通过在不同的语义下使用不同的内存屏障来进制特定类型的处理器重排序,从而来保证内存的可见性
JMM内存屏障类型

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值