转载于:https://mp.weixin.qq.com/s/EhIJpxRUb26KCJqpFbBCrA
1 JMM
1.1 问题引入
为什么要有内存模型
要想回答这个问题,我们需要先弄懂传统计算机硬件内存架构。好了,要开始画图了
硬件内存架构图
含有一二三级架构的内存架构图
1.2 CPU模型
去过机房的同学都知道,一般在大型服务器上会配置多个CPU,每个CPU还会有多个核,这就意味着多个CPU或者多个核可以同时(并发)工作。如果使用Java
起了一个多线程的任务,很有可能每个 CPU
都会跑一个线程,那么你的任务在某一刻就是真正并发执行了。
1.2.1 CPU Register
CPU Register
也就是 CPU 寄存器
。CPU寄存器
是 CPU
内部集成的,在寄存器上执行操作的效率要比在主存上高出几个数量级
在CPU
中至少要有六类寄存器:指令寄存器(IR)、程序计数器(PC)、地址寄存器(AR)、数据寄存器(DR)、累加寄存器(AC)、程序状态字寄存器(PSW)。这些寄存器用来暂存一个计算机字,其数目可以根据需要进行扩充
按与CPU远近来分
,离得最近的是寄存器
,然后缓存
,最后内存
。所以,寄存器是最贴近CPU
的,而且CPU只与寄存器中进行存取。寄存器从内存中读取数据,但由于寄存器和内存读取速度相差太大,所以有了缓存
。即读取数据的方式为:
CPU〈------〉寄存器〈---->缓存<----->内存
当寄存器没有从缓存中读取到数据时,也就是没有命中,那么就从内存中读取数据
1.2.2 CPU Cache Memory
CPU Cache Memory
也就是CPU
高速缓存。相对于硬盘读取速度来说内存读取的效率非常高,但是与 CPU
还是相差数量级,所以在 CPU 和主存间引入了多级缓存,目的是为了做一下缓冲。
CPU
内部集成的缓存称为一级缓存(L1 Cache
),外部的称为二级缓存(L2 Cache
)。
一级缓存中又分为数据缓存(D-Cache
)和指令缓存(I-Cache
)。二者可以同时被CPU进行访问,减少了争用Cache
所造成的冲突,提高了CPU的效能。
CPU的一级缓存通常都是静态RAM
(Static RAM/SRAM),速度非常快,但是贵
二级缓存
是CPU
性能表现的关键之一,在CPU核心不变化的情况下,增加二级缓存容量能使性能大幅度提高。而同一核心的CPU高低端之分往往也是在二级缓存上存在差异
三级缓存
是为读取二级缓存后未命中的数据设计的一种缓存,在拥有三级缓存的CPU中,只有约5%的数据需要从内存中调用,这进一步提高了CPU的效率,从某种意义上说,预取效率的提高,大大降低了生产成本却提供了非常接近理想状态的性能
1.2.3 Main Memory
Main Memory
就是主存,主存比 L1、L2
缓存要大很多
注意:部分高端机器还有 L3
三级缓存。
内存中相关概念:
- ROM(Read Only Memory)
只读储存器 ,对于电脑来讲就是硬盘,在系统停止供电的时候仍然可以保持数据 - PROM
PROM
是可编程的ROM
,PROM
和EPROM
(可擦除可编程ROM)两者区别是,PROM
是一次性的,也就是软件灌入后,就无法修改了,现在已经不可能使用了,而EPROM
是通过紫外光的照射擦除原先的程序,是一种通用的存储器。另外一种EEPROM
是通过电子擦除,价格很高,写入时间很长,写入很慢。 - RAM(Random Access Memory)
随机储存器 ,就是电脑内存条
。用于存放动态数据。(也叫运行内存)系统运行的时候,需要把操作系统从ROM
中读取出来,放在RAM
中运行,而RAM
通常都是在掉电之后就丢失数据,典型的RAM
就是计算机的内存 - 静态RAM(Static RAM/SRAM)
当数据被存入其中后不会消失。SRAM
速度非常快,是目前读写最快的存储设备。当这个SRAM
单元被赋予0 或者1 的状态之后,它会保持这个状态直到下次被赋予新的状态或者断电之后才会更改或者消失。需要4-6 只晶体管实现, 价格昂贵。
一级,二级,三级缓存都是使用SRAM
- 动态RAM(Dynamic RAM/DRAM)
DRAM
必须在一定的时间内不停的刷新才能保持其中存储的数据。DRAM
只要1 只晶体管就可以实现。
DRAM
保留数据的时间很短,速度也比SRAM
慢,不过它还是比任何的ROM都要快,但从价格上来说DRAM
相比SRAM
要便宜很 多,
计算机内存就是DRAM
的
1.2.4 主存存取原理
目前计算机使用的主存基本都是随机读写存储器(RAM),现代RAM的结构和存取原理比较复杂,这里本文抛却具体差别,抽象出一个十分简单的存取模型来说明RAM的工作原理。
从抽象角度看,主存是一系列的存储单元组成的矩阵,每个存储单元存储固定大小的数据。每个存储单元有唯一的地址,现代主存的编址规则比较复杂,这里将其简化成一个二维地址:通过一个行地址和一个列地址可以唯一定位到一个存储单元。上图展示了一个4 x 4的主存模型。
主存的存取过程如下:
- 当系统需要读取主存时,则将地址信号放到地址总线上传给主存,主存读到地址信号后,解析信号并定位到指定存储单元,然后将此存储单元数据放到数据总线上,供其它部件读取。
- 写主存的过程类似,系统将要写入单元地址和数据分别放在地址总线和数据总线上,主存读取两个总线的内容,做相应的写操作。
这里可以看出,主存存取的时间仅与存取次数呈线性关系,因为不存在机械操作,两次存取的数据的“距离”不会对时间有任何影响,例如,先取A0再取A1和先取A0再取D3的时间消耗是一样的。
1.2.5 磁盘存取原理
与主存不同,磁盘I/O存在机械运动耗费,因此磁盘I/O的时间消耗是巨大的。
下图是磁盘的整体结构示意图。
一个磁盘由大小相同且同轴的圆形盘片组成,磁盘可以转动(各个磁盘必须同步转动)。在磁盘的一侧有磁头支架,磁头支架固定了一组磁头,每个磁头负责存取一个磁盘的内容。磁头不能转动,但是可以沿磁盘半径方向运动(实际是斜切向运动),每个磁头同一时刻也必须是同轴的,即从正上方向下看,所有磁头任何时候都是重叠的(不过目前已经有多磁头独立技术,可不受此限制)。
下图是磁盘结构的示意图。
盘片被划分成一系列同心环,圆心是盘片中心,每个同心环叫做一个磁道,所有半径相同的磁道组成一个柱面。磁道被沿半径线划分成一个个小的段,每个段叫做一个扇区,每个扇区是磁盘的最小存储单元。为了简单起见,我们下面假设磁盘只有一个盘片和一个磁头。
当需要从磁盘读取数据时,系统会将数据逻辑地址传给磁盘,磁盘的控制电路按照寻址逻辑将逻辑地址翻译成物理地址,即确定要读的数据在哪个磁道,哪个扇区。为了读取这个扇区的数据,需要将磁头放到这个扇区上方,为了实现这一点,磁头需要移动对准相应磁道,这个过程叫做寻道
,所耗费时间叫做寻道时间
,然后磁盘旋转将目标扇区旋转到磁头下,这个过程耗费的时间叫做旋转时间
。
1.2.6 局部性原理与磁盘预读
由于存储介质的特性,磁盘本身存取就比主存慢很多,再加上机械运动耗费,磁盘的存取速度往往是主存的几百分分之一,因此为了提高效率,要尽量减少磁盘I/O。为了达到这个目的,磁盘往往不是严格按需读取,而是每次都会预读,即使只需要一个字节,磁盘也会从这个位置开始,顺序向后读取一定长度的数据放入内存。
这样做的理论依据是计算机科学中著名的局部性原理:
当一个数据被用到时,其附近的数据也通常会马上被使用。程序运行期间所需要的数据通常比较集中。由于磁盘顺序读取的效率很高(不需要寻道时间,只需很少的旋转时间),因此对于具有局部性的程序来说,预读可以提高I/O效率。
预读的长度一般为页(page)
的整倍数。页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页(在许多操作系统中,页得大小通常为4k),主存和磁盘以页为单位交换数据。当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后异常返回,程序继续运行。
1.3 缓存一致性问题
由于主存与 CPU
处理器的运算能力之间有数量级的差距,所以在传统计算机内存架构中会引入高速缓存来作为主存和处理器之间的缓冲,CPU
将常用的数据放在高速缓存中,运算结束后 CPU
再将运算结果同步到主存中
使用高速缓存解决了 CPU
和主存速率不匹配的问题,但同时又引入另外一个新问题:缓存一致性问题
在多
CPU
的系统中(或者单CPU
多核的系统),每个CPU
内核都有自己的高速缓存,它们共享同一主内存(Main Memory
)。当多个CPU
的运算任务都涉及同一块主内存区域时,CPU
会将数据读取到缓存中进行运算,这可能会导致各自的缓存数据不一致。
因此需要每个CPU
访问缓存时遵循一定的协议,在读写数据时根据协议进行操作,共同来维护缓存的一致性。这类协议有 MSI、MESI、MOSI、和 Dragon Protocol
等
缓存一致性和原子操作
1.4 处理器优化和指令重排序
为了提升性能在 CPU
和主内存之间增加了高速缓存,但在多线程并发场景可能会遇到缓存一致性问题。那还有没有办法进一步提升 CPU 的执行效率呢?答案是:处理器优化
为了使处理器内部的运算单元能够最大化被充分利用,处理器会对输入代码进行乱序执行处理,这就是处理器优化
除了处理器会对代码进行优化处理,很多现代编程语言的编译器也会做类似的优化
处理器优化其实也是重排序的一种类型,这里总结一下,重排序可以分为三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
- 内存系统的重排序。由于处理器使用缓存和读写缓冲区,这使得
加载
和存储
操作看上去可能是在乱序执行
1.5 并发编程的问题
上面讲了一堆硬件相关的东西,有些同学可能会有点懵,绕了这么大圈,这些东西跟 Java
内存模型有啥关系吗?不要急咱们慢慢往下看
熟悉 Java
并发的同学肯定对这三个问题很熟悉:可见性问题
、原子性问题
、有序性问题
。如果从更深层次看这三个问题,其实就是上面讲的缓存一致性
、处理器优化
、指令重排序
造成的
缓存一致性问题其实就是可见性问题,处理器优化可能会造成原子性问题,指令重排序会造成有序性问题,你看是不是都联系上了
出了问题总是要解决的,那有什么办法呢?首先想到简单粗暴的办法,干掉缓存让 CPU
直接与主内存交互就解决了可见性问题,禁止处理器优化和指令重排序就解决了原子性和有序性问题,但这样一夜回到解放前了,显然不可取。
所以技术前辈们想到了在物理机器上定义出一套内存模型, 规范内存的读写操作。内存模型解决并发问题主要采用两种方式:限制处理器优化
和使用内存屏障
1.5.1 可见性
可见性:当一个线程修改了共享变量的值,其他线程会马上知道这个修改。当其他线程要读取这个变量的时候,最终会去内存中读取,而不是从缓存中读取
当对非
volatile
变量进行读写时,每个线程从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的CPU缓存中。而声明变量是volatile
的,JVM保证了每次读变量都从内存中读,跳过了CPU cache这一步
1.5.2 原子性
原子性:即一个操作或者多个操作,要么全部执行并且不被打断,要么就都不执行
对变量的写操作不依赖于当前值才是原子级别的,在多线程环境中才可以不用考虑多并发问题。比如:n=n+1、n++ 就不行。n=m+1才是原子级别的,实在没把握就使用synchronized关键字来代替volatile关键字
1.5.3 有序性
有序性:虚拟机在进行代码编译时,对于那些改变顺序之后不会对最终结果造成影响的代码,虚拟机不一定会按照我们写的代码的顺序来执行,有可能将他们重排序。实际上,对于有些代码进行重排序之后,虽然对变量的值没有造成影响,但有可能会出现线程安全问题。
volatile
本身就包含了禁止指令重排序的语义,而synchronized
关键字是由一个变量在同一时刻只允许一条线程对其进行lock操作
这条规则明确的
synchronized的特点,一个线程执行互斥代码过程如下:
- 获得同步锁;
- 清空工作内存;
- 从主内存拷贝对象副本到工作内存;
- 执行代码(计算或者输出等);
- 刷新主内存数据;
- 释放同步锁
1.6 介绍JMM
1.6.1 JMM定义
Java内存模型
可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象描述,不同架构下的物理机拥有不一样的内存模型,Java虚拟机
是一个实现了跨平台的虚拟系统,因此它也有自己的内存模型,即Java
内存模型(Java Memory Model, JMM
)
Java
内存模型是一种规范,定义了很多东西:
- 所有的变量都存储在主内存(
Main Memory
)中 - 每个线程都有一个私有的本地内存(
Local Memory
),本地内存中存储了该线程以读/写共享变量的拷贝副本 - 线程对变量的所有操作都必须在本地内存中进行,而不能直接读写主内存。
-
不同的线程之间无法直接访问对方本地内存中的变量
1.6.2 线程间通信
如果两个线程都对一个共享变量进行操作,共享变量初始值为 1,每个线程都变量进行加 1,预期共享变量的值为 3。在 JMM 规范下会有一系列的操作
为了更好的控制主内存和本地内存的交互,
Java
内存模型定义了八种操作来实现:
-
lock
:锁定,作用于主内存的变量,把一个变量标识为一条线程独占状态 -
unlock
:解锁,作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定 -
read
:读取,作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load
动作使用 -
load
:载入,作用于工作内存的变量,它把read
操作从主内存中得到的变量值放入工作内存的变量副本中 -
use
:使用,作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作 -
assign
:赋值,作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作 -
store
:存储,作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write
的操作 -
write
:写入,作用于主内存的变量,它把store
操作从工作内存中一个变量的值传送到主内存的变量中
注意:
工作内存也就是本地内存的意思
对被
volatile
修饰的变量进行操作时,需要满足以下规则:
- 规则1:线程对变量执行的前一个动作是
load
时才能执行use
,反之只有后一个动作是use
时才能执行load
。线程对变量的read
,load
,use
动作关联,必须连续一起出现。-----这保证了线程每次使用变量时都需要从主存拿到最新的值,保证了其他线程修改的变量本线程能看到。 - 规则2:线程对变量执行的前一个动作是
assign
时才能执行store
,反之只有后一个动作是store
时才能执行assign
。线程对变量的assign
,store
,write
动作关联,必须连续一起出现。-----这保证了线程每次修改变量后都会立即同步回主内存,保证了本线程修改的变量其他线程能看到。 - 规则3:有线程T,变量V、变量W。假设动作A是T对V的
use或assign
动作,P是根据规则2、3与A关联的read或write
动作;动作B是T对W的use或assign动作,Q是根据规则2、3与B关联的read或write动作。如果A先与B,那么P先与Q。------这保证了volatile修饰的变量不会被指令重排序优化,代码的执行顺序与程序的顺序相同
1.6.3 Java运行时内存区域与硬件内存的关系
了解过 JVM
的同学都知道,JVM
运行时内存区域是分片的,分为栈、堆等,其实这些都是 JVM
定义的逻辑概念。在传统的硬件内存架构中是没有栈和堆这种概念
从图中可以看出栈和堆既存在于高速缓存中又存在于主内存中,所以两者并没有很直接的关系
2 JMM总结
由于CPU
和主内存间存在数量级的速率差,想到了引入了多级高速缓存的传统硬件内存架构来解决,多级高速缓存作为 CPU
和主内间的缓冲提升了整体性能。解决了速率差的问题,却又带来了缓存一致性问题。
数据同时存在于高速缓存和主内存中,如果不加以规范势必造成灾难,因此在传统机器上又抽象出了内存模型
Java
语言在遵循内存模型的基础上推出了 JMM
规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题