volatile实现原理

image.png

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

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

实际上,自Java 5以来,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缓存中。这在这里说明:


image.png

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

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

public class SharedObject {
    public int counter = 0;
}

再想象一下,只有线程1对counter变量进行增加操作,但线程1和线程2都可能读取变量counter。
如果counter变量未声明volatile,则无法保证何时将counter变量的值从CPU缓存写回主存储器。这意味着,CPU高速缓存中的counter变量值可能与主存储器中的变量值不同。这种情况如下所示:


image.png

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

volatile可见性保证
Java volatile关键字旨在解决变量可见性问题。通过使用volatile声明counter变量,对变量counter的所有写操作都将立即写回主存储器。此外,counter变量的所有读取都将直接从主存储器中读取。

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

public class SharedObject {
    public volatile int counter = 0;
}

声明变量为volatile,对其他线程写入该变量 保证了可见性

在上面给出的场景中,一个线程(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可见的所有变量也将从主存储器重新读取。

代码示例

public 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;
    }
}

udpate()方法写入三个变量,其中只有days是volatile变量。

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

当读取years,months和days的值你可以这样做:

public 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的值时,后续months 和years值的读取也会从主存储器中读取。因此使用上述读取序列可以保证看到最新的days,months和years值。


有序性

即程序执行的顺序按照代码的先后顺序执行。

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


volatile变量的特性

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

image.png
  • 当写一个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。


hapens-before原则

为了解决指令重排序挑战,除了可见性保证之外,Java volatile关键字还提供“happens-before”保证。

1.代码执行顺序原则,代码的执行顺序,编写在前面的发生在编写在后面的之前
2.锁原则,unlock后于lock
3.线程启动原则,start方法优先于run方法
4.对象销毁原则,初始化必须发生在finalize之前
5.线程终结原则,所有操作发生在线程死亡之前
6.volatile修饰的变量,写操作优先于读操作
7.传递性原则,操作A先于B,B先于C,那么A肯定先于C
8.线程中断原则,interrupt这个动作,必须发生在捕获该动作之前

八大原则
代码执行顺序,锁原则,线程原则(3个,start优先于run,interrupt优先于捕获,线程终结在最后),对象的生命周期(初始化优先于finnalize),传递性原则,volatile

volatile 之前读写

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

volatile 之后读写

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

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

public 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 线程不安全

即使volatile关键字保证volatile变量的所有读取直接从主存储器读取,并且所有对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,也不将其写回主存储器。这种情况如下图所示:

image.png

线程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变量。

原理

volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

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

内存语义

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

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

image.png

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

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

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


image.png

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

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

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

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

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

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

volatile内存语义的实现

前文我们提到过重排序分为编译器重排序和处理器重排序。为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。下面是JMM针对编译器制定的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 写操作 插入内存屏障后生成的指令序列示意图:

image

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

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

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

image

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

下面我们通过具体的示例代码来说明

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() 方法,编译器在生成字节码时可以做如下的优化:

image

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

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

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

image

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

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

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

image

在旧的内存模型中,当 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变量。

你可能感兴趣的:(volatile实现原理)