【并发编程】基础系列(一)线程安全性

简介

随着计算机硬件的飞速发展,处理器的数量持续增长,要想充分发挥多处理器系统的强大计算能力,就需要使用多线程,因此高效的并发变得越来越重要。本篇将带领大家进入并发编程领域,说到并发编程,不得不提的就是线程安全性。

在构建稳健安全的并发程序的时候,必须正确的使用线程和锁,但这只是实现机制,核心在于对状态访问操作进行管理,尤其是共享状态(Shared)和可变状态(Mutable),对象的状态,可以理解为存储在状态变量(例如实例或者静态域)中的数据。Shared意味着变量可以由多个线程同时访问,而Mutable意味着变量的值在其生命周期内可以发生变化。
一个线程是否是线程安全的,主要取决于它是否被多个线程同时访问,当多个线程访问某个状态变量且至少有一个线程是执行写入操作时,则必须采用同步机制,Java中主要的同步机制是关键字synchronized,它提供的是一种独占的加锁方式,“同步”这个术语还包括了volatile类型的变量、显式锁Explicit Lock以及原子变量。

1. 线程安全性的概念

要给线程安全性一个准确的定义是非常复杂的,各大资料都众说纷纭,但只要能抓住关键就可以,需要明确,在线程安全性的定义中,最核心的概念就是正确性,而正确性的含义是,某个类的行为与其规范完全一致。在对“正确性”有了明确的理解之后,就可以定义线程安全性了:

当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。

在开发中想要快速判断一个类是否是线程安全的时候,只需要知道线程安全的充分条件:

在多个线程访问某个类时,不管采用何种调度方式或者这些线程如何交替执行,在主调代码中都无需添加额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类时线程安全的。

Tips:
无状态对象一定是线程安全的。所谓无状态对象,指的是既不包含任何域,也不包含任何对其他类中域的引用。

2. 原子性

2.1 问题发现

先来看这么一个问题,若想统计Servlet中请求的数量,定义了一个计数器count并在每次调用service方法的时候自增,程序清单如下:

/**
 * @author Carson
 * @date 2020/1/18 13:20
 * @description
 */
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
    private long count=0;
    
    public void service(ServletRequest servletRequest,ServletResponse servletResponse){
        /* 处理请求的具体逻辑,省略 */
        ++count;
    }
}

计数器的递增操作++count虽然看上去是个紧凑的语法,但事实上并非原子的,因为它包含了三个独立的操作:读取count的值,将值加1,然后将计算结果写入内存中的count,这其实是一个“读取——修改——写入”的操作序列,并且每一步的结果状态都要依赖上一步,也就是说如果某一步顺序异常将会导致“脏数据”,带来数据完整性问题。如下图所示:
在这里插入图片描述
在上图中,count初始值为9,假设此时有两个线程A、B请求了service方法,计数器应该递增到11,然而,由于在线程A读取完但尚未完成自增和写入的时候,线程B也读取了count值,此时仍然是旧值9,线程B再进行自增、写入,显然,这样得到的count值与真实值有所偏移。

2.2 竞态条件(Race Condition)

在并发编程中,这种由于不恰当的执行顺序而出现不正确的结果是一种非常重要的情况,即Race Condition。
最常见的竞态条件就是“先检查后执行(Check-Then-Act)”,即通过一个可能已经失效的观测结果来决定下一步的动作。事实上大多数竞态条件的本质就是基于一种可能失效的观测结果来做出判断或者执行某个计算,比如:当我们需要观察某个条件X,只有当X为真时,再采取相应的动作,但在观察到条件X和执行相应动作的期间,有可能别的线程过来修改了条件X,从而使之前对条件X的观察结果变得无效,进而造成各种问题(数据被覆盖,文件被破坏等)。
示例:延迟加载中的竞态条件
延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,同时要确保只被初始化一次。

/**
 * @author Carson
 * @date 2020/1/18 13:25
 * @description
 */
@NotThreadSafe
public class LazyInitRace {
    private Object instance = null;

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

在上述代码中,如果线程A和线程B同时执行getInstance,当A线程进入判断,看到instance为空,则创建了一个实例,当线程B再进入判断的时候,此时的instance是否为空,要取决于不可预测的时序,包括线程的调度方式,以及线程A需要多长时间来初始化Object并设置到instance对象中。如果当线程B检查时,instance为空,那么在两次调用getInstance时可能会得到不同的结果。

2.3 复合操作

首先给出概念,所谓复合操作:就是包含了一组必须以原子方式进行的操作以确保线程安全性。
上述两个类UnsafeCountingFactorizer 和LazyInitRace 都包含一组需要以原子方式执行的操作。要避免竞态条件,就必须保证在某个线程修改变量时,通过某种机制保证其它线程使用这个变量,从而确保其它线程只能在修改操作完成之前或者之后读取和修改状态。

假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说都是原子的。原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作是一个原子方式执行的操作。

在上述的统计servlet请求的数量中,由于自增操作并不是原子的,所以存在线程安全性问题,可以通过采用Java中封装的原子变量类实现在数值和对象引用上的原子状态转换。

/**
 * @author Carson
 * @date 2020/1/18 14:25
 * @description
 */
@ThreadSafe
public class CountingFactorizer implements Servlet {
    private final AtomicInteger count = new AtomicInteger(0);

