java volatile关键字详解

本节目录

 

  1. java内存模型的相关概念
  2. java
  3. 并发编程的三个概念
  4. i++问题
  5. 深入理解volatile关键字
  6. volatile的应用场景

 

1.java内存模型相关概念

大家都知道,计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。如图所示

 也就是,当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。举个简单的例子,比如下面的这段代码

 i = i+1;   //i的初始值为0

单线程执行分析:当线程执行到这句代码的时候,会先从主存中读取i的值为0,复制一份到高速缓存(本地线程缓存)中,然后cpu指令对其加一操作,然后将值写入到高速缓存中,最后刷新到主内存中,单线程将不会出现问题

多线程分析:比如有两个线程,每个线程都有自己的本地内存也就是高速缓存,假如有这种情况,线程一执行到i+1中,并写入到他自己的本地缓存,此时线程二执行i+1时,拿到的i还是0,加一后存入到本地内存,线程一和二把本地内存刷新到主存都会是1,这就发生了缓存不一致性的问题

为了解决缓存不一致性问题,通常来说有以下2种解决方法:

  1)通过在总线加LOCK#锁的方式(不建议使用)

  2)通过缓存一致性协议  (使用最多)

  这2种方式都是硬件层面上提供的方式。

  在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。但是上面的方式会有一个问题,由于在锁住总线期间,程序是串行化,其他CPU无法访问内存,导致效率低下。

  所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,(Cache line置为无效)因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

2.并发编程的三个概念

1.原子性

即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。(和数据库的事务一样)

2.可见性

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

3.有序性

即程序执行的顺序按照代码的先后顺序执行

 

3.深入剖析volatile关键字

volatile关键字的两层语义

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

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

  2)禁止进行指令重排序。volatile原则,对于volatile修饰的变量:volatile之前的代码不能调整到他的后面volatile之后的代码不能调整到他的前面(as if seria)

Happens-before原则:

1. 程序次序规则:同一个线程内,按照代码出现的顺序,前面的代码先行于后面的代码,准确的说是控制流顺序,因为要考虑到分支和循环结构。

2. 管程锁定规则:一个unlock操作先行发生于后面(时间上)对同一个锁的lock操作。

3. volatile变量规则:对一个volatile变量的写操作先行发生于后面(时间上)对这个变量的读操作。

4. 线程启动规则:Thread的start( )方法先行发生于这个线程的每一个操作。

5. 线程终止规则:线程的所有操作都先行于此线程的终止检测。可以通过Thread.join( )方法结束、Thread.isAlive( )的返回值等手段检测线程的终止。

6. 线程中断规则:对线程interrupt( )方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupt( )方法检测线程是否中断

7. 对象终结规则:一个对象的初始化完成先行于发生它的finalize()方法的开始。

8. 传递性:如果操作A先行于操作B,操作B先行于操作C,那么操作A先行于操作C。

总结:一个操作“时间上的先发生”不代表这个操作先行发生;一个操作先行发生也不代表这个操作在时间上是先发生的(重排序的出现)。

时间上的先后顺序对先行发生没有太大的关系,所以衡量并发安全问题的时候不要受到时间顺序的影响,一切以先行发生原则为准。

4.使用volatile关键字的场景

  synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:

  1)对变量的写操作不依赖于当前值

  2)该变量没有包含在具有其他变量的不变式中

  实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

  事实上,我的理解就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

 

CPU 层面的内存屏障

什么是内存屏障?从前面的内容基本能有一个初步的猜想, 内存屏障就是将 store bufferes 中的指令写入到内存,从 而使得其他访问同一共享内存的线程的可见性。
X86 的 memory barrier 指令包括 lfence(读屏障) sfence(写 屏障) mfence(全屏障)

Store Memory Barrier(写屏障) 告诉处理器在写屏障之前 的所有已经存储在存储缓存(store bufferes)中的数据同步 到主内存,简单来说就是使得写屏障之前的指令的结果对 屏障之后的读或者写是可见的

Load Memory Barrier(读屏障) 处理器在读屏障之后的读 操作,都在读屏障之后执行。配合写屏障,使得写屏障之前 的内存更新对于读屏障之后的读操作是可见的
Full Memory Barrier(全屏障) 确保屏障前的内存读写操作 的结果提交到内存之后,再执行屏障后的读写操作 有了内存屏障以后,对于上面这个例子,我们可以这么来 改,从而避免出现可见性问题

总的来说,内存屏障的作用可以通过防止 CPU 对内存的乱 序访问来保证共享数据在多线程并行执行下的可见性 但是这个屏障怎么来加呢?回到最开始我们讲 volatile 关 键字的代码,这个关键字会生成一个 Lock 的汇编指令,这 个指令其实就相当于实现了一种内存屏障

这个时候问题又来了,内存屏障、重排序这些东西好像是 和平台以及硬件架构有关系的。作为 Java 语言的特性,一 次编写多处运行。我们不应该考虑平台相关的问题,并且 这些所谓的内存屏障也不应该让程序员来关心。

什么是 JMM

MM 全称是 Java Memory Model. 什么是 JMM 呢? 通过前面的分析发现,导致可见性问题的根本原因是缓存 以及重排序。 而 JMM 实际上就是提供了合理的禁用缓存 以及禁止重排序的方法。所以它最核心的价值在于解决可 见性和有序性。

