深入解析Java中volatile关键字

news2024/12/25 3:00:54

前言

我们都听说过volatile关键字,也许大家都知道它在Java多线程编程编程中可以减少很多的线程安全问题,但是会用或者用好volatile关键字的开发者可能少之又少,包括我自己。通常在遇到同步问题时,首先想到的一定是加锁,也就是synchronize关键字,暴力锁解决一切多线程疑难杂症。但,锁的代价是很高的。会导致线程阻塞、系统线程调度这些问题,都会造成很严重的性能影响。如果在一些合适的场景,使用volatile,既保证了线程安全,又极大地提高了性能

那么,今天就带大家深入了解volatile,搞清楚volatile是什么东西?它有什么作用?在什么场景下适用?它的底层原理是什么?它真的可以保证线程安全吗?

那么,我们开始吧。

认识volatile

volatile关键字的作用有两个:变量修改对其他线程立即可见、禁止指令重排。

第二个作用我们后面再讲,先主要讲一下第一个作用。通俗点来说,就是我在一个线程对一个变量进行了修改,那么其他线程马上就可以知道我修改了它。嗯?难道我修改了数值其他线程不知道?我们先从实例代码中来感受volatile关键字的第一个作用。 

package io.renren.java;


public class Test {
    private  boolean stopSignal = false;
    public static void main(String[] args) {
        Test test = new Test();
        test.fun();

    }

    public void fun(){
        // 创建10个线程
        for (int i=0;i<=10;i++){
            new Thread(() -> {
                while(!stopSignal){
                    // 循环等待
                }
                System.out.println(Thread.currentThread().toString()+"我停下来了");
            }).start();
        }
        new Thread(() -> {
            stopSignal = true;
            System.out.println("给我停下来");
        }).start();
    }

}

代码很简单,创建10个线程循环等待stopSignal,当stopSignal变为true之后,则跳出循环,打印日志。然后我们再开另外一个线程,把stopSignal改成true。如果按照正常的情况下,应该是先打印“给我停下来”,然后再打印10个“我停下来了”,最后结束进程。我们看看具体情况如何。运行查看结果如下:

为什么没有打印十个我停下来了?而且看左上角边的停止符号还处于等待状态,表示这个进程还没结束。也就是说在剩下的线程中,他们拿到的stopSignal数据依旧是false,而不是最新的true。所以导致他们所处的状态还是while种的循环等待状态。 

所以问题就是:线程中变量的修改,对于其他线程并不是立即可见。导致这个问题的原因我们后面讲,现在是怎么解决这个问题。加锁是个好办法,只要我们在循环判断与修改数值的时候加个锁,就可以拿到最新的数据了。但是前面讲到,锁是个重量级操作,然后再加上循环,这性能估计直接掉下水道里了。最好的解决方法就是:给变量加上volatile关键字,如下:

private volatile  boolean stopSignal = false;

我们再次运行一下,查看一下效果:

可以了,你会惊奇的发现,全部停下来了。使用了volatile关键修饰的变量,只要被修改,那么其他的线程均会立即可见。这就是volatile关键字的第一个重要作用:变量修改对其他线程立即可见。关于指令重排后面再讲。

那么为什么变量的修改是对其他线程不是立即可见呢?volatile为何能实现这个效果?那这样我们可不可以每个变量都给他加上volatile关键字修饰?要解决这些问题,我们得先从Java内存模型说起。系好安全带,我们的车准备开进底层原理了。

认识Java内存模型

Java内存模型不是堆区、栈区、方法区那些,而是线程之间如何共享数据的模型,也可以说是线程对共享数据读写的规范。内存模型是理解Java并发问题的基础。

JVM把内存总体分为两个部分:线程私有以及线程共享,线程共享区域也称为主内存。线程私有部分不会发生并发问题,所以主要是关注线程共享区域。这个图大致上可以这么理解:

  1. 所有共享变量存储在主内存
  2. 每条线程拥有自己的工作内存
  3. 工作内存保留了被该线程使用的变量的主内存副本
  4. 变量操作必须在工作内存进行
  5. 不同线程之间无法访问对方的工作内存

