Java并发编程-volatile关键字详解_舍其小伙伴的博客-CSDN博客

volatile关键字,应该是在java并发编程中,不可或缺的角色,今天就来探讨一下它的各种特性,以及实现原理吧。

文章目录

volatile关键字

Java内存模型

众所周知,当我们在进行读写操作时,肯定不是完全基于内存去操作数据的。java为我们抽象出一个java内存模型,被称为JMM(Java Memory Model),它主要规定了线程和内存之间的一些关系
在这里插入图片描述

  • 工作内存 : 每个线程在创建时,都会拥有自己的工作内存,保存的是对于主存的共享变量的独立拷贝,线程之间是不可见的,无法相互访问,工作内存是基于寄存器和高速缓存的。
  • 主存 : 保存的是共享变量,所有线程都可以访问到的公共区域。

volatile特性

这里我们不得不说到在并发编程中的三大特性,原子性可见性有序性,其中volatile关键字就保证了可见性有序性

可见性

可见性指 当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。

很显然,如果在我们正常代码中,假设主存存在一个变量 i = 10,线程A拷贝了变量i的副本,线程B也拷贝了变量i的副本,他们各自对i进行操作时,线程之间是不可见的,他们无法感知其他线程对该线程的修改。而且再说,对于工作内存中的变量,什么时候被写入主存中,是完全不确定的。

但是,当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

通过反编译字节码,可以从汇编层面来看,在对应被volatile修饰的变量赋值前,会加上lock前缀指令, lock前缀指令在多核处理器下引发了两件事情 :

  • 将当前处理器缓存行的数据写回到系统内存。
  • 这个写回内存操作会使其他CPU里缓存了该内存地址的数据无效。 —— 在《Java并发编程的艺术》p10有详细解释实现原则

如何实现可见性

那么它的可见性是如何实现的呢?

实现可见性,这里就先说一下 MESI缓存一致性协议 了,MESI协议之间各种状态的转换,可以参考我下面晒出链接的那篇文章。这里简单说明一下。

2.1.1.2 MESI协议

MESI(中文名:CPU缓存一致性协议)。这个规则实际上由四种数据状态的描述构成。

  • M(修改,Modified):本地处理器已经修改缓存行,即是脏行,它的内容与内存中的内容不一样,并且此 cache 只有本地一个拷贝(专有)。
  • E(专有,Exclusive):缓存行内容和内存中的一样,而且其它处理器都没有这行数据。
  • S(共享,Shared):缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝。
  • I(无效,Invalid):缓存行失效, 不能使用。

这里拿一张别的博客的MESI状态转换图,链接在最下方。
在这里插入图片描述

  • local read : 本地CPU读。
  • local write : 本地CPU写。
  • remote read : 其他CPU读。
  • remote write : 其他CPU写。

当前状态

事件

行为

下一个状态

I(Invalid)

local read

1.如果其他处理器中没有这份数据,本缓存从内存中取该数据,状态变为E
2.如果其他处理器中有这份数据,且缓存行状态为M,则先把缓存行中的内容写回到内存。本地cache再从内存读取数据,这时两个cache的状态都变为S
3.如果其他缓存行中有这份数据,并且其他缓存行的状态为S或E,则本地cache从内存中取数据,并且这些缓存行的状态变为S

E或S

local write

1.先从内存中取数据,如果其他缓存中有这份数据,且状态为M,则先将数据更新到内存再读取(个人认为顺序是这样的,其他CPU的缓存内容更新到内存中并且被本地cache读取时,两个cache状态都变为S,然后再写时把其他CPU的状态变为I,自己的变为M)
2.如果其他缓存中有这份数据,且状态为E或S,那么其他缓存行的状态变为I

M

E(exclusive)

local read

不影响状态

E

local write

状态变为M

M

remote read

数据和其他核共享,状态变为S

S

remote write

其他CPU修改了数据,状态变为I

I

S(shared)

local read

不影响状态

S

local write

其他CPU的cache状态变为I,本地cache状态变为M

M

remote read

不影响状态

S

remote write

