【Java面试】能说说你对volatile关键字的理解吗?

简介: 【Java面试】能说说你对volatile关键字的理解吗?

volatile能否保证线程安全?

下文使用到了javap命令进行class文件的反汇编来查看字节码,如果想要了解的可以学习一下javap命令。

什么是javap命令

javap命令的参数

要解决这个问题首先要明白什么样是线程安全的。

线程安全要考虑三个方面:可见性、有序性、原子性

  • 可见性指,一个线程对共享变量修改,另一个线程能看到最新的结果
  • 有序性指,一个线程内代码按编写顺序执行
  • 原子性指,一个线程内多行代码以一个整体运行,期间不能有其它线程的代码插队

在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取

volatile 能够保证共享变量的可见性与有序性,但并不能保证原子性。synchronized 关键字两者都能保证。

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其

他线程来说是立即可见的。

2)禁止进行指令重排序。

原子性

这里先来解释一下什么是原子性吧。

我们知道Java的代码结果编译之后会变为class文件(字节码文件),而class文件经过JVM的解释器之后就能变为最后操作底层操作系统的机器码了。

而我们可以使用javap去查看class文件对应的字节码指令。

javap是jdk自带的反解析工具。它的作用就是根据class字节码文件,反解析出当前类对应的code区(字节码指令)、局部变量表、异常表和代码行偏移量映射表、常量池等信息。

通过局部变量表,我们可以查看局部变量的作用域范围、所在槽位等信息,甚至可以

看到槽位复用等信息。

而经过解析之后可以发现,我们在Java中写的一行代码,其实对应了字节码中的好多行操作。如下

可以发现编写在Java中的一行代码对应了字节码中的好多行代码,在多线程情况下,CPU执行这些命令的时候,有可能线程分配的时间片结束了,那么这个任务就得被迫结束,然后CPU去执行其他的字节码指令。那么这个时候就有可能出现问题。

例如代码中的add方法和sub方法我使用了两个线程去执行,那么执行某一行字节码指令的时候,如果时间片结束,那么CPU执行另一个线程的操作。

此时就可能出现add方法才执行到getstatic这个字节码指令然后就被sub方法抢占,然后sub方法刚刚好执行完毕了所有的字节码指令,那么此时money的值在执行sub方法的线程中,他的值就是5,然后sub方法将数据写入到主内存的money中的时候,此时money=5,但是此时执行add方法的线程开始继续执行他的指令,他的money值还是10,然后add操作完毕之后money值等于15,那么此时就会出现数据覆盖了。

public class VolatileTest {
    private static volatile int money = 10;
    public VolatileTest() {
    }
    public static void add() {
        money += 5;
    }
    public static void sub() {
        money -= 5;
    }
    public static void main(String[] args) {
        (new Thread(() -> {
            add();
        })).start();
        (new Thread(() -> {
            add();
        })).start();
        System.out.println(money);
    }
}

而出现这种情况的原因就是由于,一行Java代码会被分解为多行的字节码指令,而这些字节码指令不保证原子性,也就是他们执行的过程中可以被打断,然后打断完毕之后再继续执行,那么本来的一行代码却被分块执行了。

而想要解决这种问题也很简单,就是让这个线程必须执行完毕他的所有指令的时候才能被另一个线程抢占CPU资源,那么很明显要解决这里的原子性问题,只需要使用synchronized锁或者Lock锁即可。

可见性

具体volatile关键字是如何解决可见性问题的可以先看这篇文章

上图可见,每个CPU对共享变量的操作都是将内存中的共享变量复制一份副本到自己高速缓存中,然后对这个副本进行操作。如果没有正确的同步,即使CPU0修改了某个变量,这个已修改的值还是只存在于副本中,此时CPU1需要使用到这个变量,从内存中读取的还是修改前的值,这就是其中一种可见性问题。

可见性的问题在于访问共享变量的问题,例如下面的代码,如果说while中的flag变量成功被修改为true(事实上他确实成功被修改为了true),那么这里就应该输出最后一句话,而不是卡死在死循环中,说明,虽然另一个线程修改了flag的值,但是主线程好像没有读取到,这就是一个很典型的可见性问题。

可能你会认为其实是因为线程没有修改flag的值,所以还卡死再死循环中,那么我延迟100ms再去读取一次这个flag的值,让我们来看看到底flag是什么值。

可以发现是true。

那么为什么明明是true,但是主线程还是卡在死循环呢?

