volatile关键字

前言

在Java相关的岗位面试中,很多面试官都喜欢考察面试者对Java并发的了解程度,而以volatile关键字作为一个小的切入点,往往可以一问到底,把Java内存模型( JMM),Java并发编程的一些特性都牵扯出来,深入地话还可以考察 JVM底层实现以及操作系统的相关知识。下面我们以一次假想的面试过程,来深入了解下volitile关键字!



就我理解的而言,被 volatile修饰的共享变量,具有了以下两点特性:

  • 保证了不同线程对该变量操作的内存可见性;
  • 禁止指令重排序


在Java保证多线程运行安全,主要就是围绕着如何在并发过程中如何处理原子性可见性有序性这3个特征来建立的,通过解决这三个问题,可以解除缓存不一致的问题。

  • 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作;
  • 可见性:一个线程对主内存的修改可以及时地被其他线程看到;
  • 有序性:线程执行代码的顺序是按照指令的先后顺序执行的。

volatile可见性有序性都有关。

注意:volatile关键字无法保证原子性。



  • 原子性(Atomicity)

原子性指的是一个或者多个操作在 CPU 执行的过程中不被中断的特性

Java 并发程序都是基于多线程的,操作系统为了充分利用CPU的资源,将CPU分成若干个时间片,在多线程环境下,线程会被操作系统调度进行任务切换。



为了直观的了解什么是原子性,我们看下下面哪些操作是原子性操作

int count = 0;  //1
count++;  //2
int i = count;  //3

上面展示语句中,除了语句1是原子操作,其它两个语句都不是原子性操作,下面我们来分析一下语句2
其实语句2在执行的时候,包含三个指令操作

  • 指令 1:首先,需要把变量 count 从内存加载到 CPU的寄存器
  • 指令 2:之后,在寄存器中执行 +1 操作;
  • 指令 3:最后,将结果写入内存

对于上面的三条指令来说,如果线程 A 在指令 1 执行完后做线程切换,线程 A 和线程 B 按照下图的序列执行,那么我们会发现两个线程都执行了 count+=1 的操作,但是得到的结果不是我们期望的 2,而是 1。


操作系统做任务切换,可能发生在任何一条CPU 指令执行完时

  • 有序性(Ordering)

有序性指的是程序按照代码的先后顺序执行

为了性能优化,编译器和处理器会进行指令重排序,有时候会改变程序中语句的先后顺序,比如程序:

      a = 5;      //1
      b = 20;     //2
      c = a + b;  //3

编译器优化后可能变成:

       b = 20;     //1
       a = 5;      //2
       c = a + b;  //3

在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果

synchronized(具有有序性、原子性、可见性)表示锁在同一时刻只能由一个线程进行获取,当锁被占用后,其他线程只能等待。

在单例模式的实现上有一种双重检验锁的方式

public class Singleton {

   private static volatile Singleton uniqueSingleton;

   private Singleton() {}

   public static Singleton getUniqueSingleton(){
       //先判断对象是否已经实例过,没有实例化过才进入加锁代码
       if (uniqueSingleton == null){
           //对Singleton对象加锁
           synchronized (Singleton.class){
               if (uniqueSingleton ==null){
                   uniqueSingleton = new Singleton();
               }
           }
       }
       
       return uniqueSingleton;
   }
   
}

我们先看instance=newSingleton()的未被编译器优化的操作

  • 指令 1:分配一块内存 M;
  • 指令 2:在内存 M 上初始化 Singleton对象;
  • 指令 3:然后 M 的地址赋值给 instance 变量。

编译器优化后的操作指令

  • 指令 1:分配一块内存 M;
  • 指令 2:将 M 的地址赋值给 instance 变量;
  • 指令 3:然后在内存 M 上初始化 Singleton 对象。

现在有A,B两个线程,我们假设线程A先执行getInstance()方法,当执行编译器优化后的操作指令2时(此时候未完成对象的初始化),这时候发生了线程切换,那么线程B进入,刚好执行到第一次判断 instance==null会发现instance不等于null了,所以直接返回instance,而此时的instance是没有初始化过的。

  1. 可见性(Visibility)

可见性指的是当一个线程修改了共享变量后,其他线程能够立即感知修改后的值。

首先我们来看一下Java内存模型(JMM)


  • 我们定义的所有变量都储存在主内存中
  • 每个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)
  • 线程对共享变量所有的操作都必须在自己的工作内存中进行,不能直接从主内存中读写(不能越级)
  • 不同线程之间也无法直接访问其他线程的工作内存中的变量,线程间变量值的传递需要通过主内存来进行。(同级不能相互访问)

共享变量可见性的实现原理:

线程1对共享变量的修改要被线程2及时看到的话,要经过如下步骤:

  1. 线程1工作内存1中更新的变量值刷新到主内存
  2. 线程2主内存中的变量的值更新到工作内存2



  1. 状态量标记,比在COW机制中volatile Object[] array、AQS中的volatile int state变量;这种对变量的读写操作,标记为volatile可以保证修改对线程立刻可见。比 synchronized,Lock有一定的效率提升。

  2. 双重校验锁实现单例模式

public class Singleton {

    private static volatile Singleton uniqueSingleton;

    private Singleton() {}

    public static Singleton getUniqueSingleton(){
        //先判断对象是否已经实例过,没有实例化过才进入加锁代码
        if (uniqueSingleton == null){
            //对Singleton对象加锁
            synchronized (Singleton.class){
                if (uniqueSingleton ==null){
                    uniqueSingleton = new Singleton();
                }
            }
        }
        
        return uniqueSingleton;
    }
    
}

注意,重点来了: uniqueInstance 采用 volatile关键字修饰也是很有必要。
uniqueInstance 采用 volatile关键字修饰也是很有必要的, uniqueInstance = new Singleton();这段代码其实是分
为三步执行:

  1. uniqueInstance分配内存空间
  2. 初始化uniqueInstance
  3. uniqueInstance指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出先问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用getUniqueInstance() 后发现uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。

这里使用的就是volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

你可能感兴趣的:(volatile关键字)