java编程,如何彻底理解volatile关键字?
原创

java编程,如何彻底理解volatile关键字?

好文
试试语音读文章

热门回答:

volatile在Java语言中扮演者重要的角色。它具有可见性以及禁止指令重排序两个非常显著的特点。要想解释清楚volatile的用法。首先我们要对Java的内存模型JMM有一个非常熟悉的了解。所以我从以下几点来分析volatile。

一、Java内存模型JMM

Java的内存模型规定:所有的变量都保存在主内存中。每一个线程都有属于自己的工作内存。当读取主内存的变量时。线程的工作内存都会都会存储这个变量的副本。线程对变量的操作都是在自己的工作内存中。在适当的时候会把自己工作内存的变量同步到主内存中。

从上面的内容中可以得出一个结论。多线程对变量的修改。都是先修改自己的工作内存的变量。然后把工作内存中修改的在适当的时候同步到主内存中。那么问题就来了。适当的时候是什么时候呢?不确定。所以就有问题了。当主内存中有一个变量i=0,假如同时有两个线程去修改i的值。当线程1读取主内存中的i=1,然后拷贝一份副本在自己的工作内存中。然后i=1,但是这是操作的自己的工作内存i=1,但是这个i=1什么时候刷新到主内存中呢?刚才我们说了。不确定。此时线程二读取主存的变量i=0,然后也拷贝一份到自己的工作内存中。然后i=2,然后在适当的时候刷新到主存中。所以最终的结果可能是线程二i=2的结果先刷新到主存中。线程一i=1最后刷新到主存中。这就导致现在主存中i=1,所以与想象的结果不一样。

二、volatile的大白话

了解了Java的内存模型JMM。我们了解了对于一个共享变量。如果有多个线程并发的修改这个共享变量。最终得到的结果可能与我们想象的不太一样。这是由于JMM的机制导致的。而这和我们所说的volatile有什么关系的。那接下来我们就说说。

结论:1:如果一个变量被volatile修饰。那么它在工作内存中修改的变量会立刻被刷新到主存中。而不是上面所说的不确定的时候

2:如果读取一个被volatile修饰的变量。会把此线程工作内存中的此变量副本置为无效。它会从主内存中重新读取这个变量到自己的工作内存。

上面这两点分别是volatile写内存语义和volatile内存语义。

三、volatile在JDK的使用

在JDK中。并发包中volatile把它的特点发挥到了极致。尤其通过框架AQS的state就是被volatile修饰的。在加上CAS构建出了无锁化的同步框架。在ConcurrentHashMap中也是因为有了volatile的作用加上CAS操作提高了很大的性能。

上面3点只是简单的说明了volatile的作用。如果要详细介绍volatile。估计能够一本上百页的书了。在这里就不再详述了。如果想进一步了解volatile。请关注我的头条。我会有一个关于volatile的专题。

其他观点:

谢谢邀请~!下面从 用法、注意事项、底层原理进行说明!

JMM 基础-计算机原理

Java 内存模型即Java Memory Model,简称JMM,JMM定义了Java 虚拟机(JVM)在计算机(RAM) 中的工作方式。 JVM 是整个计算机虚拟模型。所以JMM是隶属于JVM的。


在计算机系统中。寄存器是L0级缓存。接着依次是L1,L2,L3(接下来是内存。本地磁盘。远程存储).越往上的缓存存储空间越小。速度越快。成本也越高。越往下的存储空间越大。速度更慢。成本也越低。

从上至下。每一层都都可以是看作是更下一层的缓存。即:L0寄存器是L1一级缓存的缓存。

L1是L2的缓存。一次类推;每一层的数据都是来至于它的下一层。

在现在CPU上。一般来说L0,L1,L2。L3都继承在CPU内部。而L1还分为一级数据缓存和一级指令缓存。分别用于存放数据和执行数据的指令解码。每个核心拥有独立的运算处理单元、控制器、寄存器、L1缓存、L2 缓存。然后一个CPU的多个核心共享最后一层CPU缓存L3。

