架构师集合之Java内存模型(JMM)及volatile关键字

Java内存模型:

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在JVM中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量与Java编程里面的变量有所不同步,它包含了实例字段、静态字段和构成数组对象的元素,但不包含局部变量和方法参数,因为后者是线程私有的,不会共享,当然不存在数据竞争问题(如果局部变量是一个reference引用类型,它引用的对象在Java堆中可被各个线程共享,但是reference引用本身在Java栈的局部变量表中,是线程私有的)。为了获得较高的执行效能,Java内存模型并没有限制执行引起使用处理器的特定寄存器或者缓存来和主内存进行交互,也没有限制即时编译器进行调整代码执行顺序这类优化措施。

JMM规定了所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。

在这里插入图片描述

线程1和线程2要想进行数据的交换一般要经历下面的步骤:

1.线程1把工作内存1中的更新过的共享变量刷新到主内存中去。

2.线程2到主内存中去读取线程1刷新过的共享变量,然后copy一份到工作内存2中去。

三个特征:

Java内存模型是围绕着并发编程中原子性、可见性、有序性这三个特征来建立的,那我们依次看一下这三个特征:

  • 原子性: 一个操作不能被打断,要么全部执行完毕,要么不执行。在这点上有点类似于事务操作,要么全部执行成功,要么回退到执行该操作之前的状态。

  • 可见性:一个线程对共享变量做了修改之后,其他的线程立即能够看到(感知到)该变量这种修改(变化)。 Java内存模型是通过将在工作内存中的变量修改后的值同步到主内存,在读取变量前从主内存刷新最新值到工作内存中,这种依赖主内存的方式来实现可见性的。

  • 有序性:对于一个线程的代码而言,我们总是以为代码的执行是从前往后的,依次执行的。这么说不能说完全不对,在单线程程序里,确实会这样执行; 但是在多线程并发时,程序的执行就有可能出现乱序。用一句话可以总结为:在本线程内观察,操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行语义(WithIn Thread As-if-Serial Semantics)”,后半句是指“指令重排”现象和“工作内存和主内存同步延迟”现象。
    指令重排

Java提供了两个关键字volatile和synchronized来保证多线程之间操作的有序性,volatile关键字本身通过加入内存屏障来禁止指令的重排序,而synchronized关键字通过一个变量在同一时间只允许有一个线程对其进行加锁的规则来实现,在单线程程序中,不会发生“指令重排”和“工作内存和主内存同步延迟”现象,只在多线程程序中出现。

happens-before原则:

Java内存模型中定义的两项操作之间的次序关系,如果说操作A先行发生于操作B,操作A产生的影响能被操作B观察到,“影响”包含了修改了内存中共享变量的值、发送了消息、调用了方法等。

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

  • a.程序次序规则(Pragram Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环结构。

  • b.管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而”后面“是指时间上的先后顺序。

  • c.volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读取操作,这里的”后面“同样指时间上的先后顺序。

  • d.线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。

  • e.线程终于规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值等作段检测到线程已经终止执行。

  • f.线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生。

  • g.对象终结规则(Finalizer Rule):一个对象初始化完成(构造方法执行完成)先行发生于它的finalize()方法的开始。

  • h.传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

一个操作”时间上的先发生“不代表这个操作会是”先行发生“,那如果一个操作”先行发生“是否就能推导出这个操作必定是”时间上的先发生 “呢?也是不成立的,一个典型的例子就是指令重排序。所以时间上的先后顺序与happens-before原则之间基本没有什么关系,所以衡量并发安全问题一切必须以happens-before 原则为准。

JMM模型下的线程间通信:

线程间通信必须要经过主内存。

如下,如果线程A与线程B之间要通信的话,必须要经历下面2个步骤:

  • 线程A把本地内存A中更新过的共享变量刷新到主内存中去。

  • 线程B到主内存中去读取线程A之前已更新过的共享变量。

JMM模型下的线程间通信

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。

  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用

  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。

  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

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

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

  • 不允许read和load、store和write操作之一单独出现

  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。

  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。

  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。

  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现

  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值

  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。

  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

内存屏障

为了禁止编译器和CPU对代码进行重排序,在编译器和CPU层面上都有对应的指令,这个就是内存屏障。

编译器的内存屏障只是为了告诉编译器不要对指令进行重排序。当编译完了以后,这种内存屏障就消失了,CPU并不会感知到编译器层面的内存屏障。而CPU的内存屏障则是CPU提供的指令,可供开发者调用。

可以把基本的CPU内存屏障分为四种:

  • LoadLoad:禁止读和读的重排序

  • StoreStore:禁止写与写的重排序

  • LoadStore:禁止读和写的重排序

  • StoreLoad:禁止写和读的重排序

volidate关键字解析

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

  • 1.保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

  • 2.禁止进行指令重排序。

基于保守策略的JMM内存屏障插入策略

  • 1.在每个volatile写操作的前面插入一个StoreStore屏障,保证volatile写操作前面的Store Buffer队列中的操作都已经刷新到缓存中,防止前面的写操作与volatile写操作发生指令重排。

  • 2.在每个volatile写操作的后面插入一个StoreLoad屏障,保证后面的其他写/读操作前面的Store Buffer队列中的操作都已经刷新到缓存中(也就是volatile写操作),防止后面的其他读操作与volatile写操作发生指令重排。

  • 3.在每个volatile读操作的后面插入一个LoadLoad屏障,保证volatile读操作前面的Load Buffer队列中的操作都已经刷新到缓存中,防止前面的读操作与volatile读操作发生指令重排。

  • 4.在每个volatile读操作的后面插入一个LoadStore屏障,保证volatile写操作后面的Store Buffer队列中的操作都已经刷新到缓存中,防止后面的写操作与volatile读操作发生指令重排。

从JDK8开始,Java在Unsafe类中提供了三个内存屏障函数。

//在JDK9中对JDK定义的三种内存屏障与理论层面划分的四类内存屏障之间的对应进行了说明
public final class Unsafe{
	***
	//loadFence = LoadLoad+LoadStore
	public native void loadFence();
	//storeFence = StoreStore+LoadStore
	public native void storeFence();
	//fullFence = StoreStore+LoadStore+StoreLoad
	public native void fullFence();
	***
}

由于Unsafe是在rt.jar包中并且是终态的无法通过继承来调用,同时存在安全检查机制,我们无法获取其实例,只能通过反射获取到。也可以通过Unsafe.getUnsafe()获取。

public class UnsafeInstance {
    public static void main(String[] args) {
        Unsafe unsafe = Unsafe.getUnsafe();
        int a = 2;
        unsafe.storeFence(); // 添加写屏障
        int b = 3;
    }

volatile保证原子性吗?

public class Test {
    public volatile int inc = 0;
     
    public void increase() {
        inc++;
    }
     
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
         
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

大家想一下这段程序的输出结果是多少?也许有些朋友认为是10000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字。

可能有的朋友就会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。

这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。

在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:

  • 假如某个时刻变量inc的值为10,

  • 线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;

  • 然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。

  • 然后线程1接着进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。

  • 那么两个线程分别进行了一次自增操作后,inc只增加了1。

  • 解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before规则中的volatile变量规则,但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对inc值进行修改。然后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。

根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。

volatile的原理和实现机制

当volatile变量修饰的共享变量进行写操作的反汇编代码会出现0x01a3de24: lock addl $0×0,(%esp),其实就是插入了内存屏障导致的结果,lock表示volatile变量写时被缓存锁定了(MESI协议),作用如下:

  • 禁止指令重排序

  • 将当前处理器缓存行的数据写回到系统内存

  • 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效

练习题:延迟加载双重锁定是否真的安全

public class Instance {                         // 1
    private static Instance instance;           // 2
    public static Instance getInstance() {      // 3
        if (instance == null) {                 // 4:第一次检查
            synchronized (Instance.class) {     // 5:加锁
                if (instance == null)           // 6:第二次检查
                    instance = new Instance();  // 7:问题的根源出在这里
            }                                   // 8
        }                                       // 9
        return instance;                        // 10
    }                                           // 11
}

代码第7行instance=new Singleton();创建了一个对象。这一行代码可以分解为如下的3行伪代码

memory = allocate(); // A1:分配对象的内存空间 
ctorInstance(memory); // A2:初始化对象 
instance = memory; // A3:设置instance指向刚分配的内存地址

假如2和3之间重排序之后的顺序如下

memory = allocate(); // A1:分配对象的内存空间 
instance = memory;  //A3:instance指向刚分配的内存地址,此时对象还没有被初始化
ctorInstance(memory); // A2:初始化对象

假如发生A3、A2重排序,线程是不保障赋值和初始化对象两步骤操作结果会一起同步到主存。 因此第二个线程执行到if (instance == null);// 4第一次检查时,可能会得到一个刚分配的内存而没初始化的对象(此时没有加锁,锁的happens-before规则不适用)

缓存行和伪共享

缓存行

CPU读取内存数据时并非一次只读一个字节,而是会读一段64字节长度的连续的内存块(chunks of memory),这些块我们称之为缓存行(Cache line)。

一缓存行是可以存在多个变量的,比如long类型(64位==8字节),可以存入8个

伪共享

设想变量A和B没有关联,却刚好在同一缓存行;然后A被CPU-X处理,B被CPU-Y处理;因为CPU-X对A的缓存更新而导致B的缓存失效;CPU-Y要处理B,则要读取更新后的缓存行(B实际是没被更新),造成没必要的内存读取开销。这就是伪共享。

填充

一个CPU核心在加载一个缓存行时要执行上百条指令。如果一个核心要等待另外一个核心来重新加载缓存行,那么他就必须等在那里,称之为stall(停止运转)。减少伪共享也就意味着减少了stall的发生,其中一个手段就是通过填充(Padding)数据的形式,来保证本应有可能位于同一个缓存行的两个变量,在被多线程访问时必定位于不同的缓存行。

填充字节,将对应的变量填充到缓存行的大小。如下面定义的类,声明额外的属性。

必须要了解的一点是JVM可能会清除无用字段或重排无用字段的位置,这样的话,可能无形中又会引入伪共享。

为了避免无用字段被消除,通常我们会用volatile修饰一下。
public final static class FilledLong {
    /**value 加 p1 - p6;加对象头8个字节正好等于一缓存行的大小 */
    //markWord + klass (32位机,64位是16字节) 8字节 
    public volatile long value = 0L; // 8字节
    public long p1, p2, p3, p4, p5, p6; //48字节
}

使用jdk的注解@Contended修饰变量,jvm会自动将变量填充到缓存行的大小。注意的是需要加入启动参数 -XX:-RestrictContended

public class Point {
        int x;
        @Contended
        int y;
    }

上面的代码将x和y置于不同的缓存行。@Contented注解将y移动到远离对象头部的地方,(以避免和x一起被加载到同一个缓存行)。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

小明程序猿

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

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

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

打赏作者

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

抵扣说明:

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

余额充值