Java并发编程之Volatile关键字解析

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
简介: 在java的并发编程中我们经常会使用到Volatile关键字。而关于Volatile关键字的使用以及Volatile关键字的特性和实现原理也是在笔面试中经常会遇到的问题了。

1 前言


在java的并发编程中我们经常会使用到Volatile关键字。而关于Volatile关键字的使用以及Volatile关键字的特性和实现原理也是在笔面试中经常会遇到的问题了。


2 正文


volatile关键字虽然从字面上理解起来比较简单,它的中文意思是:易变的; 无定性的; 无常性的; 可能急剧波动的; 不稳定的; 易恶化的; 易挥发的; 易发散的;所以我们大概能够知道这个关键字的大概含义。但是由于volatile关键字是与Java的内存模型有关的,所以想要用好它不是一件容易的事情。


volatile是Java提供的一个轻量级同步机制,作为并发编程里一个重要组成部分,它用来修饰变量。通过volatile修饰的变量可以保证可见性与有序性。在双重检查加锁方式实现的单例中,就有使用,比如下面这个代码


public class User extends Person{
    //使用volatile修饰单例变量
    private volatile static User userBean;
    //获取单例,双重检查加锁方式
    public static User  getInstance() {
        if (userBean== null) {
            synchronized (User.class) {
                if (userBean== null) {
                    userBean= new User ();
                }
            }
        }
        return userBean;
    }
    //构造方法私有
    private User() {
    }
}
复制代码


上面代码中的:


userBean= new User ();
复制代码


一行代码其实做了三件事情:


1、为User对象分配内存


2、实例化对象


3、将userBean引用指向实例


这里还要提到并发编程中的三个重要概念:原子性问题,可见性问题,有序性问题。我们先看具体看一下这三个概念:


1.原子性


原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。


原子性问题很经典的例子就是银行账户转账问题:


比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。如果这2个操作不具备原子性,假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。


2.可见性


可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。


//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
复制代码


如果执行线程1的是CPU1,而执行线程2的是CPU2。当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。


此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.


可见性问题就是线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。


3.有序性


有序性:即程序执行的顺序按照代码的先后顺序执行。


int i = 0;              
boolean flag = false;
i = 1;                //语句1  
flag = true;          //语句2
复制代码


上面定义了一个int型变量和一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。


指令重排序指的是处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。但是虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么问题来了,它靠什么来保证呢?


//语句1
int a = 10; 
//语句2
int r = 2;
//语句3 
a = a + 3;
//语句4
r = a*a;
复制代码


这段代码有4个语句,它的执行顺序可能是:语句2-->语句1-->语句3-->语句4,也可能是语句1-->语句2-->语句3-->语句4,这些对于它的执行结果都是没有影响的,但是执行顺序不可能是:语句2-->语句1-->语句4-->语句3或者语句1-->语句2-->语句4-->语句3。我们按照顺序这些顺序可以发现如果语句4在语句3之前执行,那么结果就会发生改变的。所以根据指令重排序,后面的两种执行顺序是不可能的。


这是因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令2必须用到指令1的结果,那么处理器会保证1必须会在2之前执行。


以上都是单线程的情况下,那么多线程编程的时候呢?


//线程1:
 //语句1
string s= "hello world"; 
//语句2 
bool flag = true;           
//线程2:
while(!flag){
  sleep()
}
PrintHello(s)
复制代码


上面代码中,可以看出语句1和语句2之前没有数据依赖性,因此处理器可能会对其进行指令重排序。如果进行了重排序,那么在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行PrintHello(s)方法,而此时s变量并没有被初始化,就会导致程序出错。


所以指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。


所以在并发编程中要想保证并发程序正确地执行,就必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。


前面说到关于volatile的关键字其实和java的内存模型,也就是JVM有关。