    public void service(ServletRequest servletRequest, ServletResponse servletResponse) {
        /* 处理请求的具体逻辑,省略 */
        count.incrementAndGet();
    }
}

3. 加锁机制

3.1 问题发现

假设现在为了提高Servlet的性能,需要把上一次的结果缓存起来,结果包含两个状态:1.最近执行因数分解的数值,2.分解结果。初步设计如下代码:

/**
 * @author Carson
 * @date 2020/1/20 21:49
 * @description
 */
@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
    /* 上次执行因式分解的数值 */
    private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();
    /* 上次执行因式分解的结果 */
    private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();

    @Override
    public void service(ServletRequest request, ServletResponse response) {
        BigInteger i = request.getNumber("number");
        /* 业务逻辑此处略去 */
        BigInteger[] factors = facotr(i);
        lastNumber.set(i);
        lastFactors.set(factors);
    }
}

观察上面的代码,不难发现,在使用原子引用的情况下,尽管对set方法的每次调用都是原子的,但仍然无法保证同时更新lastNumber和lastFactors。而如果两个状态没有同时更新的话,其它线程将发现不变性条件被破坏了。
Tips:
要保证状态的一致性,就必须要在单个原子操作中更新所有相关的状态变量。

3.2 内置锁

针对上述问题,Java自身有一种机制可以解决,那就是内置锁,也即同步代码块(Synchronized Block),同步代码块有两个重要部分,一是作为锁的对象引用,二是由这个锁保护的代码块

	synchronized (lockObject){
        /* 由内置锁保护的共享状态 */
    }

Java中的每个对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。Java内置锁相当于一种互斥体,这意味着最多只有一个线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞。
针对3.1中的代码,可以将service方法加锁实现:

	@Override
    public synchronized void service(ServletRequest request, ServletResponse response) {
        BigInteger i = request.getNumber("number");
        /* 业务逻辑此处略去 */
        BigInteger[] factors = facotr(i);
        lastNumber.set(i);
        lastFactors.set(factors);
    }

但由于synchronized是互斥体,所以并发性非常糟糕,每次只能有一个请求进入方法体。

3.3 重入

如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”而不是“调用”。Java中的内置锁是可重入的。
关于重入的必要性,可以参看以下代码:

public class Widget{
    public synchronized void show(){
        /*  */
    }
}

public class subWidget extends Widget{
    @Override
    public synchronized void show(){
        System.out.println("Success");
        super.show();
    }
}

在以上程序清单中,子类重写了父类的show方法,然后调用父类中的方法,此时如果没有可重入的锁,那么这段代码将产生死锁。由于Widgt和subWidget 中的show方法都是synchronized修饰的方法,因此每个show方法在执行前都会获取Widget上的锁,如果内置锁是不可重入的,那么在调用super.show()方法时将无法获得Widget上的锁,因为这个锁已经被其自身持有,从而线程将永远等待下去。

4. 小结

对于可能由多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。
一种常见的加锁约定是:将所有的可变状态都封装在对象的内部,并通过对象的内置锁对所有访问可变状态的代码进行同步,使得在该对象上不会发生并发访问。
当某个变量由锁保护时,意味着在每次访问这个变量时都需要首先获得锁,这样就确保在同一时刻只有一个线程可以访问这个变量。并且对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。
在3.2节中通过synchronized对整个方法加锁,事实上这在生产中会引入活跃性和性能问题。我们可以通过缩小锁的粒度来提高并发性,即将同步方法优化为同步代码块:

@ThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
    /* 上次执行因式分解的数值 */
    private final AtomicReference<BigInteger> lastNumber = new AtomicReference<>();
    /* 上次执行因式分解的结果 */
    private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<>();

    @Override
    public void service(ServletRequest request, ServletResponse response) {
        BigInteger i = request.getNumber("number");
        /* 业务逻辑此处略去 */
        BigInteger[] factors = facotr(i);
        synchronized (this) {
            lastNumber.set(i);
            lastFactors.set(factors);
        }
    }
}

Tips:
在执行时间较长的计算或者无法快速完成的操作(如网络I/O)时,一定不要让该操作持有锁。

发布了28 篇原创文章 · 获赞 12 · 访问量 6万+

猜你喜欢

转载自blog.csdn.net/Carson_Chu/article/details/104028273