Java并发编程:线程安全性的原子性--基础知识 | 八月更文挑战

384 阅读8分钟

这是我参与8月更文挑战的第24天,活动详情查看:8月更文挑战

📖前言

来自我对一位我所崇拜的大佬文章的评论:

我:“喝罢黄河之水天上来,酒醒杨柳残月且偷欢,唱罢笑傲江湖祭沧海,雁渡寒潭有几只回还”

大佬:“年少正恰,纵码飞骋,略江山华月,有几人随傅虎踞龙盘。”

上一篇我们讲了:Java并发编程:什么是线程安全性--基础知识


🚀进入正题--原子性

当我们在无状态对象中增加一个状态时,会出现什么状况呢?假设希望增加一个 “命中计数器”(Hit Conunter) 来统计所处理的请求数量。最直接的方式就是加一个 long 类型的域值,每次处理一个请求就把这个值加。

@NotThreadSafe
public class UnsafeConutingFactorizer implements Servlet {
    private long count = 0;
    
    public long getConut() {
        return count;
    }
    
    public void service(ServletRequest req,ServletResponse resp) {
        BigInteger i = extractFromRequest(req); 
        BigInteger[] factors = factor(i);
        ++count;
        encodeIntoResponse(resp, factors);
    }
    
}

但是这样做却不是线程安全的,虽然可以在单线程条件下正常运行。但是结果会和上一章所讲的一样,这个类有很大可能会丢失一些更新操作。虽然说递增 ++X 是一种紧凑的语法,但是这个操作并非原子,他并不会为一个不可分割的操作来执行。

它包含了三个独立的操作:

  • 读取 count 的值 -- 读取
  • 将值加1 -- 修改
  • 将计算结果写入 count -- 写入
  • 并且将结果状态依赖于之前的状态

有的人会认为在基于 web 的服务下,命中计数器值的少量偏差或许是可以接受的,在一些情况下确实,但是考虑下下面这个情况当你用计数器去生成数值序列或者唯一的对象标识符,多次调用中返回相同的值将会导致严重的 数据完整性的问题吧。在并发编程中这种不恰当的执行时序而出现不正确的结果是非常非常重要的一种情况,他有一个正式的名字: 竞态条件(Race Condition)


竞态条件

在刚才的那一小段代码里存在多个 竞态条件,使得结果变得不可靠。当某个计算的正确性取决于多个线程的交替执行时序下的时候,就会发生 竞态条件

  • 也就是说正确的结果取决于运气

最常见的 竞态类型 就是 先检查后执行(Check-Then-Act) 操作了,也就是通过一个可能实效的观测结果来决定下一步的动作。

来说个小故事吧

比如我们公司楼下有星巴克但是有两家,原定计划的是中午12点和朋友见面简称为 B,但是并不知道 B 说的是哪一家。我们先去了星巴克A没见到朋友,那么肯定回去星巴克B看一下他在不在吧,但也不在那么只有几种可能:

  • 你的朋友 B 迟到了没到任何一家星巴克
  • 你的朋友 B 在你离开星巴克A后到了这里,
  • 你的朋友 B 在星巴克B跑去了A找你,你刚好在去星巴克A的路上
  • 如果你们两个都去过了星巴克 A 和 B 是否会怀疑对方失约了呢!

PS:如果你们之间定了某种协议是否还会这样呢哈哈哈哈哈哈哈哈哈

比如在这里面“我们去看看他是不是在另一家”这种方法,问题在于:当你走在街上,你的朋友可能离开了你要去的星巴克。你先看了看星巴克A,他不在,你就立马去找他。在星巴克B也可以做同样的抉择吧哈哈,但不是同时发生。两家星巴克怎么都得走个几分钟吧,这几分钟时间里系统的状态随时可能会发生变化。

在这个故事里说明了一种竞态条件,因为要获得正确的结果,必须取决于事件的发生时序。当你迈出前门时,你在星巴克A的观察结果就无效了,你朋友可能从后门进来你又不知道。这种观察结果的失效就是大多数静态条件的本质——基于一种可能是失效观察结果来判断或者执行某个计算。

  • 这种类型的竞态条件成为“先检查后执行”:
    • 首先观察到某个文件为真(假如文件X不存在)
    • 然后根据这个结果采用相应的动作(创建文件X)
    • 事实在你观察到这个结果以及在创建文件之间,观察的结果可能变得无效(另一个线程在这期间创建了文件X)
    • 则导致各种问题(未预期的异常、数据被覆盖、文件被破坏等等)

