一、Java中volatile用来做什么?

Volatile是Java虚拟机提供的轻量级的同步机制(三大特性):

  • 保证可见性
  • 不保证原子性
  • 禁止指令重排

要理解三大特性,就必须知道Java内存模型(JMM),那JMM又是什么呢?

二、JMM又是什么?

为什么需要Java内存模型? 

屏蔽各种硬件和操作系统的内存访问差异

JMM是Java内存模型,也就是Java Memory Model,简称JMM,本身是一种抽象的概念,实际上并不存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

到底什么是Java内存模型?

  • 定义程序中各种变量的访问规则

  • 把变量值存储到内存的底层细节

  • 从内存中取出变量值的底层细节

Java内存模型的两大内存是啥?

  • 主内存

    • Java堆中对象实例数据部分

    • 对应于物理硬件的内存

  • 工作内存

    • Java栈中的部分区域

    • 优先存储于寄存器和高速缓存

 Java内存模型是怎么做的?

Java内存模型的几个规范:

  • 1.所有变量存储在主内存

  • 2.主内存是虚拟机内存的一部分

  • 3.每条线程有自己的工作内存

  • 4.线程的工作内存保存变量的主内存副本

  • 5.线程对变量的操作必须在工作内存中进行

  • 6.不同线程之间无法直接访问对方工作内存中的变量

  • 7.线程间变量值的传递均需要通过主内存来完成

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写会主内存不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程:

  Java内存模型的三大特性

  • 可见性(当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改)

  • 原子性(一个操作或一系列操作是不可分割的,要么同时成功,要么同时失败)

  • 有序性(变量赋值操作的顺序与程序代码中的执行顺序一致)

关于有序性:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内似表现为串行的语义”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

三、那为什么其他线程能感知到变量更新?

其实这里就是用到了“窥探(snooping)”协议。在说“窥探(snooping)”协议之前,首先谈谈缓存一致性的问题。

缓存一致性

当多个CPU持有的缓存都来自同一个主内存的拷贝,当有其他CPU偷偷改了这个主内存数据后,其他CPU并不知道,那拷贝的内存将会和主内存不一致,这就是缓存不一致。那我们如何来保证缓存一致呢?这里就需要操作系统来共同制定一个同步规则来保证,而这个规则就有MESI协议。

MESI

当CPU写数据时,如果发现操作的变量是共享变量,即在其它CPU中也存在该变量的副本,系统会发出信号通知其它CPU将该内存变量的缓存行设置为无效。

当其它CPU读取这个变量的时,发现自己缓存该变量的缓存行是无效的,那么它就会从内存中重新读取。 

 总线嗅探

那其他CPU是怎么知道要将缓存更新为失效的呢?这里是用到了总线嗅探技术

每个CPU不断嗅探总线上传播的数据来检查自己缓存值是否过期了,如果处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存中把数据读取到处理器缓存中。

 总线风暴

总线嗅探技术有哪些缺点?

由于MESI缓存一致性协议,需要不断对主线进行内存嗅探,大量的交互会导致总线带宽达到峰值。因此不要滥用volatile,可以用锁来替代,看场景啦~

四、volatile为什么不保证原子性吗?

public class VolatileAtomicity {
    public static volatile int number = 0;

    public static void increase() {
        number++;
    }

    public static void main(String[] args) {

        for (int i = 0; i < 50; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    increase();
                }
            }, String.valueOf(i)).start();
        }

        // 当所有累加线程都结束
        while(Thread.activeCount() > 2) {
            Thread.yield();
        }

        System.out.println(number);
    }
}

 分析一下increase()方法,通过反编译工具javap得到如下汇编代码:

public static void increase();
    Code:
       0: getstatic     #2                  // Field number:I
       3: iconst_1
       4: iadd
       5: putstatic     #2                  // Field number:I
       8: return

 number++其实执行了3条指令

getstatic:拿number的原始值

iadd:进行加1操作

putfield:把加1后的值写回

执行了getstatic指令number的值取到操作栈顶时,volatile关键字保证了number的值在此时是正确的,但是在执行iconst_1、iadd这些指令的时候,其他线程可能已经把number的值改变了,而操作栈顶的值就变成了过期的数据,所以putstatic指令执行后就可能把较小的number值同步回主内存之中。 

总结如下:

在执行number++这行代码时,即使使用volatile修饰number变量,在执行期间还是有可能被其他线程修改。因为在入栈和自增计算执行过程中,该变量有可能正在被其他线程修改,最后计算出来的结果照样存在问题,因此volatile并不能保证非原子操作的原子性,仅在单次读或者单次写这样的原子操作中,volatile能够实现线程安全。

 

 五、禁止指令重排又是啥?

说到指令重排就得知道为什么要重排,有哪几种重排。

 会不会感觉到重排把指令顺序都打乱了,这样好吗?

可以回想下小学时候的数学题:2+3-5=?,如果把运算顺序改为3-5+2=?,结果也是一样的。所以指令重排是要保证单线程下程序结果不变的情况下做重排。

为什么要重排

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。

 有哪几种重排

  • 1.编译器优化重排:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  • 2.指令级的并行重排:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  • 3.内存系统的重排:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

注意:

  • 单线程环境里面确保最终执行结果和代码顺序的结果一致

  • 处理器在进行重排序时,必须要考虑指令之间的数据依赖性

  • 多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

volatile怎么实现禁止指令重排? 

原理:在volatile生成的指令序列前后插入内存屏障(Memory Barries)来禁止处理器重排序。

四种内存屏障

屏障类型指令类型说明
LoadLoadLoad1; LoadLoad; Load2在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStoreStore1; StoreStore; Store2在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStoreLoad1; LoadStore; Store2在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoadStore1; StoreLoad; Load2在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个”全能型屏障”,兼具其它三种内存屏障的功能

volatile写的场景如何插入内存屏障

  • 在每个volatile写操作的前面插入一个StoreStore屏障(写-写 屏障)。

  • 在每个volatile写操作的后面插入一个StoreLoad屏障(写-读 屏障)。 

volatile写的场景如何插入内存屏障:

StoreStore屏障可以保证在volatile写(flag赋值操作flag=true)之前,其前面的所有普通写(num的赋值操作num=1) 操作已经对任意处理器可见了,保障所有普通写在volatile写之前刷新到主内存。 

volatile写的场景如何插入内存屏障

  • 在每个volatile读操作的后面插入一个LoadLoad屏障(读-读 屏障)。

  • 在每个volatile读操作的后面插入一个LoadStore屏障(读-写 屏障)。

volatile读场景如何插入内存屏障:

LoadStore屏障可以保证其后面的所有普通写(num的赋值操作num=num+5) 操作必须在volatile读(if(flag))之后执行。

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