• 如果您觉得本站非常有看点,那么赶紧使用Ctrl+D 收藏吧

天天在用volatile,你知道它的底层原理吗?

互联网 diligentman 2周前 (10-15) 15次浏览

前言

对于从事java开发工作的朋友来说,在工作中可能会经常接触volatile关键字。即使有些朋友没有直接使用volatile关键字,但是如果使用过:ConcurrentHashMap、AtomicInteger、FutureTask、ThreadPoolExecutor等功能,它们的底层都使用了volatile关键字,你就不想了解一下它们为什么要使用volatile关键字,它的底层原理是什么?

从双重检查锁开始

面试时被要求写个单例模式的代码,很多朋友可能写的是双重检查锁。代码如下:

public class SimpleSingleton4 {

    private static SimpleSingleton4 INSTANCE;

    private SimpleSingleton4() {

    }

    public static SimpleSingleton4 getInstance() {
        if (INSTANCE == null) {
            synchronized (SimpleSingleton4.class) {
                if (INSTANCE == null) {
                    INSTANCE = new SimpleSingleton4();
                }
            }
        }
        return INSTANCE;
    }
}

有些朋友看到这里觉得有点熟悉,平时可能就是这个写的。

但是,我要告诉你的是,这个代码有问题,它在有些时候不是单例的。为什么会出现问题呢?

答案,在后面揭晓。

JMM(java内存模型)

在介绍volatile底层原理之前,让我们先看看什么是JMM(即java内存模型)。

天天在用volatile,你知道它的底层原理吗?

java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝。前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

java内存模型会带来三个问题:

1.可见性问题

线程A和线程B同时操作共享数据C,线程A修改的结果,线程B是不知道的,即不可见的

2.竞争问题

刚开始数据C的值为1,线程A和线程B同时执行加1操作,正常情况下数据C应该为3,但是在并发的情况下,数据C却还是2

3.重排序问题

JVM为了优化指令的执行效率,会对一下代码指令进行重排序。

那么如何解决问题呢?

volatile的底层原理

java 编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类 型的处理器重排序,从而让程序按我们预想的流程去执行。

1、保证特定操作的执行顺序。

2、影响某些数据(或则是某条指令的执行结果)的内存可见性。

java的内存屏障指令如下:

天天在用volatile,你知道它的底层原理吗?

对于volatile的写操作,在其前后分别加上 StoreStore 和 StoreLoad指令

天天在用volatile,你知道它的底层原理吗?

对于volatile的读操作,在其后加上 LoadLoad 和 LoadStore指令

天天在用volatile,你知道它的底层原理吗?

由上图可以看到,内存屏障是可以保证volatile变量前后读写顺序的。

此外,对volatile变量写操作时,使用store指令会强制线程刷新数据到主内存,读操作使用load指令会强制从主内存读取变量值。

再看看这个例子:

public class DataTest {
  
    private volatile int count = 0;

    public int getCount() {
       return count;
    }  

    public void setCount(int count) {
       this.count = count;
    }   
  
    public void incr() {
       count++;
    }  
} 

上面列子中的getCount和setCount方法这种单操作是可以保证原子性的,但是像incr方法无法保证原子性。

由此可见,volatile关键字可以解决可见性 和 重排序问题。但是不能解决竞争问题,无法保证操作的原子性,解决竞争问题需要加锁,或者使用cas等无锁技术。

再看双重检查锁问题

从上面可以看出JMM会有重排序问题,之前双重检查锁为什么有问题呢?

public static SimpleSingleton4 getInstance() {
  if (INSTANCE == null) {
     synchronized (SimpleSingleton4.class) {
        if (INSTANCE == null) {

          //1.分配内存空间
          //2.初始化引用
          //3.将实际的内存地址赋值给当前引用
          INSTANCE = new SimpleSingleton4();
        }
    }
  }
  return INSTANCE;
}

从代码中的注释可以看出,INSTANCE = new SimpleSingleton4();这一行代码其实经历了三个过程:

1.分配内存空间

2.初始化引用

3.将实际的内存地址赋值给当前引用

正常情况下是按照1、2、3的顺序执行的,但是指令重排之后也不排除按照1、3、2的顺序执行的可能性,如果按照1、3、2的顺序。

天天在用volatile,你知道它的底层原理吗?

上面错误双重检查锁定的示例代码中,如果线程 1 获取到锁进入创建对象实例,这个时候发生了指令重排序。当线程1 执行到 t3 时刻,线程 2 刚好进入,由于此时对象已经不为 Null,所以线程 2 可以自由访问该对象。然后该对象还未初始化,所以线程 2 访问时将会发生异常。

解决这个问题,可以把INSTANCE定义成volatile的。

private volatile static SimpleSingleton4 INSTANCE;

其实,创建单例的方法有很多,最好的还是静态内部类。

public class SimpleSingleton5 {

    private SimpleSingleton5() {
    }

    public static SimpleSingleton5 getInstance() {
        return Inner.INSTANCE;
    }

    private static class Inner {
        private static final SimpleSingleton5 INSTANCE = new SimpleSingleton5();
    }
}

总结

volatile的底层是通过:store,load等内存屏障命令,解决JMM的可见性和重排序问题的。但是它无法解决竞争问题,要解决竞争问题需要加锁,或使用cas等无锁技术。单例模式不建议使用双重检查锁,推荐使用静态内部类的方式创建。

彩蛋

使用volatile保证线程间的可见性和重排序问题,相对于synchronized等加锁机制更轻量级,但是对性能还是有一定的消耗,如何优化性能呢?

可以参考spring中DefaultNamespaceHandlerResolver类的getHandlerMappings方法

@Nullable
private volatile Map<String, Object> handlerMappings;

天天在用volatile,你知道它的底层原理吗?

该方法就使用了双重检查锁,可以看到方法内部使用局部变量,首先将实例变量值赋值给该局部变量,然后再进行判断。最后内容先写入局部变量,然后再将局部变量赋值给实例变量。使用局部变量相对于不使用局部变量,可以提高性能。主要是由于 volatile 变量创建对象时需要禁止指令重排序,这就需要一些额外的操作。

如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下:苏三说技术,或者点赞,转发一下,坚持原创不易,您的支持是我前进最大的动力,谢谢。


喜欢 (0)