<Java Concurrency in Practice> 读书笔记 - Part1: 基础部分

Table of Contents

第1章 简介

线程的优势

  • 发挥多处理器的强大能力
  • 建模的简单性
  • 异步事件的简化处理
  • 响应更灵敏的用户界面

线程带来的风险

  • 安全性问题: 在没有充足同步的情况下, 多个线程中的操作执行顺序是不可预测的.
  • 活跃性问题: 线程A在等待线程B释放其持有的资源, 而线程B永远都不释放该资源, 那么A就会永久的等待下去.
  •  性能问题: 多线程之间频繁出现上下文切换操作; CPU时间将更多地花在线程调度而不是线程运行上.

线程无处不在

框架通过在框架线程中调用应用程序代码将并发性引入到程序中. 在代码中将不可避免地访问应用程序状态, 因此所有访问这些状态的代码路径都必须是线程安全的.

第2章 线程安全性

如果当多个线程访问同一个可变的状态变量时没有使用合适的同步, 那么程序就会出现错误. 有三种方式可以修复这个问题:
  - 不在线程之间共享该状态变量
  - 将状态变量修改为不可变的变量
  - 在访问状态变量时使用同步
当设计线程安全的类时, 良好的面向对象技术, 不可修改性, 以及明晰的不变性规范都能起到一定的帮助作用.

什么是线程安全性

当多个线程访问某个类时, 不管运行时环境采用何种调度方式或者这些线程将如果交替执行, 并且在主调代码中不需要任何额外的同步或协同, 这个类都能表现出正确的行为, 那么就称这个类是线程安全的.
在线程安全类中封装了必要的同步机制, 因此客户端无需进一步采取同步措施.
无状态对象一定是线程安全的.

原子性

假定有两个操作A和B, 如果从执行A的线程来看, 另一个线程执行B时, 要么将B全部执行完, 要么完全不执行B, 那么A和B对彼此来说是原子的.
原子操作是指, 对于访问同一个状态的所有操作(包括该操作本身)来说, 这个操作是一个以原子方式执行的操作.
在实际情况中, 应尽可能地使用现有的线程安全对象(例如AtomicLong)来管理类的状态. 与非线程安全的对象相比, 判断线程安全对象的可能状态及其状态转换情况要更为容易, 从而也更容易维护和验证线程安全性.

加锁机制

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

关键字 synchronized 是 Java 提供的一种内置的锁机制来支持原子性. 同步代码块包括两部分: 一个作为锁的对象引用, 一个作为由这个锁保护的代码块.
每个 Java 对象都可以用做一个实现同步的锁, 这些锁被称为内置锁或监视器锁. 线程在进入同步代码块之前会自动获得锁, 并且在退出同步代码块时自动释放锁, 而无论是通过正常的控制路径退出, 还是通过从代码块中抛出异常退出. 获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法.
Java 内置锁是一种互斥锁, 最多只有一个线程能持有这种锁. 而且是可重入的, 因此简化了面向对象并发代码的开发.
Java 内置锁的机制实际跟 ReentrantLock 的非公平模式一样.

用锁保护状态

对于可能被多个线程同时访问的可变状态变量, 在访问它时都需要持有同一个锁, 在这种情况下, 我们称状态变量是由这个锁保护的.
每个共享的和可变的变量都应该只由一个锁来保护, 从而使维护人员知道是哪一个锁.
对于每个包含多个变量的不变性条件, 其中涉及的所有变量都需要由同一个锁来保护.

活跃性和性能

通常, 在简单性和性能之间存在着相互制约因素. 当实现某个同步策略时, 一定不要盲目地为了性能而牺牲简单性(这可能会破坏安全性).
当执行时间较长的计算或者可能无法快速完成的操作时(例如, 网络I/O或控制台I/O), 一定不要持有锁.

第3章 对象的共享

可见性

在没有同步的情况下, 编译器, 处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整. 在缺乏足够同步的多线程程序中, 要想对内存操作的执行顺序进行判断, 几乎无法得到正确的结论.

当线程在没有同步的情况下读取变量时, 可能会得到一个失效值, 但至少这个值是由之前某个线程设置的值, 而不是一个随机值.
这种安全性保证也被称为最低安全性. 但有一个例外: 非 volatile 类型的64位数值变量(long 和 double).
Java 内存模型要求, 变量的读取操作和写入操作都必须是原子操作, JVM 允许将64位的读操作或写操作分解为两个32位操作.
如果对该类型的变量的读操作和写操作在不同的线程中执行, 那么很可能会督导某个值的高32位和另一个值的低32位.

加锁的含义不仅仅局限于互斥行为, 还包括内存可见性. 为了确保所有线程都能看到共享变量的最新值, 所有执行读操作或者写操作的线程都必须在同一个锁上同步.

