JAVA内存模型(JMM)
Java 虚拟机规范通过来定义一个JMM来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果
JMM与Java内存结构:
对比项 | JMM | 内存结构 |
---|---|---|
区别 | 抽象的,描述一组规则指明线程如何处理共享内存与私有内存 | 划分是具体的,是JVM运行Java程序时,必要的内存划分 |
联系 | 都存在私有数据区域和共享数据区域 |
硬件层数据一致性
由于计算机的存储设备与处理器的运算速度有着几个数量级的差距,所以现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲
这样加入一个中间缓冲区就会导致当有多个处理器时 每个处理器都有自己的高速缓存 但又共用一个缓存 会造成数据的不一致性
所以这就需要各个处理器在读写时遵守一些协议,如有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等
现代CPU的数据一致性实现 = 缓存锁(MESI ...) + 总线锁
- 缓存行
L1 L2缓存在核内,L3缓存核间共享
CPU可以一次读取整个缓存行,所以利用缓存行的对齐能够提高效率(disruptor)
主内存与工作内存
Java内存模型规定了所有的变量都存储在主内存, 每条线程还有自己的工作内存(本地内存),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据,各个线程间变量的传递也必须通过主内存
这里主内存/工作内存的划分不与堆栈等在同一层次
更多地 工作内存指的是底层CPU缓存或者寄存器 主内存指的是物理主内存
乱序问题
CPU为了提高指令执行效率,除了增加缓存之后,也会在一条指令执行过程中(比如去内存读数据(慢100倍)),去同时执行另一条指令,前提是,两条指令没有依赖关系
硬件级防乱序
X86
sfence: store| 在sfence指令前的写操作当必须在sfence指令后的写操作前完成。lfence:load | 在lfence指令前的读操作当必须在lfence指令后的读操作前完成。mfence:modify/mix | 在mfence指令前的读写操作当必须在mfence指令后的读写操作前完成
原子指令,如x86上的”lock …” 指令是一个Full Barrier,执行时会锁住内存子系统来确保执行顺序,甚至跨多个CPU。Software Locks通常使用了内存屏障或原子指令来实现变量可见性和保持程序顺序
JVM级防乱序(JSR133)
- [内存屏障](/计算机系统/程序结构和执行/存储器层次结构.html#内存屏障)
volatile的细节
当一个变量被定义成volatile之后,它将具备两项特性:第一项是保证此变量对所有线程的可见性,第二个特性是禁止指令重排序优化
volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行
X86_64 平台上,只有 volatile 字段的写操作会强制刷新缓存,以及即时编译器无法将 volatile 字段分配到寄存器里
底层实现
- 字节码层面
ACC_VOLATILE 修饰符
- JVM层面
volatile内存区的读写 都加屏障
StoreStoreBarrier
volatile 写操作
StoreLoadBarrier
LoadLoadBarrier
volatile 读操作
LoadStoreBarrier
- 硬件/OS层面
内存屏障/Lock指令
synchronized实现细节
- 字节码层面
ACC_SYNCHRONIZED
monitorenter monitorexit
- JVM层面
C C++ 调用了操作系统提供的同步机制
- OS和硬件层面
X86 : lock cmpxchg / xxx
compxchg [ax] (隐式参数,EAX累加器), [bx] (源操作数地址), [cx] (目标操作数地址)
第一个操作数不在指令里面出现,是一个隐式的操作数,也就是 EAX 累加寄存器里面的值。第二个操作数就是源操作数,并且指令会对比这个操作数和上面的累加寄存器里面的值
如果值是相同的,那一方面,CPU 会把 ZF(也就是条件码寄存器里面零标志位的值)设置为 1,然后再把第三个操作数(也就是目标操作数),设置到源操作数的地址上。如果不相等的话,就会把源操作数里面的值,设置到累加器寄存器里面
内存间的交互操作
- lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
- write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
在最新的JSR133中,将这8大操作简化为read、write、lock和unlock四种
三大特性
Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的
原子性
JAVA内存模型保证了8种内存操作具有原子性
但在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行
但对于大多数情况,除非知道long 或者 double变量会有线程争用,否则没有必要使用volatile修饰它们
虚拟机提供了monitorenter monitorexit来提供更大范围原子性的保证 对应的语言层面就是synchronized关键字
可见性
可见性指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改
变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性
实现可见性的方式:
- 被volatile修饰的变量,它不会被指令重排序并保证修改的值会立即被更新到主存
- synchronized,对一个变量执行 unlock 操作之前,必须把变量值同步回主内存
- final,被 final 关键字修饰的字段在构造器中一旦初始化完成,那么其它线程就能看见 final 字段的值
有序性
在本线程内观察,所有操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序
指令重排序:Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性
volatile 关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前
synchronized 也可以来保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码
- 数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性
as-if-serial语义
as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变
先行发生原则
Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就已经存在
时间先后顺序与先行发生原则之间基本没有因果关系,所以我们衡量并发安全问题的时候不要受时间顺序的干扰,一切必须以先行发生原则为准
单一线程原则一个线程内,在程序前面的操作先行发生于后面的操作
管程锁定规则一个 unlock 操作先行发生于后面对同一个锁的 lock 操作
volatile 变量规则一个 volatile 变量的写操作先行发生于后面对这个变量的读操作
线程启动规则Thread 对象的 start() 方法调用先行发生于此线程的每一个动作
线程加入规则Thread 对象的结束先行发生于 join() 方法返回
线程中断规则对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生
对象终结规则一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始
传递性如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C