Java内存模型(JMM)详解-可见性volatile

Java内存模型(JMM)详解-可见性

  1. 什么是JMM
  2. JMM的意义
  3. 如何解决可见性问题
  4. 深入理解JMM内存模型
  5. JAVA内存模型总结

什么是JMM

JMM即是java内存模型(java memory model),屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。通俗的说,就是对java语言中对多线程执行时的规则,是解决多线程场景下并发问题的一个重要规范。

可以看看一下如下文章:

https://www.cnblogs.com/lfs2640666960/p/11019798.html

JMM存在的意义

  1. 谈到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的值),这里就不做演示。可以从这一步得出结论不同的平台上会有不同的表现
修改VM-options值

为什么示例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关键字的功能:

  1. 禁用缓存;
    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
    图一在这里插入图片描述 图二
    在这里插入图片描述
  2. volatile变量相关的指令不做重排序;

深入理解JMM内存模型

上述描述了java内存模型是什么,下面让我们来剖析java内存模型里有什么东西,加深理解

  1. 什么是共享变量
    java内存模型中定义了 share variable(共享变量)。java内存区域中有共享内存区域(比如堆内存,方法区)线程独占内存区域,存在共享区域的变量叫做共享内存变量,比如说方法区中的一些静态字段,堆中对象中存储的实例字段等。

  2. 什么是冲突
    如果至少一个访问操作时写操作,那么对于同一个变量的两次访问可能是冲突的。java内存模型就是为了解决这些冲突的。

  3. 什么是java线程间操作
    一个程序执行的操作可被其他线程感知或被其他线程直接影响(如多线程间的读写操作),java内存模型只描述线程间的操作,不描述线程内的操作,线程内操作按照线程内语义执行。

  4. 线程间操作有哪些

    1.read操作(一般读,非volatile读)
    2.write 操作(一般写,非volatile写)
    3.volatile read
    4. volatile write
    5.Lock.(锁monitor)、unlock
    6.线程的第一个和最后一个操作
    7.外部操作(例如多个程序访问同一个DB)

  5. 同步规则的定义

    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
    
  6. Happens-before先行发生原则
    happens-before关系用于描述两个有冲突的动作之间的顺序,如果一个action happens before另一个action,则第一个操作被第二个操作可见,JVM需要实现如下happens-before规则(引用下图):
    在这里插入图片描述

  7. 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
        }
    
    }
    
    
  8. Word Tearing字节处理
    有些处理器(尤其是早期的Alphas处理器)没有提供写单个字节的功能。在这样的处理器上更新byte数组,若值是简单的读取整个内容,更新对应的字节,然后将整个内容再写回内存,将是不合法的。
    这个问题有时候称为“字分裂(word tearing)”,更新单个字节有难度的处理器,就需要寻求其他方式来解决问题。因此,在编程过程中,尽量不要对byte[]中的元素进行重新赋值,更不要在多线程程序中这样做(这是java语言规范提出的意见,我们持保留意见,当代处理器基本不会出现这个问题)。

  9. double和long的特殊处理

由于《Java语言规范》的原因,对于非volatile的double、long的单次写操作是分两次进行的(double和long是64位),每次操作其中的32位,这可能导致第一次写入后,读取的值是脏数据,第二次写完后才可以读到正确数据,但是64位JVM内部实现了使得对double和long的类型变为原子性的操作。(多线程的情况下,单线程不会有问题)

JAVA内存模型总结

java虚拟机可以同时支持多个执行线程,若未正确同步,线程的行为可能会出现混淆和违反的直觉。JAVA内存模型规范了当多个线程修改了共享内存中的值时,应该读取到哪个值的规则。由于这部分规范类似于不同硬件体系结构的内存模型,因此这些语义称为java编程语言的内存模型。

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值