volatile关键字总结

volatile关键字

前言

我们知道volatile关键字的作用是保证变量在多线程之间的可见性,它是java.util.concurrent包的核心,没有volatile就没有这么多的并发类给我们使用。

本文详细解读一下volatile关键字如何保证变量在多线程之间的可见性,在此之前,有必要讲解一下CPU缓存的相关知识,掌握这部分知识一定会让我们更好地理解volatile的原理,从而更好、更正确地地使用volatile关键字。

CPU缓存

CPU缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,因为CPU运算速度要比内存读写速度快得多,举个例子:

  • 一次主内存的访问通常在几十到几百个时钟周期

  • 一次L1高速缓存的读写只需要1~2个时钟周期

  • 一次L2高速缓存的读写也只需要数十个时钟周期

这种访问速度的显著差异,导致CPU可能会花费很长时间等待数据到来或把数据写入内存。

基于此,现在CPU大多数情况下读写都不会直接访问内存(CPU都没有连接到内存的管脚),取而代之的是CPU缓存,CPU缓存是位于CPU与内存之间的临时存储器,它的容量比内存小得多但是交换速度却比内存快得多。而缓存中的数据是内存中的一小部分数据,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可先从缓存中读取,从而加快读取速度。

按照读取顺序与CPU结合的紧密程度,CPU缓存可分为:

  • 一级缓存:简称L1 Cache,位于CPU内核的旁边,是与CPU结合最为紧密的CPU缓存

  • 二级缓存:简称L2 Cache,分内部和外部两种芯片,内部芯片二级缓存运行速度与主频相同,外部芯片二级缓存运行速度则只有主频的一半

  • 三级缓存:简称L3 Cache,部分高端CPU才有

每一级缓存中所存储的数据全部都是下一级缓存中的一部分,这三种缓存的技术难度和制造成本是相对递减的,所以其容量也相对递增。

当CPU要读取一个数据时,首先从一级缓存中查找,如果没有再从二级缓存中查找,如果还是没有再从三级缓存中或内存中查找。一般来说每级缓存的命中率大概都有80%左右,也就是说全部数据量的80%都可以在一级缓存中找到,只剩下20%的总数据量才需要从二级缓存、三级缓存或内存中读取。

使用CPU缓存带来的问题

用一张图表示一下CPU–>CPU缓存–>主内存数据读取之间的关系:

                                         

当系统运行时,CPU执行计算的过程如下:

  1. 程序以及数据被加载到主内存

  2. 指令和数据被加载到CPU缓存

  3. CPU执行指令,把结果写到高速缓存

  4. 高速缓存中的数据写回主内存

如果服务器是单核CPU,那么这些步骤不会有任何的问题,但是如果服务器是多核CPU,那么问题来了,以Intel Core i7处理器的高速缓存概念模型为例(图片摘自《深入理解计算机系统》):


试想下面一种情况:

  1. 核0读取了一个字节,根据局部性原理,它相邻的字节同样被被读入核0的缓存

  2. 核3做了上面同样的工作,这样核0与核3的缓存拥有同样的数据

  3. 核0修改了那个字节,被修改后,那个字节被写回核0的缓存,但是该信息并没有写回主存

  4. 核3访问该字节,由于核0并未将数据写回主存,数据不同步

为了解决这个问题,CPU制造商制定了一个规则:当一个CPU修改缓存中的字节时,服务器中其他CPU会被通知,它们的缓存将视为无效。于是,在上面的情况下,核3发现自己的缓存中数据已无效,核0将立即把自己的数据写回主存,然后核3重新读取该数据。



  • 1、Java的内存模型

    Java内存模型简称JMM(Java Memory Model),是Java虚拟机所定义的一种抽象规范,用来屏蔽不同硬件和操作系统的内存访问差异,让java程序在各种平台下都能达到一致的内存访问效果。 
    这里写图片描述 
    注意: 
    1、主内存是被所有线程共享的,主内存中存储了共享变量的“本体”; 
    2、工作内存就是线程拥有的相互独立的内存空间,工作内存中有主内存中的变量,工作内存中存储的是共享变量的“分身”;工作内存Work Memory其实就是对CPU寄存器和高速缓存的抽象,或者说每个线程的工作内存也可以简单理解为CPU寄存器和高速缓存。

  • JMM的工作流程 
    工作内存不能对主内存的对象进行直接的操作。 
    首先经过内存读操作获取到“分身”;再对“分身”进行业务处理;最后把结果通过写操作将“分身”写回主内存的“ 本体“。这是一次完整过程。

  • JMM存在的问题 
    因为存在着先读再处理最后写回的过程,就会出现 线程A读取后,处理中,写回前(可以假设成在写回主内存之前打了个断点)的时候,线程B从主内存进行读操作(读到此处停下来想一下这个场景)。这样线程A和线程B的变量内容会不一样。

  • *解决办法 
    首先考虑上同步锁synchronized,但是会影响程序性能,除此之外,就是使用本文的主角:volatile关键字

  • volatile关键字的主要特性之一:保证可见性 
    volatile最重要的特性之一就是保证了volatile修饰的变量对所有线程的可见性。 
    可见性的意思就是: 
    当一个线程修改了变量的值,新的值会立刻同步到主内存当中。而其他线程读取这个变量的时候,也会从主内存中拉取最新的变量值。 
    使用了volatile修饰的变量,会遵循先行发生原则,以上述例子来说,就是保证了线程B从主内存读取变量的动作,一定后发生于线程A写入主内存的动作。 

  • 那么当写两条线程Thread-A与Threab-B同时操作主存中的一个volatile变量i时,Thread-A写了变量i,那么:

    • Thread-A发出LOCK#指令

    • 发出的LOCK#指令锁总线(或锁缓存行),同时让Thread-B高速缓存中的缓存行内容失效

    • Thread-A向主存回写最新修改的i

    Thread-B读取变量i,那么:

    • Thread-B发现对应地址的缓存行被锁了,等待锁的释放,缓存一致性协议会保证它读取到最新的值

    由此可以看出,volatile关键字的读和普通变量的读取相比基本没差别,差别主要还是在变量的写操作上。


    注意: 
    volatile只保证变量的可见性,并不能保证变量的原子性。这是因为volatile不能保证对变量的操作是原子性的,例如i++。
  • volatile关键字的主要特性之二:阻止指令重排 
    指令重排的意思是:JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序。 
    指令重排的目的是为了在不改变单线程下程序执行结果的前提下,优化程序的运行效率。 
    但是在多线程的情况下,会影响程序的执行结果。使用volatile关键字则可以避免这种情况。如何做到的呢?是通过CPU指令:”内存屏障“解决的。 
    内存屏障包括了四种类型: 
    LoadLoad屏障(读完再读)、StoreStore屏障(写完再写)、LoadStore屏障(读完再写)、StoreLoad屏障(写完再读)。 
    在一个变量被volatile修饰后,JVM会为我们做两件事: 
    1.在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障。 
    2.在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障。

  • volatile关键字的其他特性:解决了long类型和double类型数据的8字节赋值问题

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值