从 java 内存模型到 volatile 的简单理解

前言

在开始进入正题学习之前, 觉得有必要先来了解一下什么是计算机内存模型, 然后再回头看 java 内存模型.

1. 计算机内存模型

为什么要有内存模型呢? 我们知道在计算机执行程序的时候, 每条执行都是在 CPU 中执行的, 而执行的时候, 又无法避免的和数据打交道. 而计算机上的数据是放在主内存中的, 也可以理解为计算机的物理内存. 随着现代 CPU 技术的发展, CPU 的执行速度越来越快, 而由于内存的技术并没有太大的变化, 所以从内存中读取和写入数据的过程与 CPU 的执行速度比起来差距就会越来越大, 这就导致 CPU 每次操作内存都要消耗很多等待时间. 所以现代计算机系统不得不加入一层读写速度尽可能接近 CPU 运算速度的高速缓存(Cache)来作为内存与 CPU 之间的缓冲.

那么程序的执行过程就变成了: 程序在运行过程中, 将运算需要的数据从内存中复制一份到 CPU 的高速缓存当中, 那么 CPU 进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据, 当运算结束之后, 再将高速缓存中的数据刷新到主内存中.

而随着 CPU 能力不断提升, 一层缓存已经慢慢的变的无法满足要求了, 于是就逐渐的衍生出多级缓存.

按照数据读取顺序和 CPU 结合的紧密程度, CPU 缓存可以分为一级缓存 L1, 二级缓存 L2, 三级缓存 L3, L0 为寄存器, 接下来是内存, 本地磁盘, 远程存储. 越向上缓存的存储空间越小, 速度越快, 成本也就更高. 从上至下, 每一层都可以看做是更下一层的缓存, 即 L0 寄存器是 L1 一级缓存的缓存. 依次类推, 每一层的数据都是来自它的下一层. 所以每一层的数据是下一层数据的子集.


缓存层级

在现代 CPU 上, 一般来说 L0, L1, L2, L3 都继承在 CPU 内部, 同时 L1 还分为一级数据缓存和一级指令缓存, 分别用于存放数据和执行数据的指令解码. 每个核心拥有独立的运算处理单元, 控制器, 寄存器, L1, L2 缓存, 然后一个 CPU 的多个核心共享最后一层 CPU 缓存 L3.

 
那么现在就会出现第一个问题: 缓存一致性问题.
在 CPU 和内存之间增加缓存, 在多线程场景下会出现缓存一致性问题, 也就是说, 多个线程访问进程中的某个共享内存, 且这多个线程分别在不同的核心上执行, 那么每个核心都会在各自的高速缓存中保留一份共享内存的缓存. 由于多核是可以并行的, 可能会出现多个线程同时写各自缓存的情况, 那么就会造成同一个数据的缓存内容可能不一致.

 
除了这种情况, 还有一种硬件问题, 这是出现的第二个问题: 指令重排问题.
为了使得 CPU 内部的运算单元能尽量被充分利用, CPU 可能会对输入代码进行乱序执行优化, 处理器会在计算之后将乱序执行的结果重组. 保证该结果与顺序执行的结果是一致的, 但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致.
因此, 如果存在一个计算任务依赖另一个计算任务的中间结果, 那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似, Java 虚拟机的即时编译器中也有类似的指令重排序优化.

 
我们知道, 并发编程为了保证数据的安全, 需要满足以下三个特性

  • 原子性: 指在一个操作中, CPU 不可以在中途暂停突然再调度, 即不会被中断操作, 要不执行完成, 要不就不执行.
  • 可见性: 指当多个线程访问同一个变量时, 一个线程修改了这个变量的值, 其他线程能够立即看得到修改的值.
  • 有序性: 即程序执行的顺序按照代码的先后顺序执行.

其实缓存一致性问题其实就是可见性问题. 而处理器优化是可以导致原子性问题的, 指令重排即会导致有序性问题. 所以, 为了保证并发编程中可以满足原子性, 可见性以及有序性. 就有了一个重要的概念, 那就是内存模型.

为了保证共享内存的正确性. 内存模型定义了共享内存系统中多线程程序读写操作的行为规范. 通过这些规则来规范对内存的读写操作, 从而保证指令执行的正确性. 它与处理器有关, 与缓存有关, 与并发有关, 与编译器也有关. 它解决了 CPU 多级缓存, 处理器优化, 指令重排等导致的内存访问问题, 保证了并发场景下的一致性, 原子性与有序性.