Java语言提供一种稍弱的同步机制, 即 volatile 变量, 用来确保将变量的更新操作通知到其他线程. 当把变量声明为 volatile 类型后, 编译器和运行时都会注意到这个变量是共享的, 因此不会将该变量上的操作与其他内存操作一起重排序. volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方. 因此在读取 volatile 类型的变量时总会返回最新写入的值.
在访问 volatile 变量时不会执行加锁操作, 因此也就不会使线程阻塞, 因此 volatile 变量是一种比 sychronized 关键字 更轻量级 的同步机制.

仅当 volatile 变量能简化代码的实现以及对同步策略的验证时, 才应该使用它们. 如果在验证正确性时需要对可见性进行复杂的判断, 那么就不要使用 volatile 变量.
volatile 变量的正确使用方式包括: 确保它们自身状态的可见性, 确保它们锁引用对象的状态的可见性, 以及标识一些重要的程序生命周期事件的发生(例如, 初始化或关闭).
加锁机制即可以确保可见性又可确保原子性, 而 volatile 变量只能确保可见性

当且仅当满足以下所有条件时, 才应该使用 volatile 变量:

  • 对变量的写入操作不依赖变量的当前值, 或者你能确保只有单个线程更新变量的值.
  • 该变量不会与其他状态变量一起纳入不变性条件中.
  • 在访问变量时不需要加锁.

发布与逸出

“发布”一个对象的意思是指, 使对象能够在当前作用域之外的代码中使用.
当某个不应该发布的对象被发布时, 这种情况就被称为“逸出”.

不要在构造过程中使 this 引用逸出.

线程封闭

如果仅在单线程内访问数据, 就不需要同步. 这种技术被称为 线程封闭, 它是实现线程安全性的最简单方式之一.

  • Ad-hoc 线程封闭: 维护线程封闭性的职责完全由程序实现来承担
  • 栈封闭: 局部变量位于执行线程的栈中, 其他线程无法访问这个栈.
  • ThreadLocal类: 这个类能使线程中的某个值与保存值的对象关联起来. 相当于线程的全局变量.

不变性

不可变对象一定是线程安全的.
当满足以下条件时, 对象才是不可变的:
- 对象创建以后其状态就不能修改.
- 对象的所有域都是 final 类型.
- 对象是正确创建的(在对象的创建期间, this引用没有逸出).

关键字 final 用于构造不可变性对象. final类型的域是不能修改的.
然而在 Java 内存模型中, final 域还有着特殊的语义: final 域能确保初始化过程中的安全性, 从而可以不受限制地访问不可变对象, 并在共享这些对象时无需同步.

正如"除非需要更高的可见性, 否则应将所有的域都声明为私有域"是一个良好的编程习惯,
"除非需要某个域是可变的, 否则应该将其声明为 final 域"也是一个良好的编程习惯.

安全发布

任何线程都可以在不需要额外同步的情况下安全地访问不可变对象, 即使在发布这些对象时没有使用同步.
要安全地发布一个对象, 对象的引用以及对象的状态必须同时对其他线程可见. 一个正确构造的对象可以通过以下方式来安全地发布:
- 在静态初始化函数中初始化一个对象引用.
- 将对象的引用保存到 volatile 类型的域 或者 AtomicReferance 对象中.
- 将对象的引用保存到某个正确构造对象的 final 类型域中.
- 将对象的引用保存到一个由锁保护的域中.

通常, 要发布一个静态构造的对象, 最简单和最安全的方式是使用 静态的初始化器, 它是由 JVM 在类的初始化阶段执行.
由于在 JVM 内部存在着同步机制, 因此通过这种方式初始化的任何对象都可以被安全地发布.

事实不可变对象: 对象从技术上来看是可变的, 但其状态在发布后不会再改变.

在没有额外的同步的情况下, 任何线程都可以安全地使用被安全发布事实不可变对象.
对象的发布需求取决于它的可变性:
- 不可变对象可以通过任意机制来发布.
- 事实不可变对象必须通过安全方式来发布.
- 可变对象必须通过安全方式来发布, 并且必须是线程安全的或者由某个锁保护起来.
在并发程序中使用和共享对象时, 可以使用一些使用的策略, 包括:
  线程封闭: 线程封闭的对象只能由一个线程拥有, 对象被封闭在该线程中, 并且只能由这个线程修改.
  只读共享: 在没有额外同步的情况下, 共享的只读对象可以由多个线程并发访问, 但任何线程都不能修改它. 共享的只读对象包括不可变对象和事实不可变对象.
线程安全共享: 线程安全的对象在其内部实现同步, 因此多个线程可以通过对象的共有接口来进行访问而不需要进一步的同步.
  保护对象: 被保护的对象只能通过持有特定的锁来访问. 保护对象包括封装在其他线程安全对象中的对象, 以及已发布的并且由某个特定锁保护的对象.

