第十八章 volatile的前世今生往后

volatile的前世今生往后

volatile关键字

volatile关键字是JUC包的基础,所有的原子类型都以volatile作为修饰,因它不需要引起线程上下文切换和调度,比sychronized关键字更加轻量级的同步机制。volatile中在大厂面试中经常会问到。主要的考察点有:

  1. JUC包中利用volatile中的地方有哪些?为啥要用volatile?
  2. 结合JMM内存模型考察多线程下volatile的变量工作流程。
  3. 给出一个场景,怎么使用volatile关键字。

CPU Cache模型

因CPU与主内存(RAM)的访问速度严重不对等,需要加入Cache,现在目前的电脑一般都加入了三层Cache,离CPU最近的是L1,依次是L2,L3和主内存。加入缓存后就出现了以下的Cache模型,以inter core i7为例。其中d-cache为数据缓存,i-cache为指令缓存。unified cache为未区分是数据还是指令的统一缓存。寄存器Regs与L1之间的传输是字传输(word transfer),而L3与主内存之前的传输是块传输(block transfer)。下面先了解下缓存的缓存行结构与写类型。
在这里插入图片描述

cache line

缓存行是CPU cache系统中的最小单位,目前主流的CPU Cache的Cache Line大小都是64Bytes。以下是缓存行的结构。其中状态就是MESI中的MESI(下面会讲到)四种状态,地址则是cache line中的映射的主存地址,数据则是从内存该地址读取的数据。
在这里插入图片描述
当CPU从cache中读取数据,会比较地址和cache line的状态,决定该数据是否有效,无效则从主存中获取数据或从其他核的cache中获取数据(从其他核读取数据可以减少总线竞争)。

cache write type

  1. write through(写通):每次CPU修改了cache中的数据,立即更新到主内存。这样每次CPU写共享数据,将会导致总线事务。经常会引起总线竞争,而所有的硬件设备都挂在总线上,一旦写主存占用总线,CPU、DMA控制器等硬件设备获取不到锁,硬件设备就不能使用总线,效率低下。

  2. write back(写回):每次CPU修改了数据,不立即更新到内存,而是等到cache line在某一个必须或合适的时机才会更新到主内存中。

  3. 无论是write through和write back,只要CPU不是单核(目前i7-8700系列支持6核12线程)都需要处理缓存一致性问题。所以,处理器又提供了写失效(write invalidate)和写更新(write update)两个操作来保证cache的一致性。

    写失效:当一个CPU修改了数据,如果其他CPU有该数据,则通知其为无效。
    写更新:当一个CPU修改了数据,如果其他CPU有该数据,则通知其更新。

写更新需要占用大量的核内带宽。因此MESI协议采用了写失效的方式。确定了CPU对共享数据修改时其他CPU中对共享数据的修改方式,接下来要确定检测总线上的数据发生了变化的方法,这里就用到了窥探技术。

窥探技术

缓存控制器通过不停地窥探总线上发生的数据交换和其他缓存的变化。当一个缓存代表它所属的处理器去读写内存时,其他处理器都会得到通知,它们以此来使自己的缓存保持同步。 只要某个处理器一写内存,其他处理器马上就知道这块内存在它们自己的缓存中对应的缓存行已经失效。 窥探技术有两种实现方式。

  1. 采用锁总线的方式。CPU的lock前缀指令与xchg指令可以锁总线。通过CPU的#lock引脚链接到北桥芯片(North Bridge)的#lock引脚,当带lock前缀的执行执行时,北桥芯片会拉起#lock 电平。从而锁住总线,直到指令执行完成后放开。总线加锁会自动使所有CPU对该指令的缓存行失效。因此barrier就能保证所有CPU的cache的一致性。
  2. 通过缓存一致性协议控制缓存在不同Cache中的一致性问题。

第一种方式显然效率不高,前面已经说过原因。这是一种极端悲观的方式,相当于事务级别中的线性控制。
第二种方式是通过缓存一致性协议,目前最有名的是Intel公司提出的MESI协议。接下来对MESI协议做下了解。

MESI协议

MESI协议是利用总线窥探机制、支持回写高速缓存的最常用协议之一。MESI(Modified Exclusive Shared Or Invalid)协议是缓存行Modify(修改),Exculsive(排他),Shared(共享),Invalid(无效)四种状态(如下表)的简称。用2个bit(2^2)位可以存储。

状态解释
修改(M)缓存行仅存在于当前高速缓存中,并且是脏的(因为已从主存中的值修改)。需要高速缓在将来的某个时间将缓存行数据同步回写到主内存。回写将该缓存行数据就更新为共享状态(S)
独占(E)缓存行仅存在于当前高速缓存中,并且没有被修改过(与主存数据一致),此时,其他处理的同一缓存行将变成"失效"状态。
共享(S)表示此高速缓存行可能存储在计算机的其他高速缓存中。并且每个高速缓存的该缓存行是未被修改的(与主存器数据一致)。可以随时丢弃该行(更改为无效状态)
无效(I)表示此缓存行无效(未使用)。缓存行被其他高速缓存修改过,该值不是最新的值,需要读取主存上最新的值。

