Java 并发编程基础:线程安全与竞态条件

本专栏的学习笔记整理自机械工业出版社的《Java 并发编程实战》。

本章小结

  1. 什么时候要考虑线程安全?
  2. 先检查后执行 ( Check-Then-Act ) 策略带来的竞态条件
  3. 原子操作的定义
  4. 锁的基本使用

进程是资源分配的最小单位,而线程是 CPU 执行的最小单位。一个进程内存在多个线程,这些线程共享进程范围内的资源 ( 文件句柄,内存句柄 ),但是每个线程都有独立的程序计数器 ( Program Counter ),操作数栈,局部变量表。见笔者的 JVM 笔记:JVM :Java 内存区域划分 - 掘金 (juejin.cn)JVM:字节码执行引擎 - 掘金 (juejin.cn)

同一个进程内的多个线程共享同一块地址空间,它们可以访问并修改同一个内存空间的变量。在不设置任何协同机制的前提下,所有的线程都将独立运行。每个线程都是 "自私" 的:当它修改某个变量时完全不会考虑其它线程是否也会对这相同的变量进行读取或者修改。

基础问题

构建稳定的并发程序,必须正确使用线程和锁,而这终归只是用来实现并发编程的一些机制。要编写线程安全的代码,核心是对共享 ( Shared ) 和可变 ( Mutable ) 状态 的访问控制。

  1. 共享:变量可以被多个线程访问。
  2. 可变:变量的值可以在生命周期内发生变化。

当我们使用 "状态" 一词描述域 ( 或称成员变量,属性等 ) 时,意味着它是一个可变变量 ( variable ),而非值 ( final value )。除此之外,状态包含了其字段所能描述的所有数据。比如:某个 HashMap 域的状态不仅包括它自身的引用,还包括该 HashMap 内部的所有 Entry 的状态。

// hm 被 final 关键字修饰,但它事实上仍是可变状态。
public final HashMap<String,String> hm = new HashMap<>();
public void addKey(String k, String v){
    hm.put(k,v);
}
复制代码

想要让状态是线程安全的,则必须要引入锁机制加以保护。Java 提供了多种关键字 synchronized,原子变量,volatile,显式锁等工具实现目的,本章会介绍前两者。

关于线程安全性

当我们探讨线程安全性时,实际上是探讨程序在并发环境下执行的 正确性。展开来讲就是:某个对象的行为和规范完全一致。我们会定义两种规范:

  1. 不变性条件 ( Invariant Conditions ),即总是成立而不发生改变的条件,可以称 "性质",用以约束对象的状态。比如:当银行的账户 A 向 B 发起一笔转账时,两个账户的余额之和应该总是不变的。
  2. 后验条件 ( Post Conditions ),在执行一个操作后满足的条件,用于描述对象操作的结果。比如:账户 B 收到一笔转账后,它现在的余额应该是之前的余额与交易额之和。

并不是所有的并行程序都要考虑并发安全问题。如果多个线程各自操作不同的对象而不共享任何状态,那么这段代码实际上是并行 ( parallel ) 而非并发 ( concurrent ) 的。

在面向控制的大型系统中,通常考虑的都是并发问题,因为系统依赖于各种全局变量维护状态;除了基于锁机制的维护并发系统之外,还有基于 Actor 模型的并发系统,比如 Akka ( 基于 Actor 的并发控制要比基于锁的并发控制更加简单 )。而在面向计算的大型系统中,则更偏向于并行调度问题,比如 Spark。

此外,无状态对象一定是线程安全的。无状态对象不包含任何域,也不包含任何对其它域的引用。

见下方的 build 方法:它本身就是独立的闭包 ( closure ),内部不引用任何自由变量。即便是多个线程访问了同一个 ArrayBuilder 对象的 build 方法,局部变量 count 也是被保存在不同的栈帧下的局部变量表内的。线程之间没有共享状态,因此不会相互干涉计算结果

