《Java并发编程实战》 第二章:线程安全性

思惟导图:编程

0. 前言

线程或者锁在并发编程中的做用,相似于铆钉与工字梁在土木工程中的做用。构建稳健的并发程序,必须正确的使用线程和锁。其核心在于要对状态访问操做进行管理,特别是对共享的和可变的状态的访问缓存

从非正式的意义上说,对象的状态指存储在状态变量(例如实例或静态域)中的数据。对象的状态可能包括其余
依赖对象的域。例如,某个hashmap的状态不只存储在对象自己,还存储在许多map.entry对象中。

“共享” 意味着变量能够有多个线程同时访问,而 “可变” 则意味着变量的值在生命周期能够发生变化。
复制代码

当多个线程访问某个状态变量而且其中有一个线程执行写入操做时,必须采用同步机制来协调这些线程对变量的访问。安全

若是当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式能够修复这个问题:

* 不在线程之间共享该状态变量
* 将状态变量修改成不可变的变量
* 在访问状态变量时使用同步
复制代码

1. 什么是线程安全性

在线程安全性的定义中,最核心的概念就是正确性。正确性的含义是,某个类的行为与其规范彻底一致。所以就能够定义线程安全性:当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。bash

当多个线程访问某个类时,无论运行时环境采用何种调度方式或者这些线程将如何交替执行,而且在主调代码中不须要任何额外的同步,这个类都能表现出正确的行为,那么就称这个类是线程安全的。网络

示例:一个无状态的servlet 程序2-1给出了一个简单的因数分解servlet。这个servlet从请求中提取出数值,执行因数分解,而后将结果封装到servlet的响应中。并发

与大多数servlet相同,StatelessFactorizer无状态的:它既不包含任何域,也不包含任何对其余类中域的引用。 因为线程访问无状态对象的行为并不会影响其余线程操做的正确性,所以无状态对象是线程安全的。less

2. 原子性

当咱们在无状态对象中增长一个状态时,会出现什么状况?假设咱们想增长一个“命中计数器”来统计所处理的请求数量。一种直观的方法是在servlet中增长一个long类型的域,而且每处理一个请求就将这个值加1,如程序2-2中:性能

这样,这个类就不是线程安全的了。虽然递增操做++count是一种紧凑的语法,使其看上去只是一个操做,但这个操做并不是原子的,于是他并不会做为一个不可分割的操做来执行。实际上,包含了三个独立的操做:读取count,加1,而后将结果写入count。这是一个“读取-修改-写入”的操做序列,而且其结果状态依赖于以前的状态。 此时,当两个线程在没有同步的状况下对这个计数器进行递增操做时,若是计数器初始值为9,那么某些状况下,每一个线程读到的都是9,接着执行递增操做,而且都将计数器的值设为10。显然,这种状况丢失了一次递增操做。ui

在并发编程中,这种因为不恰当执行时序而出现不正确的结果是一种很是重要的状况,他有一个正式的名字:竞态条件(Race Condition)this

2.1 竞态条件

当某个计算的准确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。换句话说,就是正确的结果取决于运气。最多见的竞态条件类型就是“先检查后执行”操做,即经过一个可能失效的观测结果来决定下一步动做。

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

延迟初始化的目的是将对象的初始化操做推迟到实际被使用时才进行,同时要确保只被初始化一次。在程序2-3中lazyInitRace说明了这种延迟初始化状况。

在此类中包含了一个竞态条件,他可能会破坏这个类的正确性。假设线程A和线程B同时执行 getInstance。A看到 instance为空,于是建立一个新的实例。B一样须要判断 instance是否为空。此时的 instance是否为空,要取决于不可预测的时序,包括线程的调度方式,以及A须要花多长时间来初始化 ExpensiveObject并设置 instance。若是当B检查时, instance为空,那么在两次调用 getInstance时可能会获得不一样的结果,即便 getInstance一般被认为是返回相同的实例。

2.3 复合操做

咱们将“先检查后执行”以及“读取-修改-写入”等操做统称为复合操做:包含了一组必须以原子方式执行的操做以确保线程安全性。