本地cache状态变为I,修改内容的CPU的cache状态变为M

I

M(modified)

local read

不影响状态

M

local write

不影响状态

M

remote read

先把cache中的数据写到内存中,其他CPU的cache再读取,状态都变为S

S

remote write

先把cache中的数据写到内存中,其他CPU的cache再读取并修改后,本地cache状态变为I。修改的那个cache状态变为M

I

这里附上一张EMSI状态转换表,出自文章的链接放在本文最下方。

例子说明可见性的实现【重点】

例子

主存中存在变量i = 10,线程A来读取到变量i = 10,线程B也来读取到了变量i = 10,此时线程A中的变量i,和线程B中的变量i都是Shared状态,
此时线程A执行i++操作,将线程A中的工作内存的变量i 更改为 11,这个时候根据MESI协议,它会向其他拥有相同缓存行的寄存器发送信号,使他们的变量状态改为Invalid状态, 此时他们的缓存就无效了,需要重新去读取最新值。

问题

这里要说明一个问题, MESI协议是如何修改其他变量的状态为Invalid状态的?

答 : 当线程A变量进行本地修改后,就会向远程拥有相同缓存行的寄存器发送一个RFO请求,根据 CPU总线嗅探机制,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。 —— 参考于《Java并发编程的艺术》p9

tip1 : 这里说明了其他寄存器只是修改成无效状态,并没有实时更新值,这是一个点。

tip2 : 由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和cas不断循环,无效交互会导致总线带宽达到峰值。所以不要大量使用Volatile,至于什么时候去使用Volatile什么时候使用锁,根据场景区分。

总结

简单来说,当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

原子性

原子性的概念是一个或多个操作为一个整体,要么都执行且不会受到任何因素的干扰而中断,要么都不执行。在volatile关键字标识的变量进行操作时,是无法保证原子性的。

下面来举一个例子来说明,比如说有AB线程同时对主存的i变量进行自增操作,下面是按时间线排列的。

主存 : 变量 i = 10
线程A : 读取了变量i = 10 ,并进行到 i + 1 操作获得运算结果(运算)
此时线程A被阻塞了
线程B : 读取了变量i = 10,进行 i = i + 1操作(运算,赋值)
(此时的主存中变量i = 11)
此时线程A恢复执行了
线程A : 将运算结果 11 赋值给主存 i,结果还是11

此时就造成了线程安全问题。

为什么不能保证原子性

那么通过上面的例子,为什么volatile关键字无法保证原子性呢?我可以来看上面的具体逻辑。

  • 第一步,线程A读取了变量 i = 10,此时会将i = 10保存在自己工作内存的局部变量表中,而 i + 1的结果11,这些运算操作是在jvm结构模型中的操作数栈中执行的,结果11也是保存在操作数栈中。
  • 第二步,线程B读取了变量 i = 10,进行了i + 1的操作,并将结果赋值给i,根据可见性原理,i的结果会被迅速刷新至主存中,保证主存中是最新值。(可见性原理)
  • 第三步,线程A恢复执行后,虽然主存被更新了,但是他的操作已经在之前读取过了i值,所以按逻辑来说是不会再去更新这个变量i了。这时将操作数栈中的值弹出,将结果11赋值给主存的变量i。
    结果,即主存的 i = 11,造成了线程安全问题。

由此看来,volatile并不能保证操作的原子性。如果一定要保证原子性,建议使用synchronized关键字,这个关键字详解可能会在下一篇写。

Java中的原子性操作

  1. 除long和double之外的基本类型的赋值操作。
  2. 所有引用reference的赋值操作。
  3. Java.concurrent.Atomic.* 包中所有类的一切操作。
java对long和double的赋值操作是非原子操作的原因?

long和double占用的字节数都是8,也就是64bits。 在32位操作系统上对64位的数据的读写要分两步完成,每一步取32位数据。这样对double和long的赋值操作就会有问题:如果有两个线程同时写一个变量内存,一个进程写低32位,而另一个写高32位,这样将导致获取的64位数据是失效的数据。因此需要使用volatile关键字来防止此类现象。 volatile本身不保证获取和设置操作的原子性,仅仅保持修改的可见性。但是java的内存模型保证声明为volatile的long和double变量的get和set操作是原子的。