总结一下,就是所有数据都要放在主内存中,线程要操作这些数据必须要先拷贝到自己的工作内存,然后只能对自己工作内存中的数据进行修改;修改完成之后,再写回主内存。线程无法访问另一个线程的数据,这也就是为什么线程私有的数据不存在并发问题。

那为什么不直接从主内存修改数据,而要先在工作内存修改后再写回主内存呢?这就涉及到了高速缓冲区的设计。简单来说,处理器的速度非常快,但是执行的过程中需要频繁在内存中读写数据,而内存访问的速度远远跟不上cpu的速度,导致降低了cpu的效率。因而设计出高速缓冲区,处理器可以直接操作高速缓冲区的数据,等到空闲时间,再把数据写回主内存,提高了性能。而JVM为了屏蔽不同的平台对于高速缓冲区的设计,就设计出了Java内存模型,来让开发者可以面向统一的内存模型进行编程。可以看到,这里的工作内存就对应硬件层面的高速缓冲区。

认识指令重排

前面我们一直没讲指令重排,是因为这是属于JVM在后端编译阶段进行的优化,而在代码中隐藏的问题很难去复现,也很难通过代码执行来看出差别。指令重排是即时编译器对于字节码编译过程中的一种优化,受到运行时环境的影响。限于能力,笔者只能通过理论分析来讲这个问题。

我们知道JVM执行字节码一般有两种方式:解释器执行和即时编译器执行。解释器这个比较容易理解,就是一行行代码解释执行,所以也不存在指令重排的问题。但是解释执行存在很大的问题:解释代码需要耗费一定的处理时间、无法对编译结果进行优化,所以解释执行一般在应用刚启动时或者即时编译遇到异常才使用解释执行。而即时编译则是在运行过程中,把热点代码先编译成机器码,等到运行到该代码的时候就可以直接执行机器码,不需要进行解释,提高了性能。而在编译过程中,我们可以对编译后的机器码进行优化,只要保证运行结果一致,我们可以按照计算机世界的特性对机器码进行优化,如方法内联、公共子表达式消除等等,指令重排就是其中一种。

计算机的思维跟我们人的思维是不一样的,我们按照面向对象的思维来编程,但计算机却必须按照“面向过程”的思维来执行。所以,在不影响执行结果的情况下,JVM会更改代码的执行顺序。注意,这里的不影响执行结果,是指在当前线程下。如我们可能会在一个线程中初始化一个组件,等到初始化完成则设置标志位为true,然后其他线程只需要监听该标志位即可监听初始化是否完成,如下:

// 初始化操作
isFinish = true;

在当前线程看起来,注意,是看起来,会先执行初始化操作,再执行赋值操作,因为结果是符合预期的。但是!!!在其他线程看来,这整个执行顺序都是乱的。JVM可能先执行isFinish赋值操作,再执行初始化操作;而如果你在别的线程监听isFinish变化,就可能出现还未初始化完成isFinish却是true的问题。而volatile可以禁止指令重排,保证在isFinish被赋值之前,所有的初始化动作都已经完成

了解volatile的具体定义

上面讲了两个看起来跟我们的主角volatile关系不大的知识点,但其实是非常重要的知识点。

首先,通过Java内存模型的理解,现在知道为什么会出现线程对变量的修改其他线程未立即可知的原因了吧?线程修改变量之后,可能并不会立即写回主内存,而其他线程,在主内存数据更新后,也并不会立即去主内存获取最新的数据。这也是问题所在。

被volatile关键字修饰的变量规定:每次使用数据都必须去主内存中获取;每次修改完数据都必须马上同步到主内存。这样就实现了每个线程都可以立即收到该变量的修改信息。不会出现读取脏数据旧数据的情况。

