Java内存模型(JMM)详解-可见性
- 什么是JMM
- JMM的意义
- 如何解决可见性问题
- 深入理解JMM内存模型
- JAVA内存模型总结
什么是JMM
JMM即是java内存模型(java memory model),屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。通俗的说,就是对java语言中对多线程执行时的规则,是解决多线程场景下并发问题的一个重要规范。
可以看看一下如下文章:
https://www.cnblogs.com/lfs2640666960/p/11019798.html
JMM存在的意义
- 谈到JMM内存模型存在的意义,首先我们先了解到java多线程中存在的问题,有如下几点:
- 所见非所得
- 无法肉眼检测程序的准确性
- 不同的平台上会有不同的表现
- 错误难重现
- 具体示例代码demo实现如下
/**
* @Author 作者 :@潇兮
* @Date 创建时间:2019/9/14 22:46
* 类说明:多线程DEMO
*/
public class Demo_1 {
int i = 0;
boolean isRunning = true;
public static void main(String[] args) throws InterruptedException {
final Demo_1 demo = new Demo_1();
new Thread(new Runnable() {
public void run() {
System.out.println("线程开始执行");
while(demo.isRunning){
demo.i++;
}
System.out.println(demo.i);
}
}).start();
Thread.sleep(3000L);
demo.isRunning = true;
System.out.println("终止");
}
}
打印结果为:
可以看到按道理 3s 后打印的结果并没有执行输出 i 的值,这是就说明了多线程问题的第二点肉眼无法检测程序的准确性,将edit-ConfConfiguration改为VM-options:
-client
,执行程序可以看到可以打印出 i 的值(仅限于jdk32位,jdk64位无论是-server(32位不打印)还是-client都不会打印出i的值
),这里就不做演示。可以从这一步得出结论不同的平台上会有不同的表现
为什么示例demo中不会打印 i 的值
首先我们先理解上述demo在内存中的表现(如下图),其中主线程和子线程的工作区分布在cpu和RAM内存中:java内存分为共享内存和线程中独占的内存,主线程(main方法)将线程sleep(3000L)后把isRunning变为false,然后将isRunning写入高速缓存中,最后将主内存中的isRunning改为false,子线程(new Thread)则从高速缓存中读取isRunning的值,判断对比while条件,isRunning变为false时,打印 i,但是没有打印。说明可能是子线程读取时出错,存在可见性问题。
推断一:高速缓存造成的可见性问题
由于存在高速缓存协议,两个高速缓存之间的值应该会极快的同步,从而做到数据的同步。但是demo中长时间
看不见打印 i 的值,所以排除高速缓存的可能(高速缓存可以导致可见性的问题
,但是时间短到肉眼不可见,在程序级别也可能存在异常情况,demo中明显不是,故排除)。
推断二:指令重排造成的可见性问题
## 什么是指令重排:指令重排是指在程序执行过程中, 为了性能考虑, 编译器和CPU可能会对指令重新排序,遵循as-if-serial语义。as-if-serial语义保证了在单个线程内,指令重排时,最终的结果不会改变。但是在多线程中并不保证。
由此可见指令重排能够造成可见性问题,而java编译器的指令重排发生在JIT编译中
从图一(JIT编译过程)、图二(demo执行结果)中可知道,虽然由于高速缓存中同步了值过去,但是发生JIT指令重排,会将while循环体编译成类似如下代码,当isRunning为true时,将它作为一个值存储起来,当子线程读取isRunning的值时,就不会读取到false,从而也就不会打印出 i 的值。
图一
图二
如何解决可见性问题
java内存模型规定:
对于volatile修饰的变量 v的写入,与所有其他线程后续对v的读同步
可见性问题:
一个线程修改的变量,能够及时被其他线程所见
使用**volatile
**(提出规则)关键字修饰isRunning(修饰后由JVM实现具体规则),可以打印出 i 的值,打印结果如下:
volatile关键字的功能:
- 禁用缓存;
volative变量的访问控制会加个ACC_VOLATILE(图一),可以看到isRUnning被修饰了,去oracle官网可以看到规定了volatile禁用了缓存ccannot be cached
,结合上述JIT编译示例图,因为禁用了缓存,所以对于isRunning的读写都直接从主内存中读写,从而禁止了JIT指令重排,保证了可见性(图二)
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html/#jvms-4.5
图一
图二
- 对
volatile变量相关
的指令不做重排序;
深入理解JMM内存模型
上述描述了java内存模型是什么,下面让我们来剖析java内存模型里有什么东西,加深理解
-
什么是共享变量
java内存模型中定义了 share variable(共享变量)。java内存区域中有共享内存区域(比如堆内存,方法区)
和线程独占内存区域
,存在共享区域的变量叫做共享内存变量
,比如说方法区中的一些静态字段,堆中对象中存储的实例字段等。 -
什么是冲突
如果至少一个访问操作时写操作,那么对于同一个变量的两次访问可能是冲突的。java内存模型就是为了解决这些冲突的。 -
什么是java线程间操作
一个程序执行的操作可被其他线程感知或被其他线程直接影响(如多线程间的读写操作),java内存模型只描述线程间的操作,不描述线程内的操作,线程内操作按照线程内语义执行。 -
线程间操作有哪些
1.read操作(一般读,非volatile读)
2.write 操作(一般写,非volatile写)
3.volatile read
4. volatile write
5.Lock.(锁monitor)、unlock
6.线程的第一个和最后一个操作
7.外部操作(例如多个程序访问同一个DB) -
同步规则的定义
1.对于volatile变量v的写入,与所有其他线程后续对于v的读同步;(这里说的同步即可见,这里写在读前面吗,重点) 2. 对于监视器m的解锁与所有后续操作对于m加锁同步;(就是 ① 对于加锁解锁不能做指令重排;② 当有两个线程,其中一个线程加锁,其中做的操作结果解锁后是对于另一个加锁线程是可见的,重点) 3. 对于每个属性值写入默认值(0,false,null)与每个线程对其进行的操作同步 (了解) 4. 启动线程的操作与线程中的第一个操作同步(了解) 5. 线程T2的最后一个操作与线程T1发现线程T2已经结束同步(isAlive,join可以判断线程是否终止,了解) 6. 如果线程T1中断了T2,那么线程T1的中断操作与其他所有线程发现T2被中断了同步,通过抛出InterruptedException异常,或者调用Thread.interrupted或Thread.isInterrupted
-
Happens-before先行发生原则
happens-before关系用于描述两个有冲突的动作之间的顺序,如果一个action happens before另一个action,则第一个操作被第二个操作可见,JVM需要实现如下happens-before规则(引用下图):
-
final在JVM中的处理
1.final在该对象的构造函数中设置对象的字段,当线程看到该对象时,始终看到该对象的final字段的正确构造版本;
伪代码示例:读取到的f.x一定是最新的,f.y可能为默认值0,具体代码示例如下2。如果在构造函数中设置的字段发生读取,则会看到该字段分配的值,否则将看到默认值;
伪代码示例:public finalDemo{x=1;y=x};y会等于1,x用final修饰;3.读取该共享对象的final成员变量之前,先要读取共享对象;
伪代码示例:r=new ReferenceObject();k=r.f;这两个操作不能重排序4.通常被static final修饰的字段,不能被修改。然而System.in、System.out、System.err被static final修饰,却可以修改的,这是遗留问题,必须允许通过set方式改变,我们将这些字段称为写保护,以区别于普通final字段,举个例子如下图:
/** * @Author 作者 :@潇兮 * @Date 创建时间:2019/9/14 23:45 * 类说明:并不一定能重现,很难重现,仅作为理论知识了解即可 */ public class Demo2 { final int x; int y; static Demo2 f; public Demo2() { x=1; y=2; } static void writer(){ f=new Demo2(); } static void reader(){ if (f!=null){ int i=f.x; int j=f.y; System.out.println("i="+i+",j="+j);//因为x被final修饰,y没有,所以 执行main方法后x的值一定为1,y的值可能为2也可能是0 } } public static void main(String[] args){ //Thread1.writer //Thread2.reader } }
-
Word Tearing字节处理
有些处理器(尤其是早期的Alphas处理器)没有提供写单个字节的功能。在这样的处理器上更新byte数组,若值是简单的读取整个内容,更新对应的字节,然后将整个内容再写回内存,将是不合法的。
这个问题有时候称为“字分裂(word tearing)”,更新单个字节有难度的处理器,就需要寻求其他方式来解决问题。因此,在编程过程中,尽量不要对byte[]中的元素进行重新赋值,更不要在多线程程序中这样做
(这是java语言规范提出的意见,我们持保留意见,当代处理器基本不会出现这个问题)。 -
double和long的特殊处理
由于《Java语言规范》的原因,对于非volatile的double、long的单次写操作是分两次进行的(double和long是64位),每次操作其中的32位,这可能导致第一次写入后,读取的值是脏数据,第二次写完后才可以读到正确数据,但是64位JVM内部实现了使得对double和long的类型变为原子性的操作。(多线程的情况下,单线程不会有问题)
JAVA内存模型总结
java虚拟机可以同时支持多个执行线程,若未正确同步,线程的行为可能会出现混淆和违反的直觉。JAVA内存模型规范了当多个线程修改了共享内存中的值时,应该读取到哪个值的规则。由于这部分规范类似于不同硬件体系结构的内存模型,因此这些语义称为java编程语言的内存模型。