【Java面试】聊聊 volatile 关键字

使用 volatile 修饰的成员变量具有两个特性,分别是 「可见性」「有序性」

可见性

使用 volatile 修饰的成员变量,其发生的改变对其他所有线程立即可见,而普通变量是做不到这一点的。也就是说 volatile 修饰的变量只要发生了改变,其他线程总是在使用这个变量的时候会获取到最新的值。而对普通变量来说,一个线程的修改对其他线程是不可见的,其他线程获取到的可能还是旧的值。

要完全理解上面这段话,就需要先了解 Java内存模型JMMJava Memory Model)以及 缓存一致性问题

Java 内存模型

Java 内存模型规定所有的共享变量都是存储在主内存的。然后每个线程都会有自己的一个工作内存,用于缓存该线程使用到的存放于主内存的共享变量的副本。线程对变量的操作都是在工作内存中完成的,然后再写回主内存。

这样设计的原因是直接操作主内存是IO操作比较耗时,所以增加了工作内存(CPU 高速缓存)将需要的数据一次性读取并缓存。计算完毕后再同步(写)回到主内存。【Java面试】聊聊 volatile 关键字

缓存一致性问题

加入工作内存后确实提高了与主内存交互的速度,但也引入了一个新的问题:缓存一致性。【Java面试】聊聊 volatile 关键字

当两个线程都从主内存读取了变量x,并缓存到各自的工作内存当中。这个时候如果其中一个线程2修改了变量的值,那么就会造成线程1在工作内存中缓存的数据错误。【Java面试】聊聊 volatile 关键字

我们当然可以对共享变量加锁来避免这个问题,但比较重。而 volatile 就是解决缓存一致性问题最轻量级的实现。那么volatile是如何实现的呢?这就要从 volatile 的特殊规则说起。

volatile的特殊规则

Java 内存模型规定:

  1. volatile 修饰的变量只要发生改变,就必须立马回写到主内存。
  2. 只要使用到被 volatile 修饰的变量,就必须从主内存读取最新的值。

以上2点就保证了只要线程中用到被 volatile 修饰的变量,其值肯定是最新的。

线程同步

volatile 可见性特性的一个用途是可以使用其在多个线程之间做信号同步。

如下面伪代码所示,如果 shutdown 没有被申明为 volatile,那么当某个线程将 shutdown 标记为 true 之后,其他线程可能并不会立即停止执行 dosomething 方法,因为其他线程的工作内存中 shutdown 可能还是 false

private volatile boolean shutdown = false;

// 多线程调用
public void dosomething(){
    while(!shutdown){
        // 做一些事情
    }
}

public void setShutdown(){
    shutdown = true;
}

禁止指令重排

volatile 的第二个特性是「禁止指令重排优化」

先来说说什么是指令重排,JVM 为了优化程序运行速度,在不影响 单线程 最终结果的条件下,可能会对运行指令进行重新排序。

比如下面这段代码,第一行和第二行就可能被重新排序,重排后对执行结果没有影响。

// 重排前
int a = 1;
int b = 2;
int c = a + b;

// 重排后
int b = 2;
int a = 1;
int c = a + b;

指令重排带来的影响

在多线程执行的情况下,指令重排就会带来不可预测的风险。

比如下面这段伪代码:线程1通过IO加载配置信息,加载完毕后将完成标记 initialized 设置为 true,且当 initialized==true 的时候,这个类才能正常使用。

private initialized = false;

// 线程1
public void init(){
  config = loadConfig()
  initialized = true
}

// 线程2
public void dosomething(){
  
  if(initialized){
    // 做一些事情。。。
  }  
}

一旦发生指令重排,将 initialized 赋值为 true 的指令排到加载配置信息的前面去了,那么线程2就可能会出问题。因为虽然线程2中得到 initialized 等于 true,但是此时由线程1加载的配置信息可能还没有完成。

private initialized = false;

// 线程1
public void init(){
    initialized = true
  config = loadConfig()
}

// 线程2
public void dosomething(){
  
    if(initialized){
        // 做一些事情。。。
    }  
}

上述指令重排在同一个线程下是没有任何问题的。因为同一个线程的话只有将 init 方法执行完毕之后,才能执行后续其他方法。所以不管 initialized 的赋值语句被重排到哪里,只要 init 方法执行完了,配置信息肯定也就加载完成了。

使用volatile禁止指令重排

还是上面那段代码,我们只要将 initialized 前面加上 volatile 修饰符,就可以禁止指令重排。

private volatile initialized = false;

// 线程1
public void init(){
 config = loadConfig()
 initialized = true
}

// 线程2
public void dosomething(){
  
    if(initialized){
        // 做一些事情。。。
    }  
}

这样线程2只要判断出 initializedtrue,那么配置信息就一定是加载完成了的,就不会出现问题了。

双重检查单例模式

禁止指令重排的另一个典型使用就是 双重检查单例模式

package info.iyushu.design.pattern.singleton;

public class LazySingletonInstance {

    // 保证 getInstance 返回的是初始化完全的对象。
    private static volatile LazySingletonInstance instance;

    // 私有化构造器
    private LazySingletonInstance(){
        System.out.println("LazySingletonInstance 构造函数。。。");
    }

    public static LazySingletonInstance getInstance(){
        System.out.println("获取 SingletonInstance 实例");
        if(instance == null){
            synchronized (LazySingletonInstance.class){
                if(instance == null){
                    instance = new LazySingletonInstance();
                }
            }
        }
        return instance;
    }
}

上述代码中,因为 new LazySingletonInstance() 这个操作不是原子性的,如果没有用 volatile 修饰,在多线程下有可能获取到的对象还没有初始化完成。


– End –


【Java面试】聊聊 volatile 关键字
如果觉得有所收获,就顺道点个关注吧!【Java面试】聊聊 volatile 关键字 

原文始发于微信公众号(i余数):【Java面试】聊聊 volatile 关键字

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/194053.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!