用下图来解释,我们知道JVM中是有一个JIT(即时编译器)的,他负责优化我们的热点代码,也就是执行次数非常多的代码,而我的while语句由于一开始是flag是false,所以再sleep的100ms时间内,其实我的while语句由于我的机器性能,可能已经执行了几百万次了,所以此时while就是热点代码,但是我每次我的while都要去主内存中读取flag的值,那么效率是很低的,因为CPU的速度是(小于)ns级别,而内存是几十ns,所以此时内存反倒成为了速度的瓶颈,所以JIT就试图优化代码,JIT发现CPU读取了几百万次的flag值都是false,所以他就直接认为flag就是false了,然后就把下图中的while条件判断直接设定为了stop=false(这里JIT直接替换了代码,所以直接flag读取都不读取了,直接while里面写的就是一个!false,所以while直接死循环),那么这样子就会导致while的条件永远为真,即使其他线程已经修改了flag的值,stop依旧继续为false,所以就会导致可见性问题。

JIT优化之后

而其他线程由于没有优化,所以其他线程依旧可以读取到flag被修改后的值。

而如果认为其实不是由于JIT导致的上面的原因,那么我们可以再运行代码的时候设定JVM参数来关闭JIT。再VM options处添加-Xint,表示禁用JIT。

然后继续运行一样的代码,可以发现死循环结束。

上面说过JIT只会优化热点代码,那么如果循环次数不够多,那么就可能不是热点代码了,我们可以试一试,可以发现循环次数减少后,JIT就没有优化代码了,不会继续死循环。

注意,JIT优化对代码的性能提升巨大,所以我们不可能关闭JIT。

所以我们还可以使用volatile来解决这个问题。

那么volatile关键字是如何再底层实现可见性的呢?

其实就是当线程2修改了某个共享变量时,如果线程1已经把这个共享变量读取到了它的工作缓存中,那么此时线程2会发送一个信号,让线程1中缓存中的缓存行无效,也就是这个缓存失效了(反映到硬件层的话,就是CPU中的L1或者L2缓存中对于的缓存行无效了),那么此时线程1就需要再次去主内存中读取这个已经被线程2修改后的数据,这就保证了数据的可见性。

有序性

在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。

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

1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,

且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

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

后面的语句放到其前面执行。

CPU会对指令进行优化,如果几条指令之间没有关联性,那么这几条指令可能他们就不会按照原有的顺序执行,而是经过CPU的排序后进行执行。

而volatile是如何解决指令重排序的呢?

下面这段话摘自《深入理解Java虚拟机》:
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字
时,会多出一个lock前缀指令”
lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内
存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2)它会强制将对缓存的修改操作立即写入主存;
3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

他使用的是内存屏障的方式,也就是他会为volatile变量的读和写加上内存屏障,

volatile变量进行写操作时,JVM 会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存中的数据写会到系统内存。

Lock 前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。

例如x是再y之前声明的,而y加上了volatile关键字,那么此时x就不可能越过y的屏障,也就是对x的操作一定要先于y完成。而对于读取,那么会防止下面的读语句跑到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);

对于这个代码,如果inited不是volatile的,那么就可能发生inited先执行,然后此时线程2执行的时候发现inited为true,就会跳过这个循环,那么就直接执行了doSomethingwithconfig,但是此时context其实还没有被赋值,就会发生异常报错,而如果inited是volatile的,那么就不会发生指令从排序,此时就一定能保证inited为true的时候context已经赋值了。

所以volatile解决有序性的话有一个要求:

volatile给写变量的时候要把写变量放在语句的最后,也就是最后给volatile变量赋值。

读取volatile变量的时候,要把读取语句放在第一句。也就是吧volatile变量拿去读取的时候应该最早读取。

也就是 写尾读头

因此volatile的使用要求还挺高的,如果没有理解内存屏障,那么可能用不明白。

实现原理

volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障,内存屏障会提供3个功能:

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  2. 它会强制将对缓存的修改操作立即写入主存;
  3. 如果是写操作,它会导致其他CPU中对应的缓存行无效。

使用场景

Java 语言中的 volatile 变量可以被看作是一种 “程度较轻的 synchronized ”;与 synchronized 块相比,volatile 变量所需的编码较少,并且运行时开销也较少,但是它所能实现的功能也仅是synchronized 的一部分。本文介绍了几种有效使用 volatile 变量的模式,并强调了几种不适合使用volatile 变量的情形。

锁提供了两种主要特性:互斥(mutual exclusion) 和可见性(visibility)。互斥即一次只允许一个线程持有某个特定的锁,因此可使用该特性实现对共享数据的协调访问协议,这样,一次就只有一个线程能够使用该共享数据。可见性要更加复杂一些,它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的 —— 如果没有同步机制提供的这种可见性保证,线程看到的共享变量可能是修改前的值或不一致的值,这将引发许多严重问题。

Volatile 变量具有 synchronized 的可见性特性,但是不具备原子特性。这就是说线程能够自动发现volatile 变量的最新值。Volatile 变量可用于提供线程安全,但是只能应用于非常有限的一组用例:多个变量之间或者某个变量的当前值与修改后值之间没有约束。因此,单独使用 volatile 还不足以实现计数器、互斥锁或任何具有与多个变量相关的不变式(Invariants)的类(例如 “start <=end”)。

