Java 并发编程实战读书笔记
Java 并发编程实战 (Java Concurrency in Practice) 是一本介绍 Java 并发编程知识的优秀书籍,我阅读几遍后觉得里面的知识如满天繁星, 虽然作者在书中穿插了很多总结,但我觉得他没有想完全体系化地来总结 Java 并发编程,本文我试图体系化的来总结一下 Java 并发编程,将这些知识安放到对应的地方, 成体系的知识更容易记忆,也更容易应用。
既然说到并发,那我们首先就要问什么是并发?这需要回顾计算机的发展历史。
计算机的发明是源于人们对计算的需求,早期的计算就是很狭隘的数学计算,现在已经泛化。
在早期的计算机中不包含操作系统,它们从头到尾只执行一个程序,并且这个程序能访问计算机中的所有资源。在这种裸机环境中,不仅很难编写和运行程序, 而且每次只能运行一个程序,这对于昂贵并且稀有的计算机资源来说也是一种浪费。
那么如何避免这种浪费呢?我们自然而然会想到如果能同时运行多个程序那不就可以避免浪费。计算机先驱自然也想到了这个想法,于是诞生了操作系统。
操作系统的出现使得计算机每次能运行多个程序,并且不同的程序都在单独的进程中运行:操作系统为各个独立执行的进程分配各种资源,包括内存,文件句柄以及安全证书等。 如果需要的话,在不同的进程之间可以通过一些粗粒度的通信机制来交换数据,包括:套接字、信号处理器、共享内存、信号量以及文件等。
促使进程出现的因素(资源利用率、公平性以及便利性等)同样也促使着线程的出现。线程允许在同一个进程中同时存在多个程序控制流。 线程提供了一种直观的分解模式来充分利用多处理器系统中的硬件并行性,而在同一个程序中的多个线程也可以被同时调度到多个CPU上运行。
并发这个词的字面意思是同时发生,只是这个同时发生是在用户的角度来看,在操作系统这一侧则可能是通过时间片或者调度到不同的处理器核来实现的。 这种调度对应就是线程模型,所以并发本质上就是通过使用线程模型来封装我们的计算任务来让它同时发生。
多个线程同时访问同一数据,由于现代计算机硬件结构,结果可能出错,Java 的并发体系可以说是围绕解决这个问题产生的。
人们对泛化计算的需求促使计算机技术快速发展,刚开始主要是通过提高芯片时钟频率来提供性能,后来这么做越来越困难,处理器生产厂商转变成在单个芯片上放置多个处理器核。
无论是单核分时来实现并发,还是多核并行调度,都需要解决多个线程同时访问同一数据可能出错的问题。那么 Java 的设计者们会如何解决的呢?
我们不妨想象一下,如果你是 Java 语言的设计者,你会怎么做?大概率会先从成熟的计算机技术中去找解决方案,实在没有时再考虑创造新的解决方案。 加锁就是其中一种,另一种是基于硬件支持的比较并交换指定。 Java 的并发体系就是按两种解决方案生长。
多线程应该说主要有两类需求:一是同步;二是通信; 在 Java 5 之前,线程的同步主要是基于内置锁,通信则是内置锁关联的条件队列。但是内置锁有一些问题,在大多数情况下,内置锁都能很好地工作,但在功能上存在一些局限性, 例如,无法中断一个正在等待获取锁的线程,或者无法在请求获取一个锁时无限地等待下去。 内置锁必须在获取该锁的代码块中释放,这就简化了编码工作,并且与异常处理操作实现了很好的交互,但却无法实现非阻塞结构的加锁规则。 这些都是使用synchronized的原因,但在某些情况下,一种更灵活的加锁机制通常能提供更好的活跃性或性能。
于是 Java 5 引入了 JUC,扩展了线程的同步工具,它们主要是基于 LockSupport.park/noPark, 从内置锁这条脉络上来看,对应的是显示锁,通信则有了显示 Condition。 另外就是增加了 CAS。
CAS(Compare-And-Swap)
CAS 是非阻塞同步的核心原语,在 JUC 中被广泛使用。它依赖 CPU 提供的硬件指令来实现原子更新。核心思想是:
- 对比预期值(Expected Value)与当前内存值。
- 如果相等,则将内存值更新为新值。
- 如果不相等,则更新失败,需要重新尝试。
Java 提供了原子类来封装 CAS 操作,例如:
AtomicInteger count = new AtomicInteger(0);
// 原子加 1
int oldValue = count.get();
int newValue = oldValue + 1;
count.compareAndSet(oldValue, newValue);
CAS 的优点:
- 无锁(lock-free),减少线程阻塞和上下文切换开销
- 高并发下性能优越,适用于简单状态的更新
CAS 的缺点:
- ABA 问题:变量先从 A → B → A,CAS 会误以为没有变化,可用
AtomicStampedReference解决 - 自旋开销:频繁失败会浪费 CPU
- 只能保证单个变量的原子性,多变量复合操作仍需锁
CAS 在 JUC 的应用场景:
- 原子类(
AtomicInteger、AtomicReference) - 并发容器(如
ConcurrentLinkedQueue、ConcurrentHashMap) - 锁实现(如
ReentrantLock的部分状态更新) - 无锁队列、栈等数据结构
这应该说就是整个 Java 并发体系。 开发者在构造并发应用程序时,优先选择 JUC 提供的并发组件,没有时则需要自己开发组件。
作者对这两方面的知识都是既有理论又有实践,下面我按如何自己开发支持并发的组件,以及使用 JUC 提供的并发组件两块对书的内容作一个总结。
知道如何开发并发组件,对后续使用 JUC 提供的并发组件有很大帮助,使用 JUC 的并发组件时也可以反过来帮助我们开发并发组件。
从面向对象的角度来看,并发程序通常是由一组对象协作完成的。这些对象内部包含状态,并通过方法来对状态进行操作。当只有单线程访问这些对象时,问题往往比较简单; 但在多线程环境下,如果多个线程同时访问同一个对象并修改其中的状态,就可能导致程序行为不符合预期。因此,在设计支持并发的组件时, 我们首先需要回答一个问题:如何在多个线程访问对象时仍然保持对象状态的一致性与正确性?
答案就是 Java 内存模型(JMM,Java Memory Model)。
JMM 规定了 JVM 在并发环境下必须遵循的一组最小保证,这些保证定义了线程之间如何通过内存进行交互。更具体地说,JMM 规定了一个线程对共享变量的写入在什么情况下能够被另一个线程看到, 也就是所谓的可见性问题。为了描述这种关系,JMM 为程序中的所有操作定义了一个偏序关系,称为 Happens-Before。
简单来说,如果操作 A Happens-Before 操作 B,那么 A 的执行结果对 B 是可见的,并且 A 的执行顺序也排在 B 之前。无论这两个操作是否在同一个线程中执行, 只要它们之间存在 Happens-Before 关系,程序就可以保证可见性与有序性。
Happens-Before 规则包括以下几种常见情况:
- 程序顺序规则:在一个线程内,按照程序代码顺序,前面的操作 Happens-Before 后面的操作。
- 监视器锁规则:对同一个锁的解锁操作 Happens-Before 随后的加锁操作。
- volatile 变量规则:对一个 volatile 变量的写操作 Happens-Before 后续对该变量的读操作。
- 线程启动规则:调用
Thread.start()之前的操作 Happens-Before 该线程中的所有操作。 - 线程终止规则:线程中的所有操作 Happens-Before 其他线程检测到该线程已经结束(例如
join()返回)。 - 中断规则:一个线程调用另一个线程的
interrupt(),Happens-Before 被中断线程检测到中断。 - 传递性规则:如果 A Happens-Before B,B Happens-Before C,那么 A Happens-Before C。
这些规则共同构成了 Java 并发程序的可见性基础。所有并发工具,无论是 synchronized、volatile 还是 JUC 中的各种并发组件,本质上都是在利用或建立 Happens-Before 关系。
在理解了 JMM 的基本规则之后,我们就可以进一步思考:在设计并发组件时,应该如何管理对象的状态,使得多个线程访问时仍然保持正确性。
通常来说,解决共享状态问题有三种思路:
第一种是 不共享状态。如果对象的状态只被一个线程访问,那么它天然就是线程安全的。例如方法中的局部变量只能被当前线程访问,因此它们不会产生并发问题。这也是为什么在并发程序中我们常常强调“尽量减少共享”。
第二种是 共享不可变对象。如果一个对象在创建之后其状态就不再发生改变,那么多个线程同时读取它也是安全的。不可变对象在并发程序中非常有价值,因为它们不需要额外的同步机制就可以被安全地共享。
一个对象如果满足以下条件,就可以认为是不可变的:
- 对象创建之后状态不能再改变
- 所有字段都是
final - 在构造过程中没有发生
this引用逸出
例如 String 就是一个典型的不可变对象。
第三种是 共享可变状态,但通过同步机制进行保护。在很多实际场景中,不可避免地需要多个线程共同修改同一个对象的状态,这时就需要借助同步机制来保证正确性,例如锁或原子变量。
在设计线程安全类时,通常需要遵循三个基本步骤:
- 找出构成对象状态的所有变量
- 找出约束这些状态变量的不变性条件
- 为这些状态变量制定并发访问策略(例如使用锁或原子操作)
当对象被多个线程访问时,还需要考虑一个重要问题:对象是如何被发布的。如果一个对象在还没有完全构造完成时就被其他线程看到,那么即使对象本身设计正确,也可能出现错误行为。因此,对象的发布必须是安全的。
常见的安全发布方式包括:
- 在静态初始化过程中创建对象
- 将对象引用存储到
volatile变量中 - 将对象引用存储在
final字段中 - 在持有锁的情况下发布对象
这些方式本质上都是通过建立 Happens-Before 关系来保证对象构造完成之后才对其他线程可见。
有了这些基础规则之后,我们就可以开始构建各种并发组件。Java 提供了两类主要的同步机制:阻塞同步和非阻塞同步。
阻塞同步主要依赖锁机制,例如 synchronized 或 Lock。当线程获取不到锁时就会被挂起,直到锁被释放。锁机制的优点是简单、直观,但缺点是线程的挂起与恢复会带来一定的开销。
另一类是 非阻塞同步,它通常依赖硬件提供的原子指令,例如 CAS(Compare-And-Swap)。CAS 允许在不使用锁的情况下完成原子更新操作, 如果更新失败则重新尝试。基于 CAS 可以构建出许多高性能的无锁数据结构。
在 Java 的并发工具中,这两种思想都得到了广泛应用。锁机制构成了传统同步工具的基础,而 CAS 则成为原子类和部分并发容器的核心实现手段。
理解了这些底层机制之后,我们就可以进一步来看 Java 标准库提供的并发框架,也就是 java.util.concurrent 包中的各种组件。
这些组件在内部利用了锁、CAS 以及线程调度机制,为开发者提供了一组功能完善且性能良好的并发构建模块。
在实际开发中,大多数并发程序并不是直接围绕线程来设计的,而是围绕任务(Task)来构造。线程只是执行任务的一种机制,而任务则是对计算逻辑的一种抽象表达。 通过将任务与执行机制解耦,可以使程序结构更加清晰,同时也更容易控制并发行为。
最直接的方式是创建一个线程来执行任务,例如:
new Thread(new Runnable() {
@Override
public void run() {
doSomething();
}
}).start();
这种方式在简单场景下是可行的,但如果频繁创建线程,就会带来很大的开销。线程的创建、销毁以及调度都需要消耗系统资源,如果任务数量很多, 那么大量线程的存在不仅会增加内存占用,还会导致频繁的上下文切换,从而降低系统性能。
为了解决这个问题,人们引入了线程池(Thread Pool)的概念。线程池通过复用一组固定的工作线程来执行任务,从而避免频繁创建和销毁线程的开销。
Java 在 java.util.concurrent 包中提供了 Executor 框架来统一管理任务的执行。
Executor 框架的核心接口非常简单:
public interface Executor {
void execute(Runnable command);
}
这个接口定义了一个非常重要的思想:任务提交与任务执行分离。调用者只需要提交任务,而具体由多少线程执行、如何调度这些任务,则由执行器来负责。
在 Executor 的基础上,Java 又提供了更完整的 ExecutorService 接口,它增加了任务生命周期管理的能力,例如关闭线程池、等待任务执行完成等。
最常用的线程池实现是 ThreadPoolExecutor。在使用线程池时,需要根据系统的资源情况和任务特性来配置一些关键参数,例如:
- 核心线程数(corePoolSize)
- 最大线程数(maximumPoolSize)
- 任务队列(workQueue)
- 饱和策略(RejectedExecutionHandler)
- 线程工厂(ThreadFactory)
其中线程池大小的选择与任务类型密切相关。对于计算密集型任务,线程数通常接近 CPU 核数即可,因为过多线程会导致上下文切换增加。
在拥有 Ncpu 个处理器的系统上,线程池大小设置为 Ncpu + 1 往往能够取得较好的效果。
而对于I/O 密集型任务,线程在执行过程中会有大量时间处于阻塞状态,因此线程池规模通常需要设置得更大一些,以便在部分线程阻塞时仍然能够保持较高的 CPU 利用率。
除了任务执行机制之外,并发程序还需要解决一个重要问题:如何表达任务。
Runnable 作为最基本的任务抽象形式存在一定局限性。它的 run 方法既不能返回结果,也不能抛出受检查异常。在很多场景中,我们希望能够获取任务执行的结果,
或者在任务执行过程中发生异常时进行处理。
为了解决这个问题,Java 引入了 Callable 接口。与 Runnable 不同,Callable 的 call 方法可以返回一个结果,并且允许抛出异常。
public interface Callable<V> {
V call() throws Exception;
}
当一个 Callable 任务被提交给线程池执行时,会返回一个 Future 对象。Future 表示一个任务的生命周期,它提供了一组方法来查询任务状态、获取执行结果以及取消任务。例如:
Future<Integer> future = executor.submit(() -> {
return compute();
});
Integer result = future.get();
通过 Future,调用者可以在需要时获取任务的执行结果,同时也可以通过 cancel 方法来取消任务。
在并发程序中,任务取消也是一个非常重要的主题。由于线程的执行是由操作系统调度的,因此无法通过简单的方法强制停止一个线程。 Java 推荐的方式是使用中断(Interrupt)机制来实现任务取消。
当一个线程被中断时,它会收到一个中断信号。如果线程正在执行某些阻塞操作(例如 sleep、wait 或 join),则会抛出 InterruptedException。
如果线程没有处于阻塞状态,则可以通过检查中断标志来决定是否终止任务。
因此,一个设计良好的任务通常应该具备响应中断的能力。例如:
public void run() {
while (!Thread.currentThread().isInterrupted()) {
doWork();
}
}
这种方式被称为协作式取消。线程并不会被强制停止,而是通过检测中断状态来自行结束执行。
除了取消任务之外,还需要考虑关闭基于线程的服务。例如线程池在应用程序关闭时应该停止接收新任务,并等待已提交的任务执行完成。ExecutorService 提供了 shutdown 和 shutdownNow 方法来完成这一过程。
shutdown 会拒绝新的任务提交,但会继续执行已经提交的任务;而 shutdownNow 则会尝试中断正在执行的任务,并返回尚未执行的任务列表。
通过这些机制,我们就可以构建出结构清晰、行为可控的并发程序。
并发测试(Concurrency Testing)
并发程序的错误往往具有偶发性和难以复现性,因此并发测试非常重要。常见并发问题包括数据竞争、死锁、活锁、内存可见性问题等。
并发测试思路
-
多线程压力测试 创建多个线程同时访问共享资源,模拟真实的并发场景,例如线程池提交大量任务,检查状态是否正确、是否出现异常
-
边界条件测试 测试最小值、最大值、空值、满队列等极端条件下的行为
-
重复执行测试 同一测试场景多次执行,利用随机调度增强不同线程执行顺序覆盖率
-
死锁检测 利用线程 dump 分析死锁,或用工具(
jconsole、VisualVM)实时监控锁情况 -
工具支持
ThreadMXBean:检测死锁junit+ExecutorService:测试并发任务执行结果- JCStress:JDK 官方并发测试工具
并发测试实践示例
class Counter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() { count.getAndIncrement(); }
public int get() { return count.get(); }
}
Counter counter = new Counter();
ExecutorService executor = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
executor.submit(() -> counter.increment());
}
executor.shutdown();
executor.awaitTermination(1, TimeUnit.MINUTES);
System.out.println(counter.get()); // 应该输出 1000
这种测试可以在多线程环境下验证计数器的正确性,如果使用普通非线程安全计数器,则可能出现数据丢失。
并发测试注意事项
- 不可预测性:并发 bug 可能只在特定执行顺序出现
- 多次执行:单次通过不代表线程安全
- 与 JMM 结合:确保测试覆盖可见性、顺序性问题
有了任务执行框架和并发组件之后,并发程序的基本构建模块就已经具备了。但在实际系统中,即使使用了正确的并发工具,程序仍然可能出现各种问题。 正如前面所提到的,并发程序的挑战通常可以归纳为三大类:安全性问题、活跃性问题以及性能问题。
接下来,我们就从这三个方面来进一步分析并发程序在实际运行过程中可能遇到的挑战,以及相应的解决思路。
安全性问题
安全性(Safety)主要关注的是程序的正确性。如果一个程序在单线程环境下能够正确运行,但在多线程环境下却产生了错误结果,那么这个程序就是不安全的。安全性问题通常来源于共享可变状态。
在并发程序中,如果多个线程同时访问同一个变量,并且至少有一个线程会修改该变量,而这些访问之间又没有建立适当的同步关系,就会发生数据竞争(Data Race)。一旦出现数据竞争,程序的行为往往就不再是可预测的。
因此,在设计并发程序时,一个非常重要的原则就是:尽量减少共享状态。
如果某个状态只被一个线程访问,那么它天然就是线程安全的。例如方法中的局部变量只能被当前线程访问,因此它们不会产生并发问题。前面我们提到过,ThreadLocal 也可以通过为每个线程维护一份独立的变量副本来避免共享。
当必须共享数据时,另一种有效的方式是使用不可变对象。不可变对象在创建之后其状态就不会再发生变化,因此多个线程可以安全地共享同一个实例。例如 String、Integer 等类都是不可变对象。
在实际开发中,我们也可以通过设计不可变类来减少同步需求。通常来说,一个不可变类需要满足以下条件:
- 所有字段都是
final - 对象创建之后状态不能再改变
- 在构造过程中没有发生
this引用逸出 - 如果字段引用了其他对象,那么这些对象也应该是不可变的
如果既不能避免共享,又无法使用不可变对象,那么就必须通过同步机制来保护共享状态。同步的核心目标是保证对共享变量的访问满足正确的 Happens-Before 关系,从而避免数据竞争。
同步可以通过多种方式实现,例如:
- 使用
synchronized保护临界区 - 使用显式锁(例如
ReentrantLock) - 使用原子变量(例如
AtomicInteger) - 使用
volatile保证可见性
不同的同步机制适用于不同的场景。例如,如果需要对多个变量进行复合操作,那么通常需要使用锁;而如果只是对单个变量进行简单更新,那么原子变量往往更加高效。
活跃性问题
即使一个程序在逻辑上是正确的,它也可能无法顺利向前推进,这类问题通常被称为活跃性问题(Liveness)。
最常见的活跃性问题是死锁(Deadlock)。当两个或多个线程互相等待对方释放资源时,就会形成死锁。例如线程 A 持有锁 L1 并等待锁 L2,而线程 B 持有锁 L2 并等待锁 L1,这样两个线程就会永远等待下去。
死锁通常需要满足四个条件:
- 互斥条件:资源一次只能被一个线程使用
- 占有且等待:线程持有资源的同时等待其他资源
- 不可抢占:资源不能被强制夺取
- 循环等待:存在一个线程循环等待链
如果能够破坏其中任意一个条件,就可以避免死锁。例如在程序中为锁规定固定的获取顺序,就是一种常见的避免死锁的方法。
除了死锁之外,还有一种情况叫做饥饿(Starvation)。饥饿指的是某些线程长期得不到所需资源,虽然系统整体仍然在运行,但个别线程却无法获得执行机会。 例如在一个高度竞争的环境中,如果总是优先调度某些线程,那么其他线程可能会一直等待。
另一种较少见但同样重要的问题是活锁(Livelock)。在活锁中,线程并没有被阻塞,它们仍然在不断运行,但由于不断地互相“礼让”,导致任务始终无法完成。 解决活锁的一种常见方法是引入随机等待或退避策略,使系统最终能够收敛。
性能问题
除了安全性和活跃性之外,并发程序还需要关注性能(Performance)。一个程序即使是正确的,如果性能过低,也很难在实际系统中发挥价值。
在并发程序中,性能问题通常来自以下几个方面。
首先是线程上下文切换开销。当线程被阻塞或唤醒时,操作系统需要保存和恢复线程的执行状态,这个过程会消耗一定的时间。如果线程频繁阻塞和唤醒,那么系统可能会把大量时间花在线程调度上,而不是实际的计算工作上。
其次是锁竞争。当多个线程同时争夺同一把锁时,只有一个线程能够进入临界区,其余线程都必须等待。随着线程数量的增加,锁竞争会变得越来越激烈,从而导致系统吞吐量下降。
为了减少锁竞争,可以采用多种优化策略。例如:
- 缩小临界区范围,减少锁的持有时间
- 锁分解(Lock Splitting),将一个大锁拆分成多个小锁
- 锁分段(Lock Striping),让不同线程访问不同的锁
- 使用读写锁,在读多写少的场景下提高并发度
另外一个重要的性能问题是内存同步开销。像 volatile、synchronized 等机制在底层都会插入内存屏障,从而影响 CPU 的缓存行为。如果频繁进行同步操作,就可能导致缓存失效和性能下降。
因此,在设计并发程序时,通常会遵循一个重要原则:减少共享状态,减少同步操作。通过使用不可变对象、分区数据结构或者无锁算法,可以有效提高系统的可伸缩性。
通过以上分析可以看到,Java 并发编程并不仅仅是学习几个 API 或工具类,更重要的是理解其背后的设计思想。JMM 为并发程序提供了统一的内存语义,
而 synchronized、CAS 以及 java.util.concurrent 中的各种组件,则是在这一语义之上构建起来的具体实现。
在实际开发中,我们通常会优先使用标准库中已经实现好的并发组件,例如线程池、并发容器以及各种同步器。这些组件经过大量实践验证,在正确性和性能方面都具有很好的表现。 只有在标准组件无法满足需求时,才需要基于底层机制自行构建并发组件。
##修改记录
- 2026/03/07 按主线体系化整书知识
- 2025/09/06 第一次完成