Java并发编程实战(学习笔记 一 第二章 线程安全性)

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/ahaha413525642/article/details/76546489

本书的中英版的pdf和源码可以在这里下载:
java并发编程实战(中英版)pdf及源码

2.1 线程安全性(Thread Safety)

要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的(Shared)和可变的(Mutable)状态的访问。

‘共享’意味着可以被多个线程同时访问,而’可变’意味着变量的值在其生命周期内可以发生变化。

一个对象是否需要线程安全,取决于它是否被多个线程访问。要使得对象是线程安全的,需要采用同步机制来协同对对象可变状态的访问。Java中的主要同步机制是关键字synchronized(异步的),它提供了一种独占的加锁方式。

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

我们来看一个简单实例StatelessFactorizer.java——一个基于Servlet的因数分解服务,并逐渐扩展它的功能,同时确保它的线程安全性。

//          2-1       一个无状态的Servlet
public class StatelessFactorizer extends GenericServlet implements Servlet {

    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req); //BigInteger表示不可变的任意精度的整数,主要用于高精度计算中
        BigInteger[] factors = factor(i);
        encodeIntoResponse(resp, factors);
    }
    void encodeIntoResponse(ServletResponse res, BigInteger[] factors) {
    }

    BigInteger extractFromRequest(ServletRequest req) {    //从请求中得到数值,内部代码并不具这功能
        return new BigInteger("7");
    }

    BigInteger[] factor(BigInteger i) {      
        // 只是演示,不具因数分解功能
        return new BigInteger[] { i };
    }
}

与多数Servlet相同,StatelessFactorizer是无状态的。访问StatelessFactorizer的线程不会影响另一个访问同一个StatelessFactorizer的线程的计算结果,因为这两个线程并没有共享状态,就好像它们在访问不同的实例。

有状态就是有数据存储功能。有状态对象(Stateful Bean),就是有实例变量的对象 ,可以保存数据,是非线程安全的。在不同方法调用间不保留任何状态。

无状态就是一次操作,不能保存数据。无状态对象(Stateless Bean),就是没有实例变量的对象 .不能保存数据,是不变类,是线程安全的。


2.2 原子性(Atomicity)

原子是构成物质的基本单位(当然电子等暂且不论),所以原子性的意思代表着——“不可分割”;

我们来看下面的一个例子UnsafeCountingFactorizer:

//       2-2    在没有同步的情况下统计已处理请求数量的Servlet(不要这么做)
@NotThreadSafe //注解
public class UnsafeCountingFactorizer implements Servlet {
    private long count = 0;

    public long getCount() {
        return count;
    }

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

注解的作用有:
1、生成文档。这是最常见的,也是java 最早提供的注解。常用的有@see @param @return 等 2、跟踪代码依赖性,实现替代配置文件功能。比较常见的是spring 2.5 开始的基于注解配置。作用就是减少配置。现在的框架基本都使用了这种配置来减少配置文件的数量。以后java的程序开发,最多的也将实现注解配置,具有很大用处; 3、在编译时进行格式检查。如@override 放在方法前,如果你这个方法并不是覆盖了超类方法,则编译时就能检查出。

UnsafeCountingFactorizer并非是线程安全的,尽管它在单线程环境中能正确运行。++count看起来是一个操作,但这个操作并非原子性的,因为它可以被分成三个独立的步骤:
①读取count的值
②值加一
③将计算结果写入count
这是一个”读取-修改-写入”的操作序列,并且结果状态依赖于之前的状态。

下图给出了两个线程在没有同步的情况下对同一个计数器执行递增操作发生的情况。
这里写图片描述
在银行结算中如果出现这个错误是致命的,这显然不是我们希望看到的。

2.2.1 竞态条件(race condition)

是指设备或系统出现不恰当的执行时序,而得到不正确的结果。

最常见的竞态条件类型就是”先检查后执行(Check-Then-Act)”操作,这有可能通过一个可能失效的观测结果来决定下一步的动作.

这里写图片描述
例如:首先观测到某个条件为真(假如文件X不存在),然后根据这个结果采用相应的动作(创建文件X),但事实上,在你观察到这个结果以及开始创建文件之间,观测结果可能变得无效(另一个线程在这期间创建了文件X),从而导致各种问题(未预期的异常,数据被覆盖,文件被破坏等)。

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

//             2-3          延迟初始化中的竞态条件(不要这么做)
@NotThreadSafe
public class LazyInitRace {
    private ExpensiveObject instance = null;

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

class ExpensiveObject { }

这里面包含了一个竞态条件。这和++count类似。假定线程A和B同时执行getInstance。A看到instance == null,因而创建了实例,而此时的instance == null是否成立要取决于不可预测的时序,包括线程的调度方式以及A需多长时间来初始化ExpensiveObject并设置instance。这让B有可能得到不同的结果。

2.2.2 复合操作(Compound Actions)

要避免竞态条件问题,就必须在某个线程修改该变量时,通过某中方式防止其他线程使用这个变量,从而确保其他线程只能在修改完成之前或之后读取和修改状态,而不是在修改状态的过程中。

假定有操作A和B,如果对于执行A的线程来说,当另一个线程执行B时,要么全部执行完,要不完全不执行,那么A和B对彼此来说就是原子性的。

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

我们现在使用一个现有的线程安全类来修复这个问题,如下面的CountingFactorizer:

//      2-4     使用AtomicLong类型的变量来统计已处理请求的数量
@ThreadSafe
public class CountingFactorizer extends GenericServlet implements Servlet {
    private final AtomicLong count = new AtomicLong(0);   //final修饰的类不能被继承