第4章 对象的组合

设计线程安全的类

在设计线程安全类的过程中, 需要包含以下三个基本要素:
- 找出构成对象状态的所有变量.
- 找出约束状态变量的不变性条件.
- 建立对象状态的并发访问管理策略
如果不了解对象的不变性条件与后验条件, 那么就不能保证线程安全性.
要满足在状态变量的有效值或状态切换上的各种约束条件, 就需要借助于原子性与封装性.

实例封闭

如果某对象不是线程安全的, 那么可以通过多种技术使其在多线程程序中安全地使用.
你可以确保该对象只能由单个线程访问(线程封闭), 或者通过一个锁来保护对该对象的所有访问.

将数据封装在对象内部, 可以将数据的访问限制在对象的方法上, 从而更容易确保线程在访问数据时总能持有正确的锁.
封闭机制更易于构造线程安全的类, 因为当封闭类的状态时(L: 这一小句没有看懂), 在分析类的线程安全性时, 就无需检查整个程序.

线程安全性的委托

如果一个类是由多个独立且线程安全的状态变量组成, 并且在所有的操作中都不包含无效状态转换, 那么可以将线程安全性委托给底层的状态变量.
如果一个状态变量是线程安全的, 并且没有任何不变性条件来约数它的值, 在变量的操作上也不存在任何不允许的状态转换, 那么就可以安全地发布这个变量.

在现有的线程安全类中添加功能

为现有的类添加一个原子操作, 更好的方法是 组合.
使用 Java 监视器模式来封装现有的类, 并且只要在类中拥有指向底层类的唯一外部引用, 就能确保线程安全性.

将同步策略文档化

在文档中说明客户端代码需要了解的线程安全性保证, 以及代码维护人员需要了解的同步策略

在设计同步策略时要考虑多个方面, 例如, 将哪些变量声明为 volatile 类型, 哪些变量用锁来保护, 哪些锁保护哪些变量, 哪些变量必须是不可变的或者被封闭在线程中的, 哪些操作必须原子操作等.

如果某个类没有明确地声明是线程安全的, 那么就不要假设它是线程安全的.

第5章 基础构建模块

同步容器类

在设计容器类的迭代器时并没有考虑到并发修改的问题, 并且它们表现出的行为是“及时失败”的.
这意味着, 当它们发现容器在迭代过程中被修改时, 就会抛出一个 ConcurrentModificationException 异常.

正如封装对象的状态有助于维持不变性条件一样, 封装对象的同步机制同样有助于确保实施同步策略.

并发容器

非线程安全 线程安全
ArrayList CopyOnWriteArrayList
Queue ConcurrentLinkedQueue
Deque ConcurrentLinkedDeque
HashMap ConcurrentHashMap
SortedMap ConcurrentSkipListMap
SortedSet ConcurrentSkipListSet
通过并发容器来代替同步容器, 可以极大地提高伸缩性并降低风险.

ConcurrentHashMap 使用了一种完全不同的加锁策略来提供更高的并发性和伸缩性.
ConcurrentHashMap 并不是将每个方法都在同一个锁上同步并使得每次只能有一个线程访问容器, 而是使用一种粒度更细的加锁机制来实现更大程度的共享, 这种机制称为 分段锁.
在这种机制下, 任意数量的读取线程可以并发地访问 Map, 执行读取操作的线程和执行写入操作的线程可以并发地访问Map, 并且一定数量的写入线程可以并发地修改 Map.
ConcurrentHashMap 带来的结果是, 在并发访问环境下将实现更高的吞吐量, 而在单线程环境中只损失非常小的性能.
ConcurrentHashMap 返回的迭代器具有 弱一致性, 而并非“及时失败”. 弱一致性的迭代器可以容忍并发的修改, 当创建迭代器时会遍历已有的元素, 并可以(但是不保证)在迭代器被构造后将操作修改操作反映给容器.

阻塞队列和生产者 - 消费者模式

阻塞队列提供了可阻塞的 put 和 take 方法, 以及支持定时的 offer 和 poll 方法.
如果队列已经满了, 那么 put 方法将阻塞直到有空间可用; 如果队列为空, 那么 take 方法将会阻塞直到有元素可用.
队列可以是有界的也可以是无界的, 无界队列永远都不会充满, 因此无界队列上的 put 方法也永远不会阻塞.

BlockingQueue 简化了生产者 - 消费者设计的实现过程, 它支持任意数量的生产者和消费者.
一种最常见的生产者 - 消费者设计模式就是线程池与工作队列的组合, 在 Executor 任务执行框架中就体现了这种模式.

在构建高可靠的应用程序时, 有界队列是一种强大的资源管理工具: 它们能抑制并防止产生过多的工作项, 使应用程序在负荷过载的情况下变得更加健壮.

