volatile 能到这份上,也就差不多了

297次阅读  |  发布于3年以前

写volatile的文章非常多,本人也看过许多相关文章,但始终感觉有哪里不太明白,但又说不上来说为什么。可能是过于追求底层实现原理,老想问一个为什么吧。

而写这篇文章的目的很简单,就是突然之间明白了volatile为什么要这样设计了。好东西当然要拿出来分享了,于是就有了这篇文章。

我们就从硬件到软件,再到具体的案例来聊聊volatile的底层原理,文章比较长,可收藏之后阅读。

CPU缓存的出现

最初的CPU是没有缓存区的,CPU直接读写内存。但这就存在一个问题,CPU的运行效率与读写内存的效率差距百倍以上。总不能CPU执行1个写操作耗时1个时钟周期,然后再等待内存执行一百多个时钟周期吧。

于是在CPU和内存之间添加了缓存(CPU缓存:Cache Memory),它是位于CPU和内存之间的临时存储器。这就像当Mysql出现瓶颈时,我们会考虑通过缓存数据来提升性能一样。总之,CPU缓存的出现就是为了解决CPU和内存之间处理速度不匹配的问题而诞生的。

这时,我们有一个粗略的图:

CPU-CPU缓存-内存

但考虑到进一步优化数据的调度,CPU缓存又分为一级缓存、二级缓存、三级缓存等。它们主要用于优化数据的吞吐和暂存,提高执行效率。

目前主流CPU通常采用三层缓存:

经过上述细分,可以将上图进一步细化:

CPU三级缓存

这里再补充一个概念:缓存行(Cache-line),它是CPU缓存存储数据的最小单位,后面会用到。上面的CPU缓存,也称作高速缓存。

引入缓存之后,每个CPU的处理过程为:先将计算所需数据缓存在高速缓存中,当CPU进行计算时,直接从高速缓存读取数据,计算完成再写入缓存中。当整个运算过程完成之后,再把缓存中的数据同步到主内存中。

如果是单核CPU这样处理没有什么问题。但在多核系统中,每个CPU都可能将同一份数据缓存到自己的高速缓存中,这就出现了缓存数据一致性问题了。

CPU层提供了两种解决方案:总线锁和缓存一致性。

总线锁

前端总线(也叫CPU总线)是所有CPU与芯片组连接的主干道,负责CPU与外界所有部件的通信,包括高速缓存、内存、北桥,其控制总线向各个部件发送控制信号、通过地址总线发送地址信号指定其要访问的部件、通过数据总线双向传输。

比如CPU1要操作共享内存数据时,先在总线上发出一个LOCK#信号,其他处理器就不能操作缓存了该共享变量内存地址的缓存,也就是阻塞了其他CPU,使该处理器可以独享此共享内存。

很显然,这样的做法代价十分昂贵,于是为了降低锁粒度,CPU引入了缓存锁

缓存一致性协议

缓存一致性:缓存一致性机制整体来说,就是当某块CPU对缓存中的数据进行操作了之后,会通知其他CPU放弃储存在它们内部的缓存,或者从主内存中重新读取。

缓存锁的核心机制就是基于缓存一致性协议来实现的,即一个处理器的缓存回写到内存会导致其他处理器的缓存无效,IA-32处理器和Intel 64处理器使用MESI实现缓存一致性协议。

缓存一致性是一个协议,不同处理器的具体实现会有所不同,MESI是一种比较常见的缓存一致性协议实现。

MESI协议

MESI协议是以缓存行的几个状态来命名的(全名是Modified、Exclusive、Share or Invalid)。该协议要求在每个缓存行上维护两个状态位,每个数据单位可能处于M、E、S和I这四种状态之一,各种状态含义如下:

其中上述状态随着不同CPU的操作还会进行不停的变更:

对于MESI协议,从CPU读写角度来说会遵循以下原则:

CPU读数据:当CPU需要读取数据时,如果其缓存行的状态是I的,则需要从内存中读取,并把自己状态变成S,如果不是I,则可以直接读取缓存中的值,但在此之前,必须要等待其他CPU的监听结果,如其他CPU也有该数据的缓存且状态是M,则需要等待其把缓存更新到内存之后,再读取。

CPU写数据:当CPU需要写数据时,只有在其缓存行是M或者E的时候才能执行,否则需要发出特殊的RFO指令(Read Or Ownership,这是一种总线事务),通知其他CPU设置缓存无效(I),这种情况下性能开销是相对较大的。在写入完成后,修改其缓存状态为M。

当引入总线锁或缓存一致性协议之后,CPU、缓存、内存的结构变为下图:

CPU-缓存-总线-内存

MESI协议带来的问题

在上述MESI协议的交互过程中,我们已经可以看到在各个CPU之间存在大量的消息传递(监听处理)。而缓存的一致性消息传递是需要时间的,这就使得切换时会产生延迟。一个CPU对缓存中数据的改变,可能需要获得其他CPU的回执之后才能继续进行,在这期间处于阻塞状态。

Store Bufferes

等待确认的过程会阻塞处理器,降低处理器的性能。而且这个等待远远比一个指令的执行时间长的多。为了避免资源浪费,CPU又引入了存储缓存(Store Bufferes)。

基于存储缓存,CPU将要写入内存数据先写入Store Bufferes中,同时发送消息,然后就可以继续处理其他指令了。当收到所有其他CPU的失效确认(Invalidate Acknowledge)时,数据才会最终被提交。

举例说明一下Store Bufferes的执行流程:比如将内存中共享变量a的值由1修改为66。

第一步,CPU-0把a=66写入Store Bufferes中,然后发送Invalid消息给其他CPU,无需等待其他CPU相应,便可继续执行其他指令了。

store bufferes

第二步,当CPU-0收到其他所有CPU对Invalid通知的相应之后,再把Store Bufferes中的共享变量同步到缓存和主内存中。

store Bufferes

Store Forward(存储转发)

Store Bufferes的引入提升了CPU的利用效率,但又带来了新的问题。在上述第一步中,Store Bufferes中的数据还未同步到CPU-0自己的缓存中,如果此时CPU-0需要读取该变量a,缓存中的数据并不是最新的,所以CPU需要先读取Store Bufferes中是否有值。如果有则直接读取,如果没有再到自己缓存中读取,这就是所谓的”Store Forward“。

失效队列

CPU将数据写入Store Bufferes的同时还会发消息给其他CPU,由于Store Bufferes空间较小,且其他CPU可能正在处理其他事情,没办法及时回复,这个消息就会陷入等待。

为了避免接收消息的CPU无法及时处理Invalid失效数据的消息,造成CPU指令等待,就在接收CPU中添加了一个异步消息队列。消息发送方将数据失效消息发送到这个队列中,接收CPU返回已接收,发送方CPU就可以继续执行后续操作了。而接收方CPU再慢慢处理”失效队列“中的消息。

内存屏障

CPU经过上述的一系列优化,既保证了效率又确保了缓存的一致性,大多数情况下也是可以接受CPU基于Store Bufferes和失效队列异步处理的短暂延迟的。

但在多线程的极端情况下,还是会产生缓存数据不一致的情况的。比如上述实例中,CPU-0修改数据,发消息给其他CPU,其他CPU消息队列接收成功并返回。这时CPU-1正忙着处理其他业务,没来得及处理消息队列,而CPU-1处理的业务中恰好又用到了变量a,此时就会造成读取到的a值为旧值。

这种因为CPU缓存优化导致后面的指令无法感知到前面指令的执行结果,看起来就像指令之间的执行顺序错乱了一样,对于这种现象我们俗称“CPU乱序执行”。

乱序执行是导致多线程下程序Bug的原因,解决方案很简单:禁用CPU缓存优化。但大多数情况下的数据并不存在共享问题,直接禁用会导致整体性能下降,得不偿失。于是就提供了针对多线程共享场景的解决机制:内存屏障机制