只有当缓存行处于E或M状态时,处理器才能去写它,也就是说只有这两种状态下,处理器是独占这个缓存行的。当处理器想写某个缓存段时,如果它没有独占权,它必须先发送一条“我要独占权”的请求给总线,这会通知其他处理器,把它们拥有的同一缓存行的拷贝失效(I状态),如果它们有的话。只有在获得独占权后,处理器才能开始修改数据。并且此时,这个处理器知道,这个缓存行只有一份拷贝,在我自己的缓存里,所以不会有任何冲突。反之,如果有其他处理器想读取这个缓存行(能知道,因为一直在窥探总线),独占或已修改的缓存行必须先回到“共享”状态。如果是已修改的缓存段,那么还要先把内容回写到内存中。

MESI协议协作规则

一个处于修改状态M的缓存行,时刻在监听其他CPU对该主存地址"谁要读该缓存行",如果监听到,则执行"老哥,等我先写会该缓存行,你再读取"。

一个处于共享状态S的缓存行,时刻监听其他CPU使该缓存行失效或者独占的请求,如果监听到,则置为该缓存行状态为无效。

一个处于独占状态E状态的缓存行,时刻监听其他试图读取该缓存行对应的主存地址的操作,如果监听到,则置为该缓存行状态为共享状态。

以上规则对应的缓存读写操作动作如下:

读操作:一个缓存中有M状态的cache line,必须"窥探"所有该内存关联的Cache的读操作。并且监听到读操作后,让读操作等待,等待该cache line写回主存并将该cache line置为S状态。若是E状态的cache line,同样"窥探"所有内存关联cache的读操作,并在监听到读操作后将其状态由E置为S状态。

写操作:如果cache line是M或者E状态,那么它只能被处理器写。在这两种情况下,处理器是独占该cache line的,写入主存不会有任何冲突。如果是S状态,则必须先发送request-for-owership让其他cache中关联的同一内存地址的cache line变为无效才允许写入主存,写入后修改其状态为M。

read-for-owership是缓存一致性里组合一个读核一个丢弃的广播操作,该操作是一个处理器尝试写一个处于S或I状态的cache line,这个操作所有其他缓存置为I,这个操作是总线事务操作,它给缓存带来数据并丢弃其他所有的cache line。

下面是一个处理器中的缓存行与另外一个处理器的缓存行针对同一个内存地址内容的状态关系。

Modify和Exculsive两种状态都是排他的。只有在Shared状态时出现缓存不一致的情况。当有数据被修改时,其他拥有该数据的核心通过主存-缓存控制器监听到发生了remote write(可以简单理解为其他处理器缓存的读写操作修改了当前处理器cache line状态),然后将自己拥有的数据状态修改为无效。从下图奔腾主板结构可以看到。CPU与主存之前加入了Cache控制器。此奔腾系列L2 cache是在主板上。
在这里插入图片描述

Java内存模型(JMM)

如果之前了解过JMM,就会发现JMM借鉴了CPU中Cache的一致性来解决JMM中多线程数据访问的一致性问题。JMM决定了一个线程对共享变量的写入对其他线程可见的时机。借鉴MESI协议,定义了线程工作空间与主内存之间的抽象关系。主要包括:

  1. 共享变量存在于主内存中,所有线程都可以访问。
  2. 每个线程拥有自己的工作内存。
  3. 工作内存中只拥有共享变量的副本。
  4. 线程不能直接操作主内存。主内存的数据通过工作内存修改。

并发编程的三大特性

  1. 原子性
    原子性同事务的原子性是一个意思。指一个任务分多个步骤,这个任务要么所有步骤都完成,任务才完成。如果其中有一个步骤失败,则其他步骤都回滚,任务失败。
    关于原子性常见的问题有:C语言的i++汇编后变成三条指令,所有肯定不是原子操作。Java中的i++反编译后发现会出现多条字节码语句,先将i放入本地变量表的栈顶,然后加1,最后从栈顶弹出。也不是原子操作。Java中提供了synchronized关键字实现原子性,JUC中原子类也实现了原子性操作。

  2. 可见性
    可见性是指一个工作内存中的变量的修改,在另外一个线程读取时会读取到最新修改的值。volatile就能保证可见性。

  3. 有序性
    Java在编译期与运行期间会对程序代码的执行顺序进行调整,即指令重排。指令重排将导致程序执行的顺序并非是代码的顺序。但是能保证最终的结果的正确性。禁止指令重排有缺点????