所以在 CPU 层面, 内存模型定义了一个充分必要条件, 就是可见性条件.

  • 有些 CPU 提供了强内存模型, 所有 CPU 在任何时候都能看到内存中任意位置相同的值, 这种完全是硬件提供的支持.
  • 其他CPU 提供了弱内存模型, 需要执行一些特殊的指令( memory barriers 内存屏障). 刷新 CPU 缓存的数据到内存中. 保证这个写的操作能够被其他 CPU 可见. 或者将 CPU 缓存的数据设置为无效状态, 保证其他 CPU 的写操作对本 CPU 可见. 通常这些内存屏障的行为由底层实现, 对于上层语言的程序员来说是透明的.
     

2. Java 内存模型是什么

上面介绍了计算机内存模型, 这是解决多线程场景下并发问题的一个重要规范, 那么不同的编程语言, 在实现上也有所不同.

我们都知道 Java 程序是需要运行在 Java 虚拟机上面的, Java 内存模型 (Java Memory Model,JMM) 就是一种符合内存模型规范的, 屏蔽了各种硬件和操作系统的访问差异的, 保证了Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范.

提到 Java 内存模型, 一般指的是 JDK 5 开始使用的新内存模型,主要由 JSR-133:JavaTM Memory Model and Thread Specification 描述.

Java 内存模型规定了所有的变量都存储在主内存中, 每个线程都有一个私有的工作内存. 线程的工作内存中保存了该线程中用到变量的主内存副本拷贝, 线程对变量的所有操作都必须在工作内存中进行, 而不能直接读写主内存. 不同线程之间也无法直接访问对方工作内存中的变量, 线程间变量的传递均需要自己的工作内存与主内存之间进行数据同步.而 JMM 就作用于工作内存与主内存之间数据同步过程, 它规定了如何做数据同步以及什么时候做数据同步.

线程,工作内存, 主内存之间的交互关系图

简单来说: JMM 是一种规范, 是解决由于多线程通过共享数据进行通信时, 存在本地内存数据不一致, 编译器会对代码指令重排序, 处理器对代码乱序执行等带来的问题. 目的是保证并发编程场景中的原子性, 可见性和有序性.

关于主内存与工作内存之间的具体交互协议, JMM 定义了以下八种操作来完成. 即一个变量如何从主内存 copy 到工作内存, 如何从工作内存同步到主内存之间的细节实现.

  • lock    锁定:  作用于主内存的变量, 把一个变量标识为一条线程独占状态.
  • unlock 解锁:  作用于主内存的变量, 把一个处于锁定状态的变量释放, 释放后才可被其他线程锁定.
  • read    读取:  作用于主内存的变量, 把一个变量值从主内存传输到工作内存中, 以便税后的 load 动作使用.
  • load    载入:  作用于工作内存变量, 它把 load 操作从主内存中得到的变量放入工作内存的副本中.
  • use     使用:   作用于工作内存变量, 把工作内存中的一个变量值传递给执行引起, 每当虚拟机遇到一个需要使用变量值的字节码指令时执行这个操作.
  • assign 赋值:   作用于工作内存变量, 它把一个执行引起接收到的值赋值给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作.
  • store  存储:    作用于工作内存变量, 把工作内存中的一个变量的值传送到主内存中, 以便随后的 write 操作.
  • write  写入:    作用于主内存的变量, 它把 store 操作从工作内存中一个变量的值传送到主内存的变量中.

readload 从主内存复制变量到工作内存
useassign 执行代码, 修改共享变量值
storewrite 用工作内存变量值刷新主内存相关内存.

 

3. volatile

对于这个关键字, 相信大家都不陌生. 它就解决了上述问题中的可见性与指令重排序功能. 它可以被看做是 Java 中一种 "程度较轻的 synchronized"

volatile 关键字可以保证直接从主内存中读取一个变量, 如果这个变量被修改后, 会被强制写回到主内存.
 

3.1 volatile 解决的可见性问题原理

如果对声明了 volatile 的变量进行写操作, JVM 就会像处理器发送一条 Lock 前缀的指令, 将这个变量所在缓存行的数据立即写回到主内存中. 但是就算写回到内存, 其他处理器的缓存的值还是旧的. 所以在多处理器下, 为了保证各个处理器的缓存是一致的, 就会实现缓存一致性协议. 也就每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了, 当处理器发现自己缓存行对应的内存地址被修改, 就会将当前处理器的缓存行设置为无效状态, 当处理器对这个数据进行修改操作的时候, 会重新从主内存中把数据读到处理器缓存里.

Lock 前缀的指令在多核处理器下会引发两件事情

  • 将当前处理器缓存行的数据写回到主内存
  • 一个处理器的缓存回写内存会导致其他处理器的缓存无效. 需要数据操作的时候需要再次去主内存中读取.

对于 volatile 的缓存一致性协议 MESI, 需要不断的从主内存嗅探和 cas 不断循环, 无效交互会导致总线带宽达到峰值, 所以尽量不要大量的使用 volatile.

 