class ArrayBuilder{
    public void build(int[] arr){
        int count = 0;
        for(int i = 0 ; i <= arr.length - 1 ; i++,count++) {
            arr[i] = i;
            System.out.printf("count = %s\n",count);
        }
    }
}
复制代码

总的来说,想要保证一个变量是线程安全的,有以下三种方法:

  1. 不在线程之间共享这个变量。
  2. 将变量设置为不可变的。
  3. 访问 / 修改变量时引入同步机制。

显而易见的是,如果一个对象在单线程都不具备正确性,那它也一定不是多线程安全的。

活跃性问题与性能问题

安全性探讨 "永远不要发生糟糕的事情",活跃性探讨 "正确的事情应当尽快发生"。比如说:为了保证线程安全,线程 A 正等待另一个线程 B 释放某个互斥锁。如果线程 B 一直不释放资源,那么线程 A 就只能一直等待下去。活跃性问题包括死锁,饥饿,活锁等。导致活跃性问题的错误是难以分析的,因为这依赖于不同线程的事件发生时序,因此很难在单元测试中复现。

在设计良好的并行程序中,多线程能够提升程序的性能,但是,使用多线程会不可避免地带来更多的运行时开销。当调度器暂时挂起活跃进程并运行另一个线程时,需要执行上下文切换 ( Context Switch ) 操作。

如果并行程序设计的不当,那么 CPU 会被迫将大部分时间花在上下文切换而不是执行任务上。同时,当线程之间共享变量时,必须采取同步机制,而这些机制又会抑制某些编译器优化,使得内存缓冲区的数据无效。可见,活跃性问题还和性能问题密切相关,而这些内容在之后的学习中再逐步探讨。

原子性

如果将前文例子的 count 字段挪到实例域内,那么ArrayBuilder 类创建出的对象就是携带状态的了。

class ArrayBuilder{
    // count 现在被用来记录 build 方法被调用的次数
    public int count = 0;
    public void build(int[] arr){
        for(int i = 0 ; i <= arr.length - 1 ; i++) {
            arr[i] = i;
        }
        count++;
    }
}
复制代码

此时,build 方法内 count 是一个自由变量 ( 是一个不受 build 方法约束的变量 )。count 被保存到字段表 ( 而不是局部变量表 ),同一个对象的 count 可以被多个线程观察并修改。

在单线程环境中,这是一段再普通不过,且完全合理的代码。但是在多线程环境中这么做,ArrayBuilder 很可能会丢失一些更新记录。count++ 是一个紧凑的语法,因此看起来这像是一个操作,但事实上并非如此:它包含了:"读取 → 修改 → 写入" 三个独立的步骤,每一步都依赖上一步的状态。

设想一下,两个线程同时读取了 count = 0,接着执行递增操作,然后两个线程同时将 count 值修改为 1。显然,这里的计数器发生了偏差。随着冲突次数的增加,计数器的偏差将会越来越大。这种由于不恰当的执行时序而出现不正确的结果有一个官方称呼:竞态条件 ( Race Condition )。

竞态条件

当某个计算的正确性取决于多个线程的交替执行次序时,竞态条件就会发生。再通俗地说:程序的正确性取决于运气。最常见的竞态条件发生在 "先检查后执行 ( Check-Then-Act ) " 类型的代码中,因为线程可能会基于已经失效的观测结果执行下一步动作。

比如某个线程在开始时观察到某个文件 X 并不存在,并打算新创建一个文件 X 并写入一些内容。但是,在程序创建文件之前,其它程序或者用户可能创建了它 ( 该线程最开始的观测结果相当于失效了 )。这会导致各种问题:未预期的异常,数据被覆盖等。

示例:延迟初始化

"先检查后执行" 的一种典型场景延迟初始化。延迟初始化的本意是让创建成本高昂的对象被推迟到只有在真正需要时才会被加载。

class LazyInitRace {
    // 实际上,这个 Object 可能是一个创建代价昂贵的类型。
    private Object instance = null;
    public Object getInstance(){
        // if() 是一个明显的观测动作
        if(instance == null) instance = new Object();
        return instance;
    }
}
复制代码

