volatile 关键字详解

对于volatile 关键字,最重要的是理解一下三层意思

1.1保证可见性

1.2不保证原子性

1.3禁止指令重排

对于可见性

首先要对JMM有一个认识

2.2 Java内存模型(JMM)

JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

 

volatile 关键字详解_第1张图片

2.2.1 JVM对Java内存模型的实现

  • 在JVM内部,Java内存模型把内存分成了两部分:线程栈区和堆区
    JVM中运行的每个线程都拥有自己的线程栈,线程栈包含了当前线程执行的方法调用相关信息,我们也把它称作调用栈。随着代码的不断执行,调用栈会不断变化。

     

    volatile 关键字详解_第2张图片

     

所有原始类型(boolean,byte,short,char,int,long,float,double)的局部变量都直接保存在线程栈当中,对于它们的值各个线程之间都是独立的。对于原始类型的局部变量,一个线程可以传递一个副本给另一个线程,当它们之间是无法共享的。
堆区包含了Java应用创建的所有对象信息,不管对象是哪个线程创建的,其中的对象包括原始类型的封装类(如Byte、Integer、Long等等)。不管对象是属于一个成员变量还是方法中的局部变量,它都会被存储在堆区。
一个局部变量如果是原始类型,那么它会被完全存储到栈区。 一个局部变量也有可能是一个对象的引用,这种情况下,这个本地引用会被存储到栈中,但是对象本身仍然存储在堆区。
对于一个对象的成员方法,这些方法中包含局部变量,仍需要存储在栈区,即使它们所属的对象在堆区。 对于一个对象的成员变量,不管它是原始类型还是包装类型,都会被存储到堆区。Static类型的变量以及类本身相关信息都会随着类本身存储在堆区。

 

volatile 关键字详解_第3张图片

2.3 Java内存模型带来的问题

2.3.1 可见性问题

CPU中运行的线程从主存中拷贝共享对象obj到它的CPU缓存,把对象obj的count变量改为2。但这个变更对运行在右边CPU中的线程不可见,因为这个更改还没有flush到主存中:

 

volatile 关键字详解_第4张图片

 

各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存操作后再写回主内存中的.
这就可能存在一个线程AAA修改了共享变量X的值还未写回主内存中时 ,另外一个线程BBB又对内存中的一个共享变量X进行操作,但此时A线程工作内存中的共享比那里X对线程B来说并不不可见.这种工作内存与主内存同步延迟现象就造成了可见性问题.

通过前面对JMM的介绍,我们知道
各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存操作后再写回主内存中的.
这就可能存在一个线程A修改了共享变量X的值还未写回主内存中时 ,另外一个线程B又对内存中的一个共享变量X进行操作,但此时A线程工作内存中的共享比那里X对线程B来说并不不可见.这种工作内存与主内存同步延迟现象就造成了可见性问题.

Java volatile 关键字 在Java中,valatile关键字被用来标识一个Java变量 ”being stored in main memory“。更准确地讲,每次读取一个volatile变量都会从主存中去读,而不是从CPU缓存中;每次修改一个volatile变量都会将其写入到主存中,而不仅仅是CPU缓存中。

  在一个多线程的程序中,多个线程操作一个非volatile修饰的变量,每个线程可能会把变量从主存复制到CPU缓存中去,处于性能原因,如果你的计算机装有多个CPU,每个线程可能运行在不同的CPU上。这也就意味着,每个线程可能将变量拷贝到不同CPU到的CPU缓存中去。

 

对于非volatile变量,Java虚拟机将数据从主存读入到CPU缓存,或者从CPU缓存写回到主存中并没有任何保证。

看这样的一个例子,多个线程访问一个包含一个counter变量的共享对象,声明如下:

public class SharedObject{
    public int counter = 0;
}

线程1读取共享counter变量(值为0)到它的CPU的缓存中,然后将counter增加到1,没有将变化后的值写回到主存中。线程2做了和线程1同样的行为。线程1和线程2并没有进行同步。counter变量实际的值应该为2,但是每个线程在它们的CPU缓存中读到的变量的值为1,在主存中counter变量的值还是0.尽管线程最终会将counter变量的值写回到主存中,但值将会是错误的。

如果将counter声明为volatile,JVM会保证每次都从主存中读取变量,每次修改过值后都会写回到主存中。声明如下:

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

在某些场景下,将变量简单声明为volatile不能保证原子性。

 

在两个线程都读和写同一个变量时,简单的将变量声明为volatile是不够的。 在CPU1中,线程1可能将counter变量(值为0)读入到一个CPU寄存器中。与此同时(或者稍靠后),在CPU2中,线程2可能将counter变量(值为0)读入到一个CPU寄存器中。两个线程都从主存中读取变量。现在,都将变量的值加1,然后写回到主存中。它们都增加counter的寄存器版本为1,都将值1写回到主存中。经过两次自增后counter变量的值应该为2.