有序性

有序性的概念是 程序执行的顺序按照代码的先后顺序执行

在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序,这里参考熬丙的文章。

一般重排序可以分为如下三种:

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

但是他们都遵从 as-if-serial 原则。

as-if-serial 原则

编译器、runtime和处理器都遵从该语义,具体即不管怎么重排序,单线程下的执行结果不能被改变。

再说回来volatile关键字。

volatile关键字会禁止指令重排,他一方面可以保证当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作肯定已经全部进行,且结果对后面的操作已经可见,并且在其后面的操作肯定还没有进行, 另一方面可以保证 不能将 volatile 变量后面的语句放在其前面执行,也不能把 volatile 变量前面的语句放到其后面执行。

举个代码例子说明。

static volatile int volatileValue = 10;
static int x;
static int y;

public static void main(String[] args) throws InterruptedException {
    x = 10; // 1
    y = x + 5; // 2
    volatileValue = 15; // 3
    x = 2; // 4
    y = 3; // 5
}

说明两点 :

  • 第一点,语句3的前后(即12,45)他们两者永远都是在语句3的前后,比如,语句12无法在语句3后面执行,语句45无法在语句3前面执行.
  • 第二点,语句12的操作执行结果必须是对34可见的。

如何实现有序性

内存屏障

对于volatile的读写操作,JMM会为对应操作加内存屏障。JMM为volatile加内存屏障有以下4种情况:

  • 每个volatile写操作的前面插入一个StoreStore屏障,防止写volatile与后面的写操作重排序。
  • 每个volatile写操作的后面插入一个StoreLoad屏障,防止写volatile与后面的读操作重排序。
  • 每个volatile读操作的后面插入一个LoadLoad屏障,防止读volatile与后面的读操作重排序。
  • 每个volatile读操作的后面插入一个LoadStore屏障,防止读volatile与后面的写操作重排序。

上述内存屏障的插入策略是非常保守的,比如一个volatile的写操作后面需要加上StoreStore和StoreLoad屏障,但这个写volatile后面可能并没有读操作,因此理论上只加上StoreStore屏障就可以,的确,有的处理器就是这么做的。但JMM这种保守的内存屏障插入策略能够保证在任意的处理器平台,volatile变量都是有序的。

happens-before规则

从JDK5开始,JSR-133使用happens- before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

具体原则的规范可以在百度上查阅,在该篇文章有比较详细的说明volatile有序性和可见性底层原理

有序性的应用场景,双重校验锁…如果有时间会补充例子

这里说明一下在双重校验锁中volatile的使用。

static volatile StringBuilder builder;

public StringBuilder getInstance() {
    // 第一重判定
    if (builder == null) {
        synchronized (this) {
            // 第二重判定
            if (builder == null) {
                /*
                 new并不是一个原子性操作!!具体见对象初始化
                    1. 分配内存空间
                    2. 调用构造器,初始化实例
                    3. 返回地址给引用 
                 */
                builder = new StringBuilder();
            }
        }
    }
    return builder;
}

**如果指令重排序了,那么有可能构造函数在对象初始化完成前就赋值完成了,在内存里面开辟了一片存储区域后直接返回内存的引用,这个时候还没真正的初始化完对象。
如果此时,别的线程去判断发现instance!=null,直接拿去用了,但是这个对象并没有初始化完成,那就有空指针异常了。**

    • *

总结

基于网络上的各种博客参考,如有疑问,请在评论区指点一下!谢谢。

参考文章 :

MESI(缓存一致性协议)
线程基础:多任务处理(18)——MESI协议以及带来的问题:伪共享
阿里面试官没想到,一个Volatile,我都能跟他吹半小时
volatile有序性和可见性底层原理


原网址: 访问
创建于: 2023-01-06 14:47:12
目录: default
标签: 无

请先后发表评论
  • 最新评论
  • 总共0条评论