使用内存屏障后,写入数据时会保证所有指令都执行完毕,这样就能保证修改过的数据能够即时暴露给其他CPU。而读取数据时,能够保证所有“失效队列”消息都消费完毕。然后,CPU根据Invalid消息判断自己缓存状态,正确读写数据。

CPU层面的内存屏障

CPU层面提供了三类内存屏障:

下面通过一段伪代码来进行说明:

public class Demo {
    int value;
    boolean isFinish;

    void cpu0(){
        value = 10; // S->I状态,将value写入store bufferes,通知其他CPU value缓存失效
        storeMemoryBarrier(); // 插入写屏障,将value=10强制写入主内存
        isFinish = true; // E状态
    }

    void cpu1(){
        if (isFinish){ // true
            loadMemoryBarrier(); //插入读屏障,强制cpu1从主内存中获取最新数据
            System.out.println(value == 10); // true
        }
    }

    void storeMemoryBarrier(){//写屏障
    }
    void loadMemoryBarrier(){//读屏障
    }
}

上述实例中通过内存屏障防止了指令重排,能够得到预期的结果。

总之,内存屏障的作用可以通过防止CPU乱序执行来保证共享数据在多线程下的可见性。那么,在JVM中是如何解决该问题的呢?也就是编程人员如何进行控制呢?这就涉及到我们要讲的volatile关键字了。

Java内存模型

内存屏障解决了CPU缓存优化导致的指令执行的顺序性和可见性问题,但不同的硬件系统提供的“内存屏障”指令又有所不同,作为开发人员也没必要熟悉所有的内存屏障指令。而Java将不同的内存屏障指令进行了统一封装,开发人员只需关注程序逻辑开发和内存屏障规范即可。

这套封装解决方案的模型就是我们常说的Java内存模型(Java Memory Model),简称JMM。JMM最核心的价值便在于解决可见性和有序性,它是对硬件模型的抽象,定义了共享内存中多线程程序读写操作的行为规范。

这套规范通过限定对内存的读写操作从而保证指令的正确性,解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。

本质上,JMM是把硬件底层的问题抽象到了JVM层面,屏蔽了各个平台的硬件差异,然后再基于CPU层面提供的内存屏障指令以及限制编译器的重排序来解决并发问题的。

JMM抽象模型结构

JMM抽象模型中将内存分为主内存和工作内存:

线程是CPU调度的最小单位,线程之间的共享变量值的传递都必须通过主内存来完成。

JMM抽象模型结构图如下:

JMM抽象模型

JMM内存模型简单概述就是:

如果线程A需要与线程B进行通信,则线程A先把本地缓存中的数据更新到主内存,再由线程B从主内存中进行获取。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序提供内存可见性保证。

编译器指令重排

除了硬件层面的指令重排,Java编译器为了提升性能,也会对指令进行重排。Java规范规定JVM线程内部维持顺序化语义,即只要程序的最终结果与它顺序化执行的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。

JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。

从源码到最终执行示例图:

指令重排序

其中2和3属于CPU执行阶段的重排序,1属于编译器阶段的重排序。编译器会遵守happens-before规则和as-if-serial语义的前提下进行指令重排。

happens-before规则:如果A happens-before B,且B happens-before C,则需要保证A happens-before C。

as-if-serial语义:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、Runtime和处理器都必须遵守as-if-serial语义。

对于处理器重排序,JMM要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,来禁止特定类型的处理重排序。

JMM的内存屏障

上面了解了CPU的内存屏障分类,在JMM中把内存屏障分为四类:

其中,StoreLoad Barriers同时具有前3个的屏障的效果,但性能开销很大。

为了实现volatile内存语义,JMM会分别限制这两种类型的重排序类型。下图是JMM针对编译器制定的volatile重排序规则表。

JMM重排序

从图中可以得出一个基本规则:

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:

保守策略下volatile写插入内存屏障后生成的指令序列示意图:

volatile写屏障

