JAVA内存模型(JMM) && Volitale 详解

什么是JMM模型

Java内存模型(Java Memory Model简称JMM)是一种抽象的概念,并不真实存在,它描 述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构 成数组对象的元素)的访问方式。JMM屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能得到一致效果的机制及规范。目的是解决由于多线程通过共享内存进行通信时,存在的原子性、可见性(缓存一致性)以及有序性问题。

JMM是围绕原子性,可见性,有序性展开,那么什么是原子性,可见性,有序性?
原子性

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。

多线程中可能出现的问题:
线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。所以在多线程场景下,就会发生原子性问题。因为线程在执行一个读改写操作时,在执行完读改之后,时间片耗完,就会被要求放弃CPU,并等待重新调度。这种情况下,读改写就不是一个原子操作。即存在原子性问题。

可见性

可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。

对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。
但在多线程环境中可就不一定了,由于线程对共享变量的操作都是线程 拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享 变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象 就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程 序轮序执行的问题,从而也就导致可见性问题。

有序性

程序执行的顺序按照代码的先后顺序执行。

我们总是认为代码的执行是按顺序依次执行的,这样 的理解并没有毛病,毕竟对于单线程而言确实如此,但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致。

多CPU多级缓存会导致缓存一致性问题,CPU时间片会导致原子性问题,指令重排会出现有序性问题,所以,为了保证并满足并发编程中的原子性、可见性、有序性,就产生了一个重要的概念————内存模型

一个变量如何从主内存拷贝到内存,如何从工作内存到主内存之间的同步实现细节,Java内存模型定义了以下八种操作来完成:

  1. lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
  2. unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的 变量才可以被其他线程锁定
  3. read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中, 以便随后的load动作使用
  4. load(加载):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作 内存的变量副本中
  5. use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
  6. assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存 的变量
  7. store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中, 以便随后的write的操作
  8. write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送 到主内存的变量中

如果把一个变量从主内存中复制到工作内存中,就需要按顺序的执行read和load操作,如果需要把工作内存中的变量同步回主内存中,则需要按照顺序的执行store、write操作,但是Java内存模型只要求上述操作必须按照顺序执行,没有要求必须连续执行。


JMM示意图

如上图所示,必须按照 read — load — use 顺序执行,但是不一定会连续执行。

同步规则

1、不允许一个线程无原因的(没有发生任何assign操作)把数据从工作内存同步回主内存中。
2、一个新变量只能在主内存中产生,不允许工作内存中直接使用一个未被初始化的(没有被load或者assign)变量。即:对一个变量use或者store之前,必须先自行load或者assign操作。
3、一个变量在同一时刻只允许被一个线程lock,但lock操作可以被同一个线程重复执行多次,多次执行lock操作后,必须执行相同次数的unlock,变量才会被解锁。即:lock和unlock必须成对出现。
4、如果对一个变量执行lock时,将会清空工作内存中此变量的值,在执行引擎使用此变量之前,需要重新load或者assign操作重新初始化变量的值。
5、如果一个变量事先没有被lock操作,则不允许对它执行unlock操作,也不允许去unlock一个被其他线程lock的变量。
6、对一个变量执行unlock操作之前,必须先把此变量同步到主内存中,即:必须执行store和write操作。

