Volatile学习

参考书籍:[深入理解Java虚拟机:JVM高级特性与最佳实践(第二版) 周志明]
参考:一文解决内存屏障

Volatile学习

Volatile是jvm提供的轻量级的同步机制,具有三大特性:

  1. 保证可见性
  2. 不保证原子性
  3. 禁止指令重排

1.JMM

想要了解volatile的三大特性,首先需要了解JMM(Java Memory Model,Java内存模型)。
在硬件层面,计算机的存储主要分为以下几:硬盘、内存、缓存(cache),其中缓存是用于内存与cpu交流的桥梁,因为cpu的运算速度远高于内存的读写,cpu不能总是等待数据从内存中读取,所以引入缓存,将cpu需要的数据先从内存读取到缓存中,cpu使用时直接从缓存中获取,数据处理完之后放回到缓存中,再将缓存中最新的数据同步到内存中。JMM中也定义了类似于缓存和内存的概念,分别是主内存和工作内存。主内存就相当于内存,多线程共用一份,工作内存就相当于缓存,数据是从主内存中变量的拷贝副本。java程序的运行是承载在线程上的,每个线程都有自己的一份私有数据空间,也就是工作内存,而JMM规定所有变量的值都存储在主内存中,供所有线程共享使用,但是线程对变量的操作必须在自己的工作内存中进行,因此线程在操作变量前,必须将变量的值从主内存拷贝到自己的工作内存,然后对变量进行操作,操作完成后再写回主内存。线程不能直接从操作主内存中的变量,各个线程工作内存中存储着主内存中变量的副本,各线程之间也无法访问对方的工作内存,数据同步必须通过主内存进行。
JMM本身是一种抽象的存在,它描述了一种规范,规定了程序中各种变量的访问方式,包括下面三部分:

  1. 线程解锁前,必须把共享变量的值刷新回主内存
  2. 线程加锁前,必须读取主内存中最新值到自己的工作内存
  3. 加锁解锁是同一把锁

此处说的变量与JAVA编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,而不包括局部变量和方法参数,因为后者是私有的,不会被共享,自然就不存在竞争的问题。
具体情况可参考下面的例子:

  1. 线程1/2/3/都需要操作主内存的student对象的age属性,因此各拷贝一份age的值到自己的工作内存
  2. 此时线程1获得CPU时间,将age的值修改为20
  3. 修改结束,线程1将工作内存中age修改之后的值同步回主内存空间
    在这里插入图片描述

关于主内存和工作内存如何交互,JMM中定义了以下8种操作来完成,虚拟机实现时必须保证下面的每一种操作都是原子的、不可再分的:

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他的线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中的变量副本值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量副本,每当虚拟机遇到一个给变量赋值的字节码指令时将会执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中的变量副本的值传送到主内存中,以便随后的write操作使用。
  • write(写入):作用于主内存变量,它把store操作从工作内存中得到的变量副本的值放入主内存的变量中。

如果要把一个变量从主内存中复制到工作内存,那就要顺序的执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序的执行store和write操作。JMM要求上述两个操作必须是按顺序执行的,而没有保证是连续执行的,也就是说在read和load中间、store和write中间可以插入其他的指令,如对主内存中的变量a、b进行访问时,可能出现read a、read b、load a、load b。除此之外,JMM还规定了在执行上述8种基本操作时必须满足以下规则:

  • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但是主内存不接受的情况出现
  • 不允许一个线程丢弃他最近的assign操作,即变量在工作内存中改变了之后,必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存。
  • 一个新的变量只能在主内存中产生,不允许在工作内存中直接使用一个未被初始化(load和assign)的变量,换句话说,就是对一个变量实施use、store之前,必须先经过了load和assign操作。
  • 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一个线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才能被解锁。
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量之前,需要重新执行load或assign操作初始化变量的值。
  • 如果一个变量实现没有被lock操作锁定,那就不允许对它进行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。
  • 对一个变量执行unlock操作中之前,必须先把次变量的值同步回主内存中(执行store、write操作)。

2.可见性

在前一个例子中,线程1已经将主内存中的age值修改为20,但此时线程2/3并不知道主内存中age的值已修改为了20,因此需要有一种机制,有一个线程修改完自己工作内存中变量的值并同步回主内存后,就需要通知其他线程主内存中的值已被修改,这种机制就是JMM中的可见性
可见性就是指当一条线程修改了这个变量的值,新值对于其他线程是可以立即得知的。volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存中获取,普通变量是做不到这一点的。
下面通过代码证明volatile能够保证被修饰变量的可见性:

/**
 *  此类用于提供一个不被volatile的变量及修改这个变量的方法
 */