类库中 BlockingQueue 的实现:

  • ArrayBlockingQueue,FIFO, 内部数组存储, 必须有界, 加入和移除用的是同一个锁.
  • LinkedBlockingQueue, FIFO, 内部链表存储, 可以有界或无界, 加入和移除用的是不同的锁.
  • PriorityBlockingQueue, 优先级排序的队列.
  • SynchronousQueue, 没有存储空间, 内部维护一组线程, 这些线程在等待着把元素加入或移除队列.

阻塞方法与中断方法

线程可能会阻塞或暂停执行, 当线程阻塞时, 它通常被挂起, 并处于某种阻塞状态(BLOCKED, WAITING 或 TIMEDWAITING).
阻塞操作与执行时间长的普通操作的差别在于, 被阻塞的线程必须等待某个不受它控制的事件发生后才能继续执行.

当某方法抛出 InterruptedException 时, 表示该方法是一个阻塞方法, 如果这个方法被中断, 那么它将努力提前结束阻塞状态.

同步工具类

  • 闭锁: 可以延时线程的进度直到其到达终止状态
    • CountDownLatch 是一种灵活的闭锁实现. 它包含一个计数器, 该计数器被初始化一个正数, 表示需要等待的事件数量.
      countDown() 递减计数器, 表示有一个事件已经发生了, await() 等待计数器达到零, 这表示所有需要等待的事件都已经发生.
    • FutureTask 也可以用作做锁. (FutureTask 实现了 Future 语义, 表示一种抽象的可生成结果的计算).
      get 的行为取决于任务的状态. 如果任务已经完成, 那么 get 会立即返回结果, 否则 get 将阻塞直到任务进入完成状态, 然后返回结果或者抛出异常.
  • 信号量: 用来控制同时访问某个特定资源的操作数量, 或者同时执行某个指定操作的数量.
    Semaphore 中管理着一组虚拟的许可, 许可的初始数量可通过构造函数来指定. 在执行操作时可以受限获得许可(只要还有剩余的许可), 并在使用以后释放许可.
    如果没有许可, 那么 acquire 将阻塞直到有许可(或者直到被中断或者操作超时). release 方法将返回一个许可给信号量.
    初始值为1的 Semaphore 是二值信号量, 可以用做 互斥体(mutex), 并具备不可重入的加锁语义.
  • 栅栏: 类似于闭锁, 它能一阻塞一组线程直到某个事件发生. 闭锁用来等待事件, 栅栏用于等待其他线程.
    • CyclicBarrier 可以使一定数量的参与方反复地在栅栏位置汇集, 它在运行迭代算法中非常有用: 这种算法通常将一个问题拆分成一系列相互独立的子问题.
      当线程到达栅栏位置时将调用 await 方法, 这个方法将阻塞直到所有线程都到达栅栏位置. 如果所有线程都到达了栅栏位置, 那么栅栏将打开, 此时所有线程都被释放, 而栅栏将被重置以便下次使用.
      如果对 await 的调用超时, 或者 await 阻塞的线程被中断, 那么栅栏就被认为是打破了, 所有阻塞的 await 调用都将终止并抛出 BrokenBarrierException.
    • Exchanger 是一种两方栅栏, 各方在栅栏位置上交换数据.
      当一个线程向缓冲区写入数据, 而另一个线程从缓冲区读取数据时, 可以用 Exchanger 来汇合, 并将满的缓冲区与空的缓冲区交换.

本部分小结

  • 可变状态是至关重要的
    所有的并发问题都可以归结为如何协调对并发状态的访问. 可变状态越少, 就越容易确保线程安全性.
  • 尽量将域声明为 final 类型, 除非需要它们是可变的.
  • 不可变对象一定是线程安全的.
    不可变对象能极大地降低并发编程的复杂性. 它们更为简单而且安全, 可以任意共享而无须使用加锁或保护性复制等机制.
  • 封装有助于管理复杂性.
    在编写线程安全的程序时, 虽然可以将所有数据都保存在全局变量中, 但为什么要这样做?
    将数据封装在对象中, 更易于维持不变性条件: 将同步机制封装在对象中, 更易于遵循同步策略.
  • 用锁来保护每个可变变量
  • 当保护同一个不变性条件中的所有变量时, 要使用同一个锁.
  • 在执行复合操作期间, 要持有锁.
  • 如果从多个线程中访问同一个可变变量时没有同步机制, 那么程序会出现问题.
  • 不要故作聪明地推断出不需要使用同步.
  • 在设计过程中考虑线程安全, 或者在文档中明确地指出它不是线程安全的.
  • 将同步策略文档化.

Author: Saul Lawliet

Created: 2022-03-21 Mon 15:34