简单说说可见性和volatile

时间:2021-08-31 11:54:15

以下由写在书上的笔记整理出来的,前一篇文章就不再更新了(懒)

以可见性的讨论开始

可见性和硬件的关联

计算机为了高速访问资源,对内存进行了一定的缓存,但缓存不一定能在各线程(处理器)之间相互通信,因此在多线程上需要额外注意硬件带来的可见性问题(可能会读到脏数据),注意这里只讨论共享变量下的情况

可能导致的问题

处理器不直接与主内存执行读写操作(慢),而是通过寄存器/写缓冲器/高速缓存/无效化队列等部件执行,解决一个问题的同时会产生更多的问题,因此多线程下会导致以下问题

1.不可访问:线程所共享的变量分配到寄存器中

2.不可同步:县城所共享变量只更新到写缓冲器中,未到达高速缓冲

3.可同步,但需通过缓存一致性协议:总算写入到高速缓存中,但其他处理器把该更新通知的内容存入无效化队列(x86并没有该部件)

缓存一致性协议

先说疗效:对于某个处理器,通过该协议,该协议可读取其他处理器的高速缓存

我们称一个处理器从自身缓存以外的其它存储部件读取的数据并更新到该处理器的高速缓存成为缓存同步

因此缓存同步能使一个线程(处理器)读取到其它线程(处理器)的共享变量,也就保障了可见性

缓存一致性协议就是一种缓存同步的方法

缓存一致性协议做到的事情:

1.冲刷缓存:处理器的更新最终写入到(该处理器)的高速缓存或主内存

2.刷新缓存:处理器读取时必须从其他高速缓存/主内存对应的变量进行缓存同步

更为具体的内部实现Chapter.11有提到

Java上的体现

volatile的作用之一便是写进行冲刷缓存,读进行冲刷缓存,以达到保证线程可见性的目的

(另外的作用是提示JIT不要乱优化,这是有序性上的问题,一般指令重排序由JIT引起)

轮到volatile

可见性的程度

前面提到volatile是调用缓存一致性在Java中的体现,保障了可见性,但可见到什么程度?我们需要注意这个问题

给出答案:我们能保障的可见性仅是读取到共享变量的相对新值,而并非最新值

相对新值:线程更新值后,其他线程能读取到更新后的值

最新值:读取变量的线程在读取时其他线程无法对该值更新

举例

我对书上P53的例子做出一定修改来说明上面的问题

假设a为volatile int型共享变量,初值为0,先开启两个线程

处理器0 处理器1
时刻0 null(a此时为0) null(a此时为0)
时刻1 a=1 无关操作
时刻2 无关操作 b=a+1

从时刻2来看,处理器1能看到此时a必然为1,因为时刻1之后其他处理器均能看到更新后的值,因此b=2

处理器0 处理器1
时刻0 null(a此时为0) null(a此时为0)
时刻1 a=1 无关操作
时刻2 a=2 b=a+1

但如果是时刻2中处理器1在读取a的同时,处理器0也在更新,那么此时a便无法由可见性确认是1还是2,因此b无法确定

题外话:Java还规定了子线程对创建前的父线程更新的可见性,因此时刻1的读操作前无论是否有volatile都可得知a=0

题外话2:如果a仅为int型,我们只能确保其中的原子性,在表1的时刻2的处理器1看来a可能是0或者1

题外话3:如果a为long型,我们甚至无法确保原子性,在大数值时可能会产生一个不存在的数(区分高低32位)

解决重排序

volatile解决重排序除了软件顶层提醒JIT的优化以外,还会对读写操作设置不同的内存屏障禁止存储子系统重排序,有待更新

volatile的性能

从性能层面来说,volatile暴打内部锁是没问题的,原因如下

1.没有上下文切换的开销

2.没有锁的申请

但和普通变量相比,它依然有所不足:

1.读写会冲刷/刷新缓存

2.变量肯定不会暂存于寄存器,最多也就在L1

