首页 > 编程语言 >Java volatile关键字剖析

Java volatile关键字剖析

时间:2024-08-15 21:54:21浏览次数:15  
标签:Java 读取 int 写入 关键字 线程 volatile 变量

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档

文章目录


在这里插入图片描述

1. volatile关键字介绍

在阅读本文的时候,需要有Java内存模型相关基础知识,如果不了解Java内存模型的朋友,请点击这里先了解Java内存模型,有助于您更好的理解本文对volatile的讲解。

Java volatile关键字用于将 Java 变量标记为“存储在主内存中”。更准确地说,这意味着线程每次读取 volatile 变量都将从计算机的主内存中读取,而不是从 CPU 寄存器中读取,并且每次写入的 volatile 变量都将被写入主内存,而不仅仅是 CPU 寄存器

实际上,从 Java 5 开始,volatile关键字就起到保证 volatile 变量可以写入主内存和从主内存读取

2. volatile变量可见性问题

volatile关键字保证了跨线程变量更改的可见性。这听起来可能有点抽象,所以让我详细说明一下。

在线程对non-volatile 变量进行操作的多线程应用程序中,出于性能原因,每个线程在处理变量时都可以将变量从主内存复制到CPU寄存器中。如果您的计算机包含多个CPU,则每个线程可能在不同的CPU上运行。这意味着,每个线程都可以将变量复制到不同CPU的CPU寄存器中。如下图所示:
在这里插入图片描述
对于non-volatile 变量,无法保证 Java 虚拟机 (JVM) 何时将数据从主内存读取到 CPU 寄存器,或将数据从 CPU 寄存器写入主内存。这可能会导致几个问题,我将在以下部分中解释这些问题。

想象一下这样一种情况,两个或多个线程可以访问一个共享对象,该对象包含如下声明的counter变量:

public class SharedObject {
    public int counter = 0;
}

想象一下,只有线程 1 会增加counter变量,但线程 1 和线程 2 都可能counter不时读取变量

如果counter变量未声明为volatile,则无法保证counter变量的值何时从CPU寄存器写回主存储器。这意味着CPU寄存器中的counter变量值可能与主存储器中的不同。这种情况如下所示:
在这里插入图片描述
线程看不到变量的最新值,因为它尚未被另一个线程写回主内存,这种问题称为“可见性”问题,即一个线程的更新对其他线程表现为不可见。

3. volatile 变量可见性保证

Java volatile关键字旨在解决变量可见性问题。通过声明counter变量为volatile,可以促使对counter变量的所有写入都将立即写回主内存。此外,counter变量的所有读取都将直接从主存储器中读取。

以下是counter变量的volatile声明:

public class SharedObject {
    public volatile int counter = 0;
}

因此,声明了volatile的变量可以保证对该变量的其他写入线程的可见性

在上面给出的场景中,一个线程(T1)修改counter,另一线程(T2)读取counter(但从不修改),为counter变量声明volatile足以保证T2线程对计数器变量写入的可见性

然而,如果T1和T2都在递增counter变量,那么仅仅为counter变量声明volatile却是不够的,无法保证counter变量最终的值是准确的

3.1 Full volatile完全易失性可见性保证

实际上,Java volatile的可见性保证超出了volatile变量本身。能见度保证如下:

如果线程A写入一个volatile变量,而线程B随后读取了相同的volatile参数,那么线程A在写入volatile之前可见的所有变量在线程B读取volatile后也将可见

如果线程A读取了一个volatile变量,那么线程A在读取volatile时可见的所有变量也将从主存中重新读取。

让我用一个代码示例来说明这一点:

public class MyClass {
    private int years;
    private int months
    private volatile int days;

    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

udpate()方法写入三个变量,其中只有days变量是声明了volatile的。
完全易失性(Full volatile)可见性保证意味着,当一个值被写入days时,线程可见的所有变量也会被写入主存。这意味着,当一个值写入days时,years和months的值也会写入主存。
在读取years, months和days的值时,你可以这样做:

public class MyClass {
    private int years;
    private int months
    private volatile int days;

    public int totalDays() {
        int total = this.days;
        total += months * 30;
        total += years * 365;
        return total;
    }

    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

请注意,totalDays()方法首先将days的值读入total变量。在读取days的值时,years和months的值也会被读取到主存储器中。因此,您可以保证按照上述读取顺序看到最新的 years, months 和days值。

3.2 指令重新排序挑战

出于性能原因,Java VM 和 CPU 可以对程序中的指令进行重新排序,只要指令的语义保持不变。例如,查看以下指令:

int a = 1;
int b = 2;

a++;
b++;

这些指令在执行时,可以重新排序为以下顺序,而不会丢失程序的语义含义:

int a = 1;
a++;

int b = 2;
b++;

然而,当其中一个变量声明了volatile时,指令重新排序会带来挑战。让我们看看本文前面示例中的MyClass类:

public class MyClass {
    private int years;
    private int months
    private volatile int days;