3.2 volatile 的内存语义
  • volatile 写的内存语义: 当写一个 volatile修饰的变量时, JMM 会把该线程对应的工作内存中的共享变量的副本值刷新到主内存.
  • volatile 读的内存语义:当读一个 volatile修饰的变量时, JMM 会把该线程对应的工作内存中的共享变量副本置为无效. 线程接下来将从主内存中重新读取共享变量.
  • 线程 A 写一个 volatile 修饰的变量, 实质上是线程 A 向接下来将要读这个 volatile修饰变量的某个线程发出了(对其共享变量所做修改)消息.
  • 线程 B 读一个 volatile 修饰变量, 实质上是线程 B 接收了某个线程发出的(在写这个 volatile 变量之前对共享变量所做修改)消息

线程 A 写这个变量, 随后线程 B 读取这个变量, 这个过程实际上是线程 A 通过主内存向 B 线程发送消息.
 

3.3 volatile 使用内存屏障解决重排序问题

volatile 关键字本身就包含了禁止指令重排序的语义. 那么它是如何实现的呢? 就是根据我们上面说过的内存屏障(memory barriers). 不同的硬件平台实现内存屏障的方式也是不一样的, Java 通过屏蔽这些差异, 统一由 JVM 来生成内存屏障的指令.

重排序也可能会导致多线程程序出现的内存可见性问题, 对于处理器重排序, JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时插入特定类型的内存屏障指令. 通过内存屏障指令来禁止特定类型的处理器重排序. 通过禁止特定类型的编译器重排序和处理器重排序, 为程序员提供一致的内存可见性保证.

内存屏障

StoreLoad Barriers 是一个 全能型 的屏障, 它同时具有其他三个屏障的效果. 现代的多处理器大多都支持该屏障. 但是执行该屏障开销会很昂贵, 因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中.

JMM 针对编译器制定 volatile 重排序规则表如下

重排序规则

  • 当第一个操作是 volatile 读时, 不管第二个操作是什么, 都不能重排序. 这个规则确保 volatile 读之后的操作不会被编译器重新排序到 volatile 读之前.
  • 当第一个操作是 volatile 写时, 第二个操作是 volatile 读时, 不能重排序.
  • 当第二个操作是 volatile写时, 无论第一个操作是什么, 都不能重排序. 这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后.

需要注意的是:volatile 写是在前面和后面分别插入内存屏障, 而 volatile 读操作是在后面插入两个内存屏障.
下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障.
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障.
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障.
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障.

从编译器重排序规则和处理器内存屏障插入策略来看, 只要 volatile 修饰的变量与普通变量之间的重排序可能会破坏 volatile 的内存语义(内存可见性), 这种重排序就会被编译器重排序规则和处理器的内存屏障插入策略禁止.

 

3.4 volatile 为什么只能保证单次读写的原子性

对于单个 volatile 变量的读/写具有原子性, 但是类似于复合操作, 类似于 volatile i, i++ 这种就不具有原子性, 因为本质上 i++ 是三次操作. (实际上对应的机器码步骤更多,但是这里分解为三步已经足够说明问题)

int temp = i;
temp = temp + 1;
i = temp;

多线程环境, 比如 A, B 两个线程同时执行 i++ 操作. 都执行到了第2步, B 线程先执行结束 i = 1, 因为变量 ivolatile 修饰, 所以 B 线程执行结束马上刷新工作内存中的 i = 1 到主内存. 并且通知其他 CPU 中的线程: 主内存中 i 的值更新了. 使 A 线程中工作内存的 i 失效. 如果 A 线程这时候使用到变量 i, 就需要去主内存重新 copy 一份到自己的工作内存.

但是这时候 A 线程执行到了 temp = temp +1, 已经用临时变量 temp 记录了之前 i 的值, 不需要再读去 i 的值了.

所以虽然变量 i 的值 0 在 A 线程的工作内存中确实失效了, 但是 temp 仍然是有效的, 既然有效, 那么 A 线程将继续将第 3 步的结果 i=1 再次写入主内存覆盖了之前 B 线程写入的值. 这就是为什么 volatile 无法保证共享变量 i++ 线程安全的原因.

对于复合操作,可以使用同步块技术和 Java concurrent 包下的原子操作类等.

 

3.5 volatile 总结
  • 通过使用 Lock 前缀的指令禁止变量在线程工作内存中缓存来保证 volatile 变量的内存可见性.
  • 通过插入内存屏障指令来禁止会影响变量可见性的指令重排序.
  • 对任意单次 volatile 读/写都具有原子性, 但是对于符合操作不具有原子性.

本章到这里就结束了, 如果看完觉得对你有帮助, 还请随手点一个赞. 谢谢大家. 你们的鼓励就是我的动力.

下一章将会简单学习理解 synchronized.

你可能感兴趣的:(从 java 内存模型到 volatile 的简单理解)