出于简易性或可伸缩性的考虑,您可能倾向于使用 volatile 变量而不是锁。当使用 volatile 变量而非锁时,某些习惯用法(idiom)更加易于编码和阅读。此外,volatile 变量不会像锁那样造成线程阻塞,因此也很少造成可伸缩性问题。在某些情况下,如果读操作远远大于写操作,volatile 变量还可以提供优于锁的性能优势。

状态标志

也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。

1. volatile boolean shutdownRequested;
5. public void shutdown() {
6. shutdownRequested = true;
7. }
9. public void doWork() {
10. while (!shutdownRequested) {
11. // do stuff
12. }
13. }

线程1执行doWork()的过程中,可能有另外的线程2调用了shutdown,所以boolean变量必须是volatile。

而如果使用 synchronized 块编写循环要比使用 volatile 状态标志编写麻烦很多。由于 volatile 简化了编码,并且状态标志并不依赖于程序内任何其他状态,因此此处非常适合使用 volatile。

这种类型的状态标记的一个公共特性是:通常只有一种状态转换; shutdownRequested 标志从 false转换为 true ,然后程序停止。这种模式可以扩展到来回转换的状态标志,但是只有在转换周期不被察觉的情况下才能扩展(从 false 到 true ,再转换到 false )。此外,还需要某些原子状态转换机制,例如原子变量。

一次性安全发布(one-time safe publication)

在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。

这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象。

