JMM内存模型

一.硬件的效率与一致性

        在现代计算机系统中,CUP,内存和IO的处理速度是 CUP > 内存 > IO,由于计算机的存储设备与CPU的运算速度有几个数量级的差距,所以现代计算机系统都会引入一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无需等待缓慢的内存读写了。

        基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是它引入了一个新的问题:缓存一致性

        在多处理器系统中,每个CPU都有自己的高速缓存,而他们又共享同一主内存,当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,为了解决这个问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI、MOSI、Synapse、Firefly及Dragon Protocol等。本文所描述的内存模型,可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不一样的内存模型,而Java虚拟机也有自己的内存模型

        除了增加高速缓存之外,为了使得处理器内部的运算单元能尽量被充分利用,处理器会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此,如果一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序优化

 二.JAVA内存模型

       Java虚拟机规范中试图定义一种Java内存模型来屏蔽掉各个硬件和操作系统的内存访问差异,以实现让Java程序在各个平台下都能达到一致的内存访问效果。

        Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从主内存中取出变量这样的底层细节,这里的变量指的是实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然也不会存在竞争问题

        Java内存模型规定了所有的变量都存储在主内存中。然后每条线程还有自己的工作内存(工作内存可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)等必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

三.volatile关键字

volatile是Java虚拟机提供的最轻量级的同步机制,被volatile修饰的变量有两种特性:

3.1 保证此变量对所有线程的可见性

当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。普通共享变量在工作内存中发生变化了之后,必须要回写到工作内存(但不是马上回写),而对于volatile修饰的变量,则会执行以下两个步骤:
a.线程对变量进行修改之后,要立刻回写到主内存。
b.线程对变量进行读取的时候,要从主内存中读取变量,而不是在工作内存中读取。

既然 volatile 能够解决一致性问题,那么它是否能像 synchronized 一样保证线程安全吗?
答案是不能的,volatile不能解决原子性问题。

如以下代码:

package jmmtest;

public class VolatileTest {

    public static volatile int race = 0;

    public static void increase(){

        race++;

    }

    public static void main(String[] args) {

        Thread[] threads = new Thread[20];

        for (int i=0;i<20;i++){

            threads[i] = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0;i< 10000;i++){
                        increase();
                    }
                }
            });

            threads[i].start();
        }

        for (int i=0;i<threads.length;i++){
            try {
                threads[i].join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println(race);
    }

}

该代码如果能正确并发的话,最后的输出结果应该是 200000,但是实际输出的结果都是小于该数字的。

具体的原因如下:例如当 race 的值达到a线程的操作栈顶时,volatile关键字保证了 race 的值此时是正确的,但是当b线程执行 i++ 操作后,虽然会立即回写race到主内存中,然而a线程已经完成了读取操作,不会再次去主内存中读取 race 了,所以导致a线程中的 race 是小于主线程中的race的。
如图:

在不符合以下两条规则的运算场景中,我们仍要通过加锁(使用 synchronized 或 java.util.concurrent 中的原子类)来保证原子性:
a.运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
b.变量不需要与其他的状态变量共同参与不变约束。

如下场景很适合使用volatile变量来控制并发,当 shutdown() 方法被调用时,能保证所有线程中执行的doWork()方法都立即停下来:

    volatile boolean shutdownRequested;

    public void shutdown(){
        shutdownRequested = true;
    }

    public void  doWork(){

        while (!shutdownRequested){
            // do stuff
        }

    }

3.2 禁止指令重排序优化

我们看下如下代码:
 

package threadtest;

public class VisibilityTest {

        int value;

        public int getValue() {
            return value;
        }

        public void setValue(int a) {
            this.value = a;
        }

        public static void main(String[] args) {

            VisibilityTest visibilityTest = new VisibilityTest();

            new Thread(() -> {
                int x = 0;
                while (visibilityTest.getValue() < 100) {
                    x++;
                }
                System.out.println(x);
            }).start();


            System.out.println("子线程都已经开始运行了");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            visibilityTest.setValue(200);
            System.out.println("主线程马上要结束了");
        }

}

在这段程序中变量a的访问没有使用任何同步措施(如volatile、锁、final等)。编译器会认为这个变量不会被多个线程共享。从而可能对线程中的循环进行循环不变表达式优化,变成了类似如下的代码:


if(visibilityTest.getValue() < 100){
        while(true){
            x++;
        }
}

具体验证可以通过查看JIT编译器生成的汇编代码,另如果想出现这种效果需要使用 server模式 java虚拟机。这是因为client模式虚拟机不会执行循环不变表达式优化。不过我在client模式下也出现过这种情况,只是概率很小。

3.2.1 指令重排序:

为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。

一般重排序可以分为如下三种:
编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令级并行的重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。

3.2.2 as-if-serial

  无论怎么重排序,单线程内的执行结果不能被改变编译器,runtime 和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。

   as-if-serial语义把单线程程序保护了起来,所以这给我们在编写单线程程序的时候创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程在编写时无需担心重排序会影响到程序结果,也无需担心内存可见性问题

3.2.3 volatile如何保证有序性

有volatile修饰的变量,赋值后会多执行一个指令,如:"lock addl",这个操作相当于一个内存屏障(Memory Barrier 或 Memory Fence,指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个cup访问时,并不需要内存屏障;但如果有两个或更多CPU访问同一块内存,且其中有一个在观察另一个,就需要内存屏障来保证一致性:

a. volatile读操作时,在读操作后面插入两个内存屏障。
b. volatile写操作时,在写操作前面和后面分别插入内存屏障。

所以只要给上面程序中的value变量加上volatile即可:

volatile int value;

3.3 volatile与synchronized的区别

a.volatile只能修饰实例变量和类变量,而synchronized可以修饰方法和代码块

b.volatile可以保证多线程共享数据的可见性和禁止指令重排序,但是不保证原子性synchronized是一种排他锁,具有排他机制,所以能够保证可见性,有序性,原子性

c.volatile可以看做是轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized,因为赋值本身是有原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。

d.synchronized可以解决原子性,可见性和有序性;volatile可以解决可见性和有序性;final可以解决可见性

四.内存屏障

内存屏障(Memory Barrier)也叫内存栅栏(Memory Fence),内存屏障其实就是一个CPU指令,主要有两个作用:

a.保证指令执行的顺序,内存屏障前的指令一定先于内存屏障后的指令。
b.写的时候,强制把缓冲区/高速缓存中的数据写回主内存,并让缓冲中的数据失效;读的时候直接从主内存中读取。

4.1 内存屏障种类

在硬件层面来说:  

对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据。

对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

JVM的内存屏障通常分为四种 LoadLoad、StoreStore、LoadStore、StoreLoad,实际上也是上述两种的组合,完成一系列的屏障和数据同步功能:

4.2 volatile与内存屏障

在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。

在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。

五.happens-before原则

如果Java内存模型中所有的有序性都仅仅靠volatile和synchronized来完成,name有一些操作将会变得很繁琐,但是我们在编写Java并发代码的时候并没有感觉到这一点,这是因为JAVA语言有一个“先行发生”(happens-before)的原则。这个原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以通过几条规则一揽子地解决并发环境下两个操作之间是否可能存在冲突的所有问题。

happens-before原则指的是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。

下面是Java内存模型下一些天然的先行发生关系,这些先行发生关系无需任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,他们就没有顺序性保障,虚拟机可以对他们随意地进行重排序。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

纯洁的小魔鬼

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值