思考、再思考、总结、再总结
01 可见性、原子性和有序性
举几个例子先。
- 缓存可能导致可见性问题,因为多核CPU上的多个核可能都持有同一数据的不同缓存。两个线程并行地对一个字段进行累加,结果介于一倍与两倍之间。关键字volatile就是为了这样的需求,它提示底层去掉缓存这样的优化,从而使得任何写入,对于之后的读取都是可见的。
- 线程切换带来原子性问题,因为切换去切换来的中间可能会修改本线程也关注的数据,好比git维护着的多个版本之间merge的时候会因为改动了同一个数据而报冲突,以前或许是改同一个文件就冲突,现在更聪明了,该同一行才会冲突。
- 再举一个原子性(也是有序性)的问题 —— 一行命令可能对应于多条底层命令,比如,原本C语言的内存申请和初始化是分开的,但面向对象模型引入后,new一个对象不但申请了对象需要的内存空间大小,还执行了对象的初始化,不了解这个过程可能误觉这是原子的,但其实,可能在这中间有其他线程访问了这个对象,发现它是非空的,并访问了这个未初始化的对象。而这个对象可以被其他线程访问,是由于可能存在指令重排,导致对象在初始化之前被返回;而另一个线程在临界区之外判空并返回该未初始化对象,这就导致本来想提高效率的双重判空检查反而使得临界区失效了,自己这边在临界区内还在准备要准备,另一个线程已经拿到了这个对象。
02 内存模型
- 单线程内的命令是顺序性的和传递性的,如果再引入volatile,那么会使得volatile变量赋值紧挨着的之前的非volatile变量赋值也具有volatile的属性。
- 类似地,线程的start能看到其调用点之前到结果,join的结果则为调用点之后可见。
03 加锁与临界区
- 加锁是一个很好的解决并发冲突的办法。但是,加锁的范围大了,影响效率;小了,临界区就失效了,就像双重判空检查那样的。
- 通常,用一把锁来锁相关联的多个资源(在面向对象编程语言中,资源就是对象,尤其是作为字段的被管理的对象);但如果多个资源并非一定要关联在一起,那么,为了提高效率,可以给每一个资源加一个各自的锁。就像数据库的表级锁和行级锁。但是,在采取后者的情况下,又得对偶尔的关联情况做预防,好比,一个系统为每一个用户建立了各自的账本,如果没有转账行为,或者没有并发的转账行为,那么是相安无事的;否则,情况就是可能死锁。
04 死锁
死锁的条件:
- 占用且等待:细粒度地为每个资源都提供一个锁就会导致该问题,破坏并发的一个方式就是重新回到一把锁来管理多个资源的情况,唯一的问题是将两个已经分开的锁合并为一个,就需要轮询直到两个资源都获取到手,合并的粒度可以是原来那个全局唯一的锁;但是,也可以让多个局部资源由一个局部性的单例来管理,局部性越强,效率也就越好,当然,跨域资源的管理依然得需要更广级别的锁。
- 不可抢占:Java语言层面的锁synchronized就是不可抢占的
- 循环等待:可以规定,按照资源的序号,只能用一种顺序来加锁,这样,等待就变成了抢锁。因为有序的多资源就是一个资源,在我们其实不关心获取资源的先后顺序的情况下,它实现了最恰当好处的粒度;当然,我们也需要在排序消耗与轮询等待消耗之间做选择。