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

本文涉及的产品
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 在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的实现原理,这些还是很考验基本功的。

目录
相关文章
|
20天前
|
缓存 Java 调度
多线程编程核心:上下文切换深度解析
在现代计算机系统中,多线程编程已成为提高程序性能和响应速度的关键技术。然而,多线程编程中一个不可避免的概念就是上下文切换(Context Switching)。本文将深入探讨上下文切换的概念、原因、影响以及优化策略,帮助你在工作和学习中深入理解这一技术干货。
37 10
|
20天前
|
存储 编译器 C语言
【C语言】数据类型全解析:编程效率提升的秘诀
在C语言中,合理选择和使用数据类型是编程的关键。通过深入理解基本数据类型和派生数据类型,掌握类型限定符和扩展技巧,可以编写出高效、稳定、可维护的代码。无论是在普通应用还是嵌入式系统中,数据类型的合理使用都能显著提升程序的性能和可靠性。
40 8
|
20天前
|
算法 调度 开发者
多线程编程核心:上下文切换深度解析
在多线程编程中,上下文切换是一个至关重要的概念,它直接影响到程序的性能和响应速度。本文将深入探讨上下文切换的含义、原因、影响以及如何优化,帮助你在工作和学习中更好地理解和应用多线程技术。
31 4
|
19天前
|
存储 算法 Java
Java内存管理深度解析####
本文深入探讨了Java虚拟机(JVM)中的内存分配与垃圾回收机制,揭示了其高效管理内存的奥秘。文章首先概述了JVM内存模型,随后详细阐述了堆、栈、方法区等关键区域的作用及管理策略。在垃圾回收部分,重点介绍了标记-清除、复制算法、标记-整理等多种回收算法的工作原理及其适用场景,并通过实际案例分析了不同GC策略对应用性能的影响。对于开发者而言,理解这些原理有助于编写出更加高效、稳定的Java应用程序。 ####
|
19天前
|
存储 监控 算法
Java虚拟机(JVM)垃圾回收机制深度解析与优化策略####
本文旨在深入探讨Java虚拟机(JVM)的垃圾回收机制,揭示其工作原理、常见算法及参数调优方法。通过剖析垃圾回收的生命周期、内存区域划分以及GC日志分析,为开发者提供一套实用的JVM垃圾回收优化指南,助力提升Java应用的性能与稳定性。 ####
|
21天前
|
Java 数据库连接 开发者
Java中的异常处理机制:深入解析与最佳实践####
本文旨在为Java开发者提供一份关于异常处理机制的全面指南,从基础概念到高级技巧,涵盖try-catch结构、自定义异常、异常链分析以及最佳实践策略。不同于传统的摘要概述,本文将以一个实际项目案例为线索,逐步揭示如何高效地管理运行时错误,提升代码的健壮性和可维护性。通过对比常见误区与优化方案,读者将获得编写更加健壮Java应用程序的实用知识。 --- ####
|
1月前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
71 2
|
2月前
|
缓存 Java 程序员
Map - LinkedHashSet&Map源码解析
Map - LinkedHashSet&Map源码解析
76 0
|
2月前
|
算法 Java 容器
Map - HashSet & HashMap 源码解析
Map - HashSet & HashMap 源码解析
62 0
|
2月前
|
存储 Java C++
Collection-PriorityQueue源码解析
Collection-PriorityQueue源码解析
66 0

推荐镜像

更多
下一篇
DataWorks