假定有两个操做A和B,若是从执行A的线程来看,当另外一个线程执行B时,要么B所有执行完,要不彻底不执行B,
那么A和B对彼此来讲是原子的。
原子操做是指,对于访问同一个状态的全部操做(包括该操做自己)来讲,这个操做是一个以原子方式执行的操做。
复制代码

解决复合操做,可使用加锁机制,将在下一小节介绍。目前使用另外一种方式来修复这个问题,即便用一个现有的线程安全类,如程序2-4:

经过用AtomicLong来代替long类型的计数器,可以确保全部对计数器状态的访问都是原子的。

3. 加锁机制

假设但愿提高servlet的性能:将最近的计算结果缓存出来,当两个连续的请求对相同的数值进行因数分解时,能够直接使用上一次的计算结果。要实现该缓存策略,须要保存两个状态:最近执行过因数分解的数值,以及结果。

咱们尝试用添加线程安全状态变量来完成这件事,UnsafeCachingFactorizer的代码为:

//    2-5  该Servlet在没有足够原子性保证的状况下对最近计算结果进行缓存(不要这么作)
@NotThreadSafe
public class UnsafeCachingFactorizer extends GenericServlet implements Servlet {
    //AtomicReference是做用是对"对象"进行原子操做
    private final AtomicReference<BigInteger> lastNumber
            = new AtomicReference<BigInteger>();
    private final AtomicReference<BigInteger[]> lastFactors
            = new AtomicReference<BigInteger[]>();

    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        if (i.equals(lastNumber.get()))
            encodeIntoResponse(resp, lastFactors.get());
        else {
            BigInteger[] factors = factor(i);
            lastNumber.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp, factors);
        }
    }

    void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {//保存执行过因数分解的数值及其结果
    }

    BigInteger extractFromRequest(ServletRequest req) {  
        return new BigInteger("7");
    }

    BigInteger[] factor(BigInteger i) {
        return new BigInteger[]{i};
    }
}

复制代码

然而,尽管这些原子引用自己各自都是线程安全的,但在UnsafeCachingFactorizer中存在着竞态条件,这可能致使错误。

在线程安全性的定义中要求,多个线程之间的操做不管采用何种执行顺序或交替方式,都要保证不变性条件不被破坏。UnsafeCachingFactorizer的不变性条件之一是:在lastFactors中缓存的因数之积应该等于在lastNumber中缓存的数值。只有确保了这个不变性条件不被破坏,上面的Servlet才是正确的。当在不变性条件中涉及多个变量时,各个变量之间并非彼此独立的,而是某个变量的值会对其余变量的值产生约束。所以,在更新某一个变量时,须要在同一个原子操做中队其余变量同时进行更新。

在使用AtomicReference的状况下,尽管对set方法的每次调用都是原子的,但仍然没法同时更新lastNumberlastFactors。若是只修改了其中一个变量,那么在这两次修改操做之间,其余线程将发现不变性条件被破环了。一样,咱们也不能确保会同时获取两个值:线程A获取这两个值得过程当中,线程B可能修改了它们,这样线程A也会发现不变性条件被破坏了。

要保持状态的一致性,就须要在单个原子操做中更新全部相关的状态变量。
复制代码

3.1 内置锁

Java提供一种内置的锁机制来支持原子性: 同步代码块(Synchronized Block)

同步代码块包括两部分:一个做为锁的对象引用,一个做为这个锁保护的代码块。

以关键字synchronized(同步的)来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以Class对象做为锁。

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

每一个Java对象均可以用作一个实现同步的锁, 这些锁被称为内置锁(Intrinsic Lock)或者监视锁(Monitor Lock)。线程在进入代码块以前会自动得到锁,而且在退出同步代码块时自动释放锁,不管是经过正常路径退出仍是经过从代码块中抛出异常退出。得到内置锁的惟一路径就是进入由这个锁保护的同步代码块或方法。

Java的内置锁至关于一种互斥体(或互斥锁),这意味这最多只有一个线程能持有这种锁。若是线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,知道B释放这个锁。若是B一直不释放这个锁,那么A将一直等待。

因为每次只能有一个线程执行内置锁保护的代码块,所以,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块也不会相互干扰。

并发环境中的原子性与事务应用程序中的原子性有着相同的含义——一组语句做为不可分割的单元被执行。

