volatile关键字详解

  上一章节说了同步和异步机制中的同步 synchronized 相关使用。这次说说 volatile 关键字,对于 synchronized 来说主要是针对同步块或者方法。而 volatile 修饰的是变量。

  volatile关键字虽然从字面上理解起来比较简单,但是要用好不是一件容易的事情。由于volatile关键字是与Java的内存模型有关的,因此在讲述volatile关键之前,我们先来了解一下与内存模型相关的概念和知识,然后分析了volatile关键字的实现原理,最后给出了几个使用volatile关键字的场景。

1、jvm管理的内存区域划分

 

2、JMM:Java内存模型

  JVM运行程序的实体是线程,每个线程在被创建时JVM都会为其创建一个自己私有的工作内存。而Java内存模型规定所有的变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但是线程对这些变量的操作只能在自己的工作内存中进行,不能直接操作主内存中的变量,要先将变量从主内存中拷贝到线程自己的工作内存中再对其进行操作,操作完成后再将操作后的变量写回主内存当中,因此不同的线程也无法访问对方的工作内存,线程间的通信必须通过主内存来完成。

Java内存模型的抽象结构图:

关于JMM我们需要知道的点:

  1.工作内存和Java内存模型并不是真实存在于java虚拟机中,而是一种规范和定义。

  2.共享变量存储于主内存之中,每个线程都可以访问。这里的变量指的是实例变量和类变量。局部变量是线程私有的,不存在共享。

  3.每个线程都有私有的工作内存或者称为本地内存,工作内存存储的是共享变量的副本。

  4.线程不能直接操作主内存,只有先操作了工作内存之后才能写人主内存。

  5.不同的线程不能直接访问对方工作内存中的变量,线程间变量的值传递需借助主内存作为中转来完成。

3、volatile关键字

  volatile是java虚拟机提供的轻量级同步机制。其三大特性为保证可见性、不保证原子性、保证有序性(禁止指令重排)。

3.1、volatile保证可见性

  如果A线程和B线程同时获取主内存中的同一个变量,之后A线程修改了这个变量,但是此时B线程并不知道A线程已经对数据进行了修改,所以要具有可见性让线程之间进行通讯。当线程A修改完以后线程B也能知道此时该变量的值已经变为A修改后的数据,实现可见性。

具体验证可见性的代码如下:

import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.TimeUnit;

/**
 * 验证volatile修饰变量的可见性  volatile修饰对象,对引用是可见的,但是不能保证属性的可见性
 * 当两个线程分别对同一个变量V执行read操作,并对读取后的变量执行相关的运算,之后两个线程会将自己操作的变量同步会主内存,这时候就会存在后同步到操作会覆盖前一个同步到操作。volatile并不具备线程同步到特性
 * 假如int number = 0 ; number变量之前根本没有添加volatile关键字修饰
 */
@Slf4j
public class VolatileDemo01 {
    public static void main(String[] args) throws InterruptedException {
        MyData myData = new MyData();//资源类
        new Thread(() ->{
            System.out.println(Thread.currentThread().getName()+"come in");
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            myData.changeTo60();
            System.out.println(Thread.currentThread().getName()+ "update number value:"+ myData.number);
        },"线程==").start();
        //第二个线程就是我们的main线程
        while (myData.number == 0){
            // 此处千万不要 使用 System.out.println 此句代码会触发同步机制 println底层是synchonized修饰
        }
        System.out.println(Thread.currentThread().getName()+ "mission is over, main get number value:"+myData.number);
    }

}

class MyData{
    int number = 0; //volatile修饰主线程会执行结束;去掉volatile 主线程一直阻塞在while,不会执行结束
    public void changeTo60(){
        this.number = 60;
    }
}

执行以上代码可以看到代码进入死循环,代表没有可见性,main线程并不知道new Thread线程已经将变量修改。如图:

此时我们只需要将实体类中的变量number加上volatile关键字即可实现可见性。

class MyData{
    volatile int number = 0; //volatile修饰主线程会执行结束;
    public void changeTo60(){
        this.number = 60;
    }
} 

执行结果:

此时main线程已经知道new Thread线程修改了变量的值,实现了可见性。

3.2、volatile不保证原子性

测试代码如下:

public class VolatileDemo02 {

    public static volatile int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> add(1000000));
        Thread t2 = new Thread(() -> add(1000000));

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(count);
    }

    public  static   void add(int n) {
        for (int m = 0; m < n; m++) {
            count++;
        }
    }
    //结果不是2000000说明volatile不能保证原子性;在add方法加上synchronized修饰,结果为2000000,说明synchronized可以保证原子性
}

执行以上main方法 两次,可得到结果如图: 

 

  运行上述代码可知,count的输出值每次都不一样,但是不会出现期望结果20000,而且属性count还用volatile修饰了,按照之前的逻辑,加了volatile关键字,count值每次修改都会被刷新至主存,且其它线程每次也都是从主存中获取最新的count值,那为什么还会出现这种情况呢?
