欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Java J.U.C并发包(5) —— 第二章:线程安全性

程序员文章站 2022-05-15 19:21:56
...

这里会记录 《Java Concurrency in Practice》(java并发编程实战)的所有知识点哟~

要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的和可变状态的访问。对象的状态指存储在状态变量(如实例或静态域)中的数据。对象的状态也可能包括其他依赖对象的域。如HashMap的状态不仅仅存储在HashMap对象本身,还存储在许多Map.Entry对象中。

“共享”:变量可以由多个线程同时访问;
“可变”:变量的值在其生命周期内可以发生变化。

当多个线程访问某个状态变量并且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式,但“同步”这个属于还包括volatile类型的变量,显式锁以及原子变量

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

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

注意:线程安全的程序是否完全由线程安全类构成?答案是否定的,完全由线程安全类构成的程序不一定就是线程安全的,而在线程安全类中也可以包含非线程安全的类。

1. 什么是线程安全性

通常,线程安全性的需求并非来源于对线程的直接使用,而时使用像Servlet这样的框架。

示例:一个无状态的Servlet(因数分解)

public class StatelessFactorizer implements Servlet {
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        encodeIntoResponse(resp,factors);

上述代码是:线程安全的

为了很好的理解线程安全性,首先要知道什么是有状态对象什么是无状态对象。
- 有状态:有数据存储功能。有状态对象,就是有实例变量的对象,可以保存数据,而非线程安全的。也就是有数据成员的对象
- 无状态:就是一次操作,不能保存数据。无状态对象,就是没有实例变量的对象。不能保存数据,是不变类,是线程安全的。即只有方法没有数据成员的对象。或者有数据成员,但是数据成员只是可读不可改的对象

好了,现在我们回到上述示例中。与大多数Servlet相同,StatelessFactorizer 是无状态的:它既不包含任何域,也不包含任何对其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。访问StatelessFactorizer 的线程不会影响到另一个访问同一个StatelessFactorizer 的线程的计算结果,因为这两个线程没有共享状态,就好像他们在访问不同的实例。因为线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此无状态对象一定是线程安全的

大多数Servlet都是无状态,只有当Servlet在处理请求时需要保存一些信息,线程安全性才会成为一个问题。

总结:

  • 无状态对象一定是线程安全的
  • 常量始终是线程安全的,因为只存在读操作
  • 每次调用方法前都新建一个实例是线程安全的,因为不会访问共享的内存
  • 局部变量是线程安全的,因为每执行一个方法,都会在独立的空间创建局部变量,它不是共享的资源。局部变量包括方法的参数变量和方法内变量。

2. 原子性

示例:在没有同步的情况下统计已处理请求数量的Servlet

public class StatelessFactorizer 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);

上述代码是:线程不安全的

虽然递增操作++count是一种紧凑的语法,但是这个操作并非原子性的,因而它并不会作为一个不可分割的操作来执行。实际上,它包含了三个独立的操作:读取count的值;将值+1;将计算结果写入count。

这种情况在基于Web服务中,如果该计数器被用来生成数值序列或者唯一的对象标识符,那么在多次调用中返回相同的值将导致严重的数据完整性问题。这种情况引出了一个非常重要的概念:竞态条件

竞态条件:这种由于不恰当的执行时序而出现不正确的结果

竞态条件

最常见的竞态条件类型就是“先检查后执行”操作,即通过一个可能失效的预测结果来决定下一步的操作
如:首先观察到某个条件为真(例如文件X不存在),然后根据这个观察结果采用相应的动作(创建文件X),但事实上,在你观察到这个结果以及开始创建文件之间,观察结果可能变得无效(另一个线程在这个期间创建了文件X),从而导致各种问题(未预期的异常、数据被覆盖、文件被破坏等)。
上述示例类型的竞争条件称为“”先检查后执行““。

大多数静态条件的本质——基于一种可能失效的观察结果来做出判断或者执行某个计算。

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

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

示例:延迟初始化中的竞态条件(其实就是单例模式中的一类懒加载)

