硬件内存模型&Java内存模型学习笔记
硬件内存模型
硬件内存模型的目标是为了让汇编代码能够运行在一个具有一致性的内存视图上。
抽象结构
单CPU模型
cpu---->寄存器---->缓存---->内存(主存)
多CPU模型
模型可以在单CPU中运行,但是当计算机引入多CPU时,就会出现缓存一致性问题(数据不同步)。 如下图
缓存一致性问题
问题描述:两个CPU对主存中同一个值进行修改,其中一个cpu未将更新的值刷新入主存中,另一个就已经读取值,数据不同步导致缓存一致性问题。
解决办法:缓存一致性协议。cpu想要读取主存中的值时需要遵循软件层面的缓存一致性协议。
CPU缓存一致性协议
其中内容是一些和数据同步相关的操作,就可能会出现等待/唤醒等步骤,就可能导致性能问题。因此计算器科学家对此进行优化,整体思想上将同步改为异步。例如,cpu2想要读取主存上的值A,此时A正在被其他cpu修改,此时cpu2会注册一个读取A值的消息,自己先做其他事情,其他cpu修改完A后响应cpu2的注册,cpu2发现消息被响应再去主存读A值。
这样可以有效提升cpu效率,但是对于cpu2来说就不是顺序执行的了,可能会出现先运行后面的指令,再回头运行前面的指令,这就是指令重排。
指令重排问题
指令重排依然需要保证程序运行结果的准确性,无论如何重排,最后结果一定要与顺序执行时一致。
Java内存模型
硬件内存模型的目标是为了让汇编代码能够运行在一个具有一致性的内存视图上。随着高级语言的流行,工程师开始设计编程语言级别的内存模型,这是为了能够使用该语言进行编程时也能拥有一个一致性的内存视图。比如Java内存模型屏蔽掉了各种硬件和操作系统的内存访问差异,实现了让Java程序能够在各种硬件平台下都能够按照预期的方式来运行。
抽象结构
Java内存模型的抽象结构如图,每个工作线程都拥有独占的本地内存,本地内存中的存储的是私有变量以及共享变量的副本,并且使用一定机制来控制本地内存和主存之间读写数据时的同步问题。工作线程和本地内存具象为 thread stack ,将主存具象为heap。
- thread stack中有两种类型变量:
- 原始类型变量,比如 int、char等 总是存储在线程栈上。
- 对象类型变量,引用(指针)本身存储在线程栈上,引用指向的对象存储在堆上。
- heap 中:
- 存储对象本身,持有对象引用的线程就能访问该对象,heap不关系哪个线程正在访问对象。
Thread Stack & Heap 都是对物理内存的抽象,这样开发者只需要关心自己写的程序使用到了Thread Stack & Heap,而不需要关心更下层的寄存器、CPU缓存、主存。线程在工作时大部分都在读写Thread Stack 中的本地内存,对本地内存的速度要求更高高,可能大部分都是使用寄存器和CPU缓存来实现。而Heap中需要存储大量的对象,需要大的容量那么他可能都是使用主存来实现的。
以上就是Java内存模型与硬件内存模型模糊的映射关系。
线程之间的通信
Java内存模型需要设计一些机制来实现主存与工作内存之间的数据传输与同步,这种数据的传递正是线程之间的通信方式。
线程之间通过主存进行通信,存在着很多问题:可见性、原子性、有序性。
并发三要素
可见性
可见性:当一个线程修改共享变量的值,其他线程需要能立刻得知这个修改。
- 由刷新主存的时机引起的可见性问题:线程A修改了数据D,线程B需要读到修改后最新的D。当一个线程在自己的工作内存中修改了某个变量,应该把该变量立刻刷新主存中并让其他线程知道。
- 解决方法1:利用volataile关键字。写volatile变量,主动写内存;读volatile变量,主动读内存。刷新主存。
- 解决方法2:利用synchronized关键字。sync内部读写变量,隐式调用lock/unlock指令主动读写内存,并清空工作内存中该变量的值,需要使用该变量时,主动从内存中读取。
- 由指令重排引起可见性问题:线程B需要读到被修改的变量D,线程A应该修改,但是因为重排序导致线程A没有及时修改变量D。不止硬件内存模型中存在指令重排,Java内存模型中也存在指令重排。
- 解决方法1:利用volatail关键字。禁止当前变量与之前的语句进行重排。volatile这行相当于“基准线”,当运行到基准线,能保证之前语句和自身的可见性。
- 解决方法2:利用synchronized关键字。sync内部读写变量,隐式调用lock/unlock指令主动读写内存。将会重排的代码块放入sync代码块中,无论其内部如何重排,最后的结果都会绑定在一直执行(一起成功或失败),外部只能读到最终结果就避免了可见性问题。
可见性问题解决方式:Happens-Before原则
Happens-Before原则主要是为了保证可见性:对于两个操作A和B,这两个操作可以在不同的线程中执行。如果A Happens-Before B(A先与B执行),那么可以保证当A操作执行完后,A操作的执行结果对B操作是可见的。Happens-Before原则共8个,以下为主要的三个:
- 程序顺序规则:在一个线程的内部按照程序代码的书写顺序,书写在前面的代码操作Happens-Before书写在后面的代码操作。因为在单个线程中,程序员编写的代码在语义上是需要串行顺序地执行。即使编译后的代码可能会进行重排,但内存模型会保证程序执行结果的正确性。
- 锁定规则:对于一个锁的解锁总是Happens-Before这个锁的加锁。synchronized保证可见性的主要原理为:
- 及时刷新主存。
- 原子化一组指令。
- volatile变量原则:对一个volatile变量的写,总是Happens-Before与后续对这个volatile变量的读。volatile保证可见性的主要原理为:
- 及时刷新主存。
- 禁止重排序。
原子性
含义:一个操作时不可中断的,要么全部执行成功,要么全部执行失败。
- 单指令原子操作。单个指令要么成功,要么失败。比如CAS
- 利用锁的组合指令原子操作。一组操作要么成功要么失败。比如synchronized关键字。
原子性问题描述:线程A和B同时从主存中获取x=1并执行x++操作,将x=2刷新入主存。执行了两次x++操作结果应该为3,但主存中的x却为2,即原子性问题。反映了线程通信的同步问题。
有序性
有序性问题和可见性密不可分,指令重排的乱序最有可能导致的就是可见性问题。
结尾
硬件内存模型和Java内存模型都支持指令重排这种优化操作。在单线程中,内存模型能够保证执行结果的准确性,无论如何重排都和顺序执行的结果是一致的。但是在多线程环境下,就可能因为指令重排导致一些问题。指令重排引起的乱序最有可能导致的就是可见性问题。
视频链接:https://www.bilibili.com/video/BV1F64y1B7sV?spm_id_from=333.999.0.0