java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > JMM与volatile

必须要学会的JMM与volatile

作者:Wang1​​​​​​​

这篇文章主要介绍了必须要学会的JMM与volatile,文章围绕主题展开详细的内容介绍,具有一定的参考价值,需要的小伙伴可以参考一下

1. JAVA 内存模型 (JMM)

1.1 主内存与工作内存

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

这里所讲的主内存、工作内存与Java内存区域中的Java堆、栈、方法区等并不是同一个层次的对内存的划分,这两者基本上是没有任何关系的。如果两者一定要勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。

从更基础的层次上说,主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(或者是硬件、操作系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。

1.2 内存间的交互

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了以下8种操作来完成。Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许有例外)。

非原子性协定:

Java内存模型要求lock、unlock、read、load、assign、use、store、write这八种操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”(Non-Atomic Treatment of double and long Variables)。

如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行read和load操作,如果要把变量从工作内存同步回主内存,就要按顺序执行store和write操作。注意,Java内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。也就是说read与load之间、store与write之间是可插入其他指令的,如对主内存中的变量a、b进行访问时,一种可能出现的顺序是read a、read b、load b、load a。

Java内存模型还规定了在执行上述8种基本操作时必须满足如下规则:

2. 关于 Volatile 变量

Volatile变量具备的三个关键点(保证可见性,不能保证原子性,禁止指令重排序):

对于 As-If-Serial 的简要说明:对于处理器或者编译器来说,在进行指令重排序优化(为了提高并行度)的时候只能保证在单线程环境下的串行化语义的一致性

举个栗子,下面一个双锁检测(Double Check Lock,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();
    }
}

对instance变量赋值相关的字节码:

0x01a3de0f: mov $0x3375cdb0,%esi         ;...beb0cd75 33 
                                         ; {oop('Singleton')}
0x01a3de14: mov %eax,0x150(%esi)         ;...89865001 0000
0x01a3de1a: shr $0x9,%esi                   ;...c1ee09
0x01a3de1d: movb $0x0,0x1104800(%esi)     ;...c6860048 100100
0x01a3de24: lock addl $0x0,(%esp)         ;...f0830424 00
                                        ;*putstatic instance
                                        ; - Singleton::getInstance@24

有volatile修饰的变量,赋值后(前面mov%eax,0x150(%esi)这句便是赋值操作)多执行了一个 lock addl$0x0,(%esp) 操作,这个操作的作用相当于一个内存屏障 (Memory Barrier或Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个处理器访问内存时,并不需要内存屏障;但如果有两个或更多处理器访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了。

这句指令中的 addl$0x0,(%esp) (把ESP寄存器的值加0)显然是一个空操作,之所以用这个空操作而不是空操作专用指令nop,是因为IA32手册规定lock前缀不允许配合nop指令使用。这里的关键在于lock前缀,查询IA32手册可知,它的作用是将本处理器的缓存写入了内存,该写入动作也会引起别的处理器或者别的内核无效化(Invalidate)其缓存,这种操作相当于对缓存中的变量做了一次前面介绍Java内存模式中所说的“store和write”操作。所以通过这样一个空操作,可让前面volatile变量的修改对其他处理器立即可见。

那为何说它禁止指令重排序呢?从硬件架构上讲,指令重排序是指处理器采用了允许将多条指令不按程序规定的顺序分开发送给各个相应的电路单元进行处理。但并不是说指令任意重排,处理器必须能正确处理指令依赖情况保障程序能得出正确的执行结果。譬如指令1把地址A中的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值减去3,这时指令1和指令2是有依赖的,它们之间的顺序不能重排—— (A+10)*2 与 A*2+10 显然不相等,但指令3可以重排到指令1、2之前或者中间,只要保证处理器执行后面依赖到A、B值的操作时能获取正确的A和B值即可。所以在同一个处理器中,重排序过的代码看起来依然是有序的。因此,lock addl$0x0,(%esp) 指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果。

假定T表示一个线程,V和W分别表示两个volatile型变量,那么在进行read、load、use、assign、store和write操作时需要满足如下对于volatile变量的特殊规则:

3. 关于内存屏障

内存屏障又称内存栅栏(Memory Barrier)是一个CPU指令,它的作用有两个:

由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU。上的线程都能读取到这些数据的最新版本。总之volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。

Intel硬件提供了一系列的内存屏障, 主要有:

不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。

JVM中提供了四类内存屏障指令:

volatile内存语义的实现:

总的来说:

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此, JMM采取保守策略

下面是基于保守策略的JMM内存屏障插入策略:

上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。

上图中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障 上面所有的普通写在volatile写之前刷新到主内存。

上图中StoreLoad屏障的作用是避免volatile写与后面可能有的 volatile读/写 操作重排序。因为编译器常常无法准确判断在一个volatile写的后面 否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确实现volatile的内存语义,JMM在采取了保守策略:在每个volatile写的后面,或者在每个volatile读的前面插入一个StoreLoad屏障。

从整体执行效率的角度考虑,JMM最终选择了在每个volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。 当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

上图中LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁 止处理器把上面的volatile读与下面的普通写重排序。

class VolatileBarrierExample {
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;
    void readAndwrite() {
        inti = v1;        //第一个volatile读
        intj = v2        //第二个volatile读
        a = i+j;        // 普通写
        v1 = i+ 1;        //第一个volatile写
        v2=j * 2;        //第二个volatile写
    }
}

针对readAndWrite()方法,编译器在生成字节码时可以做如下的优化。

注意,最后的StoreLoad屏障不能省略。因为第二个volatile写之后, 方法立即return。 此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见编译器通常会在这里插入一个StoreLoad屏障。上面的优化针对任意处理器平台,由于不同的处理器有不同“松紧度"的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。

4. 原子性、可见性与有序性

保证原子性(Atomicity):

保证可见性(Visibility):

保证有序性(Ordering):

5. Happens-Before

先行发生是(Happens-Before) Java内存模型中定义的两项操作之间的偏序关系,比如说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。(先行发生原则是JMM的实现所体现出的一些特定的现象)Java语言无须任何同步手段保障就能成立的先行发生规则有且只有下面这些:

举个栗子:

private int value = 0;

pubilc void setValue(int value){
    this.value = value;
}
public int getValue(){
    return value;
}

假设存在线程A和B,线程A先(时间上的先后)调用了setValue(1),然后线程B调用了同一个对象的getValue(),那么线程B收到的返回值是什么?

根据先行发生原则中的各项规则来进行判断:

因此我们可以判定,尽管线程A在操作时间上先于线程B,但是无法确定线程B中getValue()方法的返回结果,换句话说,这里面的操作不是线程安全的。

再举个栗子:

// 以下操作在同一个线程中执行
int i = 1;
int j = 2;

根据程序次序规则,“int i=1”的操作先行发生于“int j=2”,但是“int j=2”的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程之中没有办法感知到这一点。

到此这篇关于必须要学会的JMM与volatile的文章就介绍到这了,更多相关JMM与volatile内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文