8000+字,就说一个字Volatile

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了8000+字,就说一个字Volatile相关的知识,希望对你有一定的参考价值。

简介

volatile是Java提供的一种轻量级的同步机制。Java 语言包含两种内在的同步机制:同步块(或方法)和 volatile 变量,相比于synchronized(synchronized通常称为重量级锁),volatile更轻量级,因为它不会引起线程上下文的切换和调度。但是volatile 变量的同步性较差(有时它更简单并且开销更低),而且其使用也更容易出错。

Java volatile关键字用于将Java变量标记为“存储在主存储器中”。更确切地说,这意味着,每次读取一个volatile变量都将从计算机的主内存中读取,而不是从CPU缓存中读取,并且每次写入volatile变量都将写入主内存,而不仅仅是CPU缓存。

实际上,自Java 5以来,volatile关键字保证的不仅仅是向主存储器写入和读取volatile变量。我将在以下部分解释。

特性

当我们声明共享变量为volatile后,对这个变量的读/写将会很特别。理解volatile特性的一个好方法是:把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。

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

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

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

    public long get() 
        return vl;   //单个volatile变量的读
    

COPYclass 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变量的单个读/写操作,与对一个普通变量的读/写操作使用同一个锁来同步,它们之间的执行效果相同。

锁的happens-before规则保证释放锁和获取锁的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。

锁的语义决定了临界区代码的执行具有原子性。这意味着即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读写就将具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。

简而言之,volatile变量自身具有下列特性:

原子性

原子性是拒绝多线程操作的,不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作。简而言之,在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。例如 a=1是原子性操作,但是a++和a +=1就不是原子性操作。Java中的原子性操作包括:

  • 基本类型的读取和赋值操作,且赋值必须是数字赋值给变量,变量之间的相互赋值不是原子性操作。
  • 所有引用reference的赋值操作
  • java.concurrent.Atomic.* 包中所有类的一切操作

可见性

在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。Java提供了volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。当然,synchronize和Lock都可以保证可见性。synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

在线程使用非volatile变量的多线程应用程序中,出于性能原因,每个线程可以在处理它们时将变量从主存储器拷贝到CPU高速缓存中。如果您的计算机包含多个CPU,则每个线程可以在不同的CPU上运行。这意味着,每个线程都可以将变量复制到不同CPU的CPU缓存中。这在这里说明:

对于volatile变量,无法保证Java虚拟机(JVM)何时将数据从主内存读取到CPU缓存中,或将数据从CPU缓存写入主内存。这可能会导致一些问题,我将在以下部分中解释。

想象一下两个或多个线程可以访问共享对象的情况,该共享对象包含一个声明如下的计数器变量:

COPYpublic class SharedObject 
    public int counter = 0;

如果counter变量未声明volatile,则无法保证何时将counter变量的值从CPU缓存写回主存储器。这意味着,CPU高速缓存中的counter变量值可能与主存储器中的变量值不同。这种情况如下所示:

线程没有看到变量的最新值的问题,是因为它还没有被另一个线程写回主内存,这被称为“可见性”问题,其他线程看不到一个线程的某些更新。

volatile可见性保证

下面是counter变量声明为volatile的样子:

COPYpublic class SharedObject 
    public volatile int counter = 0;

在上面给出的场景中,一个线程(T1)修改计数器,另一个线程(T2)读取计数器(但从不修改它),声明该counter变量为volatile足以保证写入counter变量对T2的可见性。

但是,如果T1和T2都在增加counter变量,那么声明counter变量为volatile就不够了。稍后会详细介绍。

完全volatile可见性保证
  • 如果线程A写入volatile变量并且线程B随后读取这个volatile变量,则在写入volatile变量之前对线程A可见的所有变量在线程B读取volatile变量后也将对线程B可见。
  • 如果线程A读取volatile变量,则读取volatile变量时对线程A可见的所有变量也将从主存储器重新读取。

让我用代码示例说明:

COPYpublic class MyClass 
    private int years;
    private int months
    private volatile int days;

    public void update(int years, int months, int days)
        this.years  = years;
        this.months = months;
        this.days   = days;
    

完全volatile可见性保证意味着,当将一个值写入days时,对线程可见的其他所有变量也会写入主存储器。这意味着,当一个值被写入daysyearsmonths的值也被写入主存储器(注意days的写入在最后)。

COPYpublic class MyClass 
    private int years;
    private int months
    private volatile int days;

    public int totalDays() 
        int total = this.days;
        total += months * 30;
        total += years * 365;
        return total;
    

    public void update(int years, int months, int days)
        this.years  = years;
        this.months = months;
        this.days   = days;
    

