并发编程 之 可见性、原子性、volatile、synchronized、Java内存模型的深入剖析
并发编程 之 可见性、原子性、volatile、synchronized、Java内存模型的深入剖析
并发编程总览
并发编程关注的3个基本的并发问题点
-
安全性
也就是正确性,指的是程序并发情况下执行的结果和预期一致
-
活跃性
比如死锁、活锁
-
性能
减少上线文的切换,减少内核调用,减少一致性流量等等
安全性问题是首要解决的问题
线程间通信方式
- 操作系统中
- 共享文件
- 网络通信
- 共享变量
- java中
- 消息传递(是一种显示的方式)
- 共享变量(内存共享,是一种隐式的方式)
线程间同步:让不可控的线程变得可以预测、可以控制
java中采用共享变量的方式进行通信,线程安全需要关注的3个点:
- 线程间可见性
- 执行的有序性
- 操作的原子性
将学习理解下面目标
-
理解volatile, synchronized, CAS等操作的底层实现原理
-
理解Happens-before规则描述的是可见性的问题
-
理解Java内存模型JMM解决了可见性和有序性的问题,而锁解决了原子性的问题
深入理解可见性、有序性、原子性,是理解并发编程的一个重要基础
深入底层
就地出发,深入底层实现
java代码最终转化为汇编指令在CPU上执行,Java并发机制依赖JVM的实现和CPU的指令。
Java 内存模型三大特征
在 Java 中提供了一系列和并发处理相关的关键字,比如 volatile
、synchronized
、final
、concurrent
包等解决原子性、有序性和可见性三大问题。
可见性
可见性,当一个线程修改一个共享变量时,另一个变量能够读到这个修改后的值
如果java线程间需要通过共享变量的方式进行同步协调,则共享变量需要在线程间保证可见性。
导致可见性问题的本质是:CPU写延迟
java中有两种方式来解决可见性:锁、volatile修饰共享变量
volatile的线程可见性
java语言规范定义:java允许线程访问共享变量,为确保共享变量更新准确一致,线程应该确保通过排他锁单独获得这个变量。volatitle修改的共享变量,就提供了这种能力。
volatile是轻量级的synchronized,保证共享变量在多处理器开发中保证可见性
那么volatitle关键字的底层的实现是怎么样的呢?
为了查看Java代码对应的汇编代码,需要如下配置:
-
将hsdis-amd64.dll和hsdis-i386.dll这两个文件,放到java安装目录下的jre下的bin目录下
-
在idea工具,配置启动参数,如下:
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*Demo1Visibility.main
先看下如下实例,我们看下用volatitle修改共享变量和不用volatitle修改共享变量的情况下,对应的汇编代码是怎样的,
public class Demo1Visibility {int i = 0;//用volatitle修饰
// volatile boolean isRunning = true;//不用volatitle修饰boolean isRunning = true;public static void main(String args[]) throws InterruptedException {Demo1Visibility demo = new Demo1Visibility();new Thread(new Runnable() {@Overridepublic void run() {System.out.println("here i am...");while(demo.isRunning){demo.i++;}System.out.println("i=" + demo.i);}}).start();Thread.sleep(3000L);demo.isRunning = false;System.out.println("shutdown...");}public void power() {int x = 2;int y = 3;int s = x * y;}
}
- 不用volatitle修饰的汇编代码
0x0000000003c8b519: movb $0x0,0x10(%rax) ;*putfield isRunning
- 用volatitle修饰的汇编代码
0x0000000003b6c723: lock addl $0x0,(%rsp) ;*putfield isRunning
综上,通过工具查看volatitle关键字产生的汇编代码,如下:
lock addl $0x0,(%rsp)
Intel IA-32 手册查得Lock前缀:
- 将当前处理器缓存行的数据写回到系统内存
- 处理器嗅探机制、MESI协议:写回系统内存的操作会使其他CPU缓存了该内存地址的数据失效
CPU的结构与Lock前缀的作用
CPU的结构
Lock前缀的作用
Intel IA-32 手册查得Lock前缀作用:
1.将当前处理器缓存行的数据写回系统内存
2.写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效
使用volatile修饰的变量进行写操作,JVM 会向处理器发送lock前缀指令,将CPU缓存行的数据写回到系统内存。
可见,CPU为了性能优化,内部会进行缓存和写延迟操作,导致共享变量的不可见和写回主内存的延迟。
原子性
原子操作表示,不可被中断的一个或一系列操作。
volatile共享变量无法做到++操作的原子性
volatile共享变量多个操作会导致在操作过程中,线程间读到没有同步的值
Java中通过锁和Atomic来保证操作的原子性,例如:synchronized关键字、Lock接口、Atomic类
那么synchronized、Lock、Atomic关键字在底层的实现是怎样的呢?
public class SynchronizedBlockDemo {int i = 0;boolean flag = false;Object object = new Object();AtomicInteger a = new AtomicInteger();public synchronized void write() {i = 1;flag = true;}public synchronized void read() {if(flag) {int n = i;// ......}}public void write1() {synchronized (object){i++;}}public int read1() {synchronized (object){return i;}}public static void main(String[] args) {SynchronizedBlockDemo demo = new SynchronizedBlockDemo();new Thread(() -> {for (int i =0;i<100;i++){
// LockSupport.parkNanos(1000 * 1000 * 1000 * 1);demo.write1();}}).start();new Thread(() -> {for (int i =0;i<100;i++){
// LockSupport.parkNanos(1000 * 1000 * 1000 * 1);demo.write1();}}).start();}
}
通过工具查看synchronized背后ACS的汇编代码
lock cmpxchg %r15, 0x16(%r10)
lock cmpxchg %r10, (%r11)
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:+LogCompilation -XX:LogFile=jit.log
Intel处理器Lock前缀及CMPXCHG
Lock指令可搭配的指令有很多
- 位测试修改指令:BTS、BTR、BTC
- 操作数逻辑指令:ADD、OR
- 交换指令:XADD、CMPXCHG
Lock前缀指令作用:
- 确保对内存的读-改-写操作原子执行
- 禁止该指令与之前和之后的读和写指令重排序
- 把写缓冲区的所有数据刷新到内存中
CAS底层实现
在Java中CAS最底层实现是 lock cmpxchg
处理器层CAS实现
CAS:Compare And Swap 输入旧新两个数值,操作期间先比较旧值是否发生变化,未变化则用新值替换掉,否则不做处理。
CAS在处理器层面又是如何实现的呢?
-
总线锁定
一个处理器提供一个LOCK # 信号,其他处理请求被阻塞,该处理器独占共享内存。问题是开销比较大。
-
缓存锁定
现代CPU大都支持。操作的内存区域如果被缓存在处理器缓存行中,并且在Lock期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上发出LOCK # 信号而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性。
Synchronized剖析
lock cmpxchg指令前者保证了可见性和防止重排序,后者保证了操作的原子性。
-
JVM实现
Monitorenter、Monitorexit
-
处理器实现,汇编指令
lock cmpxchg %r15, 0x16(%r10) 和lock cmpxchg %r10, (%r11)
理解synchronized的可见性和原子性
前面了解到,cmpxchg是CAS的汇编指令,上面汇编指令的含义是
- 先用lock指令对总线和缓存上锁,
- 然后用cmpxchg CAS操作设置对象头中的Synchronized标志位
- CAS完成后释放锁,把缓存刷新到主内存。
synchronized的底层操作含义是:
- 先对对象头的锁标志位用lock cmpxchg的方式设置成“锁住“状态
- 释放锁时,再用lock cmpxchg的方式修改对象头的锁标志位为”释放“状态,写操作都立刻写回主内存。
- JVM会进一步对synchronized时CAS失败的那些线程进行阻塞操作,这部分的逻辑没有体现在lock cmpxchg指令上。
有序性
有序性介绍
代码执行的顺序没有按照预想的顺序进行,造成了有序性问题。有序分为相对有序、绝对有序。
- 造成指令乱序的根本原因是:CPU指令重排序
- java中的线程间采用共享内存的方式进行隐式通信,同步则需要显示操作。
- 如果不做显示的同步操作,多个线程间的操作顺序会不可控,不了解这种机制,就会遇到各种奇怪的内存不可见问题
有序性解决方案
- 使用显示同步方式,控制不同线程间的操作发生相对顺序机制
- 单线程,as-if-serial模型
- java内存模型,HB大发
- 理解CPU内存模型,强弱之分
指令重排序
重排序指:编译器、处理器为优化程序性能而对指令序列重新排序的一种手段
这些重排序可能会导致多线程程序出现内存可见性问题
重排序扩展资料:《大话处理器》
CPU内存系统重排序
CPU写缓冲区对CPU自己可见,其他CPU不可见,导致CPU执行内存操作的顺序可能会与内存时间的操作执行顺序不一致。
各个处理器,对于读写操作的重排序支持的程度不一致,支持程度比较大的称为弱内存模型,支持程度小的称为强内存模型。
- 强内存模型代表:x86、SPARC-TSO,仅仅允许写-读操作重排序,其他的操作都不能重排序。
- 弱内存模型代表:PowerPC,允许读-写交叉的四种操作重排序,仅仅不允许有数据依赖的操作重排序
内存屏障
Java编译器在生成指令序列的适当的位置插入内存屏障来保证内存可见性。从而禁止特定类型的处理器重排
序。内存屏障的作用:
阻止屏障两边的指令重排序
强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效
插入的内存屏障,保证了其对内存操作的读写结果会立即写入内存,并对其他 CPU 核可见,即保证了可见性 ,解决了普通读写的延迟问题。例如,插入读屏障后,能够删除缓存,后续的读能够立刻读到内存中最新数据(至少当时看起来是最新)。插入写屏障后,能够立刻将缓存中的数据刷新入内存中,使其对其他 CPU 核可见。
因此,在 CPU 的物理世界里,内存屏障通常有三种:
lfence
: 读屏障(load fence),即立刻让 CPU Cache 失效,从内存中读取数据,并装载入 Cache 中;sfence
: 写屏障(write fence), 即立刻进行flush
,把缓存中的数据刷入内存中;mfence
: 全屏障 (memory fence),即读写屏障,保证读写都串行化,确保数据都写入内存并清除缓存。
针对读-写交织的四种标准屏障:LoadLoad、StoreStore、LoadStore、StoreLoad
x86的mfence,sfence,lfence,Unsafe类中提供的就是类似的屏障api
Lock前缀:Lock不是内存屏障,但能完成类似内存屏障的功能(前面学习过)
LoadLoad屏障:
举例语句是Load1; LoadLoad; Load2(这句里面的LoadLoad里面的第一个Load对应Load1加载代码,然后LoadLoad里面的第二个Load对应Load2加载代码),此时的意思就是在Load2加载代码在要读取的数据之前,保证Load1加载代码要从主内存里面读取的数据读取完毕。
StoreStore屏障:
举例语句是 Store1; StoreStore; Store2(这句里面的StoreStore里面的第一个Store对应Store1存储代码,然后StoreStore里面的第二个Store对应Store2存储代码)。此时的意思就是在Store2存储代码进行写入操作执行前,保证Store1的写入操作已经把数据写入到主内存里面,确认Store1的写入操作对其它处理器可见。
LoadStore屏障:
举例语句是 Load1; LoadStore; Store2(这句里面的LoadStore里面的Load对应Load1加载代码,然后LoadStore里面的Store对应Store2存储代码),此时的意思就是在Store2存储代码进行写入操作执行前,保证Load1加载代码要从主内存里面读取的数据读取完毕。
StoreLoad屏障:
举例语句是Store1; StoreLoad; Load2(这句里面的StoreLoad里面的Store对应Store1存储代码,然后StoreLoad里面的Load对应Load2加载代码),在Load2加载代码在从主内存里面读取的数据之前,保证Store1的写入操作已经把数据写入到主内存里面,确认Store1的写入操作对其它处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能
JMM内存模型
顺序一致性的内存模型
顺序一致性内存模型是一个理想化的理论参考模型,提供了极强的内存可见性保证。有两大特性:
- 一个线程中的所有操作必须按照程序代码的顺序来执行。最早期的处理器就是如此执行指令的。
- 所有线程都只能看到一个单一的操作执行顺序,每个操作必须原子执行且立刻对所有线程可见
例如:在单个线程中看到的顺序是一致的,但是多个线程如果不做同步操作,整体看起来就是无序的
as-if-serial内存模型
单线程程序执行顺序
单线程程序,不管怎么重排序,程序的执行结果不能被改变。编译器、runtime、处理器无论怎么优化,必须遵循
as-if-serial规则
// 单线程程序
public void power() {
int x = 2; // 第1步
int y = 3; // 第2步
int s = x * y; // 第3步
}
// 第一步和第二步之间没有数据依赖关系,可以进行重排序
// 第三步和第一步、第二版存在数据结果的依赖,不能重排序
JMM的HB法则
happens-before 关系用于描述两个有冲突的动作之间的顺序,如果一个action happends before 另一个action,则
第一个操作被第二个操作可见,JVM需要实现如下happens-before规则:
- 程序顺序规则:每个线程中的每个操作都 happens-before 该线程中任意的后续操作。
- 监视器锁规则:一个锁的解除,happens-before与随后对这个锁的加锁。
- volitile变量规则:对volatile域的写,happens-before于任意后续对这个volatile域的读
- start方法规则:在某个线程对象上调用 start()方法 happens-before 被启动线程中的任意动作
- join方法规则:如果在线程t1中成功执行了t2.join(),则t2中的所有操作对t1可见
- 传递性:如果A happens-before于B,且B happens-before于C,那么A happens-before于C
JMM的HB法则理解
两个操作间具有HB关系,不意味着前一个操作必须要在后一个操作之前执行。HB仅仅要求前一个操作的执行结果对后一个操作可见。
只要不影响程序执行的结果,线程内的执行顺序怎么优化都可以,线程间通过锁方式进行同步。
JMM中则允许编译器和处理器进行优化重排序
内存模型综述
-
顺序一致性内存模型
一个理论参考模型,同步的程序构成绝对一致顺序
-
as-if-seril
结果依赖顺序一致,其他的都可以重排序
-
处理器内存模型
硬件级内存模型,各个处理器实现不一样,但是都保证结果依赖顺序,分为强弱内存模型,越追求性能越弱。
-
JMM内存模型
是一个语言级别的内存模型,根据happens-before规则进行
JMM对于内存可见性的保证
- 单线程程序,不会出现内存可见性问题
- 正确同步的多线程程序,执行结果具有顺序一致性。
- 未正确同步的多线程程序,最小安全性保障,读到的值要么是之前某个线程写入的,要么是默认值。
Volitle内存语义
Volatile特性总结:
可见性,对于一个volatile变量的读,总是能看到任意线程对这个volatile变量最后的写入。
原子性,单线程,对单个volatile变量的读/写具有原子性,针对++操作是不保证原子性的。
有序性,volatile通过内存屏障,建立happens-before规则顺序
Volatile写读的内存语义:
volatile写,当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。
volatile读,当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程后续将从主内存读取共享变量
Volatile写读的内存语义的实现,内存屏障实际通过lock前缀实现。
volatile的内存屏障效果如下图:
锁的内存语义
锁的释放-获取的内存语义
锁释放,JMM将线程本地共享变量刷新到主内存。
线程获得锁,JMM将线程本地变量置为无效,必须从主内存读取共享变
锁的释放-获取的内存语义实现:lock cmpxchg,也就不难理解volatile是轻量级锁。
锁释放-获取内存语义的实现方式至少有两种:volatile、cas。
参考
资料
MESI协议动画网址:https://www.scss.tcd.ie/Jeremy.Jones/vivio/caches/MESIHelp.htm
- 《大话处理器:处理器基础知识读本.pdf》
- 《MESI_ISC_spec.0_12.pdf》
- 《深入理解计算机系统(原书第2版).pdf》
- 《Intel® 64 and IA-32 architectures-sdm-vol-1-2abcd-3abcd.pdf》
什么是Java内存模型(JMM)?
概要
Java内存模型(JMM)是Java虚拟机并发知识中很重要的一部分,为了更好的理解它。我们先花费一点时间去了解物理计算机中的并发问题。物理机遇到的并发问题与虚拟机中的情况有很多相似之处,物理机对并发的处理方案对虚拟机的实现也有相当大的参考意义。
一、硬件内存架构
传统计算机内存架构
随着技术的发展,CPU也在按照摩尔定律快速发展,而内存即主存(Main Memory)发展却十分缓慢,所以CPU与主存间产生了一种因发展速度带来的矛盾,CPU发展太快导致主存跟不上CPU的发展速度,所以出现了三级缓存(不一定都是三级),一种比主存读写速度更高的存储,三级缓存的出现暂缓了这种矛盾。从三级缓存的CPU架构看看现代计算机的内存模型。
传统计算机内存架构如下图:

缓存一致性问题
由于主存与 CPU 处理器的运算能力之间有数量级的差距,所以在传统计算机内存架构中会引入高速缓存来作为主存和处理器之间的缓冲,CPU 将常用的数据放在高速缓存中,运算结束后 CPU 再将运算结果同步到主存中。
使用高速缓存解决了 CPU 和主存速率不匹配的问题,但同时又引入另外一个新问题:缓存一致性问题。
如下图:

在多CPU的系统中(或者单CPU多核的系统),每个CPU内核都有自己的高速缓存,它们共享同一主内存(Main Memory)。每个CPU都有着自己独立的缓存,它们各自之间是不可见的,这就会导致对应CPU读取的数据都是自己缓存的,无法看到别人对共享数据的修改,从而导致并发BUG。
导致缓存一致性问题的核心主要是两个问题:
1)问题一: 在一个CPU修改了内存数据的时候,其它CPU是不知道的,所以导致一个CPU改了,另外一个CPU看不见,从而使用了旧的数据,导致了程序不正确的结果。
2)问题二: 在多个CPU同时读取和修改CPU的时候,如何保证这几个CPU操作的顺序性,一旦不能保证整个修改操作的顺序,那么就可能导致先写后读的两个请求,结果反映到内存就成了先读后写的结果,从而没有读取到最新的数据,又或者两个写数据的请求顺序被调换了,那么就可能会造成脏写。
基于总线的一致性解决方案
CPU要和存储设备进行交互,必须要通过总线设备(BUS),在获取到总线控制权后才能启动数据信息的传输,而CPU要想从主存读写数据,那么就必须向总线发起一个总线事务(读事务或写事务)来从主存读取或者写入数据。
总线嗅探机制
缓存一致性的第一个问题在于,在多CPU缓存的情况下,一个CPU修改了主存的共享变量,其它CPU是不知道的,所以解决这个问题最直接的办法就是使用通知机制,当一个CPU修改了主存的数据时,其它CPU都会收到相应的数据变更通知,收到通知的CPU如果发现自己也缓存了对应的数据,那么就会将自己缓存的数据所在缓存行标记为失效,当下次读取该数据时发现自己的缓存行已过期,那么就会选择从主存加载最新的数据。 而实现这个功能的机制就叫“总线嗅探”,总线嗅探是通过CPU侦听总线上发生的数据交换操作,当总线上发生了数据操作,那么总线就会广播对应的通知。
处理器优化和指令重排序
为了提升性能在 CPU 和主内存之间增加了高速缓存,但在多线程并发场景可能会遇到缓存一致性问题。那还有没有办法进一步提升 CPU 的执行效率呢?答案是:处理器优化。
为了使处理器内部的运算单元能够最大化被充分利用,处理器会对输入代码进行乱序执行处理,这就是处理器优化。
除了处理器会对代码进行优化处理,很多现代编程语言的编译器也会做类似的优化,比如像 Java 的即时编译器(JIT)会做指令重排序。
如下图:
重排序分为3个类型:
1)编译器优化的重排序
编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统的重排序
由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
内存屏障
既然经过了优化过后的缓存一致性协议无法达到数据的强一致,那么我们为什么还要去优化呢?
因为大多数情况都不存在并发问题,只有少数场景才会导致这种问题,我们不能因为极少数场景的问题而放弃了大多数场景的性能提成。当然虽然是极少数场景的问题,但是也不能放任不管,所以针对这种少数场景就必须要有一套处理机制来保证我们程序不出问题,所以这就是内存屏障的职责了。
二、硬件内存结构跟JMM的关系是什么?
我们知道并发编程有3个特征,分别是:原子性,可见性,有序性。这三个特征可谓是整个Java并发的基础。而整个Java内存模型实际上是围绕着三个特征建立起来的。
从更深层次来看这三个特征(并发要解决的问题),就是上面讲的缓存一致性、处理器优化、指令重排序造成的。缓存一致性问题其实就是可见性问题,处理器优化可能会造成原子性问题,指令重排序会造成有序性问题。
那么这些问题要如何解决呢?所以技术前辈们想到了在物理机器上定义出一套内存模型, 规范内存的读写操作。内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。
- 原子性 (Atomicity)
原子性指的是一个操作是不可分割,不可中断的,要么全部执行成功要么全部执行失败。在多线程环境中,原子性保证了一个线程在执行操作时不会被其他线程干扰,从而确保了操作的完整性和一致性。
下面的这几句代码能保证原子性吗?我们一起来看下
[](javascript:void(0)😉
1 int i = 2;
2
3 int j = i;
4
5 i++;
6
7 i = i + 1;
[](javascript:void(0)😉
第一句是基本类型赋值操作,必定是原子性操作。
第二句先读取i的值,再赋值到j,两步操作,不能保证原子性。
第三和第四句其实是等效的,先读取i的值,再+1,最后赋值到i,三步操作了,不能保证原子性。
实现方式:在 Java 中, JMM只能保证基本的原子性。如果要保证一个代码块的原子性,可以借助synchronized(提供了monitorenter 和 moniterexit 两个字节码指令)、各种 Lock 以及各种原子类实现原子性。synchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap)
- 可见性(Visibility)
可见性指当一个线程修改共享变量的值,其他线程能够立即知道被修改了。
实现方式:在 Java 中,可以借助synchronized、volatile 以及各种 Lock 实现可见性。
- volatile
如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主内存中进行读取。
- synchronized
synchronized的原理是,在执行完,进入unlock之前,必须将共享变量同步到主内存中。
- final
final修饰的字段,一旦初始化完成,如果没有对象逸出(指对象未初始化完成就可以被别的线程使用),那么对于其他线程都是可见的。
- 有序性(Ordering)
有序性即程序执行的顺序按照代码的先后顺序执行。
说明:由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。
实现方式:在 Java 中,可以使用synchronized或者volatile保证多线程之间操作的有序性。
- volatile
volatile 关键字是使用内存屏障达到禁止指令重排序,以保证有序性。
- synchronized
synchronized的原理是,一个线程lock之后,必须unlock后,其他线程才可以重新lock,使得被synchronized包住的代码块在多线程之间是串行执行的。
三、 Java内存模型(JMM)
Java内存模型简称JMM(Java Memory Model),是Java虚拟机所定义的一种抽象规范,用来屏蔽不同硬件和操作系统的内存访问差异,让java程序在各种平台下都能达到一致的内存访问效果。
这里要注意两点:
1)JMM是一个抽象的概念,并不是物理上的内存划分。
2)JMM-JVM-硬件的关系
Java内存模型(JMM)定义了Java虚拟机(JVM)在计算机内存(RAM)中的工作规范。在硬件内存模型中,各种CPU架构的实现是不尽相同的,Java作为跨平台的语言,为了屏蔽底层硬件差异,定义了Java内存模型(JMM)。JMM作用于JVM和底层硬件之间,屏蔽了下游不同硬件模型带来的差异,为上游开发者提供了统一的使用接口。
总之一句话:JMM是JVM的内存使用规范,是一个抽象的概念。
Java内存模型的抽象示意图:

如上图在JMM中,内存划分为两个区域,主内存和工作内存。
Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。
此处的变量与Java编程中所说的变量有所区别,它包括实例字段、静态字段和构成数组对象的元素,但是不包括局部变量和方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。
- 主内存(Main Memory)
主内存被所有的线程所共享,用于存储共享变量的值,包括实例变量,静态变量,但是不包括局部变量和方法参数。
说明:局部变量和方法参数不存储在主内存中,它们属于线程私有的内存区域,存储在每个线程的栈帧(Stack Frame)中。这些变量的生命周期仅限于方法的执行过程,方法调用结束后就会被销毁,不会存在于主内存中。
- 工作内存(Working Memory)
每一个线程拥有自己的工作内存(本地内存),线程的工作内存保存了该线程用到的变量和主内存的副本拷贝。
说明:
1) 每个线程的工作内存都是独立的,线程操作数据只能在工作内存中进行,然后刷回到主内存。这是 Java 内存模型定义的线程基本工作方式。
2)线程对共享变量的所有操作(读取、赋值等)都必须在工作内存进行,不能直接读写主内存中的变量。不同线程之间也无法访问彼此的工作内存,变量值的传递只能通过主内存来进行。
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:
- 工作内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。工作内存中存储了该线程读/写共享变量的拷贝副本。
- 从更低的层次来说,主内存就是硬件的内存,而为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
- Java内存模型中的线程的工作内存(working memory)是 CPU的寄存器和高速缓存的抽象描述。而JVM的静态内存储模型(JVM内存模型)只是一种对内存的物理划分而已,它只局限在内存,而且只局限在JVM的内存。
四、内存间交互操作
关于主内存和工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存这一类的实现细节,Java内存模型中定义了以下8种操作来完成。虚拟机必须保证每一个操作都是原子的、不可再分的。
如下图:

