您当前的位置:首页 > 电脑百科 > 程序开发 > 语言 > JAVA

volatile关键字在并发中有哪些作用?

时间:2023-01-10 14:32:43  来源:今日头条  作者:Java架构日记

由于计算机为了充分利用CPU的高性能,以及各个硬件 存取速度巨大的差异带来的一系列问题

  1. 为了充分压榨CPU的性能,CPU 会对指令乱序执行或者语言的编译器会指令重排,让CPU一直工作不停歇,但同时会导致有序性问题。
  2. 为了平衡CPU的寄存器和内存的速度差异,计算机的CPU 增加了高速缓存,但同时导致了 可见性问题
  3. 为了平衡CPU 与 I/O 设备的速度差异,操作系统增加了进程、线程概念,以分时复用 CPU,但同时导致了原子性问题。

JAVA 是最早尝试提供内存模型的编程语言。由于Java 语言是跨平台的,另外各个操作系统总存在一些差异,Java在物理机器的基础上抽象出一个 内存模型(JMM),来简化和管理并发程序。我们都知道Java并发的三大特性:原子性,可见性,有序性

  • 原子性指的是一个不可以被分割的操作,即这个操作在执行过程中不能被中断,要么全部不执行,要么全部执行。且一旦开始执行,不会被其他线程打断。
  • 可见性 指的是一个线程修改了共享变量后,其他线程能立即感知这个变量被修改。
  • 有序性 指程序按照代码的先后顺序执行。 在Java内存模型中,为了提升效率是允许编译器和处理器对指令进行重排序,当然重排序不会影响单线程的运行结果,但是对多线程会有影响

那么本文我们就聊聊关键字volatile ,可能是 Java 中最微妙和最难用的关键字, 看看其在Java内存模型中是如何保证并发操作的原子性、可见性、有序性的?

什么是volatile关键字

volatile是Java中用于修饰变量的关键字,其可以保证该变量的可见性以及顺序性,但是无法保证原子性。更准确地说是volatile关键字只能保证单操作的原子性,比如 x=1,但是无法保证复合操作的原子性,比如x++

其为Java提供了一种轻量级的同步机制:保证被volatile修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知。相比于synchronized关键字(synchronized通常称为重量级锁),volatile更轻量级,开销低,因为它不会引起线程上下文的切换和调度。

保证可见性

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

public class VisibilityTest {
    private boolean flag = true;

    public void change() {
        flag = false;
        System.out.println(Thread.currentThread().getName() + ",已修改flag=false");
    }

    public void load() {
        System.out.println(Thread.currentThread().getName() + ",开始执行.....");
        int i = 0;
        while (flag) {
            i++;
        }
        System.out.println(Thread.currentThread().getName() + ",结束循环");
    }

    public static void mAIn(String[] args) throws InterruptedException {
        VisibilityTest test = new VisibilityTest();

        // 线程threadA模拟数据加载场景
        Thread threadA = new Thread(() -> test.load(), "threadA");
        threadA.start();

        // 让threadA执行一会儿
        Thread.sleep(1000);
        // 线程threadB 修改 共享变量flag
        Thread threadB = new Thread(() -> test.change(), "threadB");
        threadB.start();

    }
}

其中:threadA 负责循环,threadB负责修改 共享变量flag,如果flag=false时,threadA 会结束循环,但是上面的例子会死循环! 原因是threadA无法立即读取到共享变量flag修改后的值。 我们只需 private volatile boolean flag = true;,加上volatile关键字threadA就可以立即退出循环了。

其中Java中的volatile关键字提供了一个功能:那就是被volatile修饰的变量P被修改后,JMM会把该线程本地内存中的这个变量P,立即强制刷新到主内存中去,导致其他线程中的volatile变量P缓存无效,也就是说其他线程使用volatile变量P在时,都是从主内存刷新的最新数据。而普通变量的值在线程间传递的时候一般是通过主内存以共享内存的方式实现的

因此,可以使用volatile来保证多线程操作时变量的可见性。除了volatile,Java中的synchronized和final两个关键字 以及各种 Lock也可以实现可见性。加锁的话, 当一个线程进入 synchronized代码块后,线程获取到锁,会清空本地内存,然后从主内存中拷贝共享变量的最新值到本地内存作为副本,执行代码,又将修改后的副本值刷新到主内存中,最后线程释放锁。

