—— 计算机存储结构
- 计算机存储结构,从本地磁盘到主存到 CPU 缓存,也就是硬盘到内存,到CPU,一般对应的程序的操作就是从数据库到内存然后到CPU进行计算
- CPU拥有多级缓存,(CPU和物理主内存的速度不一致,远高于主内存),CPU的运行并不是直接操作内存,而是先把内存里面的数据读到缓存,而内存的读写操作就会造成不一致的问题
- JVM 规范中试图定义一种 Java 内存模型(Java Memory Model,简称 JMM)来屏蔽各种硬件和操作系统的内存访问差异,实现让 Java 程序在各种平台下都能达到一致的内存访问效果
—— JMM 概念
- 定义:JMM (Java 内存模型,Java Memory Model)本身是一种抽象的概念,并不真实存在,它仅仅描述的是一组规范或约定,通过这组规范定义了程序中(尤其是多线程)各个变量的读写访问方式,并决定一个线程对共享变量的写入同时如何变成对另一个线程可见,关键技术点都是围绕多线程的原子性、可见性、有序性展开
- 原则:JMM 的关键技术点都是围绕多线程的原子性、可见性、有序性展开
- 作用:
- 通过 JMM 来实现线程和主内存之间的抽象关系
- 屏蔽掉各个硬件平台和操作系统的内存访问差异,实现让 Java 程序在各种平台下都能达到一致的内存访问效果
多线程对变量的读写过程
- 定义的所有共享变量都存储在物理主内存中
- 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量副本
- 线程对共享变量所有的操作都必须先在线程自己的工作内存进行后写回主内存,不能直接从主内存中进行读写(不能越级)
- 不同线程之间无法访问其他线程工作内存中的变量,线程变量值间的传递需要通过主内存来进行(同级之间不能相互访问)
—— 三大特性
可见性
- 当一个线程修改了某个共享变量的值,其他线程能够立即知道改变更,JMM 规定了所有的变量都存储在主内存中
- 系统主内存共享变量数据修改被写入的时机是不确定的,多线程并发下很可能出现脏读,所以每个线程都有自己的工作内存,线程自己的工作内存中,保存了该线程使用的的主内存副本拷贝,线程对变量的所有操作都必须在线程自己的工作内存中进行,而不能够直接读写主内存的变量。不同线程之间也无法访问对方工作内存中的变量,线程间变量值的传递都需要通过主内存来访问
原子性
- 操作不可再分,不可被打断;即多线程环境下,操作不能被其他线程所干扰
有序性
- 对于一个线程的执行代码而言,我们习惯性的认为代码总是从上而下,有序执行。但为了提升性能,编译器和处理器通常会对指令序列进行重新排序。Java 规范规定 JVM 线程内部维持顺序话语义,即只要程序的最终结果与它顺序化执行的结果一致,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序
- 优点:JVM 能根据处理器特性(CPU 的多级缓存系统、多核处理器)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能
- 缺点:指令重排可以保证串行语义的一致性,但没有义务保证多线程的语义一致(即可能产生脏读),简单说,两行以上不相干的代码在执行的时候可能先执行的不是第一条,不见的是从上到下顺序执行,执行顺序会被优化
- 源码到最终执行顺序:源代码——》编译器优化的重排——》指令并行的重排——》内存系统的重排——》最终执行的命令
重排序
- 单线程环境里确保程序最终结果和代码顺序执行的结果一致
- 处理器在进行重排序时必须考虑指令之间的数据依赖性(若两个操作访问同一变量,且这两个操作中有一个为写操作,此时两操作间就存在数据依赖性)
- 多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测
——happens-before
在 JMM 中,如果一个操作执行结果需要对另一个操作可见性,或者代码重排序,那么这两个操作之间必须存在 happens-before(先行发生)原则,逻辑上的先后关系
总原则
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前
- 两个操作之间存在 happens-before 关系,并不意味着一定按照 happens-before 原则制定的顺序来执行。如果重排序之后的执行结果与按照 happens-before 关系来执行的结果一致,那么这种重排序并不非法
八条规则
- 次序规则:一个线程内,按照代码顺序,写在前面的操作先行发生于写在后面的操作
- 锁定规则:一个 unLock 操作先行发生于后面对同一个锁的 lock 操作
- volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,前面的写对后面的读可见
- 传递规则:如果操作 A 先行发生于 B 操作,而操作 B 又先行发生于 C 操作,则 A 操作 先行发生于 C 操作
- 线程启动规则:Thread 对象的 start 方法先行发生于此线程的每一个动作
- 线程中断规则:对线程 interrupt 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupt() 检测到是否发生中断
- 线程终止规则:线程中所有操作都先行发生于对此线程的终止检测, 我们可以通过 isAlive 方法等手段检测线程是否已经终止执行
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始(对象没有完成初始化之前是不能调用 finalized 方法)
本质
- 在 Java 语言里, Happens-Before 语义本质上是一种可见性;A Happens-Before B 意味着 A 发生过的事情对 B 来说是可见的,无论 A 事件 和 B 事件是否发生在同一个线程里
- JMM 的设计分为两部分:
- 一部分是面向程序员,也就是 Happens-Before 规则,通俗易懂阐述了强内存模型,只要 理解 Happens-Before 规则,就能编写并发安全程序
- 另一部分是针对 JVM 实现,为了尽可能少的对编译器和处理器做约束,从而提高性能,JMM 在不影响程序执行结果的前提下对其不做要求,允许重排序
——volatile
特点
- 不保证原子性:多线程环境下,“数据计算”和“数据赋值”操作可能多次出现,若数据加载之后,若主内存 volatile 修饰变量发生修改之后,线程工作内存中的操作将会作废,去读主内存的最新值,操作出现写丢失问题。即各线程私有内存和主内存公共内存变量不同步,进而导致数据不一致,对于多线程修改主内存共享变量的场景必须使用加锁同步,volatile 变量不适合参与到依赖当前值的计算,通常 volatile 用作保存某个状态的 boolean 值或 int 值(比如 num++,为 3 个指令,非原子操作)
- 可见性:保证不同线程对某个变量完成操作后结果及时可见,即该共享变量一旦改变,所有线程立即可见,读到该变量的最新值;当某个线程收到通知,去读取 volatile 变量值时,线程私有工作内存的数据失效,需要重新回到主内存中读取最新的值
- 有序性(禁止指令重排序)
内存屏障(Memory Barrier) 保证可见性和有序性(禁重排)
- 对于编译器的重排序,JMM 会根据重排序的规则,禁止特定类型的编译器重排序
- 对于处理器的重排序,Java 编译器在生成指令序列的适当位置,插入内存屏障指令,来禁止特定类型的处理器排序
内存语义
- 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值立即刷新回主内存中
- 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存设置为无效,重新回到主内存中读取最新共享变量
内存屏障
- 定义:也称为内存栅栏、屏障指令等,是一类同步屏障指令,是 CPU 或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作,避免代码重排序。内存屏障实际是一种 JVM 指令,Java 内存模型的重排规则会要求 Java 编译器在生成 JVM 指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile 实现了Java 内存模型中可见性和有序性(禁重排),但 volatile 无法保证原子性
- 内存屏障之前的所有写操作都要回写到主内存
- 内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现可见性)
- 重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。即对一个 volatile 变量的写,先行发生于任意后续对这个 volatile 变量的读,也叫写后读
屏障分类
-
写屏障(Store Memory Barrier): 在写指令之后插入写屏障,强制把写缓冲区的数据刷回到主内存中,告诉处理器在写屏障之前将所有存储在缓存中的数据同步到主内存
-
读屏障(Load Memory Barrier):在读指令之前插入读屏障,让工作内存或 CPU 高速缓存中的缓存数据失效,重新回到主内存中获取最新数据;处理器在读屏障之后的读操作,都是在读屏障之后执行,保证 Load 屏障指令之后的读取数据指令一定能够读到最新数据
源码分析:Unsafe.class(loadFence、storeFence、fullFence)
Happens-Before 之 volatile 变量规则
- 当第一个操作为 volatile 读时,不论第二个操作是什么,都不能重排序。这个操作保证了 volatile 读之后的操作不会被重排到 volatile 读之前
- 当第二个操作为 volatile 写时,不论第一个操作是什么,都不能重排序
- 当第一个操作为 volatile 写时,第二个操作为 volatile 读时,不能重排
volatile 变量的读写过程
- Java 内存模型中定义的 8 种每个线程自己的工作内存与主物理内存之间的原字操作:
read(读取)——》load(加载)——》use(使用)——》assign(赋值)——》store(存储)——》write(写入)——》lock(锁定)——》unlock(解锁)
- read:作用于主内存,将变量的值从主内存传输到工作内存中
- load:作用于主内存,将read 从主内存传输的变量值放入工作内存变量副本中,数据加载
- user:作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当 JVM 遇到该变量的字节指令时会执行该操作
- assign:作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当 JVM 遇到一个变量赋值字节指令时会执行该操作
- store:作用于工作内存,将赋值完毕的工作变量的值写回给主内存
- write:作用于主内存,将store 传输的值赋值给主内存中的变量
以上 6 条只能保证单条指令的原子性,针对多条指令的组合原子保证,没有大面积加锁,所以,JVM 提供了另外两个原子指令
- lock:作用于主内存,将一个变量标记为一个线程独占的状态,只是写时候加锁,就只是锁了写变量的过程
- unlock:作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用
volatile 正确使用
- 单一赋值可以,复合运算赋值不行(i++之类)
- 状态标志,判断业务是否结束
- 开销较低的读,写锁策略(当读多于写,结合使用内存锁和 volatile 变量来减少同步的开销,利用 volatile 保证读取操作的可见性,利用 synchronize 保证复合操作的原子性)
- DCL(Double Check Lock 双检锁):多线程环境下,在“问题代码处”,会执行如下操作,由于重排序导致 2,3 乱序,后果就是其他线程得到的是 null 而不是完成初始化的对象