Java 并发编程学习总结

什么是并发编程,简单来说就是为了充分利用cpu,多个任务同时执行,快速完成任务。

并发编程相关的概念和技术看上非常零散,相关度也很低,想要学习好并发编程,可以从下面两方面入手:一是建立全景图,从细节“跳出来,看全景”,另一个是深挖细节,也就是“钻进去,看本质”。

其实不止是并发编程的学习,任何的知识的学习都是一样的。在学习的时候,要充分利用网上已有的知识体系,比如计算机网络的分层,没必要自己再去重新分层。对于网上没有的知识体系,自己要学会进行抽象和总结,建立方便自己理解的全景图。

对于并发编程领域,借用王宝令老师的总结,可以抽象成三个核心问题:分工、同步和互斥。

分工

所谓分工,类似于现实中一个组织完成一个项目,项目经理要拆分任务,安排合适的成员去完成。

在并发领域里,分工很重要,它直接决定了并发程序的性能。在现实世界里,分工是很复杂的,著名数学家华罗庚曾用“烧水泡茶”的例子通俗地讲解了统筹方法(一种安排工作进程的数学方法),“烧水泡茶”这么简单的事情都这么多说道,更何况是并发编程里的工程问题呢。

既然分工很重要又很复杂,那一定有前辈努力尝试解决过,并且也一定有成果。的确,在并发编程领域这方面的成果还是很丰硕的。Java SDK 并发包里的 Executor、Fork/Join、Future 本质上都是一种分工方法。除此之外,并发编程领域还总结了一些设计模式,基本上都是和分工方法相关的,例如生产者 - 消费者、Thread-Per-Message、Worker Thread 模式等都是用来指导你如何分工的。

同步

分好工之后,就是具体执行了。在并发编程领域里的同步,主要指的就是线程间的协作,本质上和现实生活中的协作没区别,不过是一个线程执行完了一个任务,如何通知执行后续任务的线程开工而已。

协作一般是和分工相关的。Java SDK 并发包里的 Executor、Fork/Join、Future 本质上都是分工方法,但同时也能解决线程协作的问题。例如,用 Future 可以发起一个异步调用,当主线程通过 get() 方法取结果时,主线程就会等待,当异步执行的结果返回时,get() 方法就自动返回了。主线程和异步线程之间的协作,Future 工具类已经帮我们解决了。除此之外,Java SDK 里提供的 CountDownLatch、CyclicBarrier、Phaser、Exchanger 也都是用来解决线程协作问题的。

在 Java 并发编程领域,解决协作问题的核心技术是管程,上面提到的所有线程协作技术底层都是利用管程解决的。管程是一种解决并发问题的通用模型,除了能解决线程协作问题,还能解决下面我们将要介绍的互斥问题。可以这么说,管程是解决并发问题的万能钥匙。

管程,对应的英文是 Monitor,很多 Java 领域的同学都喜欢将其翻译成“监视器”,这是直译。操作系统领域一般都翻译成“管程”,这个是意译,而我自己也更倾向于使用“管程”。

所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。翻译为 Java 领域的语言,就是管理类的成员变量和成员方法,让这个类是线程安全的。那管程是怎么管的呢?

Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。具体如下图所示。

Java 中的管程示意图

Java 内置的管程方案(synchronized)使用简单,synchronized 关键字修饰的代码块,在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量;而 Java SDK 并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操作。

并发编程里两大核心问题——互斥和同步,都可以由管程来帮你解决。学好管程,理论上所有的并发问题你都可以解决,并且很多并发工具类底层都是管程实现的,所以学好管程,就是相当于掌握了一把并发编程的万能钥匙。

这部分内容的学习,关键是理解管程模型,学好它就可以解决所有问题。其次是了解 Java SDK 并发包提供的几个线程协作的工具类的应用场景,用好它们可以妥妥地提高你的工作效率。

互斥

分工、同步主要强调的是性能,但并发程序里还有一部分是关于正确性的,用专业术语叫“线程安全”。导致不确定的主要源头是可见性问题、有序性问题和原子性问题,关于这三个问题,也可以参考文章 可见性、原子性和有序性问题:并发编程Bug的源头。为了解决这三个问题,Java 语言引入了内存模型,可以参考 Java 内存模型  一文。内存模型提供了一系列的规则来规范如何去访问变量,利用这些规则,我们可以避免可见性问题、有序性问题,但是还不足以完全解决线程安全问题。解决线程安全问题的核心方案还是互斥。

所谓互斥,指的是同一时刻,只允许一个线程访问共享变量。

实现互斥的核心技术就是锁,Java 语言里 synchronized、SDK 里的各种 Lock 都能解决互斥问题。虽说锁解决了安全性问题,但同时也带来了性能问题,那如何保证安全性的同时又尽量提高性能呢?

第一,Java SDK 里提供的 ReadWriteLock、StampedLock 就可以优化读多写少场景下锁的性能。

第二,既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。在这方面有很多相关的技术,例如线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等;Java 并发包里面的原子类也是一种无锁的数据结构;Disruptor 则是一个无锁的内存队列,性能都非常好……具体可以参考 Java CAS 原理详解 来详细了解无所化是如何实现的。