JMM怎么保证这三个特性呢?

JMM内存模型本身具备了Happens-before原则。其中有些规则是由底层协议支持的。

Happens-before八大原则

程序次序规则:一个线程内一段代码的执行结果是有序的。即使还会指令重排,随便它怎么排,最终结果与我们代码顺序生成的一致。

管程锁定规则:就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果!(管程是一种通用的同步原语,synchronized就是管程的实现)

volatile变量规则:一个volatile修饰的变量,一个线程对它进行读操作,同时另一个线程对它进行写操作。则写操作优先于读操作。这个操作是由硬件层面的内存屏障决定的。在volatile变量写操作之前插入了storestore内存屏障,保证“前者”刷入内存的数据对“后者”是可见的。

线程启动规则:Thread对象的start()方法优先于该线程的任何动作,只有start()之后才能真正运行,被系统进程接管。否则Thread也仅仅是系统中的一个占用内存的对象而已。

线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件,这句话的意思是如果线程发生收到了中断信息,那么在之前必有interrupt()。可以通过Thread.interrupted()检测到是否之前存在中断。

线程终止规则:线程中所有的操作都要先行发生于线程的终止检测,也就是线程的任务执行、逻辑单元执行肯定要优先于线程死亡。

对象终结规则:构造函数执行的结束一定 happens-before它的finalize()方法。

传递规则:这个简单的,就是happens-before原则具有传递性,即A happens-before B , B happens-before C,那么A happens-before C。

JMM与原子性

在Java中,所有基本类型的读取和赋值都是原子操作。对引用的读取和赋值也原子性的。但为了加强对原子性的理解,举几个例子。

(1)x=1;赋值操作

CPU首先会将1写入到工作内存。然后再将它写入到主内存。如果其他的工作线程将x的值修改为2,但最终结果要么是1,要么是2,肯定不会出现其他类型。所以赋值操作肯定是原子性的。

(2)y=x;赋值操作

这个操作也是赋值操作,但是并不是原子性的。因为它包含两个步骤。

1)执行线程从工作内存中查找x的值,如果存在则直接获取;否则将从主内存获取,并写入到工作内存中。

2)在执行线程的工作内存中修改y的值为x的值,并将y的值写入到主内存中。

(3)y++;自增操作

这个操作是非原子操作,经常在面试中问到。它包括三个步骤。

1)执行线程从主内存获取y的值(如果y已经存在于执行线程的工作内存中,则直接获取),然后将其存入当前线程的工作内存中。

2)在执行线程中将y入栈,读入CPU寄存器执行+1,然后写入栈顶。

3)将y的值写入主内存。

总结:JMM能保证基本类型和引用的读取和赋值操作的原子性,要保证某些代码片段具有原子性,需要使用synchronized或lock。如果要保证基本类型如int的操作具有原子性,可以使用JUCA包相关类。

JMM与可见性

在并发环境中,某个线程在工作线程中修改了共享变量的值,在该线程没有把值同步回主内存,对其他线程而言是不可见的,因为它只能读取主内存的值,而此时主内存的值还并未被最新执行线程修改。JMM为了解决这个问题。提供以下三种方法保证可见性。

1)使用volatile关键字。当一个共性变量被volatile关键字修饰时,对于该变量的读取会直接从主内存进行(当然也会缓存到工作内存中,如果其他线程对该共享变量有修改,则会导致其他所有工作内存的副本失效。必须从主内存再次获取)。对于共享资源的写操作也会先修改工作内存,然后写入到主内存中。这个地方要结合C语言源码详细讲解。

2)通过synchronized关键字。synchronized关键字能保证同一时刻只有一个线程获得锁,然后执行同步方法,在锁释放前会将该变量修改后的值刷新到主内存中。

3)通过lock也可以保证可见性。原理同synchronized关键字。

JMM与有序性

在JMM中,是允许编译器和处理器对指令进行重排序的。在单线程环境中,指令重排序后相对该线程结果也是正确的,重排序不会出现任何问题。但是并发环境下,指令重排序就会影响程序的正确执行。Java同可见性一样,JMM提供了三种方式保证有序性。

1)使用volatile关键字来保证有序性。

2)使用syncronized关键字保证有序性。

3)使用显式锁lock来保证有序性。

syncronized和lock因为控制线程串行执行。与单线程一样能保证结果的最终一致性。对于volatile关键字。

volatile关键字

当一个变量被定义为volatile之后,它将具备两种特性,第一是保证此变量对所有线程的可见性,这里的"可见性"是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量的值在线程间传递需要通过主内存来完成。第二是禁止指令重排,通过禁止指令重排达到可见性。

