java并发编程实践 2.2 原子性

缘起

《java并发编程实践》 2.2 小节

分析

【1】中我们说到我们编写的servlet一般都要无状态性(事实上,servlet规范也是这样推荐的),但是如果我们非要在servlet中添加状态呢? 即添加属性(或者叫成员变量)呢? 则该状态(成员变量)就不是线程安全的. 除非为该状态进行同步机制的处理. 处理的方式有

  1. 加java内置的同步锁
  2. 使用原子类(本质上是CAS乐观锁,亦即无锁)

那么问题来了,为什么就是线程不安全的呢? 其实所有的线程不安全都来自于”检查得到可能过期的状态,然后依据这种可能过期的状态进行处理”.

例如一个servlet中引入一个count成员属性,然后每次调用count++, 但是自增操作本质不是原子操作. 自增操作其实是read->increment->write 三合一的操作. 所以一根线程A调用count这个状态的自增操作的时候其实是依据自己一开始得到的count的值,但是殊不知,A在read之后进行后续两步操作的时候,可能线程B已经修改掉了count的值(JMM并没有对这种有竞争的代码有明确的语义规范的).则导致错误的结果.

又如下面错误的单例写法

1
2
3
4
5
6
7
8
9
10
private MyInstance instance;

private MyInstance(){}

public MySingleton getInstance() {
if(instance == null) {
instance = new MyInstance();
}
return instance;
}

之所以错误,是因为如果一根线程A进来看到instance是null, 则准备第七行代码的时候,其实这个间隙可能另一根线程已经new 好了一个实例(其实new一个实例是在堆上分配内存,这个new也不是原子操作,也是多步操作). 则内存中就会至少存在2个实例. 有人可能觉得没什么,但是如果这个单例其实是注册信息呢? 则可能会导致看到的注册信息的视图不一致的问题,这就很严重了,前面那个count的例子,如果count仅仅是web站点访问人数的统计那其实无伤大雅——多一个少一个人有什么关系,反正就是一个大概人数,但是如果这个count是订单id号呢? 等着倒闭吧~

正因为这里的事故,我们提出了原子性的概念来克服上面提到的”根据检查得到的可能过期的结果做出进一步的操作是不可靠的”.

所谓原子性指的是

假设有操作A和B,如果从执行A的线程角度看,当其他线程执行B时,要么B全部执行完毕,要么一点都没有执行,那么B相对A就是原子操作

而一个原子操作的定义是,如果该操作对于任何操作(包括自己)都是原子操作,则称该操作是原子操作.

将一些复合操作(例如前面的自增操作或者new一个实例的操作)实现原子操作可以使用java内置的原子性机制——锁(其实本质还是使用了操作系统提供的同步相关的系统调用),或者使用CAS原子操作(即原子类,java的atomic包中的类)

最后提及一个问题: 给servlet中添加一个线程安全的属性和添加两个线程安全的属性(特别是这两个线程安全的属性之间需要维持某种数据完整性的时候)是没有从1变到2那么简单的. 因为添加2个线程安全的属性的时候,需要将任何对其中任何一个属性的操作都要同步起来. 举个简单的例子——2个成员属性分别是”待分解质因数的整数”和”分解得到的质数因子”,那么这两个成员属性是有某种数据完整性的——质数因子的乘积必须等于整数.

如果只对整数的set方法和质因数的set方法进行同步处理,那么这种数据的完整性很可能在多线程环境下被破坏. 必须要这两个的操作作为一个整体是同步的——一根线程set整数的时候,别的线程不仅不能set整数,也不能set质因数.

参考

【1】https://yfsyfs.github.io/2019/06/22/java%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B%E5%AE%9E%E8%B7%B5-2-1-%E4%BB%80%E4%B9%88%E6%98%AF%E7%BA%BF%E7%A8%8B%E5%AE%89%E5%85%A8%E6%80%A7/