下面咱们使用synchronized关键字来改进:

//   2-6    这个Servlet能正确缓存最新的计算结果,但并发性却很是糟糕(不要这么作)
@ThreadSafe
public class SynchronizedFactorizer extends GenericServlet implements Servlet {
    @GuardedBy("this") private BigInteger lastNumber;
    @GuardedBy("this") private BigInteger[] lastFactors;

    public synchronized void service(ServletRequest req,
                                     ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        if (i.equals(lastNumber))
            encodeIntoResponse(resp, lastFactors);
        else {
            BigInteger[] factors = factor(i);
            lastNumber = i;
            lastFactors = factors;
            encodeIntoResponse(resp, factors);
        }
    }
}
复制代码

尽管SynchronizedFactorizer是线程安全,然而这种方法却过于极端,由于多个客户端没法同时使用因数分解Servlet,服务的响应性很是低。

3.2 重入

内置锁是可重入的,若是某个线程试图得到一个已经由它持有的锁,那么这个请求就会成功。”重入“获取锁操做的基本单位是“线程”而不是“调用”。

重入的一种实现方法是,为每一个锁关联一个获取计数值和一个全部者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并将获取值设置为1,若是同一线程再次获取这个锁,计数值递增,而当线程退出同步代码块时,计数器会相应地递减,当计数值为0时,这个锁将被释放。

“重入”进一步提高了加锁行为的封装性(encapsulation),所以简化了面向对象(Object-Oriented)并发代码的开发。
复制代码

在如下代码中,子类改写了synchronized修饰的方法,而后调用父类中方法,若是没有可重入的时,这段代码将产生死锁。因为子类和父类的doSomething方法都是synchronized方法,所以每一个doSomething方法在执行前都会获取Widget上的锁。若是内置锁是不可重入,那么在调用super.doSomething时将没法得到Widget上的锁,由于这个锁已经被持有,从而线程将永远停顿下去。重入避免了这种死锁状况的发生。

// 2-7 若是内置锁不是可重入的,这段代码将发生死锁
public class Widget {
    public synchronized void doSomething() {
...
    }
}
public class LoggingWidget extends Widget {
    public synchronized void doSomething() {
         System.out.println(toString() + ": calling doSomething");
         super.doSomething();
    }
}

复制代码

4. 用锁来保护状态

锁能以串行形式访问其保护的代码路径,所以能够经过锁来构造一些协议以实现对共享状态的独占访问。只要始终遵照这些协议,就能确保状态的一致性。

对于可能被多个线程同时访问的可变状态变量,在访问它时都须要持有同一个锁,在这种状况下,咱们成状态变量是由这个锁保护的。

上面的SynchronizedFactorizer(实现了Servlet接口)中,lastNumberlastFactors这两个变量都是由Servlet对象的内置锁来保护的。

对象的内置锁与其状态之间没有内在的关联。虽然大多数类都将内置锁用作一种有效的加锁机制,对对象的域并必定要经过内置锁来保护。当获取与对象关联的锁时,并不能阻止其余线程访问该对象。某个线程在得到对象的锁以后,只能阻止其余线程得到同一个锁。之因此每一个对象都有一个内置锁,是为了免去显式地建立锁对象。需自行构造加锁协议或同步策略来实现对共享状态的安全访问,而且在程序中一直使用它们。

每一个共享和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪个锁。

一种常见的加锁约定是,将全部的可变状态都封装在对象内部,并经过对象的内置锁对全部放问可变状态的代码路径进行同步,使得对该对象不会发生并发访问。例如Vector和其余的同步集合类都使用了这种模式。在这种状况下,对象状态中的全部变量都由对象的内置锁保护起来。若是在添加新的方法或代码路径时忘记使用同步,那么这种加锁协议就很容易被破坏。

只有被多个线程同时访问的可变数据才须要经过锁来保护,单线程程序不须要同步。

对于每一个包含多个变量的不变性条件,其中涉及的全部变量都须要由同一个锁来保护。

不加区别地滥用synchronized,可能致使程序中出现过分同步。此外即便将每一个方法都做为同步方法,在某些操做中仍然存在竞态条件。还会致使活跃性问题(Liveness)或性能问题(Performance)。