public class MyDemo {
    int num = 0;

    public void addToOne(){
        this.num=1;
    }
}
/**
 *  此类用于启动线程修改变量的值,并在main方法线程中查看该变量是否被修改
 */
public class VolatileDemo {
    public static void main(String[] args) {
        MyDemo myDemo = new MyDemo();

        new Thread(()->{
            System.out.println(Thread.currentThread().getName() + ":" + myDemo.num);
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            myDemo.addToOne();
            System.out.println(Thread.currentThread().getName() + ":" + myDemo.num);
        }, "Thread 1").start();

        while(myDemo.num==0){
        }
        System.out.println(Thread.currentThread().getName() + ":" + myDemo.num);
    }
}

运行主函数出现以下结果:
在这里插入图片描述
main方法线程的打印方法并未执行,但是num的值确实被Thread1线程给修改了,说明num修改后,同步到主内存后,并未告知其他线程num值已被修改,因此没有保证可见性,此时给num变量前面加上volatile关键字后再来执行主函数。

/**
 *  此类用于提供一个被volatile的变量及修改这个变量的方法
 */
public class MyDemo {
    volatile int num = 0;

    public void addToOne(){
        this.num=1;
    }
}

在这里插入图片描述
此时main方法线程可以获取到Thread1线程修改后的num值,并进行打印,从而证明volatile能够保证可见性。
拓展:除了volatile之外,synchronized和final也能保证变量的可见性。synchronized的可见性是通过“对一个变量执行unlock操作中之前,必须先把次变量的值同步回主内存中(执行store、write操作)”规则保证的;而被final修饰的字段,在构造器中一但初始化完成,并且构造器没有把“this”的引用传递出去,拿在其他线程中就能看见final字段的值了。

3.原子性

原子性是指某个线程在处理数据时,中间不可以被打断,保证最终一致性。从JMM来说,lock、unlock、read、load、assign、use、store和write操作都是原子性变量操作。
前面演示了volatile能够保证变量的可见性,就会有人认为,被volatile修饰的变量就,在一个线程中修改变量的值,在另一个线程中是立即可见的,因此volatile变量在各个线程中是一致的,是线程安全的。这种理解没有错,但是得出的结论是错的,下面代码来演示volatile并非那么完美。

public class MyDemo {
    volatile int num = 0;
    
    public void addadd(){
        this.num++;
    }
}
public class VolatileDemo {
    public static void main(String[] args) {
        MyDemo myDemo = new MyDemo();

        for (int i = 1; i <= 20; i++){
            new Thread(()->{
                for (int j = 1; j <= 1000 ; j++) {
                    myDemo.addadd();
                }
            }, "Thread " + i).start();
        }
		//判断后台运行线程数是不是大于2,若大于2则表示没有计算完成,后台默认会有两个线程运行:main线程和GC线程
        while (Thread.activeCount() > 2){
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + ":" + myDemo.num);
    }
}

运行结果如下:
在这里插入图片描述
无论执行多少次,都没办法加到20000,但是也有几率加到20000,不过这种几率非常小。发生这种情况的原因就是因为volatile不能保证的变量原子性。
addadd()方法经过编译后,会变成以下字节码:

  public void addadd();
    Code:
       0: aload_0
       1: dup
       2: getfield      #2      // Field num:I
       5: iconst_1
       6: iadd
       7: putfield      #2      // Field num:I
      10: return

其中num++会被分解为如下4步:

       2: getfield      #2      // Field num:I     获取对象字段的值
       5: iconst_1                               //1(int)值入栈。
       6: iadd                                   //将栈顶两int类型数相加,结果入栈
       7: putfield      #2      // Field num:I     给对象字段赋值,既写回主内存

在这里插入图片描述
程序开始执行时,线程1/2/3会分别读取到主内存中num的值为0(对应字节码getfield),然后分别在自己的工作内存中进行自增(对应iconst_1、iadd),在线程1即将向主内存中写入修改后num值为1时(对应putfield),线程2获得CPU时间向主内存中写入修改后num值为1(对应putfield),此时还没来的及告知线程1,线程2已经将num的值修改为1,而线程1再次获得CPU时间,向主内存中写入修改后num值为1,导致了num的值被覆盖为1,从而数据丢失。因此上面的代码中,出现了最终值不是20000的情况。
因此volatile保证变量的可见性必须满足如下两个条件:

  1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程来修改变量的值。
  2. 变量不需要与其他的状态变量共同参与不变约束。

如何解决无法保证原子性问题?
方法1:使用synchronized,这种加锁机制虽然效果很好但是代价太大,消耗性能
方法2:使用JUC中的AtomicInteger

