分析Volatile的作用及底层实现原理,面试问一点都不慌!

  • 定义一个共享变量 stop

  • 在main线程中创建一个子线程 thread,子线程读取到 stop的值做循环结束的条件

  • main线程中修改stop的值为 true

  • 当 stop没有增加volatile修饰时,子线程对于主线程的 stop=true的修改是不可见的,这样将导致子线程出现死循环

  • 当 stop增加了volatile修饰时,子线程可以获取到主线程对于 stop=true的值,子线程while循环条件不满足退出循环

增加volatile关键字以后,main线程对于共享变量 stop值的更新,对于子线程 thread可见,这就是volatile的作用

这段代码有些人测试不出效果,是因为JVM没有优化导致的,在cmd控制台输入java -version,如果显示的是 JavaHotSpot(TM)**ServerVM,就能正常演示,如果是 JavaHotSpot(TM)**ClientVM,需要设置成 Server模式

什么是可见性,以及volatile是如何保证可见性的呢?

02

什么是可见性

在并发编程中,线程安全问题的本质其实就是 原子性、有序性、可见性;接下来主要围绕这三个问题进行展开分析其本质,彻底了解可见性的特性

  • 原子性 和数据库事务中的原子性一样,满足原子性特性的操作是不可中断的,要么全部执行成功要么全部执行失败

  • 有序性 编译器和处理器为了优化程序性能而对指令序列进行重排序,也就是你编写的代码顺序和最终执行的指令顺序是不一致的,重排序可能会导致多线程程序出现内存可见性问题

  • 可见性 多个线程访问同一个共享变量时,其中一个线程对这个共享变量值的修改,其他线程能够立刻获得修改以后的值

为了彻底了解这三个特性,我们从两个层面来分析,第一个层面是硬件层面、第二个层面是JMM层面

从硬件层面分析三大特性


原子性、有序性、可见性这些问题,我们可以认为是基于多核心CPU架构下的存在的问题。因为在单核CPU架构下,所有的线程执行都是基于CPU时间片切换,所以不存在并发问题 (在IntelPentium4开始,引入了超线程技术,也就是一个CPU核心模拟出2个线程的CPU,实现多线程并行)。

CPU高速缓存

线程设计的目的是充分利用CPU达到实时性的效果,但是很多时候CPU的计算任务还需要和内存进行交互,比如读取内存中的运算数据、将处理结果写入到内存。在理想情况下,存储器应该是非常快速的执行一条指令,这样CPU就不会受到存储器的限制。但目前技术无法满足,所以就出现了其他的处理方式。

图片

存储器顶层是CPU中的寄存器,存储容量小,但是速度和CPU一样快,所以CPU在访问寄存器时几乎没有延迟;接下来就是CPU的高速缓存;最后就是内存。

图片

高速缓存从下到上越接近CPU访问速度越快,同时容量也越小。现在的大部分处理器都有二级或者三级缓存,分别是L1/L2/L3, L1又分为L1-d的数据缓存和L1-i的指令缓存。其中L3缓存是在多核CPU之间共享的。

原子性