因此对于极为频繁的读操作,还是要打折扣的

(至于量化测试,待我学有所成再说吧)

作用总结

1.可见性,我已经(尽我所能)说明了

2.有序性,由于Java中其它单独控制有序性的工具没有别的(final我会后续补充)

3.极为有限的原子性,volatile在规定上保证longdouble的读写原子性,以及任意操作只与自身相关的原子性

volatile的读和写

读:作为读的使用,我们是可以放心的,因为它注定只涉及自身相关,前面提到了,原子性也是可以保证的

写:写仅当不涉及共享变量时才确保原子性。具体以volatile a为例:

1.首先多个线程写入不共享也会保证原子性,比如a=3,因为最后一步必成功(必保证单一写的原子性,而3可认为是immutable)

2.即使只涉及自身的运算也不一定线程安全,因为a自身便是共享变量,volatile并不保证赋值(涉及到读共享变量和写共享变量)一定具有原子性,比如a++便是线程不安全的

针对写的不足,可以采用如下方法

1.部分加锁,可利用对读直接返回,对写加锁进行处理,比如读写锁的实现、单例模式的实现

2.CAS解决a++问题,而这便是Atomic类的实现思路

volatile的使用场合

1.作为某个通知变量,只读并输出

2.部分代替锁,对于创建新的对象,如volatile Map map = new HashMap(),该操作会分为3个步骤,分配HashMap.class所需的空间、初始化引用对象、将对象引用写入变量map。注意到前面两个步骤依然是只涉及局部变量,而最后的写操作也必然保证原子性,因此该赋值是原子性的。倘若设计一个类封装许多变量,读是并发的,但写仿照该方式来赋值,那也无需任何加锁

3.作为锁的部分优化,比如前面提到的读写锁,对读并发,对写独占,虽然不足是有的,但还是比锁厉害

final的多线程用法

在语义上,volatilefinal是不可共存的,因此final在设计上也需要线程安全的某种保障,令人惊异的是它具有有序性却没有可见性和原子性,这种设计和安全发布有一定关联

安全发布

之前曾经遇到private的逸出操作,深感自己菜到不行,因为这是常见的getter暴露对象的做法,但本篇重点不在这里,相关的内容放到以后总结

这里要提的是初始化安全问题,new的操作涉及三个步骤,1分配空间2初始化3引用写入,但重排序会导致2和3的步骤不一致,可能发布后对象内某个变量依然没有初始化完毕(或者不可见)

final就保证了引用写入前变量必然初始化完毕,这里需要注意的是,final不保证可见性但必须保证有序性,个人认为原因如下:

1.对于单一线程来说,有序性是无需讨论的,而多线程意义上,判断一个对象是否初始化想必是使用obj == null来判断,即使它不可见,但也不影响当其它线程可见时该final对象必然初始化完毕这个结论,更何况final的意义就在于发布,因此是否可见已经无所谓了

2.如果不保证有序性,其它线程无从判断是否完全初始化完毕,比如上面的例子,虽然只是部分初始化,但它确实不是null,线程安全无法保证

3.那能不能只保证可见但不保证有序,我认为不能,起码Java没这操作

题外话,static能保证读线程必然读到初始值,也算是一种有限的可见性,该初始化可能导致上下文切换

CAS

CAS可认为是硬件锁,能实现C和S的原子性(由硬件大哥保证),一般搭配volatile使用

个人感悟是其特点是不阻塞,通常用于单一可变变量的判断,比如如何在多线程下仅多开启一个线程?你可以用AtomicBooleantruefalse的CAS来轻松实现(原子性保证只有一个线程成功,其余线程均告失败但绝不阻塞),注意这个操作仅靠bool是无法完成的,因为原子(判)+原子(写)≠原子

喜闻乐见的ABA可用类似时间戳的方法解决(其中ABA在数据结构上引发的问题挺有意思的,随便搜搜就有),MySQL也有类似操作(乐观锁),不写了bye