Java 内存模型跟上一篇 JVM 内存结构很像,我经常会把他们搞混,但其实它们不是一回事,而且相差还很大的,希望你没它们搞混,特别是在面试的时候,搞混了的话就会答非所问,影响你的面试成绩,当然也许你碰到了半吊子面试官,那就要恭喜你了。Java 内存模型比 JVM 内存结构复杂很多,Java 内存模型有一个规范叫:《JSR 133 :Java内存模型与线程规范》,里面的内容很丰富,如果你没看过的话,我建议你看一下。今天我们就简单的来聊一聊 Java 内存模型,关于 Java 内存模型,我们还是先从硬件内存模型入手。
硬件内存模型
先来看看硬件内存简单架构,如下图所示:
这是一幅简单的硬件内存结构图,真实的结构图要比这复杂很多,特别是在缓存层,现在的计算机中 CPU 缓存一般有三层,你也可以打开你的电脑看看,打开 任务资源管理器 — 性能 — cpu ,如下图所示:
从图中可以看出我这台机器的 CPU 有三级缓存,一级缓存 (L1) 、二级缓存(L2)、三级缓存(L3),一级缓存是最接近 CPU 的,三级缓存是最接近内存的,每一级缓存的数据都是下一级缓存的一部分。三级缓存架构如下图所示:
现在我们对硬件内存架构有了一定的了解,我们来弄明白一个问题,为什么需要在 CPU 和内存之间添加缓存?
关于这个问题我们就简单点说,我们知道 CPU 是高速的,而内存相对来说是低速的,这就会造成一个问题,不能充分的利用 CPU 高速的特点,因为 CPU 每次从内存里获取数据的话都需要等待,这样就浪费了 CPU 高速的性能,缓存的出现就是用来消除 CPU 与内存之间差距的。缓存的速度要大于内存小于 CPU ,加入缓存之后,CPU 直接从缓存中读取数据,因为缓存还是比较快的,所以这样就充分利用了 CPU 高速的特性。但也不是每次都能从缓存中读取到数据,这个跟我们项目中使用的 redis 等缓存工具一样,也存在一个缓存命中率,在 CPU 中,先查找 L1 Cache,如果 L1 Cache 没有命中,就往 L2 Cache 里继续找,依此类推,最后没找到的话直接从内存中取,然后添加到缓存中。当然当 CPU 需要写数据到主存时,同样会先刷新寄存器中的数据到 CPU 缓存,然后再把数据刷新到主内存中。
也许你已经看出了这个框架的弊端,在单核时代只有一个处理器核心,读/写操作完全都是由单核完成,没什么问题;但是多核架构,一个核修改主存后,其他核心并不知道数据已经失效,继续傻傻的使用主存或者自己缓存层的数据,那么就会导致数据不一致的情况。关于这个问题 CPU 硬件厂商也提供了解决办法,叫做缓存一致性协议(MESI协议),缓存一致性协议这东西我也不了解,我也说不清,所以就不在这里 BB 了,有兴趣的可以自行研究。
聊完了硬件内存架构,我们将焦点回到我们的主题 Java 内存模型上,下面就一起来聊一聊 Java 内存模型。
Java 内存模型
Java 内存模型是什么?Java 内存模型可以理解为遵照多核硬件架构的设计,用 Java 实现了一套 JVM 层面的“缓存一致性”,这样就可以规避 CPU 硬件厂商的标准不一样带来的风险。好了,正式介绍一下 Java 内存模型:Java 内存模型 ( Java Memory Model,简称 JMM ),本身是种抽象的概念,并不是像硬件架构一样真实存在的,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量 (包括实例字段、静态字段和构成数组对象的元素) 的访问方式,更多关于 Java 内存模型知识可以阅读 JSR 133 :Java内存模型与线程规范。
我们知道 JVM 运行程序的实体是线程,在上一篇 JVM 内存结构中我们得知每个线程创建时,JVM 都会为其创建一个工作内存 ( Java 栈 ),用于存储线程私有数据,而 Java 内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作 ( 读取赋值等 ) 必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完后再将变量写回主内存,不能直接操作主内存中的变量。
我们知道 Java栈是每个线程私有的数据区域,别的线程无法访问到不同线程的私有数据,所以线程需要通信的话,就必须通过主内存来完成,Java 内存模型就是夹在这两者之间的一组规范,我们先来看看这个抽象架构图:
从结构图来看,如果线程 A 与线程 B 之间需要通信的话,必须要经历下面 2 个步骤:
首先,线程 A 把本地内存 A 中的共享变量副本中的值刷新到主内存中去。
然后,线程 B 到主内存中去读取线程 A 更新之后的值,这样线程 A 中的变量值就到了线程 B 中。
我们来看一个具体的例子来加深一下理解,看下面这张图:
现在线程 A 需要和线程 B 通信,我们已经知道线程之间通信的两部曲了,假设初始时,这三个内存中的 x 值都为 0。线程 A 在执行时,把更新后的 x 值(假设值为 1)临时存放在自己的本地内存 A 中。当线程 A 和线程 B 需要通信时,线程 A 首先会把自己本地内存中修改后的 x 值刷新到主内存中,此时主内存中的 x 值变为了 1。随后,线程 B 到主内存中去读取线程 A 更新后的 x 值,此时线程 B 的本地内存的 x 值也变为了 1,这样就完成了一次通信。
JMM 通过控制主内存与每个线程的本地内存之间的交互,来为 Java 程序员提供内存可见性保证。Java 内存模型除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。这套实现也就是我们常用的volatile、synchronized、final 等。
Happens-Before 内存模型
Happens-Before 内存模型或许叫做 Happens-Before 原则更为合适,在 《JSR 133 :Java内存模型与线程规范》中,Happens-Before 内存模型被定义成 Java 内存模型近似模型,Happens-Before 原则要说明的是关于可见性的一组偏序关系。
为了方便程序员开发,将底层的烦琐细节屏蔽掉,Java 内存模型 定义了 Happens-Before 原则。只要我们理解了Happens-Before 原则,无需了解 JVM 底层的内存操作,就可以解决在并发编程中遇到的变量可见性问题。JVM 定义的 Happens-Before 原则是一组偏序关系:对于两个操作A和B,这两个操作可以在不同的线程中执行。如果A Happens-Before B,那么可以保证,当A操作执行完后,A操作的执行结果对B操作是可见的。
Happens-Before 原则一共包括 8 条,下面我们一起简单的学习一下这 8 条规则。
1、程序顺序规则
这条规则是指在一个线程中,按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。这一条规则还是非常好理解的,看下面这一段代码
第四行代码要 Happens-Before 于第五行代码,也就是按照代码的顺序来。
2、锁定规则
这条规则是指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的
对于锁定规则可以这样理解:假设 x 的初始值是 10,线程 A 执行完代码块后 x 的值会变成 12(执行完自动释放锁),线程 B 进入代码块时,能够看到线程 A 对 x 的写操作,也就是线程 B 能够看到 x==12。
3、volatile变量规则
这条规则是指对一个 volatile 变量的写操作及这个写操作之前的所有操作 Happens-Before 对这个变量的读操作及这个读操作之后的所有操作。
4、线程启动规则
这条规则是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
子线程 t1 能够看见主线程对 count 变量的修改,所以在线程中打印出来的是 12 。这也就是线程启动规则
5、线程结束规则
这条是关于线程等待的。它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作。
6、中断规则
一个线程在另一个线程上调用 interrupt ,Happens-Before 被中断线程检测到 interrupt 被调用。
mian 线程中调用了 t1 线程的 interrupt() 方法,mian 对 count 的修改对 t1 线程是可见的。
7、终结器规则
一个对象的构造函数执行结束Happens-Before它的finalize()方法的开始。“结束”和“开始”表明在时间上,一个对象的构造函数必须在它的finalize()方法调用时执行完。根据这条原则,可以确保在对象的finalize方法执行时,该对象的所有field字段值都是可见的。
8、传递性规则
这条规则是指如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens- Before C。