public class MyDemo {
    volatile int num = 0;

    public void addToOne(){
        this.num=1;
    }

    public void addadd(){
        this.num++;
    }

    AtomicInteger atomicInteger = new AtomicInteger();

    public void atomicadd(){
        atomicInteger.getAndIncrement();
    }
}
public class VolatileDemo {
    public static void main(String[] args) {
        MyDemo myDemo = new MyDemo();

        for (int i = 1; i <= 20; i++){
            new Thread(()->{
                for (int j = 1; j <= 1000 ; j++) {
                    myDemo.addadd();
                    myDemo.atomicadd();
                }
            }, "Thread " + i).start();
        }

        while (Thread.activeCount() > 2 ){//判断后台运行线程数是不是大于2,若大于2则表示没有计算完成,后台默认会有两个线程运行:main线程和GC线程
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "num :" + myDemo.num);
        System.out.println(Thread.currentThread().getName() + "atomicInteger :" + myDemo.atomicInteger);

    }
}

在这里插入图片描述
拓展:对于long和double型变量的特殊规则,JMM中的8个操作都具有原子性,但是对于64位类型的数据,允许虚拟机将其读写操作划分为两次32位的操作来进行,即允许虚拟机可以不保证read、load、store和write操作的原子性,这就是long和double的非原子性协定。如果多个线程共享一个未声明为volatile的long或者double类型的变量,并且对其有读写操作,那么有些线程就可能会读取到一个即非原值,也不是其他线程修改的值,而是一个半个变量的数值。不过这种情况几乎看不到,因为JVM把这些变量的读写操作实现为了原子操作。

4.指令重排序

在计算机执行程序过程中,为了提高性能,编译器和处理器会对指令进行重新排序,一般分为三种:编译器优化重排,指令并行重排,系统内存重排。单线程中指令重排序能够保证重排后的执行结果与顺序执行的结果保持一致,在多线程中线程交替执行,指令重排序无法保证重排后的执行结果与顺序执行的结果保持一致,结果无法预测。Java程序中天然的有序性可以总结为一句话:如果在本线程内部观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句指的是“指令重排序”现象和“工作内存与主内存同步延迟”现象。

public void test(){
	int x = 1;//1
	int y = 2;//2
	x = x + 2;//3
	y = x + y;//4
}

上述代码的顺序执行顺序为1 2 3 4,但在指令重排后可能会变成2 1 3 4或1 3 2 4等一些其他情况,但是4和3没办法放在1和2的前面,因为处理器进行重排序是必须考虑指令之间的数据依赖性。
下面是一段事例代码,按理来说会出现指令重排,但是我运行好多次,就是没出现。。。求大神指导一下。

public class MyDemo {
    int c = 0;
    boolean flag = false;
    public void m1(){
        this.c = 1;
        this.flag = true;
        System.out.println("m1:" + this.c);
    }

    public void m2(){
        if(this.flag){
            this.c = this.c + 5;
            System.out.println("m2:" + this.c);
        }
    }
}
public class VolatileDemo {
    public static void main(String[] args) {
        MyDemo myDemo = new MyDemo();
                new Thread(()->{
            myDemo.m1();
        }, "Thread 1").start();
        new Thread(()->{
            myDemo.m2();
        }, "Thread 2").start();
        while (Thread.activeCount() > 2 ){//判断后台运行线程数是不是大于2,若大于2则表示没有计算完成,后台默认会有两个线程运行:main线程和GC线程
            Thread.yield();
        }
        System.out.println(myDemo.c);
    }
}
m1()方法中,如果
this.c = 1;
this.flag = true;
重排为
this.flag = true;
this.c = 1;
当先执行this.flag = true;后直接执行m2()方法
则在m2()方法中,if判断为true,先进行了
this.c = this.c + 5;
然后再输出c为5
但是我执行了好多次都是6..
这块还需要在研究一下

volatile是如何禁止指令重排序
volatile标记,可以解决编译器层面的可见性与重排序问题。被volatile修饰的变量,在编译后,会在该变量的操作(读、写)后增加内存屏障,在对指令进行重排序时,不能把后面的指令重排序到内存屏障之前的位置。
所谓内存屏障是硬件底层一种指令,CPU在接收到内存屏障指令时,会保证屏障之前的代码执行完成后,才会执行屏障之后的代码,即不允许屏障之后的代码指令重排序到屏障之前。内存屏障的另一个作用是强制刷出各种CPU的缓存数据,从而保证变量的修改对线程立即可见。
内存屏障分为如下四类:

屏障类型指令示例说明
LoadLoad BarriersLoad1;LoadLoad;Load2该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作
StoreStore BarriersStore1;StoreStore;Store2该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
LoadStore BarriersLoad1;LoadStore;Store2确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作
StoreLoad BarriersStore1;StoreLoad;Load2该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令

StoreLoad Barriers具有其他三种屏障的效果,因此也被成为全能屏障,但相对于其他屏障,开销也比较大

不进行乱序优化时,处理器的指令执行过程如下:

 1. 指令获取。
 2. 如果输入的运算对象是可以获取的(比如已经存在于寄存器中),这条指令会被发送到合适的功能单元。如果一个或者更多的运算对象在当前的时钟周期中是不可获取的(通常需要从主内存获取),处理器会开始等待直到它们是可以获取的。
 3. 指令在合适的功能单元中被执行。
 4. 功能单元将运算结果写回寄存器。

乱序优化下的执行过程如下:

 1. 指令获取。
 2. 指令被发送到一个指令序列(也称执行缓冲区或者保留站)中。
 3. 指令将在序列中等待,直到它的数据运算对象是可以获取的。然后,指令被允许在先进入的、旧的指令之前离开序列缓冲区。(此处表现为乱序)。
 4. 指令被分配给一个合适的功能单元并由之执行。
 5. 结果被放到一个序列中。
 6. 仅当所有在该指令之前的指令都将他们的结果写入寄存器后,这条指令的结果才会被写入寄存器中。(重整乱序结果)

当插入内存屏障后执行过程如下:

 1. 指令获取。
 2. Cpu接收到屏障指令,将屏障指令及其之后的所有指令放到一个FIFO队列中,然后将屏障之前的指令被发送到一个指令序列(也称执行缓冲区或者保留站)中,允许乱序执行完这些指令,并刷新缓存。
 3. 将FIFO队列中的的指令发送到一个指令序列中,恢复正常的乱序执行

此处参考:一文解决内存屏障

4.1 先行发生原则

如果在JMM中所有的有序性都依靠volatile和synchronize的来保证,那就太麻烦了,其实在Java语言中,有一个先行发生原则,定义了两项操作之间对的顺序关系,如果说操作A先行发生于操作B, 操作A发生的影响能被操作B观察到,影响包括了修改主内存中共享变量的值、发送了消息、调用了方法等等。下面列举一些Java总天然的先行发生关系,无需任何同步代码,可以在编程中直接使用。

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

DCL(Double Check Lock 双端检锁机制)的弊端
在单例模式中,有一种俗称懒汉式的实现方式

public class MySingleton {
    private static MySingleton mySingleton = null;

    private MySingleton(){
        System.out.println(Thread.currentThread().getName() + "产生一个实例");
    }
    public static MySingleton getInstance(){
        if(mySingleton == null){
            mySingleton = new MySingleton();
        }

        return mySingleton;
    }

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

这种实现方式是非线程安全的,执行结果如下:
在这里插入图片描述
会在多个线程中进行实例的初始化,因此需要给 getInstance()方法加锁,但如果给整个getInstance()加锁,代价太大,会导致并发量下降,所以引入DCL,既在加锁前和加锁后分别进行判断

    public static MySingleton getInstance(){
        if(mySingleton == null){
            synchronized (MySingleton.class){
                if(mySingleton == null){
                    mySingleton = new MySingleton();
                }
            }
        }

        return mySingleton;
    }

此时可以保证在99.99%的情况下都是现成安全的,但是剩下的0.01%是会由于指令重排序,导致线程不安全。

mySingleton = new MySingleton();
初始化过程可分为如下三步
1.为新对象变量申请内存空间
2.初始化对象(调用构造方法)
3.mySingleton指向初始化完成的对象
此时23并不存在数据依赖关系,因此可能进行指令重排
1.为新对象变量申请内存空间
3.mySingleton指向初始化完成的对象
2.初始化对象(调用构造方法)
当线程1,因指令重排,走到3.mySingleton指向初始化完成的对象时,线程2获得CPU时间,判断mySingleton不为NULL,
然后直接返回,此时再调用mySingleton中的方法就会产生问题
因此就需要给mySingleton增加volatile修饰符
private static volatile MySingleton mySingleton = null;

5.volatile学习总结

volatile是java提供的轻量级的同步机制,能保证变量的可见性和禁止指令重排序,但是不保证原子性。在以下条件下能够很好的保证变量线程安全:

  1. 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程来修改变量的值。
  2. 变量不需要与其他的状态变量共同参与不变约束。

其他情况下还是需要通过synchronized来保证线程安全。
volatile对比synchronized,synchronized虽然功能强大,能保证大部分情况下的线程安全,但这容易造成synchronized滥用,对系统性能造成较大影响。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值