5. 活跃性(Liveness)和性能(Performance)

SynchronizedFactorizer中,经过Servlet对象的内置锁来保护每个状态变量,该策略的实现方式也就是对整个service方法进行同步。虽然这种简单且粗鲁的方法能确保线程安全,但代价却很高。

Servlet须要能同时处理多个请求,SynchronizedFactorizer违背了这个初衷。其余客户端必须等待Servlet处理完当前的请求,才能开始新的因数分解运算。这浪费了不少时间和减低了CPU的使用率。

下图给出了当多个请求同时达到因数分解Servlet时发生的状况:这些请求将排队等待处理。咱们将这种Web应用程序称为不良并发(Poor Concurrency)应用程序: 可同时调用的数量,不只受到可用处理资源的限制,还受到应用程序自己结构的限制。

经过缩小同步代码块的做用范围,咱们很容易作到既确保Servlet的并发性,同时又维护线程安全性。CachedFactorizerServlet的代码修改成使用两个独立的同步代码块,一个同步代码块负责保护判断是否只需返回缓存结构的”先检查后执行”操做序列,另外一个同步代码块负责确保对缓存的数值和因数分解结果进行同步更新。此外咱们还引入了“命中计数器”,添加了“缓存命中”计数器,并在第一个同步代码块中更新这两个变量。因为这两个计数器也是共享可变状态的一部分,所以必须在全部访问它们的位置都使用同步。位于同步代码块以外的代码将以独占方式来访问局部(位于栈上的)变量,这些变量不会在多个线程贡献,所以不须要同步。

//缓存最近执行因数分解的数值以及其计算结果的Servlet
@ThreadSafe
public class CachedFactorizer extends GenericServlet implements Servlet {
    @GuardedBy("this") private BigInteger lastNumber;
    @GuardedBy("this") private BigInteger[] lastFactors;
    @GuardedBy("this") private long hits;
    @GuardedBy("this") private long cacheHits;

    public synchronized long getHits() { //这两个计数器也是共享可变状态的一部分,所以必须在全部访问它们的位置都使用同步
        return hits;
    }

    public synchronized double getCacheHitRatio() {//这两个计数器也是共享可变状态的一部分,所以必须在全部访问它们的位置都使用同步
        return (double) cacheHits / (double) hits;
    }

    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = null;
        synchronized (this) {     //负责保护判断是否只需返回缓存结构的"先检查后执行"操做序列
            ++hits;
            if (i.equals(lastNumber)) {
                ++cacheHits;
                factors = lastFactors.clone();//clone()会复制对象。所谓的复制对象,首先要分配一个和源对象一样大小的空间,在这个空间中建立一个新的对象。
            }
        }
        if (factors == null) {
            factors = factor(i);
            synchronized (this) {       //负责确保对缓存的数值和因数分解结果进行同步更新。
                lastNumber = i;
                lastFactors = factors.clone();
            }
        }
        encodeIntoResponse(resp, factors);
    }

    void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {
    }

    BigInteger extractFromRequest(ServletRequest req) {
        return new BigInteger("7");
    }

    BigInteger[] factor(BigInteger i) {
        return new BigInteger[]{i};
    }
}
复制代码

这里没有使用AtomicLong类型的命中计数器,而是使用long类型。对单个变量上实现原子操做来讲,原子变量是颇有用,但咱们已经使用了同步代码块来构造原子操做,而使用两种不一样的同步机制不只会带来混乱,也不会在性能或安全性上带来任何好处,因此这里不使用原子变量。

CachedFactorizerSynchronizedFactorizer相比,实现了简单性(对整个方法进行同步)与并发性(对尽量短的代码路径进行同步)之间的平衡。在获取与释放锁等操做上都须要必定开销,若是同步代码块分得太细(例如将++this分解为一个同步代码块),那样一般很差。

一般,在简单性与性能之间存在着互相制约因素。当实现某个同步策略时,必定不要盲目为了性能牺牲简单性,这可能破坏安全性。

当执行时间较长的计算或者没法快速完成的操做时(例如,网络I/O或控制台I/O),必定不要持有锁。
复制代码
相关文章
相关标签/搜索