例子:延迟初始化中的竞态条件

“先检查后执行”的一种常见情况就是延迟初始化。延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,同事要确保只被初始化一次。

@NotThreadSafe
public class LazyInitRace {

    private ExpensiveObject instance = null;
    
    public ExpensiveObject getInstance() {
        if (instance == null)
            instance = new ExpensiveObject();
        return count;
    }
    
}

getInstance 方法首先判断 ExpensiveObject 是否已经被初始化,如果已经初始化则返回现有实例(最简单的单例模式不就这样么)反之就创建一个实例,并返回一个引用,后面再来调用就不用再执行这个高开销的代码了。

LazyInitRace 里的竞态条件,他可能会破坏这个类的正确性,多线程情况下 AB 一起执行,看到了值为空,就创建一个新的 ExpensiveObject 实例。B也一样判断。

决定 instance 是否为空取决于:

  • 不可测的时序
  • 线程的调度方式
  • A需要多久来初始化 ExpensiveObject 并设置 instance

在我们的 UnsafeConutingFactorizer 中存在另一个竞态条件。在“读取 - 修改 - 写入”这种操作下,基于对象之前的状态来定义对象状态的转换。要一个计数器,你需要知道他之前的值,并确保在执行更新的过程中没有其他线程会修改或者使用这个值。

总结:

与多数的并发错误一样,竞态条件并不总是会产生错误,还需要不恰当的执行时序。但是竞态条件也可能会导致严重的问题。假设 LazyInitRace 被用于初始化应用程序范围内的注册表,若在多次调用中返回的实例不一样,那么是不是就造成了注册信息的丢失,要么为多个行为对同一组注册对象表现出不一致的视图。若用于在某个持久化框架中声称对象标识,那么两个不同的对象最终将获得相同的标识,则违反了我们第一篇说的 完整性约束条件


复合操作

哈哈有没有失恋的兄弟看到这个标题有点激动哈哈哈哈哈哈~

UnsafeConutingFactorizerLazyInitRace 都包含了一组需要以原子方式执行(不可分割)的操作。要避免竞态条件就需要在某个线程修变量前,以某种方式防止其他线程使用这个变量,保证其他线程只能在修改完成前或者之后读取和修改状态,不能再修改状态的过程中(保证时序)。

假设说有两个操作 A 和 B,如果从执行A的线程看当另一个线程执行B时,要么 B 执行完,要么不执行 B,那么 A 和 B 对彼此就是原子的。原子操作是说:对于访问同一个状态的所有操作(包括了操作本身)来说,这个操作时一个以源自方式执行的操作。

若我们之前说到的递增操作是原子操作,则竞态条件就不会发生,而且递增操作每次执行都会把计数器+1。为确保线程安全性,“先检查后执行”(延迟初始化)和“读取 - 修改 - 写入”(递增运算)等操作必须是原子的。“先检查后执行”(延迟初始化)和“读取 - 修改 - 写入”(递增运算)等操作也称为复合操作:

  • 包含一组必须以原子方式执行的操作以确保线程安全性。
@NotThreadSafe
public class CountingFactorizer implements Servlet {
    private final AtomicLong count = new AtomicLong(0);
    
    public long getConut() {
        return count.get();
    }
    
    public void service(ServletRequest req,ServletResponse resp) {
        BigInteger i = extractFromRequest(req); 
        BigInteger[] factors = factor(i);
        count.incrementAndGet();
        encodeIntoResponse(resp, factors);
    }
    
}

使用一个线程安全类解决,也可以使用加锁机制下节讲解。

通过 AtomicLong 来代替 Long 类型的计数器,可以确保所有计数器状态访问均为原子的。线程也是安全的,当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象管理,这个类仍然是线程安全的。


🎉最后

  • 在实际情况中,要尽可能使用现有的线程安全对象来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转换情况会很简单,也更容易维护和验证线程安全性。

  • 下一篇我们讲--Java并发编程:线程安全性的加锁机制--基础知识

  • 更多参考精彩博文请看这里:《陈永佳的博客》

  • 喜欢博主的小伙伴可以加个关注、点个赞哦,持续更新嘿嘿!