volatile变量在各个线程的工作内存中不存在一致性问题(在各个线程的工作内存中,volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,字节码执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是Java里面的运算并非原子性操作,导致volatile变量的运算在并发下一样是不安全的。可以使用-XX:+PrintAssembly参数输出反汇编来分析会更加严谨一些。

由于volatile变量只能保证可见性,在并发控制场景中,我们仍然要通过加锁(使用synchronized或java.util.concurrent中的原子类)来保证原子性。

使用volatile会在编译成字节码时生成以下指令。

lock add1 $0x0,(%esp)

通过前面对MESI协议分析,加入lock的汇编指令(该字节码指令在底层也会转化为lock),它的作用是使得本CPU写入了内存,该写入操作也会引起别的CPU或者别的内核无效化(Invalidate)其Cache,这种操作相当于对Cache中的变量做了一次前面介绍Java内存模式中所说的"store和write"操作。所以通过这样的一个操作,可让前面volatile变量的修改对其他CPU立即可见。

volatile的同步机制的性能确实要优于锁(使用synchronized关键字或java.util.concurrent包里边的锁),但是由于虚拟机对锁实行的许多消除和优化,使得我们很难量化地认为volatile就会比synchroized快多少(只知道快)。如果让volatile自己与自己比较,那可以确定一个原则:volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

下面归纳下JMM对volatile变量定义的特殊规则。假定T表示一个线程,V和W分别表示两个volatile型变量,那么在进行read、load、use、assign、store和write操作时需要满足如下规则:

  1. 只有当线程T对变量V执行的前一个动作是load的时候,线程T才能对变量V执行use动作;并且,只有当线程T对变量V执行的后一个动作是use…(P373).

volatile的实现原理及机制

在OpenJDK下的unsafe.cpp源码下。存在一个lock;的前缀。

volatile的使用场景

虽然volatile有部分syncronized关键字的语义,但是volatile不可能完成替代syncronized关键字。因为volatile关键字不能保证原子性,但是可以充分利用它的可见性和有序性。

1.利用可见性做开关控制。

2.利用指令重排的顺序性做状态标记,单例设计模式中的double-check也是利用了顺序性的特点。

volatile与syncronized异同

  1. 使用上的区别

    1)volatile关键字只能用于修饰类变量、实例变量。不能修饰方法,方法参数,局部变量,常量。

    2)syncronized关键字不能用于对变量的修饰,只能用于修饰方法或语句块。

    3)volatile修饰的变量可以为null,但是syncronized关键字同步语句块的monitor对象不能为null。

  2. 原子性区别

    1)volatile无法保证原子性。

    2)syncronized关键字采用排他机制,因此被syncronized关键字修饰的同步代码是无法被中途打断的,因此其能保证代码的原子性。

  3. 可见性区别

    两者都能保证共享变量在并发环境的可见性,但是实现机制完全不同。syncronized关键字借助于JVM指令的monitor enter和monitor exit通过排他的方式使得同步代码串行化,在monitor exit前对所有共享变量刷新到主内存。相比syncronized,volatile使用机器指令"lock;"的方式迫使其他线程工作内存中的数据失效,不得到主内存中进行再次加载。

  4. 有序性区别

    1)volatile关键字通过禁止编译器及处理器对其进行重排序达到可见性。

    2)sychronized关键字是通过排他机制串行化实现的。在syncronized块中的代码块也会发生指令重排。但是在monitor exit执行完后的编写顺序一致。

  5. 其他

    1)volatile不会使线程陷入阻塞。

    2)syncronized关键字会使线程进入阻塞状态。

常见面试题

public class AppendDemo {
    private static   volatile   boolean isStop;
    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            int i=0;
            @Override
            public void run() {
                while (!isStop){
                    ++i;
                }
            }
        }).start();
        TimeUnit.SECONDS.sleep(1);
        isStop=true;

    }
}
  • 1.volatile,是怎么可见性的问题(CPU缓存),那么他是怎么解决的—>MESI
  • 2.CAS指令,确保了对同一个同一个内存地址操作的原子性,那么他应该也会遇到和上面可见性一样的问题,他是怎么解决的,是不是和volatile的底层原理类似?—>是的,也是利用了MESI
  • 3.volatile还避免了指令重排,是通过内存屏障解决的?那么他和MESI有什么关系?还是说volatile关键字即用了MESI也用了内存屏障?—>是的,其实MESI底层也还是需要内存屏障

Java 在 1.5 版本中引入了 JSR 133 标准,这个标准提出了 Java 中的并发内存模型和线程规范,这个标准的发布标志着 Java 拥有独立于系统平台的并发内存模型。和 C/C++不同的是,Java 并没有直接操作系统平台中的内存模型,而是自己定义了一套机制,这套机制包含了并发访问机制、缓存一致性协议以及指令重排序解决方案等内容。

参考:
https://createchance.github.io/post/java-并发之基石篇/
《Java 理论与实践: 正确使用 Volatile 变量》

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值