Java volatile详解

一、概念

        volatile是Java提供的一种轻量级的同步机制,在并发编程中,它也扮演着比较重要的角色。同synchronized相比(synchronized通常称为重量级锁),volatile更轻量级,相比使用synchronized所带来的庞大开销,倘若能恰当的合理的使用volatile,自然是美事一桩。volatile的内存语义可以归为下面两句话:

  • 当写一个volatile变量时,JMM( Java Memory Model,即内存模型,简称 JMM )会把该线程对应的本地内存中的共享变量值立即刷新回主内存中
  • 当读一个volatile变量时,JMM( Java Memory Model,即内存模型,简称 JMM )会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量

所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。

二、Java内存模型的3个特性

1.可见性

        可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉。通常,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。

        可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。 也就是一个线程修改的结果,另一个线程马上就能看到。比如:用 volatile 修饰的变量,就会具有可见性。volatile 修饰的变量不允许线程内部缓存和重排序,即直接修改内存。所以对其他线程是可见的。但是这里需要注意一个问题,volatile 只能让被他修饰内容具有可见性,但不能保证它具有原子性。比如 volatile int a = 0; 之后有一个操作 a++;这个变量a具有可见性,但是a++ 依然是一个非原子操作,也就是这个操作同样存在线程安全问题。

        在 Java 中 volatilesynchronized 和 final 实现可见性。

2.原子性

        原子是世界上的最小单位,具有不可分割性。 原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。

        比如 a=0;(a 非 long 和 double 类型) 这个操作是不可分割的,那么我们说这个操作是原子操作。

        再比如:a++; 这个操作实际是 a = a + 1;是可分割的,所以他不是一个原子操作。

        非原子操作都会存在线程安全问题,需要我们使用同步技术(sychronized)来让它变成一个原子操作。一个操作是原子操作,那么我们称它具有原子性。

        java 的 concurrent 包下提供了一些原子类,我们可以通过阅读API来了解这些原子类的用法。比如:AtomicInteger、AtomicLong、AtomicReference 等。

        在 Java 中 synchronized 和在 lock、unlock 中操作保证原子性。

         多线程环境下,"数据计算"和"数据赋值"操作可能多次出现,即操作非原子。见上图,若数据在加载之后,若主内存count变量发生修改之后,由于线程工作内存中的值在此前已经加载,从而不会对变更操作做出相应变化,即私有内存和公共内存中变量不同步,进而导致数据不一致。
        对于volatile变量,JVM只是保证从主内存加载到线程工作内存的值是最新的,也就是数据加载时是最新的。由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改共享变量的场景必须使用加锁同步。

        以i++为例,不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,
分3步完成。如果第二个线程在第一个线程读取旧值和写回新值期间(上图所指三步期间)读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于add方法必须使用synchronized修饰,以便保证线程安全。

3.有序性

        Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性,volatile 是因为其本身包含 “禁止指令重排” 的语义,synchronized 是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得的,此规则决定了持有同一个对象锁的两个同步块只能串行执行。

三、volatile变量的读写过程

        Java内存模型中定义的8种工作内存与主内存之间的原子操作,分别是:read(读取) -> load(加载) -> use(使用)-> assign(赋值) -> store(存储) -> write(写入) -> lock(锁定)-> unlock(解锁)。

  •  read:作用于主内存,将变量的值从主内存传输到工作内存,主内存到工作内存
  • load:作用于工作内存,将read从主内存传输的变量值放入工作内存变量副本中,即数据加载
  • use:作用于工作内存,将工作内存变量副本的值传递给执行引擎,每当JVM遇到需要该变量的字节码指令时会执行该操作
  • assign:作用于工作内存,将从执行引擎接收到的值赋值给工作内存变量,每当JVM遇到一个给变量赋值字节码指令时会执行该操作
  • store:作用于工作内存,将赋值完毕的工作变量的值写回给主内存
  • write:作用于主内存,将store传输过来的变量值赋值给主内存中的变量

        由于上述只能保证单条指令的原子性,针对多条指令的组合性原子保证,没有大面积加锁,所以,JVM提供了另外两个原子指令:

  • lock:作用于主内存,将一个变量标记为一个线程独占的状态,只是写时候加锁,就只是锁了写变量的过程。
  • unlock:作用于主内存,把一个处于锁定状态的变量释放,然后才能被其他线程占用

        这里需要注意的是,read-load-use和assign-store-write是两个不可分割的原子操作,但是在use和assign之间依然有极小的一段真空期,有可能变量会被其他线程读取,导致写丢失一次。

 案例参考见下面的“2.原子性案例分析”。

