JMM 通过构建一个统一的内存模型来屏蔽掉不同硬件平台和不同操作系统之间的差异, 让 Java 开发者无需关注不同平台之间的差异, 达到一次编译, 随处运行的目的, 这也正是 Java 的设计目的之一.
CPU 和内存
在讲 JMM 之前, 我想先和大家聊聊硬件层面的东西. 大家应该都知道执行运算操作的 CPU 本身是不具备存储能力的, 它只负责根据指令对传递进来的数据做相应的运算, 而数据存储这一任务则交给内存去完成. 虽然内存的运行速度虽然比起硬盘快非常多, 但是和 3GHZ,4GHZ, 甚至 5GHZ 的 CPU 比起来还是太慢了, 在 CPU 的眼中, 内存运行的速度简直就是弟弟中的弟弟, 等内存进行一次读写操作, CPU 能思考成百上千次人生了: grin:. 但是 CPU 的运算能力是紧缺资源啊, 可不能这么白白浪费了, 所以得想办法解决这一个问题.
没有什么问题是一个缓存不能解决的, 如果有, 那就再加一个缓存 -- 鲁迅: 反正我没说这句话
所以人们就想到了给 CPU 增加一个高速缓存 (为什么是加高速缓存而不是给内存提高速度就牵扯到硬件成本问题了) 来解决这个问题, 比如像博主用的 Intel 的 I9 9900k CPU 就带了高达 16M 的三级缓存, 所以硬件上的内存模型大概如下图所示.
如图可以很清楚的看到, 在 CPU 内部构建了一到多层的缓存, 并且其中的 L1 Cache 是 CPU 内核心独有的, 不同的 Core 之间是不能共享的, 而 L2 Cache 则是所有的核心共享. 简单来说就是 CPU 在读取一个数据时会先去最近的 Cache 层级上读取, 如果找不到则会去下一个层级寻找, 都找不到的话就会从内存中去加载, 而如果 Cache 中能拿到所需要的数据就不会去内存读取. 在将数据写回的时候也会先写入 Cache 中, 等待合适的时机再写入到内存中(其中有一个细节就是缓存行的问题, 关于这部分内容放在文章结尾). 而由于存在多个 cache 层级, 并且部分 cache 还不能够被共享, 所以会存在内存可见性的问题.
举个简单的例子: 假设现在存在两个 Core, 分别是 CoreA 和 CoreB 并且他们都拥有属于自己的 L1Chace 和共用的 L2Cache. 同时有一个变量 X 的值为 1, 该变量已经被加载在 L2Cahce 上. 此时 CoreA 执行运算需要用到变量 X, 先去 L1Cache 寻找, 未命中, 继续在 L2Cache 寻找, 命中成功, 将 X=1 载入 L1Cahce, 再经过一系列运算后将 X 修改为 2 并写入 L1Cache. 于此同时 CoreB 刚好也需要 X 来进行运算, 此时他去自己的 L1Cahce 寻找, 未命中, 继续 L2Cache 寻找, 命中成功, 将 X=1 载入自己的 L1Cache. 此时就出现了问题, CoreA 明明已经将 X 的值修改为 2 了, CoreB 读取到的依然是 X=1, 这就是内存可见性问题.
看到这里的小伙伴们可能要问了, 博主你啥情况啊, 你这写的渐渐忘记标题了啊, 说好了 Java 内存模型, 你扯这么多硬件上的问题干啥啊?(╯‵□′)╯︵┻━┻
Java 中的主内存和工作内存
小伙伴们别着急, 其实 JMM 和上面的硬件层次上的模型很像, 不信看下面的图片
怎么样, 是不是看起来很像, 可以简单的理解为线程的工作内存就是 CPU 里 Core 独占的 L1Cahce, 而主内存就是共享的 L2Cache. 所以上述的内存一致性问题也会在 JMM 中存在, 而 JMM 就需要制定一些列的规则来保证内存一致性, 这也是 Java 多线程并发的一个疑难点, 那么 JMM 制定了哪些规则呢?
内存间交互操作 首先是主内存的工作内存之间的交互协议, 具体来说定义了以下几个操作(并且保证这几个操作都是原子性的):
lock (锁定)作用于主内存的变量, 将一个变量标识为一个线程独占状态
unlock(解锁)作用于主内存的变量, 将一个处于锁定状态的变量释放出来, 释放之后才能被其他线程锁定
read(读取)作用于主内存的变量, 将一个变量的值从主内存传输到线程工作内存中, 便于之后的 load 操作使用
load(载入)作用于工作内存的变量, 它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中.
use(使用)作用于工作内存的变量, 把工作内存中的一个变量值传递给执行引擎, 每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作.
assign(赋值)作用于工作内存的变量, 它把一个从执行引擎接收到的值赋值给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作.
store(存储)作用于工作内存的变量, 把工作内存中的一个变量的值传送到主内存中, 以便随后的 write 的操作.
write(写入)作用于主内存的变量, 它把 store 操作从工作内存中一个变量的值传送到主内存的变量中.
同时还规定了执行上述八个操作时必须遵循以下规则:
如果要把一个变量从主内存中复制到工作内存, 就需要按顺寻地执行 read 和 load 操作, 如果把变量从工作内存中同步回主内存中, 就要按顺序地执行 store 和 write 操作. 但 Java 内存模型只要求上述操作必须按顺序执行, 而没有保证必须是连续执行.
不允许 read 和 load,store 和 write 操作之一单独出现
不允许一个线程丢弃它的最近 assign 的操作, 即变量在工作内存中改变了之后必须同步到主内存中.
不允许一个线程无原因地 (没有发生过任何 assign 操作) 把数据从工作内存同步回主内存中.
一个新的变量只能在主内存中诞生, 不允许在工作内存中直接使用一个未被初始化 (load 或 assign) 的变量. 即就是对一个变量实施 use 和 store 操作之前, 必须先执行过了 assign 和 load 操作.
一个变量在同一时刻只允许一条线程对其进行 lock 操作, 但 lock 操作可以被同一条线程重复执行多次, 多次执行 lock 后, 只有执行相同次数的 unlock 操作, 变量才会被解锁. lock 和 unlock 必须成对出现
如果对一个变量执行 lock 操作, 将会清空工作内存中此变量的值, 在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值
如果一个变量事先没有被 lock 操作锁定, 则不允许对它执行 unlock 操作; 也不允许去 unlock 一个被其他线程锁定的变量.
对一个变量执行 unlock 操作之前, 必须先把此变量同步到主内存中(执行 store 和 write 操作).
- (上述部分参考并引用《深入理解 Java 虚拟机》中的内容)
- volatile(能够保证内存可见性和禁止指令重排序)
对于 volatile 修饰的变量, JMM 对其有一些特殊的规定.
内存可见性
往简单来说 volatile 关键字可以理解为, 有一个 volatile 修饰的变量 x, 当一个线程需要使用该变量的时候, 直接从主内存中读取, 而当一个线程修改该变量的值时, 直接写入到主内存中. 根据之前的分析我们能得出具备这些特性的 volatile 能够保证一个变量的内存可见性和内存一致性.
指令重排序
指令重排序是一个大部分 CPU 都有的操作, 同时 JVM 在运行时也会存在指令重排序的操作. 简单举个: chestnut:
- private void test(){
- int a,b,c;//1
- a=1;//2
- b=3;//3
- c=a+b;//4
- }
假设有上面这么一个方法, 内部有这 4 行代码. 那么 JVM 可能会对其进行指令重排序, 而指令重排序的规定则是 as-if-serial 不管怎么重排序 (编译器和处理器为了提高并行度),(单线程) 程序的执行结果不能被改变. 根据这一规定, 编译器和处理器不会对有依赖关系的指令重排序, 但是对没有依赖的指令则可能会进行重排序. 放在上面的例子里面就是, 第 1 行代码和 2,3,4 行代码是有依赖关系的, 所以第一行代码的指令必须排在 2,3,4 之前, 因为不可能对一个未定义的变量进行赋值操作. 而第 2,3 行代码之间并没有相互依赖关系, 所以此处可能会发生指令重排序, 先执行 3, 再执行 2. 而最后的第 4 行代码和之前的 3 行代码都有依赖关系, 所以他一定会放在最后执行.
既然 JVM 特别指出指令重排序只在单线程下和未排序的效果一致, 那是否表示在多线程下会存在一些问题呢? 答案是肯定的, 多线程下指令重排序会带来一些意想不到的结果.
- int a=0;
- //flag 作为一个标识符, 标识是否写入完成
- boolean flag = false;
- public void writer(){
- a=10;//1
- flag=true;//2
- }
- public void reader(){
- if (flag)
- System.out.println("a:"+a);
- }
假设存在一个类, 他有上述部分的 field 和 method, 该类在设计上以 flag 作为写入是否完成的标志, 在单线程下这并不会存在问题. 而此时有两个线程分别执行 writer 和 reader 方法, 暂时不考虑内存可见性的问题, 假设对 a 和 flag 的写入, 是立即被其他线程所知晓的, 这个时候大家觉得输出 a 的值为多少? 10?
即使不考虑内存可见性, 此时 a 的值还是有可能会输出 0, 这就是指令重排序带来的问题. 在上述代码中注释 1 和 2 处的代码是没有依赖关系的, 在单线程下先执行 1 还是 2 都没有任何问题, 根据 as-if-serial 原则此时就可能会发生指令重排序.
而 volatile 关键字可以禁止指令重排序.
long,double 的问题
我们都知道 JMM 定义的 8 个主内存和工作内存之间的操作都是具备原子性的, 但是对 long 和 double 这两个 64 位的数据类型有一些例外.
允许虚拟机将没有被 volatile 修饰的 long 和 double 的 64 数据的读写操作划分为两次 32 位的读写操作, 即不要求虚拟机保证对他们的 load ,store,read,write 四个操作的原子性. 但是大部分的虚拟机实现都保证了这四个操作的原子性的, 所以大部分时候我们都不需要刻意的对 long,double 对象使用 volatile 修饰.
性能问题
volatile 是 Java 提供的保证内存可见性的最轻量级操作, 比起重量级的 synchronized 能快上不少, 但是具体能快多少这部分没办法量化. 而我们可以知道的是 volatile 修饰的变量读操作的性能消耗几乎和普通变量相差无几, 而写操作则会慢上一些. 所以当 volatile 能解决我们的问题的时候(内存可见性和禁止指令重排序), 我们应该优先选择使用 volatile 而不是锁.
synchronized 的内存语义
简单概括就是
当程序进入 synchronized 块时, 把在 synchronized 块中用到的变量从工作内存中清楚, 这样在需要访问这些变量的时候会重新从主内存中获取. 当程序退出 synchronized 块时, 把对块中恭喜变量的修改刷新到主内存. 如此依赖 synchronized 也能保证了内存的可见性.
final 的内存语义
final 也能保证内存的可见性
被 final 修饰的字段在构造器中一旦初始化完成, 并且构造器没有把 this 引用传递出去, 那么其他线程中就能看见 final 字段的值.
后记之 CPU 缓存行和伪共享
什么是伪共享
根据前面的文章, 我们知道 CPU 和 Memory 之间是有 Cache 的, 而 Cache 内部是按行存储的, 行拥有固定的大小, 这些行被称为缓存行. 当 CPU 访问的某个变量不在 Cache 中时, 就会去内存里获取, 并将该变量所在内存的一个缓存行大小的数据读入 Cache 中. 由于一次读取并不是单个对象而是一整个缓存行, 所以可能会存在多个变量被读入一个缓存行中. 而一个缓存行只能同时被一个线程操作, 所以当多个线程同时修改一个缓存行里的多个变量时会造成其他线程等待从而带来性能损耗(但是在单线程情况下, 伪共享反而会提升性能, 因为一次性可能会缓存多个变量, 节省后续变量的读取时间).
如何避免伪共享
在 Java8 之后可以使用 JDK 提供的 @sun.misc.Contended 注解来解决伪共享, 像 Thread 中的 threadLocalRandom 字段就使用了这个注解.
那么架构师掌握的技能又有哪些呢?
前面我写的文章《揭秘阿里 Java 架构师背后的技术体系支撑(详细分层, 建议收藏)》中有详细讲解.
喜欢的朋友可以关注下专栏: Java 架构技术进阶. 里面有大量 batj 面试题集锦, 还有各种技术分享, 如有好文章也欢迎投稿哦.
来源: http://www.jianshu.com/p/3c968c1f3ae5