关于Java内存模型
(面试被问到,想到之前有个笔记,整理一下发出来。)
内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不一样的内存模型,而Java虚拟机也有自己的内存模型。
Java内存模型(Java Memory Model,JMM)是来屏蔽各种硬件和操作系统的内存访问差异,以让Java程序在各种平台下都能达到一致的内存访问效果。
主内存/工作内存
JMM的主要目的是定义程序中各个变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。
这里的变量包括实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数(这些是线程私有的。如果局部变量是一个reference类型,它引用的对象在Java堆中可被各个线程共享,但是reference本身在Java栈的局部变量表中是线程私有的)。
JMM规定所有变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory)。
- 线程的工作内存保存了被该线程使用的变量的主内存副本,线程堆变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的数据。(volatile变量是有特殊的操作顺序性,看起来如同直接在主内存中读写)
- 不同线程之间无法直接访问对方工作内存中的变量。线程间传递变量值需要通过主内存完成。
内存间交互操作
8种基本操作
即主内存与工作内存之间具体的交互协议,JMM定义了8种操作。
JVM实现时必须保证这每一种操作都是原子的、不可再分的,只对于double和long在某些平台上有例外。
8种操作为:
- lock,锁定,作用于主内存的变量,把一个变量标识为一条线程独占的状态。
- unlock,解锁,作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read,读取,作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load操作使用。
- load,载入,作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use,使用,作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎。(每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行这个操作)
- assign,赋值,作用于工作内存中的变量,把一个从执行引擎接受的值赋给工作内存的变量。(每当虚拟机遇到一个给变量赋值的字节码指令时就会执行这个操作)
- store,存储,作用于工作内存的变量,把工作内存中的一个变量值传送到主内存,以便write使用。
- write,写入,作用于主内存的变量,把store操作中从工作内存中得到的变量的值放入主内存的变量中。
对基本操作的规定
JMM对这些基本操作有一些规定,通过这些规定,以及专门针对volatile的规定,就能描述出Java程序中哪些内存访问操作在并发下是安全的。
这些规定很繁琐,之后Java的设计团队将JMM的操作简化了read、write、lock、unlock四种,但这只是语言描述上的等价简化,JMM的基础设计没变。
除了JVM的开发人员外,大概没有其他开发人员会以这种方式来思考并发问题,之后通过先行发生原则来确定一个操作在并发环境下是否安全。
对基本操作的规定:
不允许read和load、store和write操作之一单独出现。JMM只要求read和load,store和write操作按顺序执行,但不要求连续执行。
不允许一个线程丢弃它最近的assign操作,变量在工作内存中改变了之后必须把变化同步回主内存。【# assign了之后必须store write】
不允许一个线程没有发生过assign操作就把数据从工作内存同步回主内存。
一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化的变量。即,对一个变量use或store之前,必须先执行assign和load操作。
【# 初始化变量就是分配内存空间,并给它一个初始值,assign操作是赋值操作,注意6里面是“初始化变量的值”,变量的初始化操作不知道是不是我没仔细读书,有点迷惑,我在下一段中引用了bing对此的讲解】
一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次lock后,要执行相同次数的unlock操作变量才会解锁。
如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新load或assign操作以初始化变量的值。
如果一个变量事先没有被lock锁定,就不允许unlock,也不允许unlock被其他线程锁定的变量。
对一个变量unlock前,必须把此变量同步回主内存中,即执行store和write。
变量的初始化过程(来自bing的解释,略作修改)
- 首先,JVM会为变量分配一个内存空间,这个空间可以是主内存或者工作内存,取决于变量的类型和作用域。如果变量是一个类的静态字段,那么它会被分配在主内存中,因为它属于类的共享数据。如果变量是一个类的实例字段,那么它会被分配在工作内存中,因为它属于对象的私有数据。如果变量是一个局部变量,那么它也会被分配在工作内存中,因为它属于方法的栈帧。
- 其次,JVM会为变量赋予一个初始值,这个初始值可以是一个默认值或者一个显式值,取决于变量是否被显示地初始化。如果变量没有被显示地初始化,那么它会被赋予一个默认值,这个默认值是根据变量的类型来确定的。例如,如果变量是一个int类型,那么它的默认值是0。如果变量是一个引用类型,那么它的默认值是null。如果变量被显示地初始化,那么它会被赋予一个显式值,这个显式值是根据变量的初始化表达式来确定的。例如,如果变量是一个int类型,而且被初始化为1,那么它的显式值就是1。如果变量是一个引用类型,而且被初始化为一个新的对象,那么它的显式值就是这个对象的引用。
- 最后,JVM会根据变量的类型和作用域,决定是否需要将变量的值同步到主内存中,以便其他线程可以看到。如果变量是一个类的静态字段,那么它的值必须同步到主内存中,因为它属于类的共享数据。如果变量是一个类的实例字段,那么它的值是否需要同步到主内存中,取决于它是否被volatile修饰。如果变量被volatile修饰,那么它的值必须同步到主内存中,因为它属于线程间的可见数据。如果变量没有被volatile修饰,那么它的值不一定需要同步到主内存中,因为它属于线程内的私有数据。如果变量是一个局部变量,那么它的值不需要同步到主内存中,因为它属于方法的栈帧,只有当前线程可以访问。
原子性、可见性与有序性
JMM是围绕着在并发过程中如何处理原子性、可见行和有序性这三个特征来建立的。
1. 原子性
基本数据类型的访问、读写都是具备原子性的。
如果应用场景需要一个更大范围的原子性保证,JMM提供了lock和unlock操作。
JVM未把lock和unlock操作直接开放给用户使用,但是提供了更高层次的字节码指令monitorenter和monitorexit来隐式使用这两个操作。
这两个字节码反映到代码中是synchronized关键字。
2. 可见性
JMM是通过在变量修改后将新值同步回主内存(之前的规则,assign了之后必须store write),在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。
volatile,synchronized和final能实现可见性。
-
普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。
-
synchronized的可见性是:对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(store, write)。
-
final的可见性:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把this的引用传递出去【# 另一本书里有,this引用逃逸很危险,需要再翻出来看看】
3. 有序性
如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。
即,“线程内表现为串行语义”(within-thread as-if-serial semantics),“指令重排序”现象和“工作内存与主内存同步延迟”现象。
Java提供volatile和synchronized关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序语义,而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行的进入。
对volatile型变量的特殊规则
volatile是JVM提供的最轻量级的同步机制。当一个变量被定义成volatile之后,有两个特性:
一、保证此变量对所有线程的可见性。当一个线程修改了这个值之后,其他线程立即得知。
volatile变量在各个线程的工作内存中是不存在一致性问题的,但是Java中的运算操作符并非原子操作,导致volatile变量的运算在并发下是不安全的。
下面代码的结果总是小于200000。
class Solution {
public static volatile int race = 0;
public static void increase() {
race++;
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(() -> {
for (int i1 = 0; i1 < 10000; i1++) {
increase();
}
});
threads[i].start();
}
// 大于2是因为idea在运行main函数时会启动两个线程
while (Thread.activeCount() > 2) {
Thread.yield();
}
System.out.println(race);
}
}
之所以这样,在字节码层面就可以很好解释了(不过即使编译出来只有一条字节码指令,也并不意味着就是一个原子操作),字节码层面的increase()
有4条指令,大概酱紫:
0: getstatic #2 // Field race:I
3: iconst_1
4: iadd
5: putstatic #2 // Field race:I
当getstatic指令把race的值取到操作栈顶时,volatile关键字保证了race的值此时是正确的,但是在执行iconst_1和iadd时,其他的线程可能已经把race的值改变了,操作栈顶的值就成了过期的数据,putstatic指令执行后就把较小的race值同步回了主内存。
于是不符合这样两种情况仍需要加锁,(符合就不需要加锁,我看书时就看错了🤣):
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他的状态变量共同参与不变约束。
下面场景适合使用volatile。
volatile boolean shutdownRequested;
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while (!shutdownRequested) {
// ....
}
}
二、禁止重排序优化。
一个例子:考虑在并发情况下读取配置,initialized是一个boolean类型,另一个线程等待这个变量变为true就做相应操作:
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
如果initialized没有使用volatile修饰,那么initialized=true
就可能被提前执行(提前执行这条语句对应的汇编代码),这样另一条线程中的代码就可能发生错误,解决方法就是使用volatile修饰。
(普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。)
因为在同一个线程的方法执行过程中无法感知到这点,这就是Java内存模型中描述的所谓“线程内表现为串行的语义”(within-thread as-if-serial semantics)。
这里《深入理解Java虚拟机》还有一个例子,考虑一个双锁检测单例。使用了-XX:+PrintAssembly
打印出汇编指令,发现使用volatile修饰时,汇编指令中会多执行一个lock add|$0x0, (%esp)
的操作,相当于一个内存屏障,重排序时不能把后面的指令重排序到内存屏障之前的位置。
【这里还有诸多细节,我目前没有关注,这里列出了各种处理器架构下的内存屏障指令:“http://gee.cs.oswego.edu/dl/jmm/cookbook.html”,以及为了输出汇编在Mac下需要下载hsdis-amd64.dylib,并且移动到Java_homoe/jre/lib
,可能不同版本还有差异,暂时没管这里】
为什么要使用volatile
【这里有待参考其他书】
与锁对比:在某些情况下,volatile的同步机制的性能确实要优于锁,但是由于虚拟机对锁实行的许多消除和优化,使得我们很难确切地说volatile就会比synchronized快上多少。不过大多数场景下,volatile的总开销比锁低。使用volatile还是锁唯一判断依据是volatile的语义能否满足使用场景的需求。
JMM中对volatile变量定义的特殊规则
假定T表示一个线程,V和W分别表示两个voaltile型变量,那么在进行read、load、use、assign、store和write操作时需要满足如下规则:
-
只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且,只有当线程T对变量V执行的后一个动作是use时,线程T才能对变量V执行load动作。
线程T对变量V的use动作可以认为是和线程T对变量V的load、read动作相关联的,必须连续一起出现。
这条规则要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V所做的修改。
-
只有当线程T对变量V执行的前一个动作是assign的时候,线程T才能对变量V执行store动作;并且,只有当线程T对变量V执行的后一个动作是store的时候,线程T才能对变量V执行assign动作。
线程T对变量V的assign动作可以认为是和线程T对变量V的store、write动作相关联的,必须连续一起出现。
这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存,用于保证其他线程可以看到自己对变量V所做的修改。
-
如果A先于B,那么P先于Q:
动作A:线程T对变量V实施use/assign
动作F: 与A关联,load/store
动作P:和F相应,对V read/write
动作B:线程T对变量W use/assign
动作G:和B关联,load/store
动作Q:和G对应,对W read/write。
这条规则要求volatile修饰的变量不会被指令重排序优化。
针对long和double型变量的特殊规则
“long和double的非原子性协定”:JMM要求之前提到的八种操作都需要具有原子性,但是对于64位的数据类型(long和double)比较宽松,允许虚拟机将没有被volatile修饰的64位数据的读写划分为两次32位的操作来进行。
对于long:在目前主流平台下商用的64位Java虚拟机中并不会出现非原子性访问行为,但是对于32位的JVM(如32位x86平台下的HotSpot虚拟机),对long类型的数据存在非原子性访问的风险。
JDK9起,HotSpot有一个参数-XX:+AlwaysAtomicAccesses
,用于约束JVM对所有数据类型进行原子性访问。
对于double:现代cpu中一般都包含专门的浮点运算器,哪怕是32位虚拟机中通常也不会出现非原子性访问的问题。
【需要再参考csapp中有关内容】
先行发生原则
这个原则是判断数据是否存在竞争,线程是否安全的非常有用的手段。
依赖这个原则可以通过几条简单规则解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入Java内存模型苦涩难懂的定义之中。
先行发生是JMM中定义的两项操作之间的偏序关系。
操作A先行发生于操作B,就是操作B发生之前,操作A产生的影响能被B观察到,影响包括修改了内存中共享变量的值、发送了消息、调用了方法等。
JMM中有一些天然的先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用,如果两个操作之间的关系不在此列或者无法从此推导出来,则它们没有顺序性保障,虚拟机可以对它们随意重排列。
- 程序次序规则(Program Order Rule):在一个线程中,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。
- 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于之后对同一个锁的lock操作。
- volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于之后对这个变量的读操作。
- 线程启动规则(Thread Start Rule):Thread对象start()方法先行发生于此线程的每一个动作。
- 线程终止规则(Thread Termination Rule):线程中的所有操作先行发生于对此线程的终止检测。
- 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。
- 对象终结规则(Finalizer Rule):一个对象的初始化完成先行发生于它的finalize()方法。
- 传递性(Transitivity):A先行发生于B,B先行发生于C,则A先行发生于C。
时间上的先后顺序与先行发生原则之间没有因果关系,衡量并发安全问题的时候不要受时间顺序的干扰,一切以先行发生原则为准。
总结与思考
well,这一部分就写在我的博客好了,与正文无关了。
本文来自博客园,作者:EisenJi,转载请注明原文链接:https://www.cnblogs.com/eisenji/p/jmm.html