5 volatile关键字(用途、原理、用法)

 

volatile 的作用:保证内存可见性,但不保证并发正确性

 
在多线程环境下,读和写发生在不同的线程中的时候,可能会出现:读线程不能及时的读取到其他线程写入的最新的值。这就是所谓的可见性为了实现跨线程写入的内存可见性,可以使用到一些volatile机制来实现。

 

1.1 用途

具有synchronized关键字的“可见性” volatile让变量每次在使用的时候,都从主存中取,而不是从各个线程的“工作内存”。  (注:可见性是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果,另一个线程马上就能看到。 )

但没有synchronized关键字的“并发正确性”也就是说不保证线程执行的有序性。volatile变量对于每次使用,线程都能得到当前volatile变量的最新值。但是volatile变量并不保证并发的正确性。

 

1.2 实现原理

当对volatile变量进行读写的时候每个线程先从主内存拷贝变量到CPU缓存中,如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,多个线程可以拷贝到不同的CPU cache中。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,保证了每次读写变量都从主内存中读,跳过CPU cache这一步。当一个线程修改成新的值,新值对于其他线程是立即得知的。

 

1.3 volatilesynchronized的不同

比较一下volatile和synchronized的不同是最容易解释清楚的。volatile是变量修饰符,而synchronized则作用于一段代码或方法;看如下三句get代码:

 int i1;    // volatile修饰的变量,多个线程可能会产生脏读

 int geti1() {return i1;}

 volatile int i2; //volatile修饰的变量,不会产生脏读

int geti2(){return i2;}

int i3;     // volatile只是在线程内存和“主”内存间同步某个变量的值,而

synchronized int geti3() {return i3;}   synchronized通过锁定和解锁某个监视器同步所有变量的值。

解读:

一 得到存储在当前线程中i1的数值。多个线程有多个i1变量拷贝,而且这些i1之间可以互不相同。换句话说,另一个线程可能已经改变了它线程内的i1值,而这个值可以和当前线程中的i1值不相同。事实上,Java有个思想叫“主”内存区域,这里存放了变量目前的“准确值”。每个线程可以有它自己的变量拷贝,而这个变量拷贝值可以和“主”内存区域里存放的不同。因此实际上存在一种可能:“主”内存区域里的i1值是1,线程1里的i1值是2,线程2里的i1值是3——这在线程1和线程2都改变了它们各自的i1值,而且这个改变还没来得及传递给“主”内存区域或其他线程时就会发生。

 geti2()得到的是“主”内存区域的i2数值。用volatile修饰后的变量不允许有不同于“主”内存区域的变量拷贝。换句话说,一个变量经volatile修饰后在所有线程中必须是同步的;任何线程中改变了它的值,所有其他线程立即获取到了相同的值。理所当然的,volatile修饰的变量存取时比一般变量消耗的资源要多一点,因为线程有它自己的变量拷贝更为高效。

既然volatile关键字已经实现了线程间数据同步,又要 synchronized干什么呢?它们之间有两点不同。首先,synchronized获得并释放监视器——如果两个线程使用了同一个对象锁,监视器能强制保证代码块同时只被一个线程所执行——这是众所周知的事实。但是,synchronized也同步内存:事实上,synchronized“ ”内存区域同步整个线程的内存。因此,执行geti3()方法做了如下几步:

1. 线程请求获得监视this对象的对象锁(假设未被锁,否则线程等待直到锁释放)

2. 线程内存的数据被消除,从“主”内存区域中读入(Java虚拟机能优化此步。。。[后面的不知道怎么表达,汗])

3. 代码块被执行

4. 对于变量的任何改变现在可以安全地写到“主”内存区域中

5. 线程释放监视this对象的对象锁

因此volatile只是在线程内存和“主”内存间同步某个变量的值,而synchronized通过锁定和解锁某个监视器同步所有变量的值显然synchronized要比volatile消耗更多资源。

 

1.2 适用范围

volatile关键字用于声明简单类型变量,如intfloat boolean等数据类型。如果这些简单数据类型声明为volatile,对它们的操作就会变成原子级别的。但这有一定的限制。因此,在使用volatile关键字时要慎重,并不是只要简单类型变量使用volatile修饰,对这个变量的所有操作都是原来操作,当变量的值由自身的上一个决定时,如n=n+1、n++ 等,volatile关键字将失效,只有当变量的值和自身上一个值无关时对该变量的操作 是原子级别的,如n = m + 1,这个就是原级别的。所以在使用volatile关键时一定要谨慎,如果自己没有把握,可以使用synchronized来代替volatile。

 -----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

 

 

 

从硬件层面了解可见性的本质
一台计算机中最核心的组件是 CPU、内存、以及 I/O 设备。CPU 的计算速度是非常快的,内存次之、最后是 IO 设备比如磁盘。而在绝大部分的程序中,一定会存在内存访问,有些可能还会存在 I/O 设备的访问。为了平衡三者的速度差异,最大化的利用 CPU 提升性能,从硬件、操作系统、编译器等方面都做出了很多的优化
1. CPU 增加了高速缓存
2. 操作系统增加了进程、线程。通过 CPU 的时间片切换最大化的提升 CPU 的使用率
3. 编译器的指令优化,更合理的去利用好 CPU 的高速缓存然后每一种优化,都会带来相应的问题,而这些问题也是导致线程安全性问题的根源。为了了解前面提到的可见性问题的本质,我们有必要去了解这些优化的过程。

 

CPU 高速缓存

现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存之中

通过高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,因为它引入了一个新的问题,缓存一致性。 

 