第三,减少锁持有的时间。互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。这个方案具体的实现技术也有很多,例如使用细粒度的锁,一个典型的例子就是 Java 并发包里的 ConcurrentHashMap,它使用了所谓分段锁的技术(这个技术后面我们会详细介绍);还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。

性能方面的度量指标有很多,我觉得有三个指标非常重要,就是:吞吐量、延迟和并发量。

  1. 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。

  2. 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。

  3. 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。

除此之外,还有一些其他的方案,原理是不共享变量或者变量只允许读。这方面,Java 提供了 ThreadLocal 和 final 关键字,还有一种 Copy-on-write 的模式,其实  Copy-on-write 模式最早是出现在操作系统中的,当 fork 一个新的进程的时候,没必要把所有的东西都拷贝,先让两个进程进行共享,只有在写入数据的时候在进行拷贝和分离,这样会减少cpu的损耗,以及节省时间 。

使用锁除了要注意性能问题外,还需要注意死锁问题。具体可以参考文章实例详解 Java 死锁与破解死锁

还有就是线程封闭。

线程方法里的局部变量,因为不会和其他线程共享,所以没有并发问题,这个思路很好,已经成为解决并发问题的一个重要技术,同时还有个响当当的名字叫做线程封闭,比较官方的解释是:仅在单线程内访问数据。由于不存在共享,所以即便不同步也不会有并发问题,性能杠杠的。

采用线程封闭技术的案例非常多,例如从数据库连接池里获取的连接 Connection,在 JDBC 规范里并没有要求这个 Connection 必须是线程安全的。数据库连接池通过线程封闭技术,保证一个 Connection 一旦被一个线程获取之后,在这个线程关闭 Connection 之前的这段时间里,不会再分配给其他线程,从而保证了 Connection 不会有并发问题。

这部分内容比较复杂,往往还是跨领域的,例如要理解可见性,就需要了解一些 CPU 和缓存的知识;要理解原子性,就需要理解一些操作系统的知识;很多无锁算法的实现往往也需要理解 CPU 缓存。这部分内容的学习,需要博览群书,在大脑里建立起 CPU、内存、I/O 执行的模拟器。这样遇到问题就能得心应手了。 

 如何写好并发程序

那如何才能用面向对象思想写好并发程序呢?可以从封装共享变量、识别共享变量间的约束条件和制定并发访问策略这三个方面下手。

一、封装共享变量

并发程序,我们关注的一个核心问题,不过是解决多线程同时访问共享变量的问题。我们类比过球场门票的管理,现实世界里门票管理的一个核心问题是:所有观众只能通过规定的入口进入,否则检票就形同虚设。在编程世界这个问题也很重要,编程领域里面对于共享变量的访问路径就类似于球场的入口,必须严格控制。好在有了面向对象思想,对共享变量的访问路径可以轻松把控。

面向对象思想里面有一个很重要的特性是封装,封装的通俗解释就是将属性和实现细节封装在对象内部,外界对象只能通过目标对象提供的公共方法来间接访问这些内部属性,这和门票管理模型匹配度相当的高,球场里的座位就是对象属性,球场入口就是对象的公共方法。我们把共享变量作为对象的属性,那对于共享变量的访问路径就是对象的公共方法,所有入口都要安排检票程序就相当于我们前面提到的并发访问策略。

利用面向对象思想写并发程序的思路,其实就这么简单:将共享变量作为对象属性封装在内部,对所有公共方法制定并发访问策略。就拿很多统计程序都要用到计数器来说,下面的计数器程序共享变量只有一个,就是 value,我们把它作为 Counter 类的属性,并且将两个公共方法 get() 和 addOne() 声明为同步方法,这样 Counter 类就成为一个线程安全的类了。

public class Counter {
  private long value;
  synchronized long get(){
    return value;
  }
  synchronized long addOne(){
    return ++value;
  }
}
当然,实际工作中,很多的场景都不会像计数器这么简单,经常要面临的情况往往是有很多的共享变量,例如,信用卡账户有卡号、姓名、身份证、信用额度、已出账单、未出账单等很多共享变量。这么多的共享变量,如果每一个都考虑它的并发安全问题,那我们就累死了。但其实仔细观察,你会发现,很多共享变量的值是不会变的,例如信用卡账户的卡号、姓名、身份证。对于这些不会发生变化的共享变量,建议你用 final 关键字来修饰。这样既能避免并发问题,也能很明了地表明你的设计意图,让后面接手你程序的兄弟知道,你已经考虑过这些共享变量的并发安全问题了。

二、识别共享变量间的约束条件

识别共享变量间的约束条件非常重要。因为这些约束条件,决定了并发访问策略。例如,库存管理里面有个合理库存的概念,库存量不能太高,也不能太低,它有一个上限和一个下限。关于这些约束条件,我们可以用下面的程序来模拟一下。在类 SafeWM 中,声明了两个成员变量 upper 和 lower,分别代表库存上限和库存下限,这两个变量用了 AtomicLong 这个原子类,原子类是线程安全的,所以这两个成员变量的 set 方法就不需要同步了。

