可见性

可见性是指一个线程对共享变量的修改,其他线程可以立即感知到这个变化

计算机中程序的执行,本质上是线程指令在CPU处理器上的执行,并且在执行必然牵涉到数据的读和写,程序运行过程的临时数据都是存放在主内存(RAM)中,因此CPU在处理数据的时候也必然牵涉到和主内存的交互。处理器访问内存时,需要先获取内存总线的控制权,任何时刻只能有一个处理器获得内存总线的控制权,可以理解为同一时刻某个内存地址只可能被一个处理器访问。

随着硬件技术的不断发展,现在的CPU处理速度已经远远超过主内存的访问速度,如果任何时候对数据的操作都要和内存进行交互,会大大降低指令的执行速度,因此就有了CPU高速缓存:
图片

高速缓存的产生大大减少了CPU直接访问主内存的频率,也减少了内存访问速度对CPU的拖累,提高了CPU的执行效率。如果是单核CPU的操作系统中,只有一个高速缓存,没有任何问题。但是多核CPU的诞生打破了这个规则,处理器对数据的修改在没有做任何措施的情况下,不会及时通知到其他处理器的缓存,这就导致其他处理器的数据是脏数据。

比如i++操作,编译成指令后大概有三步骤:

  • 将i=0从主内存复制到
  • 对i进行加1运算
  • 将运算后的值刷回主内存

假设俩个线程对公共变量i=0执行i++操作,我们期望俩个线程都执行完毕后i的值变为2,由于操作系统配置是多核CPU,俩个线程分别在不同的CPU上并行执行,用时间线流程图模拟执行效果:
图片

图中可以看出CPU-1计算完毕后还没来得及将数据i刷回主内存,另一个CPU就去主内存获取i值并且到的是脏数据,这导致i最终值并不是我们期望的结果。对此问题,早期的解决方案是总线加锁,一个处理器在总线上输出LOCK#信号,使得其他处理器对内存的操作请求都会被阻塞,该处理器独占共享内存。方法简单粗暴,就是锁定范围太大(整个共享内存),导致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++操作:
图片

同样的i++操作,可见性与原子性对线程安全强调的角度却不一样,可见性强调线程在修改完数据后未及时从缓存刷新到主内存,导致另一个线程获取脏数据,继而影响到最终计算结果,而原子性强调非原子操作在执行过程中被上下文打断,在未切回期间另一个线程对数据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和Java(内存读写)的原子性与数据库的原子性还是有区别的,CPU和Java(内存读写)执行原子操作过程中发生中断的唯一可能就是断电,这时候所有内存数据全部消失,也就没讨论的意义了。而数据库的增删改操作涉及的都是持久化的磁盘数据,就算执行过程发生断电,持久化的数据仍然存在,因此数据库的原子操作增加了事务回滚概念,只要事务没提交,就相当于没执行。无论CPU还是Java还是数据库,都是强调整体的成败,不允许仅执行部分操作的存在。

有序性

有序性是指程序按照写代码的顺序执行

处理器和编译器为了提高程序运行效率,可能会对输入代码进行优化,并且不保证程序中各个语句的执行先后顺序同代码中的顺序一致。当然,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提供的措施来实现。

评论