四、内存屏障

1.volatile凭什么可以保证可见性和有序性?

        内存屏障

2.指令重排

        计算机在执行程序时,为了提高性能,编译器和处理器尝尝会对指令重排,一般分为一下三种:

        在单线程环境里边确保程序最终执行结果和代码顺序执行的结果一致。处理器在进行重排序时必须要考虑指令之间的数据依赖性,不存在数据依赖关系,可以重排序; 存在数据依赖关系,禁止重排序 。多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

数据依赖性:若两个操作访问同一变量,且这两个操作中有一个为写操作,此时两操作间就存在数据依赖性。

重排序的分类和执行流程:

1> 指令级并行的重排序:处理器使用指令级并行技术来将多条指令重叠执行,若不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

2> 编译器优化的重排序:编译器在不改变单线程串行语义的前提下,可以重新调整指令的执行顺序 

3> 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是乱序执行
先看一段代码:

 		int a = 1; // 1
        int b = 2; // 2
        a = a + 1; // 3
        b = a * a; // 4

1> 那么程序按照1234的顺序可以正常输出,不影响我们最初的逻辑。
2> 按照2134,1324顺序和1234也是没有违背最初的逻辑。
3> 但是如果说4123的顺序可不可以呢?肯定是不行的,此时a,b变量都没声明,没有办法进行使用(依赖性)。
        也就是说在多线程的情况下,2的情况属于指令重排的,3的情况是不会指令重排的,因为会影响原有的逻辑和输出结果。

        那么为什么要禁止指令重排呢?下边在看一段代码:

	int a = 1;
    boolean flag = false;

    public void method1() {
        flag = true;
        a = 1;
    }

    public void method2() {
        while (flag) {
            a = a + 1;
        }
    }

上边这段代码中:
        int a = 1;
        boolean flag = false;

和method1()中:

        flag = true;
        a = 1;
        都仅仅是变量的声明,赋值,也就是说会导致指令重排,但是调用mehthod2()的时候就会出现变量a还没有重新赋值导致了结果错误,所以有时候要禁止指令重排。

volatile有关禁重排的行为:

在这里插入图片描述

volatile关键字禁止指令重排序有两层意思:

  1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

  2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。

  可能上面说的比较绕,举个简单的例子:

//x、y为非volatile变量
//flag为volatile变量
 
x = 2;        //语句1
y = 0;        //语句2
flag = true;  //语句3
x = 4;         //语句4
y = -1;       //语句5

        由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

        看如下一个例子:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2
 
//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

        上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

        这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕。

3.什么是内存屏障

        内存屏障(也称内存栅栏,内存栅障,屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,使得此点之前的所有读写操作都执行后才可以开始执行此点之后的操作),避免代码重排序。

        内存屏障其实就是一种JVM指令,Java内存模型的重排规则会要求Java编译器在生成JVM指令时插入特定的内存屏障指令,通过这些内存屏障指令,volatile实现了Java内存模型中的可见性和有序性,但volatile无法保证原子性。内存屏障之前的所有写操作都要回写到主内存,内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。 因此重排序时,不允许把内存屏障之后的指令重排序到内存屏障之前。

  • 内存屏障之前的所有写操作都要回写到主内存
  • 内存屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果(实现了可见性)。

粗分主要是以下两种屏障:

  • 读屏障(Load Memory Barrier) :在读指令之前插入读屏障,让工作内存或CPU高速缓存当中的缓存数据失效,重新回到主内存中获取最新数据。
  • 写屏障(Store Memory Barrier) :在写指令之后插入写屏障,强制把写缓冲区的数据刷回到主内存中。

4.volatile流程

1>从主存读取volatile变量到本地副本

2>修改变量值

3>本地副本值写回主存

4>插入内存屏障,即lock指令。内存屏障会让其他线程每次读取强制从主存读取(让其他线程可见)

 可见volatile并没有加锁,1234不是原子性的。

五、双重锁式单例模式

public class Singleton {
    // volatile 保证可见性和禁止指令重排序
    private static volatile Singleton singleton;