JMM如何解决原子性&可见性&有序性问题

  • 原子性问题
    在Java中,提供了两个高级的字节码执行:monitorenter 和 monitorexit来保证原子性。在Java中可以通过synchronized 和 Lock 实现原子性。synchronized 和 lock 可以保证任何时候只有一个线程访问该代码块。
  • 可见性问题
    volatile关键字保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中,当其他线程需要读取时,它会去内存中读取新值。synchronized 和 Lock 也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中。
  • 有序性问题
    在Java里面,可以通过volatile关键字来保证一定的代码执行的有序性(在编译器编译时,会加上#Lock的字节码保证编译顺序)。另外可以通过 synchronized 和 Lock 来保证有序性,很显然,synchronized 和 Lock 保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就 保证了有序性。

到这里我们可以发现,好像 synchronized 和 Lock 是万能的,他们可以同时满足以上三种特性,所以就导致了现在很多人滥用synchronized 和 lock的原因。但是synchronized比较影响性能,虽然编译器提供了很多种锁优化技术,但还是不建议过度使用。

总结:

JMM是一种规范,用来解决多线程通过共享内存进行通信时,与存在的本地内存中数据不一致,编译器会对代码进行指令重排、处理器会乱序执行代码等带来的问题,目的就是保证并发场景中原子性,可见性,有序性。

volitale

volatile 是Java虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用
1、保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。
2、禁止指令重排序优化。

volitale的可见性

关于volatile的可见性作用,我们必须意识到被volatile修饰的变量对所有线程总数立即可见的,对volatile变量的所有写操作总是能立刻反应到其他线程中。

    private volatile boolean initFlag = false;
    private static Object object = new Object();

    public void refresh(){
        this.initFlag = true;
        String threadName = Thread.currentThread().getName();
        System.out.println("线程:"+threadName+": 修改共享变量initFlag");
    }

    public void load(){
        String threadName = Thread.currentThread().getName();
        int i = 0;
        // 程序在此空跑,由于 volatile 修饰 initFlag,所以会一直嗅探其他线程对 initFlag 的修改
        while (!initFlag){
            /*
            synchronized (object){
                i++;
            }
            System.out.println(i);
            */
        }
        System.out.println("线程:"+threadName+" 当前线程嗅探到initFlag的状态值改变");
    }

    public static void main(String[] args) {
        TestThread testThread = new TestThread();
        Thread threadA = new Thread(()->{
           testThread.refresh();
        },"threadA");

        Thread threadB = new Thread(()->{
            testThread.load();
        },"threadB");

        threadB.start();

        try {
            Thread.sleep(2000);
        }catch (Exception e){
            e.printStackTrace();
        }
        threadA.start();
    }

上面的示例可以看到,一共创建两个线程,线程A(threadA)去修改initFlag的值,线程B(threadB)在等其他线程修改initFlag后,跳出自旋(while (!initFlag)),线程A改变initFlag属性之后,线程B马上感知到。

volitale无法保证原子性

    public static volatile int i = 0;

    public static void increase(){
        i++;
    }

在并发场景下,i变量的任何改变都会立马反应到其他线程中,但是如此存在多条线程同时 调用increase()方法的话,就会出现线程安全问题,毕竟i++;操作并不具备原子性,该操作是 先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一 个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一 个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于increase方法必须使 用synchronized修饰,以便保证线程安全,需要注意的是一旦使用synchronized修饰方法 后,由于synchronized本身也具备与volatile相同的特性,即可见性,因此在这样种情况下就 完全可以省去volatile修饰变量。

volatile禁止重排优化

volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,下面主要简单说明一下volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。
内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行 顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译 器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器 和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏 障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出 各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之, volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。下面看一个非常典型的禁止重排优化的例子DCL

    public static DoubleCheckLock instance;
    
    private  DoubleCheckLock(){}
    
    public static DoubleCheckLock getInstance(){
        // 第一次判断
        if(instance == null){
            // 同步代码块
            synchronized (DoubleCheckLock.class){
                if(instance == null){
                    // 多线程环境下可能会出现问题的地方
                    instance = new DoubleCheckLock();
                }
            }
        }
        return instance;
    }

上述代码一个经典的单例的双重检测的代码,这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。
因为instance = new DoubleCheckLock();可以分为以下3步完成:
1、分配队形内存空间。
2、初始化对象。
3、设置instance指向刚分配的内存地址。此时instance! =null
由于步骤1和步骤2间可能会重排序,如下:
1、分配对象内存空间
3、设置instance指向刚分配的内存地址,此时instance! =null,但是对象还没有初始化完成!
2、初始化对象
由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单 线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一 致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null 时,由于instance实例未必已初始化完成,也就造成了线程安全问题。
那么该如何解决呢,很简单,我们使用volatile禁止instance变量被执行指令重排优化即可。

    // 禁止指令重排优化
    public volatile static DoubleCheckLock instance;

为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。 下图是JMM针对编译器制定的volatile重排序规则表。

是否能重排 第二个操作 第二个操作 第二个操作
第一个操作 普通读/写 volitale读 volitale写
普通读/写 NO
volitale读 NO NO NO
volitale写 NO NO

举例来说,第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或 写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。

从上图可以看出:

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。


    volatile写插入内存屏障后生成的指令序列示意图

    上图中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主 内存。
    这里比较有意思的是,volatile写后面的StoreLoad屏障。此屏障的作用是避免 volatile写与 后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在 一个volatile写的后面是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方 法立即return)。为了保证能正确 实现volatile的内存语义,JMM在采取了保守策略: 在每个volatile写的后面,或者在每个volatile读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM最终选择了在每个 volatile写的后面插入一个StoreLoad 屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写 之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

volatile读插入内存屏障后生成的指令序列示意图

上图中LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335

推荐阅读更多精彩内容