• 那是从何处传来的钟声呢?偶尔听到那钟声,平添一份喜悦与向往之情。

并发基础知识(四)

后端 Nanait 7年前 (2018-04-16) 1136次浏览 已收录 0个评论 扫描二维码
“浅谈线程安全性(一)”

前言

前面我们简单的了解了一下 java 内存模型和重排序以及相关一些规则,同时通过重排序告诉了我们一个道理: “如果错误的假设程序中的操作将按照某种特定的顺序来执行,那么会存在各种可能的危险。”

这篇开始我们借此来继续讨论下线程安全性问题。


正文

首先我们来思考一个问题。

什么是线程安全性?

  • 事实上要对线程安全性给出一个确切的定义是非常复杂的。在线程安全性的定义中,最核心的概念就是正确性。正确性的含义是:某个类的行为与其规范一致。
    在良好的规范中通常会定义各种不变性条件来约束对象的状态,以及定义各种后验条件(针对方法,规定了方法顺利执行完毕之后必须为真的条件)来描述对象操作的结果。
  • 当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类为线程安全的。
    ps:如果对上面的文字理解起来有些不友好时,你可以把线程安全性理解成:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。或者你也可以理解为,线程安全类是一个在并发环境和单线程环境中都不会被破坏的类。
  • 如果某个类在单线程环境中都不是正确的,那么它肯定不是线程安全的。
    无状态的对象(没有实例变量不能保存数据的对象)一定是线程安全的,因为不管怎么访问它始终都是不变的。

谈到线程安全性首先我们得了解几个相关的概念。

原子性

还是老规矩,从代码先讲起吧。首先我们来看个简单的例子吧:

public class AnUnsafeCounter {
    private long count = 0;

    public long getCount() {
        return count++;
    }
}

相信上面的代码大家都能看懂,尽管它在单线环境中能够正确的运行。但是在多线程环境下,这个类很可能会丢失一些更新操作。虽然递增操作 count++ 是一种紧凑语法,让人看上去觉得这只是一个单一的操作。但是实际上这个操作包含了三个独立的操作:首先读取 count 的值,将值加 1,然后将计算写入 count。它是一个 “读取 ─ 修改 ─ 写入” 的操作序列,并且其结果状态依赖于之前的状态。所以说这个操作是一个非原子的,因为它不会作为一个不可分割的操作来执行。

上面例子给了出两个线程在没有同步的情况下同时对一个计数器执行递增操作时发生的情况。如果计数器的初始值为 9,那么在某些情况下,可能每个线程读到的值都为 9,接着执行递增操作,并且都将计数器的值设为 10。如果其中有一次递增操作丢失了,命中计算器的值就将偏差 1。

并发编程中,这 z 种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正式的名字:竞态条件。

竞态条件

当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件(比如上面那个例子)。

一旦程序中发生了竞态条件时,那么正确的执行结果就要取决与运气。最常见的竞态条件类型就是先检查后执行的操作,因为这种操作是通过一个很可能失效的观测结果来决定下一步动作的。

举个例子:
假如你计划星期天中午 12 点约朋友在某个地方的星巴克见面。但是当你到达那里时,发现那个地方有两家星巴克,并且你们事先没有说好在哪家。当时钟悄悄转到 12 时,你在星巴克 A 没有发现朋友,这时候你心里可能会想兴许朋友会是在星巴克 B。但是但你到达星巴克 B 的时候并没有发现朋友。这时候就存在几种可能:比如你的朋友迟到了,还没有到仍和一家星巴克。或者你的朋友在你离开后到了星巴克 A。亦或者你的朋友在星巴克 B,但是他去星巴克 A 找你,并且此时正在去星巴克 A 的途中。

我们假设最糟糕的情况,即最后一种可能。现在是 12.30,你们两个都去过了两家星巴克,并且都开始怀疑对方是否失约了。现在你会怎么做?回到另一家星巴克?来来回回要走多少次?除非你们之间约定了某种协议,否则你们可能整天都在路上走走去去。

在这里我们做个假设,假设“我去看看他是否在另一家星巴克”在程序里是一个方法,那么这个方法的问题在于:当你在街上走时,你的朋友可能已经离开了你要去的星巴克。你首先看星巴克 A,发现“他不在”,然后你去星巴克 B 找他。你在星巴克 B 中也可以作出同样的选择,但不是同时发生。两家星巴克之间有几分钟路程,而在这几分钟的时间里,系统的状态就可能发生变化。

在星巴克这个示例中说明了一种竞态条件,因为要获取正确的结果(与朋友会面),必须要取决时事件的发生时序(当你们到达星巴克时,在离开并去另一家星巴克之前要等待多长时间)。当你迈出前门时,你在星巴克 A 的观察结果将变得无效,你的朋友很可能从后门进来了,而你不知道。这种观察结果的失效就是大多数竞态条件的本质────基于一种可能失效的观察结果来作出判断或执行某个计算。这种类型的竞态条件被成为先检查后执行。首先观察到某个条件为真(例如文件 X 不存在),然后根据这个观察结果采用相应的动作(创建文件 X),但事实上,在你观察到这个结果以及开始创建文件之间,观察结果可能变得无效(另一个线程在这期间创建了文件 X),从而导致各种问题。

注意:这里说的是 “竞态条件” 而不是 “数据竞争”,不要把两个概念相混淆。数据竞争是指,如果在访问共享非 final 类型的域时没有采用同步来进行协同,那么就会出现数据竞争。比如有两个线程,其中一个线程写入一个变量,另一个线程接下来读取这个变量。或者读取一个之前由另一个线程写入变量时,这两个线程之间没有使用同步。在 java 内存模型中,如果在代码中存在数据竞争,那么这段代码就没有明确定义(这个我们留到以后讲)。
而竞态条件指的是类中没有加锁的对象。如果一个类中没有属性变量,则称为无状态的,相反成为有状态的。多个进程,如果访问时序正确则不会出现数据等安全性问题,但进程的访问顺序是不可控的,出现安全性错误也是常见的。
不是所有竞态条件都会出现数据竞争,竞态条件有时取决于运气。同样,也不是所有的数据竞争都是竞态条件。但是两者都可能会导致并发程序失败。

讲一讲 “先检查后执行” 中存在的竞态条件

老规矩,首先我们先来看一段代码:

public class Singleton {
    private static Singleton instance = null;

    private Singleton() { }

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

代码我就不多说了。它是 “先检查后执行” 的一种最常见的情景(延迟初始化)。延长初始化的目的是将对象的初始化操作推迟到实际中被使用才进行,同时要确保只被初始化一次,很显然这段的代码并不能达到到这一目的,因为在 Singleton 中包含了一个竞态条件,他会破坏这个类的正确性。假设线程 A 和线程 B 同时执行 getInstance 方法 首先 A 看到 instance 为空,因此创建一个新的 Singleton 实例,B 也同样如此。那么此时的 instance 是否为空要取决与不可预测的时序,包括线程的调度方式,以及 A 需要花多长时间来初始化 Singleton 并设置 instance。如果当 B 检查时,instance 为空,那么两次在调用时可能会得到不同的结果。竞态条件并不会总是产生错误(这要取决于你运气),因为产生竞态条件需要不恰当的执行顺序。

来源:luob 的博客文章


何处钟 , 版权所有丨如未注明 , 均为原创丨本网站采用BY-NC-SA协议进行授权
转载请注明原文链接:并发基础知识(四)
喜欢 (1)
[15211539367@163.com]
分享 (0)

您必须 登录 才能发表评论!