Java多线程8:Volatile原理和使用场景

前言:volatile关键字是Java提供的一种轻量级的同步机制,可以保证变量的内存可见性和禁止指令重排序。一个硬币具有两面,volatile不会造成上下文切换的开销,但是它也并不能像synchronized那样保证所有场景下的线程安全,因此我们需要在合适的场景下使用volatile机制。


一、基本概念

在并发编程中分析线程安全的问题时往往需要切入点,那就是JMM抽象内存模型、happens-before规则、三条性质:原子性,有序性和可见性。

1.1、Java内存模型

JMM(Java Memory Model):Java 内存模型,是 Java 虚拟机规范中所定义的一种内存模型,Java 内存模型是标准化的,屏蔽掉了底层不同计算机的区别。也就是说,JMM 是 JVM 中定义的一种并发编程的底层模型机制。

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

Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如原子性、可见性和有序性的问题。JMM就是为了解决这些问题而出现的,这个模型建立了一些规范,来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果,以保证在多核CPU多线程编程环境下,对共享变量读写的原子性、可见性和有序性。

JMM 的规定:

  • 所有的共享变量都存储于主内存。这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。

  • 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。

  • 线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。

  • 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

JMM 的抽象示意图:

Java多线程8:Volatile原理和使用场景_第1张图片

JMM 这样的规定可能会导致线程对共享变量的修改没有即时更新到主内存,或者线程没能够即时将共享变量的最新值同步到工作内存中,从而使得线程在使用共享变量的值时,该值并不是最新的。正因为 JMM 这样的机制,就出现了内存可见性问题。

内存可见性

内存可见性是指当一个线程修改了某个变量的值,其它线程总是能知道这个变量变化。也就是说,如果线程 A 修改了共享变量 V 的值,那么线程 B 在使用 V 的值时,能立即读到 V 的最新值。

内存可见性问题的解决方案

我们如何保证多线程下共享变量的可见性呢?也就是当一个线程修改了某个值后,对其他线程是可见的。

这里有两种方案:加锁synchronizerd 和 使用 volatile 关键字。

1.2、并发编程中的可见性、原子性和有序性

可见性:

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

可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉。通常,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。

可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果。另一个线程马上就能看到。比如:用volatile修饰的变量,就会具有可见性。volatile修饰的变量不允许线程内部缓存和重排序,即直接修改内存。所以对其他线程是可见的。但是这里需要注意一个问题,volatile只能让被他修饰内容具有可见性,但不能保证它具有原子性。比如 volatile int a = 0;之后有一个操作 a++;这个变量a具有可见性,但是a++ 依然是一个非原子操作,也就是这个操作同样存在线程安全问题。

在 Java 中 volatile、synchronized 和 final 实现可见性。

原子性:

原子性是指一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

原子是世界上的最小单位,具有不可分割性。比如 a=0;(a非long和double类型) 这个操作是不可分割的,那么我们说这个操作时原子操作。再比如:a++; 这个操作实际是a = a + 1;是可分割的,所以他不是一个原子操作。非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。java的concurrent包下提供了一些原子类,我们可以通过阅读API来了解这些原子类的用法。比如:AtomicInteger、AtomicLong、AtomicReference等。

在 Java 中 synchronized 和在 lock、unlock 中操作保证原子性。

有序性:

有序性是指程序执行的顺序按照代码的先后顺序执行。

Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 是因为其本身包含“禁止指令重排序”的语义,synchronized 是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得的,此规则决定了持有同一个对象锁的两个同步块只能串行执行。

二、Volatile作用


Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程,volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入主内存的值;当把变量声明为volatile类型后,编译器与CPU处理器都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起指令重排序。

在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。可以保证变量的内存可见性和禁止指令重排序。

Java多线程8:Volatile原理和使用场景_第2张图片

当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。

当一个变量定义为 volatile 之后,将具备两种特性:

2.1、保证此变量对所有的线程的可见性

可见性是指当一个线程修改了共享变量后,其他线程能够立即得知这个修改。通过之前对synchronzed内存语义进行了分析,当线程获取锁时会从主内存中获取共享变量的最新值,释放锁的时候会将共享变量同步到主内存中。从而 synchronized 具有可见性。

同样的在volatile分析中,会通过在指令中添加lock指令,以实现内存可见性。当一个线程修改了这个共享变量的值,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。 

volatile能达到下面两个效果:

  • 当一个线程写一个volatile变量时,JMM会把该线程对应的本地内存中的变量值强制刷新到主内存中去;

  • 这个写会操作会导致其他线程中的这个共享变量的缓存失效,要使用这个变量的话必须重新去主内存中取值。

2.2、禁止指令重排序优化

volatile还有一个特性:禁止指令重排序优化。

指令重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。虽然很多硬件都会为了优化做一些重排,但是在Java中,不管怎么排序,都不能影响单线程程序的执行结果。这就是as-if-serial语义,所有硬件优化的前提都是必须遵守as-if-seria(线程内表现为串行)语义。

所以重排序在单线程模式下是一定会保证最终结果的正确性,但是在多线程环境下,可能就会出问题。

有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障。