这个多线程的问题就是没有看到变量最后被修改后的值因为值还没有被写回到主存中,一个线程的更新对其它线程是不可见的。

java重排序问题

Java内存模型中的重排序

  • 在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。

2.4.1 重排序类型

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

2.4.2 重排序与依赖性

  • 数据依赖性
    如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列3种类型,这3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。

     

    volatile 关键字详解_第5张图片

  • 控制依赖性
    flag变量是个标记,用来标识变量a是否已被写入,在use方法中比变量i依赖if (flag)的判断,这里就叫控制依赖,如果发生了重排序,结果就不对了。

     

    volatile 关键字详解_第6张图片

  • as-if-serial
    不管如何重排序,都必须保证代码在单线程下的运行正确,连单线程下都无法正确,更不用讨论多线程并发的情况,所以就提出了一个as-if-serial的概念。
    as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。(强调一下,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。)但是,如果操作之间不存在数据依赖关系,这些操作依然可能被编译器和处理器重排序。

     

    volatile 关键字详解_第7张图片

     

    1和3之间存在数据依赖关系,同时2和3之间也存在数据依赖关系。因此在最终执行的指令序列中,3不能被重排序到1和2的前面(3排到1和2的前面,程序的结果将会被改变)。但1和2之间没有数据依赖关系,编译器和处理器可以重排序1和2之间的执行顺序。
    asif-serial语义使单线程下无需担心重排序的干扰,也无需担心内存可见性问题。

2.4.3 并发下重排序带来的问题

volatile 关键字详解_第8张图片

 

这里假设有两个线程A和B,A首先执行init ()方法,随后B线程接着执行use ()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?答案是:不一定能看到。
由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作1和操作2重排序时,可能会产生什么效果?操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入,这时就会发生错误!
当操作3和操作4重排序时会产生什么效果?
在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。当操作3的条件判断为真时,就把该计算结果写入变量i中。猜测执行实质上对操作3和4做了重排序,问题在于这时候,a的值还没被线程A赋值。在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

2.4.4 解决在并发下的问题

1)内存屏障——禁止重排序

volatile 关键字详解_第9张图片

 

Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按我们预想的流程去执行。
1、保证特定操作的执行顺序。
2、影响某些数据(或则是某条指令的执行结果)的内存可见性。

编译器和CPU能够重排序指令,保证最终相同的结果,尝试优化性能。插入一条Memory Barrier会告诉编译器和CPU:不管什么指令都不能和这条Memory Barrier指令重排序。
Memory Barrier所做的另外一件事是强制刷出各种CPU cache,如一个Write-Barrier(写入屏障)将刷出所有在Barrier之前写入 cache 的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。
JMM把内存屏障指令分为4类,解释表格,StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。

2)临界区(synchronized?)

volatile 关键字详解_第10张图片

 

临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

2.5 Happens-Before

用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系 。

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second) 。

1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。(对程序员来说)

2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序是允许的(对编译器和处理器 来说)

在Java 规范提案中为让大家理解内存可见性的这个概念,提出了happens-before的概念来阐述操作之间的内存可见性。对应Java程序员来说,理解happens-before是理解JMM的关键。JMM这么做的原因是:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和as-if-serial语义是一回事。as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。

volatile 关键字详解_第11张图片

  • Happens-Before规则-无需任何同步手段就可以保证的
    1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
    2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
    3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
    4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
    5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
    6)join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
    7 )线程中断规则:对线程interrupt方法的调用happens-before于被中断线程的代码检测到中断事件的发生。

3.实现原理

  • 内存语义:可以简单理解为 volatile,synchronize,atomic,lock 之类的在 JVM 中的内存方面实现原则

3.1 volatile的内存语义

volatile变量自身具有下列特性:

  • 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

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

 

volatile 关键字详解_第12张图片

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

 

volatile 关键字详解_第13张图片

volatile重排序规则:

 

volatile 关键字详解_第14张图片

volatile内存语义的实现——JMM对volatile的内存屏障插入策略:
在每个volatile写操作的前面插入一个StoreStore屏障。在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的后面插入一个LoadLoad屏障。在每个volatile读操作的后面插入一个LoadStore屏障。

 

volatile 关键字详解_第15张图片

 

volatile 关键字详解_第16张图片

3.1.1 volatile的实现原理

有volatile变量修饰的共享变量进行写操作的时候会使用CPU提供的Lock前缀指令:

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

 

volatile 实际上,volatile保证了两点:

  • 内存可见性

  • 防止指令重排序

  •   
    volatile 关键字详解_第17张图片

volatile 关键字详解_第18张图片

volatile的适用场景 如果一个线程读和写一个volatile变量,其它线程只读取这个变量,然后读线程可以确保看到这个变量最后的被修改的值。如果这个变量不被修改为volatile,将没有这样的担保。