保守策略下volatile读插入内存屏障后生成的指令序列示意图:

volatile读内存屏障

JMM对volatile的特殊规则定义

JVM内存指令与volatile相关的操作有:

在对volatile修饰的变量进行操作时,需满足以下规则:

volatile实例及分析

通过上面的分析关于volatile关键词的来源,以及被它修饰的变量的可见性和有序性都从理论层面讲解清楚了。下面看一个可见性的实例。

示例代码如下:

public class VolatileTest {

 private boolean initFlag = false;

 public static void main(String[] args) throws InterruptedException {
  VolatileTest sample = new VolatileTest();
  Thread threadA = new Thread(sample::refresh, "threadA");

  Thread threadB = new Thread(sample::load, "threadB");

  threadB.start();
  Thread.sleep(2000);
  threadA.start();
 }

 public void refresh() {
  this.initFlag = true;
  System.out.println("线程:" + Thread.currentThread().getName() + ":修改共享变量initFlag");
 }

 public void load() {
  int i = 0;
  while (!initFlag) {
  }
  System.out.println("线程:" + Thread.currentThread().getName() + "当前线程嗅探到initFlag的状态的改变" + i);
 }
}

根据上面的理论知识,先猜测一下线程先后打印出的内容是什么?先打印”线程threadA修改共享变量initFlag“,然后打印”线程threadB当前线程嗅探到initFlag的状态的改变0“?

当真正执行程序时,会发现整个线程阻塞在while循环处,并未打印出第2条内容。此时JMM操作如下图:

thread-without-volatile

虽然线程A中将initFlag改为了true并且最终会同步回主内存,但是线程B中循环读取的initFlag一直都是从工作内存读取的,所以会一直进行死循环无法退出。

当对变量initFlag添加了volatile修饰之后:

public class VolatileTest {

 private volatile boolean initFlag = false;
 //...
}

JMM操作如下图:

thread-with-volatile

添加了volatile修饰之后,两句日志都会被打印出来。这是因为添加volatile关键字后,就会有lock指令,使用缓存一致性协议,线程B中会一直嗅探initFlag是否被改变,线程A修改initFlag后会立即同步回主内存,同时通知线程B将缓存行状态改为I(无效状态),重新从主内存读取。

volatile无法保证原子性

volatile虽然保证了共享变量的可见性和有序性,但并不能够保证原子性。

以常见的自增操作(count++)为例来进行说明,通常自增操作底层是分三步的:

我们来分析一下在这个过程中会有的线程安全问题:

第一步,线程A和B同时获得count的初始值,这一步没什么问题;

第二步,线程A自增count并回写,但线程B此时也已经拿到count,不会再去拿线程A回写的值,因此对原始值进行自增并回写,这就导致了线程安全的问题。有人可能要问了,线程A自增之后不是应该通知其他CPU缓存失效吗,并重新load吗?我们要知道,重新获取的前提操作是读,在线程A回写时,线程B已经拿到了count的值,并不存在再次读的场景。也就是说,线程B的缓存行的确会失效,但线程B中count值已经运行在加法指令中,不存在需要再次从缓存行读的场景。

volatile关键字只保证可见性,所以在以下情况中,需要使用锁来保证原子性:

所以,想要使用volatile变量提供理想的线程安全,必须同时满足两个条件:

也就是说被修饰的变量值独立于任何程序的状态,包括变量的当前状态。

volatile适用场景

状态标志

使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。

volatile boolean shutdownRequested;

...

public void shutdown() { 
    shutdownRequested = true; 
}

public void doWork() { 
    while (!shutdownRequested) { 
        // do stuff
    }
}

线程1执行doWork()的过程中,线程2可能调用了shutdown,所以boolean变量必须是volatile。

这种状态标记的一个公共特性是:通常只有一种状态转换;shutdownRequested 标志从false 转换为true,然后程序停止。这种模式可以扩展到来回转换的状态标志,但是只有在转换周期不被察觉的情况下才能扩展(从false 到true,再转换到false)。此外,还需要某些原子状态转换机制,例如原子变量。