在多核CPU架构下,在同一时刻对同一共享变量执行 decl指令(递减指令,相当于i--,它分为三个过程:读->改->写,这个指令涉及到两次内存操作,那么在这种情况下i的结果是无法预测的。这就是原子性问题

处理器如何解决原子性问题呢?

其实这个问题稍微提炼一下,无非就是多线程并行访问同一个共享资源的时候的原子性问题,如果把问题放大到分布式架构里面,这个问题的解决方法就是锁。所以在CPU层面,提供了两种锁的机制来保证原子性

总线锁

如果多个处理器同时对同一共享变量进行 decl指令操作,那这个操作一定不是原子的,也就是执行的结果和预期结果不一致。如下图所示,我们期望的结果是3,但是有可能结果是2

图片

如果要解决这个问题,就需要是的CPU0在更新共享变量时,CPU1就不能操作缓存了该共享变量内存地址的缓存,所以处理器提供了总线锁来解决问题,处理器会提供一个LOCK#信号,当一个处理器在总线上输出这个信号时,其他处理器的请求会被阻塞,那么该处理器就可以独占共享内存

总线锁有一个弊端,总线锁相当于使得多个CPU由并行执行变成了串行,使得CPU的性能严重下降,所以在P6系列以后的处理器中,引入了缓存锁。

缓存锁

我们只需要保证 多个线程操作同一个被缓存的共享数据的原子性就行,所以只需要锁定被缓存的共享对象即可。所谓缓存锁是指被缓存在处理器中的共享数据,在Lock操作期间被锁定,那么当被修改的共享内存的数据回写到内存时,处理器不在总线上声明LOCK#信号,而是修改内部的内存地址,并通过 缓存一致性机制来保证操作的原子性。

什么是缓存一致性呢?

缓存一致性

所谓缓存一致性,就是多个CPU核心中缓存的同一共享数据的数据一致性,而(MESI)使用比较广泛的缓存一致性协议。MESI协议实际上是表示缓存的四种状态

  • M(Modify) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
  • E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
  • S(Shared) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
  • I(Invalid) 表示缓存已经失效

图片

图片

图片

每个CPU核心不仅仅知道自己的读写操作,也会监听其他Cache的读写操作 CPU的读取会遵循几个原则

  • 如果缓存的状态是I,那么就从内存中读取,否则直接从缓存读取
  • 如果缓存处于M或者E的CPU 嗅探到其他CPU有读的操作,就把自己的缓存写入到内存,
    并把自己的状态设置为S
  • 只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为M

可见性

CPU高速缓存以及指令重排序都会造成可见性问题,接下来从两个角度来分析

MESI优化带来的可见性问题

前面说过MESI协议,也就是缓存一致性协议。这个协议存在一个问题,就是当CPU0修改当前缓存的共享数据时,需要发送一个消息给其他缓存了相同数据的CPU核心,这个消息传递给其他CPU核心以及收到消息完成各自缓存状态的切换这个过程中,CPU会等待所有缓存响应完成,这样会降低处理器的性能。为了解决这个问题,引入了 StoreBufferes存储缓存。


处理器把需要写入到主内存中的值先写入到存储缓存中,然后继续去处理其他指令。当所有的CPU核心返回了失效确认时,数据才会被最终提交。但是这种优化又会带来另外的问题。 如果某个CPU尝试将其他CPU占有的共享数据写入到内存,消息提交给store buffer以后,当前CPU继续做其他事情,而如果后面的指令依赖于这个被写入内存的最新数据(由于store buffer还没有写入到内存),就会产生可见性问题(也就是值还没有更新到内存中,这个时候读取到的共享数据的值是错误的)。

Store Bufferes带来的CPU内存的乱序访问导致的可见性问题

Store Bufferes中的数据何时写入到内存中是不确定的,那么意味着这个过程的执行顺序也是不确定的,比如下面这个例子 exeToCPU0和exeToCPU1分别在两个独立的cpu核心上执行,假如CPU0 缓存了 isFinish这个共享变量,并且状态为(E->独占),而value可能是(S共享状态被其他CPU核心修改以后变为I(失效状态)。 这种情况下value的缓存数据变更路径为, value将失效状态需要响应给触发缓存更新的CPU核心,接着该CPU将 StoreBufferes写入到内存,这就会导致value会比isFinish更迟的抛弃存储缓存。那么就可能出现CPU1读取到了isFinish的值为true,而value的值不等于10的情况。 这种CPU的内存乱序访问,会带来可见性问题。

value = 3;

void exeToCPU0(){

value = 10;

isFinsh = true;

}

void exeToCPU1(){

if(isFinsh){

assert value == 10;

}

}

从硬件层面很难去知道软件层面上的这种前后依赖关系,所以没有办法通过某种手段自动去解决,因此CPU层面提供了 memory barrier(内存屏障)的指令,从硬件层面来看这个 memroy barrier就是CPU flush store bufferes中的指令。软件层面可以决定在适当的地方来插入内存屏障。

CPU层面的内存屏障

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

  • Store Memory Barrier(写屏障) 告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的
  • Load Memory Barrier(读屏障) 处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
  • Full Memory Barrier(全屏障) 确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作

有了内存屏障以后,对于上面这个例子,我们可以这么来改,从而避免出现可见性问题

value = 3;

void exeToCPU0(){

value = 10;

storeMemoryBarrier(); //这个是一个伪代码,插入一个写屏障,使得value=10这个值强制写入到主内存中

isFinsh = true;

}

void exeToCPU1(){

if(isFinsh){

loadMemoryBarrier();//伪代码,插入一个读屏障,使得cpu1从主内存中获得最新的数据

assert value == 10;

}

}

总的来说,内存屏障的作用可以通过防止CPU对内存的乱序访问来保证共享数据在多线程并行执行下的可见性

有序性

有序性简单来说就是程序代码执行的顺序是否按照我们编写代码的顺序执行,一般来说,为了提高性能,编译器和处理器会对指令做重排序,重排序分3类

  • 编译器优化重排序,在不改变单线程程序语义的前提下,改变代码的执行顺序

  • 指令集并行的重排序,对于不存在数据依赖的指令,处理器可以改变语句对应指令的执行顺序来充分利用CPU资源

  • 内存系统的重排序,也就是前面说的CPU的内存乱序访问问题

也就是说,我们编写的源代码到最终执行的指令,会经过三种重排序

图片

有序性会带来可见性问题,所以可以通过内存屏障指令来进制特定类型的处理器重排序

从JMM层面解决线程并发问题


从硬件层面的分析了解到原子性、有序性、可见性的本质以后,知道硬件层面针对这三个问题的解决办法,原子性是通过总线锁或缓存锁来实现,而有序性和可见性可以通过内存屏障来解决。那么在软件层面,如何解决原子性、有序性、可见性问题呢?答案就是: JMM(JavaMemoryModel)内存模型

硬件层面的原子性、有序性、可见性在不同的CPU架构和操作系统中的实现可能都不一样,而Java语言的特性是 write once,run anywhere,意味着JVM层面需要屏蔽底层的差异,因此在JVM规范中定义了JMM。

图片

(JMM内存模型的抽象结构)

JMM属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作的行为规范,也就是在虚拟机中将共享变量存储到内存以及从内存中取出共享变量的底层细节。 通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。 需要注意的是,JMM并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序,也就是说在JMM中,也会存在缓存一致性问题和指令重排序问题。只是JMM把底层的问题抽象到JVM层面,再基于CPU层面提供的内存屏障指令,以及限制编译器的重排序来解决并发问题

Java内存模型定义了线程和内存的交互方式,在JMM抽象模型中,分为主内存、工作内存;主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量。工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成。 在JMM中,定义了8个原子操作来实现一个共享变量如何从主内存拷贝到工作内存,以及如何从工作内存同步到主内存,交互如下

图片

8个原子操作指令

  • **lock(锁定):**作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • **unlock(解锁):**作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • **read(读取):**作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • **load(载入):**作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • **use(使用):**作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • **assign(赋值):**作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • **store(存储):**作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • **write(写入):**作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

顺序一致性

如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。JMM只要求这两个操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。

JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致,因为如果想要保证执行结果一致,意味着JMM需要进制处理器和编译器的优化,这对于程序的执行性能会产生很大的影响。所以在未同步程序的执行中,由于执行顺序的不确定性导致结果无法预测。我们可以使用同步原语比如 synchronized,volatile、final来实现程序的同步操作来保证顺序一致性

假如有两个线程A和B并行执行,A和B线程分别都有3个操作,在程序中的顺序是 A1->A2->A3, B1->B2->B3。 假设这两个程序没有使用同步原语,那么线程并行执行的效果可能是

图片

(此图来自并发编程的艺术)如果这两个程序使用了监视器锁来实现正确同步,那么执行的过程一定是

图片

(此图来自并发编程的艺术)

重排序

CPU层面的内存乱序访问属于重排序的一部分,同时我们还提到了编译器的优化执行的重排序。重排序是一种优化手段,但是在多线程并发中,会导致可见性问题。 编译器的重排序是指,在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序来优化程序的性能. 编译器的重排序和CPU的重排序的原则一样,会遵守数据依赖性原则,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序,比如下面的代码,这三种情况在单线程里面如果改变代码的执行顺序,都会导致结果不一致,所以重排序不会对这类的指令做优化,也就是需要满足 as-if-serial语义

//写后读

a=1;

b=1;

//写后写

a=1;

a=2;

//读后写

a=b;

b=1;

as-if-serial语义

as-if-serial语义的意思是不管怎么重排序,单线程程序的执行结果不能被改变,编译器、处理器都必须遵守这个语义

JMM层面的内存屏障

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

图片

屏障的作用这里就不重复再说了,实际上JMM层面的内存屏障就是对CPU层面的内存屏障指令做的包装,作用是通过在合适的位置插入内存屏障来保证可见性

JVM是如何在JMM层面解决原子性、有序性、可见性问题的呢?

相信通过上面的分析,基本上有了答案

  • **原子性:**Java中提供了两个高级指令 monitorenter和 monitorexit,也就是对应的synchronized同步锁来保证原子性

  • **可见性:**volatile、synchronized、final都可以解决可见性问题

  • **有序性:**synchronized和volatile可以保证多线程之间操作的有序性,volatile会禁止指令重排序

03

volatile源码分析

如果你看到这个章节了,意味着你对可见性有一个清晰的认识了,也知道JMM是基于禁止指令重排序来实现可见性的,那么我们再来分析volatile的源码,就会简单很多

基于最开始演示的这段代码作为入口

public class VolatileDemo {

public volatile static boolean stop=false;

public static void main(String[] args) throws InterruptedException {

Thread thread=new Thread(()->{

int i=0;

while(!stop){

i++;

}

});

thread.start();

System.out.println(“begin start thread”);

Thread.sleep(1000);

stop=true;

}

}

通过 javap-vVolatileDemo.class查看字节码指令

public static volatile boolean stop;

descriptor: Z

flags: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE

…//省略

public static void main(java.lang.String[]) throws java.lang.InterruptedException;

descriptor: ([Ljava/lang/String;)V

flags: ACC_PUBLIC, ACC_STATIC

Code:

stack=3, locals=2, args_size=1

0: new           #2                  // class java/lang/Thread

3: dup

4: invokedynamic #3,  0              // InvokeDynamic #0:run:()Ljava/lang/Runnable;

9: invokespecial #4                  // Method java/lang/Thread.“”:(Ljava/lang/Runnable;)V

12: astore_1

13: aload_1

14: invokevirtual #5                  // Method java/lang/Thread.start:()V

17: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;

20: ldc           #7                  // String begin start thread

22: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V

25: ldc2_w        #9                  // long 1000l

28: invokestatic  #11                 // Method java/lang/Thread.sleep:(J)V

31: iconst_1

32: putstatic     #12                 // Field stop:Z

35: return

注意被修饰了volatile关键字的 stop字段,会多一个 ACC_VOLATILE的flag,在给 stop复制的时候,调用的字节码是 putstatic,这个字节码会通过BytecodeInterpreter解释器来执行,找到Hotspot的源码 bytecodeInterpreter.cpp文件,搜索 putstatic指令定位到代码

CASE(_putstatic):

{

u2 index = Bytes::get_native_u2(pc+1);

ConstantPoolCacheEntry* cache = cp->entry_at(index);

if (!cache->is_resolved((Bytecodes::Code)opcode)) {

CALL_VM(InterpreterRuntime::resolve_get_put(THREAD, (Bytecodes::Code)opcode),

handle_exception);

cache = cp->entry_at(index);

}

#ifdef VM_JVMTI

if (_jvmti_interp_events) {

int *count_addr;

oop obj;

// Check to see if a field modification watch has been set

// before we take the time to call into the VM.

count_addr = (int *)JvmtiExport::get_field_modification_count_addr();

if ( *count_addr > 0 ) {

if ((Bytecodes::Code)opcode == Bytecodes::_putstatic) {

obj = (oop)NULL;

}

else {

if (cache->is_long() || cache->is_double()) {

obj = (oop) STACK_OBJECT(-3);

} else {

obj = (oop) STACK_OBJECT(-2);

}

VERIFY_OOP(obj);

}

CALL_VM(InterpreterRuntime::post_field_modification(THREAD,

obj,

cache,

(jvalue *)STACK_SLOT(-1)),

handle_exception);

}

}

#endif /* VM_JVMTI */

// QQQ Need to make this as inlined as possible. Probably need to split all the bytecode cases

// out so c++ compiler has a chance for constant prop to fold everything possible away.

oop obj;

int count;

TosState tos_type = cache->flag_state();

count = -1;

if (tos_type == ltos || tos_type == dtos) {

–count;

}

if ((Bytecodes::Code)opcode == Bytecodes::_putstatic) {

Klass* k = cache->f1_as_klass();

obj = k->java_mirror();

} else {

–count;

obj = (oop) STACK_OBJECT(count);

CHECK_NULL(obj);

}

//

// Now store the result

//

int field_offset = cache->f2_as_index();

if (cache->is_volatile()) {

if (tos_type == itos) {

obj->release_int_field_put(field_offset, STACK_INT(-1));

} else if (tos_type == atos) {

VERIFY_OOP(STACK_OBJECT(-1));

obj->release_obj_field_put(field_offset, STACK_OBJECT(-1));

OrderAccess::release_store(&BYTE_MAP_BASE[(uintptr_t)obj >> CardTableModRefBS::card_shift], 0);

} else if (tos_type == btos) {

obj->release_byte_field_put(field_offset, STACK_INT(-1));

} else if (tos_type == ltos) {

obj->release_long_field_put(field_offset, STACK_LONG(-1));

} else if (tos_type == ctos) {

obj->release_char_field_put(field_offset, STACK_INT(-1));

} else if (tos_type == stos) {

obj->release_short_field_put(field_offset, STACK_INT(-1));

} else if (tos_type == ftos) {

obj->release_float_field_put(field_offset, STACK_FLOAT(-1));

} else {

obj->release_double_field_put(field_offset, STACK_DOUBLE(-1));

}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值