一文读懂《Java并发编程实战》:第2章 影响线程安全性的原子性和加锁机制

2022-05-28 12:50:16 浏览数 (1)

上文《Java并发编程实战》的第1章“多线程安全性与风险”,讲述了多线程带来的好处与风险。本文承接上文,继续总结《Java并发编程实战》的第二章:线程安全性。

下文挑选出原著第二章的六个重点概念或观点进行剖析,分别对应《Java并发编程实战》原著的P11~P26,感兴趣的同学可以阅读原著。对于操作系统的线程模型有疑惑的同学,可以参考《系统线程模型与实现原理》一文。

1:什么是线程安全性

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

对象状态一般存储在状态变量(实例或静态域),可能还包括其他依赖对象的域。例如:HashMap 的状态不仅存在 HashMap 对象本身,还存储在许多 Map.Entry 对象中(HashMap的红黑树/链表的节点Node就是实现了该接口,后期HashMap源码分析会着重讲)。

在线程安全性定义里,核心概念就是正确性。因此可以定义线程安全性为:当多个线程访问某个类,这个类始终都能表现出正确的行为,那么久称这个类是线程安全的。

我们以Servlet 框架举个例子:因为 StatelessFactorizer 实现 Servlet 接口,它便能够完成对请求的处理, StatelessFactorizer 的 service() 方法实现是无状态的:StatelessFactorizer 不包含任何全局变量和其他类或者其他类域的引用

Servlet是Server与Applet的缩写,是服务端小程序的意思。是SUN公司提供的一门用于开发动态Web资源的技术。Servlet本质上也是Java类,但要遵循Servlet规范进行编写,没有main()方法,它的创建、使用、销毁都由Servlet容器进行管理(如Tomcat)。Servlet是和HTTP协议是紧密联系的,其可以处理HTTP协议相关的所有内容。提供了Servlet功能的服务器,Servlet容器,其常见容器有Tomcat, Jetty等。

下面是伪代码:

代码语言:javascript复制
@ThreadSafe
public class StatelessFactorizer implements Servlet {
  @Override
  public void init(ServletConfig config) throws ServletException {

  }

  @Override
  public ServletConfig getServletConfig() {
    return null;
  }

  @Override
  public void service(ServletRequest req, ServletResponse response) throws ServletException, IOException {
    //获取请求参数
    BigInteger count = extractFromRequest(req);
    //拓展请求参数处理
    BigInteger[] factors = factor(count);
    //设置响应数据
    encodeIntoResponse(response, factors);
  }

  @Override
  public String getServletInfo() {
    return null;
  }

  @Override
  public void destroy() {

  }
}

因为访问 StatelessFactorizer 的线程并不会影响到另外一个访问 StatelessFactorizer 的线程的计算结果,因为彼此之间并没有共享状态的途径,就好像它们访问了不同的实例。所以,我们得出结论:无状态对象一定是线程安全的。

大多数Servlet 都是无状态的,只有当Servlet 处理请求时需要保存信息时(例如:设置访问用户状态,设置请求Cookie,设置业务上下文等等)才会使线程安全性成为问题。

2: 竞态条件和复合操作

线程不安全的两大原因:竞态条件和复合操作

其一、竞态条件:由于不恰当的执行时序而出现不正确的结果归纳为竞态条件。

例子1:竞态条件导致的线程不安全

代码语言:javascript复制
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {

  private long count = 10;

  public long getCount() {
    return count;
  }

  @Override
  public void service(ServletRequest req, ServletResponse response) throws ServletException, IOException {
    //获取请求参数
    BigInteger i = extractFromRequest(req);
    //拓展请求参数处理
    BigInteger[] factors = factor(i);
    count  ;
    //设置响应数据
    encodeIntoResponse(response, factors);
  }
}

分析:UnsafeCountingFactorizer 不是线程安全的,因为递增操作 count 并非是原子操作,实际上,它包含了三个独立的操作:读取count的值,将值加1,然后将计算结果写入count。

如果线程A读到count为10,马上线程B读到count也为10,线程A加1写入后为11,线程B由于已经读过count值为10,执行加1写入后依然为11,这样就丢失了一次计数。

例子2:延迟初始化导致的竞态条件

