Java并发编程

深入分析volatile实现原理

2019-05-30  本文已影响0人  Java技术天地

在前面一文中我们深入的分享了synchronized的实现原理,也知道了synchronized是一把重量级的锁。在Java中还有一个关键词,那就是volatile。volatile是轻量级的synchronized,它在多线程中保证了变量的“可见性”。可见性的意思是当一个线程修改了一个变量的值后,另外的线程能够读取到这个变量修改后的值。volatile在Java语言规范中的定义如下:

Java编程语言允许线程访问共享变量,为了确保共享变量能够被准确和一致性的更新,线程应该确保通过排他锁单独获取这个变量。

这句话可能说的比较绕,我们先来看一段代码:

public class VolatileTest implements Runnable {
    private boolean flag = false;
    @Override
    public void run() {
        while (!flag){
            
        }
        System.out.println("线程结束运行...");
    }
    public void setFlag(boolean flag) {
        this.flag = flag;
    }
    public static void main(String[] args) throws InterruptedException {
        VolatileTest v = new VolatileTest();
        Thread t1 = new Thread(v);
        t1.start();
        Thread.sleep(2000);
        v.setFlag(true);
    }
}

这段代码的运行结果:

EBoel4.png

可以看到尽管在代码中调用了v.setFlag(false)方法,线程也没有结束运行。这是因为在上面的代码中,实际上是有2个线程在运行,一个是main线程,一个是在main线程中创建的t1线程。因此我们可以看到在线程中的变量是互不可见的。 要理解线程中变量的可见性,我们需要先理解Java的内存模型。

Java内存模型

在Java中,所有的实例域、静态变量和数组元素都存储在堆内存中,堆内存在线程之间是共享的。局部变量,方法定义参数和异常数量参数是存放在Java虚拟机栈上面的。Java虚拟机栈是线程私有的因此不会在线程之间共享,它们不存在内存可见性的问题,也不受内存模型的影响。

Java内存模型(Java Memory Model 简称 JMM),决定一个一个线程对共享变量的写入何时对其它线程可见。JMM定义了线程和主内存之间的抽象关系:

线程之间共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程共享变量的副本。本地内存是JMM的一个抽象概率,并不真实的存在。它涵盖了缓存,写缓存区,寄存器以及其他的硬件和编译优化。

Java内存模型的抽象概念图如下所示:

image

看完了Java内存模型的概念,我们再来看看内存模型中主内存是如何和线程本地内存之间交互的。

主内存和本地内存间的交互

主内存和本地内存的交互即一个变量是如何从主内存中拷贝到本地内存又是如何从本地内存中回写到主内存中的实现,Java内存模型提供了8中操作来完成主内存和本地内存之间的交互。它们分别如下:

从上面8种操作中,我们可以看出,当一个变量从主内存复制到线程的本地内存中时,需要顺序的执行read和load操作,当一个变量从本地内存同步到主内存中时,需要顺序的执行store和write操作。Java内存模型只要求上述的2组操作是顺序的执行的,但并不要求连续执行。比如对主内存中的变量a 和 b 进行访问时,有可能出现的顺序是read a read b load b load a。除此之外,Java内存模型还规定了在执行上述8种基本操作时必须满足以下规则:

volatile 内存语义之可见性

大概了解了Java的内存模型后,我们再看上面的代码结果我们将很好理解为什么是这样子的了。首先主内存中flag的值是false,在t1线程执行时,依次执行的操作有read、load和use操作,这个时候t1线程的本地内存中flag的值也是false,线程会一直执行。当main线程调用v.setFlag(true)方法时,main线程中的falg被赋值成了true,因为使用了assign操作,因此main线程中本地内存的flag值将同步到主内存中去,这时主内存中的flag的值为true。但是t1线程没有再次执行read 和 load操作,因此t1线程中flag的值任然是false,所以t1线程不会终止运行。想要正确的停止t1线程,只需要在flag变量前加上volatile修饰符即可,因为volatile保证了变量的可见性。既然volatile在各个线程中是一致的,那么volatile是否能够保证在并发情况下的安全呢?答案是否定的,因为volatile不能保证变量的原子性。示例如下:

public class VolatileTest2 implements Runnable {
    private volatile int i = 0;
    @Override
    public void run() {
        for (int j=0;j<1000;j++) {
            i++;
        }
    }
    public int getI() {
        return i;
    }