第二个作用是禁止指令重排。这里是使用到了JVM的一个规定:同步内存操作前的所有操作必须已经完成。而我们知道每次给volatile赋值的时候,他会同步到主内存中。所以,在同步之前,保证所有操作都必须完成了。所以当其他线程监测到变量的变化时,赋值前的操作就肯定都已经完成了。

既然volatile关键字这么好,那可不可以每个地方都使用好了?当然不行!在前面讲Java内存模型的时候有提到,为了提高cpu效率,才分出了高速缓冲区。如果一个变量并不需要保证线程安全,那么频繁地写入和读取内存,是很大的性能消耗。因而,只有必须使用volatile的地方,才使用他。

volatile修饰的变量一定是线程安全吗

首先明确一下,怎么样才算是线程安全?

  1. 一个操作要符合原子性,要么不执行,要么一次性执行完成,在执行过程中不会受其他操作的影响。
  2. 对于变量的修改相对于其他线程必须立即可见。
  3. 代码的执行在其他线程看来要满足有序性。

从上面分析我们讲了:代码的有序性、修改的可见性,但是,缺少了原子性。在JVM中,在主内存进行读写都是满足原子性的,这是JVM保证的原子性,那么volatile岂不是线程安全的?并不是。

JVM的计算过程并不是原子性的。我们举个例子:

    private volatile int num = 0;

    public void fun(){
        for (int k=0;k<=10;k++){
            new Thread(() -> {
                int a = 10000;
                while (a > 0) {
                    a--;
                    num++;
                }
                System.out.println(num);
            }).start();
        }
    }

按照正常的情况,最后的输出应该是100000才对,我们看看运行结果:

怎么是35220,不应该是10万吗?这是为什么?

原来,这是因为,volatile仅仅只是保证了修改后的数据对其他线程立即可见,但是并不保证运算的过程的原子性。

这里的num++在编译之后是分为三步:

  1. 在工作区中取出变量数据到处理器
  2. 对处理器中的数据进行加一操作
  3. 把数据写回工作内存。如果,在变量数据取到处理器运算的过程中,变量已经被修改了,所以这时候进行自增操作得到的结果就是错误的。

举个例子:

变量a=5,当前线程取5到处理器,这时a被其他线程改成了8,但是处理器继续工作,把5自增得到6,然后把6写回主内存,覆盖数据8,从而导致了错误。因此,由于Java运算的非原子性,volatile并不是绝对线程安全

那什么时候他是安全的:

  1. 计算的结果不依赖原来的状态。
  2. 不需要与其他的状态变量共同参与不变约束。

通俗点来讲,就是运算不需要依赖于任何状态的运算。因为依赖的状态,可能在运算的过程中就已经发生了变化了,而处理器并不知道。如果涉及到需要依赖状态的运算,则必须使用其他的线程安全方案,如加锁,来保证操作的原子性。

适用场景

1:状态标志/多线程通知

状态标志是很适合使用volatile关键字,正如我们在第一部分举的例子,通过设置标志来通知其他所有的线程执行逻辑。或者是如上一部分的例子,当初始化完成之后设置一个标志通知其他线程。

而更加常用的一个类似场景是线程通知。我们可以设置一个变量,然后多个线程观察他。这样只需要在一个线程更改这个数值,那么其他的观察这个变量的线程均可以收到通知。非常轻量级、逻辑也更加简单。

2:保证初始化完整:双重锁检查问题

单例模式是我们经常使用的,其中一种比较常见的写法就是双重锁检查,如下:

public class Singleton {
	// 静态内部变量
   private static Singleton instance;
    // 构造器私有
   private Singleton(){}
    
   public static Singleton getInstance(){
       // 第一重判空
       if (javaClass==null){
           // 加锁,第二重判空
           synchronized(Singleton.class){
               if (instance ==null){
                   instance = new Singleton();
               }
           }
       }
       return instance;
   }
}

这样的代码是否绝对是线程安全的呢?并不是的,在某些极端情况下,仍然会出现问题。而问题就出在javaClass = new JavaClass();这句代码上。