代码语言:javascript复制
@NotThreadSafe
public class LazyInitRace {
  private ExpensiveObject instance = null;

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

分析:在LazyInitRace中包含竞态条件:首先线程A判断instance为null,然后线程B判断instance也为null,之后线程A和线程B分别创建对象,这样对象就进行了两次初始化,发生错误。

其二、复合操作:代码中修改状态数据的“先检查后执行”操作(延迟初始化)和“读取 - 修改 - 写入”(递增运算)操作都统称为符合操作。如果想确保线程安全,那么这么一组操作必须以原子方式执行。

总结:与大多数并发错误一样,竞态条件不总是产生错误。所以,我们日常开发中经常看到类似例子2的单例模式的代码。但是竞态条件确实可能导致严重问题。

3:原子性和线程安全

通过第2点的理解,原子性可以用来描述一组不可拆分的状态数据操作,如果拆分了就会导致线程不安全。

例子3:使用原子类优化线程不安全操作

代码语言:javascript复制
@ThreadSafe
public class CountingFactorizer extends BaseClass implements Servlet {

  private final AtomicLong count = new AtomicLong();

  public AtomicLong getCount() {
    return count;
  }

  @Override
  public void service(ServletRequest req, ServletResponse response) throws ServletException, IOException {
    //获取请求参数
    BigInteger i = extractFromRequest(req);
    //拓展请求参数处理
    BigInteger[] factors = factor(i);
    count.incrementAndGet();
    //设置响应数据
    encodeIntoResponse(response, factors);
  }
}

例4:多个原子操作存在依赖关系导致竞态条件

代码语言:javascript复制
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {

  private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>();
  private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();

  @Override
  public void service(ServletRequest req, ServletResponse response) throws ServletException, IOException {
    //获取请求参数
    BigInteger i = extractFromRequest(req);
    if (i.equals(lastNumber.get())){
      encodeIntoResponse(response, lastFactors.get());
    } else {
      //拓展请求参数处理
      BigInteger[] factors = factor(i);
      lastNumber.set(i);
      lastFactors.set(factors);
      //设置响应数据
      encodeIntoResponse(response, factors);
    }
  }
}

分析:在 UnsafeCachingFactorizer 中存在竞态条件,可能产出错误结果:在 lastFactors 中缓存的因数之积应该等于在 lastNumber 中缓存的数值。

lastNumber 和 lastFactors 变量之间并不是彼此独立的,而是相互约束,因此当更新某个变量时,需要在同一个原子操作中,同时完成对另一个变量的更新。

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

4:加锁机制和线程安全

通过第3点的理解,我们知道即使一个类都使用了线程安全类,也不能确保它是线程安全的。所以,Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)。

例5:优化多个原子类不能实现原子操作的问题

代码语言:javascript复制
@ThreadSafe
public class UnsafeCountingFactorizer implements Servlet {

  private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>();
  private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();

  @Override
  public synchronized void service(ServletRequest req, ServletResponse response) throws ServletException, IOException {
    //获取请求参数
    BigInteger i = extractFromRequest(req);
    if (i.equals(lastNumber.get())){
      encodeIntoResponse(response, lastFactors.get());
    } else {
      //拓展请求参数处理
      BigInteger[] factors = factor(i);
      lastNumber.set(i);
      lastFactors.set(factors);
      //设置响应数据
      encodeIntoResponse(response, factors);
    }
  }

分析:好处是:以关键字 synchronized 修饰的方法就是一个横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象,最终结果就是将一组语句作为不可分割的单元被执行。java中每个对象都有唯一的一个monitor,在Java的设计中,每一个对象自打娘胎里出来,就带了一把看不见的锁,通常我们叫“内部锁”,或者“Monitor锁”。弊端是:多个客户端无法同时使用该Servlet,导致服务响应非常低。

重入锁是另外一个重要概念:当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。

因为 synchronized 内置锁是可重入的,因此如果某个线程试图获得一个已有由它自己持有的锁,那么这个请求就会成功。

例6:因为可重入特性,下面代码才不会发生死锁

重入进一步提升了加锁行为的封装性,因此简化了面向对象并发代码的开发。上述代码中,子类改写了父类的synchronized方法,然后调用父类中的方法,此时如果没有可重入的锁,那么这段代码将产生死锁。

代码语言:javascript复制
public class Singer {
    public synchronized void sing() {
       //...
    }
}
 
public class LoggingSingger extends Singer {
    public synchronized void sing() {
        System.out.println("logging");
        //...
    }
}

重入的一种实现方法:为每个锁关联一个获取计数值和一个所有者线程。

设计思路: 1)当计数器为0时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法; 2)当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为1;此时其它线程请求该锁,则必须等待; 3)而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增; 4)当线程退出同步代码块时,计数器会递减,如果计数器为0,则释放该锁。

