(二) Java并发编程实战——线程安全性

1.1 什么是线程安全性?

  要对线程安全性给出一个确切的定义是非常复杂的。定义越正式,就越复杂,不仅很难提供实际意义的指导建议,而且很难从直观上去理解。例如这样的描述“如果某个类可以在多个线程中安全地使用,那么它就是一个线程安全的类”。对于这种说法,虽然没有太多争议,但同样不会带来多大帮助。

  在线程安全性的定义中,最核心的概念就是正确性。如果对线程安全性的定义是模糊的,那么就是因为缺乏对正确性的清晰定义。正确性的含义是,某个类的行为与其规范完全一致。

  在良好的规范中,通常会定义各种不变性条件来约束对象的状态,以及定义各种后验条件来描述对象操作的结果。由于我们通常不会为类编写详细的规范,那么如何知道这些类是否正确呢?我们无法知道,但这并不妨碍我们对正确性的理解,因此我们将单线程的正确性近似定义为“所见即所知”。

  在对“正确性”给出了一个较为清晰的定义后,就可以定义线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么称这个类是线程安全的。如果某个类在单线程环境中都不是正确的,那么它肯定不会是线程安全的。

  无状态对象:无状态的含义是指与其它任何对象都没有共享的域。访问无状态对象的线程不会影响到另一个访问同一个对象的线程,因为两个线程之间没有共享状态,就好像它们在访问不同的实例。由于线程访问无状态对象的行为并不会影响其它线程中操作的正确性,因此无状态对象是线程安全的。

1.2 原子性

  如果做一个“命中计数器”(Hit Counter)来记录访问请求数,一种直观的方法是增加一个long类型的域,并且每处理一个请求就将这个值加1。然而不幸的是,这样并不是线程安全的。因为counter++看上去只是一个操作,但是它并不是原子的。它包含的过程是“读取——修改——写入”这三个操作,并且每个操作的结果状态依赖于之前的状态。

  如果该计数器被用来生成数值序列或者唯一的对象标识符,那么在多次调用中返回相同的值将导致严重的数据完整性问题。在并发编程中,这种由于不恰当的执行时序而导致不正确的结果是一种非常重要的情况,它有一个正式的名字:竞态条件(Race Condition)。

  当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。换句话说,正确的运行结果取决于运气。最常见的竞态条件类型就是“先检查后执行(Check-Then-Act)”操作。

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

@NotThreadSafe
public class LazyInitRace{
    private ExpensiveObject instance = null;
    
    public ExpensiveObject getInstance(){
        if(instance == null){
            instance = new ExpensiveObject();
        }
        return instance;
    }  
}

  在LazyInitRace中包含一个竞态条件,可能会破坏类的正确性。假定线程A和线程B同时执行getInstance。A获取的instance为空,因而创建一个新的ExpensiveObject;B同样判断instance时,结果为空,说明两次调用getInstance时可能会得到不同的结果。

  复合操作:对于某些操作,需要以原子方式执行(或者说不可分割的操作)。要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其它线程使用这个变量,从而确保其它线程只能在操作完成之前或之后读取和修改状态。我们将“先检查后执行”和“读取-修改-写入”等操作统称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。

1.3 保证正确性的方法

(1)线程安全类

  为确保操作的原子性,有很多方法可以使用,这里使用一个现有的线程安全类:

@ThreadSafe
public class CountingFactorizer implements Servlet {
    private final AtomicLong count = new AtomicLong(0);
    
    public long getCount(){ return count.get(); }

    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        count.incrementAndGet();
        encodeIntoResponse(resp, factors);
    }
}

  count.incrementAndGet()与计数器状态一致,而且它是线程安全的,因此这里的Servlet也是线程安全的。

(2)加锁机制

  当在Servlet中添加一个状态变量时,可以通过线程安全的对象来管理Servlet的状态以维护Servlet的线程安全性。但是如果想在Servlet中添加更多的状态,那么是否只需添加更多的线程安全状态变量就足够呢?答案是否定的。要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

  内置锁

  Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象作为锁。

synchronized(lock){
    //访问或修改由锁保护的共享状态
}

  每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock)。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁,而无论是通过正常的控制路径退出,还是通过从代码块中抛出异常退出。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。Java的内置锁相当于一种互斥锁,这意味着最多只有一个线程能持有这种锁。

  重入

  当某个线程请求一个由其它线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果一个线程试图获得一个它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作粒度是“线程”,而不是“调用”。重入的一种实现方法是,为每个锁关联一个获取计算值和一个所有者线程。当计数值为0时,这个锁就被认为没有线程持有。当线程请求一个未被持有的锁时,JVM会记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应的递减。当计数值为0时,这个锁将被释放。

(3)活跃性与性能

  如果不加考虑地使用锁,虽然能确保线程安全性,但付出的代价可能非常高。在一个方法上加synchronized锁,这样每次只有一个线程可以执行,这在负载过高的情况下将给用户带来糟糕的体验。如果系统中有多个CPU系统,那么当负载值很高时,仍然会有处理器处于空闲状态。

  我们将这种Web应用程序称之为不良并发(Poor Concurrency)应用程序:可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序本身结构的限制。幸运的是,通过缩小同步代码块的作用范围,可以做到既确保并发行,同时又维护线程安全性。应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其它线程可以访问共享状态。