    public static Singleton getInstance() {
        // 第一次检查
        if (singleton == null) {
          // 同步代码块
          synchronized(this.getClass()) {
              // 第二次检查
              if (singleton == null) {
                    // 对象的实例化是一个非原子性操作
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

上面代码中, new Singleton() 是一个非原子性操作,对象实例化分为三步操作:
(1)分配内存空间,
(2)初始化实例,
(3)返回内存地址给引用。

所以,在使用构造器创建对象时,编译器可能会进行指令重排序。假设线程 A 在执行创建对象时,(2)和(3)进行了重排序,如果线程 B 在线程 A 执行(3)时拿到了引用地址,并在第一个检查中判断 singleton != null 了,但此时线程 B 拿到的不是一个完整的对象,在使用对象进行操作时就会出现问题。

所以,这里使用 volatile 修饰 singleton 变量,就是为了禁止在实例化对象时进行指令重排序。

六、案例分析

1.可见性案例分析

public class volatileDemo1 {
    static boolean flag = true;

    public static void main(String[] args) {
        new Thread(()->{
            System.out.println(Thread.currentThread().getName()+"\t -----come in");
            try {
                //睡眠2秒
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = false;
        },"t1").start();

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

            }
            System.out.println(Thread.currentThread().getName()+"\t -----flag被设置为false,程序停止");
        },"t2").start();
    }
}

运行结果:

         上面这个例子,模拟在多线程环境里,t1线程对flag共享变量修改的值能否被t2可见,即是否输出 “-----flag被设置为false,程序停止” 这句话。

        答案是没有输出。其实可以理解,因为倘若在单线程模型里,因为先行发生原则之happens-before,自然是可以正确保证输出的;但是在多线程模型中,是没法做这种保证的。因为对于共享变量flag来说,线程t1的修改,对于线程t2来讲,是"不可见"的。也就是说,线程t2此时可能无法观测到flage已被修改为false。所谓可见性,是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更,JMM规定了所有的变量都存储在主内存中。很显然,上述的例子中是没有办法做到内存可见性的。

        而volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取,从而保证了可见性。
改进版本:

package com.xj.algorithm;

import java.util.concurrent.TimeUnit;

public class volatileDemo1 {
    //将flag设置成volatile变量
    static volatile boolean flag = true;

    public static void main(String[] args) {
        new Thread(()->{
            System.out.println(Thread.currentThread().getName()+"\t -----come in");
            try {
                //睡眠2秒
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = false;
        },"t1").start();

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

            }
            System.out.println(Thread.currentThread().getName()+"\t -----flag被设置为false,程序停止");
        },"t2").start();
    }
}

运行结果:

若不加volatile修饰为何t2 看不到被 t1线程修改为 false的flag的值?

  • t1线程修改了flag之后没有将其刷新回住内存,所以t2线程获取不到。
  • 主线程将flag刷新到了主内存,但是t2一直读取的是自己工作内存中flag的值,没有去主内存中更新获取flag最新的值。

使用volatile修饰共享变量后,被volatile修饰的变量有以下特点:

  • 线程中读取时候,每次读取都会去主内存中读取共享变量最新的值,然后将其复制到工作内存;
  • 线程中修改了工作内存中变量的副本,修改之后回立即刷新到主内存。

2.原子性案例分析

package com.xj.algorithm;

import java.util.concurrent.TimeUnit;

class MyNumber {
    //使用volatile修饰变量
    volatile int number;

    public void addPlusPlus() {
        number++;
    }
}
public class VolatileDemo2 {
    public static void main(String[] args) {
        //循环10次,看结果是否一致
        for(int j = 1;j <=10; j++){
            MyNumber myNumber = new MyNumber();
            for (int i = 0; i < 10; i++) {
                new Thread(()->{
                    for (int i1 = 0; i1 < 1000; i1++) {
                        myNumber.addPlusPlus();
                    }
                }).start();
            }
            try {
                //睡眠1秒
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("第" + j + "次执行结果:" + myNumber.number);
        }
    }
}

运行结果: 

        其实正确输出应该是10000,但是这里运行多次会发现,经常会出现小于10000的情况。

