volatile底层原理剖析

在理解volatile之前,我们先来熟悉下计算机执行程序的过程

要执行我们的应用程序,首先将我们的程序从磁盘上读取到内存中,内存里这个时候存放了要执行的指令和数据,要执行一条指令的时候,指令寄存器根据程序计数器PC中存放的下一条待执行指令的地址,从内存中将指令取出来,cpu再根据指令内容将内存中的数据读取到数据寄存器中,ALU(算数逻辑单元)计算得出结果,将结果数据写回到内存。

那么volatile是用来干什么的呢?
volatile有两大作用:保证线程间可见禁止指令重排

线程间可见

volatile可以保证线程的可见性,那么是什么造成了线程间不可见,这里还涉及到一个知识点:缓存一致性协议

我们的应用程序都是交给cpu来执行的,对于cpu来说,它的运行速度是非常快的,相对于CPU来说,内存的速度是非常慢的,而cpu要读取内存中的内容,那这个时候该怎么办?如果能够把内存中的内容缓存在cpu的内部,这个时候cpu直接在自己内部读取,速度会快很多,不然的话,这个效率也太低了。

所以在cpu和主内存之间增加了一系列的缓存,目前市面上流行的计算机主板配置,一般是三级缓存,一级缓存和二级缓存在cpu内部,速度是非常快的。我们应用程序的每个线程都是运行在cpu里的,cpu去读取主内存中一份数据的时候,会将数据读取到自己内部的缓存当中,这个时候线程才可以去执行,在默认情况下,cpu后续会一直读取缓存当中的这个份数据,它不会主动去内存中再次读取,当我们的多个线程运行在不同的cpu当中,每个线程都会读取自己所在cpu缓存当中的数据,经过计算,再把值写回主内存,这个时候就存在数据同步不一致的问题

而cpu从内存中读取数据的时候,并不是一位一位读的,而是分块读取,比如说你要读取一位数据,cpu其实是会把这一整块数据都先读进缓存。大部分cpu都是一次性读取64个字节的数据,这64个字节被称之为缓存行。代表cpu从内存中读取数据的基本单位。例如cpu要读取A的值,它不会只把A读取出来,而是把A所在的整个的这一块64个字节放入到缓存当中。

为了解决数据同步不一致这个问题,推出了缓存一致性协议,缓存一次性协议有多种,像MSI、MESI、MOSI、Synapse等,不同cpu有不同的协议,我们常用的intel cpu采用的是MESI,MESI协议就是把每一个缓存行标记成不同的状态,MESI协议有4种状态:

  • Modified:代表此缓存行已经被修改过了
  • Exclusive:独占此缓存行
  • Shared:共享此缓存行
  • Invalid:该缓存行已失效,因为已经被改过了

一个cpu更改了一个缓存行,只需要通知其他cpu该缓存行已失效,需要重新从内存当中读取数据。这个就是缓存一致性协议。

但其实volatile的底层实现跟缓存一致性协议是没有关系的,JVM采用lock锁总线的方式保证线程间可见,不管有没有缓存一致性协议,使用了volatile,JVM都能够保证线程间的可见性。

禁止指令重排

cpu乱序执行

相对于内存来说,cpu的运行速度是非常快的,如果说cpu在执行一条指令的时候还要一直等待内存的话,那么就很容易造成cpu的空转。所以cpu在发现两条指令之间没有任何关系的时候,在执行第一条指令的过程中,干脆就把第二天指令也拿过来运行。这个时候就有一个现象,就是我们在代码中写的是先执行第一条指令,再执行第二条指令,但实际上cpu是有可能先执行完了第二条指令,再执行的第一条指令。

那么volatile是怎么禁止指令重排序的呢?这里我们先来了解一下一个概念:内存屏障

volatile在jvm层级有jvm级别的内存屏障,除此之外还有底层的内存屏障

JVM级别内存屏障

JVM规范要求java虚拟机对volatile关键字修饰的数据进行读写的时候,必需加一道屏障,插入一个内存屏障,相当于告诉CPU先于这个命令的操作必须先执行,后于这个命令的操作必须后执行。内存屏障有两个作用:

  • 阻止屏障两侧的指令重排序
  • 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效