注意totalDays()方法通过读取days的值到total变量中开始。当读取days的值时,后续monthsyears值的读取也会从主存储器中读取。因此使用上述读取序列可以保证看到最新的daysmonthsyears值。

有序性

java内存模型中的有序性可以总结为:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指“线程内表现为串行语义”,后半句是指“指令重排序”现象和“工作内存主主内存同步延迟”现象。 ​ 在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序不会影响单线程的运行结果,但是对多线程会有影响。Java提供volatile来保证一定的有序性。最著名的例子就是单例模式里面的DCL(双重检查锁)。另外,可以通过synchronized和Lock来保证有序性,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

volatile变量的特性

保证可见性,不保证原子性

  • 当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去;
  • 这个写会操作会导致其他线程中的缓存无效。

禁止指令重排

  • 重排序操作不会对存在数据依赖关系的操作进行重排序。

    比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运

    行时这两个操作不会被重排序。

  • 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变

    比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系, 所以可能会发

生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。

重排序在单线程下一定能保证结果的正确性,但是在多线程环境下,可能发生重排序,影响结果,下例中的1和2由于不存在数据依赖关系,则有可能会被重排序,先执行status=true再执行a=2。而此时线程B会顺利到达4处,而线程A中a=2这个操作还未被执行,所以b=a+1的结果也有可能依然等于2。

指令重排序

COPYint a = 1;
int b = 2;

a++;
b++;
COPYint a = 1;
a++;

int b = 2;
b++;

然而,当其中一个变量是volatile变量时,指令重排序会出现一个挑战。让我们看看MyClass这个前面Java volatile教程中的例子中出现的类:

COPYpublic class MyClass 
    private int years;
    private int months
    private volatile int days;

    public void update(int years, int months, int days)
        this.years  = years;
        this.months = months;
        this.days   = days;
    

一旦update()方法写入一个值days,新写入的值,以yearsmonths也被写入主存储器。但是,如果JVM重新排序指令,如下所示:

COPYpublic void update(int years, int months, int days)
    this.days   = days;
    this.months = months;
    this.years  = years;

days变量被修改时monthsyears的值仍然写入主内存中,但是这一次它发生在新的值被写入monthsyears之前,也就是这两个变量的旧值会写入主存中,后面两句的写入操作只是写到缓存中。因此,新值不能正确地对其他线程可见。重新排序的指令的语义含义已经改变。

happens before

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

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

COPYclass 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 关系。黑色箭头表示程序顺序规则;橙色箭头表示volatile规则;蓝色箭头表示组合这些规则后提供的happens before保证。

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

Happens-Before 保证

volatile 之前读写

如果读取/写入最初发生在写入volatile变量之前,读取/写入其他变量不能重新排序在写入volatile变量之后。 ​ 写入volatile变量之前的读/写操作被保证 “happen before” 写入volatile变量。请注意,发生在写入volatile变量之后的读/写操作依然可以重排序到写入volatile变量前,只是不能相反。允许从后到前,但不允许从前到后。

volatile 之后读写

如果读/写操作最初发生在读取volatile变量之后,则读取/写入其他变量不能重排序到发生在读取volatile变量之前。请注意,发生在读取volatile变量之前的读/写操作依然可以重排序到读取volatile变量后,只是不能相反。允许从前到后,但不允许从后到前。

上述 “happens-before”规则保证确保volatile关键字的可见性保证在强制执行。

COPYpublic class VolatileTest 
    private volatile int vi = 1;
    private int i = 2;
    private int i2 = 3;

    @Test
    public void test() 
        System.out.println(i);      //1  读取普通变量
        i=3;                        //2  写入普通变量

        //1 2 不能重排序到3之后,操作4可以重排序到3前面
        vi = 2;                     //3  写入volatile变量
        i2 = 5;                     //4  写入普通变量
    

    @Test
    public void test2() 
        System.out.println(i);      //1  读取普通变量

        //3不能重排序到在2前,但1可以重排序到2后
        System.out.println(vi);     //2  读取volatile变量
        System.out.println(i2);     //3  读取普通变量
    

volatile注意事项

volatile 线程不安全

在前面解释的情况中,只有线程1写入共享counter变量,声明counter变量为volatile足以确保线程2始终看到最新的写入值。

实际上,如果写入volatile变量的新值不依赖于其先前的值,则甚至可以多个线程写入共享变量,并且仍然可以在主存储器中存储正确的值。换句话说,就是将值写入共享volatile变量的线程开始并不需要读取其旧值来计算其下一个值。