显然,这是一个 "先检查后执行“ 的过程:线程 A 在访问 getInstance() 方法时,会率先观察 instance 是否为 null,然后再决定是初始化,还是直接返回引用;另一个到达的线程 B 要做相同的检查。但现在,它所观察到的 instance 实际是否为空取决于不可预测的时序,还包括线程 A 需要花费多长的时间来初始化 instance。如果线程 B 无意间做出了误判,那么整个流程会错误地创建出两个 instance 实例。

附:当一个类首次被加载时,其静态域会执行一次。可以利用这个特性实现线程安全的单例模式。

class LazyInitRace {
    // 即使 LazyInitRace 被加载了,Instance$ 也未必加载,直到某个 LazyInitRace 对象的 getInstance 方法被首次调用:
    private static class Instance${private static final Object instance_ = new Object();}
    public Object getInstance(){return Instance$.instance_;}
}
复制代码

复合操作

想要避免竞态条件,就必须要引入原子操作。比如两个线程 A 和 B 正同时修改同一个状态,当任意一个线程要开始操作时,另一个线程要么已经完整执行了访问 → 修改 → 写入的流程,要么就还没有开始。此时 A 和 B 两线程的修改状态操作就是原子的。原子操作针对的是 同一个状态 的访问与修改。

如果对状态 count++ 的操作是原子的,那么原本存在的竞态条件就不存在了。我们将访问 → 修改 → 修改这三个流程统称为一个复合操作,将这个复合操作变成一个原子操作,以保证线程安全性。

class ArrayBuilder{
    private final AtomicInteger count$ = new AtomicInteger(0);
    // count() 记录 build 方法被调用的次数
    public int count(){ return count$.get();}
    public void build(int... arr){
        for(int i = 0 ; i <= arr.length - 1 ; i++) {arr[i] = i;}
        count$.incrementAndGet();
    }
}
复制代码

java.util.concurrent.atomic 下本身包含了一些用于提升普通数值类型的原子变量类。在本例中,AtomicInteger 能够保证对 count$ 的所有操作都是原子的。而又因为目前的 ArrayBuilder 只维护了一个 count$ 状态,此时也可以称 ArrayBuilder 类本身是线程安全的。

尽可能使用线程安全的对象管理状态,会使得并发任务的代码更加易于维护。

加锁机制

假设现在的类内部定义了更多的状态,复合操作变得更加复杂,只是简单地添加更多的原子变量就足够了吗?为了说明这个问题,下面仍以银行转账为例子,Bank 类维护两个状态:A,B 两个账户的余额。

class Bank {
    // 此处的 final 指代 account_A 和 account_B 的引用不可改变。
    public final AtomicInteger account_A = new AtomicInteger(100);
    public final AtomicInteger account_B = new AtomicInteger(200);
    public void transform(AtomicInteger x, AtomicInteger y,int amount) {
        var a_ = account_A.get();
        var b_ = account_B.get();
        account_A.set(a_ - amount);
        account_B.set(b_ + amount);
    }
}
复制代码

线程安全性规定,无论多个线程的操作以什么样的时序交替执行,都不能破坏类的不变性条件。比如在转账业务中的不变性条件之一是:任何线程在开始时观察到的 A,B 两个账户余额之和应该相等。

尽管两个引用 account_Aaccount_B 都是线程安全的,但是在 Bank 类当中仍然存在着竞态条件,这可能引发错误的结果,原因在于线程无法同时修改两个账户的金额。比如另一个线程 T2 在 T1 线程 "刚刚写入 A 账户但未写入 B 账户" 的时机进入,它观察到的不变性准则就未必成立。

因此,想要保持状态的一致性,就需要在单个原子操作中更新所有状态相关的状态变量

Java 从语法层面上支持原子性:synchronized 关键字,又被称之为 内置锁。它可以单独作为一个语法块使用,写法如:

synchronized(lock){/**/}
复制代码