    public static void main(String[] args) throws InterruptedException {
        VolatileTest2 v2 = new VolatileTest2();
        for (int i=0;i<100;i++){
            new Thread(v2).start();
        }
        Thread.sleep(5000);
        System.out.println(v2.getI());
    }
}

这段代码启动了100线程,每个线程都对i变量进行1000次的自增操作,若果这段代码能够正确的运行,那么正确的结果应该是100000,但是实际并非如此,实际运行的结果是少于100000的,这是因为volatile不能保证i++这个操作的原子性。我们用javap反编译这段代码,截取run()方法的代码片段如下:

 public void run();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=3, locals=2, args_size=1
         0: iconst_0
         1: istore_1
         2: iload_1
         3: sipush        1000
         6: if_icmpge     25
         9: aload_0
        10: dup
        11: getfield      #2                  // Field i:I
        14: iconst_1
        15: iadd
        16: putfield      #2                  // Field i:I
        19: iinc          1, 1
        22: goto          2
        25: return

我们发现i++虽然只有一行代码,但是在Class文件中却是由4条字节码指令组成的。从上面字节码片段,我们很容易分析出并发失败的原因:当getfield指令把变量i的值取到操作栈时,volatile关键字保证了i的值在此时的正确性,但是在执行iconst_1和iadd指令时,i的值可能已经被其它的线程改变,此时再执行putfield指令时,就会把一个过期的值回写到主内存中去了。由于volatile只保证了变量的可见性,在不符合以下规则的场景中,我们任然需要使用锁来保证并发的正确性。

volatile 内存语义之禁止重排序

在介绍volatile的禁止重排序之前,我们先来了解下什么是重排序。重排序是指编译器和处理器为了优化程序性能而对指令进行重新排序的一种手段。那么重排序有哪些规则呢?不可能任何代码都可以重排序,如果是这样的话,那么在单线程中,我们将不能得到明确的知道运行的结果。重排序规则如下:

既然volatile禁止重排序,那是不是重排序对多线程有影响呢?我们先来看下面的代码示例

public class VolatileTest3 {
    int a = 0;
    boolean flag = false;

    public void write(){
        a = 1;                 // 1
        flag = true;           // 2
    }

    public void read(){
        if(flag){               // 3
            int i = a*a;        // 4
            System.out.println("i的值为:"+i);
        }

    }
}

此时有2个线程A和B,线程A先执行write()方法,虽有B执行read()方法,在B线程执行到第4步时,i的结果能正确得到吗?结论是 不一定 ,因为步骤1和2没有数据依赖关系,因此编译器和处理器可能对这2个操作进行重排序。同样步骤3和4也没有数据依赖关系,编译器和处理器也可以对这个2个操作进行重排序,我们来看看这两中重排序带来的效果:

image

重上面图片,这2组重排序都会破坏多线程的运行结果。了解了重排序的概率和知道了重排序对多线程的影响,我们知道了volatile为什么需要禁止重排序,那JMM到底是如何实现volatile禁止重排序的呢?下面我们就来探讨下JMM是如何实现volatile禁止重排序的。

前面提到过,重排序分为编译器重排序和处理器重排序,为了实现volatile内存语义,JMM分别对这两种重排序进行了现在。下图是JMM对编译器重排序指定的volatile规则:

image

从上面图中我们可以分析出:

为了实现volatile内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型处理器的重排序,在JMM中,内存屏障的插入策略如下:

StoreStore屏障可以保证在volatile写之前,前面所有的普通读写操作同步到主内存中

StoreLoad屏障可以保证防止前面的volatile写和后面有可能出现的volatile度/写进行重排序

LoadLoad屏障可以保证防止下面的普通读操作和上面的volatile读进行重排序

LoadStore屏障可以保存防止下面的普通写操作和上面的volatile读进行重排序

上面的内存屏障策略可以保证任何程序都能得到正确的volatile内存语义。我们以下面代码来分析

public class VolatileTest3 {
    int a = 0;
    volatile boolean flag = false;

    public void write(){
        a = 1;                 // 1
        flag = true;           // 2
    }

    public void read(){
        if(flag){               // 3
            int i = a*a;        // 4
        }
    }
}
image

通过上面的示例我们分析了volatile指令的内存屏蔽策略,但是这种内存屏障的插入策略是非常保守的,在实际执行时,只要不改变volatile写/读的内存语义,编译器可以根据具体情况来省略不必要的屏障。如下示例:

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

上述代码,编译器在生成字节码时,可能做了如下优化

image 微信扫一扫,关注公众号.jpg
上一篇下一篇

猜你喜欢

热点阅读