在JMM中,为了提高效率,抽象出一个主内存与工作内存的概念。线程之间的共享变量存储在主内存中,另外每个线程又都配备了一个私有的工作内存,工作内存中使用到的变量需要到主内存去拷贝,线程对变量的读取、赋值操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。


aec617af54754d3bb159e42694e1ccd1~tplv-k3u1fbpfcp-zoom-in-crop-mark_1304_0_0_0.webp.jpg


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


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


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


那么volatile关键字如何保证可见性的呢?


当变量被volatile修饰后,在生成汇编代码指令时会在volatile修饰的共享变量进行写操作的时候添加一个Lock前缀。

Lock前缀表示当一个线程修改该共享变量后,它会将新值立即刷新到主内存中,同时导致该变量在所有线程的本地内存失效,这样其他线程再读取共享变量时,会直接从主内存中读取,达成缓存一致性。这里与synchronize或者Lock等锁机制保证可见性的做法还是有差别的。锁机制的做法是保证同一时刻只有一个线程获取锁并执行同步代码,释放锁时将对变量的修改刷新到主存当中。


再来看看有序性。


前面说到了有序性问题需要归咎于指令重排。在Java内存模型中,如果某些指令之间不存在数据依赖,为了提高效率,是允许编译器和CPU对指令进行重排序,指令重排序不会影响单线程的运行结果,但是对多线程会有影响。


在多线程中为了避免指令重排引起的并发问题,就需要依靠volatile关键字了。在Java编译器生成指令时,对于volatile关键字修饰的变量,会在指令序列中插入特定的内存屏障。


内存屏障其实说到底也是一个指令而已,例如StoreStore、StoreLoad、LoadLoad、LoadStore等,但是它特殊的地方就是告诉编译器和CPU,不管什么指令都不能和我的内存屏障指令重排序。所以被volatile关键字修饰的变量就会被禁止进行指令重排序,这样就能够保证并发编程的有序性。


关于内存屏障的概念设计的概念又比较多,篇幅有限这里就不进行说明了,后面可能会单独记录下,有兴趣的可以自己先了解下。这里我们只需要知道使用volatile关键字修饰的变量会被禁止指令重排,从而保证了有序性。


那么原子性呢?volatile关键字能够保证并发编程的原子性吗?这里答案是不能的!我们先来看看JVM的说明:


JMM规定了所有的变量都存储在主内存(Main Memory)中,多个线程共享主内存中的数据。每个线程都有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量在主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。


这里需要说明自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存,也就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现。


假设在工作内存中有一个共享的变量 int number = 0,并且有四个线程:线程1、线程2、线程3、线程4,并且在四个线程中的操作都是对 number变量进行number++操作。


开始四个线程都将共享变量读入到自己的工作内存中,同时执行++操作,然后线程1将自己的变量刷新到主内存中,此时值为 number = 1,因为变量发生的变化,线程2、线程3都开始重新读取数据进行操作,线程4在没有收到变量发生改变的通知之前,已经将自己的变量刷新到主内存,此时主内存中的变量 number 还是等于1,但此时线程1、线程4都已经执行完,所以最后的结果时2或者3。


原因就是自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。


所以volatile关键字能够保证可见性和有序性但是并不能够保证原子性。


3 总结


所以关于volatile关键字的笔面试题虽然答案很简单,局势保证了可见性和有序性,但不能保证有序性。但是能够考察的方面其实还是很多的比如:volatile的使用;JMM指令重排以及volatile的实现原理,这些还是很考验基本功的。