一旦线程需要首先读取volatile变量的旧值,并且基于该值为共享volatile变量生成新值,volatile变量就不再足以保证正确的可见性。读取volatile 变量和写入新值之间的短时间间隔会产生竞争条件 ,其中多个线程可能读取volatile变量的同一个旧值,然后为其生成新值,并将该值写回主内存 - 覆盖彼此的值。

多个线程递增同一个计数器的情况正是 volatile变量并不安全的情况。以下部分更详细地解释了这种情况。

想象一下,如果线程1将值为0的共享变量counter读入其CPU高速缓存,将其增加到1并且不将更改的值写回主存储器。然后,线程2也从主存储器读取相同的counter变量进入自己的CPU高速缓存,其中变量的值仍为0。然后,线程2也将计数器递增到1,也不将其写回主存储器。这种情况如下图所示:

线程1和线程2现在失去了同步。共享变量counter的实际值应为2,但每个线程的CPU缓存中的变量值为1,而在主内存中,该值仍为0。这是一个混乱!即使线程最终将共享变量counter的值写回主存储器,该值也将是错误的。

保证线程安全

正如我前面提到的,如果两个线程都在读取和写入共享变量,那么使用 volatile关键字是不安全的。 在这种情况下,您需要使用synchronized来保证变量的读取和写入是原子性的。读取或写入一个volatile变量不会阻塞其他线程读取或写入这个变量。为此,您必须在临界区周围使用synchronized关键字。

作为synchronized块的替代方法,您还可以使用java.util.concurrent中众多的原子数据类型。例如,AtomicLong或者 AtomicReference或其他的。

如果只有一个线程读取和写入volatile变量的值,而其他线程只读取这个变量,那么此线程将保证其他线程能看到volatile变量的最新值。如果不将变量声明为volatile,则无法保证。

volatile关键字也可以保证在64位变量上正常使用。

volatile的性能考虑

读取和写入volatile变量会导致变量从主存中读取或写入主存,读取和写入主内存比访问CPU缓存开销更大。访问volatile变量也会阻止指令重排序,这是一种正常的性能提升技术。因此,当您确实需要强制实施变量可见性时,才使用volatile变量。

原理

  • 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  • 它会强制将对缓存的修改操作立即写入主存;
  • 如果是写操作,它会导致其他CPU中对应的缓存行无效。

内存语义

volatile写的内存语义

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

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

volatile读的内存语义

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

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

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

小结
  • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所在修改的)消息。
  • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
  • 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

volatile内存语义的实现

是否能重排序 第二个操作 第二个操作 第二个操作
第一个操作 普通读/写 volatile读 volatile写
普通读/写 NO
volatile读 NO NO NO
volatile写 NO NO

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

  • 当第二个操作为volatile写操作时,不管第一个操作是什么(普通读写或者volatile读写),都不能进行重排序。这个规则确保volatile写之前的所有操作都不会被重排序到volatile写之后;
  • 当第一个操作为volatile读操作时,不管第二个操作是什么,都不能进行重排序。这个规则确保volatile读之后的所有操作都不会被重排序到volatile读之前;
  • 当第一个操作是volatile写操作时,第二个操作是volatile读操作,不能进行重排序。

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

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障(禁止前面的写与volatile写重排序)。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障(禁止volatile写与后面可能有的读和写重排序)。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障(禁止volatile读与后面的读操作重排序)。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障(禁止volatile读与后面的写操作重排序)。

  其中重点说下StoreLaod屏障,它是确保可见性的关键,因为它会将屏障之前的写缓冲区中的数据全部刷新到主内存中。上述内存屏障插入策略非常保守,但它可以保证在任意处理平台,任意的程序中都能得到正确的volatile语义。下面是保守策略(为什么说保守呢,因为有些在实际的场景是可省略的)下,volatile 写操作 插入内存屏障后生成的指令序列示意图:

其中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作对任意处理器可见(把它刷新到主内存)。

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

 上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况忽略不必要的屏障。在JMM基础中就有提到过各个处理器对各个屏障的支持度,其中x86处理器仅会对写-读操作做重排序。

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

    …                    //其他方法

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

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

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

为什么要增强volatile的内存语义

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

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

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

以上是关于8000+字,就说一个字Volatile的主要内容,如果未能解决你的问题,请参考以下文章

万字长文深入剖析volatile(Java)

我如何理解读内存屏障和易失性

java架构之路(多线程)大厂方式手写单例模式

万字长文详细搞懂 volatile 关键字

计算机论文如何写

万字长文!一文彻底搞懂Java—volatile关键字