新建对象并不是一个原子操作,他主要有三个子操作:

  1. 分配内存空间
  2. 初始化Singleton实例
  3. 赋值 instance 实例引用

正常情况下,也是按照这个顺序执行。但是JVM是会进行指令重排优化的。就可能变成:

  1. 分配内存空间
  2. 赋值 instance 实例引用
  3. 初始化Singleton实例

赋值引用在初始化之前,那么外部拿到的引用,可能就是一个未完全初始化的对象,这样就造成问题了。所以这里可以给单例对象进行volatile修饰,限制指令重排,就不会出现这种情况了。当然,关于单例模式,更加建议的写法还是利用类加载来保证全局单例以及线程安全,当然,前提是你要保证只有一个类加载器。

加其他类型的初始化标志,也是可以利用volatile关键字来达到限制指令重排的作用。

那么如何保证这个单列可以实现线程安全了?通常我们在网上有看到这样的列子,加volatile关键字,如下所示:

public class Singleton {
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

加了volatile关键字,这样真的就能保证线程吗?答案依然是不能的。

上面的示例代码中,通过在 getInstance 方法中使用双重检查锁定来确保只有一个实例被创建。volatile 关键字可以避免指令重排序,以下是具体的说明。

instance = new Singleton()不是原子操作,这段代码可以简单分为下面三步执行:

  1. 为 instance 分配内存空间;
  2.  初始化 instance;
  3.  将 instance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2,造成未初始化完全的对象发布。

由此可见,使用volatile 关键字在有些场景也是不能达到线程安全的。

在上述列子种,如果我们想实现一个线程安全的单列,可以考虑采用静态内部类和枚举方式实现。

1:采用静态内部类实现单列的线程安全

这种方式采用了类装载的机制来保证初始化实例时只有一个线程。类的静态属性只会在第一次加载类的时候初始化,在这里,JVM 帮助我们保证了线程的安全性,在类进行初始化时,别的线程是无法进入的。

public class Singleton {  
  
    private static class SingletonHolder {  
  	  private static final Singleton INSTANCE = new Singleton();  
    }  
  
    private Singleton () {}  
  
    public static final Singleton getInstance() {  
  	  return SingletonHolder.INSTANCE;  
    }  
}

将 instance 放在了内部类 SingletonHolder 中,前面我们提到饿汉式是类加载时就会立即创建对象,而静态内部类不会,它只会在调用了 getInstance 时,才会加载内部类SingletonHolder,此时才会创建对象。利用静态内部类特点实现延迟加载,效率高。

2:采用枚举方式实现单列的线程安全

这种方式是 Effective Java 作者 Josh Bloch 提倡的方式,它不仅能避免多线程同步问题,而且还自动支持序列化机制,防止反序列化重新创建新的对象,绝对防止多次实例化。

enum Singleton{
    INSTANCE;
    public void say(){
        System.out.println("hello");
    }
}

在上面的示例中,Singleton被定义为一个枚举,其中只有一个元素INSTANCE,该元素代表了Singleton的唯一实例。可以通过Singleton.INSTANCE来访问这个唯一的实例。

限于篇幅这里就不展开了。后面会出一集专门讲Singleton,这里就不要说太远了。

总结