    public void update(int years, int months, int days){
        this.years  = years;
        this.months = months;
        this.days   = days;
    }
}

一旦update()方法向days写入值,新写入的years和month值也会写入主存。但是,如果Java虚拟机对指令进行了重新排序,如下所示:

public void update(int years, int months, int days){
    this.days   = days;
    this.months = months;
    this.years  = years;
}

修改days变量时,monthsyears的值仍会写入主内存,但这次是在新值写入monthsyears之前发生的。因此,新值不能正确地对其他线程可见。重新排序的指令的语义含义发生了变化。

Java为这个问题提供了一个解决方案,请继续阅读下一节

3.3 volatile 的 Happens-Before 保证

为了应对指令重新排序的挑战,Java volatile关键字除了提供可见性保证外,还提供了“happens-before”的保证。担保前发生保证:

如果读取/写入最初发生在写入volatile变量之前,则不能将对其他变量的读取和写入重新排序为在写入volatile变量之后发生。

在写入volatile变量之前进行的读/写操作保证会发生在写入volatile变量之前。请注意,例如,对位于写入volatile变量之后的其他变量的读取/写入仍然有可能在写入volatile之前重新排序。只是不是反过来。从后到前是允许的,但从前到后是不允许的

如果读取/写入最初发生在读取volatile变量之后,则不能将对其他变量的读取和写入重新排序为在读取volable变量之前发生。请注意,在读取volatile变量之前发生的其他变量的读取可能会被重新排序为在读取volable变量之后发生。只是不是反过来。从之前到之后是允许的,但从之后到之前是不允许的。

4. volatile 并不总是够用的

即使volatile关键字保证对volatile变量的所有读取都直接从主存读取,对volatile变量的所有写入都直接写入主存,但仍然存在仅声明一个volatile变量是不够的情况。

在前面解释的只有线程 1 写入共享变量counter的情况下,声明volatile的counter变量足以确保线程 2 始终看到最新的写入值。

事实上,如果写入volatile变量的新值不依赖于其先前的值,多个线程甚至可以写入共享变量,并且仍然将正确的值存储在主内存中。换句话说,如果将值写入共享volatile变量的线程不需要先读取其值来找出其下一个值。

一旦线程需要先读取volatile变量的值,然后根据该值为共享volatile变量生成新值,volatile变量就不足以保证正确的可见性。读取volatile 变量和写入新值之间的短暂时间间隔会产生竞争条件 ,其中多个线程可能会读取相同的变量值volatile,为变量生成新值,并在将值写回主内存时覆盖彼此的值。

多个线程增加同一个counter的情况正是volatile变量将表现不足的情况。以下部分将更详细地解释这种情况。

想象一下,如果线程1将值为0的共享counter变量读取到其CPU寄存器中,将其增量为1,而不是将更改后的值写回主内存。然后,线程2可以从主内存中读取相同的counter变量,其中变量的值仍然是0,并将其读取到自己的CPU寄存器中。然后,线程2也可以将counter递增到1,并且也不会将其写回主内存。这种情况如下图所示:
在这里插入图片描述
线程1和线程2现在几乎不同步。共享counter变量的实际值应该是2,但每个线程在其CPU寄存器中的变量值都是1,而在主存中的值仍然是0。真是一团糟!即使线程最终将共享counter变量的值写回主内存,该值也会出错

5. volatile怎样才能具备原子性?

正如我之前提到的,如果两个线程都在读写共享变量,那么使用volatile关键字是不能保证的该共享变量的原子性的。在这种情况下,您需要使用synchronized来保证变量的读写是原子性的。

作为同步块的替代方案,您还可以使用java.util.concurrent包中的许多原子数据类型之一。例如,AtomicLongAtomicReference或其中之一。

如果只有一个线程读取和写入volatile变量的值,而其他线程只读取该变量,那么读取线程可以保证看到写入volatile变量的最新值。如果不将变量设为 volatile,就无法保证这一点,这就是volatile的可见性特性。

6. volatile的性能考虑

读取和写入volatile变量会导致变量被读取或写入主内存从主存储器读取和写入比访问CPU寄存器昂贵。访问volatile变量还可以防止指令重新排序,这是一种正常的性能增强技术。因此,只有在确实需要强制变量可见性时,才应该使用volatile变量

在实践中,CPU寄存器值通常只会写入CPU L1缓存,这相当快。虽然不如写入CPU寄存器快,但仍然很快。从L1缓存到L2和L3缓存,再到主存储器(RAM)的同步是由与CPU不同的芯片完成的(据我所知),因此CPU没有负担。

即便如此,在实际需要时,尽管只使用volatile变量,这也将迫使您详细了解volatile变量的工作原理!

7. 总结

通过本文对volatile关键字的剖析,我们详细了解了volatile的工作原理。

总体说来,volatile具有如下三大特性:

7.1 可见性

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

7.2 不具备原子性

两个线程都在读写共享变量,那么使用volatile关键字是不能保证的该共享变量的原子性的。在这种情况下,您需要使用synchronized来保证变量的读写是原子性的。

7.3 有序性

有序性是指的volatile变量会禁止前后语句发生指令重排序。因此多线程环境下要保证指令执行的有序性禁止指令被JVM出于性能优化而采取重排序机制),必须要使用volatile关键字。

标签:Java,读取,int,写入,关键字,线程,volatile,变量
From: https://blog.csdn.net/lilinhai548/article/details/141102829

相关文章

