深入理解volatile关键字

如题所述

第1个回答  2024-09-19
前言

volatile 这个关键字可能很多朋友都听说过,它有两个重要的特性:保证可见性和禁止指令重排序。但是对于 volatile 的使用以及背后的原理我们一无所知,所以本文将带你好好了解一番。

由于 volatile 关键字是与 Java的内存模型有关的,因此在讲述 volatile 关键之前,我们先来了解一下Java内存模型,然后介绍 volatile 关键字的使用,最后详解 volatile 关键字的原理。废话不多说,我们直接进入正文。

volatile的使用

一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层语义:

保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

禁止进行指令重排序。

volatile保证可见性

先看一段代码,假如线程A先执行,线程B后执行:

publicclassVolatitleTest{privatestaticbooleanstopRequested=false;publicstaticvoidmain(String[]args)throwsInterruptedException{intn=0;Threadthread1=newThread(()->{inti=0;while(!stopRequested){i++;}},"A");Threadthread2=newThread(()->{stopRequested=true;},"B");thread1.start();TimeUnit.SECONDS.sleep(1);//为了演示死循环,特意sleep一秒thread2.start();}}

这段代码是很典型的一段代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。

下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程A在运行的时候,会将 stopRequested 变量的值拷贝一份放在自己的工作内存当中。

那么当线程B更改了 stopRequested 变量的值之后,但是还没来得及写入主存当中,线程B转去做其他事情了,那么线程A由于不知道线程B对 stopRequested 变量的更改,因此还会一直循环下去。

上述代码将 stopRequested 定义为 volatile,就变成了典型的状态标记量案例。

当一个变量被定义成 volatile 之后,它将具备以下特性:保证此变量对所有线程的可见性,这里的“ 可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。具体而言就是说,volatile 关键字可以保证直接从主存中读取一个变量,如果这个变量被修改后,总是会被写回到主存中去。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。

普通变量与 volatile 变量的区别是:volatile 的特殊规则保证了新值能立即同步到主内存,以及每个线程在每次使用 volatile 变量前都立即从主内存刷新。因此我们可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

在本例中,线程B更改了 stopRequested 变量的值之后,新值会被立即回写到主存中,线程A再次读取 stopRequested 变量时要去主存读取。

关于 volatile 变量的可见性,经常会被开发人员误解,他们会误以为下面的描述是正确的:“ volatile 变量对所有线程是立即可见的,对 volatile 变量所有的写操作都能立刻反映到其他线程之中。换句话说,volatile 变量在各个线程中是一致的,所以基于 volatile 变量的运算在并发下是线程安全的”。这句话的论据部分并没有错,但是由其论据并不能得出“ 基于 volatile 变量的运算在并发下是线程安全的”这样的结论。Java 里面的运算操作符并非原子操作,这导致 volatile 变量的运算在并发下一样是不安全的。

volatile无法保证原子性

在JMM一文中提到 volatile 不能保证原子性,接下来我们通过案例进行分析。

publicclassVolatileAddNum{staticvolatileintcount=0;publicstaticvoidmain(String[]args){VolatileAddNumobj=newVolatileAddNum();Threadt1=newThread(()->{obj.add();},"A");Threadt2=newThread(()->{obj.add();},"B");t1.start();t2.start();try{t1.join();t2.join();System.out.println("main线程输入结果为==>"+count);}catch(InterruptedExceptione){e.printStackTrace();}}publicvoidadd(){for(inti=0;i<100000;i++){count++;}}}

上面这段代码做的事情很简单,开了 2 个线程对同一个共享整型变量分别执行十万次加1操作,我们期望最后打印出来 count 的值为200000,但事与愿违,运行上面的代码,count 的值是极有可能不等于 20万的,而且每次运行结果都不一样,总是小于 20万。为什么会出现这个情况呢?

自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:

假如某个时刻变量 count 的值为10,

线程A对变量进行自增操作,线程A先读取了变量 count 的原始值,然后线程A被阻塞了(可能存在的情况);

然后线程B对变量进行自增操作,线程B也去读取变量 count 的原始值,由于线程A只是对变量 count 进行读取操作,而没有对变量进行修改操作,所以主存中 count 的值未发生改变,此时线程B会直接去主存读取 count 的值,发现 count 的值为10,然后进行加1操作,并把11写入工作内存,最后写入主存。

然后线程A接着进行加1操作,由于已经读取了 count 的值,注意此时在线程A的工作内存中 count 的值仍然为10,所以线程A对 count 进行加1操作后 count 的值为11,然后将11写入工作内存,最后写入主存。

那么两个线程分别进行了一次自增操作后,inc只增加了1。

解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改 volatile 变量时,新值对于其他线程来说是可以立即得知的?对,这个没错。这个就是上面的 happens-before 规则中的 volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。但是要注意,线程A对变量进行读取操作之后,被阻塞了的话,并没有对 count 值进行修改。然后虽然 volatile 能保证线程B对变量 count 的值读取是从内存中读取的,但是线程A没有进行修改,所以线程B根本就不会看到修改的值。

根源就在这里,自增操作不是原子性操作,而且 volatile 也无法保证对变量的任何操作都是原子性的。

把上面的代码改成以下任何一种都可以达到效果:

synchronized 关键字,伪码如下:

publicsynchronizedvoidadd(){for(inti=0;i<100000;i++){num++;}}

Lock 锁,代码如下:

publicstaticvolatileintnum=0;Locklock=newReentrantLock();publicsynchronizedvoidadd(){lock.lock();try{for(inti=0;i<100000;i++){num++;}}finally{lock.unlock();}

除了上述两种方案,我们还可以采用 AtomicInteger 来完成加法操作。

publicclassVolatileAddNum{publicstaticintnum=0;publicAtomicIntegerinc=newAtomicInteger();publicstaticvoidmain(String[]args){VolatileAddNumobj=newVolatileAddNum();Threadt1=newThread(()->{obj.add();},"A");Threadt2=newThread(()->{obj.add();},"B");t1.start();t2.start();try{t1.join();t2.join();System.out.println("main线程输入结果为==>"+obj.inc);}catch(InterruptedExceptione){e.printStackTrace();}}publicvoidadd(){for(inti=0;i<100000;i++){//num++;inc.getAndIncrement();}}}

在 JDK1.5的 java.util.concurrent.atomic 包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。

AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。 CAS 实际上是利用处理器提供的CMPXCHG 指令实现的,而处理器执行 CMPXCHG 指令是一个原子性操作。

volatile禁止指令重排

在前面提到 volatile 关键字能禁止指令重排序,所以 volatile 能在一定程度上保证有序性。

volatile 关键字禁止指令重排序有两层意思:

当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;

在进行指令优化时,不能将在对 volatile 变量访问的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。

我们从一个最经典的例子来分析重排序问题。大家应该都很熟悉单例模式的实现,而在并发环境下的单例实现方式,我们通常可以采用双重检查加锁(DCL)的方式来实现。其源码如下:

publicclassSingleton{publicstaticvolatileSingletonsingleton;/***构造函数私有,禁止外部实例化*/privateSingleton(){};publicstaticSingletongetInstance(){if(singleton==null){synchronized(Singleton.class){if(singleton==null){singleton=newSingleton();}}}returnsingleton;}}

现在我们分析一下为什么要在变量 singleton 之间加上 volatile 关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:

(1)分配内存空间。

(2)初始化对象。

(3)将内存空间的地址赋值给对应的引用。

但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:

(1)分配内存空间。

(2)将内存空间的地址赋值给对应的引用。

(3)初始化对象

如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。因此,为了防止这个过程的重排序,我们需要将变量设置为 volatile 类型的变量。

volatile的原理可见性实现

在前文中已经提及过,线程本身并不直接与主内存进行数据的交互,而是通过线程的工作内存来完成相应的操作。这也是导致线程间数据不可见的本质原因。 如下图所示:

volatile 保证此变量对所有线程的可见性,这里的“ 可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。

底层原因:

volatile 使用 Lock 前缀的指令禁止线程本地内存缓存,保证不同线程之间的内存可见性。

在了解 JMM 的相关知识后,我们知道 JVM 为了提高处理速度,处理器不直接和主内存进行通信,而是先将主内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会将缓存中的数据写回到主内存。如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据会立即写回到主内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从主内存中把数据读到处理器缓存里。

Lock 前缀的指令在多核处理器下会引发了两件事情:

将当前处理器缓存行的数据写回到主内存。

一个处理器的缓存回写到主内存会导致其他处理器的缓存无效。

理解 volatile 特性的一个好方法是把对 volatile 变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。从内存语义的角度来说,volatile 的写-读与锁的释放-获取有相同的内存效果:volatile 写和锁的释放有相同的内存语义;volatile 读与锁的获取有相同的内存语义——这使得 volatile 变量的写-读可以实现线程之间的通信。

volatile的内存语义:

volatile 写的内存语义:当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存

volatile 读的内存语义:当读一个volatile变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

volatile写 - 读的内存语义:

线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。

线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。

线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

如下图所示:

禁止指令重排序

在 JMM 一文中有提及编译器和处理器关于重排序的内容,单线程环境下由于遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序,也就不会出现错误。但是多线程环境下,重排序可能会导致无法获取准确的数据。

首先我们来看下指令重排序对内存可见性的影响:

当1和2之间没有数据依赖关系时,1和2之间就可能被重排序(3和4类似)。这样的结果就是:读线程B执行4时,不一定能看到写线程A在执行1时对共享变量的修改。

volatile禁止指令重排序语义的实现关键在于内存屏障。

重排序可能会导致多线程程序出现内存可见性问题。对于处理器重排序,JMM 的处理器重排序规则会要求 Java 编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。

StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。

JMM针对编译器制定volatile重排序规则表:

当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到volatile读之前。

当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序

当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

下面是基于保守策略的 JMM 内存屏障插入策略:

在每个 volatile 写操作的前面插入一个 StoreStore 屏障。

在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。

在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。

在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图:

下面是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图:

从编译器重排序规则和处理器内存屏障插入策略来看,只要 volatile 变量与普通变量之间的重排序可能会破坏volatile 的内存语义(内存可见性),这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

上述 volatile 写和 volatile 读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile 写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面通过具体的示例代码进行说明。

classVolatileBarrierExample{inta;volatileintv1=1;volatileintv2=2;voidreadAndWrite(){inti=v1;//第一个volatile读intj=v2;//第二个volatile读a=i+j;//普通写v1=i+1;//第一个volatile写v2=j*2;//第二个volatile写}…//其他方法}

针对 readAndWrite 方法,编译器对指令可以做如下优化:

注意,最后的 StoreLoad 屏障不能省略。因为第二个 volatile 写之后,方法立即 return。此时编译器可能无法准确断定后面是否会有 volatile 读或写,为了安全起见,编译器通常会在这里插入一个 StoreLoad 屏障。

扩展volatile修饰对象和数组

volatile 修饰对象和数组时,只是保证其引用地址的可见性。

如下述代码所示,nums 加了 volatile之后下面的代码会马上打印“结束”,如果不给数组加 volatile 就永远不会打印。

publicclassVolatileWork{staticvolatileint[]nums=newint[5];publicstaticvoidmain(String[]args){newThread(()->{try{TimeUnit.SECONDS.sleep(1);}catch(InterruptedExceptione){e.printStackTrace();}nums[0]=2;},"A").start();newThread(()->{while(true){//inti=num;if(nums[0]==2){System.out.println("结束");break;}//System.out.println("waiting");}},"B").start();}}

首先需要了解的一点是:数组存放在主内存中,当线程访问该对象时,会将数组引用复制一份到线程的工作内存,甚至有可能将 nums[0] 复制到工作内存中,参考《深入理解Java虚拟机》 如下叙述:

根据 volatile 可见性的实现原理分析,我们知道当执行 nums[0] = 2;语句时,数组引用会回写到主内存中,并且导致线程B工作内存中关于数组引用的缓存行失效,从而导致重新从主内存中读取。但是有一点需要注意的是:nums 引用和 nums[0] 不位于同一缓存行中,所以无法保证 nums[0] 在线程之间的可见性。

为了测试多线程情况下,无法实时读取 nums[0] 的最新值,我们利用下面代码进行演示:

publicclassVolatileWork{staticvolatileint[]nums=newint[5];publicstaticvoidma

logo设计

创造品牌价值

¥500元起

APP开发

量身定制,源码交付

¥2000元起

商标注册

一个好品牌从商标开始

¥1480元起

公司注册

注册公司全程代办

¥0元起

    官方电话官方服务
      官方网站八戒财税知识产权八戒服务商企业需求数字市场

volatile 关键字,你真的理解吗?
volatile 关键字用于保证变量内存可见性和禁止指令重排序,主要涉及 Java 内存模型和并发编程。通过理解 volatile 的作用,可以解决多线程访问共享变量时的可见性问题。在多线程环境下,一个线程修改了共享变量的值后,其他线程需要立即读取到该值,才能保证程序的正确执行。volatile 保证了这一点,通过从主内...

谁能真正整明白java volatile 关键字
这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕。 4.volatile的原理和实现机制 前面讲述了源于volatile关键字的一些使用,下面我们来探讨一下volatile到底如何保证可见性和禁止指令重排序的。 下面这段话摘自《深入理解Java虚拟机》: “观察加入v...

深入理解volatile关键字
由于volatile 关键字是与 Java的内存模型有关的,因此在讲述 volatile 关键之前,我们先来了解一下Java内存模型,然后介绍 volatile 关键字的使用,最后详解 volatile 关键字的原理。废话不多说,我们直接进入正文。volatile的使用 一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层...

深入理解和使用volatile关键字
好了,现在咱们来深入了解一下volatile这个“神秘”的关键字。在Java中,volatile是一种用于声明变量的修饰符。它告诉JVM和编译器,这个变量可能会被多个线程同时访问,而且还不通过锁来控制。这听起来有点像是给变量加了一个“注意”标签,让它在并发环境下表现得更好。首先,小黑给大家强调一下,volati...

volatile关键字详解
本文将深入解析volatile关键字在Java并发编程中的关键语义,以及它与Java内存模型的关系。首先,理解内存模型是理解volatile的基础,内存模型描述了多线程环境中变量如何在主内存和工作内存之间交互。volatile关键字在此中扮演了轻量级同步的角色,但正确理解其语义至关重要。内存模型允许处理器使用缓存优化性能,...

Java并发性:了解“Volatile”关键字
Java并发性:了解“Volatile”关键字 在多线程环境中,volatile关键字是确保线程间共享数据可见性与一致性的重要工具。本文将深入探讨volatile的定义、语境、应用以及误解,帮助开发者正确理解和使用此关键字。volatile是一个用于字段的关键字,它保证了当一个线程向该字段写入值时,写入的值对随后读取该字段...

Java中volatile关键字有什么作用?
Java中的volatile关键字主要用于弱同步,确保变量的实时更新并避免数据竞争。其核心特性包括变量可见性和禁止指令重排序。当一个线程修改volatile变量时,其他线程能够立即看到更新的值,实现数据同步。同时,volatile避免了CPU缓存中的指令重排序,避免了并发时可能出现的不一致性问题,尤其是在多处理器环境下,...

Volatile的实现原理(看这篇就够了)
探讨并发编程的核心要素——原子性、可见性与有序性,Volatile作为关键角色,在多线程环境中确保了可见性与有序性,成为轻量级同步机制的代表。本文旨在全面剖析Volatile的实现原理,通过理论与实践相结合的方式,帮助读者深入理解并熟练掌握Volatile变量的正确运用。Volatile关键字与Java内存模型 在深入探讨...

为什么在多线程程序中要慎用volatile关键字
参照《深入理解java虚拟机》一书,volatile运用在以下场景:1>运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。2>变量不需要与其他的状态变量共同参与不变约束(表示看不懂这句)。因此,在使用volatile关键字时要慎重,并不是只要简单类型变量使用volatile修饰,对这个变量的所有操作...

线程同步的几种方法
volatile关键字:它保证了变量的可见性和禁止指令重排,确保多线程环境下的数据一致性。尤其在多线程读写操作中,volatile有助于避免并发问题。总结来说,线程同步提供了对共享资源的控制,每个方法都有其适用场景和局限性。深入理解和灵活运用这些同步机制,是编写高效、健壮并发代码的关键。更多关于线程同步...

相似回答
大家正在搜