可见性
可见性是指一个线程对共享变量的修改,其他线程可以立即感知到这个变化
计算机中程序的执行,本质上是线程指令在CPU处理器上的执行,并且在执行必然牵涉到数据的读和写,程序运行过程的临时数据都是存放在主内存(RAM)中,因此CPU在处理数据的时候也必然牵涉到和主内存的交互。处理器访问内存时,需要先获取内存总线的控制权,任何时刻只能有一个处理器获得内存总线的控制权,可以理解为同一时刻某个内存地址只可能被一个处理器访问。
随着硬件技术的不断发展,现在的CPU处理速度已经远远超过主内存的访问速度,如果任何时候对数据的操作都要和内存进行交互,会大大降低指令的执行速度,因此就有了CPU高速缓存:
高速缓存的产生大大减少了CPU直接访问主内存的频率,也减少了内存访问速度对CPU的拖累,提高了CPU的执行效率。如果是单核CPU的操作系统中,只有一个高速缓存,没有任何问题。但是多核CPU的诞生打破了这个规则,处理器对数据的修改在没有做任何措施的情况下,不会及时通知到其他处理器的缓存,这就导致其他处理器的数据是脏数据。
比如i++操作,编译成指令后大概有三步骤:
- 将i=0从主内存复制到
- 对i进行加1运算
- 将运算后的值刷回主内存
假设俩个线程对公共变量i=0执行i++操作,我们期望俩个线程都执行完毕后i的值变为2,由于操作系统配置是多核CPU,俩个线程分别在不同的CPU上并行执行,用时间线流程图模拟执行效果:
CPU如何保证可见性?
为了解决总线锁开销过大问题,CPU提出了缓存一致性解决方案,主要有Directory协议、Snoopy协议、MESI协议。这个说下MESI协议,这个协议只会对高速缓存中的某个数据加锁(如果数据不在缓存中,还是会总线加锁),不会影响到内存中其他数据的读写。MESI协议将数据划分为四种状态,通过总线嗅探机制让所有处理器监听数据状态的变化,达到缓存一致的目的:
我们重点关注一下修改缓存数据的情况,对于E状态的数据,只有自己在读,可以直接把数据设置为M状态,对于S状态的数据,说明有多个处理器在读,必须将其他处理器对此数据的缓存作废,然后才能把数据的状态设置为M。当其他处理器发现自己的缓存是I状态时,就去主内存再次读取,而MESI协议保证其他处理器去主内存读取此数据前,将修改后的数据从高速缓存刷回主内存,并把数据状态改为E。
高并发情况下可能出现俩个处理器同时修改变量,并同时向总线发出将各自的缓存行更改为M状态的情况,此时总线会采取裁决机制进行裁决,将其中一个置为M状态,另一个置为I状态,且I状态的缓存行修改无效。
Java自带的可见性操作:
- volatile关键字(采用MESI协议保证,如果数据不在缓存中就用总线锁)
- synchronized关键字(同一时刻就一个线程操作,还有啥不可见的)
- Lock相关类(跟synchronized一个套路)
原子性
原子性操作是指一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。并且针对某个值的原子操作在被执行的过程中,CPU绝不会再去进行其他的针对该值的操作
java作为一门高级语言,一个可执行线程会被编译成多条指令序列交由CPU执行,既然是多条指令,在执行过程中就存在被上下文切换打断的可能。在多线程编程中如果有的操作不具有原子性,同样会导致运行结果与预期的不一致,比如i++操作:
CPU如何保证原子性?
首先,处理器自动保证单条指令、基本内存操作的原子性,因此中断只会发生在指令之间。在单核CPU中保证操作原子性非常简单,只要禁止CPU在原子操作过程中发生上下文切换,那么就可以保证多线程对某个公共变量的多步骤操作都是串行的。嗯,有点线程安全的味道了。到了多核CPU时代,仅仅保证原子操作的执行不被CPU打断已经没什么卵用了,因为多个线程可以并行执行修改一个公共变量,线程之间又出现了干扰。对此,CPU又提出了CAS来解决这个问题(CAS下一章会讲)。
Java自带的原子性操作:
- 基本数据类型的赋值(long、double无法保证)
- 所有引用类型的赋值
- synchronized关键字(采用jvm的monitor,monitor底层采用CPU的CAS)
- Lock相关类(采用aqs,aqs底层采用CPU的CAS)
- java.concurrent.Atomic包下所有类(采用CPU的CAS)
有序性
有序性是指程序按照写代码的顺序执行
处理器和编译器为了提高程序运行效率,可能会对输入代码进行优化,并且不保证程序中各个语句的执行先后顺序同代码中的顺序一致。当然,CPU和编译器是在遵循as-if-serial语意的前提下对指令重排,而不是随意重排。首先CPU保证调度线程过程中,单线程的执行结果不会受指令重排影响导致结果不一致,编译器保证编译过程中不会对有依赖关系的数据进行指令重排。由此看出多线程情况下还是会有问题:
CPU如何保证有序性?
处理器主要通过内存屏障机制来解决有序性问题,如果不想让它重排,在两条指令中间加一道屏障。拿X86平台来说,有几种主要的内存屏障:
- lfence,是一种Load Barrier(读屏障),在lfence指令前的所有读操作当必须在lfence指令后的所有读操作前完成
- sfence, 是一种Store Barrier(写屏障),在sfence指令前的所有写操作当必须在sfence指令后的所有写操作前完成
- mfence, 是一种General Barrier(通用屏障),在mfence指令前的所有读写操作当必须在mfence指令后的所有读写操作前完成
- 除了内存屏障,也可以使用原子指令,如x86上的”lock…”前缀
Java自带的有序性操作:
- volatile关键字(内存屏障)
- synchronized关键字(单线程操作,as-if-serial语意自动保证)
- Lock相关类(单线程操作,as-if-serial语意自动保证)
总结
原子性、可见性、有序性问题是一切线程安全问题的根源,单纯的保证操作具有某一种特性只能解决某一部分场景问题。Java提供了很多类以及修饰符,提供了不同维度的保证,底层也都是封装CPU提供的措施来实现。