1. volatile boolean shutdownRequested;
2. public void shutdown() {
3.  shutdownRequested = true;
4. }
8.
5. public void doWork() {
6. while (!shutdownRequested) {
7. // do stuff
8. }
9. }
10. //注意volatile!!!!!!!!!!!!!!!!!
11. private volatile static Singleton instace;
3.
12. public static Singleton getInstance(){
13. //第一次null检查
14. if(instance == null){
15. synchronized(Singleton.class) { //1
16. //第二次null检查
17. if(instance == null){ //2
18. instance = new Singleton();//3
19. }
20. }
21. }
22. return instance;

如果不用volatile,则因为内存模型允许所谓的“无序写入”,可能导致失败。某个线程可能会获得一个未完全初始化的实例。

考察上述代码中的 //3 行。此行代码创建了一个 Singleton 对象并初始化变量 instance 来引用此对象。

这行代码的问题是:在Singleton 构造函数体执行之前,变量instance 可能成为非 null 的!

什么?这一说法可能让您始料未及,但事实确实如此。

在解释这个现象如何发生前,请先暂时接受这一事实,我们先来考察一下双重检查锁定是如何被破坏的。假设上述代码执行以下事件序列:

  1. 线程 1 进入 getInstance() 方法。
  2. 由于 instance 为 null,线程 1 在 //1 处进入synchronized 块。
  3. 线程 1 前进到 //3 处,但在构造函数执行之前,使实例成为非null。
  4. 线程 1 被线程 2 预占。
  5. 线程 2 检查实例是否为 null。因为实例不为 null,线程 2 将instance 引用返回,返回一个构造完整但部分初始化了的Singleton 对象。
  6. 线程 2 被线程 1 预占。
  7. 线程 1 通过运行 Singleton 对象的构造函数并将引用返回给它,来完成对该对象的初始化。

独立观察(independent observation)

安全使用 volatile 的另一种简单模式是:定期 “发布” 观察结果供程序内部使用。【例如】假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。

使用该模式的另一种应用程序就是收集程序的统计信息。

例如下代码展示了身份验证机制如何记忆最近一次登录的用户的名字。将反复使用 lastUser 引用来发布值,以供程序的其他部分使用。

1. public class UserManager {
2. public volatile String lastUser; //发布的信息
3.
4. public boolean authenticate(String user, String password) {
5. boolean valid = passwordIsValid(user, password);
6. if (valid) {
7. User u = new User();
8. activeUsers.add(u);
9. lastUser = user;
10. }
11. return valid;
12. }
13. }

“volatile bean” 模式

volatile bean 模式的基本原理是:很多框架为易变数据的持有者(例如 HttpSession )提供了容器,但是放入这些容器中的对象必须是线程安全的。

在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通——即不包含约束!

1. @ThreadSafe
2. public class Person {
3. private volatile String firstName;
4. private volatile String lastName;
5. private volatile int age;
6.
6. public String getFirstName() { return firstName; }
7. public String getLastName() { return lastName; }
8. 9. public int getAge() { return age; }
10.
11. public void setFirstName(String firstName) {
12. this.firstName = firstName;
13. }
14.
15. public void setLastName(String lastName) {
16. this.lastName = lastName;
17. }
18.
19. public void setAge(int age) {
20. this.age = age;
21. }
22. }

开销较低的“读-写锁”策略

如果读操作远远超过写操作,您可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。如下显示的线程安全的计数器,使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及volatile 读操作,这通常要优于一个无竞争的锁获取的开销。

1. @ThreadSafe
2. public class CheesyCounter {
3. // Employs the cheap read-write lock trick
4. // All mutative operations MUST be done with the 'this' lock held
5. @GuardedBy("this") private volatile int value;
6.
7. //读操作,没有synchronized,提高性能
8. public int getValue() {
9. return value;
10. }
11.
12. //写操作,必须synchronized。因为x++不是原子操作
13. public synchronized int increment() {
14. return value++;
15. }

使用锁进行所有变化的操作,使用 volatile 进行只读操作。

其中,锁一次只允许一个线程访问值,volatile 允许多个线程执行读操作


相关文章
|
25天前
|
设计模式 Java 关系型数据库
【Java笔记+踩坑汇总】Java基础+JavaWeb+SSM+SpringBoot+SpringCloud+瑞吉外卖/谷粒商城/学成在线+设计模式+面试题汇总+性能调优/架构设计+源码解析
本文是“Java学习路线”专栏的导航文章,目标是为Java初学者和初中高级工程师提供一套完整的Java学习路线。
211 37
|
25天前
|
设计模式 安全 算法
【Java面试题汇总】设计模式篇(2023版)
谈谈你对设计模式的理解、七大原则、单例模式、工厂模式、代理模式、模板模式、观察者模式、JDK中用到的设计模式、Spring中用到的设计模式
【Java面试题汇总】设计模式篇(2023版)
|
25天前
|
存储 关系型数据库 MySQL
【Java面试题汇总】MySQL数据库篇(2023版)
聚簇索引和非聚簇索引、索引的底层数据结构、B树和B+树、MySQL为什么不用红黑树而用B+树、数据库引擎有哪些、InnoDB的MVCC、乐观锁和悲观锁、ACID、事务隔离级别、MySQL主从同步、MySQL调优
【Java面试题汇总】MySQL数据库篇(2023版)
|
25天前
|
存储 缓存 NoSQL
【Java面试题汇总】Redis篇(2023版)
Redis的数据类型、zset底层实现、持久化策略、分布式锁、缓存穿透、击穿、雪崩的区别、双写一致性、主从同步机制、单线程架构、高可用、缓存淘汰策略、Redis事务是否满足ACID、如何排查Redis中的慢查询
【Java面试题汇总】Redis篇(2023版)
|
14天前
|
消息中间件 NoSQL Java
Java知识要点及面试题
该文档涵盖Java后端开发的关键知识点,包括Java基础、JVM、多线程、MySQL、Redis、Spring框架、Spring Cloud、Kafka及分布式系统设计。针对每个主题,文档列举了重要概念及面试常问问题,帮助读者全面掌握相关技术并准备面试。例如,Java基础部分涉及面向对象编程、数据类型、异常处理等;JVM部分则讲解内存结构、类加载机制及垃圾回收算法。此外,还介绍了多线程的生命周期、同步机制及线程池使用,数据库设计与优化,以及分布式系统中的微服务、RPC调用和负载均衡等。
|
2月前
|
存储 Java
【IO面试题 四】、介绍一下Java的序列化与反序列化
Java的序列化与反序列化允许对象通过实现Serializable接口转换成字节序列并存储或传输,之后可以通过ObjectInputStream和ObjectOutputStream的方法将这些字节序列恢复成对象。
|
2月前
|
Java C++
【Java基础面试十七】、Java为什么是单继承,为什么不能多继承?
这篇文章讨论了Java单继承的设计原因,指出Java不支持多继承主要是为了避免方法名冲突等混淆问题,尽管Java类不能直接继承多个父类,但可以通过接口和继承链实现类似多继承的效果。
【Java基础面试十七】、Java为什么是单继承,为什么不能多继承?
|
2月前
|
XML 存储 JSON
【IO面试题 六】、 除了Java自带的序列化之外,你还了解哪些序列化工具?
除了Java自带的序列化,常见的序列化工具还包括JSON(如jackson、gson、fastjson)、Protobuf、Thrift和Avro,各具特点,适用于不同的应用场景和性能需求。
|
2月前
|
Java
【Java基础面试三十七】、说一说Java的异常机制
这篇文章介绍了Java异常机制的三个主要方面:异常处理(使用try、catch、finally语句)、抛出异常(使用throw和throws关键字)、以及异常跟踪栈(异常传播和程序终止时的栈信息输出)。
|
2月前
|
Java
【Java基础面试三十八】、请介绍Java的异常接口
这篇文章介绍了Java的异常体系结构,主要讲述了Throwable作为异常的顶层父类,以及其子类Error和Exception的区别和处理方式。