5:用锁来保管状态

通过第4点的了解,我们知道了通过合理使用原子类和适当的加锁机制可以实现线程安全,确保状态的一致性。

由于锁能使其保护的代码以串行方式被访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问,我们将这些同步策略统称为“加锁”。

为什么每个对象都有一个内置锁呢?

之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象。而且对象的内置锁大多数与其状态之间没有内在的关联,因此,虽然大多数类都将内置锁用做一种有效的加锁机制,但对象的域并不一定要通过内置锁来保护

《Java并发编程实战》提出了一种常见的加锁约定:

将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。弊端是:如果在新加的方法忘记使用同步,这种加锁协议会很容易被破坏(在许多线程安全类中都使用了这种模式,例如:Vector和其他的同步集合类,这些类也因为性能问题被诟病)。

其他建议:

  • 并非所有数据都需要锁的保护,只有被多个线程同步访问的可变数据才需要。对于每个包含多个变量的不变性条件,其中涉及的所有变量都要使用同一个锁来保护。
  • 虽然 synchronized 能够确保单个操作原子性,但多个操作合并为一个复合操作,还是需要额外的加锁机制。若是每个方法都作为同步方法,甚至导致活跃性问题(Liveness)或性能问题(Performance)。

6:活跃性与性能

通过第5点的理解,我们知道了常见的加锁协议及其利弊,也知道内置锁的同步手段也不是随便用的,它会带来一系列的性能问题与活跃性问题。

在例5中的代码执行性能是非常糟糕的。

由于service是一个synchronized方法,因此每次只有一个线程可以执行。这就背离了Servlet框架的初衷,即Servlet需要能同时处理多个请求,目前这种在负载过高的情况下将给用户带来糟糕的体验。如果Servlet在对某个大数值进行因数分解时需要很长的执行时间,那么其他的客户端必须一直等待,直到Servlet处理完当前的请求,才能开始另一个新运算。

优化手段

  1. 通过缩小同步代码块的作用范围,我们很容易做到既确保Servlet的并发性,同时又维护线程安全性。
  2. 要确保同步代码块不要过小,并且不要将本应是原子的操作拆分到多个同步代码块中。
  3. 应该尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去,从而在这些操作的执行过程中,其他线程可以访问共享状态。

例7:性能优化后的代码

代码语言:javascript复制
@ThreadSafe
public class CachedFactoriser 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();
      }
    }
    if(factors == null) {
      factors = factor(i);
      synchronized (this) {
        lastNumber = i;
        lastFactors = factors.clone();
      }
    }
    encodeIntoResponse(resp,factors);
  }
}

分析:我认为 BigInteger[] factors 局部变量的使用是一手妙招,它巧妙设计为局部变量,用于两个同步代码块之间的同步通信。factors 为空,意味着不需要进行新的同步锁操作(重新计算 factors),这样减少了性能的损耗。

因此,在重构线程安全的代码时,要判断同步代码块的合理大小,需要在各种设计需求之间进行权衡,包括安全性(这个需求必须得到满足)、简单性和性能,在二者之间通常能找到某种合理的平衡。

总结

之前看到一句很受用的话:外修原子,内修可见。确实如此。

《Java并发编程实战》第二章整理起来有点凌乱,因为知识点比较杂,而且随便挑一个点都能发散到很远,因此抓重点是理解原著的意思很关键。综合第1章和第2章,我们应该清楚这本书提供的思路是解决哪些问题的,如何合理运用Java提供的各种同步工具和手段来解决实际问题,就在后面的章节再作探讨了。

—END—

0 人点赞