缓存一致性
首先,有了高速缓存的存在以后,每个 CPU 的处理过程是,先将计算需要用到的数据缓存在 CPU 高速缓存中,在 CPU进行计算时,直接从高速缓存中读取数据并且在计算完成,之后写入到缓存中。在整个运算过程完成后,再把缓存中的数据同步到主内存。由于在多 CPU 种,每个线程可能会运行在不同的 CPU 内,并且每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题,为了解决缓存不一致的问题,在 CPU 层面做了很多事情,主要提供了两种解决办法1. 总线锁  2. 缓存锁
 
总线锁和缓存锁
总线锁,简单来说就是,在多 cpu 下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把 CPU 和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,这种机制显然是不合适的。最好的方法就是控制锁的保护粒度,我们只需要保证对于被多个 CPU 缓存的同一份数据是一致的就行。所以引入了缓存锁,它核心机制是基于缓存一致性协议来实现的。
 
缓存一致性协议
为了达到数据访问的一致,需要各个处理器在访问缓存时遵循一些协议,在读写时根据协议来操作,常见的协议有MSI,MESI,MOSI 等。最常见的就是 MESI 协议。接下来给大家简
MESI 表示缓存行的四种状态,分别是
1. M(Modify) 表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致。
2. E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU 缓存中,并且没有被修改。
3. S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致。
4. I(Invalid) 表示缓存已经失效在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听(snoop)其它 Cache 的读写操作。

对于 MESI 协议,从 CPU 读写角度来说会遵循以下原则:
CPU 读请求:缓存处于 M、E、S 状态都可以被读取,I 状态 CPU 只能从主存中读取数据,CPU 写请求:缓存处于 M、E 状态才可以被写。对于 S 状态的写,需要将其他 CPU 中缓存行置为无效才可写使用总线锁和缓存锁机制之后,CPU 对于内存的操作大概可以抽象成下面这样的结构。从而达到缓存一致性效果。

 

总结可见性的本质
由于 CPU 高速缓存的出现使得 如果多个 cpu 同时缓存了相同的共享数据时,可能存在可见性问题。也就是 CPU0 修改了自己本地缓存的值对于 CPU1 不可见。不可见导致的后果是 CPU1 后续在对该数据进行写入操作时,是使用的脏数据。使得数据最终的结果不可预测。
刚刚不是说基于缓存一致性协议或者总线锁能够达到缓存一致性的要求吗?为什么还需要加 volatile 关键字?或者说为什么还会存在可见性问题呢?

 

MESI 优化带来的可见性问题
MESI 协议虽然可以实现缓存的一致性,但是也会存在一些问题。
就是各个 CPU 缓存行的状态是通过消息传递来进行的。如果 CPU0 要对一个在缓存中共享的变量进行写入,首先需要发送一个失效的消息给到其他缓存了该数据的 CPU。并且要等到他们的确认回执。CPU0 在这段时间内都会处于阻塞状态。为了避免阻塞带来的资源浪费。在 cpu 中引入了 Store Bufferes

 

 

 

 

 

CPU0 只需要在写入共享数据时,直接把数据写入到 storebufferes 中,同时发送 invalidate 消息,然后继续去处理其他指令。当收到其他所有 CPU 发送了 invalidate acknowledge 消息时,再将 store bufferes 中的数据数据存储至 cache line中。最后再从缓存行同步到主内存。

 

 

 

 

但是这种优化存在两个问题
1. 数据什么时候提交是不确定的,因为需要等待其他 cpu给回复才会进行数据同步。这里其实是一个异步操作
2. 引入了 storebufferes 后,处理器会先尝试从 storebuffer中读取值,如果 storebuffer 中有数据,则直接从storebuffer 中读取,否则就再从缓存行中读取

 

exeToCPU0和exeToCPU1分别在两个独立的CPU上执行。假如 CPU0 的缓存行中缓存了 isFinish 这个共享变量,并且状态为(E)、而 Value 可能是(S)状态。那么这个时候,CPU0 在执行的时候,会先把 value=10 的指令写入到storebuffer中。并且通知给其他缓存了该value变量的 CPU。在等待其他 CPU 通知结果的时候,CPU0 会继续执行 isFinish=true 这个指令。而因为当前 CPU0 缓存了 isFinish 并且是 Exclusive 状态,所以可以直接修改 isFinish=true。这个时候 CPU1 发起 read操作去读取 isFinish 的值可能为 true,但是 value 的值不等于 10。这种情况我们可以认为是 CPU 的乱序执行,也可以认为是一种重排序,而这种重排序会带来可见性的问题 。从硬件层面很难去知道软件层面上的这种前后依赖关系,所以要优化软件。在 CPU 层面提供了 memory barrier(内存屏障)的指令,从硬件层面来看这个 memroy barrier 就是 CPU flushstore bufferes 中的指令。软件层面可以决定在适当的地方来插入内存屏障。
 
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 全称是 Java Memory Model. 
通过前面的分析发现,导致可见性问题的根本原因是缓存以及重排序。 而 JMM 实际上就是提供了合理的禁用缓存以及禁止重排序的方法。所以它最核心的价值在于解决可见性和有序性。
JMM 属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作的行为规范:在虚拟机中把共享变量存储到内存以及从内存中取出共享变量的底层实现细节通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了 CPU 多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。
JMM 抽象模型分为主内存、工作内存;主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量。工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成。

 

 

 

 

 

posted @ 2020-02-04 20:50  LPJのBLOG  Views(433)  Comments(0)    收藏  举报