一次性安全发布

在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。

这种场景在著名的双重检查锁定(double-checked-locking)中会出现:

//注意volatile!
private volatile static Singleton instace;   

public static Singleton getInstance(){   
    //第一次null检查     
    if(instance == null){            
        synchronized(Singleton.class) {    //1     
            //第二次null检查       
            if(instance == null){          //2  
                instance = new Singleton();//3  
            }  
        }           
    }  
    return instance;        
}

其中第3步中实例化Singleton分多步执行(分配内存空间、初始化对象将对象指向分配的内存空间),某些编译器为了性能原因,会将第二步和第三步进行重排序(分配内存空间、将对象指向分配的内存空间初始化对象)。这样,某个线程可能会获得一个未完全初始化的实例。

独立观察(independent observation)

场景:定期 “发布” 观察结果供程序内部使用。比如,传感器感知温度,一个线程每隔几秒读取一次传感器,并更新当前的volatile修饰变量。其他线程可以读取这个变量,随时看到最新温度。

另一种场景就是应用程序搜集统计信息。比如记录最后一次登录的用户名,反复使用lastUser引用来发布值,以供其他程序使用。

public class UserManager {
    public volatile String lastUser; //发布的信息

    public boolean authenticate(String user, String password) {
        boolean valid = passwordIsValid(user, password);
        if (valid) {
            User u = new User();
            activeUsers.add(u);
            lastUser = user;
        }
        return valid;
    }
} 

“volatile bean” 模式

volatile bean 模式的基本原理是:很多框架为易变数据的持有者(例如 HttpSession)提供了容器,但是放入这些容器中的对象必须是线程安全的。在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通——即不包含约束。

@ThreadSafe
public class Person {
    private volatile String firstName;
    private volatile String lastName;
    private volatile int age;

    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }

    public void setFirstName(String firstName) { 
        this.firstName = firstName;
    }

    public void setLastName(String lastName) { 
        this.lastName = lastName;
    }

    public void setAge(int age) { 
        this.age = age;
    }
}

开销较低的“读-写锁”策略

如果读操作远远超过写操作,可以结合使用内部锁volatile 变量来减少公共代码路径的开销。

如下线程安全的计数器代码,使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。

@ThreadSafe
public class CheesyCounter {
    // Employs the cheap read-write lock trick
    // All mutative operations MUST be done with the 'this' lock held
    @GuardedBy("this") private volatile int value;

    //读操作,没有synchronized,提高性能
    public int getValue() { 
        return value; 
    } 

    //写操作,必须synchronized。因为x++不是原子操作
    public synchronized int increment() {
        return value++;
    }

使用锁进行有变化的操作,使用volatile进行只读操作。volatile允许多个线程同时执行读操作。

小结

本文先从硬件层面分析CPU的处理机制,为了优化CPU引入了缓存,为了更进一步优化引入了Store Bufferes,而Store Bufferes导致了缓存一致性问题。于是有了总线锁和缓存一致性协议(EMSI实现),从而有了CPU的内存屏障机制。

而CPU的内存屏障反映在Java编程语言中,有了Java内存模型(JMM),JMM屏蔽了底层硬件的不同,提供了统一的操作,进而编程人员可以通过volatile关键字来解决共享变量的可见性和顺序性。

紧接着,通过实例演示了volatile的作用以及它不具有线程安全保证的反面案例。最后,举例说明volatile的运用场景。

想必通过这篇文章,你已经彻底弄懂了volatile相关的知识了吧?来,关注一波。

参考文章:

https://juejin.cn/post/6876395693854949389

https://zhuanlan.zhihu.com/p/43526907

https://www.chinacion.cn/article/8093.html

https://blog.csdn.net/u012988901/article/details/111313057

https://blog.csdn.net/fumitzuki/article/details/81630048

https://www.cnblogs.com/yaowen/p/11240540.html

https://blog.csdn.net/vking_wang/article/details/9982709

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8