public class SafeWM {
  // 库存上限
  private final AtomicLong upper =
        new AtomicLong(0);
  // 库存下限
  private final AtomicLong lower =
        new AtomicLong(0);
  // 设置库存上限
  void setUpper(long v){
    upper.set(v);
  }
  // 设置库存下限
  void setLower(long v){
    lower.set(v);
  }
  // 省略其他业务代码
}

虽说上面的代码是没有问题的,但是忽视了一个约束条件,就是库存下限要小于库存上限,这个约束条件能够直接加到上面的 set 方法上吗?我们先直接加一下看看效果(如下面代码所示)。我们在 setUpper() 和 setLower() 中增加了参数校验,这乍看上去好像是对的,但其实存在并发问题,问题在于存在竞态条件。这里我顺便插一句,其实当你看到代码里出现 if 语句的时候,就应该立刻意识到可能存在竞态条件。

我们假设库存的下限和上限分别是 (2,10),线程 A 调用 setUpper(5) 将上限设置为 5,线程 B 调用 setLower(7) 将下限设置为 7,如果线程 A 和线程 B 完全同时执行,你会发现线程 A 能够通过参数校验,因为这个时候,下限还没有被线程 B 设置,还是 2,而 5>2;线程 B 也能够通过参数校验,因为这个时候,上限还没有被线程 A 设置,还是 10,而 7<10。当线程 A 和线程 B 都通过参数校验后,就把库存的下限和上限设置成 (7, 5) 了,显然此时的结果是不符合库存下限要小于库存上限这个约束条件的。

public class SafeWM {
  // 库存上限
  private final AtomicLong upper =
        new AtomicLong(0);
  // 库存下限
  private final AtomicLong lower =
        new AtomicLong(0);
  // 设置库存上限
  void setUpper(long v){
    // 检查参数合法性
    if (v < lower.get()) {
      throw new IllegalArgumentException();
    }
    upper.set(v);
  }
  // 设置库存下限
  void setLower(long v){
    // 检查参数合法性
    if (v > upper.get()) {
      throw new IllegalArgumentException();
    }
    lower.set(v);
  }
  // 省略其他业务代码
}
在没有识别出库存下限要小于库存上限这个约束条件之前,我们制定的并发访问策略是利用原子类,但是这个策略,完全不能保证库存下限要小于库存上限这个约束条件。所以说,在设计阶段,我们一定要识别出所有共享变量之间的约束条件,如果约束条件识别不足,很可能导致制定的并发访问策略南辕北辙。

共享变量之间的约束条件,反映在代码里,基本上都会有 if 语句,所以,一定要特别注意竞态条件。

三、制定并发访问策略

制定并发访问策略,是一个非常复杂的事情。应该说整个专栏都是在尝试搞定它。不过从方案上来看,无外乎就是以下“三件事”。

  1. 避免共享:避免共享的技术主要是利于线程本地存储以及为每个任务分配独立的线程。

  2. 不变模式:这个在 Java 领域应用的很少,但在其他领域却有着广泛的应用,例如 Actor 模式、CSP 模式以及函数式编程的基础都是不变模式。

  3. 管程及其他同步工具:Java 领域万能的解决方案是管程,但是对于很多特定场景,使用 Java 并发包提供的读写锁、并发容器等同步工具会更好。

接下来在咱们专栏的第二模块我会仔细讲解 Java 并发工具类以及他们的应用场景,在第三模块我还会讲解并发编程的设计模式,这些都是和制定并发访问策略有关的。

除了这些方案之外,还有一些宏观的原则需要你了解。这些宏观原则,有助于你写出“健壮”的并发程序。这些原则主要有以下三条。

  1. 优先使用成熟的工具类:Java SDK 并发包里提供了丰富的工具类,基本上能满足你日常的需要,建议你熟悉它们,用好它们,而不是自己再“发明轮子”,毕竟并发工具类不是随随便便就能发明成功的。

  2. 迫不得已时才使用低级的同步原语:低级的同步原语主要指的是 synchronized、Lock、Semaphore 等,这些虽然感觉简单,但实际上并没那么简单,一定要小心使用。

  3. 避免过早优化:安全第一,并发程序首先要保证安全,出现性能瓶颈后再优化。在设计期和开发期,很多人经常会情不自禁地预估性能的瓶颈,并对此实施优化,但残酷的现实却是:性能瓶颈不是你想预估就能预估的。

 

相关文章

实例详解 Java 死锁与破解死锁

Java 内存模型 

可见性、原子性和有序性问题:并发编程Bug的源头

Java CAS 原理详解

深入详解 Java 线程

Java 并发编程学习总结

ThreadPoolExcutor 原理探究

synchronized(this) 与synchronized(class) 之间的区别

简述伪共享和缓存一致性MESI

 

posted @ 2022-02-28 11:06  huansky  阅读(852)  评论(0编辑  收藏  举报