    public long getCount() { return count.get(); }

    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        count.incrementAndGet();   //使用atomic中的方法
        encodeIntoResponse(resp, factors);
    }

    void encodeIntoResponse(ServletResponse res, BigInteger[] factors) {}
    BigInteger extractFromRequest(ServletRequest req) {return null; }
    BigInteger[] factor(BigInteger i) { return null; }
}

在java.util.concurrent.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子状态转换。通过用AtomicLong来代替long类型的计时器,能够确保所有对计数器状态的访问操作都是原子的。因为计数器是线程安全的,所以这里的Servlet也是线程安全的。

原子操作( atomic operations)
原子操作指的是在一步之内就完成而且不能被中断。

为什么long型赋值不是原子操作呢?
例如:
long foo = 65465498L;
实时上java会分两步写入这个long变量,先写32位,再写后32位。这样就线程不安全了。

当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那这个类仍是线程安全的。

在实际情况中,应尽可能使用现有的线程安全对象(例如AtomicLong)来管理类的状态,与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转换情况要更加容易,从而更容易维护和验证线程安全性。


2.3 加锁机制(Locking)

当在无状态的类中添加一个状态时,可以用线程安全你的对象来管理类的状态以维护类的安全性,但是如果假如更多状态时,就不能一味得添加更多线程安全状态变量。

加入我们希望提升性能,将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果。要实现该缓存策略,需要保存两个状态:最近执行过因数分解的数值,以及结果。

我们尝试用添加线程安全状态变量来完成这件事,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方法的每次调用都是原子de,但仍然无法同时更新lastNumber和lastFactors。如果只修改了其中一个变量,那么在这两次修改操作之间,其他线程将发现不变性条件被破环了。同样,我们也不能确保会同时获取两个值:线程A获取这两个值得过程中,线程B可能修改了它们,这样线程A也会发现不变性条件被破坏了。

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

2.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,服务的响应性非常低。

2.3.2 重入(Reentrancy)

内置锁是可重入的,如果某个线程试图获得一个已经由它持有的锁,那么这个请求就会成功。”重入“获取锁操作的基本单位是“线程”而不是”调用“。重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为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();
    }
}

2.4 用锁来保护状态(Guarding State with Locks)

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

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

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

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

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

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

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

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

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


2.5 活跃性(Liveness)和性能(Performance)

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

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

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

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

//   2-8     缓存最近执行因数分解的数值以及其计算结果的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类型。对单个变量上实现原子操作来说,原子变量是很有用,但我们已经使用了同步代码块来构造原子操作,而使用两种不同的同步机制不仅会带来混乱,也不会在性能或安全性上带来任何好处,所以这里不使用原子变量。

CachedFactorizer与SynchronizedFactorizer相比,实现了简单性(对整个方法进行同步)与并发性(对尽可能短的代码路径进行同步)之间的平衡。在获取与释放锁等操作上都需要一定开销,如果同步代码块分得太细(例如将++this分解为一个同步代码块),那样通常不好。

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

当执行时间较长的计算或者无法快速完成的操作时(例如,网络I/O或控制台I/O),一定不要持有锁。

展开阅读全文

没有更多推荐了,返回首页