- Java并发编程深度解析与实战
- 谭锋(Mic)
- 4869字
- 2022-05-10 18:39:20
3.2 深度理解可见性问题的本质
实际上,除编译器优化带来的可见性问题外,还有很多因素会导致可见性问题,比如CPU高速缓存、CPU指令及重排序等,为了彻底搞懂可见性的本质,下面我们围绕硬件及操作系统层面的优化进行分析。
3.2.1 如何最大化提升CPU利用率
CPU是计算机最核心的资源,它主要用来解释计算机指令及处理计算机软件中的数据。当程序加载到内存中后,操作系统会把当前进程分配给指定的CPU执行,在获得CPU执行权后,CPU从内存中取出指令进行解码,并执行,然后取出下一个指令解码,再次执行。
CPU在做运算时,无法避免地要从内存中读取数据和指令,CPU的运算速度远远高于内存的I/O速度,比如一个支持2.6GHz主频的CPU,每秒可以执行2.6x109次,相当于每个指令只需要0.38ns。而从内存中读写一个数据,每次寻址需要100ns,很显然两者的速度差异非常大,CPU和内存之间的这个速度瓶颈被称为冯诺依曼瓶颈。虽然计算机在不断地迭代升级(比如CPU的处理性能越来越快、内存容量越来越大、内存的I/O效率也在不断提升),但是这个核心的矛盾无法消除。
如图3-1所示,CPU在做计算时必须与内存交互,即便是存储在磁盘上的数据,也必须先加载到内存中,CPU才能访问。也就是说,CPU和内存之间存在无法避免的I/O操作。
图3-1 CPU的执行过程
基于上述的分析可以看到,当CPU向内存发起一个读操作时,在等待内存返回之前,CPU都处于等待状态,直到返回之后CPU继续运行下一个指令,这个过程很显然会导致CPU资源的浪费。为了解决这个问题,开发者在硬件设备、操作系统及编译器层面做了很多优化。
• 在CPU层面增加了寄存器,来保存一些关键变量和临时数据,还增加了CPU高速缓存,以减少CPU和内存的I/O等待时间。
• 在操作系统层面引入了进程和线程,也就是说在当前进程或线程处于阻塞状态时,CPU会把自己的时间片分配给其他线程或进程使用,从而减少CPU的空闲时间,最大化地提升CPU的使用率。
• 在编译器层面增加指令优化,减少与内存的交互次数。
以上这些优化的目的是提升CPU利用率,但是恰恰也是这些优化导致了可见性问题的发生,下面我们进行展开分析。
3.2.2 详述CPU高速缓存
CPU和内存的I/O操作是无法避免的,为了降低内存的I/O耗时,开发者在CPU中设计了高速缓存,用存储与内存交互的数据。CPU在做读操作时,会先从高速缓存中读取目标数据,如果目标数据不存在,就会从内存中加载目标数据并保存到高速缓存中,再返回给处理器。
在主流的X86架构的处理器中,CPU高速缓存通常分为L1、L2、L3三级,它的具体结构如图3-2所示。
图3-2 CPU高速缓存的结构
图3-2展示了CPU高速缓存的结构,L1和L2缓存是CPU核内的缓存,是属于CPU私有的。L3是跨CPU核心共享的缓存,其中L1缓存又分为L1D一级数据缓存、L1I一级指令缓存,这三级缓存的大小和缓存的访问速度排列为L1 > L2 > L3。
• L1是CPU硬件上的一块缓存,它分为数据缓存和指令缓存(指令缓存用来处理CPU必须要执行的操作信息,数据缓存用来存储CPU要操作的数据),它的容量最小但是速度最快,容量一般在256KB左右,好一点的CPU可以达到1MB以上。
• L2也是CPU硬件上的一块缓存,相比L1缓存来说,容量会大一些,但是速度相对来说会慢,容量通常在256KB到8MB之间。
• L3是CPU高速缓存中最大的一块,也是访问速度最慢的缓存,它的容量在4MB到50MB之间,它是所有CPU核心共享的一块缓存。
当CPU读取数据时,会先尝试从L1缓存中查找,如果L1缓存未命中,继续从L2和L3缓存中查找,如果在缓存行中没有命中到目标数据,最终会访问内存。内存中加载的数据会依次从内存流转到L3缓存,再到L2缓存,最后到L1缓存。当后续再次访问存在于缓存行中的数据时,CPU可以不需要访问内存,从而提升CPU的I/O效率。
3.2.2.1 关于缓存行的实现
如图3-3所示,CPU的高速缓存是由若干缓存行组成的,缓存行是CPU高速缓存的最小存储单位,也是CPU和内存交换数据的最小单元。在x86架构中,每个缓存行大小为64位,即8字节,CPU每次从内存中加载8字节的数据作为一个缓存行保存到高速缓存中,这意味着高速缓存中存放的是连续位置的数据,这是基于空间局部性原理的实现。
图3-3 缓存行原理简图
空间局部性原理是指,如果一个存储器的位置被引用,那么将来它附近的位置也会被引用,这种缓存行读取的方式能够减少与内存的交互次数,提升CPU利用率,从而节省CPU读取数据的时间。
3.2.2.2 缓存行导致的伪共享问题
在缓存行的加载方式下,当CPU从内存加载数据到缓存行时,会把临近的64位数据一起保存到缓存行中。基于空间局部性原理,CPU在读取第二个数据时发现该数据已经存在于缓存行中,因此不需要再去内存中寻址了,可以直接从缓存中获取数据。
比如,在Java中,一个long类型是8字节,因此一个缓存行中可以存8个long类型的变量,假设当前访问的是一个long类型数组,当数组中的一个值被加载到缓存中时,也会同步加载另外7个。因此,CPU可以减少与内存的交互,快速完成这些数据的计算,这是缓存行的优势。
假设存在这样一种情况:有两个线程,分别访问上述long类型数组的不同的值,比如线程A访问long[1],线程B访问long[4],由于缓存行的机制使得两个CPU的高速缓存会共享同一个缓存行,为了保证缓存的一致性,CPU会不断使缓存行失效,并重新加载到高速缓存。如果这两个线程竞争非常激烈,就会导致缓存频繁失效,这就是典型的伪共享问题。
如图3-4所示,CPU0要从主内存中加载X变量,CPU1要从主内存中加载Y变量,如果X/Y/Z都在同一个缓存行中,那么CPU0和CPU1都会把这个缓存行加载到高速缓存中。如果CPU1先执行了对X变量的修改,那么基于缓存一致性协议,会使得CPU1中的缓存行失效。接着CPU1执行对X变量的修改,发现缓存行已经失效了,此时需要再次从主内存中加载该缓存行进行修改,而CPU1的这个修改也会导致CPU0中的缓存行失效,基于这样的方式不断循环运行。这个问题最终导致的结果就是程序的处理性能会大大降低。
图3-4 缓存行的伪共享问题
为了更加直观地理解伪共享问题,我们来看下面这个例子。
上述代码的核心功能就是,通过创建多个线程并对共享对象的值进行修改,来模拟伪共享的问题,代码中定义了如下两个静态类。
• ValuePadding,针对成员变量value做了对齐填充,其中p1、p2、p3、p4、p5、p6、p7作为前置填充,p9、p10、p11、p12、p13、p14、p15作为后置填充。之所以要做前后置填充,就是为了使value不管在哪个位置,都能够保证它处于不同的缓存行中,避免出现伪共享问题。
• ValueNoPadding,没有做对齐填充。
运行上述代码,执行结果如下。
下面把实例对象改成ValuePadding,代码如下。
运行结果如下:
可以很明显地发现,做了缓存行填充的程序,其运行效率提高了近10倍。
3.2.2.3 @Contended
JDK 1.8提供了@Contended注解,该注解的作用是实现缓存行填充,解决伪共享的问题。
@Contended注解可以添加在类上,也可以添加在字段上,当添加在字段上时,可以保证该字段处于一个独立的缓存行中。在使用时,为了确保@Contended注解生效,我们需要配置一个JVM运行时参数:
类级别和字段级别修饰的使用方法如下。
@Contended注解还支持一个contention group属性(针对字段级别),同一个group的多个字段在内存上是连续存储的,并且能和其他字段隔离开来。
上述代码就是把value和value1字段放在了同一个group中,这意味着这两个字段会放在同一个缓存行,并且和其他字段进行缓存行隔离。而value2没有做填充,如果对value2进行更新,则仍然会存在伪共享问题。
3.2.3 CPU缓存一致性问题
CPU高速缓存的设计极大地提升了CPU的运算性能(从FalseSharingExample这个例子就可以看出来),但是它存在一个问题:在CPU中的L1和L2缓存是CPU私有的,如果两个线程同时加载同一块数据并保存到高速缓存中,再分别进行修改,那么如何保证缓存的一致性呢?
如图3-5所示,两个CPU的高速缓存中都缓存了x=20这个值,其中CPU1将x=20修改成了x=40,这个修改只对本地缓存可见,而当CPU0后续对x再进行运算时,它获取的值仍然是20,这就是缓存不一致的问题。
图3-5 CPU缓存一致性问题
3.2.3.1 总线锁和缓存锁机制
为了解决缓存一致性问题,开发者在CPU层面引入了总线锁和缓存锁机制。
在了解锁之前,我们先介绍一下总线。所谓的总线,就是CPU与内存、输入/输出设备传递信息的公共通道(也叫前端总线),当CPU访问内存进行数据交互时,必须经过总线来传输,那么什么是总线锁呢?
简单来说,总线锁就是在总线上声明一个Lock#信号,这个信号能够确保共享内存只有当前CPU可以访问,其他的处理器请求会被阻塞,这就使得同一时刻只有一个处理能够访问共享内存,从而解决了缓存不一致的问题。但是这种做法产生的代价是,CPU的利用率直线下降,很显然这是无法让人接受的,于是从P6系列的处理器开始增加了缓存锁的机制。
缓存锁指的是,如果当前CPU访问的数据已经缓存在其他CPU的高速缓存中,那么CPU不会在总线上声明Lock#信号,而是采用缓存一致性协议来保证多个CPU的缓存一致性。
CPU最终用哪种锁来解决缓存一致性问题,取决于当前CPU是否支持缓存锁,如果不支持,就会采用总线锁。还有一种情况是,当前操作的数据不能被缓存在处理器内部,或者操作的数据跨多个缓存行时,也会使用总线锁。
3.2.3.2 缓存一致性协议
缓存锁通过缓存一致性协议来保证缓存的一致性,不同的CPU类型支持的缓存一致性协议也有区别,比如MSI、MESI、MOSI、MESIF协议等,比较常见的是MESI(Modified Exclusive Shared Or Invalid)协议。
具体来说,MESI协议表示缓存行的四种状态,分别是:
• M(Modify),表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,缓存的数据和主内存中的数据不一致。
• E(Exclusive),表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改。
• S(Shared),表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致。
• 4.I(Invalid),表示缓存已经失效。
这四种状态会基于CPU对缓存行的操作而产生转移,所以MESI协议针对不同的状态添加了不同的监听任务。
• 如果一个缓存行处于M状态,则必须监听所有试图读取该缓存行对应的主内存地址的操作,如果监听到有这类操作的发生,则必须在该操作执行之前把缓存行中的数据写回主内存。
• 如果一个缓存行处于S状态,那么它必须要监听使该缓存行状态设置为Invalid或者对缓存行执行Exclusive操作的请求,如果存在,则必须要把当前缓存行状态设置为Invalid。
• 如果一个缓存行处于E状态,那么它必须要监听其他试图读取该缓存行对应的主内存地址的操作,一旦有这种操作,那么该缓存行需要设置为Shared。
这个监听过程是基于CPU中的Snoopy嗅探协议来完成的,该协议要求每个CPU缓存都可以监听到总线上的数据事件并做出相应的反应,具体的通信原理如图3-6所示,所有CPU都会监听地址总线上的事件,当某个处理器发出请求时,其他CPU会监听到地址总线的请求,根据当前缓存行的状态及监听的请求类型对缓存行状态进行更新。
为了让大家更好地理解MESI协议的工作原理,我们在本书配套源码的concurrent-chapter-3模块的resource目录下放了一个针对MESI状态变更的动画,读者可以下载下来演示。
在基于嗅探协议实现缓存一致性的过程中涉及的消息类型如图3-7所示,CPU根据不同的消息类型进行不同的处理,以实现缓存的一致性。
图3-6 CPU通信原理
图3-7 消息类型
理解了MESI协议的基本原理之后,我们通过一个简图来了解一下MESI协议是如何协助处理器来实现缓存一致性的。
如图3-8所示,当单个CPU从主内存中读取一个数据保存到高速缓存中时,具体的流程是,CPU0发出一条从内存中读取x变量的指令,主内存通过总线返回数据后缓存到CPU0的高速缓存中,并且设置该缓存状态为E。
如图3-9所示,此时如果CPU1同样发出一条针对x的读取指令,那么当CPU0检测到缓存地址冲突时就会针对该消息做出响应,将缓存在CPU0中的x的值通过Read Response消息返回给CPU1,此时x分别存在于CPU0和CPU1的高速缓存中,所以x的状态被设置为S。
图3-8 单个CPU读取内存数据
图3-9 多个CPU同时读取相同的数据
然后,CPU0把x变量的值修改成x=30,把自己的缓存行状态设置为E。接着,把修改后的值写入内存中,此时x的缓存行是共享状态,同时需要发送一个Invalidate消息给其他缓存,CPU1收到该消息后,把高速缓存中的x置为Invalid状态,最终得到如图3-10所示的结构。
图3-10 缓存行修改
3.2.4 总结可见性问题的本质
至此,我们基本上理解了部分可见性问题的本质,CPU高速缓存的设计导致了缓存一致性问题,为了解决这一问题,开发者在CPU层面提供了总线锁和缓存锁的机制。
总线锁和缓存锁通过Lock#信号触发,如果当前CPU支持缓存锁,则不会在总线上声明Lock#信号,而是基于缓存一致性协议来保证缓存的一致性。如果CPU不支持缓存锁,则会在总线上声明Lock#信号锁定总线,从而保证同一时刻只允许一个CPU对共享内存的读写操作。缓存一致性保证如图3-11所示。
图3-11 缓存一致性保证