  • Java创建线程的方式
    1.继承Thread类第一步,创建一个线程类并继承Thread类第二步,重写run()方法,内部自定义线程执行体第三步,创建自定义的线程类对象,调用start()方法,启动线程示例代码如下publicclassMyThread1extendsThread{@Overridepublicvoidrun(){for(inti=0;i<......
  • JavaSE基础知识分享(七)
    写在前面前面讲的是面向对象中的常用类部分,下面让我们来看看java中集合这部分的内容!在本文的最后给大家发一个题目,便于复习Java面向对象部分的知识!集合数据结构栈和队列数组和链表树哈希表图本部分知识太多,就不一一列举了。了解更多泛型泛型类格式......
  • Java学习的第二天
    今天接着上篇的Java内容继续。首先说一下开发Java的注意事项:1、Java开发是以.Java为拓展名,源文件的基本组成是类(class)。2、应用程序的执行入口是main()方法,且有固定的书写模式:publicstaticvoidmain(String[]args){...}.3、严格区分大小写。4、Java方法是以一条条语句......
  • 高级java每日一道面试题-2024年8月15日-设计模式篇-设计模式与面向对象原则的关系是什
    如果有遗漏,评论区告诉我进行补充面试官:设计模式与面向对象原则的关系是什么?我回答:在设计模式与面向对象原则的关系中,两者紧密相连且相互促进。面向对象的原则为设计模式的形成提供了理论基础和指导思想,而设计模式则是这些原则在特定问题域中的具体实践和实现方式。下......
  • Java集合框架
    常见的集合框架Java集合框架可以分为两大的支线:①、Collection,主要由List、Set、Queue组成:List代表有序、可重复的集合,典型代表就是封装了动态数组的ArrayList和封装了链表的LinkedListSet代表无序、不可重复的集合,典型代表就是HashSet和TreeSet;Queue代表队列,典型代表就......
  • java异常你了解多少
    一、知识点概述(1)异常:异常就是Java程序在运行过程中出现的错误。(2)异常由来:问题也是现实生活中一个具体事务,也可以通过java的类的形式进行描述,并封装成对象。其实就是Java对不正常情况进行描述后的对象体现。(3)JVM的默认处理方案把异常的名称,错误原因及异常出现的位置等......
  • Java Data解决报错过程记录
    [attendancewebservice][24-08-1519:01:03.199][b3960aea15204b76b7c838189c28d45d][10.129.1.238]DEBUG[Thread-10][ne.jdbc.spi.SqlExceptionHelper.logExceptions139]couldnotexecutequery[select*fromid_customerswhereuserid=?]java.sql.SQLExceptio......
  • java7
    一、内部类1.成员内部类在一个类的内部定义的普通类可以访问外部类的所有成员,包括私有成员需要一个外部类的实例来创建成员内部类的实例可以被修饰为public、private、protected或者默认2.静态内部类一个静态内部类是静态的成员类。不需要外部类的实例来创建静态内部类......
  • Python yield和yield from关键字
    在Python中,yield和yieldfrom是两个与生成器(generator)紧密相关的关键字,它们允许函数以迭代的方式逐个返回结果,而不是一次性返回所有结果。这种方式在处理大量数据或需要惰性计算时非常有用,因为它可以节省内存并提高效率。yieldyield关键字用于从函数中返回一个值,并保留函......
  • this关键字
    7.4this关键字目录7.4this关键字7.4.1引用当前对象的实例变量:7.4.2调用当前对象的方法:7.4.3调用当前对象的构造方法:7.4.4返回当前对象:7.4.5作为参数传递当前对象:7.4.6this实现链式调用this关键字在Java中有多种用法,主要用来引用当前对象。以下是this关键字的五种常见......