CPU 的缓存一致性解决方案

分为以下两种方案

总线锁(每次锁总线。是悲观锁)

缓存锁(只锁缓存的数据)


MESI协议如下:

M(modify):

I(invalid)

E(Exclusive)

S(Share)

JMM内存模型的八种同步操作

1、read(读取)。从主内存读取数据

2、load(载入):将主内存读取到的数据写入到工作内存

3、use(使用): 从工作内存读取数据来计算

4、assign(赋值):将计算好的值重新赋值到工作内存中

5、store(存储):将工作内存数据写入主内存

6、write(写入):将store过去的变量值赋值给主内存中的变量

7、lock(锁定):将主内存变量加锁。标识为线程 独占状态

8、unlock(解锁):将主内存变量解锁。解锁后其他线程可以锁定该变量

Java 内存模型带来的问题

1、可见性问题

左边CPU中运行的线程从主内存中拷贝对象obj到它的CPU缓存。把对象obj的count变量改为2。但这个变更对运行在右边的CPU中的线程是不可见。因为这个更改还没有flush到主内存中。

在多线程环境下。如果某个线程首次读取共享变量。则首先到主内存中获取该变量。然后存入到工作内存中。以后只需要在工作内存中读取该变量即可。同样如果对该变量执行了修改的操作。则先将新值写入工作内存中。然后再刷新至于内存中。但是什么时候最新的值会被刷新到主内存中是不太确定的。一般来说是很快的。但是具体时间未知。。要解决共享对象可见性问题。我们可以使用volatile关键字或者加锁。

2、竞争问题

线程A 和 线程B 共享一个对象obj, 假设线程A从主存读取obj.count变量到自己的缓存中。同时。线程B也读取了obj.count变量到它的CPU缓存。并且这两个线程都对obj.count做了加1操作。此时。obj.count加1操作被执行了两次。不过都在不同的CPU缓存中。

如果则两个加1操作是串行执行的。那么obj.count变量便会在原始值上加2。最终主内存中obj.count的值会为3。然后图中两个加1操作是并行的。不管是线程A还是线程B先flush计算结果到主存。最终主存中的obj.count只会增加1次变成2。尽管一共有两次加1操作。要解决上面的问题我们可以使用synchronized 代码块。

3、重排序

除了共享内存和工作内存带来的问题。还存在重排序的问题。在执行程序时。为了提高性能。编译器和处理器常常会对指令做重排序。

重排序分3中类型:

(1) 编译器优化的重排序。

(2) 指令级并行的重排序

(3)内存系统的重排序

① 数据依赖性

数据依赖性: 如果两个操作访问同一变量。且这两个操作中有一个为写。此时这两个操作之间就存在数据依赖性。

依赖性分为以下三种:


上图很明显。A和C存在数据依赖。B和C也存在数据依赖。而A和B之间不存在数据依赖。如果重排序了A和C或者B和C的执行顺序。程序的执行结果就会被改变。

很明显。不管如何重排序。都必须保证代码在单线程下的运行正确。连单线程下都无法保证。更不用讨论多线程并发的情况。所以就提出一个as - if -serial 的概念。

4、as - if -serial