JMM 属于语言级别的抽象内存模型,可以简单理解为对硬 件模型的抽象,它定义了共享内存中多线程程序读写操作 的行为规范:在虚拟机中把共享变量存储到内存以及从内 存中取出共享变量的底层实现细节 通过这些规则来规范对内存的读写操作从而保证指令的正 确性,它解决了 CPU 多级缓存、处理器优化、指令重排序 导致的内存访问问题,保证了并发场景下的可见性。

需要注意的是,JMM 并没有限制执行引擎使用处理器的寄 存器或者高速缓存来提升指令执行速度,也没有限制编译 器对指令进行重排序,也就是说在 JMM 中,也会存在缓存 一致性问题和指令重排序问题。只是 JMM 把底层的问题抽 象到 JVM 层面,再基于 CPU 层面提供的内存屏障指令, 以及限制编译器的重排序来解决并发问题

JMM 抽象模型分为主内存、工作内存;主内存是所有线程 共享的,一般是实例对象、静态字段、数组对象等存储在 堆内存中的变量。工作内存是每个线程独占的,线程对变 量的所有操作都必须在工作内存中进行,不能直接读写主 内存中的变量,线程之间的共享变量值的传递都是基于主 内存来完成

Java 内存模型底层实现可以简单的认为:通过内存屏障 (memory barrier)禁止重排序,即时编译器根据具体的底层 体系架构,将这些内存屏障替换成具体的 CPU 指令。对 于编译器而言,内存屏障将限制它所能做的重排序优化。 而对于处理器而言,内存屏障将会导致缓存的刷新操作。 比如,对于 volatile,编译器将在 volatile 字段的读写操作 前后各插入一些内存屏障。

JMM 是如何解决可见性有序性问题的

简单来说,JMM 提供了一些禁用缓存以及进制重排序的方 法,来解决可见性和有序性问题。这些方法大家都很熟悉: volatile、synchronized、final;
以及

JMM 如何解决顺序一致性问题

重排序问题

为了提高程序的执行性能,编译器和处理器都会对指令做 重排序,其中处理器的重排序在前面已经分析过了。所谓 的重排序其实就是指执行的指令顺序。 编译器的重排序指的是程序编写的指令在编译之后,指令 可能会产生重排序来优化程序的执行性能。 从源代码到最终执行的指令,可能会经过三种重排序。

2 和 3 属于处理器重排序。这些重排序可能会导致可见性 问题。编译器的重排序,JMM 提供了禁止特定类型的编译器重排 序。处理器重排序,JMM 会要求编译器生成指令时,会插入内存屏来禁止处理器重排序

当然并不是所有的程序都会出现重排序问题 编译器的重排序和 CPU 的重排序的原则一样,会遵守数据 依赖性原则,编译器和处理器不会改变存在数据依赖关系 的两个操作的执行顺序,比如下面的代码,
a=1; b=a;
a=1;a=2;
a=b;b=1; 这三种情况在单线程里面如果改变代码的执行顺序,都会 导致结果不一致,所以重排序不会对这类的指令做优化。 这种规则也成为 as-if-serial。不管怎么重排序,对于单个 线程来说执行结果不能改变。比如
int a=2; //1
int b=3; //2
int rs=a*b; //3
1 和 3、2 和 3 存在数据依赖,所以在最终执行的指令中, 3 不能重排序到 1 和 2 之前,否则程序会报错。由于 1 和 2 不存在数据依赖,所以可以重新排列 1 和 2 的顺序

JMM 层面的内存屏障

为了保证内存可见性,Java 编译器在生成指令序列的适当

位置会插入内存屏障来禁止特定类型的处理器的重排序, 在 JMM 中把内存屏障分为四类

HappenBefore

它的意思表示的是前一个操作的结果对于后续操作是可见 的,所以它是一种表达多个线程之间对于内存的可见性。 所以我们可以认为在 JMM 中,如果一个操作执行的结果需 要对另一个操作课件,那么这两个操作必须要存在 happens-before 关系。这两个操作可以是同一个线程,也 可以是不同的线程

JMM 中有哪些方法建立 happen-before 规则

程序顺序规则

1. 一个线程中的每个操作,happens-before 于该线程中的 任意后续操作; 可以简单认为是 as-if-serial。单个线程

中的代码顺序不管怎么变,对于结果来说是不变的
顺 序 规 则 表 示 1 happenns-before 2; 3 happens- before 4

2. volatile 变量规则,对于 volatile 修饰的变量的写的操作, 一定 happen-before 后续对于 volatile 变量的读操作; 根据 volatile 规则,2 happens before 3

3传递性规则,如果 1 happens-before 2; 3happens- before 4; 那么传递性规则表示: 1 happens-before 4;

4. start 规则,如果线程 A 执行操作 ThreadB.start(),那么线 程 A 的 ThreadB.start()操作 happens-before 线程 B 中

的任意操作

5. join 规则,如果线程 A 执行操作 ThreadB.join()并成功返 回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB.join()操作成功返回。

Thread t1 = new Thread(()->{ // 此处对共享变量 x 修改 x= 100;
});
// 例如此处对共享变量修改,
// 则这个修改结果对线程 t1 可见 // 主线程启动子线程
t1.start();
t1.join()
// 子线程所有对共享变量的修改
// 在主线程调用 t1.join() 之后皆可见 // 此例中,x==100

6. 监视器锁的规则,对一个锁的解锁,happens-before 于 随后对这个锁的加锁

假设 x 的初始值是 10,线程 A 执行完代码块后 x 的 值会变成 12(执行完自动释放锁),线程 B 进入代码块 时,能够看到线程 A 对 x 的写操作,也就是线程 B 能 够看到 x==12。

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值