任何一个 Java 对象都可以作为互斥的同步锁 lock。在进入同步代码块之前,线程必须要先获得 lock 锁,然后在退出同步代码块 ( 包括抛出异常退出 ) 时释放它。当另一个线程 T2 想要获取 T1 正在持有的锁对象 lock 时,它必须要阻塞等待。如果 T1 一直不释放 lock,那么 T2 会无限期地等待下去。通过这种形式,Java 保证了同一时刻只能有一个线程执行同步代码块。

可以直接将被保护的变量作为 lock 放入其中,此时称该状态是被锁保护的;也可以将整个对象 ( this ) 作为一个锁放入其中,此时该对象的所有可变状态都会受到保护。

synchronized 关键字还可以直接标注在方法声明上。被标注的方法相当于是一个横跨整个函数体的同步代码块,而该代码块的锁就是被调用方法的对象本身 ( this )。如果是静态的同步方法,那么就会以描述该对象的类信息的 Class 对象作为锁。

class Bank {
    // 不变性条件:A B 两个账户的余额之和不变。
    // account_A 和 account_B 被内置锁保护,因此没有必要再单独设置成原子变量了。
    public Integer account_A = 100;
    public Integer account_B = 200;

    public synchronized void transform(Integer amount) {
        account_A = account_A - amount;
        account_B = account_B + amount;
    }
}
复制代码

重入特性

获取锁操作的粒度是线程,而非调用。在 Java 中,线程可以反复访问由它自己持有的锁,这种特性称之为锁的可重入性。

class MutexFoo {
    // 这里的锁就是 MutexFoo 实例本身
    public synchronized void f(){g();}
    public synchronized void g(){}
}
复制代码

重入的一种实现方式是:为每把锁设置一个计数器并记录持有者线程。持有者线程可以反复重入,每次计数器都会累加;每释放一次,计数器减 1。当计数器数值为 0 时,说明这把锁目前处于释放状态。

显然,在调用 MutexFoo 对象的 f 方法时,线程需要对此对象上两次锁。假如 Java 将锁设计成了不可重入的,那么下面的代码就会产生死锁。

var foo = new MutexFoo();
foo.f();
复制代码

加锁的约定

一种常见的加锁协议是:将所有的可变状态全部封装到对象内部,然后通过对象的 synchronized 内置锁在 所有 访问可变状态的代码块进行同步。开发者必须要小心,如果在某处遗漏了同步规则,那么整个加锁协议就会失效。

对于一个原子操作所涉及的多个变量都必须使用同一个内置锁来保护,比如说转账业务的 account_Aaccount_B,它们都由 Bank 对象自身的内置锁来保护。

并非所有的数据都需要锁的保护,只有会被多线程同时访问的状态才需要上锁,这同时涉及到了锁粒度的问题。假设 Bank 在转账之前会临时通过购买的单价和数量计算交易额:

class Bank {
    public Integer account_A = 100;
    public Integer account_B = 200;
    public synchronized void transform(Integer number, Integer price) {
        var amount = number * price;
        account_A = account_A - amount;
        account_B = account_B + amount;
    }
}
复制代码

由于内置锁加在了 transform 方法上,因此多个线程必须以完全串行的方式工作 ( 假定这里仅使用一个 Bank 对象 ),整段代码的执行性能将会非常糟糕。我们实际上注意到,amount 是一个局部变量,它本身就不会被任何线程共享,因此完全没必要加入到同步代码块内。下面是优化代码:

public void transform(Integer number, Integer price) {
    var amount = number * price;
    synchronized(this) {
        account_A = account_A - amount;
        account_B = account_B + amount;
    }
}
复制代码

这样,即便线程发现不能够立刻修改 A,B 账户的状态,也可以率先计算出转账的金额,而不是傻傻地等待其它线程完全执行完之后再计算。尤其是某些局部变量需要大量运算时,精细化封锁粒度能够在性能和安全之间找到良好的平衡。

如果持有锁的时间过长,那么性能问题会变得非常显著。因此,一定不要在同步代码块内部做严重耗时的操作。

猜你喜欢

转载自juejin.im/post/7094540493110919181