volatile 性能:

volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。


三、volatile使用场景和原理

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。

通常来说,使用volatile必须具备以下2个条件:

  • 对变量的写操作不依赖于当前值;

  • 该变量没有包含在具有其他变量的不变式中。

3.1、单例模式双重检查 (禁止指令重排)

class Singleton{
    private volatile static Singleton instance = null;
     
    private Singleton() {
         
    }
     
    public static Singleton getInstance() {
        if(instance==null) { // 1
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();  //2
            }
        }
        return instance;
    }
}

上述的Instance类变量如果没有用volatile关键字修饰的,会导致这样一个问题:
在线程执行到第1行的时候,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。

造成这种现象主要的原因是重排序,重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

第二行代码可以分解成以下几步

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

根源在于代码中的2和3之间,可能会被重排序。例如:

这种重排序可能就会导致一个线程拿到的instance是非空的但是还没初始化完全。

3.2、volatile不能保证原子性

volatile并不是在所有场景下都能保证线程安全的

public class Counter {
    public static volatile int num = 0;
    //使用CountDownLatch来等待计算线程执行完
    static CountDownLatch countDownLatch = new CountDownLatch(30);
    public static void main(String []args) throws InterruptedException {
        //开启30个线程进行累加操作
        for(int i=0;i<30;i++){
            new Thread(){
                public void run(){
                    for(int j=0;j<10000;j++){
                        num++;//自加操作
                    }
                    countDownLatch.countDown();
                }
            }.start();
        }
        //等待计算线程执行完
        countDownLatch.await();
        System.out.println(num);
    }
}

上面的代码中,每个线程都对共享变量num加了10000次,一共有30个线程,那么感觉上num的最后应该是300000。但是执行下来,大概率最后的结果不是300000(大家可以自己执行下这个代码)。这是因为什么原因呢?

问题就出在num++这个操作上,因为num++不是个原子性的操作,而是个复合操作。我们可以简单讲这个操作理解为由这三步组成:

  • step1:从主存中读取最新的num值,并在CPU中存一份副本;

  • step2:对CPU中的num的副本值加1;

  • step3:赋值。

假如现在有两个线程在执行,线程1在执行到step2的时候被阻断了,CPU切换给线程2执行,线程2成功地将num值加1并刷新到内存。CPU又切会线程1继续执行step2,但是此时不会再去拿最新的num值,step2中的num值是已经过期的num值。

上面代码的执行结果和我们预期不符的原因就是类似num++这种操作并不是原子操作,而是分几步完成的。这些执行步骤可能会被打断。在这种情况下volatile就不能保证线程安全了,需要使用锁等同步机制来保证线程安全。

如果让volatile保证原子性,必须符合以下两条规则:

  • 运算结果并不依赖于变量的当前值,或者能够确保只有一个线程修改变量的值;

  • 变量不需要与其他的状态变量共同参与不变约束

3.3、volatile使用总结

  • volati是Java提供的一种轻量级同步机制,可以保证共享变量的可见性和有序性(禁止指令重排),volatile的实现原理是基于处理器的Lock指令的,这个指令会使得对变量的修改立马刷新回主内存,同时使得其他CPU中这个变量的副本失效;

  • volatile对于单个的共享变量的读/写(比如a=1;这种操作)具有原子性,但是像num++或者a=b;这种复合操作,volatile无法保证其原子性;

  • volatile的使用场景不是很多,使用时需要深入考虑下当前场景是否适用volatile(记住“对变量的写操作不依赖于当前值”、“该变量没有包含在具有其他变量的不变式中”这两个使用条件)。常见的使用场景有多线程下的状态标记量和单例模式双重检查等。

  • synchronized无法禁止指令重排,却能保证有序性,synchronized通过排他锁的方式就保证了同一时间内,被synchronized修饰的代码是单线程执行的,单线程的有序性就天然存在了。


四、volatile的实现原理

通过上面的介绍,我们知道volatile可以实现内存的可见性和防止指令重排序。那么volatile的这些功能是怎么实现的呢?其实volatile的这些内存语义是通过内存屏障技术实现的。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。同时内存屏障还能保证内存的可见性。内存屏障是一组处理器指令,它的作用是禁止指令重排序和解决内存可见性的问题。

下面我们来看看 volatile 读 / 写时是如何插入内存屏障的,见下图:

Java多线程8:Volatile原理和使用场景_第3张图片

从上图,我们可以知道 volatile 读 / 写插入内存屏障规则:

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

  • 在每个 volatile 写操作的前后分别插入一个 StoreStore 屏障和一个 StoreLoad 屏障。

也就是说,编译器不会对 volatile 读与 volatile 读后面的任意内存操作重排序;编译器不会对 volatile 写与 volatile 写前面的任意内存操作重排序。


参考链接:

三大性质总结:原子性、可见性以及有序性

volatile 关键字,你真的理解吗?

面试官:双重检查锁为什么要使用 volatile 字段?

volatile原子性问题:一万次i++操作

你可能感兴趣的:(Java基础,volatile禁止指令重排,volatile内存可见性,volatile实现原理,volatile)