目录
打赏
0
0
0
0
8
分享
相关文章
Java 并发编程——volatile 关键字解析
本文介绍了Java线程中的`volatile`关键字及其与`synchronized`锁的区别。`volatile`保证了变量的可见性和一定的有序性,但不能保证原子性。它通过内存屏障实现,避免指令重排序,确保线程间数据一致。相比`synchronized`,`volatile`性能更优,适用于简单状态标记和某些特定场景,如单例模式中的双重检查锁定。文中还解释了Java内存模型的基本概念,包括主内存、工作内存及并发编程中的原子性、可见性和有序性。
137 5
Java 并发编程——volatile 关键字解析
|
4月前
|
Java volatile关键字:你真的懂了吗?
`volatile` 是 Java 中的轻量级同步机制,主要用于保证多线程环境下共享变量的可见性和防止指令重排。它确保一个线程对 `volatile` 变量的修改能立即被其他线程看到,但不能保证原子性。典型应用场景包括状态标记、双重检查锁定和安全发布对象等。`volatile` 适用于布尔型、字节型等简单类型及引用类型,不适用于 `long` 和 `double` 类型。与 `synchronized` 不同,`volatile` 不提供互斥性,因此在需要互斥的场景下不能替代 `synchronized`。
2567 3
C++ `noexcept` 关键字的深入解析
`noexcept` 关键字在 C++ 中用于指示函数不会抛出异常,有助于编译器优化和提高程序的可靠性。它可以减少代码大小、提高执行效率,并增强程序的稳定性和可预测性。`noexcept` 还可以影响函数重载和模板特化的决策。使用时需谨慎,确保函数确实不会抛出异常,否则可能导致程序崩溃。通过合理使用 `noexcept`,开发者可以编写出更高效、更可靠的 C++ 代码。
120 1
面试官的加分题:super关键字全解析,轻松应对!
小米,29岁程序员,通过一个关于Animal和Dog类的故事,详细解析了Java中super关键字的多种用法,包括调用父类构造方法、访问父类成员变量及调用父类方法,帮助读者更好地理解和应用super,应对面试挑战。
80 3
深入理解HTTP/2:nghttp2库源码解析及客户端实现示例
通过解析nghttp2库的源码和实现一个简单的HTTP/2客户端示例,本文详细介绍了HTTP/2的关键特性和nghttp2的核心实现。了解这些内容可以帮助开发者更好地理解HTTP/2协议,提高Web应用的性能和用户体验。对于实际开发中的应用,可以根据需要进一步优化和扩展代码,以满足具体需求。
160 29
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
167 2
JS数组操作方法全景图,全网最全构建完整知识网络!js数组操作方法全集(实现筛选转换、随机排序洗牌算法、复杂数据处理统计等情景详解,附大量源码和易错点解析)
这些方法提供了对数组的全面操作,包括搜索、遍历、转换和聚合等。通过分为原地操作方法、非原地操作方法和其他方法便于您理解和记忆,并熟悉他们各自的使用方法与使用范围。详细的案例与进阶使用,方便您理解数组操作的底层原理。链式调用的几个案例,让您玩转数组操作。 只有锻炼思维才能可持续地解决问题,只有思维才是真正值得学习和分享的核心要素。如果这篇博客能给您带来一点帮助,麻烦您点个赞支持一下,还可以收藏起来以备不时之需,有疑问和错误欢迎在评论区指出~
从入门到精通:H5游戏源码开发技术全解析与未来趋势洞察
H5游戏凭借其跨平台、易传播和开发成本低的优势,近年来发展迅猛。接下来,让我们深入了解 H5 游戏源码开发的技术教程以及未来的发展趋势。
分片上传技术全解析:原理、优势与应用(含简单实现源码)
分片上传通过将大文件分割成多个小的片段或块,然后并行或顺序地上传这些片段,从而提高上传效率和可靠性,特别适用于大文件的上传场景,尤其是在网络环境不佳时,分片上传能有效提高上传体验。 博客不应该只有代码和解决方案,重点应该在于给出解决方案的同时分享思维模式,只有思维才能可持续地解决问题,只有思维才是真正值得学习和分享的核心要素。如果这篇博客能给您带来一点帮助,麻烦您点个赞支持一下,还可以收藏起来以备不时之需,有疑问和错误欢迎在评论区指出~

热门文章

最新文章

推荐镜像

更多