【搞定Java并发编程】第8篇:volatile关键字详解

上一篇:Java内存模型详解:https://blog.csdn.net/pcwl1206/article/details/84871090

目  录

1、volatile的作用

1.1、volatile的可见性

1.2、volatile禁止指令重排序

2、volatile写-读建立的happens-before关系

3、volatile写-读的内存语义

4、volatile的内存语义


1、volatile的作用

volatile 在并发编程中很常见,但也容易被滥用,现在我们就分析下 volatile 关键字的语义。volatile 是Java虚拟机提供的轻量级的同步机制。volatile 关键字有如下两个作用

1、保证被 volatile 修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被 volatile 修饰共享变量的值,新值总数可以被其他线程立即得知;

2、禁止指令重排序优化。 

1.1、volatile的可见性

关于volatile的可见性作用,我们必须意识到被volatile修饰的变量对所有线程总是立即可见的,对volatile变量的所有写操作总是能立刻反应到其他线程中的,但是对于volatile变量运算操作在多线程环境并不保证安全性,如下:

public class VolatileVisibility {
    public static volatile int i =0;

    public static void increase(){
        i++;
    }
}

正如上述代码所示,i 变量的任何改变都会立马反应到其他线程中。但是如果存在多条线程同时调用 increase() 方法的话,就会出现线程安全问题,毕竟 i++ 操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成。如果第二个线程在第一个线程读取旧值和写回新值期间读取 i 的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败。因此对于 increase() 方法必须使用 synchronized 修饰,以便保证线程安全,需要注意的是一旦使用 synchronized 修饰方法后,由于synchronized 本身也具备与volatile相同的特性,即可见性,因此在这样种情况下就完全可以省去 volatile 修饰变量。

public class VolatileVisibility {
    public static int i =0;

    public synchronized static void increase(){
        i++;
    }
}

现在来看另外一种场景,可以使用volatile修饰变量达到线程安全的目的,如下:

public class VolatileSafe {

    volatile boolean close;

    public void close(){
        close = true;
    }

    public void doWork(){
        while (!close){
            System.out.println("safe....");
        }
    }
}

由于对于boolean变量close值的修改属于原子性操作,因此可以通过使用volatile修饰变量close,使用该变量对其他线程立即可见,从而达到线程安全的目的。

那么JMM是如何实现让volatile变量对其他线程立即可见的呢?实际上,当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量。volatile变量正是通过这种写-读方式实现对其他线程可见(但其内存语义实现则是通过内存屏障,稍后会说明)。

1.2、volatile禁止指令重排序

volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,关于指令重排优化上篇文章中已经详细分析过,这里主要简单说明一下volatile是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)。 

内存屏障:又称内存栅栏是一个CPU指令。它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷新各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。下面看一个非常典型的禁止重排优化的例子DCL,如下:

public class DoubleCheckLock {

	private static DoubleCheckLock instance;
	
	private DoubleCheckLock(){
	
	}
	
	public static DoubleCheckLock getInstance(){
		
		// 第一次检查
		if(instance == null){
			// 同步
			synchronized (DoubleCheckLock.class) {
				if(instance == null){
					// 多线程环境下可能会出现问题的地方
					instance = new DoubleCheckLock();
				}
			}
		}
		return instance;
	}
}

上述代码一个经典的单例的双重检测的代码。这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可能出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的 instance 不为null时,instance的引用对象可能没有完成初始化。因为 instance = new DoubleCheckLock() 可以分为以下3步完成(伪代码):

memory = allocate(); //1.分配对象内存空间
instance(memory);    //2.初始化对象
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null

由于步骤1和步骤2间可能会重排序,如下:

memory = allocate(); //1.分配对象内存空间
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance(memory);    //2.初始化对象

由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问 instance 不为null时,由于 instance 实例未必已初始化完成,也就造成了线程安全问题。那么该如何解决呢?很简单,我们使用 volatile 禁止 instance 变量被执行指令重排优化即可。

//禁止指令重排优化
private volatile static DoubleCheckLock instance;

2、volatile写-读建立的happens-before关系

从JSR-133开始,volatile 变量的写-读可以实现线程之间的通信。

从内存语义的角度来说,volatile与监视器锁有相同的效果:volatile写和监视器的释放有相同的内存语义;volatile读与监视器的获取有相同的内存语义。

请看下面使用volatile变量的示例代码:

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;
 
    public void writer() {
        a = 1;                     // 1
        flag = true;               // 2
    }
 
    public void reader() {
        if (flag) {                // 3
            int i =  a;            // 4
            ……
        }
    }
}

假设线程A执行writer()方法之后,线程B执行reader()方法。根据happens before规则,这个过程建立的happens before 关系可以分为三类:

  1. 根据程序次序规则,1 happens before 2; 3 happens before 4。
  2. 根据volatile规则,2 happens before 3。
  3. 根据happens before 的传递性规则,1 happens before 4。

上述happens before 关系的图形化表现形式如下:

【搞定Java并发编程】第8篇:volatile关键字详解_第1张图片

在上图中,每一个箭头链接的两个节点,代表了一个happens before 关系。黑色箭头表示程序顺序规则;橙色箭头表示volatile规则;蓝色箭头表示组合这些规则后提供的happens before保证。

这里A线程写一个volatile变量后,B线程读同一个volatile变量。A线程在写volatile变量之前所有可见的共享变量在B线程读同一个volatile变量后,将立即变得对B线程可见。


3、volatile写-读的内存语义

volatile写的内存语义如下:

  • 当写一个 volatile 变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。

以上面示例程序 VolatileExample 为例,假设线程A首先执行 writer() 方法,随后线程B执行 reader() 方法,初始时两个线程的本地内存中的 flag 和 a 都是初始状态。下图是线程A执行 volatile 写后,共享变量的状态示意图:

【搞定Java并发编程】第8篇:volatile关键字详解_第2张图片

如上图所示,线程A在写 flag 变量后,本地内存A中被线程A更新过的共享变量的值被刷新到主内存中。此时,本地内存A和主内存中的共享变量的值是一致的。

volatile读的内存语义如下:

  • 当读一个 volatile 变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

下面是线程B读同一个volatile变量后,共享变量的状态示意图:

【搞定Java并发编程】第8篇:volatile关键字详解_第3张图片

如上图所示,在读 flag 变量时,本地内存B已经被置为无效。此时,线程B必须从主内存中读取共享变量。线程B的读取操作将导致本地内存B与主内存中的共享变量的值也变成一致的了。

如果我们把 volatile 写和 volatile 读这两个步骤综合起来看的话。在线程B读一个 volatile 变量后和线程A在写这个volatile 变量之前所有可见的共享变量的值都将立即变得对读线程B可见。

下面对volatile写和volatile读的内存语义做个总结:

1、线程A写一个 volatile 变量时,实质上是线程A向接下来将要读这个 volatile 变量的某个线程发出了(其对共享变量所在修改的)消息;

2、线程B读一个 volatile 变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息;

3、线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。


4、volatile的内存语义

下面,让我们来看看JMM如何实现 volatile 写/读的内存语义。

前文我们提到过重排序分为编译器重排序和处理器重排序。为了实现 volatile 内存语义,JMM会分别限制这两种类型的重排序类型。下面是JMM针对编译器制定的 volatile 重排序规则表:

【搞定Java并发编程】第8篇:volatile关键字详解_第4张图片

举例来说,第三行最后一个单元格的意思是:在程序顺序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作

从上表我们可以看出:

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

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

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

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

下面是保守策略下,volatile 写插入内存屏障后生成的指令序列示意图:

【搞定Java并发编程】第8篇:volatile关键字详解_第5张图片

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

这里比较有意思的是 volatile 写后面的 StoreLoad 屏障。这个屏障的作用是避免 volatile 写与后面可能有的 volatile 读/写操作重排序。因为编译器常常无法准确判断在一个 volatile 写的后面,是否需要插入一个 StoreLoad 屏障(比如,一个volatile 写之后方法立即 return)。为了保证能正确实现 volatile 的内存语义,JMM在这里采取了保守策略:在每个 volatile 写的后面或在每个 volatile 读的前面插入一个 StoreLoad 屏障。从整体执行效率的角度考虑,JMM选择了在每个 volatile 写的后面插入一个 StoreLoad 屏障。因为 volatile 写-读内存语义的常见使用模式是:一个写线程写 volatile 变量,多个读线程读同一个 volatile 变量。当读线程的数量大大超过写线程时,选择在 volatile 写之后插入 StoreLoad 屏障将带来可观的执行效率的提升。从这里我们可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

下面是在保守策略下,volatile 读插入内存屏障后生成的指令序列示意图:

【搞定Java并发编程】第8篇:volatile关键字详解_第6张图片

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

上述 volatile 写和 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写
    }
 
    …                         // 其他方法
}

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

【搞定Java并发编程】第8篇:volatile关键字详解_第7张图片

注意,最后的 StoreLoad 屏障不能省略。因为第二个 volatile 写之后,方法立即 return。此时编译器可能无法准确断定后面是否会有 volatile 读或写,为了安全起见,编译器常常会在这里插入一个 StoreLoad 屏障。

上面的优化是针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以x86处理器为例,上图中除最后的 StoreLoad 屏障外,其它的屏障都会被省略。

前面保守策略下的 volatile 读和写,在 x86处理器平台可以优化成:

【搞定Java并发编程】第8篇:volatile关键字详解_第8张图片

前文提到过,x86处理器仅会对写-读操作做重排序。X86不会对读-读,读-写和写-写操作做重排序,因此在x86处理器中会省略掉这三种操作类型对应的内存屏障。在x86中,JMM仅需在 volatile 写后面插入一个 StoreLoad 屏障,即可正确实现volatile写-读的内存语义。这意味着在x86处理器中,volatile 写的开销比 volatile 读的开销会大很多(因为执行StoreLoad屏障开销会比较大)。

  • JSR-133为什么要增强volatile的内存语义

在JSR-133之前的旧Java内存模型中,虽然不允许 volatile 变量之间重排序,但旧的Java内存模型允许 volatile 变量与普通变量之间重排序。在旧的内存模型中,VolatileExample示例程序可能被重排序成下列时序来执行:

【搞定Java并发编程】第8篇:volatile关键字详解_第9张图片

在旧的内存模型中,当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类似)。其结果就是:读线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改。

因此在旧的内存模型中 ,volatile的写-读没有监视器的释放-获所具有的内存语义。为了提供一种比监视器锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强 volatile 的内存语义:严格限制编译器和处理器对 volatile 变量与普通变量的重排序,确保 volatile 的写-读和监视器的释放-获取一样,具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要 volatile 变量与普通变量之间的重排序可能会破坏 volatile 的内存语意,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

由于 volatile 仅仅保证对单个 volatile 变量的读/写具有原子性,而监视器锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,监视器锁比 volatile 更强大;在可伸缩性和执行性能上,volatile 更有优势。如果读者想在程序中用 volatile 代替监视器锁,请一定谨慎。


上一篇:Java内存模型详解:https://blog.csdn.net/pcwl1206/article/details/84871090

本文内容主要转发自以下文章中的内容:

1、全面理解Java内存模型(JMM)及volatile关键字

2、并发三大问题与volatile关键字,CAS操作

3、volatile关键字

你可能感兴趣的:(Java并发编程(JUC),搞定Java并发编程)