        为什么呢?对于volatile变量具备可见性,JVM只是保证从主内存加载到线程工作内存的值是最新的,也仅是数据加载时是最新的。但是在多线程环境下,“数据计算” 和 “数据赋值” 操作可能多次出现,若数据在加载之后,若主内存volatile修饰变量发生修改之后,线程工作内存中的操作将会作废去读主内存的最新值,操作出现丢失问题。即 各线程工作内存和主内存中变量不同步,进而导致数据不一致。由此可见volatile解决的是变量读时的可见性问题,但无法保证原子性,对于多线程修改主内存共享变量的场景必须使用加锁同步。

改进方法一:使用synchronized修饰方法保证原子性

package com.xj.algorithm;

import java.util.concurrent.TimeUnit;

/**
 * @Author: xjfu
 * @Create: 2023/08/22 22:47
 * @Description:
 */
class MyNumber {
    int number;
    //改进方法一:使用synchronized修饰方法保证原子性
    public synchronized void addPlusPlus() {
        number++;
    }
}
public class VolatileDemo2 {
    public static void main(String[] args) {
        //循环10次,看结果是否一致
        for(int j = 1;j <=10; j++){
            MyNumber myNumber = new MyNumber();
            for (int i = 0; i < 10; i++) {
                new Thread(()->{
                    for (int i1 = 0; i1 < 1000; i1++) {
                        myNumber.addPlusPlus();
                    }
                }).start();
            }
            try {
                //睡眠1秒
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("第" + j + "次执行结果:" + myNumber.number);
        }
    }
}

运行结果:

改进方法二:使用Lock

package com.xj.algorithm;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @Author: xjfu
 * @Create: 2023/08/22 22:47
 * @Description:
 */
class MyNumber {

    int number;
    //方法二:使用Lock
    Lock lock = new ReentrantLock();

    public void addPlusPlus() {
        lock.lock();
        try{
            number++;
        }finally {
            lock.unlock();
        }
    }
}
public class VolatileDemo2 {
    public static void main(String[] args) {
        //循环10次,看结果是否一致
        for(int j = 1;j <=10; j++){
            MyNumber myNumber = new MyNumber();
            for (int i = 0; i < 10; i++) {
                new Thread(()->{
                    for (int i1 = 0; i1 < 1000; i1++) {
                        myNumber.addPlusPlus();
                    }
                }).start();
            }
            try {
                //睡眠1秒
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("第" + j + "次执行结果:" + myNumber.number);
        }
    }
}

运行结果:

改进方法三:使用AtomicInteger

package com.xj.algorithm;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

class MyNumber {

    //方法三 :使用AtomicInteger
    AtomicInteger number = new AtomicInteger();

    public void addPlusPlus() {
        number.getAndIncrement();
    }
}
public class VolatileDemo2 {
    public static void main(String[] args) {
        //循环10次,看结果是否一致
        for(int j = 1;j <=10; j++){
            MyNumber myNumber = new MyNumber();
            for (int i = 0; i < 10; i++) {
                new Thread(()->{
                    for (int i1 = 0; i1 < 1000; i1++) {
                        myNumber.addPlusPlus();
                    }
                }).start();
            }
            try {
                //睡眠1秒
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("第" + j + "次执行结果:" + myNumber.number);
        }
    }
}

运行结果:

七、volatile使用场景

1> 单一赋值可以,但含复合运算赋值不可以(i++)之类的。

volatile int a = 10;

2> 状态标志,判断业务是否结束

3> 开销较低的读,写锁策略

使用:当读远多于写,结合使用内部锁和volatile变量来成少同步的开销  

理由:利用volatile保证读取操作的可见性;利用synchronized保证复合操作的原子性

    //当读远多于写,结合使用内部锁和volatile变量来成少同步的开销
    public class Counter{
        private volatile int value;
        public int getValue(){
            return value; //利用volatile保证读取操作的可见性
        }
        public synchronized int increment(){
            return value++; //利用synchronized保证复合操作的原子性
        }
    }

4> DCL双端锁的发布。

多线程环境下,由于重排序,该对象可能还未完成初始化就被其他线程读取。

八、参考

1.java并发(3)内存模型 - 简书

2.java 的volatile 关键字_猎人在吃肉的博客-CSDN博客

3.Java关键字volatile全面解析和实例讲解_壹升茉莉清的博客-CSDN博客

4.volatile详解_枫陵的博客-CSDN博客

5.https://www.cnblogs.com/dolphin0520/p/3920373.html

6.【Java基础】volatile关键字_Android西红柿的博客-CSDN博客

7.Java中的volatile_volatile j_HGW689的博客-CSDN博客

8.Java多线程-内存模型(JMM)-详解 - 自学精灵

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值