转载

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

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

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

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

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

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

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

1. 什么是线程安全性

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

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

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

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

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

2. 原子性

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

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

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

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

2.1 竞态条件

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

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

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

《Java并发编程实战》 第二章:线程安全性
在此类中包含了一个竞态条件,他可能会破坏这个类的正确性。假设线程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:

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

通过用 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)应用程序: 可同时调用的数量,不仅受到可用处理资源的限制,还受到应用程序本身结构的限制。

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

通过缩小同步代码块的作用范围,我们很容易做到既确保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),一定不要持有锁。
复制代码
原文  https://juejin.im/post/5cb43c1b5188251b2261e12a
正文到此结束
Loading...