意思是:不管怎么重排序(编译器和处理器为了提高并行度)。(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as - if -serial 语义。


A和C之间存在数据依赖。同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中。C不能被重排序A和B的前面(C排到A和B的前面。程序的结果将会被改变)。但A和B之间没有数据依赖关系。编译器和处理器可以重排序A和B之间的执行顺序。

as - if -serial 语义把单线程程序保护了起来。遵守as-if-serial语义的编译器、runtime和处理器可以让我们感觉到: 单线程程序看起来是按程序的顺序来执行的。as-if-srial语义使单线程程序无需担心重排序干扰他们。也无需担心内存可见性的问题。

5、内存屏障

Java 编译器在生成指令序列的适当位置会插入内存屏障来禁止特定类型的处理去重排序。从而让程序按我们预想的流程去执行。

① 保证特定操作的执行顺序

② 影响某些数据(或者是某条指令的执行结果)的内存可见性

编译器和CPU能够重排序指令。保证最终相同的结果。尝试优化性能。插入一条Memory Barrier 会告诉编译器和CPU 。不管什么指令都不能和这条Memory Barrier 指令重排序。

Memory Barrier 所做的另外一件事是强制刷出各种CPU cache, 如一个Write-Barrier(写入屏障)将刷出所在的Barrier 之前写入cache的数据。因此。任何CPU上的线程都能读取到这些数据的最新版本。

JMM把内存屏障指令分为4类:

StoreLoad Barriers 是一个\"全能型\"的屏障。它同时具有其他3个屏障的效果。

volatile 关键字介绍

1、保证可见性

对一个volatile变量的读。总是能看到(任意线程)对这个volatile变量最后的写。

我们先看下面代码:

initFlag 没有用volatile关键字修饰;

上面结果为:

说明一个线程改变initFlag状态。另外一个线程看不见;

如果加上volatile关键字呢?

结果如下:

我们通过汇编看下代码的最终底层实现:

volatile写的内存语义如下:

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

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

比如:

如果我们将flag变量以volatile关键字修饰。那么实际上:线程A在写flag变量后。本地内存A中被线程A更新过的两个共享变量的值都被刷新到主内存中。

在读flag变量后。本地内存B包含的值已经被置为无效。此时。线程B必须从主内存中读取共享变量。线程B的读取操作将导致本地内存B与主内存中的共享变量的值变成一致。

如果我们把volatile写和volatile读两个步骤综合起来看的话。在读线程B读一个volatile变量后。写线程A在写这个volatile变量之前所有可见的共享变量的值都将立即变得对读线程B可见。

2、原子性

volatile 不保证变量的原子性;

运行结果如下:

因为count ++;

包含 三个操作:

(1) 读取变量count

(2) 将count变量的值加1

(3) 将计算后的值再赋给变量count

从JMM内存分析:

下面从字节码分析为什么i++这种的用volatile修改不能保证原子性?

javap : 字节码查看

其实i++这种操作主要可以分为3步:(汇编)

读取volatile变量值到local

增加变量的值

把local的值写回。让其它的线程可见

Load到store到内存屏障。一共4步。其中最后一步jvm让这个最新的变量的值在所有线程可见。也就是最后一步让所有的CPU内核都获得了最新的值。但中间的几步(从Load到Store)是不安全的。中间如果其他的CPU修改了值将会丢失。

3、有序性

(1) volatile重排序规则表

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

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

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

(2) volatile的内存屏障

① volatile写

storestore屏障:对于这样的语句store1; storestore; store2。在store2及后续写入操作执行前。保证store1的写入操作对其它处理器可见。(也就是说如果出现storestore屏障。那么store1指令一定会在store2之前执行。CPU不会store1与store2进行重排序)

storeload屏障:对于这样的语句store1; storeload; load2。在load2及后续所有读取操作执行前。保证store1的写入对所有处理器可见。(也就是说如果出现storeload屏障。那么store1指令一定会在load2之前执行,CPU不会对store1与load2进行重排序

② volatile读

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

loadload屏障:对于这样的语句load1; loadload; load2。在load2及后续读取操作要读取的数据被访问前。保证load1要读取的数据被读取完毕。(也就是说。如果出现loadload屏障。那么load1指令一定会在load2之前执行。CPU不会对load1与load2进行重排序)

loadstore屏障:对于这样的语句load1; loadstore; store2。在store2及后续写入操作被刷出前。保证load1要读取的数据被读取完毕。(也就是说。如果出现loadstore屏障。那么load1指令一定会在store2之前执行。CPU不会对load1与store2进行重排序)

volatile的实现原理

volatile的实现原理

❶ 通过对OpenJDK中的unsafe.cpp源码的分析。会发现被volatile关键字修饰的变量会存在一个“lock:”的前缀。

❷ Lock前缀。Lock不是一种内存屏障。但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁。可以理解为CPU指令级的一种锁。

❸ 同时该指令会将当前处理器缓存行的数据直接写会到系统内存中。且这个写回内存的操作会使在其他CPU里缓存了该地址的数据无效。

❹ 具体的执行上。它先对总线和缓存加锁。然后执行后面的指令。最后释放锁后会把高速缓存中的脏数据全部刷新回主内存。在Lock锁住总线的时候。其他CPU的读写请求都会被阻塞。直到锁释放。

【欢迎随手关注@码农的一天,希望对你有帮助】

其他观点:

从几道相关面试题说起吧。volatile 常被这么问:

谈谈你对 volatile 的理解?

你知道 volatile 底层的实现机制吗?

volatile 变量和 atomic 变量有什么不同?

volatile 的使用场景。你能举两个例子吗?

我们知道Java 内存模型——JMM。 JMM是围绕着并发过程中如何处理可见性、原子性和有序性这 3 个 特征建立起来的。而 volatile 可以保证其中的两个特性。下面具体探讨下这个面试必问的关键字。

1. 概念

volatile 是 Java 中的关键字。是一个变量修饰符。用来修饰会被不同线程访问和修改的变量。

2. Java 内存模型 3 个特性

2.1 可见性

可见性是一种复杂的属性。因为可见性中的错误总是会违背我们的直觉。通常。我们无法确保执行读操作的线程能适时地看到其他线程写入的值。有时甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性。必须使用同步机制。

可见性。是指线程之间的可见性。一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果。另一个线程马上就能看到。

在 Java 中 volatile、synchronized 和 final 都可以实现可见性。

2.2 原子性

原子性指的是某个线程正在执行某个操作时。中间不可以被加塞或分割。要么整体成功。要么整体失败。比如 a=0;(a非long和double类型) 这个操作是不可分割的。那么我们说这个操作是原子操作。再比如:a++; 这个操作实际是a = a + 1;是可分割的。所以他不是一个原子操作。非原子操作都会存在线程安全问题。需要我们使用同步技术(sychronized)来让它变成一个原子操作。一个操作是原子操作。那么我们称它具有原子性。Java的 concurrent 包下提供了一些原子类。AtomicInteger、AtomicLong、AtomicReference等。

在 Java 中 synchronized 和在 lock、unlock 中操作保证原子性。

2.3 有序性

Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程之间操作的有序性。volatile 是因为其本身包含“禁止指令重排序”的语义。synchronized 是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得的。此规则决定了持有同一个对象锁的两个同步块只能串行执行。

3. volatile 是 Java 虚拟机提供的轻量级的同步机制

保证可见性

不保证原子性

禁止指令重排(保证有序性)

3.1 空说无凭。代码验证

3.1.1 可见性验证

class MyData {

int number = 0;

public void add() {

this.number = number + 1;

}

}

// 启动两个线程。一个work线程。一个main线程。work线程修改number值后。查看main线程的number

private static void testVolatile() {

MyData myData = new MyData();

new Thread(() -> {

System.out.println(Thread.currentThread().getName()+\"\t come in\");

try {

TimeUnit.SECONDS.sleep(2);

myData.add();

System.out.println(Thread.currentThread().getName()+\"\t update number value :\"+myData.number);

} catch (InterruptedException e) {

e.printStackTrace();

}

}, \"workThread\").start();

//第2个线程。main线程

while (myData.number == 0){

//main线程还在找0

}

System.out.println(Thread.currentThread().getName()+\"\t mission is over\");

System.out.println(Thread.currentThread().getName()+\"\t mission is over。main get number is:\"+myData.number);

}

}

运行 方法。输出如下。会发现在 main 线程死循环。说明 main 线程的值一直是 0

workThread execute

workThread update number value :1

修改 ,。在 number 前加关键字 volatile,重新运行。main 线程获取结果为 1

workThread execute

workThread update number value :1

main execute over。main get number is:1

3.1.2 不保证原子性验证

class MyData {

volatile int number = 0;

public void add() {

this.number = number + 1;

}

}

private static void testAtomic() throws InterruptedException {

MyData myData = new MyData();

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

new Thread(() ->{

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

myData.addPlusPlus();

}

},\"addPlusThread:\"+ i).start();

}

//等待上边20个线程结束后(预计5秒肯定结束了)。在main线程中获取最后的number

TimeUnit.SECONDS.sleep(5);

while (Thread.activeCount() > 2){

Thread.yield();

}

System.out.println(\"final value:\"+myData.number);

}

运行 发现最后的输出值。并不一定是期望的值 10000。往往是比 10000 小的数值。

final value:9856

为什么会这样呢。因为 在转化为字节码指令的时候是4条指令

获取原始值

将值入栈

进行加 1 操作

把 后的操作写回主内存

这样在运行时候就会存在多线程竞争问题。可能会出现了丢失写值的情况。

如何解决原子性问题呢?

加 或者直接使用 原子类。

3.1.3 禁止指令重排验证

计算机在执行程序时。为了提高性能。编译器和处理器常常会对指令做重排。一般分为以下 3 种

处理器在进行重排序时必须要考虑指令之间的数据依赖性。我们叫做 语义

单线程环境里确保程序最终执行结果和代码顺序执行的结果一致;但是多线程环境中线程交替执行。由于编译器优化重排的存在。两个线程中使用的变量能否保证一致性是无法确定的。结果无法预测。

我们往往用下面的代码验证 volatile 禁止指令重排。如果多线程环境下。`最后的输出结果不一定是我们想象到的 2。这时就要把两个变量都设置为 volatile。

public class ReSortSeqDemo {

int a = 0;

boolean flag = false;

public void mehtod1(){

a = 1;

flag = true;

}

public void method2(){

if(flag){

a = a +1;

System.out.println(\"reorder value: \"+a);

}

}

}

实现禁止指令重排优化。从而避免了多线程环境下程序出现乱序执行的现象。

还有一个我们最常见的多线程环境中 版本的单例模式中。就是使用了 volatile 禁止指令重排的特性。

public class Singleton {

private static volatile Singleton instance;

private Singleton(){}

// DCL

public static Singleton getInstance(){

if(instance ==null){ //第一次检查

synchronized (Singleton.class){

if(instance == null){ //第二次检查

instance = new Singleton();

}

}

}

return instance;

}

}

因为有指令重排序的存在。双端检索机制也不一定是线程安全的。

why ?

Because: 初始化对象的过程其实并不是一个原子的操作。它会分为三部分执行。

给 instance 分配内存

调用 instance 的构造函数来初始化对象

将 instance 对象指向分配的内存空间(执行完这步 instance 就为非 null 了)

步骤 2 和 3 不存在数据依赖关系。如果虚拟机存在指令重排序优化。则步骤 2和 3 的顺序是无法确定的。如果A线程率先进入同步代码块并先执行了 3 而没有执行 2。此时因为 instance 已经非 null。这时候线程 B 在第一次检查的时候。会发现 instance 已经是 非null 了。就将其返回使用。但是此时 instance 实际上还未初始化。自然就会出错。所以我们要限制实例对象的指令重排。用 volatile 修饰(JDK 5 之前使用了 volatile 的双检锁是有问题的)。

4. 原理

volatile 可以保证线程可见性且提供了一定的有序性。但是无法保证原子性。在 JVM 底层是基于内存屏障实现的。

当对非 volatile 变量进行读写的时候。每个线程先从内存拷贝变量到 CPU 缓存中。如果计算机有多个CPU。每个线程可能在不同的 CPU 上被处理。这意味着每个线程可以拷贝到不同的 CPU cache 中

而声明变量是 volatile 的。JVM 保证了每次读变量都从内存中读。跳过 CPU cache 这一步。所以就不会有可见性问题

对 volatile 变量进行写操作时。会在写操作后加一条 store 屏障指令。将工作内存中的共享变量刷新回主内存;

对 volatile 变量进行读操作时。会在写操作后加一条 load 屏障指令。从主内存中读取共享变量;

通过 hsdis 工具获取 JIT 编译器生成的汇编指令来看看对 volatile 进行写操作CPU会做什么事情。还是用上边的单例模式。可以看到

PS:具体的汇编指令对我这个 Javaer 太南了。但是 JVM 字节码我们可以认识。 的含义是给一个静态变量设置值。那这里的 ,而且是第 17 行代码。更加确定是给 instance 赋值了。果然像各种资料里说的。找到了 据说还得翻阅。这里可以看下这两篇 https://www.jianshu.com/p/6ab7c3db13c3 、 https://www.cnblogs.com/xrq730/p/7048693.html )

有 volatile 修饰的共享变量进行写操作时会多出第二行汇编代码。该句代码的意思是对原值加零。其中相加指令addl前有 lock 修饰。通过查IA-32架构软件开发者手册可知。lock前缀的指令在多核处理器下会引发两件事情:

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

这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效

正是 lock 实现了 volatile 的「防止指令重排」「内存可见」的特性

5. 使用场景

您只能在有限的一些情形下使用 volatile 变量替代锁。要使 volatile 变量提供理想的线程安全。必须同时满足下面两个条件:

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

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

其实就是在需要保证原子性的场景。不要使用 volatile。

6. volatile 性能

volatile 的读性能消耗与普通变量几乎相同。但是写操作稍慢。因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

引用《正确使用 volaitle 变量》一文中的话:

很难做出准确、全面的评价。例如 “X 总是比 Y 快”。尤其是对 JVM 内在的操作而言。(例如。某些情况下 JVM 也许能够完全删除锁机制。这使得我们难以抽象地比较 和 的开销。)就是说。在目前大多数的处理器架构上。volatile 读操作开销非常低 —— 几乎和非 volatile 读操作一样。而 volatile 写操作的开销要比非 volatile 写操作多很多。因为要保证可见性需要实现内存界定(Memory Fence)。即便如此。volatile 的总开销仍然要比锁获取低。

volatile 操作不会像锁一样造成阻塞。因此。在能够安全使用 volatile 的情况下。volatile 可以提供一些优于锁的可伸缩特性。如果读操作的次数要远远超过写操作。与锁相比。volatile 变量通常能够减少同步的性能开销。

参考

《深入理解Java虚拟机》

http://tutorials.jenkov.com/java-concurrency/java-memory-model.html https://juejin.im/post/5dbfa0aa51882538ce1a4ebc

《正确使用 Volatile 变量》

https://www.ibm.com/developerworks/cn/java/j-jtp06197.htm

l

您还感兴趣的文章推荐

以上就是由互联网推广工程师 网创网 整理编辑的,如果觉得有帮助欢迎收藏转发~

分享到 :
相关推荐

发表评论

登录... 后才能评论

评论(2)

  • 风华三生 永久VIP 2022年12月27日 18:54:50

    线程,变量,内存,操作,指令,屏障,缓存,数据,原子,主存

  • 心太野╮ 永久VIP 2022年12月27日 18:54:50

    没想到大家都对java编程,如何彻底理解volatile关键字?感兴趣,不过这这篇解答确实也是太好了

  • 一桥孤寂 永久VIP 2022年12月27日 18:54:50

    volatile在Java语言中扮演者重要的角色。它具有可见性以及禁止指令重排序两个非常显著的特点。要想解释清楚vola