title: 深入理解Java虚拟机之Java内存模型
date: 2020-07-29 23:24
category:JVM
tags: JVM
前言
最近在看周志明老师的JVM这本书,感觉每次看一次都会有不同的感受,诚然,作为一个Androider,使用的是Java语言,但是并没有深入的挖掘Java语言的魅力,
- 一则是自己水平不够,无法领略其中奥妙.
- 二则可能是长期处于编写业务阶段,无法将知识点给串联起来吧.
- 三则就是大家都会出现的一种思维惰性吧.
好了,认真去拜读了这本书,水平不够,当然还是要去不断的看,反复的去看了,笔者水平有限,对此书中重要概念进行摘抄以及个人理解,可能描述不准,望大家见谅.
Java内存模型的由来
《Java虚拟机规范》中定义了一种“Java内存模型(JMM)”来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果.
从这一句话你就知道Java语言为什么能够这么轻松就能够跨平台呢,是因为Java虚拟机制定了这一系列的规范措施,至少我们在使用Java语言的时候不必担心数据在各个平台上数据不一致导致的错误.
那么这个模型也自然必须定义足够严谨,是为了让Java的并发访问操作不会产生歧义,同时定义的条件足够的宽松,是为了虚拟机的实现能够有足够的自由空间利用硬件的各种特性(寄存器、高速缓存和指令集中某些特有的指令)来获取更好更快的执行速度.直到JDK5发布后,Java内存模型才终于成熟、完善起来.
自然讨论的是内存,势必引出下一节的内容,主内存和工作内存的概念吧.
主内存与工作内存浅谈
1) 主内存与工作内存之间的关系
Java内存模型主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节
谈一谈此处的变量:
- 实例字段(姑且理解类中的全局变量)
- 静态字段(伴随着类加载而加载的)
- 构成数组对象的元素
这里没有列举出局部变量和方法参数、因为其是线程私有的、不被共享.
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本.线程对变量的所有操作**(读取、赋值等)都必须在工作内存中进行、而不能直接读写主内存中的数据.不同线程之间也无法直接访问对方工作内存中的变量、线程之间变量值的传递只能通过主内存**来完成.
注意:
这里所说的主内存、工作内存与Java内存区域的Java堆、栈、方法区等并不是一个层次上对内存的划分,甚至是可以说这两者基本上没有任何关系.
主内存更加强调的是物理硬件的内存,工作内存由于是被主要访问的区域,为了更好的运行速度,虚拟机可能会让工作内存优先存储在寄存器和高速缓存中.
总结:
所以说了这么多主内存和工作内存之间的关系还是围绕着变量来说的.所有的变量是存储在主内存中,工作内存要使用这些变量,就需要拷贝一份主内存中的变量作为副本来进行下一步操作.
2)主内存和工作内存之间的交互
主要讨论的是将一变量从主内存中拷贝到工作内存,如何从工作内存同步到主内存的一些细节问题.
Java内存模型定义了8种操作来完成上述操作,并且保证这8种操作必须是原子性的.
- lock(锁定): 作用于主内存的变量,把一个变量标识为一条线程独占的状态
- unlock(解锁): 作用于主内存的变量,把一个锁定状态的变量释放出来,其他线程就可以继续进行该变量的锁定操作
- read(读取): 作用于主内存的变量,把一个变量的值从主内存中得到的变量值放入工作内存的变量副本中,以便工作内存进行load操作
- load(载入): 作用于工作内存的变量, 把read操作从主内存得到的变量值放入到工作内存中的工作副本中.
- use(使用): 作用于工作内存的变量,把工作内存中的变量值传递给执行引擎.
- assign(赋值): 作用于工作内存的变量,从执行引擎接收值赋给工作内存中的变量.
- store(存储): 作用于工作内存的变量,把工作内存中一个变量的值传递给主内存中,以便主内存中的write操作
- write(写入): 作用于主内存的变量,将store操作从工作内存中得到的变量的值放入到主内存变量中.
]
由上图可知: 把一个变量从主内存中拷贝到工作内存中,需要经历read和load操作,
把一个变量从工作内存同步到主内存中,需要经历store和write操作.而且要求这些操作是顺序执行的,但不要求连续执行.
并发安全浅谈
介绍了上述的Java内存模型定义,我们始终是要去面对多线程并发情况下对内存模型中变量的操作,势必就会提出问题,到底是谁动了我的奶酪呢? 为啥这个值不对,为什么这个计算结果不对啊等等一系列问题.这零零总总都在发出一个声音:
该操作在并发环节是否是安全的,如何去保证线程安全问题等等.
此时陷入思考, 到底Java有没有解决这种问题的方案呢, 诶,还真有,Java是提供了这么一种同步机制的.下面就来展开谈谈.
1) volatile类型变量的浅析
关键字volatile可以说是Java虚拟机提供的轻量级的同步机制,但是大多情况下处理多线程数据竞争的时候,咱们都是一律使用synchronized来进行同步(别说你没有,你肯定是这样干的😄).
Java内存模型为***volatile***专门定义了一些特殊的访问规则(划重点,会考).
当一个变量被定义为volatile,一般具备两种特性了:
- 该变量的所有变换都对其他线程是可见的.
- 禁止指令重排序优化
下面就围绕这两个特性一一展开:
1、对上述第一个特性:“可见性”是指当一个线程修改了这个被***volatile修饰***变量的值,被修改之后的新值对于其他线程是可以立即可以获取的.
但是对于***普通***变量的值,如果被修改,其他线程无法获知,因为线程之间信息是隔离的,如果需要告知其他线程,必须通过工作线程A的工作内存将值同步到主内存,然后通过主内存更新到工作线程B的工作内存中.
虽然说被volatile修饰的变量,对于其他线程是可见的,并不能说明***基于volatile变量的运算在并发下是线程安全的.***
原因是volatile修饰的变量在其他线程可以存在不一致的情况,但是每个线程在使用该变量的时候,都是会先刷新其值,那么这个变量的值就同步过来了,每个线程线程认为这个值都是一样的.但是往往忽略了一个问题:Java运算操作符并非是原子操作, 这样势必就会导致volatile变量的运算在并发情况下一样也是不安全的.
public class VolatileTest{
public static volatile int race =0;
public static void increase(){
race++;
}
public static final int thread_count =20;
public static void main(String[] args){
Thread[] threads = new Thread[thread_count];
for(int i=0; i< thread_count;i++){
threads[i] = new Thread(new Runnable(){
@Override
public void run(){
for(int i=0;i<10000;i++){
increase();
}
}
});
threads[i].start();
}
}
// 等待所有累加的线程结束
while(Thread.activeCount()>1){
Thread.yield();
}
System.out.print(race);
}
这段代码发起了20个线程,每个线程对race变量进行了10000次自增操作.
如果这段代码能够争取并发的话,最后输出结果应该是20*10000. 但是很遗憾,输出结果都不一样,且都是小于20*10000的值.
问题出在哪里呢?在于volatile在保证了可见的同时,并没有保证程序执行的原子性.
问题代码: race++
问题分析:
public static void increase(){
Code:
Stack=2,Locals=0,Args_size=0
0: getstatic #13; //Field race:I
3: iconst_1
4: iadd
5: putstatic #13; //Field race:I
8: return
LineNumberTable:
line 14 : 0
line 15 : 8
}
getstatic指令把race的值取到操作栈顶时,volatile关键字保证了race此时的值是正确的,但是在执行icont_1、iadd这些指令,其他线程可能将race的值改变了(破坏了原子性),而操作栈顶的值就变成了过期的数据,所以putstatic指令执行后就能将较小的race值同步回主内存了,这样其他线程拿到的较小的值进行累加操作.
不满足以下两种规则的运算场景仍然需要通过加锁来保证原子性:
- 运算结果并不依赖当前值,或者能够确保只有单一的线程修改变量的值(race++不满足)
- 变量不需要与其他的状态变量共同参与不变约束
volatile boolean shutdownFlag;
public void shutdown(){
shutdownFlag = true;
}
public void doWork(){
while(!shutdowFlag){
//..
}
}
上述代码片段可以通过volatile来控制并发,当shutdown()方法被调用,能够保证所有线程执行的doWork()方法能够停止下来.
2、对于使用volatile变量的第二个语义是禁止指令重排,普通变量仅仅保证该方法的执行过程所有依赖赋值结果的地方能获取到正确的结果,不能保证该变量赋值操作的顺序和程序执行顺序一致.
通过一个案例来看看指令重排会干扰程序的并发执行:
Map configOptions;
char[] configText;
// init 是被volatile修饰,这里记一下,后面分析用
volatile boolean init = false;
// 线程A完成下面配置操作
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText,configOptions);
init =true;
// 线程B执行以下代码
while(!init){
sleep();
}
// 使用线程A中初始化好的配置信息
doSomethingWithConfig();
上述代码片段中init变量假如没有被volatile关键字修饰,那么线程A处理的代码段最后一行init=true可能会提前被执行,这样线程B执行的代码段的时候,是有可能在线程A并没有配置好配置信息的情况下贸然的去使用了线程A的配置信息,而导致程序出错.
注:这里指的指令重排优化是机器级的优化操作,提前执行的这条语句对应的汇编代码被提前执行了.
再来一发案例:DCL(单例模式)
public class Singleton{
private volatile static Singleton instance;
public static Singleton getInstance(){
if(instance == null){
synchronized(Singleton.class){
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
public static void main(String[] args){
Singleton.getInstance();
}
}
编译之后的字节码
0x01a3de0f : mov $0x3375cdb0,%esi ; ...beb0cd75 33
....
0x01a3de1f : lock addl $0x0,(%esp); ...f0830424
; *putstatic instance
; - Singleton::getInstance@24
通过执行了lock addl...
指令,作用就是设置一个内存屏障,不能将后面的指令排序到内存屏障之前.从而到达了禁止了指令重排序列.
2) 并发安全三大特性浅谈
Java内存模型围绕着在并发过程中保证原子性、可见性和有序性来建立的.下面就来好好谈谈这三种特性吧.
1、原子性
由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write,也就是说我们可以认为基本数据的访问和读写都是具备原子性的.
如果说还需要更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求.这种操作Java虚拟机并没有直接开放给用户,但是提供了更高层次的等价的字节码指令monitorenter 和 monitorexit 来隐式来使用这两个操作.
这个两个字节码指令反映到Java代码中就是同步块------ synchronized关键字.同理,synchonrized块之间的操作也同样具备原子性.
2、可见性
可见性是指当一个线程修改了共享变量的值时候,其他线程能够立即得知这个修改.
Java内存模型是通过在变量修改后将新值同步到主内存,在变量读取前从主内存中刷新新值这种依赖主内存作为传递媒介的方式来实现可见性的.
普通变量和volatile变量区别在于: volatile的特殊规则保证了新值能够立即同步到主内存,以及每次使用前立即从主内存中刷新,而普通变量不能保证这一点.
除了volatile之外,Java还有两个关键字能够实现: synchronzied和final.
同步块的可见性是通过对一个变量执行了unlock操作之前,必须先把此变量同步回主内存中(执行了store、write操作).
final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”引用传递出去(如果传递出去,可能导致被final修饰的变量没有初始化完成),那么在其他线程中就能看见final字段的值.
public static final int i;
public final int j;
static {
i =0;
}
{
j=1;
}
//变量i,j都具备可见性,因此无须同步就能被其他线程正确访问
关于This引用逃逸定义:
在构造器构造还未彻底完成前(即实例初始化阶段还未完成),将自身this引用向外抛出并被其他线程复制(访问)了该引用,可能会问到该还未被初始化的变量,甚至可能会造成更大严重的问题。
/**
* 模拟this逃逸
* @author Lijian
*
*/
public class ThisEscape {
//final常量会保证在构造器内完成初始化(但是仅限于未发生this逃逸的情况下,具体可以看多线程对final保证可见性的实现)
final int i;
//尽管实例变量有初始值,但是还实例化完成
int j = 0;
static ThisEscape obj;
public ThisEscape() {
i=1;
j=1;
//将this逃逸抛出给线程B
obj = new ThisEscape();
}
public static void main(String[] args) {
//线程A:模拟构造器中this逃逸,将未构造完全对象引用抛出
/*Thread threadA = new Thread(new Runnable() {
@Override
public void run() {
//obj = new ThisEscape();
}
});*/
//线程B:读取对象引用,访问i/j变量
Thread threadB = new Thread(new Runnable() {
@Override
public void run() {
//可能会发生初始化失败的情况解释:实例变量i的初始化被重排序到构造器外,此时1还未被初始化
ThisEscape objB = obj;
try {
System.out.println(objB.j);
} catch (NullPointerException e) {
System.out.println("发生空指针错误:普通变量j未被初始化");
}
try {
System.out.println(objB.i);
} catch (NullPointerException e) {
System.out.println("发生空指针错误:final变量i未被初始化");
}
}
});
//threadA.start();
threadB.start();
}
}
3、有序性
Java程序的有序性可以总结为:如果在本线程内进行观察,所有操作都是有序的,如果在一个线程观察另外一个线程,所有操作都是无序的.
Java语言提供了volatile和synchronized两个关键字来保证线程之间的操作的有序性,
volatile关键字本身包含了禁止指令重排序的语义(加入内存屏障),而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作.”这条规则获取的.
这个规则决定了持有同一个锁的两个同步代码块只能串行地进入.
参考
深入理解Java虚拟机第三版 - 周志明