虽然volatile会使不同的线程每次从主内存中读取,而不是从线程本地工作内存中读取,这样是保证了数据的可见性。但是需要注意的是:如果修改实例变量中的数据,例如count++,也就是count=count+1,这个操作并不是一个原子操作,它包含下面三步:

   (1)从内存中取出count的值;
   (2)计算count的值;
   (3)将count的值写回内存中。

  若在上述步骤(3)中,线程1计算完count的值,还未来得及将count的值写回内存,线程2来获取count的值,此时线程2拿到未被线程1修改的count,同样执行count=count+1操作,执行完成后同样需要将count写回主内存,这时就会将线程1写入主内存中的值覆盖。虽然是两个线程执行分别执行了count=count+1,但是由于开始拿到的count值是同一个,实际上count的值只增加了一次,因此导致最后的count值不符合预期值10000。具体流程如下图所示:

上述问题的根本原因:自增操作是非原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。

解决volidate无法保证原子性的方法:

1.使用java.util.concurrent.atomic下的AtomicInteger可以保证原子性(CAS自旋锁):

import java.util.concurrent.atomic.AtomicInteger;

public class VolatileDemo03 {
    //new AtomicInteger(); ()里不写默认为0,相当于现在atomicInteger = 0
    static AtomicInteger atomicInteger = new AtomicInteger();

    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() ->  addMyAtomic(1000000));
        Thread t2 = new Thread(() -> addMyAtomic(1000000));

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(atomicInteger);
    }
    public static void addMyAtomic(int n) {
        //等同于++ , 带原子性的++
        for (int m = 0; m < n; m++) {
            atomicInteger.getAndIncrement();
        }
    }
}

2.加synchronized同步锁。(但是此处使用synchronized显得太重了,杀鸡用牛刀!)

public class VolatileDemo02 {

    public static volatile int count = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> add(1000000));
        Thread t2 = new Thread(() -> add(1000000));

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(count);
    }

    public  static synchronized void add(int n) {
        for (int m = 0; m < n; m++) {
            count++;
        }
    }
    //结果不是2000000说明volatile不能保证原子性;在add方法加上synchronized修饰,结果为2000000,说明synchronized可以保证原子性
}

3.3、volidate保证有序性(即:禁止指令重排序)

volatile实现禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。

如果不使用volatile,在多线程环境中线程交替执行,由于编译器优化重排,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

计算机在执行程序时,为了提高性能,编译器和处理器通常会对指令做出重排:

 为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

4、volatile使用场景

volatile适用于以下场景:

1. 某个属性被多个线程共享,其中一个线程修改了此属性,其他线程可以立即获得修改后的值,比如线程循环标识boolean flag;
2. volatile还可以用于单例模式,可以解决单例双重检查对象初始化代码执行乱序问题。
volatile应用于单例模式代码:

public class Singleton {
 
    private volatile static Singleton singleton = null;
 
    public Singleton() {
        System.out.println(Thread.currentThread().getName() + "生成singleton");
    }
 
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

 

上述单例模式中使用了双重检验,如果不用volatile关键字修饰属性又会怎样?
  单线程情况下,属性singleton不加volatile关键字也不会出现任何问题;但是多线程情况下,会出现指令重排序的情况,就有可能出现空指针问题。首先需要了解的是对象创建包含下面三个过程:1.分配内存空间;2.调用构造函数,初始化对象;3.返回地址给引用。由于步骤2和步骤3不存在数据依赖关系,而且无论是重排前还是重排后的执行结果在单线程中并没有发生改变,因此这种重排优化是允许的。若此时先执行步骤3,步骤2还未执行完,另一个线程来执行if (singleton == null)会返回false,此时对象未完全生成,是个半成品,当访问对象方法或属性时,就会抛出空指针异常。使用volatile避免指令重排序,同时保证写回主存中的对象只有一个,实现真正意义上的单例。

5、volatile与synchronized的区别

1.关键字volatile是线程同步的轻量级实现,所以volatile的性能略胜于synchronized,并且volatile只能修饰变量,而synchronized可以修饰方法、代码块等;
2.多线程访问volatile不会发生阻塞,而synchronized会出现阻塞;
3.volatile能保证数据的可见性,但不能保证原子性;而synchronized可以保证原子性,也可以间接保证可见性,因为它会将私有内存和共有内存中的数据做同步。
4.关键字volatile解决的是变量在多个线程之间的可见性;而synchronized关键字解决的是多个线程之间访问资源的同步性。

6、volatile原理

  Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。

  在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。

  当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。

  而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。

  volatile 性能:volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

posted @ 2022-09-13 16:47  江南大才子  阅读(143)  评论(0编辑  收藏  举报