并发编程问题来源
- 并发编程是每个研发绕不过去的一个难点,并发编程为啥如此的复杂,并发编程为什么回产生可见,有序性,原子性,最终的原因在于,计算机硬件的发展,因为计算机硬件发展太快了,自从出现了多核CPU的架构,以及CPU缓存,才导致这一问题的发生。
- 具体问题是我们通过CPU来快速执行计算机指令,为了达到处理极致的目的,我们将多个CPU集成到一台主机中,这样可以每个CPU互相不干扰的情况之下执行指令,这样达到了并发的目的
- 同时,还是为了追求极致的执行速度,每个CPU中都会有自己的一份缓存,这部分缓存用来存储CPU即将要执行的指令需要用到的一些数据,这部分数据会提前将系统内存中数据加载到CPU缓存中
- 那么问题来了,当两个CPU执行的指令都需要操作同一块内存地址中的数据,就会产生并发问题了,我们怎么保证数据的一致性,以哪个CPU执行的结果为最终结果,这就是并发问题产生的问题之一(这里只列举一个用来说明)
CPU缓存
- CPU的运算速度最块,内存的读写速度无法和CPu运算速度匹配。假如定义CPU的一次存储或者访问为一个CPU时钟周期,那么内存的一次运算通常需要几十甚至几百时钟周期。如果在CPU内直接读取内存,那么CPU大部分时间都在等内存的访问,利用率就降低到现在的几十份之一或者几百分之一。为了解决CPU运算速度与内存读写速度不匹配的矛盾,在CPU和内存之间,引入了L1高速缓存,L2高速缓存,L3高速缓存,通过每一级别缓存中所存储的数据,全部都是下一级缓存中的一部分,当CPU直接从缓存中读,提高读写速度,提高CPU利用率,提升整体效率。
- L1高速缓存:也叫一级缓存,一般在内核旁边,一次访问只需要2~4个时钟周期
- L2高速缓存:也叫二级缓存,空间比L1缓存大,速度比L1缓存慢,一次访问10多个时钟周期
- L3高速缓存:也叫三级缓存,部分单CPU多核心的才会有的缓存,介于多核和内存之间,存储空间可达Mb级别,一次访问需要数十个时钟周期。
- 单CPU需要读取一个数据时,首先从L1缓存查找,命中则返回,接着依次L2,L3,还没有的话直接内存,并且将数据逐级加载到缓存。
总线锁和缓存锁
- 操作系统中,我们对volatile关键字编译后,得到的机器语言中会有一个Lock前缀:
- lock前缀,会保证某个处理器对共享内存(一般是缓存行cacheline,后续介绍)的独占使用。他将本处理器缓存失败,达到了“指令重排序无法越过内存屏障”的作用
- 总线锁:就是锁住总线。通过处理器发出Lock指令,总线接受到指令后,其他处理器就会被阻塞,直到此处处理器执行完成。这样,处理器就可以肚占共享内存的使用,但是,总线锁存在较大的缺点,当某个处理器获取总线锁,其他处理器只能阻塞等待,多处理器优势就无法发挥了。
- 经过优化后,又产生了缓存锁:缓存锁就不需要锁总线,只需要被缓存的共享对象(实际就是缓存行)即可,接受到lock指令,通过缓存一致性协议,维护本处理器内部缓存和其他处理器缓存的一致性,相比总线锁,会提告CPU利用率。
缓存行
- 上面提到的,缓存锁,会锁定共享对象,如果仅仅锁定所用的对象,那么有大有小,随取随用,对于CPU来说,如果每次需要用多个对象,那就会许下一多次加锁释放锁,这样利用率不能最大化。所以采用一次获取整块内存的数据,放入缓存,那么这一块数据,通常称为缓存行(cache line)。缓存行是CPU缓存中可分配,操作的,最小存储单元。与CPU架构有关,有32,64,128字节不等。目前64位架构下64字节最常用
缓存一致性协议
- 这个之后文章单独写吧,此处只要知道,这个机制可以做到数据一致性,每个处理器的缓存数据和主内存区域
volatile
- 以上我们主要介绍CPU的缓存,为现在的说明做铺垫
- Volatile关键字是java虚拟机提供的最轻量级的同步机制,在多线程编程中,volatile和synchronized都起着重要作用。
- 接下来主要说明volatile的作用,volatile底层原理,以及他和CPU中缓存设计的相关性,可以更好的理解volatile底层实现
volatile的作用
-
并发编程三大特性:原子性,可见性,有序性
- 原子性:一个操作或者多个操作集合,要么全部执行 成功/失败。满足原子性操作,中途不可被中断
- 可见性:多线程共同访问共享变量,某个线程修改此变量,其他线程立即能看到修改后的值。
- 有序性:程序执行顺序按照代码先后顺序执行。(由于JMM模型允许编译器和处理器为了效率进行指令重新排序的优化。指令重排序再单线程内表现为串行语义,在多线程中会表现出无序。那么多线程并发编程中,就要考虑如何在多线程环境下下可以允许部分指令重排序)
-
synchronized关键字可以同时保证上述三种特性
- synchronized 是同步锁,同步块内的代码相当于同一时刻单线程执行,不存在原子性和指令冲排序的问题
- synchronized,关键字的语JMM有两个规定,保证其实现内存可见性
- 线程解锁前,必须将共享变量的最新值刷新到主内存中
- 线程加锁前,将清空工作内存中共享变量的值,从主内存中重新取值
-
volatile 关键字的作用是保证可见性和有序性,并不保证原子性
volatile变量的可见性
-
javau 你急规范中定义了一种java内存模型(JMM,Java Memory Model)用来屏蔽各种硬件和操作系统的内存访问差异,以此实现java程序在各种平台下都能达到一致的并发效果。java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的细节。
-
JMM中规定所有变量都存储在主内存(Mail Memory)中。每条线程都有自己的工作内存(work Memory),此处说明的主内存,即上文中的操作系统的内存,每条线程的工作内存,就是我们CPU的高速缓存,从上文对CPU缓存的说明可以看出,线程对变量读,写在工作内存中进行,同时,本线程工作内存的变量无法改变其他线程工作内存,必须通过主内存完成信息交换
-
内存模型如下:
-
如上图中,线程A修改变量后,只会在此线程工作内存中体现。在为同步到主内存前,如果B也改变了此变量,从主内存中获取到的是修改之前的值,此时就发生了共享变量值不一致,也就是线程可见性问题。
-
volatile定义:
- 当对volatile变量执行写操作,JMM会吧工作内存中最新变量值强制刷新到主内存
- 写操作会导致其他线程中的缓存无效。
-
这样其他线程使用缓存时候,发现本地工作内存中此变量无效,便从主内存中获取,这样获取到的变量便是最新的值,实现了线程的可见性
volatile 有序性保证
-
volatile通过编译器在生成字节码时候,在指令序列中添加“内存屏障”来禁止指令重排序。
-
硬件方面的“内存屏障”:
- sfence:写内存屏障(Store Barrier),在写指令之后插入写内存屏障,能让写入缓存的最新数据写回到主内存,以保证写入数据立刻对其他线程可见。
- ifence:读屏障(Load Barrier)在读指令之前插入读屏障,可以让高速缓存中数据失效,重新从主内存加载数据,以保证读取到是最新的数据
- mfence:全能屏障(modify/mix Barrier),兼有sfence和ifence
- lock前缀:lock不是内存屏障,是一种锁,执行时候会锁住主内存子系统来保证顺序执行,甚至跨越多个CPU
-
JMM层面的“内存屏障”:
指令类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | load1;loadload;load2 | 保证Load1要读取的数据,在load2以及之后的读取操作中要读取的数据被访问前被读取完毕 |
StoreStore Barriers | Load1;LoadStore;Store2 | 在Store2及后续写入操作执行前,保证Store1的写入操作对其他处理器可见。 |
LoadStore Barriers | Load1;LoadStore;Store2 | 确保Load1数据的读取操作 先于Load2以及后续写入操作刷新到内存 |
StoreLoad Barriers | Store1;StoreLoad;Load2 | 确保Store1修改的数据对其他处理器的可见性(只刷新到内存)并且这个操作先于Load2以及所有后续装载指令之前完成 |
- volatile关键字的内存屏障
- 为了实现volatile的内存语意,编译器在生产字节码时候,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优解的布置来最小化插入屏障的数量几乎不可能,所以JVM采用保守策略,如下:
- 在每个volatile写操作的前面加入一个StoreStore Barrier
- 在每个volatile 写操作后面加入一个StoreLoad Barrier
- 在每个volatile读彩妆后面插入一个LoadLoad Barrier
- 在每个volatile读操作后面插入一个LoadStore Barrier
volatile 底层实现
专业术语总结
术语 | 英文 | 描述 |
---|---|---|
内存屏障 | memory barriers | 一组计算机处理器指令,用于实现对内存操作的顺序限制 |
缓存行 | cache line | 缓存中可用来分配的最小存储单元。处理器填充缓存时候会加载整个缓存行,西药使用多个主内存读周期 |
原子操作 | atomic operations | 不可中断的一个或者一系列的操作 |
缓存行填充 | cache line fill | 当处理器识别到从内存中读取操作是可缓存的,处理器读取整个缓存行到适当的高速缓存中(L1,L2,L3) |
缓存命中 | cache hit | 如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器会从缓存中读取操作而不是从内存中读取 |
写命中 | write hit | 当处理器将操作写回到一个内存缓存的区域(即高速缓存)中时候,他首先回检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数据写回到缓存(高速缓存),而不是写回到内存,这个操作被称为写命中 |
写缺失 | write misses the cache | 一个有效的缓存行被写入到不存在的内存区域 |
volatile 实现原理
instance = new Singletion();
0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);
- 以X86处理器下说明,用工具的到java代码编译后的汇编指令来查看在对volatile写操作时候(下一篇说明工具应用,待更新),CPU会怎么处理, 有volatile 修饰的变量在进行写操作的时候,会多出一行汇编代码,就是Lock前缀指令,在多核处理器下会引起两件事情:
- 将当前处理器缓存行的数据写回到系统内存
- 这个写回内存的操作会使在其他CPU里缓存了改地址的数据无效(CPU缓存一致性协议)
- 实现:volatile变量在写操作时候,JVM会向处理器发送一条Lock前缀指令,将这个变量所在的缓存行数据写回到系统内存,然后通过缓存一致性协议通知其他处理器。
volatile两条实现原则
- Lock前缀指令会引起处理器写回到内存,获取到Lock#信号的处理器可以独占任何共享内存。但是他不锁总线,而是锁缓存,并且写回到内存,接着利用CPU缓存一致性协议确保修改原子性。
- 一个处理器的缓存写回到内存会导致其他处理器的缓存无效:也就是缓存一致性协议,例如MESI(修改,独占,共享,无效)控制协议去维护内部缓存和其他处理器缓存的一致性