public class LazyInitRace {
    private ExpensiveObject instance = null;

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

上述代码是:线程不安全的

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

竞态条件并不总是会产生错误,还需要某种不恰当的执行时序。然而,竞态条件也可能导致严重的问题。假定LazyInitRace 被用于初始化应用程序范围内的注册表,如果在多次调用中返回不同的实例,那么要么会丢失部分注册信息,要么多个行为对同一组注册对象表现出不一致的视图。

复合操作

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

更改上面的计数器变量,使用一个atomic包中的现有线程安全类来保证线程安全

示例:使用AtomicLong类型的变量来统计已处理请求的数量(AtomicLong是一种替代long类型整数的线程安全类)

public class CountingFactorizer implements Servlet {
    private final AtomicLong count = new AtmoicLong(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);

上述代码是:线程安全的

在J.U.C.atomic包中包含了一些原子变量类,用于实现在数值和对象引用上的原子转换状态。

加锁机制

想象以下情况:想要对传入的数值进行因数分解,将最近的计算结果缓存起来,当两个连续的请求对相同的数值进行因数分解时,可以直接使用上一次的计算结果,而无需冲虚计算。要实现该缓存策略,就要保存两个状态:最近执行因数分解的数值和分解的结果

示例:在没有足够原子性保证的情况下对其最近计算结果进行缓存

public class UnsafeCachingFactorizer implements Servlet { 
    private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>();
    private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();

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

上述代码是:线程不安全的

在某些执行时序中,UnsafeCachingFactorizer可能会破坏这个不变形条件。在使用原子引用的情况下,尽管对set方法的每次调用都是原子的,但是仍然无法同时更新lastNumber和lastFactors。如果只修改了其中一个变量,那么在这两次修改操作之间,其他线程将发现不变形条件被破坏了。同时,我们也不能保证会同时获取两个值:在线程A获取这两个值的过程中,线程B可能修改了他们,这样线程A也会发现不变性条件被破坏了。

内置锁

java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block),由Synchronized 关键字修改。
同步代码块包含两个部分:锁的对象引用;由这个锁保护的代码块。
同步代码块的锁就是方法调用所在的对象。静态方法的Synchronized 方法以class对象作为锁。

synchronizedthis) {
    //同步代码
}

每个java对象都可以用作一个实现同步的锁,这个锁被称为内置锁(相当于互斥锁),或监视锁。线程在进入代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法。

java的内置锁相当于一种互斥体,这意味着最多只有一个线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁。如果B永远不释放锁,那么A将永远等待

重入

我觉得重入就只要是给父类和子类用的,以免子类在重写+调用父类方法时出现问题

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

示例:如果内置锁不是可重入的,那么这段代码将发生死锁

public class Widget {
    public synchronized void doSomething(){
        //代码...
    }
}

public class LoggingWidget extends Widget{
    public synchronized void doSomething(){
        super.doSomething();
    }
}

上述代码是:线程安全的

上述代码中:子类改写了父类的synchronized方法,然后调用父类对应的synchronized方法,如果这个时候没有重入锁,那么这段代码将产生死锁。因为Widget类和LoggingWidget 类中的doSomething方法都是synchronized方法,所以每个doSomething方法在执行前都会获取Widget上的锁。然而,如果内置锁不是可重入的,那么在调用super.doSomething时将无法获得 Widget的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获得的锁。

用锁来保护状态

  • 对象串行访问(Serializing Access):多个线程依次以独占的方式访问数据,而非并发访问。
  • 对象的序列化(Serialization):将对象转化为字节流。

由于锁能够使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。
注意:

  • 并非只有在写入变量时才需要使用同步。(同步是指:在发出一个同步调用时,在没有得到结果之前,该调用就不返回)
  • 对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。
  • 每个共享的和可变的变量都应该只由一个锁来保护,从而使维护人员知道是哪一个锁。

一种常见的加锁约定时,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。

总结:
- 当某个变量由锁来保护时,意味着在每次访问这个变量时都需要首先获得锁,这样就确保在同一时刻只有一个线程可以访问这个变量。
- 对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。

活跃性与性能

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

由于service时一个synchronized方法,因此每次只有一个线程可以执行。这就背离了Servlet框架的初衷,即Servlet需要能同时处理多个请求,这在负载过高的情况下讲给用户带来糟糕的体验。如果Servlet在对某个大数值进行因素分解时需要很长的执行时间,那么其他的客户端必须一致等待,直到Servlet处理完当前的请求,才能开始另一个新的因数分解运算。如果在系统中有多个CPU系统,那么当负载很高时,仍然会有处理器处于空闲状态。即使一些执行时间很短的请求,比如访问缓存的值,仍然需要很长时间,因为这些请求都必须等待前一个请求执行完成。

参考资料

Java J.U.C并发包(5) —— 第二章:线程安全性