硬件层面的内存屏障分为Load Barrier Store Barrier读屏障写屏障

对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载新数据。

对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

jvm规范把读、写屏障排列组合形成4种内存屏障

LoadLoad屏障、StoreStore屏障、LoadStore屏障、StoreLoad屏障

Load就是读,Store就是写

1)LoadLoad屏障

上边有一条load指令,下边有一条load指令,这两条指令不可以重排序,上面的load指令读完,下面的load才能读。

2)StoreStore屏障

上边有一条store指令,下边有一条store指令,这两条指令不可以重排序,上面的store指令写完,下面的store才能写。

3)LoadStore屏障

上边有一条load指令,下边有一条store指令,这两条指令不可以重排序,上面的load读完,下面的store才能写。

4)StoreLoad屏障

上边有一条store指令,下边有一条load指令,这两条指令不可以重排序,上面的store写完,下面的load才能读。

到这里,我们总结一下,jvm规范要求对于volatile修饰内存的读写操作,前后都要添加内存屏障,前后都有了屏障,就绝对不会发生指令重排这种问题。

那么jvm层级是怎么实现volatile的细节的呢?

写入操作:

假如要对volatile修饰的内存进行写入的操作,写入就是store操作。那么在它的上边会有一个StoreStore屏障,下边会有一个StoreLoad屏障,上边的store写完了,我才能执行写入操作,我写完了,其它人才能读。

读取操作:

读就是store操作,那么在它的上边会有一个LoadLoad屏障,下边会有一个LoadStore屏障。上边的读完了,我才能读,得等我读完了,你才能写。

此外,JVM还规定了在8种情况下不可以进行重排序(happens-before原则),具体规则略过。

我们说的这些都是JVM层级的规范,那么在java虚拟机当中具体是如何实现的呢?

内存屏障底层实现

现在的cpu大都是支持内存屏障的,比如说X86 CPU,它有三条这样的指令支持内存屏障:sfence(相当于store)、lfence(相当于load)、mfence(读写操作)。除此之外,还有一条万能的指令:lock(锁总线/缓存)。而我们的hospot虚拟机实现内存屏障就是使用lock锁总线的方式(lock addl,addl是一个加0的空操作),因为所有的cpu都是有lock指令的。

一个线程在对volatile修饰的内存进行读写操作的时候,会在前面加上lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能lock在执行的时候,会对总线/缓存加锁,然后执行后面的指令。因为所有的cpu要想访问内存,都必须通过总线。在Lock锁住总线的时候,其它线程都不能够访问内存,直到锁释放。释放锁的时候会把缓存中的数据刷新回主内存,而且这个写回主存的操作会使得在其他CPU里的缓存失效。这样一来也就没办法进行指令重排序了,同时也保证了线程间可见。

既然锁住了总线,那为什么volatile还是无法保证线程安全呢?

简单来说,修改volatile值包括3步:

  1. Load,读取值到cpu缓存中
  2. 计算,修改值
  3. Store,将缓存中的值写回到内存

但是从Load到Store并不是安全的,不能保证没有其他线程修改。因为volatile并不能保证这4步作为一个原子操作。

假如有A、B两个线程要执行t=t+1的操作,刚开始的时候,线程A通过Load将t=0的值读取到cpu缓存中,这个时候恰好cpu时间片用完了,线程B获得执行权,读取t的值也为0。这个时候线程A再执行完+1操作,写回内存。但是此时线程B已经读取过t的值了,不会再去拿一遍t的值,因此执行完+1操作后,写回内存,这个时候t的值还是1;也就是出现了线程不安全问题。

不对,如果线程A执行自增了并写会主内存后,不是应该应该通知其他线程重新加载t最新的值么?

拿到新值的前提是load,但此时线程B已经拿到了t的值,并不需要再次load t的值,当然是获取不到t最新的值的

 

 

 

 

 

 

 

 

 

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值