volatile的性能考虑

  • 发生在主存上的读写操作的代价要高于访问CPU缓存

  • volatile阻止了指令重排序这种性能优化技术

 

上面介绍了volitile的概念

下面进行举例说明

 

 

import java.util.concurrent.TimeUnit;

/**
 * 1 验证volatile的可见性
 * 1.1 假如 int number = 0; number变量之前根本没有添加volatile关键字修饰,没有可见性
 * 1.2 添加了 volatile int number = 0; 可以解决可见性问题。
 * 
 * 2 验证volatile不保证原子性
 * 2.1 原子性指的是什么意思?
 *     不可分割,完整性,也即某个线程正在做某个具体业务时,中间不可以被加塞或者被分割。需要整体完整,要么同时成功,要么同时失败。
 * 
 * 2.2 volatile不保证原子性的案例演示
 * 
 */
public class VolatileDemo {

    public static void main(String[] args) {
        MyData myData = new MyData();

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t come in");

            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            myData.add();
            System.out.println(Thread.currentThread().getName() + "\t updated number value: " + myData.number);
        }, "AAA").start();

        // 第2个线程就是我们的main线程
        while (myData.number == 0) {
            // main线程就一直在这里等待循环,直到number值不再等于0
        }
        System.out.println(Thread.currentThread().getName() + "\t mission is over");
    }

}

class MyData {
    // int number = 0;
    volatile int number = 0;

    public void add() {
        this.number = 60;
    }

}

class VolatileDemo {
     int num = 0;
    

    public void updateNum(int c ) {
        this.num = c;
    }

public void test(){

  Thread t = new Thread(()->{

   updateNum( );

})

while(num==0){

  }

}   

public static void main(String args){

 VolatileDemo vd = new VolatileDemo();

vd.test();

}
}

此程序可能永远都结束不了,因为变量对于主线程是不可见的,数据改变了,主线程不可见,循环继续

变量加上voletile 程序改变循环停止。volatile int num = 0;

//num++不保证原子性  可以多建几个线程循环调用求和,和期望值不符合,可以加锁synchronize 但此锁较重可以用原子类解决

 

public static void volatileTest2() {
        VolatileDemo volatileDemo = new VolatileDemo();
        // 创建20个线程,每个线程累加1000次
        for (int i = 1; i <= 20; i++) {
            new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    volatileDemo.addNum();
                    volatileDemo.addAtomInteger();
                }
            }, "子线程" + String.valueOf(i)).start();
        }
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println("num = " + volatileDemo.num);
        System.out.println("num = " + volatileDemo.atomicInteger);
    }


    public void addNum() {
        num++;
    }

 AtomicInteger atomicInteger = new AtomicInteger();

    //保证原子性
    public void addAtomInteger() {
        atomicInteger.getAndIncrement();
    }

/**
     * volatile保证可见性
     */
    public static void volatileTest() {
        VolatileDemo dataDemo = new VolatileDemo();
        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            dataDemo.updateNum(60);
            System.out.println("num = " + dataDemo.num);
        }, "AAA").start();
        while (dataDemo.num == 0) {
        }
        System.out.println("main 执行完成");
    }
}
 

volitile应用

单例

public class SingletonDemo {

    private static volatile SingletonDemo instance=null;
    private SingletonDemo(){
        System.out.println(Thread.currentThread().getName()+"\t 构造方法");
    }

    /**
     * 双重检测机制
     * @return
     */
    public static SingletonDemo getInstance(){
        if(instance==null){
            synchronized (SingletonDemo.class){
                if(instance==null){
                    instance=new SingletonDemo();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        for (int i = 1; i <=10; i++) {
            new Thread(() ->{
                SingletonDemo.getInstance();
            },String.valueOf(i)).start();
        }
    }
}

 

DCL(双端检锁) 机制不一定线程安全,原因是有指令重排的存在,加入volatile可以禁止指令重排
  原因在于某一个线程在执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化.
instance=new SingletonDem(); 可以分为以下步骤(伪代码)
 
memory=allocate();//1.分配对象内存空间
instance(memory);//2.初始化对象
instance=memory;//3.设置instance的指向刚分配的内存地址,此时instance!=null 
 
步骤2和步骤3不存在数据依赖关系.而且无论重排前还是重排后程序执行的结果在单线程中并没有改变,因此这种重排优化是允许的.
memory=allocate();//1.分配对象内存空间
instance=memory;//3.设置instance的指向刚分配的内存地址,此时instance!=null 但对象还没有初始化完.
instance(memory);//2.初始化对象
但是指令重排只会保证串行语义的执行一致性(单线程) 并不会关心多线程间的语义一致性
所以当一条线程访问instance不为null时,由于instance实例未必完成初始化,也就造成了线程安全问题.

你可能感兴趣的:(多线程)