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

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

目录
相关文章
|
4天前
|
JSON Java Apache
非常实用的Http应用框架,杜绝Java Http 接口对接繁琐编程
UniHttp 是一个声明式的 HTTP 接口对接框架,帮助开发者快速对接第三方 HTTP 接口。通过 @HttpApi 注解定义接口,使用 @GetHttpInterface 和 @PostHttpInterface 等注解配置请求方法和参数。支持自定义代理逻辑、全局请求参数、错误处理和连接池配置,提高代码的内聚性和可读性。
|
5天前
|
存储 安全 Java
Java多线程编程的艺术:从基础到实践####
本文深入探讨了Java多线程编程的核心概念、应用场景及其实现方式,旨在帮助开发者理解并掌握多线程编程的基本技能。文章首先概述了多线程的重要性和常见挑战,随后详细介绍了Java中创建和管理线程的两种主要方式:继承Thread类与实现Runnable接口。通过实例代码,本文展示了如何正确启动、运行及同步线程,以及如何处理线程间的通信与协作问题。最后,文章总结了多线程编程的最佳实践,为读者在实际项目中应用多线程技术提供了宝贵的参考。 ####
|
2天前
|
监控 安全 Java
Java中的多线程编程:从入门到实践####
本文将深入浅出地探讨Java多线程编程的核心概念、应用场景及实践技巧。不同于传统的摘要形式,本文将以一个简短的代码示例作为开篇,直接展示多线程的魅力,随后再详细解析其背后的原理与实现方式,旨在帮助读者快速理解并掌握Java多线程编程的基本技能。 ```java // 简单的多线程示例:创建两个线程,分别打印不同的消息 public class SimpleMultithreading { public static void main(String[] args) { Thread thread1 = new Thread(() -> System.out.prin
|
4天前
|
存储 缓存 安全
在 Java 编程中,创建临时文件用于存储临时数据或进行临时操作非常常见
在 Java 编程中,创建临时文件用于存储临时数据或进行临时操作非常常见。本文介绍了使用 `File.createTempFile` 方法和自定义创建临时文件的两种方式,详细探讨了它们的使用场景和注意事项,包括数据缓存、文件上传下载和日志记录等。强调了清理临时文件、确保文件名唯一性和合理设置文件权限的重要性。
12 2
|
5天前
|
Java UED
Java中的多线程编程基础与实践
【10月更文挑战第35天】在Java的世界中,多线程是提升应用性能和响应性的利器。本文将深入浅出地介绍如何在Java中创建和管理线程,以及如何利用同步机制确保数据一致性。我们将从简单的“Hello, World!”线程示例出发,逐步探索线程池的高效使用,并讨论常见的多线程问题。无论你是Java新手还是希望深化理解,这篇文章都将为你打开多线程的大门。
|
1月前
|
缓存 Java 程序员
Map - LinkedHashSet&Map源码解析
Map - LinkedHashSet&Map源码解析
66 0
|
1月前
|
算法 Java 容器
Map - HashSet & HashMap 源码解析
Map - HashSet & HashMap 源码解析
52 0
|
1月前
|
存储 Java C++
Collection-PriorityQueue源码解析
Collection-PriorityQueue源码解析
59 0
|
1月前
|
安全 Java 程序员
Collection-Stack&Queue源码解析
Collection-Stack&Queue源码解析
78 0
|
1天前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
9 2

推荐镜像

更多