保证有序性

有序性,顾名思义即程序执行的顺序按照代码的先后顺序执行。但现代的计算机中CPU中为了能够让指令的执行尽可能地同时运行起来,提示计算机性能,采用了指令流水线。一个 CPU 指令的执行过程可以分成 4 个阶段:取指、译码、执行、写回。这 4 个阶段分别由 4 个独立物理执行单元来完成。

理想的情况是:指令之间无依赖,可以使流水线的并行度最大化 但是如果两条指令的前后存在依赖关系,比如数据依赖,控制依赖等,此时后一条语句就必需等到前一条指令完成后,才能开始。所以CPU为了提高流水线的运行效率,对无依赖的前后指令做适当的乱序和调度,即现代的计算机中CPU是乱序执行指令的

另一方面,只要不会改变程序的运行结果,Java编译器是可以通过指令重排来优化性能。然而,重排可能会影响本地处理器缓存与主内存交互的方式,可能导致在多线程的情况下发生"细微"的BUG。

指令重排一般可以分为如下三种类型:

  • 编译器优化重排序,编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行重排序,现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统重排序,由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。这并不是显式的将指令进行重排序,只是因为缓存的原因,让指令的执行看起来像乱序。

从 Java 源代码到最终执行的指令序列,一般会经历下面三种重排序:

 

变量初始化赋值

我们一起来看一个例子,让大家体悟volatile关键字的禁止指令重排的作用:

int i = 0;
int j = 0;
int k = 0;
i = 10; 
j = 1; 

对于上面的代码我们正常的执行流程是:

初始化i 初始化j 初始化k i赋值 j赋值

但由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。语句可能的执行顺序如下:

初始化i i赋值 初始化j j赋值 初始化k

指令重排对于非原子性的操作,在不影响最终结果的情况下,其拆分成的原子操作可能会被重新排列执行顺序,提升性能。指令重排不会影响单线程的执行结果,但是会影响多线程并发执行的结果正确性。 但当我们用volatile修饰变量k时:

int i = 0;
int j = 0;
volatile int k = 0;
i = 10; 
j = 1; 

这样会保证上面代码执行顺序:变量i和j的初始化,在volatile int k = 0之前,变量i和j的赋值操作在volatile int k = 0后面

懒汉式单例 -- 双重校验锁 volatile版

我们可以使用volatile关键字去阻止重排 volatile变量周围的读写指令,这种操作通常称为 memory barrier (内存屏障),详情可见:
mp.weixin.qq.com/s/TyiCfVMee… 中 懒汉式单例 -- 双重校验锁 volatile版

隐藏特性

volatile关键字除了禁止指令重排的作用,还有一个特性: 当线程向一个volatile 变量写入时,在线程写入之前的其他所有变量(包括非volatile变量)也会刷新到主内存。当线程读取一个 volatile变量时,它也会读取其他所有变量(包括非volatile变量)与volatile变量一起刷新到主内存。 尽管这是一个重要的特性,但是我们不应该过于依赖这个特性,来"自动"使周围的变量变得volatile,若是我们想让一个变量是volatile的,我们编写程序的时候需要非常明确地用volatile关键字来修饰。

无法保证原子性

volatile关键字无法保证原子性,更准确地说是volatile关键字只能保证单操作的原子性,比如 x=1,但是无法保证复合操作的原子性,比如x++

所谓原子性:即一个或者多个操作作为一个整体,要么全部执行,要么都不执行,并且操作在执行过程中不会被线程调度机制打断;而且这种操作一旦开始,就一直运行到结束,中间不会有任何上下文切换(context switch)

int  = 0;   //语句1,单操作,原子性的操作

i++;         //语句2,复合操作,非原子性的操作

其中:语句2i++ 其实在Java中执行过程,可以分为3步:

  1. i 被从局部变量表(内存)取出,
  2. 压入操作栈(寄存器),操作栈中自增
  3. 使用栈顶值更新局部变量表(寄存器更新写入内存)

执行上述3个步骤的时候是可以进行线程切换的,或者说是可以被另其他线程的 这3 步打断的,因此语句2不是一个原子性操作

volatile版 i++

我们再来看一个例子:

public class Test1 {

    public static volatile int val;

    public static void add() {
        for (int i = 0; i < 1000; i++) {
            val++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(Test1::add);
        Thread t2 = new Thread(Test1::add);
        t1.start();
        t2.start();
        t1.join();//等待该线程终止
        t2.join();
        System.out.println(val);
    }
}

2个线程各循环2000次,每次+1,如果volatile关键字能够保证原子性,预期的结果是2000,但实际结果却是:1127,而且多次执行的结果都不一样,可以发现volatile关键字无法保证原子性。

synchronized版 i++

我们可以利用synchronized关键字来解决上面的问题:

public class SynchronizedTest {
    public static int val;

    public synchronized static void add() {
        for (int i = 0; i < 1000; i++) {
            val++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(SynchronizedTest::add);
        Thread t2 = new Thread(SynchronizedTest::add);
        t1.start();
        t2.start();
        t1.join();//等待该线程终止
        t2.join();
        System.out.println(val);
    }
}

运行结果:2000

Lock版 i++

我们还可以通过加锁来解决上述问题:

public class LockTest {

    public static int val;

    static Lock lock = new ReentrantLock();

    public static void add() {

        for (int i = 0; i < 1000; i++) {

            lock.lock();//上锁
            try {
                val++;
            }catch(Exception e) {
                e.printStackTrace();
            }finally {
                lock.unlock();//解锁
            }

        }

    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(LockTest::add);
        Thread t2 = new Thread(LockTest::add);
        t1.start();
        t2.start();
        t1.join();//等待该线程终止
        t2.join();
        System.out.println(val);
    }

}

运行结果:2000

Atomic版 i++

Java从JDK 1.5开始提供了
java.util.concurrent.atomic包(以下简称Atomic包),这个包中的原子操作类, 靠CAS循环的方式来保证其原子性,是一种用法简单、性能高效、线程安全地更新一个变量的方式。

这些类可以保证多线程环境下,当某个线程在执行atomic的方法时,不会被其他线程打断,而别的线程就像自旋锁一样,一直等到该方法执行完成,才由JVM从等待队列中选择一个线程执行。

我们来用atomic包来解决volatile原子性的问题:

public class AtomicTest {
    public static AtomicInteger val = new AtomicInteger();

    public static void add() {
        for (int i = 0; i < 1000; i++) {
            val.getAndIncrement();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(AtomicTest::add);
        Thread t2 = new Thread(AtomicTest::add);
        t1.start();
        t2.start();
        t1.join();//等待该线程终止
        t2.join();
        System.out.println(val);
    }
}

运行结果:2000, 如果我们维护现有的项目,如果遇到volatile变量最好将其替换为Atomic 变量,除非你真的特别了解volatile。Atomic 就不展开说了,先挖个坑,以后补上

volatile 原理

当大家仔细读完上文的懒汉式单例 -- 双重校验锁 volatile版,会发现volatile关键字修饰变量后,我们反汇编后会发现 多出了lock前缀指令,lock前缀指令在汇编中 LOCK指令前缀功能如下:

  • 被修饰的汇编指令成为"原子的"
  • 与被修饰的汇编指令一起提供"内存屏障"效果(lock指令可不是内存屏障)

内存屏障主要分类:

  1. 一类是可以强制读取主内存,强制刷新主内存的内存屏障,叫做Load屏障和Store屏障
  2. 另一类是禁止指令重排序的内存屏障,主要有四个分别叫做LoadLoad屏障、StoreStore屏障、LoadStore屏障、StoreLoad屏障

这4个屏障具体作用:

  • LoadLoad屏障:(指令Load1; LoadLoad; Load2),在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • LoadStore屏障:(指令Load1; LoadStore; Store2),在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:(指令Store1; StoreStore; Store2),在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • StoreLoad屏障:(指令Store1; StoreLoad; Load2),在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

对于volatile操作而言,其操作步骤如下:

  • 每个volatile写入之前,插入一个 StoreStore,写入以后插入一个 StoreLoad
  • 每个volatile读取之前,插入一个 LoadLoad,读取之后插入一个** LoadStore**

我们再总结以下,用volatile关键字修饰变量后,主要发生的变化有哪些?:

  1. 当一个线程修改了 volatile 修饰的变量,当修改后的变量写回主内存时,其他线程能立即看到最新值。即volatile关键字保证了并发的可见性

使用volatile关键字修饰共享变量后,每个线程要操作该变量时会从主内存中将变量拷贝到本地内存作为副本,但当线程操作完变量副本,会强制将修改的值立即写入主内存中。 然后通过 CPU总线嗅探机制告知其他线程中该变量副本全部失效,(在CPU层,一个处理器的缓存回写到内存会导致其他处理器的缓存行无效),若其他线程需要该变量,必须重新从主内存中读取。

  1. 在x86的架构中,volatile关键字 底层 含有lock前缀的指令,与被修饰的汇编指令一起提供"内存屏障"效果,禁止了指令重排序,保证了并发的有序性

确保一些特定操作执行的顺序,让cpu必须按照顺序执行指令,即当指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

  1. volatile关键字无法保证原子性**,更准确地说是**volatile关键字只能保证单操作的原子性,比如 x=1,但是无法保证复合操作的原子性,比如x++。

有人可能问赋值操作是原子操作,本来就是原子性的,用volatile修饰有什么意义? 在Java 数据类型足够大的情况下(在 Java 中 long 和 double 类型都是 64 位),写入变量的过程分两步进行,就会发生 **word tearing (字分裂)**情况。 JVM 被允许将64位数量的读写作为两个单独的32位操作执行,这增加了在读写过程中发生上下文切换的可能性,多线程的情况下可能会出现值会被破坏的情况

在缺乏任何其他保护的情况下,用 volatile 修饰符定义一个 long 或 double 变量,可阻止字分裂情况



Tags:volatile   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,不构成投资建议。投资者据此操作,风险自担。如有任何标注错误或版权侵犯请与我们联系,我们将及时更正、删除。
▌相关推荐
C语言中的volatile:变量的易变性和内存访问的优化
概念:在C语言中,volatile是一个关键字,用于告诉编译器变量的值是易变的,可能会在意料之外的情况下发生改变,从而防止编译器对该变量的优化和缓存。volatile关键字用于修饰那些可...【详细内容】
2023-12-31  Search: volatile  点击:(64)  评论:(0)  加入收藏
Java中的Volatile到底是什么?
图片volatile是什么?"volatile"是一个关键字,用于修饰变量。它的作用是告诉编译器该变量可能会在意料之外的时候被修改,因此编译器在对该变量进行优化时需要特别小心。具体来说...【详细内容】
2023-10-11  Search: volatile  点击:(259)  评论:(0)  加入收藏
说说你对volatile关键字的理解?
一、简介在上篇文章中,我们介绍到在多线程环境下,如果编程不当,可能会出现程序运行结果混乱的问题。出现这个原因主要是,JMM 中主内存和线程工作内存的数据不一致,以及多个线程执...【详细内容】
2023-09-21  Search: volatile  点击:(285)  评论:(0)  加入收藏
C语言中volatile关键字的高级玩法
在C语言编程中,volatile是一个重要的关键字,用于告知编译器变量可能会在意料之外被改变,从而避免编译器对该变量的优化。尽管最常见的用途是在多线程编程中,volatile还有一些高...【详细内容】
2023-08-21  Search: volatile  点击:(460)  评论:(0)  加入收藏
volatile关键字在并发中有哪些作用?
由于计算机为了充分利用CPU的高性能,以及各个硬件 存取速度巨大的差异带来的一系列问题 为了充分压榨CPU的性能,CPU 会对指令乱序执行或者语言的编译器会指令重排,让CPU一直工...【详细内容】
2023-01-10  Search: volatile  点击:(223)  评论:(0)  加入收藏
volatile关键字详解
volatile关键字是面试中必问的一个知识点,今天我们来剖析下。volatile这个关键字可能很多朋友都听说过,或许也都用过。在Java 5之前,它是一个备受争议的关键字,因为在程序中使...【详细内容】
2020-08-12  Search: volatile  点击:(251)  评论:(0)  加入收藏
Java并发编程之验证volatile指令重排-理论篇
Java并发编程之验证volatile指令重排-理论篇Java并发包下的类中大量使用了volatile关键字。通过之前文章介绍,大家已经知道了volatile的三大特性:共享变量可见性;不保证原子性;...【详细内容】
2020-03-23  Search: volatile  点击:(313)  评论:(0)  加入收藏
▌简易百科推荐
Java 8 内存管理原理解析及内存故障排查实践
本文介绍Java8虚拟机的内存区域划分、内存垃圾回收工作原理解析、虚拟机内存分配配置,以及各垃圾收集器优缺点及场景应用、实践内存故障场景排查诊断,方便读者面临内存故障时...【详细内容】
2024-03-20  vivo互联网技术    Tags:Java 8   点击:(14)  评论:(0)  加入收藏
如何编写高性能的Java代码
作者 | 波哥审校 | 重楼在当今软件开发领域,编写高性能的Java代码是至关重要的。Java作为一种流行的编程语言,拥有强大的生态系统和丰富的工具链,但是要写出性能优异的Java代码...【详细内容】
2024-03-20    51CTO  Tags:Java代码   点击:(24)  评论:(0)  加入收藏
在Java应用程序中释放峰值性能:配置文件引导优化(PGO)概述
译者 | 李睿审校 | 重楼在Java开发领域,优化应用程序的性能是开发人员的持续追求。配置文件引导优化(Profile-Guided Optimization,PGO)是一种功能强大的技术,能够显著地提高Ja...【详细内容】
2024-03-18    51CTO  Tags:Java   点击:(25)  评论:(0)  加入收藏
Java生产环境下性能监控与调优详解
堆是 JVM 内存中最大的一块内存空间,该内存被所有线程共享,几乎所有对象和数组都被分配到了堆内存中。堆被划分为新生代和老年代,新生代又被进一步划分为 Eden 和 Survivor 区,...【详细内容】
2024-02-04  大雷家吃饭    Tags:Java   点击:(57)  评论:(0)  加入收藏
在项目中如何避免和解决Java内存泄漏问题
在Java中,内存泄漏通常指的是程序中存在一些不再使用的对象或数据结构仍然保持对内存的引用,从而导致这些对象无法被垃圾回收器回收,最终导致内存占用不断增加,进而影响程序的性...【详细内容】
2024-02-01  编程技术汇  今日头条  Tags:Java   点击:(68)  评论:(0)  加入收藏
Java中的缓存技术及其使用场景
Java中的缓存技术是一种优化手段,用于提高应用程序的性能和响应速度。缓存技术通过将计算结果或者经常访问的数据存储在快速访问的存储介质中,以便下次需要时可以更快地获取。...【详细内容】
2024-01-30  编程技术汇    Tags:Java   点击:(72)  评论:(0)  加入收藏
JDK17 与 JDK11 特性差异浅谈
从 JDK11 到 JDK17 ,Java 的发展经历了一系列重要的里程碑。其中最重要的是 JDK17 的发布,这是一个长期支持(LTS)版本,它将获得长期的更新和支持,有助于保持程序的稳定性和可靠性...【详细内容】
2024-01-26  政采云技术  51CTO  Tags:JDK17   点击:(89)  评论:(0)  加入收藏
Java并发编程高阶技术
随着计算机硬件的发展,多核处理器的普及和内存容量的增加,利用多线程实现异步并发成为提升程序性能的重要途径。在Java中,多线程的使用能够更好地发挥硬件资源,提高程序的响应...【详细内容】
2024-01-19  大雷家吃饭    Tags:Java   点击:(106)  评论:(0)  加入收藏
这篇文章彻底让你了解Java与RPA
前段时间更新系统的时候,发现多了一个名为Power Automate的应用,打开了解后发现是一个自动化应用,根据其描述,可以自动执行所有日常任务,说的还是比较夸张,简单用了下,对于office、...【详细内容】
2024-01-17  Java技术指北  微信公众号  Tags:Java   点击:(97)  评论:(0)  加入收藏
Java 在 2023 年仍然流行的 25 个原因
译者 | 刘汪洋审校 | 重楼学习 Java 的过程中,我意识到在 90 年代末 OOP 正值鼎盛时期,Java 作为能够真正实现这些概念的语言显得尤为突出(尽管我此前学过 C++,但相比 Java 影响...【详细内容】
2024-01-10  刘汪洋  51CTO  Tags:Java   点击:(75)  评论:(0)  加入收藏
站内最新
站内热门
站内头条