volatile 学习笔记

全面理解Java内存模型(JMM)及volatile关键字
正确使用 Volatile 变量

Java内存模型

在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步。通信是指线程之间以何种机制来交换信息。同步是指程序中用于控制不同线程间操作发生相对顺序的机制。

线程间的通信机制有两种:共享内存和消息传递。在共享内存的并发模型中,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。在消息传递的并发模型中,线程之间没有公共状态,线程之间必须通过发消息来显示进行通信。

在共享内存并发模型中,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

Java的并发采用的是共享内存模型。

1. 主内存与工作内存

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中。每条线程还有私有的工作内存(Working Memory),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量,不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如下图:

image.png-81kB

内存间交互操作:

  1. Lock(锁定):作用于主内存中的变量,把一个变量标识为一条线程独占的状态。
  2. Read(读取):作用于主内存中的变量,把一个变量的值从主内存传输到线程的工作内存中。
  3. Load(加载):作用于工作内存中的变量,把read操作从主内存中得到的变量的值放入工作内存的变量副本中。
  4. Use(使用):作用于工作内存中的变量,把工作内存中一个变量的值传递给执行引擎。
  5. Assign(赋值):作用于工作内存中的变量,把一个从执行引擎接收到的值赋值给工作内存中的变量。
  6. Store(存储):作用于工作内存中的变量,把工作内存中的一个变量的值传送到主内存中。
  7. Write(写入):作用于主内存中的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。
  8. Unlock(解锁):作用于主内存中的变量,把一个处于锁定状态的变量释放出来,之后可被其它线程锁定。

Java内存模型规定了在执行上述八中基本操作式必须满足如下规则:

  1. 不允许read和load、store和write操作之一单独出现。
  2. 不允许一个线程丢弃最近的assign操作,变量在工作内存中改变了之后必须把该变化同步回主内存中。
  3. 不允许一个线程没有发生过任何assign操作把数据从线程的工作内存同步回主内存中。
  4. 一个新的变量只能在主内存中诞生。
  5. 一个变量在同一时刻只允许一条线程对其进行lock操作,但可以被同一条线程重复执行多次。
  6. 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行read、load操作。
  7. 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作。
  8. 对一个变量执行unlock操作前,必须先把该变量同步回主内存中。

2. 指令重排序

在执行程序时,JVM虚拟机只保证单线程执行情况下,程序的执行结果不会因为指令重排序而改变,但是多线程情况下则不保证。因为为了提高性能,编译器和处理器都常常会对指令做重排序。重排序分为3种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行结束来将多条指令叠加执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

其中编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题,下面分别阐明这两种重排优化可能带来的问题。

编译器重排序:

下面我们简单看一个编译器重排序的例子:

线程 1             线程 2
1: x2 = a ;      3: x1 = b ;
2: b = 1;         4: a = 2 ;

两个线程同时执行,分别有1、2、3、4四段执行代码,其中1、2属于线程1 , 3、4属于线程2 ,从程序的执行顺序上看,似乎不太可能出现x1 = 1 和x2 = 2 的情况,但实际上这种情况是有可能发现的,因为如果编译器对这段程序代码执行重排优化后,可能出现下列情况:

线程 1              线程 2
2: b = 1;          4: a = 2 ; 
1:x2 = a ;        3: x1 = b ;

这种执行顺序下就有可能出现x1 = 1 和x2 = 2 的情况,这也就说明在多线程环境下,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的。

指令重排序:

先了解一下指令重排的概念,处理器指令重排是对CPU的性能优化,从指令的执行角度来说一条指令可以分为多个步骤完成,如下:

  • 取指 (IF)
  • 译码和取寄存器操作数(ID)
  • 执行或者有效地址计算 (EX)
  • 存储器访问 (MEM)
  • 写回 (WB)

CPU在工作时,需要将上述指令分为多个步骤依次执行(注意硬件不同有可能不一样),由于每一个步会使用到不同的硬件操作,比如取指时会只有PC寄存器和存储器,译码时会执行到指令寄存器组,执行时会执行ALU(算术逻辑单元)、写回时使用到寄存器组。为了提高硬件利用率,CPU指令是按流水线技术来执行的,如下:

image.png-30.8kB

从图中可以看出当指令1还未执行完成时,第2条指令便利用空闲的硬件开始执行,这样做是有好处的,如果每个步骤花费1ms,那么如果第2条指令需要等待第1条指令执行完成后再执行的话,则需要等待5ms,但如果使用流水线技术的话,指令2只需等待1ms就可以开始执行了,这样就能大大提升CPU的执行性能。虽然流水线技术可以大大提升CPU的性能,但不幸的是一旦出现流水中断,所有硬件设备将会进入一轮停顿期,当再次弥补中断点可能需要几个周期,这样性能损失也会很大,就好比工厂组装手机的流水线,一旦某个零件组装中断,那么该零件往后的工人都有可能进入一轮或者几轮等待组装零件的过程。因此我们需要尽量阻止指令中断的情况,指令重排就是其中一种优化中断的手段,我们通过一个例子来阐明指令重排是如何阻止流水线技术中断的:

a = b + c ;
d = e + f ;

下面通过汇编指令展示了上述代码在CPU执行的处理过程:

image.png-84kB

  • LW指令 表示 load,其中LW R1,b :表示把b的值加载到寄存器R1中
  • LW R2,c :表示把c的值加载到寄存器R2中
  • ADD 指令:表示加法,把R1 、R2的值相加,并存入R3寄存器中。
  • SW 指令:表示 store 即将 R3寄存器的值保持到变量a中
  • LW R4,e :表示把e的值加载到寄存器R4中
  • LW R5,f :表示把f的值加载到寄存器R5中
  • SUB 指令:表示减法,把R4 、R5的值相减,并存入R6寄存器中。
  • SW d,R6 :表示将R6寄存器的值保持到变量d中

上述便是汇编指令的执行过程,在某些指令上存在X的标志,X代表中断的含义,也就是只要有X的地方就会导致指令流水线技术停顿,同时也会影响后续指令的执行,可能需要经过1个或几个指令周期才可能恢复正常,那为什么停顿呢?这是因为部分数据还没准备好,如执行ADD指令时,需要使用到前面指令的数据R1,R2,而此时R2的MEM操作没有完成,即未拷贝到存储器中,这样加法计算就无法进行,必须等到MEM操作完成后才能执行,也就因此而停顿了,其他指令也是类似的情况。前面阐述过,停顿会造成CPU性能下降,因此我们应该想办法消除这些停顿,这时就需要使用到指令重排了,如下图,既然ADD指令需要等待,那我们就利用等待的时间做些别的事情,如把LW R4,eLW R5,f 移动到前面执行,毕竟LW R4,eLW R5,f执行并没有数据依赖关系,对他们有数据依赖关系的SUB R6,R5,R4指令在R4,R5加载完成后才执行的,没有影响,过程如下:

image.png-213.2kB

正如上图所示,所有的停顿都完美消除了,指令流水线也无需中断了,这样CPU的性能也能带来很好的提升,这就是处理器指令重排的作用。关于编译器重排以及指令重排(这两种重排我们后面统一称为指令重排)相关内容已阐述清晰了,我们必须意识到对于单线程而已指令重排几乎不会带来任何影响,比竟重排的前提是保证串行语义执行的一致性,但对于多线程环境而已,指令重排就可能导致严重的程序轮序执行问题,如下:

class MixedOrder{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1;
        flag = true;
    }

    public void read(){
        if(flag){
            int i = a + 1;
        }
    }
}

如上述代码,同时存在线程A和线程B对该实例对象进行操作,其中A线程调用写入方法,而B线程调用读取方法,由于指令重排等原因,可能导致程序执行顺序变为如下:

 线程A                    线程B
 writer:                 read:
 1:flag = true;           1:flag = true;
 2:a = 1;                 2: a = 0 ; //误读
                          3: i = 1 ;