 1. Java并发编程的知识很多,而volatile仅仅只是冰山一角。而学习并发原理与并发编程思想非常重要。只有注重原理,掌握了原理和本质,那么其他的相关知识也是手到擒来。

2. 不同关键字使用场景不同,并不是适用于所有场景,只有掌握了基础的jvm 内存模型,才能合理高效的掌握并发知识。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1794972.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【AI基础】第二步:安装AI运行环境

开局一张图&#xff1a; ​ 接下来按照从下往上的顺序来安装部署。 规则1 注意每个层级的安装版本&#xff0c;上层的版本由下层版本决定 比如CUDA的版本&#xff0c;需要看显卡安装了什么版本的驱动&#xff0c;然后CUDA的版本不能高于这个驱动的版本。 这个比较好理解&…

520表白神器

&#x1f341;博主简介&#xff1a; &#x1f3c5;云计算领域优质创作者 &#x1f3c5;2022年CSDN新星计划python赛道第一名 &#x1f3c5;2022年CSDN原力计划优质作者 &#x1f3c5;阿里云ACE认证高级工程师 &#x1f3c5;阿里云开发者社区专…

Ripple:使用Wavelet Approximations来加速FHE的Programmable Bootstraps

1. 引言 University of Delaware和Nillion团队的 Charles Gouert、Mehmet Ugurbil、Dimitris Mouris、Miguel de Vega 和 Nektarios G. Tsoutsos&#xff0c;2024年论文《Ripple: Accelerating Programmable Bootstraps for FHE with Wavelet Approximations》&#xff0c;开源…

如何理解与学习数学分析——第二部分——数学分析中的基本概念——第9章——可积性

第2 部分&#xff1a;数学分析中的基本概念 (Concepts in Analysis) 9. 可积性(Integrability) 本章讨论了可积性(integrability)的概念(它不同于积分过程)。研究了反导数(antiderivative&#xff0c;或称原函数)和函数图像下面积之间的关系&#xff0c;然后通过对面积的近似…

使用Python绘制南丁格尔图(玫瑰图)

使用Python绘制南丁格尔图&#xff08;玫瑰图&#xff09; 南丁格尔图效果代码 南丁格尔图 南丁格尔图&#xff08;Nightingale Rose Chart&#xff09;&#xff0c;也被称为玫瑰图或极区图&#xff0c;是一种特殊的圆形统计图&#xff0c;用于显示多个类别的数据。它是由弗洛…

用蒙特卡罗积分法近似求解定积分的值及举例

一、背景知识 1、连续随机变量的概率密度函数 对于连续型随机变量的概率密度函数&#xff08;PDF&#xff09;&#xff0c;其在整个定义域上的积分必须等于1。这是概率密度函数的一个基本属性&#xff0c;它确保了随机变量取任何值的概率之和等于1&#xff0c;符合概率论的公…

Leetcode:电话号码的字母组合

题目链接&#xff1a;17. 电话号码的字母组合 - 力扣&#xff08;LeetCode&#xff09; 普通版本&#xff08;回溯&#xff09; class Solution { public:string tmp;//临时存放尾插内容vector<string> res;//将尾插好的字符串成组尾插给resvector<string> board{…

Apache POI(使用Java读写Excel表格数据)

1.Apache POI简介 Apache POI是一个开源的Java库&#xff0c;用于操作Microsoft Office格式的文件。它支持各种Office文档的读写功能&#xff0c;包括Word文档、Excel电子表格、PowerPoint演示文稿、Outlook电子邮件等。Apache POI提供了一组API&#xff0c;使得Java开发者能够…

服务器遭遇UDP攻击时的应对与解决方案

UDP攻击作为分布式拒绝服务(DDoS)攻击的一种常见形式&#xff0c;通过发送大量的UDP数据包淹没目标服务器&#xff0c;导致网络拥塞、服务中断。本文旨在提供一套实用的策略与技术手段&#xff0c;帮助您识别、缓解乃至防御UDP攻击&#xff0c;确保服务器稳定运行。我们将探讨监…

LCTF 2018 bestphp‘s revenge

考点:Soap原生类Session反序列化CRLF注入 <?php highlight_file(__FILE__); $b implode; call_user_func($_GET[f], $_POST); session_start(); if (isset($_GET[name])) { $_SESSION[name] $_GET[name]; } var_dump($_SESSION); $a array(reset($_…

推荐低成本低功耗的纯数字现场可重构IC

CPLD采用CMOS EPROM、EEPROM、快闪存储器和SRAM等编程技术&#xff0c;从而构成了高密度、高速度和低功耗的可编程逻辑器件。 RAMSUN提供的型号LS98003是通用可配置的数字逻辑芯片&#xff0c;有体积小、超低功耗和高可靠性等特点。客户可以根据自己的功能需求设计芯片&#x…

搜索与图论:八皇后问题

搜索与图论&#xff1a;八皇后问题 题目描述参考代码 题目描述 输入样例 4输出样例 .Q.. ...Q Q... ..Q...Q. Q... ...Q .Q..参考代码 #include <iostream>using namespace std;const int N 20;int n; char g[N][N]; bool col[N], dg[N], udg[N];void dfs(int u) {//…

这个世界,对于心态好的人,就是个大游乐场,越刺激越好玩。对于胆小鬼,那就是地狱,随时随地都会受伤

心态决定你的世界&#xff1a;游乐场还是地狱 在这个充满变数的世界里&#xff0c;我们的心态决定了我们看待世界的方式。对于心态积极的人来说&#xff0c;世界就像一个巨大的游乐场&#xff0c;每一个挑战都是一个新的游戏&#xff0c;每一个刺激都是乐趣的一部分。而对于那…

大模型产品层出不穷,如何慧眼识珠?

先预祝亲爱的读者们“端午安康“ 大模型百花齐放&#xff0c;选择难上加难 面对眼前层出不穷的大模型产品&#xff0c;许多人会不禁感到困惑&#xff1a;哪个才是真正适合自己的爆款大模型?在中国本土 alone&#xff0c;就有百来个大模型产品&#xff0c;简直是五花八门&…

Polar Web【简单】upload

Polar Web【简单】upload Contents Polar Web【简单】upload思路EXPPythonGo 运行&总结 思路 如题目所说&#xff0c;本题考查的是文件上传漏洞的渗透技巧。 打开环境&#xff0c;发现需要上传的是图片文件&#xff0c;故考虑使用截取数据包进行数据修改进行重放。在重发器…

【C++ | 析构函数】类的析构函数详解

&#x1f601;博客主页&#x1f601;&#xff1a;&#x1f680;https://blog.csdn.net/wkd_007&#x1f680; &#x1f911;博客内容&#x1f911;&#xff1a;&#x1f36d;嵌入式开发、Linux、C语言、C、数据结构、音视频&#x1f36d; ⏰发布时间⏰&#xff1a;2024-06-06 1…

2024 年适用于 Linux 的 5 个微软 Word 替代品

对于那些最近由于隐私问题或其他原因而转向 Linux 的用户来说&#xff0c;可能很难替换他们最喜欢的、不在 Linux 操作系统上运行的应用程序。 寻找流行程序的合适替代品可能会成为一项挑战&#xff0c;而且并不是每个人都准备好花费大量时间来尝试弄清楚什么可以与他们在 Win…

Leetcode:四数之和

题目链接&#xff1a;18. 四数之和 - 力扣&#xff08;LeetCode&#xff09; 普通版本&#xff08;排序 双指针&#xff09; 主旨&#xff1a;类似于三数之和的解法&#xff0c;但需要多加一些限制&#xff0c;同时为了防止多个数组元素的相加之和出现整型溢出问题还要将整型…

C++入门 ros服务通信

一、 开发环境 ubuntu20.04 ros版本noetic 参考视频 https://www.bilibili.com/video/BV1Ci4y1L7ZZ/?p52&spm_id_from333.1007.top_right_bar_window_history.content.click&vd_source4cd1b6f268e2a29a11bea5d2568836ee 二、 编写srv文件 在功能包下面创建srv文件夹…

数据结构(3)栈、队列、数组

1 栈 1.1 栈的定义 后进先出【LIFO】 1.2 基本操作 元素进栈出栈 只能在栈顶进行&#xff01;&#xff01;&#xff01; 经常考的题&#xff1a; 穿插的进行进栈和出栈 可能有多个选项 1.3 顺序栈 1.3.1 初始化 下标是从0开始的 1.3.2 进栈 更简单的写法&#xff1a; 1.3…