CH02-线程与锁

虽然该模型稍显原始且难以驾驭、不够可靠还有点危险,但仍然是并发编程的首选项,也是其他并发模型的基石。

对硬件运行过程形式化

该模型其实是对底层硬件运行过程的形式化。这种形式化既是该模型的最大优点、也是其最大的缺点。

该模型非常简单直接,几乎所有编程语言都提供了对该模型的支持,且不对其使用方式加以限制。

互斥与内存模型

互斥:使用锁来保证某一时间仅有一个线程可以访问数据。它会带来竟态条件和死锁。

乱序执行的来源:

  • 编译器和静态优化
  • JVM 动态优化
  • 底层硬件优化

从直觉上来说,编译器、JVM、硬件都不应该修改原有的代码逻辑,但是近几年的运行效率提升,尤其是共享内存架构的运行效率提升,均基于此类代码的优化。

Java 内存模型为这类优化提供了标准。

内存可见性

Java 内存模型定义了一个线程对内存的修改何时对另一个线程可见。基本原则是,如果读线程和写线程不进行同步,就不能保证可见性。

多把锁

很容易得出一个结论:让多线程代码安全运行的方法只能是让所有的代码都同步。但是这么做有两个缺点:

  • 效率低下:如果每个方法都同步,大多数线程会频繁阻塞,也就失去了并发的意义。
  • 死锁:哲学家就餐问题。

总结

  • 对共享变量的所有访问都需要同步化。
  • 读线程、写线程都需要同步化。
  • 按照约定的全局顺序来获取多把锁。
  • 当持有锁时避免调用外部方法(无法确保线程安全性)。
  • 持有锁的时间尽可能的短。

更多同步机制

内置锁的限制

  • 一个线程因为等待内置锁而进入阻塞之后,就无法中断该线程了。
  • 尝试获得内置锁时无法设置超时。
  • 必须通过 synchronized 块来获取内置锁。

可中断的锁

ReentrantLock 提供了显式的加解锁方法,可以在代码的不同位置来实现加解锁逻辑,这是 synchronized 块无法做到的。

同时,ReentrantLock 提供的 lockInterruptibly 方法可以用于终止死锁线程。

超时设置

ReentrantLock 还可以为获取锁的超时设置超时时间。

交替锁

设想我们要在链表插入一个节点,一种做法是用锁保护整个链表,但链表加锁时其他使用者无法访问该链表。而交替锁可以做到仅锁住链表的一部分,允许不涉及被锁部分的其他线程继续自由的访问链表。同样可以由 ReentrantLock 实现。

条件变量

并发编程经常需要等待某个事件发生。比如从队列删除元素前需要等待队列非空、向缓存添加数据前需要等待缓存拥有足够的空间。这时就需要条件变量 Condition。

一个条件变量需要与一把锁关联,线程在开始等待条件之前必须先获取这把锁。获取锁后,线程检查所有等待的条件是否为真。如果条件为真,线程将解锁并继续执行。

如果条件不为真,线程会调优 await 方法,它将原子的解锁并阻塞等待该条件。

当另一个线程调用了 signal 或 signalAll,意味着对应的条件可能已变为真,await 方法将原子的恢复运行并重新加锁。

原子变量

比如 AtomicInteger。与锁相比,原子变量有很多好处。首先,我们不会忘记在正确的时候获取锁;其次,由于没有锁的参与,对原子变量的操作不会引发死锁;最后,原子变量是无锁(lock-free)非阻塞(non-blocking)算法的基础,这种算法可以不使用锁和阻塞来达到同步的目的。

无锁代码比起有锁代码更加复杂,JUC 中的类都尽量使用了无锁代码。

总结

ReentrantLock 和 JUC.atomic 突破了使用内置锁的限制,可以利用它们做到:

  1. 在线程获取锁时将其中断。
  2. 设置线程获取锁时的超时时间。
  3. 按任意顺序获取和释放锁。
  4. 用条件变量来等待某个条件变为真。
  5. 使用原子变量来避免使用锁。

利用已有工具

线程池

比如,编写服务端应用时为每个连接请求创建一个线程,这样存在两个隐患:

  1. 创建线程是有代价的。
  2. 连接数的增长会使得线程数不断增长,而系统资源(如内存)是有限的。

可以使用线程池来对线程进行复用,JUC 提供了各种类型的线程池。

写时复制

比如 CopyOnWriteArrayList,它使用了保护性复制策略。它并非在遍历链表前进行复制,而是在链表被修改时复制,已经投入使用的迭代器会使用当时的旧副本。

其他概念

  • 使用线程构建“生产者——消费者模型”。
  • 毒丸(Poison Pill) 是一个特殊对象,告诉消费者“数据已取完,你可以退出了”。
  • 使用线程构建“单生产者——多消费者模型”。
  • 使用并发集合汇总多个消费者并发生成的结果。
  • 使用线程池来优化线程的使用。
  • 使用 ConcurrentHashMap 的分段锁优势,避免过多线程对单个资源的过度竞争。
  • 为各个消费者提供各自的结构缓存,最后再汇总这些缓存,以避免没有必要的数据竞争。

总结

  • 使用线程池,而不是直接创建线程。
  • 使用写时复制让监听器先关的代码更简单高效。
  • 使用同步队列构建生产者消费者模型。
  • ConcurrentHashMap 提供了更好的并发访问。

本章总结

优点

  • 适用面广,是许多其他技术的基础,更加接近于本质——近似对硬件工作方式的形式化,真确应用可以得到很高的效率。能够解决从小达到不同粒度的问题。
  • 该模型可以被集成到大多数编程语言中。语言设计者可以轻易让一门指令式语言或 OO 语言支持该模型。

缺点

  • 该模型没有为并行提供直接的支持。
  • 该模型仅支持共享内存模型。如果要支持分布式内存模型则需要借助其他工具。
  • 最大的缺点在于“无助”,应用开发者在编程语言层面没有得到足够的帮助。

隐性错误

应用多线程的难点不在编程,而在于难以测试。而测试中的一个大问题是难以复现。

随着项目的迭代和时间的流式,复杂的多线程代码会变得难以维护。