由于指令重排的原因,线程A的flag置为true被提前执行了,而a赋值为1的程序还未执行完,此时线程B,恰好读取flag的值为true,直接获取a的值(此时B线程并不知道a为0)并执行i赋值操作,结果i的值为1,而不是预期的2,这就是多线程环境下,指令重排导致的程序乱序执行的结果。因此,请记住,指令重排只会保证单线程中串行语义的执行的一致性,但并不会关心多线程间的语义一致性。

as-if-serial语义:

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

数据依赖关系常见的场景:后一个操作依赖于前一个操作的执行结果,此时这两个操作之间就存在数据依赖关系,编译器、处理器等都必须as-if-serial语义,不能对这两个操作进行重排序。

3. happens-before

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。这里提到的“可见”包括修改了内存中共享变量的值、发送了消息、调用了方法等。

下面是Java内存模型下一些“天然”的happens-before关系,这些happens-before关系无须任何同步器协助就已存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,他们就没有顺序性保障,虚拟机可以对它们进行随意地重排序。

  • 程序次序规则:在同一个线程内,按照程序代码顺序,书写在前面的操作happens-before于书写在后面的操作。准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  • 管程锁定规则:一个unlock操作happens-before于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
  • volatile变量规则:对一个volatile变量的写操作happens-before于后面的读操作,这里“后面”是指时间上的先后顺序。
  • 线程启动规则:Thread对象的start()方法happens-before与此线程的每一个动作。
  • 线程终止规则:线程中的所有操作都happens-before与对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  • 线程中断规则:对线程interrupt()方法的调用happens-before于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)happens-before与它的finalize()方法的开始。
  • 传递性:如果操作A happens-before与操作B,操作B happens-before 与操作C,那就可以得出操作A happens-before 与操作C的结论。

volatile

可见性:

当一个变量被定义为volatile之后,它将具备两种特性,第一是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即感知的。换句话说,被volatile修饰的变量,所有线程看到的变量值都是一致的。

根据happens-before规则,对一个volatile变量的写操作happens-before于后面的读操作,这里“后面”是指时间上的先后顺序。

理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。下面通过具体的示例来说明,示例代码如下:

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变量的读
    }
}

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

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变量的单个读/写操作,与一个普通变量的读/写操作都是使用同一个锁来同步,它们之间的执行效果相同,它们都能保证任意线程对这个volatile变量读到值都是最后的写入。

可见性的实现原理:

volatile是如何来保证可见性的呢?让我们在X86处理器下通过工具获取JIT编译器生成的汇编指令来查看对volatile进行写操作时,CPU会做什么事情。Java代码如下:

volatile int instance = new Singleton(); 

转换为汇编代码,如下:

0x01a3de1d: movb $0×0,0×1104800(%esi);
0x01a3de24: lock addl $0×0,(%esp);

有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,通过查IA-32架构软件开发者手册可知,Lock前缀的指令在多核处理器下会引发了两件事情:

  • 将当前处理器缓存行的数据写回到系统内存。
  • 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

禁止指令重排序优化

前文提到过重排序分为编译器重排序和处理器重排序。JMM针对于编译器重排序指定了如下规则:

image.png-47.3kB

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

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

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

屏障指令解释:

指令 序列 解释
LoadLoad屏障 Load1,Loadload,Load2 确保Load1所要读入的数据能够在被Load2和后续的load指令访问前读入
StoreStore屏障 Store1,StoreStore,Store2 确保Store1的数据在Store2以及后续Store指令操作相关数据之前对其它处理器可见(例如向主存刷新数据)
LoadStore屏障 Load1,LoadStore,Store2 确保Load1的数据在Store2和后续Store指令被刷新之前读取

volatile写插入内存屏障后生成的指令序列示意图:

image.png-67.7kB

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

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

volatile读插入内存屏障后生成的指令序列示意图:

image.png-72.5kB

Volatile 为什么不能保证原子性?

volatile只能保证 可见性和禁止指令重排序,但是这并不表明并发对volatile变量的修改能够保证原子性和正确性。 下面,我们看个例子:

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

大家想一下这段程序的输出结果是多少?也许有些朋友认为是10000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字。

可能有的朋友就会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。

这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。

posted @ 2018-05-02 12:21  做个有梦想的咸鱼  阅读(504)  评论(1编辑  收藏  举报