- lock(锁定)
作用于主内存的变量,把一个变量标识为线程独占的状态。
- read(读取)
作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便下一步的load操作使用。
- load(载入)
作用于工作内存的变量,它把read操作从主内存中读取的变量值放入工作内存的变量副本中(副本是相对于主内存的变量而言的)。
- use(使用)
作用于工作内存中的变量,表示把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作。
- assign(赋值)
作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操作。
- store(存储)
作用于工作内存的变量,它把工作内存中变量的值传送到主内存中,以便后续的write的操作。
- wirte(写入)
作用于主内存的变量,它把store操作从工作内存中得到的变量值放入主内存的变量中。
- unlock(解锁)
作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
说明:
1)如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行 read 和 load 操作。
2)如果把变量从工作内存中同步到主内存中,就需要按顺序地执行 store 和 write 操作。
但Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
下面继续补充一下JMM对8种内存交互操作制定的规则:
1)不允许read、load、store、write操作之一单独出现,也就是read操作后必须load,store操作后必须write。
2)不允许线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步到主内存中。
3)不允许线程将没有assign的数据从工作内存同步到主内存。
4)一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施use、store操作之前,必须经过load和assign操作。
5)一个变量同一时间只能有一个线程对其进行lock操作。多次lock之后,必须执行相同次数unlock才可以解锁。
6)如果对一个变量进行lock操作,会清空所有工作内存中此变量的值。在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值。
7)如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量。
8)一个线程对一个变量进行unlock操作之前,必须先把此变量同步回主内存。
四、JMM中的重要原则
- happens-before(先行发生原则)
happens-before原则是JMM中非常重要的原则,它是判断数据是否存在竞争、线程是否安全的主要依据,保证了多线程环境下的可见性。
happens-before原则定义如下:
1) 如果一个操作发生在另一个操作之前,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2) 两个操作之间存在先行发生的关系,并不意味着一定要按照先行发生原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
我们无法就所有场景来规定某个线程修改的变量何时对其他线程可见,但是我们可以指定某些规则,这规则就是happens-before,从JDK 5 开始,JMM就使用happens-before的概念来阐述多线程之间的内存可见性。
说明:
1)这里的操作,实际上就是各种指令操作,比如读操作、写操作、初始化操作、锁操作等等。
2)先行发生原则作用于很多场景下,包括同步锁、线程启动、线程终止、volatile。
五、Java 运行时内存区域和 JMM 有何区别?
Java 内存区域和内存模型是完全不一样的两个东西。
- Java内存区域
Java 内存区域和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
- Java内存模型(JMM)
Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
可以说,JMM主要针对的是多线程环境下,如何在主内存与工作内存之间安全地执行操作。
六、总结
-
由于CPU 和主内存间存在数量级的速率差,想到了引入了多级高速缓存的传统硬件内存架构来解决,多级高速缓存作为 CPU 和主内存的缓冲提升了整体性能。解决了速率差的问题,却又带来了缓存一致性问题。
-
数据同时存在于高速缓存和主内存中,如果不加以规范势必造成灾难,因此在传统机器上又抽象出了内存模型。
-
Java 语言在遵循内存模型的基础上推出了 JMM 规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。
-
JMM定义了线程和主内存之间的交互规则,确保了在多线程环境中对共享变量的访问和更新的一致性。
-
为了更精准控制工作内存和主内存间的交互,JMM 还定义了八种操作:lock, unlock, read, load, use, assign, store, write。
olatile。
五、Java 运行时内存区域和 JMM 有何区别?
Java 内存区域和内存模型是完全不一样的两个东西。
- Java内存区域
Java 内存区域和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
- Java内存模型(JMM)
Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
可以说,JMM主要针对的是多线程环境下,如何在主内存与工作内存之间安全地执行操作。
六、总结
-
由于CPU 和主内存间存在数量级的速率差,想到了引入了多级高速缓存的传统硬件内存架构来解决,多级高速缓存作为 CPU 和主内存的缓冲提升了整体性能。解决了速率差的问题,却又带来了缓存一致性问题。
-
数据同时存在于高速缓存和主内存中,如果不加以规范势必造成灾难,因此在传统机器上又抽象出了内存模型。
-
Java 语言在遵循内存模型的基础上推出了 JMM 规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。
-
JMM定义了线程和主内存之间的交互规则,确保了在多线程环境中对共享变量的访问和更新的一致性。
-
为了更精准控制工作内存和主内存间的交互,JMM 还定义了八种操作:lock, unlock, read, load, use, assign, store, write。