深入理解java并发编程基础篇(三)-------volatile

一、前言

  在上一篇,我们研究了Java内存模型,并且知道Java内存模型的概念以及作用,围绕着原子性、可见性、有序性进行了简单的概述,那么在这一篇我们首先会介绍volatile关键字的基础认知,然后深入的去解析volatile在这三个特性中究竟有什么样的作用?volatile是如何实现的?

二、volatile的用法

  volatile通常被比喻成”轻量级的Synchronized“,也是Java并发编程中比较重要的一个关键字。和Synchronized不同,volatile是一个变量修饰符,只能用来修饰变量。无法修饰方法及代码块等。

  volatile的用法比较简单,只需要在声明一个可能被多线程同时访问的变量时,使用volatile修饰就可以了。

  举一个单例实现的简单例子,代码如下:

package com.MyMineBug.demoRun.test;

public class Singleton {

	private volatile static Singleton singleton;

	private Singleton() {
		
	};

	public static Singleton getInstance() {
		if(singleton == null) {
			synchronized (Singleton.class) {
				if (singleton == null) {
					singleton = new Singleton();
				}
			}
		}
		return singleton;
	}

}
复制代码

这段代码是比较典型的使用双重锁校验实现单例的一种形式,其中使用volatile关键字修饰可以被多个线程同时访问。

三、volatile的特性

  首先看一个代码例子:

package com.MyMineBug.demoRun.test;

class VolatileFeaturesExample {

    volatile long vl = 0L;  // 使用 volatile 声明 64 位的 long 型变量 

    public void set(long l) {
        vl = l;   // 单个 volatile 变量的写 
    }

    public void getAndIncrement () {
        vl++;    // 复合(多个)volatile 变量的读 / 写 
    }

    public long get() {
        return vl;   // 单个 volatile 变量的读 
    }
}
复制代码

假设有多个线程分别调用上面程序的三个方法,这个程序在语意上和下面程序等价:

package com.MyMineBug.demoRun.test;

class VolatileFeaturesExample {
    long vl = 0L;               // 64 位的 long 型普通变量 

    public synchronized void set(long l) {     // 对单个的普通 变量的写用同一个监视器同步 
        vl = l;
    }

    public void getAndIncrement () { // 普通方法调用 
        long temp = get();           // 调用已同步的读方法 
        temp += 1L;                  // 普通写操作 
        set(temp);                   // 调用已同步的写方法 
    }
    
    public synchronized long get() { 
    // 对单个的普通变量的读用同一个监视器同步 
        return vl;
    }
}
复制代码

如上面示例程序所示,对一个 volatile 变量的单个读 / 写操作,与对一个普通变量的读 / 写操作使用同一个监视器锁来同步,它们之间的执行效果相同。 通过对比,我们可以知道:

1.可见性。对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。

2.原子性:对任意单个 volatile 变量的读 / 写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。

3.1 volatile与有序性

  volatile一个强大的功能,那就是他可以禁止指令重排优化。通过禁止指令重排优化,就可以保证代码程序会严格按照代码的先后顺序执行。那么volatile又是如何禁止指令重排的呢?

  先看一个概念内存屏障(Memory Barrier):是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作。而volatile就是是通过内存屏障来禁止指令重排的。下表描述了和volatile有关的指令重排禁止行为:

从上表我们可以看出:

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

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

当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

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

在每个 volatile 写操作的前面插入一个 StoreStore 屏障。

在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。

在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。

在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

这种保守策略总结如下:

接下来,我们通过具体的代码来说明:

package com.MyMineBug.demoRun.test;

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 写 
    }

    …                    // 其他方法 
}
复制代码

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

所以,volatile通过在volatile变量的操作前后插入内存屏障的方式,来禁止指令重排,进而保证多线程情况下对共享变量的有序性。

3.2 volatile与可见性

  可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

  在上一篇文章深入理解java并发编程基础篇(二)-------线程、进程、Java内存模型中,我们知道:Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。所以,就可能出现线程1改了某个变量的值,但是线程2不可见的情况。

  在Java中,我们知道被volatile修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。那么被volatile修饰的变量程序是如何让具体保证其可见性呢?这就与*内存屏障有关。
  volatile对于可见性的实现,内存屏障也起着至关重要的作用。因为内存屏障相当于一个数据同步点,他要保证在这个同步点之后的读写操作必须在这个点之前的读写操作都执行完之后才可以执行。并且在遇到内存屏障的时候,缓存数据会和主存进行同步,或者把缓存数据写入主存、或者从主存把数据读取到缓存。

  所以,内存屏障也是保证可见性的重要手段,操作系统通过内存屏障保证缓存间的可见性,JVM通过给volatile变量加入内存屏障保证线程之间的可见性。

3.3 volatile与原子性

  原子性是指一个操作是不可中断的,要么全部执行完成,要么就都不执行。

  在我们的实际应用场景中,我们应该知道的是:volatile是不能保证原子性的。 那么为神马volatile是不能保证原子性?

  下一篇介绍synchronized的时候,我们会知道为了保证原子性,需要通过字节码指令monitorenter和monitorexit,但是volatile和这两个指令之间是没有任何关系的。

  根据自己的理解是:线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。所以在多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。

下面来看一段volatile与原子性的代码:

package com.MyMineBug.demoRun.test;

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);
    }
}
复制代码

以上代码比较简单,就是创建10个线程,然后分别执行1000次 i++ 操作。正常情况下,程序的输出结果应该是10000,但是,多次执行的结果都小于10000。这其实就是volatile无法满足原子性的原因。
为什么会出现这种情况呢,那就是因为虽然volatile可以保证inc在多个线程之间的可见性。但是无法inc++ 的原子性。

四、总结

  volatile有序性和可见性是通过内存屏障实现的。而volatile是无法保证原子性的。 在下一下篇,我们将深入解析关键字synchronized

  如果觉得还不错,请点个赞!!!

  Share Technology And Love Life

猜你喜欢

转载自juejin.im/post/5dbbd500518825232c377a77