@@ -0,0 +1,95 @@
|
||||
---
|
||||
title: Java 并发编程专题:线程、锁、JMM、CAS、AQS、线程池与虚拟线程
|
||||
description: Java 并发编程面试与学习路线,涵盖线程、锁、synchronized、ReentrantLock、JMM、CAS、AQS、ThreadLocal、线程池、CompletableFuture 和虚拟线程。
|
||||
category: Java
|
||||
tag:
|
||||
- Java
|
||||
- Java并发
|
||||
- Java面试
|
||||
sitemap:
|
||||
changefreq: weekly
|
||||
priority: 0.9
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: Java并发,Java锁,synchronized,ReentrantLock,JMM,CAS,AQS,ThreadLocal,线程池,CompletableFuture,并发容器,Atomic,虚拟线程
|
||||
---
|
||||
|
||||
Java 并发编程是后端开发和面试中最重要、也最容易混淆的模块之一。学习并发不能只背 API,要把线程生命周期、锁机制、内存模型、原子操作、线程池和并发工具类放在同一条主线上理解。
|
||||
|
||||
## 适合谁看
|
||||
|
||||
- 想系统学习 Java 并发编程的后端开发者。
|
||||
- 准备线程、锁、JMM、CAS、AQS、线程池等面试题的同学。
|
||||
- 已经在项目中使用多线程,但对死锁、线程池参数、ThreadLocal 泄漏等问题不够熟的读者。
|
||||
- 想理解并发容器、CompletableFuture、虚拟线程等工程实践能力的工程师。
|
||||
|
||||
## 学习重点
|
||||
|
||||
- 线程创建、生命周期、上下文切换、线程安全和常见并发问题。
|
||||
- `synchronized`、`volatile`、`ReentrantLock`、互斥锁、读写锁、乐观锁、悲观锁的适用边界。
|
||||
- JMM、happens-before、指令重排、可见性、原子性和有序性。
|
||||
- CAS、Atomic 原子类、AQS、并发容器和阻塞队列的底层思路。
|
||||
- 线程池核心参数、拒绝策略、任务队列、参数配置和生产实践。
|
||||
- CompletableFuture、ThreadLocal、虚拟线程在真实项目中的使用方式和风险。
|
||||
|
||||
## 建议阅读顺序
|
||||
|
||||
1. [Java并发常见面试题总结(上)](./java-concurrent-questions-01.md):先建立线程、锁和线程安全的基础问题清单。
|
||||
2. [Java并发常见面试题总结(中)](./java-concurrent-questions-02.md) 和 [Java并发常见面试题总结(下)](./java-concurrent-questions-03.md):继续补齐 JMM、CAS、AQS、线程池和并发工具。
|
||||
3. [Java 锁详解](./java-lock.md)、[乐观锁和悲观锁详解](./optimistic-lock-and-pessimistic-lock.md)、[CAS 详解](./cas.md)、[JMM(Java 内存模型)详解](./jmm.md):先建立锁体系,再理解并发控制的底层语义。
|
||||
4. [AQS 详解](./aqs.md)、[从ReentrantLock的实现看AQS的原理及应用](./reentrantlock.md):深入理解 Java 锁和同步器。
|
||||
5. [Java 线程池详解](./java-thread-pool-summary.md) 和 [Java 线程池最佳实践](./java-thread-pool-best-practices.md):掌握生产中最常用的并发基础设施。
|
||||
|
||||
## 核心文章
|
||||
|
||||
### 并发面试题
|
||||
|
||||
- [Java并发常见面试题总结(上)](./java-concurrent-questions-01.md):覆盖线程基础、线程安全、锁和常见并发问题。
|
||||
- [Java并发常见面试题总结(中)](./java-concurrent-questions-02.md):继续梳理 JMM、volatile、CAS、AQS 等核心知识。
|
||||
- [Java并发常见面试题总结(下)](./java-concurrent-questions-03.md):补齐线程池、并发工具类、CompletableFuture 和虚拟线程等内容。
|
||||
|
||||
### 锁、内存模型与同步器
|
||||
|
||||
- [Java 锁详解](./java-lock.md):从互斥锁、读写锁、自旋锁到 `synchronized`、`ReentrantLock`、AQS,建立 Java 锁体系。
|
||||
- [乐观锁和悲观锁详解](./optimistic-lock-and-pessimistic-lock.md):理解不同并发冲突处理策略。
|
||||
- [CAS 详解](./cas.md):理解比较并交换、ABA 问题和自旋开销。
|
||||
- [JMM(Java 内存模型)详解](./jmm.md):掌握可见性、原子性、有序性和 happens-before。
|
||||
- [AQS 详解](./aqs.md):理解同步队列、独占/共享模式和常见同步器底层。
|
||||
- [从ReentrantLock的实现看AQS的原理及应用](./reentrantlock.md):通过 ReentrantLock 深入理解 AQS。
|
||||
|
||||
### 并发工具与工程实践
|
||||
|
||||
- [Java 线程池详解](./java-thread-pool-summary.md):理解核心参数、任务队列、拒绝策略和执行流程。
|
||||
- [Java 线程池最佳实践](./java-thread-pool-best-practices.md):总结生产环境中线程池隔离、参数配置和监控建议。
|
||||
- [Java 常见并发容器总结](./java-concurrent-collections.md):梳理 ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue 等容器。
|
||||
- [Atomic 原子类总结](./atomic-classes.md):理解原子更新基本类型、数组、引用和字段。
|
||||
- [ThreadLocal 详解](./threadlocal.md):理解线程本地变量、ThreadLocalMap 和内存泄漏风险。
|
||||
- [CompletableFuture 详解](./completablefuture-intro.md):掌握异步编排、异常处理和线程池使用。
|
||||
- [虚拟线程常见问题总结](./virtual-thread.md):理解虚拟线程的定位、适用场景和使用限制。
|
||||
|
||||
## 高频问题
|
||||
|
||||
- 线程和进程有什么区别?线程有哪些状态?
|
||||
- 什么是线程安全?如何定位死锁?
|
||||
- 互斥锁、读写锁、自旋锁有什么区别?
|
||||
- `synchronized` 和 `ReentrantLock` 有什么区别?
|
||||
- `volatile` 能保证原子性吗?它解决什么问题?
|
||||
- JMM 是什么?happens-before 规则有什么用?
|
||||
- CAS 有什么优缺点?ABA 问题如何解决?
|
||||
- AQS 的核心思想是什么?哪些工具类基于 AQS?
|
||||
- 线程池核心参数如何配置?拒绝策略如何选择?
|
||||
- 为什么不建议直接使用 `Executors` 创建线程池?
|
||||
- `ThreadLocal` 为什么可能内存泄漏?
|
||||
- CompletableFuture 默认线程池有什么风险?
|
||||
- 虚拟线程适合 CPU 密集型任务吗?
|
||||
|
||||
## 相关专题
|
||||
|
||||
- [Java 知识体系](../)
|
||||
- [Java 集合专题](../collection/)
|
||||
- [JVM 专题](../jvm/)
|
||||
- [Java IO 专题](../io/)
|
||||
- [操作系统](../../cs-basics/operating-system/)
|
||||
|
||||
<!-- @include: @article-footer.snippet.md -->
|
||||
@@ -0,0 +1,404 @@
|
||||
---
|
||||
title: Atomic 原子类总结
|
||||
description: Java原子类详解:全面总结JUC包Atomic原子类体系、AtomicInteger/AtomicLong/AtomicReference等常用类、基于CAS的线程安全实现、使用场景与性能优势。
|
||||
category: Java
|
||||
tag:
|
||||
- Java并发
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: Atomic原子类,AtomicInteger,AtomicLong,AtomicReference,CAS原子操作,JUC并发包,原子类使用
|
||||
---
|
||||
|
||||
## Atomic 原子类介绍
|
||||
|
||||
`Atomic` 翻译成中文是“原子”的意思。在化学上,原子是构成物质的最小单位,在化学反应中不可分割。在编程中,`Atomic` 指的是一个操作具有原子性,即该操作不可分割、不可中断。即使在多个线程同时执行时,该操作要么全部执行完成,要么不执行,不会被其他线程看到部分完成的状态。
|
||||
|
||||
原子类简单来说就是具有原子性操作特征的类。
|
||||
|
||||
`java.util.concurrent.atomic` 包中的 `Atomic` 原子类提供了一种线程安全的方式来操作单个变量。
|
||||
|
||||
`Atomic` 类依赖于 CAS(Compare-And-Swap,比较并交换)乐观锁来保证其方法的原子性,而不需要使用传统的锁机制(如 `synchronized` 块或 `ReentrantLock`)。
|
||||
|
||||
这篇文章我们只介绍 Atomic 原子类的概念,具体实现原理可以阅读笔者写的这篇文章:[CAS 详解](./cas.md)。
|
||||
|
||||

|
||||
|
||||
根据操作的数据类型,可以将 JUC 包中的原子类分为 4 类:
|
||||
|
||||
**1、基本类型**
|
||||
|
||||
使用原子的方式更新基本类型
|
||||
|
||||
- `AtomicInteger`:整型原子类
|
||||
- `AtomicLong`:长整型原子类
|
||||
- `AtomicBoolean`:布尔型原子类
|
||||
|
||||
**2、数组类型**
|
||||
|
||||
使用原子的方式更新数组里的某个元素
|
||||
|
||||
- `AtomicIntegerArray`:整型数组原子类
|
||||
- `AtomicLongArray`:长整型数组原子类
|
||||
- `AtomicReferenceArray`:引用类型数组原子类
|
||||
|
||||
**3、引用类型**
|
||||
|
||||
- `AtomicReference`:引用类型原子类
|
||||
- `AtomicMarkableReference`:原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来,~~也可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题~~。
|
||||
- `AtomicStampedReference`:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
|
||||
|
||||
**🐛 修正(参见:[issue#626](https://github.com/Snailclimb/JavaGuide/issues/626))** : `AtomicMarkableReference` 不能解决 ABA 问题。
|
||||
|
||||
**4、对象的属性修改类型**
|
||||
|
||||
- `AtomicIntegerFieldUpdater`:原子更新整型字段的更新器
|
||||
- `AtomicLongFieldUpdater`:原子更新长整型字段的更新器
|
||||
- `AtomicReferenceFieldUpdater`:原子更新引用类型里的字段
|
||||
|
||||
## 基本类型原子类
|
||||
|
||||
使用原子的方式更新基本类型
|
||||
|
||||
- `AtomicInteger`:整型原子类
|
||||
- `AtomicLong`:长整型原子类
|
||||
- `AtomicBoolean`:布尔型原子类
|
||||
|
||||
上面三个类提供的方法几乎相同,所以我们这里以 `AtomicInteger` 为例子来介绍。
|
||||
|
||||
**`AtomicInteger` 类常用方法**:
|
||||
|
||||
```java
|
||||
public final int get() //获取当前的值
|
||||
public final int getAndSet(int newValue)//获取当前的值,并设置新的值
|
||||
public final int getAndIncrement()//获取当前的值,并自增
|
||||
public final int getAndDecrement() //获取当前的值,并自减
|
||||
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
|
||||
boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)
|
||||
public final void lazySet(int newValue)//最终设置为newValue, lazySet 提供了一种比 set 方法更弱的语义,可能导致其他线程在之后的一小段时间内还是可以读到旧的值,但可能更高效。
|
||||
```
|
||||
|
||||
**`AtomicInteger` 类使用示例** :
|
||||
|
||||
```java
|
||||
// 初始化 AtomicInteger 对象,初始值为 0
|
||||
AtomicInteger atomicInt = new AtomicInteger(0);
|
||||
|
||||
// 使用 getAndSet 方法获取当前值,并设置新值为 3
|
||||
int tempValue = atomicInt.getAndSet(3);
|
||||
System.out.println("tempValue: " + tempValue + "; atomicInt: " + atomicInt);
|
||||
|
||||
// 使用 getAndIncrement 方法获取当前值,并自增 1
|
||||
tempValue = atomicInt.getAndIncrement();
|
||||
System.out.println("tempValue: " + tempValue + "; atomicInt: " + atomicInt);
|
||||
|
||||
// 使用 getAndAdd 方法获取当前值,并增加指定值 5
|
||||
tempValue = atomicInt.getAndAdd(5);
|
||||
System.out.println("tempValue: " + tempValue + "; atomicInt: " + atomicInt);
|
||||
|
||||
// 使用 compareAndSet 方法进行原子性条件更新,期望值为 9,更新值为 10
|
||||
boolean updateSuccess = atomicInt.compareAndSet(9, 10);
|
||||
System.out.println("Update Success: " + updateSuccess + "; atomicInt: " + atomicInt);
|
||||
|
||||
// 获取当前值
|
||||
int currentValue = atomicInt.get();
|
||||
System.out.println("Current value: " + currentValue);
|
||||
|
||||
// 使用 lazySet 方法设置新值为 15
|
||||
atomicInt.lazySet(15);
|
||||
System.out.println("After lazySet, atomicInt: " + atomicInt);
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```java
|
||||
tempValue: 0; atomicInt: 3
|
||||
tempValue: 3; atomicInt: 4
|
||||
tempValue: 4; atomicInt: 9
|
||||
Update Success: true; atomicInt: 10
|
||||
Current value: 10
|
||||
After lazySet, atomicInt: 15
|
||||
```
|
||||
|
||||
## 数组类型原子类
|
||||
|
||||
使用原子的方式更新数组里的某个元素
|
||||
|
||||
- `AtomicIntegerArray`:整形数组原子类
|
||||
- `AtomicLongArray`:长整形数组原子类
|
||||
- `AtomicReferenceArray`:引用类型数组原子类
|
||||
|
||||
上面三个类提供的方法几乎相同,所以我们这里以 `AtomicIntegerArray` 为例子来介绍。
|
||||
|
||||
**`AtomicIntegerArray` 类常用方法**:
|
||||
|
||||
```java
|
||||
public final int get(int i) //获取 index=i 位置元素的值
|
||||
public final int getAndSet(int i, int newValue)//返回 index=i 位置的当前的值,并将其设置为新值:newValue
|
||||
public final int getAndIncrement(int i)//获取 index=i 位置元素的值,并让该位置的元素自增
|
||||
public final int getAndDecrement(int i) //获取 index=i 位置元素的值,并让该位置的元素自减
|
||||
public final int getAndAdd(int i, int delta) //获取 index=i 位置元素的值,并加上预期的值
|
||||
boolean compareAndSet(int i, int expect, int update) //如果输入的数值等于预期值,则以原子方式将 index=i 位置的元素值设置为输入值(update)
|
||||
public final void lazySet(int i, int newValue)//最终 将index=i 位置的元素设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。
|
||||
```
|
||||
|
||||
**`AtomicIntegerArray` 类使用示例** :
|
||||
|
||||
```java
|
||||
int[] nums = {1, 2, 3, 4, 5, 6};
|
||||
// 创建 AtomicIntegerArray
|
||||
AtomicIntegerArray atomicArray = new AtomicIntegerArray(nums);
|
||||
|
||||
// 打印 AtomicIntegerArray 中的初始值
|
||||
System.out.println("Initial values in AtomicIntegerArray:");
|
||||
for (int j = 0; j < nums.length; j++) {
|
||||
System.out.print("Index " + j + ": " + atomicArray.get(j) + " ");
|
||||
}
|
||||
|
||||
// 使用 getAndSet 方法将索引 0 处的值设置为 2,并返回旧值
|
||||
int tempValue = atomicArray.getAndSet(0, 2);
|
||||
System.out.println("\nAfter getAndSet(0, 2):");
|
||||
System.out.println("Returned value: " + tempValue);
|
||||
for (int j = 0; j < atomicArray.length(); j++) {
|
||||
System.out.print("Index " + j + ": " + atomicArray.get(j) + " ");
|
||||
}
|
||||
|
||||
// 使用 getAndIncrement 方法将索引 0 处的值加 1,并返回旧值
|
||||
tempValue = atomicArray.getAndIncrement(0);
|
||||
System.out.println("\nAfter getAndIncrement(0):");
|
||||
System.out.println("Returned value: " + tempValue);
|
||||
for (int j = 0; j < atomicArray.length(); j++) {
|
||||
System.out.print("Index " + j + ": " + atomicArray.get(j) + " ");
|
||||
}
|
||||
|
||||
// 使用 getAndAdd 方法将索引 0 处的值增加 5,并返回旧值
|
||||
tempValue = atomicArray.getAndAdd(0, 5);
|
||||
System.out.println("\nAfter getAndAdd(0, 5):");
|
||||
System.out.println("Returned value: " + tempValue);
|
||||
for (int j = 0; j < atomicArray.length(); j++) {
|
||||
System.out.print("Index " + j + ": " + atomicArray.get(j) + " ");
|
||||
}
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```plain
|
||||
Initial values in AtomicIntegerArray:
|
||||
Index 0: 1 Index 1: 2 Index 2: 3 Index 3: 4 Index 4: 5 Index 5: 6
|
||||
After getAndSet(0, 2):
|
||||
Returned value: 1
|
||||
Index 0: 2 Index 1: 2 Index 2: 3 Index 3: 4 Index 4: 5 Index 5: 6
|
||||
After getAndIncrement(0):
|
||||
Returned value: 2
|
||||
Index 0: 3 Index 1: 2 Index 2: 3 Index 3: 4 Index 4: 5 Index 5: 6
|
||||
After getAndAdd(0, 5):
|
||||
Returned value: 3
|
||||
Index 0: 8 Index 1: 2 Index 2: 3 Index 3: 4 Index 4: 5 Index 5: 6
|
||||
```
|
||||
|
||||
## 引用类型原子类
|
||||
|
||||
基本类型原子类只能更新一个变量,如果需要原子更新多个变量,需要使用 引用类型原子类。
|
||||
|
||||
- `AtomicReference`:引用类型原子类
|
||||
- `AtomicStampedReference`:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
|
||||
- `AtomicMarkableReference`:原子更新带有标记的引用类型。该类将 boolean 标记与引用关联起来,~~也可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。~~
|
||||
|
||||
上面三个类提供的方法几乎相同,所以我们这里以 `AtomicReference` 为例子来介绍。
|
||||
|
||||
**`AtomicReference` 类使用示例** :
|
||||
|
||||
```java
|
||||
// Person 类
|
||||
class Person {
|
||||
private String name;
|
||||
private int age;
|
||||
//省略getter/setter和toString
|
||||
}
|
||||
|
||||
|
||||
// 创建 AtomicReference 对象并设置初始值
|
||||
AtomicReference<Person> ar = new AtomicReference<>(new Person("SnailClimb", 22));
|
||||
|
||||
// 打印初始值
|
||||
System.out.println("Initial Person: " + ar.get().toString());
|
||||
|
||||
// 更新值
|
||||
Person updatePerson = new Person("Daisy", 20);
|
||||
ar.compareAndSet(ar.get(), updatePerson);
|
||||
|
||||
// 打印更新后的值
|
||||
System.out.println("Updated Person: " + ar.get().toString());
|
||||
|
||||
// 尝试再次更新
|
||||
Person anotherUpdatePerson = new Person("John", 30);
|
||||
boolean isUpdated = ar.compareAndSet(updatePerson, anotherUpdatePerson);
|
||||
|
||||
// 打印是否更新成功及最终值
|
||||
System.out.println("Second Update Success: " + isUpdated);
|
||||
System.out.println("Final Person: " + ar.get().toString());
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```plain
|
||||
Initial Person: Person{name='SnailClimb', age=22}
|
||||
Updated Person: Person{name='Daisy', age=20}
|
||||
Second Update Success: true
|
||||
Final Person: Person{name='John', age=30}
|
||||
```
|
||||
|
||||
**`AtomicStampedReference` 类使用示例** :
|
||||
|
||||
```java
|
||||
// 创建一个 AtomicStampedReference 对象,初始值为 "SnailClimb",初始版本号为 1
|
||||
AtomicStampedReference<String> asr = new AtomicStampedReference<>("SnailClimb", 1);
|
||||
|
||||
// 打印初始值和版本号
|
||||
int[] initialStamp = new int[1];
|
||||
String initialRef = asr.get(initialStamp);
|
||||
System.out.println("Initial Reference: " + initialRef + ", Initial Stamp: " + initialStamp[0]);
|
||||
|
||||
// 更新值和版本号
|
||||
int oldStamp = initialStamp[0];
|
||||
String oldRef = initialRef;
|
||||
String newRef = "Daisy";
|
||||
int newStamp = oldStamp + 1;
|
||||
|
||||
boolean isUpdated = asr.compareAndSet(oldRef, newRef, oldStamp, newStamp);
|
||||
System.out.println("Update Success: " + isUpdated);
|
||||
|
||||
// 打印更新后的值和版本号
|
||||
int[] updatedStamp = new int[1];
|
||||
String updatedRef = asr.get(updatedStamp);
|
||||
System.out.println("Updated Reference: " + updatedRef + ", Updated Stamp: " + updatedStamp[0]);
|
||||
|
||||
// 尝试用错误的版本号更新
|
||||
boolean isUpdatedWithWrongStamp = asr.compareAndSet(newRef, "John", oldStamp, newStamp + 1);
|
||||
System.out.println("Update with Wrong Stamp Success: " + isUpdatedWithWrongStamp);
|
||||
|
||||
// 打印最终的值和版本号
|
||||
int[] finalStamp = new int[1];
|
||||
String finalRef = asr.get(finalStamp);
|
||||
System.out.println("Final Reference: " + finalRef + ", Final Stamp: " + finalStamp[0]);
|
||||
```
|
||||
|
||||
输出结果如下:
|
||||
|
||||
```plain
|
||||
Initial Reference: SnailClimb, Initial Stamp: 1
|
||||
Update Success: true
|
||||
Updated Reference: Daisy, Updated Stamp: 2
|
||||
Update with Wrong Stamp Success: false
|
||||
Final Reference: Daisy, Final Stamp: 2
|
||||
```
|
||||
|
||||
**`AtomicMarkableReference` 类使用示例** :
|
||||
|
||||
```java
|
||||
// 创建一个 AtomicMarkableReference 对象,初始值为 "SnailClimb",初始标记为 false
|
||||
AtomicMarkableReference<String> amr = new AtomicMarkableReference<>("SnailClimb", false);
|
||||
|
||||
// 打印初始值和标记
|
||||
boolean[] initialMark = new boolean[1];
|
||||
String initialRef = amr.get(initialMark);
|
||||
System.out.println("Initial Reference: " + initialRef + ", Initial Mark: " + initialMark[0]);
|
||||
|
||||
// 更新值和标记
|
||||
String oldRef = initialRef;
|
||||
String newRef = "Daisy";
|
||||
boolean oldMark = initialMark[0];
|
||||
boolean newMark = true;
|
||||
|
||||
boolean isUpdated = amr.compareAndSet(oldRef, newRef, oldMark, newMark);
|
||||
System.out.println("Update Success: " + isUpdated);
|
||||
|
||||
// 打印更新后的值和标记
|
||||
boolean[] updatedMark = new boolean[1];
|
||||
String updatedRef = amr.get(updatedMark);
|
||||
System.out.println("Updated Reference: " + updatedRef + ", Updated Mark: " + updatedMark[0]);
|
||||
|
||||
// 尝试用错误的标记更新
|
||||
boolean isUpdatedWithWrongMark = amr.compareAndSet(newRef, "John", oldMark, !newMark);
|
||||
System.out.println("Update with Wrong Mark Success: " + isUpdatedWithWrongMark);
|
||||
|
||||
// 打印最终的值和标记
|
||||
boolean[] finalMark = new boolean[1];
|
||||
String finalRef = amr.get(finalMark);
|
||||
System.out.println("Final Reference: " + finalRef + ", Final Mark: " + finalMark[0]);
|
||||
```
|
||||
|
||||
输出结果如下:
|
||||
|
||||
```plain
|
||||
Initial Reference: SnailClimb, Initial Mark: false
|
||||
Update Success: true
|
||||
Updated Reference: Daisy, Updated Mark: true
|
||||
Update with Wrong Mark Success: false
|
||||
Final Reference: Daisy, Final Mark: true
|
||||
```
|
||||
|
||||
## 对象的属性修改类型原子类
|
||||
|
||||
如果需要原子更新某个类里的某个字段时,需要用到对象的属性修改类型原子类。
|
||||
|
||||
- `AtomicIntegerFieldUpdater`:原子更新整形字段的更新器
|
||||
- `AtomicLongFieldUpdater`:原子更新长整形字段的更新器
|
||||
- `AtomicReferenceFieldUpdater`:原子更新引用类型里的字段的更新器
|
||||
|
||||
要想原子地更新对象的属性需要两步。第一步,因为对象的属性修改类型原子类都是抽象类,所以每次使用都必须使用静态方法 newUpdater()创建一个更新器,并且需要设置想要更新的类和属性。第二步,更新的对象属性必须使用 volatile int 修饰符。
|
||||
|
||||
上面三个类提供的方法几乎相同,所以我们这里以 `AtomicIntegerFieldUpdater` 为例子来介绍。
|
||||
|
||||
**`AtomicIntegerFieldUpdater` 类使用示例** :
|
||||
|
||||
```java
|
||||
// Person 类
|
||||
class Person {
|
||||
private String name;
|
||||
// 要使用 AtomicIntegerFieldUpdater,字段必须是 volatile int
|
||||
volatile int age;
|
||||
//省略getter/setter和toString
|
||||
}
|
||||
|
||||
// 创建 AtomicIntegerFieldUpdater 对象
|
||||
AtomicIntegerFieldUpdater<Person> ageUpdater = AtomicIntegerFieldUpdater.newUpdater(Person.class, "age");
|
||||
|
||||
// 创建 Person 对象
|
||||
Person person = new Person("SnailClimb", 22);
|
||||
|
||||
// 打印初始值
|
||||
System.out.println("Initial Person: " + person);
|
||||
|
||||
// 更新 age 字段
|
||||
ageUpdater.incrementAndGet(person); // 自增
|
||||
System.out.println("After Increment: " + person);
|
||||
|
||||
ageUpdater.addAndGet(person, 5); // 增加 5
|
||||
System.out.println("After Adding 5: " + person);
|
||||
|
||||
ageUpdater.compareAndSet(person, 28, 30); // 如果当前值是 28,则设置为 30
|
||||
System.out.println("After Compare and Set (28 to 30): " + person);
|
||||
|
||||
// 尝试使用错误的比较值进行更新
|
||||
boolean isUpdated = ageUpdater.compareAndSet(person, 28, 35); // 这次应该失败
|
||||
System.out.println("Compare and Set (28 to 35) Success: " + isUpdated);
|
||||
System.out.println("Final Person: " + person);
|
||||
```
|
||||
|
||||
输出结果:
|
||||
|
||||
```plain
|
||||
Initial Person: Name: SnailClimb, Age: 22
|
||||
After Increment: Name: SnailClimb, Age: 23
|
||||
After Adding 5: Name: SnailClimb, Age: 28
|
||||
After Compare and Set (28 to 30): Name: SnailClimb, Age: 30
|
||||
Compare and Set (28 to 35) Success: false
|
||||
Final Person: Name: SnailClimb, Age: 30
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- 《Java 并发编程的艺术》
|
||||
|
||||
<!-- @include: @article-footer.snippet.md -->
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
title: CAS 详解
|
||||
description: CAS比较并交换深度解析:详解CAS原子操作原理、Unsafe类实现、ABA问题及解决方案、自旋锁机制、与悲观锁性能对比。
|
||||
category: Java
|
||||
tag:
|
||||
- Java并发
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: CAS,Compare-And-Swap,原子操作,ABA问题,自旋锁,乐观锁,Unsafe,CAS原理
|
||||
---
|
||||
|
||||
乐观锁和悲观锁的介绍以及乐观锁常见实现方式可以阅读笔者写的这篇文章:[乐观锁和悲观锁详解](https://javaguide.cn/java/concurrent/optimistic-lock-and-pessimistic-lock.html)。
|
||||
|
||||
这篇文章主要介绍:Java 中 CAS 的实现以及 CAS 存在的一些问题。
|
||||
|
||||
## Java 中 CAS 是如何实现的?
|
||||
|
||||
在 Java 中,实现 CAS(Compare-And-Swap, 比较并交换)操作的一个关键类是 `Unsafe`。
|
||||
|
||||
`Unsafe` 类位于 `sun.misc` 包下,是一个提供低级别、不安全操作的类。由于其强大的功能和潜在的危险性,它通常用于 JVM 内部或一些需要极高性能和底层访问的库中,而不推荐普通开发者在应用程序中使用。关于 `Unsafe` 类的详细介绍,可以阅读这篇文章:📌[Java 魔法类 Unsafe 详解](https://javaguide.cn/java/basis/unsafe.html)。
|
||||
|
||||
`sun.misc` 包下的 `Unsafe` 类提供了 `compareAndSwapObject`、`compareAndSwapInt`、`compareAndSwapLong` 方法来实现的对 `Object`、`int`、`long` 类型的 CAS 操作:
|
||||
|
||||
```java
|
||||
/**
|
||||
* 以原子方式更新对象字段的值。
|
||||
*
|
||||
* @param o 要操作的对象
|
||||
* @param offset 对象字段的内存偏移量
|
||||
* @param expected 期望的旧值
|
||||
* @param x 要设置的新值
|
||||
* @return 如果值被成功更新,则返回 true;否则返回 false
|
||||
*/
|
||||
boolean compareAndSwapObject(Object o, long offset, Object expected, Object x);
|
||||
|
||||
/**
|
||||
* 以原子方式更新 int 类型的对象字段的值。
|
||||
*/
|
||||
boolean compareAndSwapInt(Object o, long offset, int expected, int x);
|
||||
|
||||
/**
|
||||
* 以原子方式更新 long 类型的对象字段的值。
|
||||
*/
|
||||
boolean compareAndSwapLong(Object o, long offset, long expected, long x);
|
||||
```
|
||||
|
||||
`Unsafe` 类中的 CAS 方法是 `native` 方法。`native` 关键字表明这些方法是用本地代码(通常是 C 或 C++)实现的,而不是用 Java 实现的。这些方法直接调用底层的硬件指令来实现原子操作。也就是说,Java 语言并没有直接用 Java 实现 CAS。
|
||||
|
||||
更准确点来说,Java 中 CAS 是 C++ 内联汇编的形式实现的,通过 JNI(Java Native Interface) 调用。因此,CAS 的具体实现与操作系统以及 CPU 密切相关。
|
||||
|
||||
`java.util.concurrent.atomic` 包提供了一些用于原子操作的类。
|
||||
|
||||

|
||||
|
||||
关于这些 Atomic 原子类的介绍和使用,可以阅读这篇文章:[Atomic 原子类总结](https://javaguide.cn/java/concurrent/atomic-classes.html)。
|
||||
|
||||
Atomic 类依赖于 CAS 乐观锁来保证其方法的原子性,而不需要使用传统的锁机制(如 `synchronized` 块或 `ReentrantLock`)。
|
||||
|
||||
`AtomicInteger` 是 Java 的原子类之一,主要用于对 `int` 类型的变量进行原子操作,它利用 `Unsafe` 类提供的低级别原子操作方法实现无锁的线程安全性。
|
||||
|
||||
下面,我们通过解读 `AtomicInteger` 的核心源码(JDK1.8),来说明 Java 如何使用 `Unsafe` 类的方法来实现原子操作。
|
||||
|
||||
`AtomicInteger` 核心源码如下:
|
||||
|
||||
```java
|
||||
// 获取 Unsafe 实例
|
||||
private static final Unsafe unsafe = Unsafe.getUnsafe();
|
||||
private static final long valueOffset;
|
||||
|
||||
static {
|
||||
try {
|
||||
// 获取“value”字段在AtomicInteger类中的内存偏移量
|
||||
valueOffset = unsafe.objectFieldOffset
|
||||
(AtomicInteger.class.getDeclaredField("value"));
|
||||
} catch (Exception ex) { throw new Error(ex); }
|
||||
}
|
||||
// 确保“value”字段的可见性
|
||||
private volatile int value;
|
||||
|
||||
// 如果当前值等于预期值,则原子地将值设置为newValue
|
||||
// 使用 Unsafe#compareAndSwapInt 方法进行CAS操作
|
||||
public final boolean compareAndSet(int expect, int update) {
|
||||
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
|
||||
}
|
||||
|
||||
// 原子地将当前值加 delta 并返回旧值
|
||||
public final int getAndAdd(int delta) {
|
||||
return unsafe.getAndAddInt(this, valueOffset, delta);
|
||||
}
|
||||
|
||||
// 原子地将当前值加 1 并返回加之前的值(旧值)
|
||||
// 使用 Unsafe#getAndAddInt 方法进行CAS操作。
|
||||
public final int getAndIncrement() {
|
||||
return unsafe.getAndAddInt(this, valueOffset, 1);
|
||||
}
|
||||
|
||||
// 原子地将当前值减 1 并返回减之前的值(旧值)
|
||||
public final int getAndDecrement() {
|
||||
return unsafe.getAndAddInt(this, valueOffset, -1);
|
||||
}
|
||||
```
|
||||
|
||||
`Unsafe#getAndAddInt` 源码:
|
||||
|
||||
```java
|
||||
// 原子地获取并增加整数值
|
||||
public final int getAndAddInt(Object o, long offset, int delta) {
|
||||
int v;
|
||||
do {
|
||||
// 以 volatile 方式获取对象 o 在内存偏移量 offset 处的整数值
|
||||
v = getIntVolatile(o, offset);
|
||||
} while (!compareAndSwapInt(o, offset, v, v + delta));
|
||||
// 返回旧值
|
||||
return v;
|
||||
}
|
||||
```
|
||||
|
||||
可以看到,`getAndAddInt` 使用了 `do-while` 循环:在 `compareAndSwapInt` 操作失败时,会不断重试直到成功。也就是说,`getAndAddInt` 方法会通过 `compareAndSwapInt` 方法来尝试更新 `value` 的值,如果更新失败(当前值在此期间被其他线程修改),它会重新获取当前值并再次尝试更新,直到操作成功。
|
||||
|
||||
由于 CAS 操作可能会因为并发冲突而失败,因此通常会与 `while` 循环搭配使用,在失败后不断重试,直到操作成功。这就是 **自旋锁机制**。
|
||||
|
||||
## CAS 算法存在哪些问题?
|
||||
|
||||
ABA 问题是 CAS 算法最常见的问题。
|
||||
|
||||
### ABA 问题
|
||||
|
||||
如果一个变量 V 初次读取的时候是 A 值,并且在准备赋值的时候检查到它仍然是 A 值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回 A,那 CAS 操作就会误认为它从来没有被修改过。这个问题被称为 CAS 操作的 **"ABA"问题。**
|
||||
|
||||
ABA 问题的解决思路是在变量前面追加上**版本号或者时间戳**。JDK 1.5 以后的 `AtomicStampedReference` 类就是用来解决 ABA 问题的,其中的 `compareAndSet()` 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
|
||||
|
||||
```java
|
||||
public boolean compareAndSet(V expectedReference,
|
||||
V newReference,
|
||||
int expectedStamp,
|
||||
int newStamp) {
|
||||
Pair<V> current = pair;
|
||||
return
|
||||
expectedReference == current.reference &&
|
||||
expectedStamp == current.stamp &&
|
||||
((newReference == current.reference &&
|
||||
newStamp == current.stamp) ||
|
||||
casPair(current, Pair.of(newReference, newStamp)));
|
||||
}
|
||||
```
|
||||
|
||||
### 循环时间长开销大
|
||||
|
||||
CAS 经常会用到自旋操作来进行重试,也就是不成功就一直循环执行直到成功。如果长时间不成功,会给 CPU 带来非常大的执行开销。
|
||||
|
||||
如果 JVM 能够支持处理器提供的 `pause` 指令,那么自旋操作的效率将有所提升。`pause` 指令有两个重要作用:
|
||||
|
||||
1. **延迟流水线执行指令**:`pause` 指令可以延迟指令的执行,从而减少 CPU 的资源消耗。具体的延迟时间取决于处理器的实现版本,在某些处理器上,延迟时间可能为零。
|
||||
2. **避免内存顺序冲突**:在退出循环时,`pause` 指令可以避免由于内存顺序冲突而导致的 CPU 流水线被清空,从而提高 CPU 的执行效率。
|
||||
|
||||
### 只能保证一个共享变量的原子操作
|
||||
|
||||
CAS 操作仅能对单个共享变量有效。当需要操作多个共享变量时,CAS 就显得无能为力。不过,从 JDK 1.5 开始,Java 提供了 `AtomicReference` 类,这使得我们能够保证引用对象之间的原子性。通过将多个变量封装在一个对象中,我们可以使用 `AtomicReference` 来执行 CAS 操作。
|
||||
|
||||
除了 `AtomicReference` 这种方式之外,还可以利用加锁来保证。
|
||||
|
||||
## 总结
|
||||
|
||||
在 Java 中,CAS 通过 `Unsafe` 类中的 `native` 方法实现,这些方法调用底层的硬件指令来完成原子操作。由于其实现依赖于 C++ 内联汇编和 JNI 调用,因此 CAS 的具体实现与操作系统以及 CPU 密切相关。
|
||||
|
||||
CAS 虽然具有高效的无锁特性,但也需要注意 ABA、循环时间长开销大等问题。
|
||||
@@ -0,0 +1,735 @@
|
||||
---
|
||||
title: CompletableFuture 详解
|
||||
description: CompletableFuture异步编程详解:全面讲解CompletableFuture核心API、异步任务编排、thenCompose/thenCombine组合、allOf/anyOf聚合、线程池配置与最佳实践。
|
||||
category: Java
|
||||
tag:
|
||||
- Java并发
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: CompletableFuture,异步编程,异步编排,Future,thenCompose,thenCombine,allOf,并行任务
|
||||
---
|
||||
|
||||
实际项目中,一个接口可能需要同时获取多种不同的数据,然后再汇总返回,这种场景还是挺常见的。举个例子:用户请求获取订单信息,可能需要同时获取用户信息、商品详情、物流信息、商品推荐等数据。
|
||||
|
||||
如果是串行(按顺序依次执行每个任务)执行的话,接口的响应速度会非常慢。考虑到这些任务之间有大部分都是 **无前后顺序关联** 的,可以 **并行执行**,就比如说调用获取商品详情的时候,可以同时调用获取物流信息。通过并行执行多个任务的方式,接口的响应速度会得到大幅优化。
|
||||
|
||||

|
||||
|
||||
对于存在前后调用顺序关系的任务,可以进行任务编排。
|
||||
|
||||

|
||||
|
||||
1. 获取用户信息之后,才能调用商品详情和物流信息接口。
|
||||
2. 成功获取商品详情和物流信息之后,才能调用商品推荐接口。
|
||||
|
||||
可能会用到多线程异步任务编排的场景(这里只是举例,数据不一定是一次返回,可能会对接口进行拆分):
|
||||
|
||||
1. 首页:例如技术社区的首页可能需要同时获取文章推荐列表、广告栏、文章排行榜、热门话题等信息。
|
||||
2. 详情页:例如技术社区的文章详情页可能需要同时获取作者信息、文章详情、文章评论等信息。
|
||||
3. 统计模块:例如技术社区的后台统计模块可能需要同时获取粉丝数汇总、文章数据(阅读量、评论量、收藏量)汇总等信息。
|
||||
|
||||
对于 Java 程序来说,Java 8 才被引入的 `CompletableFuture` 可以帮助我们来做多个任务的编排,功能非常强大。
|
||||
|
||||
这篇文章是 `CompletableFuture` 的简单入门,带大家看看 `CompletableFuture` 常用的 API。
|
||||
|
||||
## Future 介绍
|
||||
|
||||
`Future` 类是异步思想的典型运用,主要用在一些需要执行耗时任务的场景,避免程序一直原地等待耗时任务执行完成,执行效率太低。具体来说是这样的:当我们执行某一耗时的任务时,可以将这个耗时任务交给一个子线程去异步执行,同时我们可以干点其他事情,不用傻傻等待耗时任务执行完成。等我们的事情干完后,我们再通过 `Future` 类获取到耗时任务的执行结果。这样一来,程序的执行效率就明显提高了。
|
||||
|
||||
这其实就是多线程中经典的 **Future 模式**,你可以将其看作是一种设计模式,核心思想是异步调用,主要用在多线程领域,并非 Java 语言独有。
|
||||
|
||||
在 Java 中,`Future` 类只是一个泛型接口,位于 `java.util.concurrent` 包下,其中定义了 5 个方法,主要包括下面这 4 个功能:
|
||||
|
||||
- 取消任务;
|
||||
- 判断任务是否被取消;
|
||||
- 判断任务是否已经执行完成;
|
||||
- 获取任务执行结果。
|
||||
|
||||
```java
|
||||
// V 代表了Future执行的任务返回值的类型
|
||||
public interface Future<V> {
|
||||
// 取消任务执行
|
||||
// 成功取消返回 true,否则返回 false
|
||||
boolean cancel(boolean mayInterruptIfRunning);
|
||||
// 判断任务是否被取消
|
||||
boolean isCancelled();
|
||||
// 判断任务是否已经执行完成
|
||||
boolean isDone();
|
||||
// 获取任务执行结果
|
||||
V get() throws InterruptedException, ExecutionException;
|
||||
// 指定时间内没有返回计算结果就抛出 TimeOutException 异常
|
||||
V get(long timeout, TimeUnit unit)
|
||||
|
||||
throws InterruptedException, ExecutionException, TimeoutExceptio
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
简单理解就是:我有一个任务,提交给了 `Future` 来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 `Future` 那里直接取出任务执行结果。
|
||||
|
||||
## CompletableFuture 介绍
|
||||
|
||||
`Future` 在实际使用过程中存在一些局限性,比如不支持异步任务的编排组合、获取计算结果的 `get()` 方法为阻塞调用。
|
||||
|
||||
Java 8 才被引入 `CompletableFuture` 类可以解决 `Future` 的这些缺陷。`CompletableFuture` 除了提供了更为好用和强大的 `Future` 特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。
|
||||
|
||||
下面我们来简单看看 `CompletableFuture` 类的定义。
|
||||
|
||||
```java
|
||||
public class CompletableFuture<T> implements Future<T>, CompletionStage<T> {
|
||||
}
|
||||
```
|
||||
|
||||
可以看到,`CompletableFuture` 同时实现了 `Future` 和 `CompletionStage` 接口。
|
||||
|
||||

|
||||
|
||||
`CompletableFuture` 除了提供了更为好用和强大的 `Future` 特性之外,还提供了函数式编程的能力。
|
||||
|
||||

|
||||
|
||||
`Future` 接口有 5 个方法:
|
||||
|
||||
- `boolean cancel(boolean mayInterruptIfRunning)`:尝试取消执行任务。
|
||||
- `boolean isCancelled()`:判断任务是否被取消。
|
||||
- `boolean isDone()`:判断任务是否已经被执行完成。
|
||||
- `get()`:等待任务执行完成并获取运算结果。
|
||||
- `get(long timeout, TimeUnit unit)`:多了一个超时时间。
|
||||
|
||||
`CompletionStage` 接口描述了一个异步计算的阶段。很多计算可以分成多个阶段或步骤,此时可以通过它将所有步骤组合起来,形成异步计算的流水线。
|
||||
|
||||
`CompletionStage` 接口中的方法比较多,`CompletableFuture` 的函数式能力就是这个接口赋予的。从这个接口的方法参数你就可以发现其大量使用了 Java8 引入的函数式编程。
|
||||
|
||||

|
||||
|
||||
由于方法众多,所以这里不能一一讲解,下文中我会介绍大部分常见方法的使用。
|
||||
|
||||
## CompletableFuture 常见操作
|
||||
|
||||
### 创建 CompletableFuture
|
||||
|
||||
常见的创建 `CompletableFuture` 对象的方法如下:
|
||||
|
||||
1. 通过 new 关键字。
|
||||
2. 基于 `CompletableFuture` 自带的静态工厂方法:`runAsync()`、`supplyAsync()`。
|
||||
|
||||
#### new 关键字
|
||||
|
||||
通过 new 关键字创建 `CompletableFuture` 对象这种使用方式可以看作是将 `CompletableFuture` 当做 `Future` 来使用。
|
||||
|
||||
我在我的开源项目 [guide-rpc-framework](https://github.com/Snailclimb/guide-rpc-framework) 中就是这种方式创建的 `CompletableFuture` 对象。
|
||||
|
||||
下面咱们来看一个简单的案例。
|
||||
|
||||
我们通过创建了一个结果值类型为 `RpcResponse<Object>` 的 `CompletableFuture`,你可以把 `resultFuture` 看作是异步运算结果的载体。
|
||||
|
||||
```java
|
||||
CompletableFuture<RpcResponse<Object>> resultFuture = new CompletableFuture<>();
|
||||
```
|
||||
|
||||
假设在未来的某个时刻,我们得到了最终的结果。这时,我们可以调用 `complete()` 方法为其传入结果,这表示 `resultFuture` 已经被完成了。
|
||||
|
||||
```java
|
||||
// complete() 方法只能调用一次,后续调用将被忽略。
|
||||
resultFuture.complete(rpcResponse);
|
||||
```
|
||||
|
||||
你可以通过 `isDone()` 方法来检查是否已经完成。
|
||||
|
||||
```java
|
||||
public boolean isDone() {
|
||||
return result != null;
|
||||
}
|
||||
```
|
||||
|
||||
获取异步计算的结果也非常简单,直接调用 `get()` 方法即可。调用 `get()` 方法的线程会阻塞直到 `CompletableFuture` 完成运算。
|
||||
|
||||
```java
|
||||
rpcResponse = completableFuture.get();
|
||||
```
|
||||
|
||||
如果你已经知道计算的结果的话,可以使用静态方法 `completedFuture()` 来创建 `CompletableFuture`。
|
||||
|
||||
```java
|
||||
CompletableFuture<String> future = CompletableFuture.completedFuture("hello!");
|
||||
assertEquals("hello!", future.get());
|
||||
```
|
||||
|
||||
`completedFuture()` 方法底层调用的是带参数的 new 方法,只不过,这个方法不对外暴露。
|
||||
|
||||
```java
|
||||
public static <U> CompletableFuture<U> completedFuture(U value) {
|
||||
return new CompletableFuture<U>((value == null) ? NIL : value);
|
||||
}
|
||||
```
|
||||
|
||||
#### 静态工厂方法
|
||||
|
||||
这两个方法可以帮助我们封装计算逻辑。
|
||||
|
||||
```java
|
||||
static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier);
|
||||
// 使用自定义线程池(推荐)
|
||||
static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier, Executor executor);
|
||||
static CompletableFuture<Void> runAsync(Runnable runnable);
|
||||
// 使用自定义线程池(推荐)
|
||||
static CompletableFuture<Void> runAsync(Runnable runnable, Executor executor);
|
||||
```
|
||||
|
||||
`runAsync()` 方法接受的参数是 `Runnable`,这是一个函数式接口,不允许返回值。当你需要异步操作且不关心返回结果的时候可以使用 `runAsync()` 方法。
|
||||
|
||||
```java
|
||||
@FunctionalInterface
|
||||
public interface Runnable {
|
||||
public abstract void run();
|
||||
}
|
||||
```
|
||||
|
||||
`supplyAsync()` 方法接受的参数是 `Supplier<U>`,这也是一个函数式接口,`U` 是返回结果值的类型。
|
||||
|
||||
```java
|
||||
@FunctionalInterface
|
||||
public interface Supplier<T> {
|
||||
|
||||
/**
|
||||
* Gets a result.
|
||||
*
|
||||
* @return a result
|
||||
*/
|
||||
T get();
|
||||
}
|
||||
```
|
||||
|
||||
当你需要异步操作且关心返回结果的时候,可以使用 `supplyAsync()` 方法。
|
||||
|
||||
```java
|
||||
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> System.out.println("hello!"));
|
||||
future.get();// 输出 "hello!"
|
||||
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "hello!");
|
||||
assertEquals("hello!", future2.get());
|
||||
```
|
||||
|
||||
### 处理异步结算的结果
|
||||
|
||||
当我们获取到异步计算的结果之后,还可以对其进行进一步的处理,比较常用的方法有下面几个:
|
||||
|
||||
- `thenApply()`
|
||||
- `thenAccept()`
|
||||
- `thenRun()`
|
||||
- `whenComplete()`
|
||||
|
||||
`thenApply()` 方法接受一个 `Function` 实例,用它来处理结果。
|
||||
|
||||
```java
|
||||
// 沿用上一个任务的线程池
|
||||
public <U> CompletableFuture<U> thenApply(
|
||||
Function<? super T,? extends U> fn) {
|
||||
return uniApplyStage(null, fn);
|
||||
}
|
||||
|
||||
//使用默认的 ForkJoinPool 线程池(不推荐)
|
||||
public <U> CompletableFuture<U> thenApplyAsync(
|
||||
Function<? super T,? extends U> fn) {
|
||||
return uniApplyStage(defaultExecutor(), fn);
|
||||
}
|
||||
// 使用自定义线程池(推荐)
|
||||
public <U> CompletableFuture<U> thenApplyAsync(
|
||||
Function<? super T,? extends U> fn, Executor executor) {
|
||||
return uniApplyStage(screenExecutor(executor), fn);
|
||||
}
|
||||
```
|
||||
|
||||
`thenApply()` 方法使用示例如下:
|
||||
|
||||
```java
|
||||
CompletableFuture<String> future = CompletableFuture.completedFuture("hello!")
|
||||
.thenApply(s -> s + "world!");
|
||||
assertEquals("hello!world!", future.get());
|
||||
// 这次调用将被忽略。
|
||||
future.thenApply(s -> s + "nice!");
|
||||
assertEquals("hello!world!", future.get());
|
||||
```
|
||||
|
||||
你还可以进行 **流式调用**:
|
||||
|
||||
```java
|
||||
CompletableFuture<String> future = CompletableFuture.completedFuture("hello!")
|
||||
.thenApply(s -> s + "world!").thenApply(s -> s + "nice!");
|
||||
assertEquals("hello!world!nice!", future.get());
|
||||
```
|
||||
|
||||
**如果你不需要从回调函数中获取返回结果,可以使用 `thenAccept()` 或者 `thenRun()`。这两个方法的区别在于 `thenRun()` 不能访问异步计算的结果。**
|
||||
|
||||
`thenAccept()` 方法的参数是 `Consumer<? super T>`。
|
||||
|
||||
```java
|
||||
public CompletableFuture<Void> thenAccept(Consumer<? super T> action) {
|
||||
return uniAcceptStage(null, action);
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> thenAcceptAsync(Consumer<? super T> action) {
|
||||
return uniAcceptStage(defaultExecutor(), action);
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> thenAcceptAsync(Consumer<? super T> action,
|
||||
Executor executor) {
|
||||
return uniAcceptStage(screenExecutor(executor), action);
|
||||
}
|
||||
```
|
||||
|
||||
顾名思义,`Consumer` 属于消费型接口,它可以接收 1 个输入对象然后进行“消费”。
|
||||
|
||||
```java
|
||||
@FunctionalInterface
|
||||
public interface Consumer<T> {
|
||||
|
||||
void accept(T t);
|
||||
|
||||
default Consumer<T> andThen(Consumer<? super T> after) {
|
||||
Objects.requireNonNull(after);
|
||||
return (T t) -> { accept(t); after.accept(t); };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`thenRun()` 的方法是的参数是 `Runnable`。
|
||||
|
||||
```java
|
||||
public CompletableFuture<Void> thenRun(Runnable action) {
|
||||
return uniRunStage(null, action);
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> thenRunAsync(Runnable action) {
|
||||
return uniRunStage(defaultExecutor(), action);
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> thenRunAsync(Runnable action,
|
||||
Executor executor) {
|
||||
return uniRunStage(screenExecutor(executor), action);
|
||||
}
|
||||
```
|
||||
|
||||
`thenAccept()` 和 `thenRun()` 使用示例如下:
|
||||
|
||||
```java
|
||||
CompletableFuture.completedFuture("hello!")
|
||||
.thenApply(s -> s + "world!").thenApply(s -> s + "nice!").thenAccept(System.out::println);//hello!world!nice!
|
||||
|
||||
CompletableFuture.completedFuture("hello!")
|
||||
.thenApply(s -> s + "world!").thenApply(s -> s + "nice!").thenRun(() -> System.out.println("hello!"));//hello!
|
||||
```
|
||||
|
||||
`whenComplete()` 的方法的参数是 `BiConsumer<? super T, ? super Throwable>`。
|
||||
|
||||
```java
|
||||
public CompletableFuture<T> whenComplete(
|
||||
BiConsumer<? super T, ? super Throwable> action) {
|
||||
return uniWhenCompleteStage(null, action);
|
||||
}
|
||||
|
||||
|
||||
public CompletableFuture<T> whenCompleteAsync(
|
||||
BiConsumer<? super T, ? super Throwable> action) {
|
||||
return uniWhenCompleteStage(defaultExecutor(), action);
|
||||
}
|
||||
// 使用自定义线程池(推荐)
|
||||
public CompletableFuture<T> whenCompleteAsync(
|
||||
BiConsumer<? super T, ? super Throwable> action, Executor executor) {
|
||||
return uniWhenCompleteStage(screenExecutor(executor), action);
|
||||
}
|
||||
```
|
||||
|
||||
相对于 `Consumer`, `BiConsumer` 可以接收 2 个输入对象然后进行“消费”。
|
||||
|
||||
```java
|
||||
@FunctionalInterface
|
||||
public interface BiConsumer<T, U> {
|
||||
void accept(T t, U u);
|
||||
|
||||
default BiConsumer<T, U> andThen(BiConsumer<? super T, ? super U> after) {
|
||||
Objects.requireNonNull(after);
|
||||
|
||||
return (l, r) -> {
|
||||
accept(l, r);
|
||||
after.accept(l, r);
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`whenComplete()` 使用示例如下:
|
||||
|
||||
```java
|
||||
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "hello!")
|
||||
.whenComplete((res, ex) -> {
|
||||
// res 代表返回的结果
|
||||
// ex 的类型为 Throwable ,代表抛出的异常
|
||||
System.out.println(res);
|
||||
// 这里没有抛出异常所有为 null
|
||||
assertNull(ex);
|
||||
});
|
||||
assertEquals("hello!", future.get());
|
||||
```
|
||||
|
||||
### 异常处理
|
||||
|
||||
你可以通过 `handle()` 方法来处理任务执行过程中可能出现的抛出异常的情况。
|
||||
|
||||
```java
|
||||
public <U> CompletableFuture<U> handle(
|
||||
BiFunction<? super T, Throwable, ? extends U> fn) {
|
||||
return uniHandleStage(null, fn);
|
||||
}
|
||||
|
||||
public <U> CompletableFuture<U> handleAsync(
|
||||
BiFunction<? super T, Throwable, ? extends U> fn) {
|
||||
return uniHandleStage(defaultExecutor(), fn);
|
||||
}
|
||||
|
||||
public <U> CompletableFuture<U> handleAsync(
|
||||
BiFunction<? super T, Throwable, ? extends U> fn, Executor executor) {
|
||||
return uniHandleStage(screenExecutor(executor), fn);
|
||||
}
|
||||
```
|
||||
|
||||
示例代码如下:
|
||||
|
||||
```java
|
||||
CompletableFuture<String> future
|
||||
= CompletableFuture.supplyAsync(() -> {
|
||||
if (true) {
|
||||
throw new RuntimeException("Computation error!");
|
||||
}
|
||||
return "hello!";
|
||||
}).handle((res, ex) -> {
|
||||
// res 代表返回的结果
|
||||
// ex 的类型为 Throwable ,代表抛出的异常
|
||||
return res != null ? res : "world!";
|
||||
});
|
||||
assertEquals("world!", future.get());
|
||||
```
|
||||
|
||||
你还可以通过 `exceptionally()` 方法来处理异常情况。
|
||||
|
||||
```java
|
||||
CompletableFuture<String> future
|
||||
= CompletableFuture.supplyAsync(() -> {
|
||||
if (true) {
|
||||
throw new RuntimeException("Computation error!");
|
||||
}
|
||||
return "hello!";
|
||||
}).exceptionally(ex -> {
|
||||
System.out.println(ex.toString());// CompletionException
|
||||
return "world!";
|
||||
});
|
||||
assertEquals("world!", future.get());
|
||||
```
|
||||
|
||||
如果你想让 `CompletableFuture` 的结果就是异常的话,可以使用 `completeExceptionally()` 方法为其赋值。
|
||||
|
||||
```java
|
||||
CompletableFuture<String> completableFuture = new CompletableFuture<>();
|
||||
// ...
|
||||
completableFuture.completeExceptionally(
|
||||
new RuntimeException("Calculation failed!"));
|
||||
// ...
|
||||
completableFuture.get(); // ExecutionException
|
||||
```
|
||||
|
||||
### 组合 CompletableFuture
|
||||
|
||||
你可以使用 `thenCompose()` 按顺序链接两个 `CompletableFuture` 对象,实现异步的任务链。它的作用是将前一个任务的返回结果作为下一个任务的输入参数,从而形成一个依赖关系。
|
||||
|
||||
```java
|
||||
public <U> CompletableFuture<U> thenCompose(
|
||||
Function<? super T, ? extends CompletionStage<U>> fn) {
|
||||
return uniComposeStage(null, fn);
|
||||
}
|
||||
|
||||
public <U> CompletableFuture<U> thenComposeAsync(
|
||||
Function<? super T, ? extends CompletionStage<U>> fn) {
|
||||
return uniComposeStage(defaultExecutor(), fn);
|
||||
}
|
||||
|
||||
public <U> CompletableFuture<U> thenComposeAsync(
|
||||
Function<? super T, ? extends CompletionStage<U>> fn,
|
||||
Executor executor) {
|
||||
return uniComposeStage(screenExecutor(executor), fn);
|
||||
}
|
||||
```
|
||||
|
||||
`thenCompose()` 方法会使用示例如下:
|
||||
|
||||
```java
|
||||
CompletableFuture<String> future
|
||||
= CompletableFuture.supplyAsync(() -> "hello!")
|
||||
.thenCompose(s -> CompletableFuture.supplyAsync(() -> s + "world!"));
|
||||
assertEquals("hello!world!", future.get());
|
||||
```
|
||||
|
||||
在实际开发中,这个方法还是非常有用的。比如说,task1 和 task2 都是异步执行的,但 task1 必须执行完成后才能开始执行 task2(task2 依赖 task1 的执行结果)。
|
||||
|
||||
和 `thenCompose()` 方法类似的还有 `thenCombine()` 方法, 它同样可以组合两个 `CompletableFuture` 对象。
|
||||
|
||||
```java
|
||||
CompletableFuture<String> completableFuture
|
||||
= CompletableFuture.supplyAsync(() -> "hello!")
|
||||
.thenCombine(CompletableFuture.supplyAsync(
|
||||
() -> "world!"), (s1, s2) -> s1 + s2)
|
||||
.thenCompose(s -> CompletableFuture.supplyAsync(() -> s + "nice!"));
|
||||
assertEquals("hello!world!nice!", completableFuture.get());
|
||||
```
|
||||
|
||||
**那 `thenCompose()` 和 `thenCombine()` 有什么区别呢?**
|
||||
|
||||
- `thenCompose()` 可以链接两个 `CompletableFuture` 对象,并将前一个任务的返回结果作为下一个任务的参数,它们之间存在着先后顺序。
|
||||
- `thenCombine()` 会在两个任务都执行完成后,把两个任务的结果合并。两个任务是并行执行的,它们之间并没有先后依赖顺序。
|
||||
|
||||
除了 `thenCompose()` 和 `thenCombine()` 之外, 还有一些其他的组合 `CompletableFuture` 的方法用于实现不同的效果,满足不同的业务需求。
|
||||
|
||||
例如,如果我们想要实现 task1 和 task2 中的任意一个任务执行完后就执行 task3 的话,可以使用 `acceptEither()`。
|
||||
|
||||
```java
|
||||
public CompletableFuture<Void> acceptEither(
|
||||
CompletionStage<? extends T> other, Consumer<? super T> action) {
|
||||
return orAcceptStage(null, other, action);
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> acceptEitherAsync(
|
||||
CompletionStage<? extends T> other, Consumer<? super T> action) {
|
||||
return orAcceptStage(asyncPool, other, action);
|
||||
}
|
||||
```
|
||||
|
||||
简单举一个例子:
|
||||
|
||||
```java
|
||||
CompletableFuture<String> task = CompletableFuture.supplyAsync(() -> {
|
||||
System.out.println("任务1开始执行,当前时间:" + System.currentTimeMillis());
|
||||
try {
|
||||
Thread.sleep(500);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
System.out.println("任务1执行完毕,当前时间:" + System.currentTimeMillis());
|
||||
return "task1";
|
||||
});
|
||||
|
||||
CompletableFuture<String> task2 = CompletableFuture.supplyAsync(() -> {
|
||||
System.out.println("任务2开始执行,当前时间:" + System.currentTimeMillis());
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
System.out.println("任务2执行完毕,当前时间:" + System.currentTimeMillis());
|
||||
return "task2";
|
||||
});
|
||||
|
||||
task.acceptEitherAsync(task2, (res) -> {
|
||||
System.out.println("任务3开始执行,当前时间:" + System.currentTimeMillis());
|
||||
System.out.println("上一个任务的结果为:" + res);
|
||||
});
|
||||
|
||||
// 增加一些延迟时间,确保异步任务有足够的时间完成
|
||||
try {
|
||||
Thread.sleep(2000);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```plain
|
||||
任务1开始执行,当前时间:1695088058520
|
||||
任务2开始执行,当前时间:1695088058521
|
||||
任务1执行完毕,当前时间:1695088059023
|
||||
任务3开始执行,当前时间:1695088059023
|
||||
上一个任务的结果为:task1
|
||||
任务2执行完毕,当前时间:1695088059523
|
||||
```
|
||||
|
||||
任务组合操作 `acceptEitherAsync()` 会在异步任务 1 和异步任务 2 中的任意一个完成时触发执行任务 3,但是需要注意,这个触发时机是不确定的。如果任务 1 和任务 2 都还未完成,那么任务 3 就不能被执行。
|
||||
|
||||
### 并行运行多个 CompletableFuture
|
||||
|
||||
你可以通过 `CompletableFuture` 的 `allOf()` 这个静态方法来并行运行多个 `CompletableFuture`。
|
||||
|
||||
实际项目中,我们经常需要并行运行多个互不相关的任务,这些任务之间没有依赖关系,可以互相独立地运行。
|
||||
|
||||
比说我们要读取处理 6 个文件,这 6 个任务都是没有执行顺序依赖的任务,但是我们需要返回给用户的时候将这几个文件的处理的结果进行统计整理。像这种情况我们就可以使用并行运行多个 `CompletableFuture` 来处理。
|
||||
|
||||
示例代码如下:
|
||||
|
||||
```java
|
||||
CompletableFuture<Void> task1 =
|
||||
CompletableFuture.supplyAsync(()->{
|
||||
//自定义业务操作
|
||||
});
|
||||
......
|
||||
CompletableFuture<Void> task6 =
|
||||
CompletableFuture.supplyAsync(()->{
|
||||
//自定义业务操作
|
||||
});
|
||||
......
|
||||
CompletableFuture<Void> headerFuture=CompletableFuture.allOf(task1,.....,task6);
|
||||
|
||||
try {
|
||||
headerFuture.join();
|
||||
} catch (Exception ex) {
|
||||
......
|
||||
}
|
||||
System.out.println("all done. ");
|
||||
```
|
||||
|
||||
经常和 `allOf()` 方法拿来对比的是 `anyOf()` 方法。
|
||||
|
||||
**`allOf()` 方法会等到所有的 `CompletableFuture` 都运行完成之后再返回**
|
||||
|
||||
```java
|
||||
Random rand = new Random();
|
||||
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
Thread.sleep(1000 + rand.nextInt(1000));
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
System.out.println("future1 done...");
|
||||
}
|
||||
return "abc";
|
||||
});
|
||||
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
Thread.sleep(1000 + rand.nextInt(1000));
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
System.out.println("future2 done...");
|
||||
}
|
||||
return "efg";
|
||||
});
|
||||
```
|
||||
|
||||
调用 `join()` 可以让程序等 `future1` 和 `future2` 都运行完了之后再继续执行。
|
||||
|
||||
```java
|
||||
CompletableFuture<Void> completableFuture = CompletableFuture.allOf(future1, future2);
|
||||
completableFuture.join();
|
||||
assertTrue(completableFuture.isDone());
|
||||
System.out.println("all futures done...");
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```plain
|
||||
future1 done...
|
||||
future2 done...
|
||||
all futures done...
|
||||
```
|
||||
|
||||
**`anyOf()` 方法不会等待所有的 `CompletableFuture` 都运行完成之后再返回,只要有一个执行完成即可!**
|
||||
|
||||
```java
|
||||
CompletableFuture<Object> f = CompletableFuture.anyOf(future1, future2);
|
||||
System.out.println(f.get());
|
||||
```
|
||||
|
||||
输出结果可能是:
|
||||
|
||||
```plain
|
||||
future2 done...
|
||||
efg
|
||||
```
|
||||
|
||||
也可能是:
|
||||
|
||||
```plain
|
||||
future1 done...
|
||||
abc
|
||||
```
|
||||
|
||||
## CompletableFuture 使用建议
|
||||
|
||||
### 使用自定义线程池
|
||||
|
||||
我们上面的代码示例中,为了方便,都没有选择自定义线程池。实际项目中,这是不可取的。
|
||||
|
||||
`CompletableFuture` 默认使用全局共享的 `ForkJoinPool.commonPool()` 作为执行器,所有未指定执行器的异步任务都会使用该线程池。这意味着应用程序、多个库或框架(如 Spring、第三方库)若都依赖 `CompletableFuture`,默认情况下它们都会共享同一个线程池。
|
||||
|
||||
虽然 `ForkJoinPool` 效率很高,但当同时提交大量任务时,可能会导致资源竞争和线程饥饿,进而影响系统性能。
|
||||
|
||||
为避免这些问题,建议为 `CompletableFuture` 提供自定义线程池,带来以下优势:
|
||||
|
||||
- **隔离性**:为不同任务分配独立的线程池,避免全局线程池资源争夺。
|
||||
- **资源控制**:根据任务特性调整线程池大小和队列类型,优化性能表现。
|
||||
- **异常处理**:通过自定义 `ThreadFactory` 更好地处理线程中的异常情况。
|
||||
|
||||
```java
|
||||
private ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10,
|
||||
0L, TimeUnit.MILLISECONDS,
|
||||
new LinkedBlockingQueue<Runnable>());
|
||||
|
||||
CompletableFuture.runAsync(() -> {
|
||||
//...
|
||||
}, executor);
|
||||
```
|
||||
|
||||
### 尽量避免使用 get()
|
||||
|
||||
`CompletableFuture` 的 `get()` 方法是阻塞的,尽量避免使用。如果必须要使用的话,需要添加超时时间,否则可能会导致主线程一直等待,无法执行其他任务。
|
||||
|
||||
```java
|
||||
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
Thread.sleep(10_000);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return "Hello, world!";
|
||||
});
|
||||
|
||||
// 获取异步任务的返回值,设置超时时间为 5 秒
|
||||
try {
|
||||
String result = future.get(5, TimeUnit.SECONDS);
|
||||
System.out.println(result);
|
||||
} catch (InterruptedException | ExecutionException | TimeoutException e) {
|
||||
// 处理异常
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
上面这段代码在调用 `get()` 时抛出了 `TimeoutException` 异常。这样我们就可以在异常处理中进行相应的操作,比如取消任务、重试任务、记录日志等。
|
||||
|
||||
### 正确进行异常处理
|
||||
|
||||
使用 `CompletableFuture` 的时候一定要以正确的方式进行异常处理,避免异常丢失或者出现不可控问题。
|
||||
|
||||
下面是一些建议:
|
||||
|
||||
- 使用 `whenComplete` 方法可以在任务完成时触发回调函数,并正确地处理异常,而不是让异常被吞噬或丢失。
|
||||
- 使用 `exceptionally` 方法可以处理异常并重新抛出,以便异常能够传播到后续阶段,而不是让异常被忽略或终止。
|
||||
- 使用 `handle` 方法可以处理正常的返回结果和异常,并返回一个新的结果,而不是让异常影响正常的业务逻辑。
|
||||
- 使用 `CompletableFuture.allOf` 方法可以组合多个 `CompletableFuture`,并统一处理所有任务的异常,而不是让异常处理过于冗长或重复。
|
||||
- ……
|
||||
|
||||
### 合理组合多个异步任务
|
||||
|
||||
正确使用 `thenCompose()`、 `thenCombine()`、`acceptEither()`、`allOf()`、`anyOf()` 等方法来组合多个异步任务,以满足实际业务的需求,提高程序执行效率。
|
||||
|
||||
实际使用中,我们还可以利用或者参考现成的异步任务编排框架,比如京东的 [asyncTool](https://gitee.com/jd-platform-opensource/asyncTool)。
|
||||
|
||||

|
||||
|
||||
## 后记
|
||||
|
||||
这篇文章只是简单介绍了 `CompletableFuture` 的核心概念和比较常用的一些 API。如果想要深入学习的话,还可以多找一些书籍和博客看,比如下面几篇文章就挺不错:
|
||||
|
||||
- [CompletableFuture 原理与实践-外卖商家端 API 的异步化 - 美团技术团队](https://tech.meituan.com/2022/05/12/principles-and-practices-of-completablefuture.html):这篇文章详细介绍了 `CompletableFuture` 在实际项目中的运用。参考这篇文章,可以对项目中类似的场景进行优化,也算是一个小亮点了。这种性能优化方式比较简单且效果还不错!
|
||||
- [读 RocketMQ 源码,学习并发编程三大神器 - 勇哥 java 实战分享](https://mp.weixin.qq.com/s/32Ak-WFLynQfpn0Cg0N-0A):这篇文章介绍了 RocketMQ 对 `CompletableFuture` 的应用。具体来说,从 RocketMQ 4.7 开始,RocketMQ 引入了 `CompletableFuture` 来实现异步消息处理。
|
||||
|
||||
另外,建议 G 友们可以看看京东的 [asyncTool](https://gitee.com/jd-platform-opensource/asyncTool) 这个并发框架,里面大量使用到了 `CompletableFuture`。
|
||||
|
||||
<!-- @include: @article-footer.snippet.md -->
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,166 @@
|
||||
---
|
||||
title: Java 常见并发容器总结
|
||||
description: Java并发容器全面总结:详解ConcurrentHashMap/CopyOnWriteArrayList/BlockingQueue等JUC线程安全容器特性、适用场景与性能对比。
|
||||
category: Java
|
||||
tag:
|
||||
- Java并发
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: Java并发容器,ConcurrentHashMap,CopyOnWriteArrayList,BlockingQueue,ConcurrentLinkedQueue,线程安全容器
|
||||
---
|
||||
|
||||
JDK 提供的这些容器大部分在 `java.util.concurrent` 包中。
|
||||
|
||||
- **`ConcurrentHashMap`** : 线程安全的 `HashMap`
|
||||
- **`CopyOnWriteArrayList`** : 线程安全的 `List`,在读多写少的场合性能非常好,远远好于 `Vector`。
|
||||
- **`ConcurrentLinkedQueue`** : 高效的并发队列,使用链表实现。可以看做一个线程安全的 `LinkedList`,这是一个非阻塞队列。
|
||||
- **`BlockingQueue`** : 这是一个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。
|
||||
- **`ConcurrentSkipListMap`** : 跳表的实现。这是一个 Map,使用跳表的数据结构进行快速查找。
|
||||
|
||||
## ConcurrentHashMap
|
||||
|
||||
我们知道,`HashMap` 是线程不安全的,如果在并发场景下使用,一种常见的解决方式是通过 `Collections.synchronizedMap()` 方法对 `HashMap` 进行包装,使其变为线程安全。不过,这种方式是通过一个全局锁来同步不同线程间的并发访问,会导致严重的性能瓶颈,尤其是在高并发场景下。
|
||||
|
||||
为了解决这一问题,`ConcurrentHashMap` 应运而生,作为 `HashMap` 的线程安全版本,它提供了更高效的并发处理能力。
|
||||
|
||||
在 JDK1.7 的时候,`ConcurrentHashMap` 对整个桶数组进行了分割分段(`Segment`,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
|
||||
|
||||

|
||||
|
||||
到了 JDK1.8 的时候,`ConcurrentHashMap` 取消了 `Segment` 分段锁,采用 `Node + CAS + synchronized` 来保证并发安全。数据结构跟 `HashMap` 1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。
|
||||
|
||||
Java 8 中,锁粒度更细,`synchronized` 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,就不会影响其他 Node 的读写,效率大幅提升。
|
||||
|
||||

|
||||
|
||||
关于 `ConcurrentHashMap` 的详细介绍,请看我写的这篇文章:[`ConcurrentHashMap` 源码分析](./../collection/concurrent-hash-map-source-code.md)。
|
||||
|
||||
## CopyOnWriteArrayList
|
||||
|
||||
在 JDK1.5 之前,如果想要使用并发安全的 `List` 只能选择 `Vector`。而 `Vector` 是一种老旧的集合,已经被淘汰。`Vector` 对于增删改查等方法基本都加了 `synchronized`,这种方式虽然能够保证同步,但这相当于对整个 `Vector` 加上了一把大锁,使得每个方法执行的时候都要去获得锁,导致性能非常低下。
|
||||
|
||||
JDK1.5 引入了 `Java.util.concurrent`(JUC)包,其中提供了很多线程安全且并发性能良好的容器,其中唯一的线程安全 `List` 实现就是 `CopyOnWriteArrayList`。
|
||||
|
||||
对于大部分业务场景来说,读取操作往往是远大于写入操作的。由于读取操作不会对原有数据进行修改,因此,对于每次读取都进行加锁其实是一种资源浪费。相比之下,我们应该允许多个线程同时访问 `List` 的内部数据,毕竟对于读取操作来说是安全的。
|
||||
|
||||
这种思路与 `ReentrantReadWriteLock` 读写锁的设计思想非常类似,即读读不互斥、读写互斥、写写互斥(只有读读不互斥)。`CopyOnWriteArrayList` 更进一步地实现了这一思想。为了将读操作性能发挥到极致,`CopyOnWriteArrayList` 中的读取操作是完全无需加锁的。更加厉害的是,写入操作也不会阻塞读取操作,只有写写才会互斥。这样一来,读操作的性能就可以大幅度提升。
|
||||
|
||||
`CopyOnWriteArrayList` 线程安全的核心在于其采用了 **写时复制(Copy-On-Write)** 的策略,从 `CopyOnWriteArrayList` 的名字就能看出了。
|
||||
|
||||
当需要修改(`add`,`set`、`remove` 等操作) `CopyOnWriteArrayList` 的内容时,不会直接修改原数组,而是会先创建底层数组的副本,对副本数组进行修改,修改完之后再将修改后的数组赋值回去,这样就可以保证写操作不会影响读操作了。
|
||||
|
||||
关于 `CopyOnWriteArrayList` 的详细介绍,请看我写的这篇文章:[`CopyOnWriteArrayList` 源码分析](./../collection/copyonwritearraylist-source-code.md)。
|
||||
|
||||
## ConcurrentLinkedQueue
|
||||
|
||||
Java 提供的线程安全的 `Queue` 可以分为**阻塞队列**和**非阻塞队列**,其中阻塞队列的典型例子是 `BlockingQueue`,非阻塞队列的典型例子是 `ConcurrentLinkedQueue`,在实际应用中要根据实际需要选用阻塞队列或者非阻塞队列。 **阻塞队列可以通过加锁来实现,非阻塞队列可以通过 CAS 操作实现。**
|
||||
|
||||
从名字可以看出,`ConcurrentLinkedQueue` 这个队列使用链表作为其数据结构.`ConcurrentLinkedQueue` 应该算是在高并发环境中性能最好的队列了。它之所有能有很好的性能,是因为其内部复杂的实现。
|
||||
|
||||
`ConcurrentLinkedQueue` 内部代码我们就不分析了,大家知道 `ConcurrentLinkedQueue` 主要使用 CAS 非阻塞算法来实现线程安全就好了。
|
||||
|
||||
`ConcurrentLinkedQueue` 适合在对性能要求相对较高,同时对队列的读写存在多个线程同时进行的场景,即如果对队列加锁的成本较高则适合使用无锁的 `ConcurrentLinkedQueue` 来替代。
|
||||
|
||||
## BlockingQueue
|
||||
|
||||
### BlockingQueue 简介
|
||||
|
||||
上面我们己经提到了 `ConcurrentLinkedQueue` 作为高性能的非阻塞队列。下面我们要讲到的是阻塞队列——`BlockingQueue`。阻塞队列(`BlockingQueue`)被广泛使用在“生产者-消费者”问题中,其原因是 `BlockingQueue` 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。
|
||||
|
||||
`BlockingQueue` 是一个接口,继承自 `Queue`,所以其实现类也可以作为 `Queue` 的实现来使用,而 `Queue` 又继承自 `Collection` 接口。下面是 `BlockingQueue` 的相关实现类:
|
||||
|
||||

|
||||
|
||||
下面主要介绍一下 3 个常见的 `BlockingQueue` 的实现类:`ArrayBlockingQueue`、`LinkedBlockingQueue`、`PriorityBlockingQueue`。
|
||||
|
||||
### ArrayBlockingQueue
|
||||
|
||||
`ArrayBlockingQueue` 是 `BlockingQueue` 接口的有界队列实现类,底层采用数组来实现。
|
||||
|
||||
```java
|
||||
public class ArrayBlockingQueue<E>
|
||||
extends AbstractQueue<E>
|
||||
implements BlockingQueue<E>, Serializable{}
|
||||
```
|
||||
|
||||
`ArrayBlockingQueue` 一旦创建,容量不能改变。其并发控制采用可重入锁 `ReentrantLock`,不管是插入操作还是读取操作,都需要获取到锁才能进行操作。当队列容量满时,尝试将元素放入队列将导致操作阻塞;尝试从一个空队列中取一个元素也会同样阻塞。
|
||||
|
||||
`ArrayBlockingQueue` 默认情况下不能保证线程访问队列的公平性,所谓公平性是指严格按照线程等待的绝对时间顺序,即最先等待的线程能够最先访问到 `ArrayBlockingQueue`。而非公平性则是指访问 `ArrayBlockingQueue` 的顺序不是遵守严格的时间顺序,有可能存在,当 `ArrayBlockingQueue` 可以被访问时,长时间阻塞的线程依然无法访问到 `ArrayBlockingQueue`。如果保证公平性,通常会降低吞吐量。如果需要获得公平性的 `ArrayBlockingQueue`,可采用如下代码:
|
||||
|
||||
```java
|
||||
private static ArrayBlockingQueue<Integer> blockingQueue = new ArrayBlockingQueue<Integer>(10,true);
|
||||
```
|
||||
|
||||
### LinkedBlockingQueue
|
||||
|
||||
`LinkedBlockingQueue` 底层基于**单向链表**实现的阻塞队列,可以当做无界队列也可以当做有界队列来使用,同样满足 FIFO 的特性,与 `ArrayBlockingQueue` 相比起来具有更高的吞吐量,为了防止 `LinkedBlockingQueue` 容量迅速增,损耗大量内存。通常在创建 `LinkedBlockingQueue` 对象时,会指定其大小,如果未指定,容量等于 `Integer.MAX_VALUE`。
|
||||
|
||||
**相关构造方法:**
|
||||
|
||||
```java
|
||||
/**
|
||||
*某种意义上的无界队列
|
||||
* Creates a {@code LinkedBlockingQueue} with a capacity of
|
||||
* {@link Integer#MAX_VALUE}.
|
||||
*/
|
||||
public LinkedBlockingQueue() {
|
||||
this(Integer.MAX_VALUE);
|
||||
}
|
||||
|
||||
/**
|
||||
*有界队列
|
||||
* Creates a {@code LinkedBlockingQueue} with the given (fixed) capacity.
|
||||
*
|
||||
* @param capacity the capacity of this queue
|
||||
* @throws IllegalArgumentException if {@code capacity} is not greater
|
||||
* than zero
|
||||
*/
|
||||
public LinkedBlockingQueue(int capacity) {
|
||||
if (capacity <= 0) throw new IllegalArgumentException();
|
||||
this.capacity = capacity;
|
||||
last = head = new Node<E>(null);
|
||||
}
|
||||
```
|
||||
|
||||
### PriorityBlockingQueue
|
||||
|
||||
`PriorityBlockingQueue` 是一个支持优先级的无界阻塞队列。默认情况下元素采用自然顺序进行排序,也可以通过自定义类实现 `compareTo()` 方法来指定元素排序规则,或者初始化时通过构造器参数 `Comparator` 来指定排序规则。
|
||||
|
||||
`PriorityBlockingQueue` 并发控制采用的是可重入锁 `ReentrantLock`,队列为无界队列(`ArrayBlockingQueue` 是有界队列,`LinkedBlockingQueue` 也可以通过在构造函数中传入 `capacity` 指定队列最大的容量,但是 `PriorityBlockingQueue` 只能指定初始的队列大小,后面插入元素的时候,**如果空间不够的话会自动扩容**)。
|
||||
|
||||
简单地说,它就是 `PriorityQueue` 的线程安全版本。不可以插入 null 值,同时,插入队列的对象必须是可比较大小的(comparable),否则报 `ClassCastException` 异常。它的插入操作 put 方法不会 block,因为它是无界队列(take 方法在队列为空的时候会阻塞)。
|
||||
|
||||
**推荐文章:** [《解读 Java 并发队列 BlockingQueue》](https://javadoop.com/post/java-concurrent-queue)
|
||||
|
||||
## ConcurrentSkipListMap
|
||||
|
||||
> 下面这部分内容参考了极客时间专栏[《数据结构与算法之美》](https://time.geekbang.org/column/intro/126?code=zl3GYeAsRI4rEJIBNu5B/km7LSZsPDlGWQEpAYw5Vu0=&utm_term=SPoster “《数据结构与算法之美》”)以及《实战 Java 高并发程序设计》。
|
||||
|
||||
为了引出 `ConcurrentSkipListMap`,先带着大家简单理解一下跳表。
|
||||
|
||||
对于一个单链表,即使链表是有序的,如果我们想要在其中查找某个数据,也只能从头到尾遍历链表,这样效率自然就会很低,跳表就不一样了。跳表是一种可以用来快速查找的数据结构,有点类似于平衡树。它们都可以对元素进行快速的查找。但一个重要的区别是:对平衡树的插入和删除往往很可能导致平衡树进行一次全局的调整。而对跳表的插入和删除只需要对整个数据结构的局部进行操作即可。这样带来的好处是:在高并发的情况下,你会需要一个全局锁来保证整个平衡树的线程安全。而对于跳表,你只需要部分锁即可。这样,在高并发环境下,你就可以拥有更好的性能。而就查询的性能而言,跳表的时间复杂度也是 **O(logn)** 所以在并发数据结构中,JDK 使用跳表来实现一个 Map。
|
||||
|
||||
跳表的本质是同时维护了多个链表,并且链表是分层的,
|
||||
|
||||

|
||||
|
||||
最低层的链表维护了跳表内所有的元素,每上面一层链表都是下面一层的子集。
|
||||
|
||||
跳表内的所有链表的元素都是排序的。查找时,可以从顶级链表开始找。一旦发现被查找的元素小于当前访问节点的后继节点(或后继节点为空),就会转入下一层链表继续找。这也就是说在查找过程中,搜索是跳跃式的。如上图所示,在跳表中查找元素 18。
|
||||
|
||||

|
||||
|
||||
查找 18 的时候原来需要遍历 18 次,现在只需要 7 次即可。针对链表长度比较大的时候,构建索引查找效率的提升就会非常明显。
|
||||
|
||||
从上面很容易看出,**跳表是一种利用空间换时间的算法。**
|
||||
|
||||
使用跳表实现 `Map` 和使用哈希算法实现 `Map` 的另外一个不同之处是:哈希并不会保存元素的顺序,而跳表内所有的元素都是排序的。因此在对跳表进行遍历时,你会得到一个有序的结果。所以,如果你的应用需要有序性,那么跳表就是你不二的选择。JDK 中实现这一数据结构的类是 `ConcurrentSkipListMap`。
|
||||
|
||||
## 参考
|
||||
|
||||
- 《实战 Java 高并发程序设计》
|
||||
- <https://javadoop.com/post/java-concurrent-queue>
|
||||
- <https://juejin.im/post/5aeebd02518825672f19c546>
|
||||
|
||||
<!-- @include: @article-footer.snippet.md -->
|
||||
@@ -0,0 +1,400 @@
|
||||
---
|
||||
title: Java并发常见面试题总结(上)
|
||||
description: Java并发编程基础面试题:深入讲解线程与进程区别、多线程创建方式、线程生命周期状态、死锁四个条件及预防、并发与并行概念等核心知识。
|
||||
category: Java
|
||||
tag:
|
||||
- Java并发
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: Java并发,线程与进程,多线程,死锁,线程生命周期,并发编程,Java面试题,线程创建方式
|
||||
---
|
||||
|
||||
## 线程
|
||||
|
||||
### ⭐️ 什么是线程和进程?
|
||||
|
||||
#### 何为进程?
|
||||
|
||||
进程是程序的一次执行过程,是系统运行程序的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。
|
||||
|
||||
在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程。
|
||||
|
||||
如下图所示,在 Windows 中通过查看任务管理器的方式,我们就可以清楚看到 Windows 当前运行的进程(`.exe` 文件的运行)。
|
||||
|
||||

|
||||
|
||||
#### 何为线程?
|
||||
|
||||
线程与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中可以产生多个线程。与进程不同的是同类的多个线程共享进程的**堆**和**方法区**资源,但每个线程有自己的**程序计数器**、**虚拟机栈**和**本地方法栈**,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比进程小得多,也正因为如此,线程也被称为轻量级进程。
|
||||
|
||||
Java 程序天生就是多线程程序,我们可以通过 JMX 来看看一个普通的 Java 程序有哪些线程,代码如下。
|
||||
|
||||
```java
|
||||
public class MultiThread {
|
||||
public static void main(String[] args) {
|
||||
// 获取 Java 线程管理 MXBean
|
||||
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
|
||||
// 不需要获取同步的 monitor 和 synchronizer 信息,仅获取线程和线程堆栈信息
|
||||
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
|
||||
// 遍历线程信息,仅打印线程 ID 和线程名称信息
|
||||
for (ThreadInfo threadInfo : threadInfos) {
|
||||
System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.getThreadName());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
上述程序输出如下(输出内容可能不同,不用太纠结下面每个线程的作用,只用知道 main 线程执行 main 方法即可):
|
||||
|
||||
```plain
|
||||
[5] Attach Listener //添加事件
|
||||
[4] Signal Dispatcher // 分发处理给 JVM 信号的线程
|
||||
[3] Finalizer //调用对象 finalize 方法的线程
|
||||
[2] Reference Handler //清除 reference 线程
|
||||
[1] main //main 线程,程序入口
|
||||
```
|
||||
|
||||
从上面的输出内容可以看出:**一个 Java 程序的运行是 main 线程和多个其他线程同时运行**。
|
||||
|
||||
### Java 线程和操作系统的线程有啥区别?
|
||||
|
||||
JDK 1.2 之前,Java 线程是基于绿色线程(Green Threads)实现的,这是一种用户级线程(用户线程),也就是说 JVM 自己模拟了多线程的运行,而不依赖于操作系统。由于绿色线程和原生线程比起来在使用时有一些限制(比如绿色线程不能直接使用操作系统提供的功能如异步 I/O、只能在一个内核线程上运行无法利用多核),在 JDK 1.2 及以后,Java 线程改为基于原生线程(Native Threads)实现,也就是说 JVM 直接使用操作系统原生的内核级线程(内核线程)来实现 Java 线程,由操作系统内核进行线程的调度和管理。
|
||||
|
||||
我们上面提到了用户线程和内核线程,考虑到很多读者不太了解二者的区别,这里简单介绍一下:
|
||||
|
||||
- 用户线程:由用户空间程序管理和调度的线程,运行在用户空间(专门给应用程序使用)。
|
||||
- 内核线程:由操作系统内核管理和调度的线程,运行在内核空间(只有内核程序可以访问)。
|
||||
|
||||
顺便简单总结一下用户线程和内核线程的区别和特点:用户线程创建和切换成本低,但不可以利用多核。内核态线程,创建和切换成本高,可以利用多核。
|
||||
|
||||
一句话概括 Java 线程和操作系统线程的关系:**现在的 Java 线程的本质其实就是操作系统的线程**。
|
||||
|
||||
线程模型是用户线程和内核线程之间的关联方式,常见的线程模型有这三种:
|
||||
|
||||
1. 一对一(一个用户线程对应一个内核线程)
|
||||
2. 多对一(多个用户线程映射到一个内核线程)
|
||||
3. 多对多(多个用户线程映射到多个内核线程)
|
||||
|
||||

|
||||
|
||||
在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个 Java 线程对应一个系统内核线程。Solaris 系统是一个特例(Solaris 系统本身就支持多对多的线程模型),HotSpot VM 在 Solaris 上支持多对多和一对一。具体可以参考 R 大的回答: [JVM 中的线程模型是用户级的么?](https://www.zhihu.com/question/23096638/answer/29617153)。
|
||||
|
||||
### ⭐️ 请简要描述线程与进程的关系,区别及优缺点?
|
||||
|
||||
下图是 Java 内存区域,通过下图我们从 JVM 的角度来说一下线程和进程之间的关系。
|
||||
|
||||

|
||||
|
||||
从上图可以看出:一个进程中可以有多个线程,多个线程共享进程的**堆**和**方法区(JDK1.8 之后的元空间)**资源,但是每个线程有自己的**程序计数器**、**虚拟机栈** 和 **本地方法栈**。
|
||||
|
||||
**总结:** 线程是进程划分成的更小的运行单位。线程和进程最大的不同在于基本上各进程是独立的,而各线程则不一定,因为同一进程中的线程极有可能会相互影响。线程执行开销小,但不利于资源的管理和保护;而进程正相反。
|
||||
|
||||
下面是该知识点的扩展内容!
|
||||
|
||||
下面来思考这样一个问题:为什么**程序计数器**、**虚拟机栈**和**本地方法栈**是线程私有的呢?为什么堆和方法区是线程共享的呢?
|
||||
|
||||
#### 程序计数器为什么是私有的?
|
||||
|
||||
程序计数器主要有下面两个作用:
|
||||
|
||||
1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
|
||||
2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
|
||||
|
||||
需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
|
||||
|
||||
所以,程序计数器私有主要是为了**线程切换后能恢复到正确的执行位置**。
|
||||
|
||||
#### 虚拟机栈和本地方法栈为什么是私有的?
|
||||
|
||||
- **虚拟机栈:** 每个 Java 方法在执行之前会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
|
||||
- **本地方法栈:** 和虚拟机栈所发挥的作用非常相似,区别是:**虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。** 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
|
||||
|
||||
所以,为了**保证线程中的局部变量不被别的线程访问到**,虚拟机栈和本地方法栈是线程私有的。
|
||||
|
||||
#### 一句话简单了解堆和方法区
|
||||
|
||||
堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象(几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
|
||||
|
||||
### 如何创建线程?
|
||||
|
||||
一般来说,创建线程有很多种方式,例如继承 `Thread` 类、实现 `Runnable` 接口、实现 `Callable` 接口、使用线程池、使用 `CompletableFuture` 类等等。
|
||||
|
||||
不过,这些方式其实并没有真正创建出线程。准确点来说,这些都属于是在 Java 代码中使用多线程的方法。
|
||||
|
||||
严格来说,Java 就只有一种方式可以创建线程,那就是通过 `new Thread().start()` 创建。不管是哪种方式,最终还是依赖于 `new Thread().start()`。
|
||||
|
||||
### ⭐️ 说说线程的生命周期和状态?
|
||||
|
||||
Java 线程在运行的生命周期中的指定时刻只可能处于下面 6 种不同状态的其中一个状态:
|
||||
|
||||
- NEW: 初始状态,线程被创建出来但没有被调用 `start()`。
|
||||
- RUNNABLE: 运行状态,线程被调用了 `start()` 等待运行的状态。
|
||||
- BLOCKED:阻塞状态,需要等待锁释放。
|
||||
- WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)。
|
||||
- TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待。
|
||||
- TERMINATED:终止状态,表示该线程已经运行完毕。
|
||||
|
||||
线程在生命周期中并不是固定处于某一个状态而是随着代码的执行在不同状态之间切换。
|
||||
|
||||
Java 线程状态变迁图(图源:[挑错 |《Java 并发编程的艺术》中关于线程状态的三处错误](https://mp.weixin.qq.com/s/0UTyrJpRKaKhkhHcQtXAiA)):
|
||||
|
||||

|
||||
|
||||
由上图可以看出:线程创建之后它将处于 **NEW(新建)** 状态,调用 `start()` 方法后开始运行,线程这时候处于 **READY(可运行)** 状态。可运行状态的线程获得了 CPU 时间片(timeslice)后就处于 **RUNNING(运行)** 状态。
|
||||
|
||||
> 在操作系统层面,线程有 READY 和 RUNNING 状态;而在 JVM 层面,只能看到 RUNNABLE 状态(图源:[HowToDoInJava](https://howtodoinJava.com/ "HowToDoInJava"):[Java Thread Life Cycle and Thread States](https://howtodoinJava.com/Java/multi-threading/Java-thread-life-cycle-and-thread-states/ "Java Thread Life Cycle and Thread States")),所以 Java 系统一般将这两个状态统称为 **RUNNABLE(运行中)** 状态。
|
||||
>
|
||||
> **为什么 JVM 没有区分这两种状态呢?**(摘自:[Java 线程运行怎么有第六种状态? - Dawell 的回答](https://www.zhihu.com/question/56494969/answer/154053599)) 现在的时分(time-sharing)多任务(multi-task)操作系统架构通常都是用所谓的“时间分片(time quantum or time slice)”方式进行抢占式(preemptive)轮转调度(round-robin 式)。这个时间分片通常是很小的,一个线程一次最多只能在 CPU 上运行比如 10-20ms 的时间(此时处于 running 状态),也即大概只有 0.01 秒这一量级,时间片用后就要被切换下来放入调度队列的末尾等待再次调度。(也即回到 ready 状态)。线程切换的如此之快,区分这两种状态就没什么意义了。
|
||||
|
||||

|
||||
|
||||
- 当线程执行 `wait()` 方法之后,线程进入 **WAITING(等待)** 状态。进入等待状态的线程需要依靠其他线程的通知才能够返回到运行状态。
|
||||
- **TIMED_WAITING(超时等待)** 状态相当于在等待状态的基础上增加了超时限制,比如通过 `sleep(long millis)` 方法或 `wait(long millis)` 方法可以将线程置于 TIMED_WAITING 状态。当超时时间结束后,线程将会返回到 RUNNABLE 状态。
|
||||
- 当线程进入 `synchronized` 方法/块或者调用 `wait` 后(被 `notify`)重新进入 `synchronized` 方法/块,但是锁被其它线程占有,这个时候线程就会进入 **BLOCKED(阻塞)** 状态。
|
||||
- 线程在执行完了 `run()` 方法之后将会进入到 **TERMINATED(终止)** 状态。
|
||||
|
||||
### 什么是线程上下文切换?
|
||||
|
||||
线程在执行过程中会有自己的运行条件和状态(也称上下文),比如上文所说到过的程序计数器,栈信息等。当出现如下情况的时候,线程会从占用 CPU 状态中退出。
|
||||
|
||||
- 主动让出 CPU,比如调用了 `sleep()`, `wait()` 等。
|
||||
- 时间片用完,因为操作系统要防止一个线程或者进程长时间占用 CPU 导致其他线程或者进程饿死。
|
||||
- 调用了阻塞类型的系统中断,比如请求 IO,线程被阻塞。
|
||||
- 被终止或结束运行
|
||||
|
||||
这其中前三种都会发生线程切换,线程切换意味着需要保存当前线程的上下文,留待线程下次占用 CPU 的时候恢复现场。并加载下一个将要占用 CPU 的线程上下文。这就是所谓的 **上下文切换**。
|
||||
|
||||
上下文切换是现代操作系统的基本功能,因其每次需要保存信息恢复信息,这将会占用 CPU,内存等系统资源进行处理,也就意味着效率会有一定损耗,如果频繁切换就会造成整体效率低下。
|
||||
|
||||
### Thread#sleep() 方法和 Object#wait() 方法对比
|
||||
|
||||
**共同点**:两者都可以暂停线程的执行。
|
||||
|
||||
**区别**:
|
||||
|
||||
- **`sleep()` 方法没有释放锁,而 `wait()` 方法释放了锁**。
|
||||
- `wait()` 通常被用于线程间交互/通信,`sleep()` 通常被用于暂停执行。
|
||||
- `wait()` 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 `notify()` 或者 `notifyAll()` 方法。`sleep()` 方法执行完成后,线程会自动苏醒,或者也可以使用 `wait(long timeout)` 超时后线程会自动苏醒。
|
||||
- `sleep()` 是 `Thread` 类的静态本地方法,`wait()` 则是 `Object` 类的本地方法。为什么这样设计呢?下一个问题就会聊到。
|
||||
|
||||
### 为什么 wait() 方法不定义在 Thread 中?
|
||||
|
||||
`wait()` 是让获得对象锁的线程实现等待,会自动释放当前线程占有的对象锁。每个对象(`Object`)都拥有对象锁,既然要释放当前线程占有的对象锁并让其进入 WAITING 状态,自然是要操作对应的对象(`Object`)而非当前的线程(`Thread`)。
|
||||
|
||||
类似的问题:**为什么 `sleep()` 方法定义在 `Thread` 中?**
|
||||
|
||||
因为 `sleep()` 是让当前线程暂停执行,不涉及到对象类,也不需要获得对象锁。
|
||||
|
||||
### 可以直接调用 Thread 类的 run 方法吗?
|
||||
|
||||
这是另一个非常经典的 Java 多线程面试问题,而且在面试中会经常被问到。很简单,但是很多人都会答不上来!
|
||||
|
||||
new 一个 `Thread`,线程进入了新建状态。调用 `start()` 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 `start()` 会执行线程的相应准备工作,然后自动执行 `run()` 方法的内容,这是真正的多线程工作。 但是,直接执行 `run()` 方法,会把 `run()` 方法当成一个普通方法在调用该方法的线程去执行,所以这并不是多线程工作。
|
||||
|
||||
**总结:调用 `start()` 方法方可启动线程并使线程进入就绪状态,直接执行 `run()` 方法的话不会以多线程的方式执行。**
|
||||
|
||||
## 多线程
|
||||
|
||||
### 并发与并行的区别
|
||||
|
||||
- **并发**:两个及两个以上的作业在同一 **时间段** 内执行。
|
||||
- **并行**:两个及两个以上的作业在同一 **时刻** 执行。
|
||||
|
||||
最关键的点是:是否是 **同时** 执行。
|
||||
|
||||
### 同步和异步的区别
|
||||
|
||||
- **同步**:发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待。
|
||||
- **异步**:调用在发出之后,不用等待返回结果,该调用直接返回。
|
||||
|
||||
### ⭐️ 为什么要使用多线程?
|
||||
|
||||
先从总体上来说:
|
||||
|
||||
- **从计算机底层来说:** 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。
|
||||
- **从当代互联网发展趋势来说:** 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。
|
||||
|
||||
再深入到计算机底层来探讨:
|
||||
|
||||
- **单核时代**:在单核时代多线程主要是为了提高单进程利用 CPU 和 IO 系统的效率。 假设只运行了一个 Java 进程的情况,当我们请求 IO 的时候,如果 Java 进程中只有一个线程,此线程被 IO 阻塞则整个进程被阻塞。CPU 和 IO 设备只有一个在运行,那么可以简单地说系统整体效率只有 50%。当使用多线程的时候,一个线程被 IO 阻塞,其他线程还可以继续使用 CPU。从而提高了 Java 进程利用系统资源的整体效率。
|
||||
- **多核时代**: 多核时代多线程主要是为了提高进程利用多核 CPU 的能力。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,不论系统有几个 CPU 核心,都只会有一个 CPU 核心被利用到。而创建多个线程,这些线程可以被映射到底层多个 CPU 核心上执行,在任务中的多个线程没有资源竞争的情况下,任务执行的效率会有显著性的提高,约等于(单核时执行时间/CPU 核心数)。
|
||||
|
||||
### ⭐️ 单核 CPU 支持 Java 多线程吗?
|
||||
|
||||
单核 CPU 是支持 Java 多线程的。操作系统通过时间片轮转的方式,将 CPU 的时间分配给不同的线程。尽管单核 CPU 一次只能执行一个任务,但通过快速在多个线程之间切换,可以让用户感觉多个任务是同时进行的。
|
||||
|
||||
这里顺带提一下 Java 使用的线程调度方式。
|
||||
|
||||
操作系统主要通过两种线程调度方式来管理多线程的执行:
|
||||
|
||||
- **抢占式调度(Preemptive Scheduling)**:操作系统决定何时暂停当前正在运行的线程,并切换到另一个线程执行。这种切换通常是由系统时钟中断(时间片轮转)或其他高优先级事件(如 I/O 操作完成)触发的。这种方式存在上下文切换开销,但公平性和 CPU 资源利用率较好,不易阻塞。
|
||||
- **协同式调度(Cooperative Scheduling)**:线程执行完毕后,主动通知系统切换到另一个线程。这种方式可以减少上下文切换带来的性能开销,但公平性较差,容易阻塞。
|
||||
|
||||
Java 使用的线程调度是抢占式的。也就是说,JVM 本身不负责线程的调度,而是将线程的调度委托给操作系统。操作系统通常会基于线程优先级和时间片来调度线程的执行,高优先级的线程通常获得 CPU 时间片的机会更多。
|
||||
|
||||
### ⭐️ 单核 CPU 上运行多个线程效率一定会高吗?
|
||||
|
||||
单核 CPU 同时运行多个线程的效率是否会高,取决于线程的类型和任务的性质。一般来说,有两种类型的线程:
|
||||
|
||||
1. **CPU 密集型**:CPU 密集型的线程主要进行计算和逻辑处理,需要占用大量的 CPU 资源。
|
||||
2. **IO 密集型**:IO 密集型的线程主要进行输入输出操作,如读写文件、网络通信等,需要等待 IO 设备的响应,而不占用太多的 CPU 资源。
|
||||
|
||||
在单核 CPU 上,同一时刻只能有一个线程在运行,其他线程需要等待 CPU 的时间片分配。如果线程是 CPU 密集型的,那么多个线程同时运行会导致频繁的线程切换,增加了系统的开销,降低了效率。如果线程是 IO 密集型的,那么多个线程同时运行可以利用 CPU 在等待 IO 时的空闲时间,提高了效率。
|
||||
|
||||
因此,对于单核 CPU 来说,如果任务是 CPU 密集型的,那么开很多线程会影响效率;如果任务是 IO 密集型的,那么开很多线程会提高效率。当然,这里的“很多”也要适度,不能超过系统能够承受的上限。
|
||||
|
||||
### 使用多线程可能带来什么问题?
|
||||
|
||||
并发编程的目的就是为了能提高程序的执行效率进而提高程序的运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。
|
||||
|
||||
### 如何理解线程安全和不安全?
|
||||
|
||||
线程安全和不安全是在多线程环境下对于同一份数据的访问是否能够保证其正确性和一致性的描述。
|
||||
|
||||
- 线程安全指的是在多线程环境下,对于同一份数据,不管有多少个线程同时访问,都能保证这份数据的正确性和一致性。
|
||||
- 线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失。
|
||||
|
||||
## ⭐️ 死锁
|
||||
|
||||
### 什么是线程死锁?
|
||||
|
||||
线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
|
||||
|
||||
如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
|
||||
|
||||

|
||||
|
||||
下面通过一个例子来说明线程死锁,代码模拟了上图的死锁的情况(代码来源于《并发编程之美》):
|
||||
|
||||
```java
|
||||
public class DeadLockDemo {
|
||||
private static Object resource1 = new Object();//资源 1
|
||||
private static Object resource2 = new Object();//资源 2
|
||||
|
||||
public static void main(String[] args) {
|
||||
new Thread(() -> {
|
||||
synchronized (resource1) {
|
||||
System.out.println(Thread.currentThread() + "get resource1");
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
System.out.println(Thread.currentThread() + "waiting get resource2");
|
||||
synchronized (resource2) {
|
||||
System.out.println(Thread.currentThread() + "get resource2");
|
||||
}
|
||||
}
|
||||
}, "线程 1").start();
|
||||
|
||||
new Thread(() -> {
|
||||
synchronized (resource2) {
|
||||
System.out.println(Thread.currentThread() + "get resource2");
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
System.out.println(Thread.currentThread() + "waiting get resource1");
|
||||
synchronized (resource1) {
|
||||
System.out.println(Thread.currentThread() + "get resource1");
|
||||
}
|
||||
}
|
||||
}, "线程 2").start();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Output
|
||||
|
||||
```plain
|
||||
Thread[线程 1,5,main]get resource1
|
||||
Thread[线程 2,5,main]get resource2
|
||||
Thread[线程 1,5,main]waiting get resource2
|
||||
Thread[线程 2,5,main]waiting get resource1
|
||||
```
|
||||
|
||||
线程 A 通过 `synchronized (resource1)` 获得 `resource1` 的监视器锁,然后通过 `Thread.sleep(1000);` 让线程 A 休眠 1s,为的是让线程 B 得到执行然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。
|
||||
|
||||
上面的例子符合产生死锁的四个必要条件:
|
||||
|
||||
1. **互斥条件**:该资源任意一个时刻只由一个线程占用。
|
||||
2. **请求与保持条件**:一个线程因请求资源而阻塞时,对已获得的资源保持不放。
|
||||
3. **不剥夺条件**:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
|
||||
4. **循环等待条件**:若干线程之间形成一种头尾相接的循环等待资源关系。
|
||||
|
||||
### 如何检测死锁?
|
||||
|
||||
- 使用 `jmap`、`jstack` 等命令查看 JVM 线程栈和堆内存的情况。如果有死锁,`jstack` 的输出中通常会有 `Found one Java-level deadlock:` 的字样,后面会跟着死锁相关的线程信息。另外,实际项目中还可以搭配使用 `top`、`df`、`free` 等命令查看操作系统的基本情况,出现死锁可能会导致 CPU、内存等资源消耗过高。
|
||||
- 采用 VisualVM、JConsole 等工具进行排查。
|
||||
|
||||
这里以 JConsole 工具为例进行演示。
|
||||
|
||||
首先,我们要找到 JDK 的 bin 目录,找到 jconsole 并双击打开。
|
||||
|
||||

|
||||
|
||||
对于 MAC 用户来说,可以通过 `/usr/libexec/java_home -V` 查看 JDK 安装目录,找到后通过 `open . + 文件夹地址` 打开即可。例如,我本地的某个 JDK 的路径是:
|
||||
|
||||
```bash
|
||||
open . /Users/guide/Library/Java/JavaVirtualMachines/corretto-1.8.0_252/Contents/Home
|
||||
```
|
||||
|
||||
打开 jconsole 后,连接对应的程序,然后进入线程界面选择检测死锁即可!
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### 如何预防和避免线程死锁?
|
||||
|
||||
**如何预防死锁?** 破坏死锁的产生的必要条件即可:
|
||||
|
||||
1. **破坏请求与保持条件**:一次性申请所有的资源。
|
||||
2. **破坏不剥夺条件**:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
|
||||
3. **破坏循环等待条件**:靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。
|
||||
|
||||
**如何避免死锁?**
|
||||
|
||||
避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
|
||||
|
||||
> **安全状态** 指的是系统能够按照某种线程推进顺序(P1、P2、P3……Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称 `<P1、P2、P3.....Pn>` 序列为安全序列。
|
||||
|
||||
我们对线程 2 的代码修改成下面这样就不会产生死锁了。
|
||||
|
||||
```java
|
||||
new Thread(() -> {
|
||||
synchronized (resource1) {
|
||||
System.out.println(Thread.currentThread() + "get resource1");
|
||||
try {
|
||||
Thread.sleep(1000);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
System.out.println(Thread.currentThread() + "waiting get resource2");
|
||||
synchronized (resource2) {
|
||||
System.out.println(Thread.currentThread() + "get resource2");
|
||||
}
|
||||
}
|
||||
}, "线程 2").start();
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```plain
|
||||
Thread[线程 1,5,main]get resource1
|
||||
Thread[线程 1,5,main]waiting get resource2
|
||||
Thread[线程 1,5,main]get resource2
|
||||
Thread[线程 2,5,main]get resource1
|
||||
Thread[线程 2,5,main]waiting get resource2
|
||||
Thread[线程 2,5,main]get resource2
|
||||
|
||||
Process finished with exit code 0
|
||||
```
|
||||
|
||||
我们分析一下上面的代码为什么避免了死锁的发生?
|
||||
|
||||
线程 1 首先获得到 resource1 的监视器锁,这时候线程 2 就获取不到了。然后线程 1 再去获取 resource2 的监视器锁,可以获取到。然后线程 1 释放了对 resource1、resource2 的监视器锁的占用,线程 2 获取到就可以执行了。这样就破坏了循环等待条件,因此避免了死锁。
|
||||
|
||||
<!-- @include: @article-footer.snippet.md -->
|
||||
@@ -0,0 +1,436 @@
|
||||
---
|
||||
title: Java 锁详解:互斥锁、读写锁、自旋锁与 synchronized 锁优化
|
||||
description: Java 锁机制系统梳理:从互斥锁、读写锁、自旋锁到 synchronized、ReentrantLock、AQS、StampedLock,讲清锁分类、实现原理、版本差异与选型建议。
|
||||
category: Java
|
||||
tag:
|
||||
- Java并发
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: Java锁,互斥锁,读写锁,自旋锁,synchronized,ReentrantLock,AQS,StampedLock,CAS,锁升级,锁优化,Java并发
|
||||
---
|
||||
|
||||
学 Java 并发时,锁相关的名字很容易让大家搞混:互斥锁、读写锁、自旋锁、悲观锁、乐观锁、CAS、AQS、`synchronized`、`ReentrantLock`、`StampedLock`、偏向锁、轻量级锁、重量级锁。
|
||||
|
||||
这些名字并不都在同一个分类维度里。
|
||||
|
||||
有的说“谁能进入临界区”,比如互斥锁和读写锁;有的说“拿不到锁时怎么等”,比如自旋锁和阻塞锁;有的说“修改共享数据前先锁住,还是提交时再校验”,比如悲观锁和乐观锁;还有的说 HotSpot 在不同竞争强度下怎么优化 `synchronized`。
|
||||
|
||||
这篇文章先把锁的坐标系立起来,再看 Java 里常用锁工具怎么落地。关于悲观锁、乐观锁和 CAS 的细节,站内已经有两篇文章详细介绍:[乐观锁和悲观锁详解](./optimistic-lock-and-pessimistic-lock.md)、[CAS 详解](./cas.md)。本文只保留必要上下文,重点放在“锁体系怎么串起来”。
|
||||
|
||||
PS:本文主要以 HotSpot / OpenJDK 为背景。`synchronized` 的 monitor 互斥和内存语义属于 Java/JVM 规范层面;对象头、Mark Word、轻量级锁、锁膨胀这些内容属于 HotSpot 实现优化,Java 语言规范并不承诺固定流程。偏向锁从 JDK 15 起默认禁用并废弃相关参数,JDK 18 起相关参数已经 obsoleted;虚拟线程与 `synchronized` pinning 的结论也要区分 JDK 21~23 和 JDK 24+。
|
||||
|
||||
先用一张表把分类维度拆开:
|
||||
|
||||
| 维度 | 典型名称 | 回答的问题 |
|
||||
| -------------- | ---------------------------------------------- | ------------------------------------ |
|
||||
| 临界区互斥方式 | 互斥锁、读写锁 | 谁能进入临界区 |
|
||||
| 等待策略 | 自旋锁、阻塞锁 | 拿不到锁时怎么等 |
|
||||
| 并发控制思路 | 悲观锁、乐观锁 | 先锁住再改,还是提交时校验 |
|
||||
| 原子更新机制 | CAS、Atomic 类 | 如何无阻塞更新单个变量 |
|
||||
| JVM 实现优化 | 轻量级锁、重量级锁、锁膨胀 | HotSpot 如何降低 `synchronized` 成本 |
|
||||
| Java 锁工具 | `synchronized`、`ReentrantLock`、`StampedLock` | 代码里具体用什么 |
|
||||
|
||||
## 一把锁到底保护什么?
|
||||
|
||||
锁要解决的是临界区问题。临界区指那段会访问共享可变状态,并且不能让多个执行单元随意交错执行的代码。
|
||||
|
||||

|
||||
|
||||
比如下面这个自增:
|
||||
|
||||
```java
|
||||
count++;
|
||||
```
|
||||
|
||||
源码里只有一行,但这行代码不能当成不可拆的动作。线程通常要先读出 `count`,再加 1,最后写回去。两个线程同时执行时,可能都读到旧值 `0`,各自算出 `1`,最后都把 `1` 写回去。两个线程都执行了自增,结果只加了一次。
|
||||
|
||||
锁的做法很直接:进入这段代码前先获得锁,执行完再释放锁。只要所有访问同一份共享状态的代码都遵守同一把锁的约定,就可以把原本可能交错的读写压成一段互斥执行。
|
||||
|
||||
这里有一句很容易被忽略的话:**锁真正保护的是访问对象状态的协议,对象本身不会因为被加锁就自动安全**。
|
||||
|
||||
`synchronized (account)` 不会神奇地让 `account` 的所有字段都安全。如果另一段代码绕过这把锁直接改 `account.balance`,线程安全照样会被破坏。MIT 6.005 的锁课程反复强调的也是这个点:锁应该守住某个数据抽象的表示不变量,随手找个对象套一下,并不能保证不变量一直成立。
|
||||
|
||||
## 互斥锁:同一时刻只允许一个线程进入
|
||||
|
||||
互斥锁(Mutex)的规则很简单:同一时刻,最多只有一个线程持有锁并进入临界区。
|
||||
|
||||
在 Java 中,`synchronized` 和 `ReentrantLock` 都可以作为互斥锁使用:
|
||||
|
||||
```java
|
||||
class Counter {
|
||||
private int count;
|
||||
|
||||
public synchronized void increment() {
|
||||
count++;
|
||||
}
|
||||
|
||||
public synchronized int get() {
|
||||
return count;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
换成 `ReentrantLock`,写法会啰嗦一点,但能拿到更多控制权:
|
||||
|
||||
```java
|
||||
class Counter {
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private int count;
|
||||
|
||||
public void increment() {
|
||||
lock.lock();
|
||||
try {
|
||||
count++;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`try/finally` 不能省。`synchronized` 的释放动作由 JVM 帮你做,代码块正常退出或异常退出都会释放 monitor;`ReentrantLock` 是显式 API,拿锁和解锁要自己配对。Oracle 的 `ReentrantLock` 文档也把“调用 `lock` 后立刻进入 `try` 块”作为推荐写法。
|
||||
|
||||
互斥锁真正难的地方不在语法,而在锁粒度。
|
||||
|
||||
一把大锁把所有操作都包住,最省心,但并发度低;多把小锁分别保护不同数据,吞吐可能更好,但锁顺序、死锁、状态一致性都更难管。OSTEP 在讲 POSIX mutex 时也提到过这个取舍:不同数据用不同锁能增加并发,但程序员必须清楚每把锁到底保护哪一块状态。
|
||||
|
||||
## 读写锁:读读共享,写操作独占
|
||||
|
||||
互斥锁对读操作也很严格:只要一个线程在读,另一个线程也不能进来读。但很多业务对象有一个特点:读不会改变状态,多个读线程同时执行并不会互相破坏。
|
||||
|
||||
读写锁就是为这种场景准备的。
|
||||
|
||||
它把访问分成两类:
|
||||
|
||||
- 读锁:共享锁,多个线程可以同时持有。
|
||||
- 写锁:独占锁,只能一个线程持有,并且写锁和读锁互斥。
|
||||
|
||||
对应规则也很好记:
|
||||
|
||||
- 读读不互斥。
|
||||
- 读写互斥。
|
||||
- 写写互斥。
|
||||
|
||||
Java 里的典型实现是 `ReentrantReadWriteLock`:
|
||||
|
||||
```java
|
||||
class ProfileCache {
|
||||
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
|
||||
private final Lock readLock = rwLock.readLock();
|
||||
private final Lock writeLock = rwLock.writeLock();
|
||||
private final Map<Long, String> cache = new HashMap<>();
|
||||
|
||||
public String get(long userId) {
|
||||
readLock.lock();
|
||||
try {
|
||||
return cache.get(userId);
|
||||
} finally {
|
||||
readLock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public void put(long userId, String profile) {
|
||||
writeLock.lock();
|
||||
try {
|
||||
cache.put(userId, profile);
|
||||
} finally {
|
||||
writeLock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
读写锁适合读多写少、读操作足够短、数据结构不容易被拆坏的场景。它不适合所有缓存,也不一定比互斥锁快。如果写操作很频繁,读线程和写线程会不断互相挡路,读写锁的维护成本反而可能抵消收益。
|
||||
|
||||
Java 还提供了 `StampedLock`,它支持写锁、悲观读锁和乐观读。乐观读没有真正持有传统读锁;它会先拿一个 stamp,读完后再校验期间有没有写入发生:
|
||||
|
||||
```java
|
||||
class Point {
|
||||
private final StampedLock lock = new StampedLock();
|
||||
private double x;
|
||||
private double y;
|
||||
|
||||
double distanceFromOrigin() {
|
||||
long stamp = lock.tryOptimisticRead();
|
||||
double currentX = x;
|
||||
double currentY = y;
|
||||
if (!lock.validate(stamp)) {
|
||||
stamp = lock.readLock();
|
||||
try {
|
||||
currentX = x;
|
||||
currentY = y;
|
||||
} finally {
|
||||
lock.unlockRead(stamp);
|
||||
}
|
||||
}
|
||||
return Math.hypot(currentX, currentY);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`StampedLock` 的乐观读有边界:读到的数据可能短暂不一致,所以只适合能在本地变量里完成读取、并且可以通过 `validate` 失败后重读来兜底的短读场景。它也很难直接替代 `ReentrantReadWriteLock`,尤其要注意它不支持重入。
|
||||
|
||||
## 自旋锁:不阻塞,先原地等一会儿
|
||||
|
||||
线程拿不到锁时,通常有两种等待方式:
|
||||
|
||||
- 阻塞:挂起当前线程,让操作系统之后再唤醒。
|
||||
- 自旋:不挂起线程,在 CPU 上循环检查锁是否可用。
|
||||
|
||||
自旋锁适合临界区非常短的场景。比如持锁线程马上就会释放锁,如果等待线程直接阻塞,线程挂起和唤醒的成本可能比“原地转几圈”等待还高。
|
||||
|
||||
问题也在这里:自旋会付出 CPU 成本,它会持续占用 CPU。如果锁很久不释放,或者等待线程很多,自旋会把 CPU 时间浪费在空转上。
|
||||
|
||||
Java 代码里可以用 CAS 写出一个很小的自旋锁示例:
|
||||
|
||||
```java
|
||||
class SpinLock {
|
||||
private final AtomicReference<Thread> owner = new AtomicReference<>();
|
||||
|
||||
public void lock() {
|
||||
Thread current = Thread.currentThread();
|
||||
while (!owner.compareAndSet(null, current)) {
|
||||
Thread.onSpinWait();
|
||||
}
|
||||
}
|
||||
|
||||
public void unlock() {
|
||||
Thread current = Thread.currentThread();
|
||||
if (!owner.compareAndSet(current, null)) {
|
||||
throw new IllegalMonitorStateException();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这段代码只是用来说明“自旋 + CAS”的关系,不建议直接拿去做业务锁。真实锁要考虑可重入、公平性、中断、超时、异常释放、监控指标、等待队列等问题。JDK 已经把这些复杂性封装在 `synchronized`、`ReentrantLock`、AQS 同步器和 Atomic 类里了。
|
||||
|
||||
## 悲观锁、乐观锁和 CAS 的位置
|
||||
|
||||
悲观锁和乐观锁描述的是两种并发控制思路,不对应某个固定 Java 类。
|
||||
|
||||
悲观锁假设冲突很可能发生,所以先把资源锁住再操作。`synchronized`、`ReentrantLock`、数据库 `SELECT ... FOR UPDATE` 都是常见例子。
|
||||
|
||||
乐观锁假设冲突不频繁,先不阻塞别人,提交修改时再检查数据有没有被改过。数据库里的 `version` 字段、Java Atomic 类里的 CAS,都属于这个方向。
|
||||
|
||||
CAS(Compare-And-Swap,比较并交换)可以理解成一种硬件支持的原子更新方式:只有当内存中的值仍然等于预期旧值时,才把它改成新值。否则说明有人先改过了,当前线程可以选择重试、放弃或走降级逻辑。
|
||||
|
||||
CAS 常见问题有三个:
|
||||
|
||||
- 失败重试会消耗 CPU,冲突越激烈越明显。
|
||||
- 只能很自然地处理单个变量,多个变量的一致性要额外设计。
|
||||
- ABA 问题:值从 A 变成 B,又变回 A,单看值会以为它没变过。
|
||||
|
||||
ABA 可以用版本号、时间戳或带标记引用解决。Java 里有 `AtomicStampedReference` 和 `AtomicMarkableReference` 这类工具,不过在业务代码里更常见的做法是让数据模型本身带版本号。
|
||||
|
||||
这块如果继续展开,就会和已有文章重复。想看实现方式、版本号示例、ABA 处理和 Atomic 类源码,可以继续读:
|
||||
|
||||
- [乐观锁和悲观锁详解](./optimistic-lock-and-pessimistic-lock.md)
|
||||
- [CAS 详解](./cas.md)
|
||||
- [Atomic 原子类总结](./atomic-classes.md)
|
||||
|
||||
## synchronized 的底层:monitor、字节码和内存语义
|
||||
|
||||
`synchronized` 是 Java 语言内置的同步机制,可以修饰实例方法、静态方法,也可以包住代码块。
|
||||
|
||||
```java
|
||||
class Account {
|
||||
private long balance;
|
||||
|
||||
public synchronized void deposit(long amount) {
|
||||
balance += amount;
|
||||
}
|
||||
|
||||
public long balance() {
|
||||
synchronized (this) {
|
||||
return balance;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
同步方法和同步代码块在字节码层面的表现不完全一样:
|
||||
|
||||
- 同步方法依赖方法访问标志 `ACC_SYNCHRONIZED`。
|
||||
- 同步代码块会生成 `monitorenter` 和 `monitorexit` 指令。
|
||||
|
||||
不管表现形式如何,语义都是进入 monitor、退出 monitor。Java 语言规范还规定了锁释放和后续锁获取之间的 happens-before 关系:一个线程释放某把锁之前的写入,对随后获得同一把锁的线程可见。
|
||||
|
||||
这也是 `synchronized` 和“只做互斥”的普通概念锁不同的地方。它同时提供互斥和内存可见性。只保护临界区但不建立可见性,另一个线程可能仍然读到旧值。
|
||||
|
||||
另外,`synchronized` 是可重入的。一个线程已经持有某个对象的 monitor 时,可以再次进入同一把锁保护的代码,JVM 会记录重入次数,退出时逐层减少。
|
||||
|
||||
```java
|
||||
class ReentrantDemo {
|
||||
public synchronized void outer() {
|
||||
inner();
|
||||
}
|
||||
|
||||
public synchronized void inner() {
|
||||
// 同一线程可以再次进入 this 的 monitor
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## synchronized 锁优化:别把“锁升级”背成固定口诀
|
||||
|
||||
很多资料会把 `synchronized` 讲成“无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁”。这条线索对理解 HotSpot 早期优化很有帮助,但不能脱离版本。
|
||||
|
||||
JDK 6 之后,HotSpot 为 `synchronized` 做了大量优化。偏向锁面向“总是同一个线程进入同一把锁”的场景;轻量级锁面向“竞争不激烈,多个线程错开进入”的场景;重量级锁则会用到 ObjectMonitor,竞争线程可能阻塞和唤醒。
|
||||
|
||||
版本差异要单独记一下:
|
||||
|
||||
- JDK 6 到 JDK 14:偏向锁是 HotSpot 常见优化之一。
|
||||
- JDK 15:JEP 374 将偏向锁默认禁用,并废弃相关参数。
|
||||
- JDK 18:偏向锁相关参数被 obsoleted,传入后会被忽略并给出警告。
|
||||
- JDK 21 到 JDK 23:虚拟线程在 `synchronized` 中阻塞时可能 pin 住平台线程。
|
||||
- JDK 24:JEP 491 改进了虚拟线程与 `synchronized` 的配合,阻塞在 `synchronized` 上的虚拟线程可以释放底层平台线程,减少 pinning 问题。
|
||||
|
||||
所以,面试或写文章时可以讲“HotSpot 曾经通过偏向锁、轻量级锁、重量级锁降低 `synchronized` 成本”,但不要把偏向锁说成现代 JDK 一定会走的默认路径。
|
||||
|
||||
工程上更重要的是另一个结论:早年那句“能不用就不用”已经不适合今天的 `synchronized`。普通互斥场景下,它语法简单、异常释放安全、JIT 优化成熟。只有当你需要公平锁、可中断获取、超时获取、多个条件队列时,才更自然地转向 `ReentrantLock`。
|
||||
|
||||
## ReentrantLock、Condition 和 AQS 怎么接上
|
||||
|
||||
`ReentrantLock` 提供了比 `synchronized` 更细的控制能力:
|
||||
|
||||
- 可以选择公平锁或非公平锁。
|
||||
- 可以用 `lockInterruptibly()` 响应中断。
|
||||
- 可以用 `tryLock()` 或 `tryLock(timeout, unit)` 避免无限等待。
|
||||
- 可以创建多个 `Condition`,把不同等待条件拆开管理。
|
||||
|
||||
一个典型写法如下:
|
||||
|
||||
```java
|
||||
class BoundedBuffer<E> {
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final Condition notFull = lock.newCondition();
|
||||
private final Condition notEmpty = lock.newCondition();
|
||||
private final Queue<E> queue = new ArrayDeque<>();
|
||||
private final int capacity;
|
||||
|
||||
BoundedBuffer(int capacity) {
|
||||
this.capacity = capacity;
|
||||
}
|
||||
|
||||
public void put(E item) throws InterruptedException {
|
||||
lock.lockInterruptibly();
|
||||
try {
|
||||
while (queue.size() == capacity) {
|
||||
notFull.await();
|
||||
}
|
||||
queue.add(item);
|
||||
notEmpty.signal();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public E take() throws InterruptedException {
|
||||
lock.lockInterruptibly();
|
||||
try {
|
||||
while (queue.isEmpty()) {
|
||||
notEmpty.await();
|
||||
}
|
||||
E item = queue.remove();
|
||||
notFull.signal();
|
||||
return item;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这里的 `while` 也不能随便改成 `if`。线程被唤醒后,只能说明“有机会重新竞争锁并检查条件”,不代表条件一定成立。虚假唤醒、多个等待线程竞争、条件被别的线程先消费掉,都要求醒来后再次检查。
|
||||
|
||||
`ReentrantLock` 底层依赖 AQS(AbstractQueuedSynchronizer)。AQS 可以先粗略理解成一套同步器框架:用一个 `state` 表示同步状态,用 FIFO 队列管理没抢到资源的线程,再配合 CAS、`LockSupport.park/unpark` 完成排队、阻塞和唤醒。
|
||||
|
||||
很多并发工具都建立在 AQS 之上,比如 `ReentrantLock`、`Semaphore`、`CountDownLatch`、`ReentrantReadWriteLock`。如果想把队列、`state`、CAS 和阻塞唤醒这条线继续拆开,可以接着看 [AQS 详解](./aqs.md) 和 [从ReentrantLock的实现看AQS的原理及应用](./reentrantlock.md)。
|
||||
|
||||
## Java 锁该怎么选?
|
||||
|
||||
选锁时别急着比较“哪个最快”。锁的表现跟临界区长度、竞争强度、线程数量、失败后的处理方式都有关。先把几个问题问清楚:这段代码保护哪份共享状态?持锁时间大概多长?拿不到锁时能不能等待?等待失败后是返回、重试,还是直接报错?
|
||||
|
||||
如果只是保护 JVM 进程内的一小段状态,比如更新几个字段、维护一个内存 Map、切换对象状态,`synchronized` 往往就够了。它写起来短,退出代码块时自动释放锁,也少了手写 `unlock()` 漏掉的风险。等到代码需要超时获取、可中断获取、公平锁,或者要用多个 `Condition` 管理不同等待队列,再换成 `ReentrantLock` 会更顺手。
|
||||
|
||||
读多写少时,可以看 `ReentrantReadWriteLock`。这里的重点是“写少”,光有读方法还不够。如果写操作很频繁,读锁和写锁会一直互相挡,维护读写状态也有成本,最后未必比一把互斥锁更划算。`StampedLock` 的乐观读更挑场景:读逻辑要短,读到中间状态也不能出大问题,并且必须接受校验失败后重新读一遍。
|
||||
|
||||
如果只是更新一个计数、状态位或引用,优先看 Atomic 类、`LongAdder`、`LongAccumulator` 这类工具。它们适合很短的原子更新,不适合把一整段业务流程塞进 CAS 重试循环里。业务流程越长,失败重试越容易把 CPU 消耗在无效循环上,也更难处理副作用。
|
||||
|
||||
如果问题已经越过 JVM 边界,比如多个应用实例同时改同一行数据库记录,Java 里的锁就管不住了。冲突不频繁时,可以用版本号做乐观锁;冲突比较频繁、必须强一致修改时,通常要回到数据库行锁、`SELECT ... FOR UPDATE`、唯一约束这类数据库机制。再往外走到跨服务互斥,就需要 Redis、ZooKeeper、数据库等外部系统来承接,不能指望 `synchronized` 或 `ReentrantLock`。
|
||||
|
||||
锁粒度也别一味追求“小”。一把大锁容易保证正确性,但吞吐可能受影响;拆成多把小锁,竞争会少一些,可锁顺序、死锁和排查成本都会上来。很多时候,先用清楚的一把锁把不变量守住,再根据压测结果拆锁,比一开始就设计一堆细粒度锁更稳。
|
||||
|
||||
## 常见坑
|
||||
|
||||
**锁对象不稳定。**
|
||||
|
||||
有些代码看起来加了锁,实际可能锁到了不同对象。常见原因是锁对象会变,比如字符串拼接结果、装箱对象、可重新赋值的字段。线程 A 进来时锁的是旧对象,线程 B 进来时锁的是新对象,两边互不影响,临界区就被拆开了。
|
||||
|
||||
```java
|
||||
private Object lock = new Object();
|
||||
|
||||
public void update() {
|
||||
synchronized (lock) {
|
||||
lock = new Object(); // 后续线程可能会锁到另一把锁
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果需要单独的锁对象,通常把它定义成 `private final`,并且不要把它暴露给外部代码。
|
||||
|
||||
**锁住外部可见对象。**
|
||||
|
||||
`synchronized (this)` 和 `synchronized (SomeClass.class)` 有时没问题,但它们也可能被外部代码拿来加锁,导致你无法控制锁竞争范围。库代码尤其要谨慎,通常更推荐私有 final 锁对象。
|
||||
|
||||
```java
|
||||
private final Object lock = new Object();
|
||||
```
|
||||
|
||||
**持锁期间做慢操作。**
|
||||
|
||||
持锁时访问数据库、调用 RPC、写大文件,都会拉长锁占用时间。锁占用越久,等待线程越多,超时、线程池耗尽和死锁风险都会变高。
|
||||
|
||||
**锁顺序不一致。**
|
||||
|
||||
两个线程分别按 `A -> B` 和 `B -> A` 的顺序加锁,很容易形成死锁。多把锁同时使用时,要给资源排一个全局稳定顺序。死锁的完整介绍可以看 [死锁详解](../../cs-basics/operating-system/dead-lock.md)。
|
||||
|
||||
**把线程安全类和复合操作混为一谈。**
|
||||
|
||||
`ConcurrentHashMap` 的单次 `get`、`put` 是线程安全的,但“先判断不存在,再插入”是复合操作,需要用 `computeIfAbsent` 这类原子方法,或者额外同步。
|
||||
|
||||
```java
|
||||
// 不推荐:containsKey 和 put 之间可能被其他线程插入
|
||||
if (!map.containsKey(key)) {
|
||||
map.put(key, createValue());
|
||||
}
|
||||
|
||||
// 推荐:把复合逻辑交给 ConcurrentHashMap 的原子方法
|
||||
map.computeIfAbsent(key, ignored -> createValue());
|
||||
```
|
||||
|
||||
**只看锁,不看资源池。**
|
||||
|
||||
线上很多“卡住”和 Java 锁死锁无关,线程可能都在等数据库连接、HTTP 连接、线程池队列或外部服务返回。线程栈里看到 `WAITING` 只能说明线程在等,判断死锁还要找到稳定的等待环。
|
||||
|
||||
## 总结
|
||||
|
||||
锁是一组并发控制工具的总称,这个概念还是比较大的。
|
||||
|
||||
互斥锁和读写锁回答“谁能进临界区”;自旋锁和阻塞锁回答“拿不到锁怎么等”;悲观锁和乐观锁回答“冲突发生前后怎么处理”;CAS 和 Atomic 类解决单变量原子更新;`synchronized`、`ReentrantLock`、`StampedLock`、AQS 则是 Java 把这些思想落到代码里的方式。
|
||||
|
||||
真正写代码时,先把共享状态和不变量找出来,再决定锁保护什么、锁粒度多大、等待是否能中断、失败是否能重试。工具只是手段,真正要守住的是同一套同步协议:所有访问共享状态的路径都必须遵守它。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [The Java Language Specification, Chapter 17. Threads and Locks](https://docs.oracle.com/javase/specs/jls/se24/html/jls-17.html)
|
||||
- [The Java Virtual Machine Specification, monitorenter](https://docs.oracle.com/javase/specs/jvms/se24/html/jvms-6.html#jvms-6.5.monitorenter)
|
||||
- [Oracle Java API: Lock](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/locks/Lock.html)
|
||||
- [Oracle Java API: ReentrantLock](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/locks/ReentrantLock.html)
|
||||
- [Oracle Java API: StampedLock](https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/locks/StampedLock.html)
|
||||
- [OpenJDK JEP 374: Deprecate and Disable Biased Locking](https://openjdk.org/jeps/374)
|
||||
- [OpenJDK JEP 491: Synchronize Virtual Threads without Pinning](https://openjdk.org/jeps/491)
|
||||
- [OSTEP 中文版:Locks](https://pages.cs.wisc.edu/~remzi/OSTEP/Chinese/28.pdf)
|
||||
- [MIT 6.005: Locks and Synchronization](http://web.mit.edu/6.005/www/fa15/classes/23-locks/)
|
||||
@@ -0,0 +1,307 @@
|
||||
---
|
||||
title: Java 线程池最佳实践
|
||||
description: Java线程池最佳实践总结:详解线程池参数配置、避免Executors工厂方法OOM风险、拒绝策略选择、线程池监控、线程命名规范等生产级实践。
|
||||
category: Java
|
||||
tag:
|
||||
- Java并发
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: 线程池最佳实践,ThreadPoolExecutor配置,Executors陷阱,OOM风险,拒绝策略,线程池监控,线程命名
|
||||
---
|
||||
|
||||
简单总结一下我了解的使用线程池的时候应该注意的东西,网上似乎还没有专门写这方面的文章。
|
||||
|
||||
## 1、正确声明线程池
|
||||
|
||||
**线程池必须手动通过 `ThreadPoolExecutor` 的构造函数来声明,避免使用 `Executors` 类创建线程池,会有 OOM 风险。**
|
||||
|
||||
`Executors` 返回线程池对象的弊端如下(后文会详细介绍到):
|
||||
|
||||
- **`FixedThreadPool` 和 `SingleThreadExecutor`**:使用的是阻塞队列 `LinkedBlockingQueue`,任务队列的默认长度和最大长度为 `Integer.MAX_VALUE`,可以看作是无界队列,可能堆积大量的请求,从而导致 OOM。
|
||||
- **`CachedThreadPool`**:使用的是同步队列 `SynchronousQueue`,允许创建的线程数量为 `Integer.MAX_VALUE`,可能会创建大量线程,从而导致 OOM。
|
||||
- **`ScheduledThreadPool` 和 `SingleThreadScheduledExecutor`** : 使用的无界的延迟阻塞队列 `DelayedWorkQueue`,任务队列最大长度为 `Integer.MAX_VALUE`,可能堆积大量的请求,从而导致 OOM。
|
||||
|
||||
说白了就是:**使用有界队列,控制线程创建数量。**
|
||||
|
||||
除了避免 OOM 的原因之外,不推荐使用 `Executors` 提供的两种快捷的线程池的原因还有:
|
||||
|
||||
- 实际使用中需要根据自己机器的性能、业务场景来手动配置线程池的参数比如核心线程数、使用的任务队列、饱和策略等等。
|
||||
- 我们应该显示地给我们的线程池命名,这样有助于我们定位问题。
|
||||
|
||||
## 2、监测线程池运行状态
|
||||
|
||||
你可以通过一些手段来检测线程池的运行状态比如 SpringBoot 中的 Actuator 组件。
|
||||
|
||||
除此之外,我们还可以利用 `ThreadPoolExecutor` 的相关 API 做一个简陋的监控。从下图可以看出, `ThreadPoolExecutor` 提供了获取线程池当前的线程数和活跃线程数、已经执行完成的任务数、正在排队中的任务数等等。
|
||||
|
||||

|
||||
|
||||
下面是一个简单的 Demo。`printThreadPoolStatus()` 会每隔一秒打印出线程池的线程数、活跃线程数、完成的任务数、以及队列中的任务数。
|
||||
|
||||
```java
|
||||
/**
|
||||
* 打印线程池的状态
|
||||
*
|
||||
* @param threadPool 线程池对象
|
||||
*/
|
||||
public static void printThreadPoolStatus(ThreadPoolExecutor threadPool) {
|
||||
ScheduledExecutorService scheduledExecutorService = new ScheduledThreadPoolExecutor(1, createThreadFactory("print-images/thread-pool-status", false));
|
||||
scheduledExecutorService.scheduleAtFixedRate(() -> {
|
||||
log.info("=========================");
|
||||
log.info("ThreadPool Size: [{}]", threadPool.getPoolSize());
|
||||
log.info("Active Threads: {}", threadPool.getActiveCount());
|
||||
log.info("Number of Tasks : {}", threadPool.getCompletedTaskCount());
|
||||
log.info("Number of Tasks in Queue: {}", threadPool.getQueue().size());
|
||||
log.info("=========================");
|
||||
}, 0, 1, TimeUnit.SECONDS);
|
||||
}
|
||||
```
|
||||
|
||||
## 3、建议不同类别的业务用不同的线程池
|
||||
|
||||
很多人在实际项目中都会有类似这样的问题:**我的项目中多个业务需要用到线程池,是为每个线程池都定义一个还是说定义一个公共的线程池呢?**
|
||||
|
||||
一般建议是不同的业务使用不同的线程池,配置线程池的时候根据当前业务的情况对当前线程池进行配置,因为不同的业务的并发以及对资源的使用情况都不同,重心优化系统性能瓶颈相关的业务。
|
||||
|
||||
**我们再来看一个真实的事故案例!** (本案例来源自:[《线程池运用不当的一次线上事故》](https://heapdump.cn/article/646639),很精彩的一个案例)
|
||||
|
||||

|
||||
|
||||
上面的代码可能会存在死锁的情况,为什么呢?画个图给大家捋一捋。
|
||||
|
||||
试想这样一种极端情况:假如我们线程池的核心线程数为 **n**,父任务(扣费任务)数量为 **n**,父任务下面有两个子任务(扣费任务下的子任务),其中一个已经执行完成,另外一个被放在了任务队列中。由于父任务把线程池核心线程资源用完,所以子任务因为无法获取到线程资源无法正常执行,一直被阻塞在队列中。父任务等待子任务执行完成,而子任务等待父任务释放线程池资源,这也就造成了 **“死锁”**。
|
||||
|
||||

|
||||
|
||||
解决方法也很简单,就是新增加一个用于执行子任务的线程池专门为其服务。
|
||||
|
||||
## 4、别忘记给线程池命名
|
||||
|
||||
初始化线程池的时候需要显示命名(设置线程池名称前缀),有利于定位问题。
|
||||
|
||||
默认情况下创建的线程名字类似 `pool-1-thread-n` 这样的,没有业务含义,不利于我们定位问题。
|
||||
|
||||
给线程池里的线程命名通常有下面两种方式:
|
||||
|
||||
**1、利用 guava 的 `ThreadFactoryBuilder`**
|
||||
|
||||
```java
|
||||
ThreadFactory threadFactory = new ThreadFactoryBuilder()
|
||||
.setNameFormat(threadNamePrefix + "-%d")
|
||||
.setDaemon(true).build();
|
||||
ExecutorService threadPool = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, TimeUnit.MINUTES, workQueue, threadFactory)
|
||||
```
|
||||
|
||||
**2、自己实现 `ThreadFactory`。**
|
||||
|
||||
```java
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 线程工厂,它设置线程名称,有利于我们定位问题。
|
||||
*/
|
||||
public final class NamingThreadFactory implements ThreadFactory {
|
||||
|
||||
private final AtomicInteger threadNum = new AtomicInteger();
|
||||
private final String name;
|
||||
|
||||
/**
|
||||
* 创建一个带名字的线程池生产工厂
|
||||
*/
|
||||
public NamingThreadFactory(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Thread newThread(Runnable r) {
|
||||
Thread t = new Thread(r);
|
||||
t.setName(name + " [#" + threadNum.incrementAndGet() + "]");
|
||||
return t;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5、正确配置线程池参数
|
||||
|
||||
说到如何给线程池配置参数,美团的骚操作至今让我难忘(后面会提到)!
|
||||
|
||||
我们先来看一下各种书籍和博客上一般推荐的配置线程池参数的方式,可以作为参考。
|
||||
|
||||
### 常规操作
|
||||
|
||||
很多人甚至可能都会觉得把线程池配置过大一点比较好!我觉得这明显是有问题的。就拿我们生活中非常常见的一例子来说:**并不是人多就能把事情做好,增加了沟通交流成本。你本来一件事情只需要 3 个人做,你硬是拉来了 6 个人,会提升做事效率嘛?我想并不会。** 线程数量过多的影响也是和我们分配多少人做事情一样,对于多线程这个场景来说主要是增加了**上下文切换** 成本。不清楚什么是上下文切换的话,可以看我下面的介绍。
|
||||
|
||||
> 上下文切换:
|
||||
>
|
||||
> 多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。**任务从保存到再加载的过程就是一次上下文切换**。
|
||||
>
|
||||
> 上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。
|
||||
>
|
||||
> Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。
|
||||
|
||||
类比于现实世界中的人类通过合作做某件事情,我们可以肯定的一点是线程池大小设置过大或者过小都会有问题,合适的才是最好。
|
||||
|
||||
- 如果我们设置的线程池数量太小的话,如果同一时间有大量任务/请求需要处理,可能会导致大量的请求/任务在任务队列中排队等待执行,甚至会出现任务队列满了之后任务/请求无法处理的情况,或者大量任务堆积在任务队列导致 OOM。这样很明显是有问题的,CPU 根本没有得到充分利用。
|
||||
- 如果我们设置线程数量太大,大量线程可能会同时在争取 CPU 资源,这样会导致大量的上下文切换,从而增加线程的执行时间,影响了整体执行效率。
|
||||
|
||||
有一个简单并且适用面比较广的公式:
|
||||
|
||||
- **CPU 密集型任务 (N):** 这种任务消耗的主要是 CPU 资源,线程数应设置为 N(CPU 核心数)。由于任务主要瓶颈在于 CPU 计算能力,与核心数相等的线程数能够最大化 CPU 利用率,过多线程反而会导致竞争和上下文切换开销。
|
||||
- **I/O 密集型任务(M \* N):** 这类任务大部分时间处理 I/O 交互,线程在等待 I/O 时不占用 CPU。 为了充分利用 CPU 资源,线程数可以设置为 M \* N,其中 N 是 CPU 核心数,M 是一个大于 1 的倍数,建议默认设置为 2,具体取值取决于 I/O 等待时间和任务特点,需要通过测试和监控找到最佳平衡点。
|
||||
|
||||
CPU 密集型任务不再推荐 N+1,原因如下:
|
||||
|
||||
- "N+1" 的初衷是希望预留线程处理突发暂停,但实际上,处理缺页中断等情况仍然需要占用 CPU 核心。
|
||||
- CPU 密集场景下,CPU 始终是瓶颈,预留线程并不能凭空增加 CPU 处理能力,反而可能加剧竞争。
|
||||
|
||||
**如何判断是 CPU 密集任务还是 IO 密集任务?**
|
||||
|
||||
CPU 密集型简单理解就是利用 CPU 计算能力的任务比如你在内存中对大量数据进行排序。但凡涉及到网络读取,文件读取这类都是 IO 密集型,这类任务的特点是 CPU 计算耗费时间相比于等待 IO 操作完成的时间来说很少,大部分时间都花在了等待 IO 操作完成上。
|
||||
|
||||
🌈 拓展一下(参见:[issue#1737](https://github.com/Snailclimb/JavaGuide/issues/1737)):
|
||||
|
||||
线程数更严谨的计算的方法应该是:`最佳线程数 = N(CPU 核心数)∗(1+WT(线程等待时间)/ST(线程计算时间))`,其中 `WT(线程等待时间)=线程运行总时间 - ST(线程计算时间)`。
|
||||
|
||||
线程等待时间所占比例越高,需要越多线程。线程计算时间所占比例越高,需要越少线程。
|
||||
|
||||
我们可以通过 JDK 自带的工具 VisualVM 来查看 `WT/ST` 比例。
|
||||
|
||||
CPU 密集型任务的 `WT/ST` 接近或者等于 0,因此, 线程数可以设置为 N(CPU 核心数)∗(1+0)= N,和我们上面说的 N(CPU 核心数)+1 差不多。
|
||||
|
||||
IO 密集型任务下,几乎全是线程等待时间,从理论上来说,你就可以将线程数设置为 2N(按道理来说,WT/ST 的结果应该比较大,这里选择 2N 的原因应该是为了避免创建过多线程吧)。
|
||||
|
||||
**注意**:上面提到的公示也只是参考,实际项目不太可能直接按照公式来设置线程池参数,毕竟不同的业务场景对应的需求不同,具体还是要根据项目实际线上运行情况来动态调整。接下来介绍的美团的线程池参数动态配置这种方案就非常不错,很实用!
|
||||
|
||||
### 美团的骚操作
|
||||
|
||||
美团技术团队在[《Java 线程池实现原理及其在美团业务中的实践》](https://tech.meituan.com/2020/04/02/java-pooling-pratice-in-meituan.html)这篇文章中介绍到对线程池参数实现可自定义配置的思路和方法。
|
||||
|
||||
美团技术团队的思路是主要对线程池的核心参数实现自定义可配置。这三个核心参数是:
|
||||
|
||||
- **`corePoolSize` :** 核心线程数定义了最小可以同时运行的线程数量。
|
||||
- **`maximumPoolSize` :** 当队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
|
||||
- **`workQueue`:** 当新任务来的时候会先判断当前工作线程总数是否达到核心线程数;如果达到的话,新任务就会被优先存放在队列中,等空闲工作线程来处理。
|
||||
|
||||
**为什么是这三个参数?**
|
||||
|
||||
我在这篇[《新手也能看懂的线程池学习总结》](https://mp.weixin.qq.com/s?__biz=Mzg2OTA0Njk0OA==&mid=2247485808&idx=1&sn=1013253533d73450cef673aee13267ab&chksm=cea246bbf9d5cfad1c21316340a0ef1609a7457fea4113a1f8d69e8c91e7d9cd6285f5ee1490&token=510053261&lang=zh_CN&scene=21#wechat_redirect) 中就说过这三个参数是 `ThreadPoolExecutor` 最重要的参数,它们基本决定了线程池对于任务的处理策略。
|
||||
|
||||
**如何支持参数动态配置?** 且看 `ThreadPoolExecutor` 提供的下面这些方法。
|
||||
|
||||

|
||||
|
||||
格外需要注意的是 `corePoolSize`, 程序运行期间的时候,我们调用 `setCorePoolSize()` 这个方法的话,线程池会首先判断当前工作线程数是否大于 `corePoolSize`,如果大于的话就会回收工作线程。
|
||||
|
||||
另外,你也看到了上面并没有动态指定队列长度的方法,美团的方式是自定义了一个叫做 `ResizableCapacityLinkedBlockIngQueue` 的队列(主要就是把 `LinkedBlockingQueue` 的 capacity 字段的 final 关键字修饰给去掉了,让它变为可变的)。
|
||||
|
||||
最终实现的可动态修改线程池参数效果如下。👏👏👏
|
||||
|
||||

|
||||
|
||||
如果我们的项目也想要实现这种效果的话,可以借助现成的开源项目:
|
||||
|
||||
- **[Hippo4j](https://github.com/opengoofy/hippo4j)**:异步线程池框架,支持线程池动态变更&监控&报警,无需修改代码轻松引入。支持多种使用模式,轻松引入,致力于提高系统运行保障能力。
|
||||
- **[Dynamic TP](https://github.com/dromara/dynamic-tp)**:轻量级动态线程池,内置监控告警功能,集成三方中间件线程池管理,基于主流配置中心(已支持 Nacos、Apollo,Zookeeper、Consul、Etcd,可通过 SPI 自定义实现)。
|
||||
|
||||
## 6、别忘记关闭线程池
|
||||
|
||||
当线程池不再需要使用时,应该显式地关闭线程池,释放线程资源。
|
||||
|
||||
线程池提供了两个关闭方法:
|
||||
|
||||
- **`shutdown()`** :关闭线程池,线程池的状态变为 `SHUTDOWN`。线程池不再接受新任务了,但是队列里的任务得执行完毕。
|
||||
- **`shutdownNow()`** :关闭线程池,线程池的状态变为 `STOP`。线程池会终止当前正在运行的任务,停止处理排队的任务并返回正在等待执行的 List。
|
||||
|
||||
调用完 `shutdownNow` 和 `shuwdown` 方法后,并不代表线程池已经完成关闭操作,它只是异步的通知线程池进行关闭处理。如果要同步等待线程池彻底关闭后才继续往下执行,需要调用 `awaitTermination` 方法进行同步等待。
|
||||
|
||||
在调用 `awaitTermination()` 方法时,应该设置合理的超时时间,以避免程序长时间阻塞而导致性能问题。另外。由于线程池中的任务可能会被取消或抛出异常,因此在使用 `awaitTermination()` 方法时还需要进行异常处理。`awaitTermination()` 方法会抛出 `InterruptedException` 异常,需要捕获并处理该异常,以避免程序崩溃或者无法正常退出。
|
||||
|
||||
```java
|
||||
// ...
|
||||
// 关闭线程池
|
||||
executor.shutdown();
|
||||
try {
|
||||
// 等待线程池关闭,最多等待5分钟
|
||||
if (!executor.awaitTermination(5, TimeUnit.MINUTES)) {
|
||||
// 如果等待超时,则打印日志
|
||||
System.err.println("线程池未能在5分钟内完全关闭");
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
// 异常处理
|
||||
}
|
||||
```
|
||||
|
||||
## 7、线程池尽量不要放耗时任务
|
||||
|
||||
线程池本身的目的是为了提高任务执行效率,避免因频繁创建和销毁线程而带来的性能开销。如果将耗时任务提交到线程池中执行,可能会导致线程池中的线程被长时间占用,无法及时响应其他任务,甚至会导致线程池崩溃或者程序假死。
|
||||
|
||||
因此,在使用线程池时,我们应该尽量避免将耗时任务提交到线程池中执行。对于一些比较耗时的操作,如网络请求、文件读写等,可以采用 `CompletableFuture` 等其他异步操作的方式来处理,以避免阻塞线程池中的线程。
|
||||
|
||||
## 8、线程池使用的一些小坑
|
||||
|
||||
### 重复创建线程池的坑
|
||||
|
||||
线程池是可以复用的,一定不要频繁创建线程池比如一个用户请求到了就单独创建一个线程池。
|
||||
|
||||
```java
|
||||
@GetMapping("wrong")
|
||||
public String wrong() throws InterruptedException {
|
||||
// 自定义线程池
|
||||
ThreadPoolExecutor executor = new ThreadPoolExecutor(5,10,1L,TimeUnit.SECONDS,new ArrayBlockingQueue<>(100),new ThreadPoolExecutor.CallerRunsPolicy());
|
||||
|
||||
// 处理任务
|
||||
executor.execute(() -> {
|
||||
// ......
|
||||
}
|
||||
return "OK";
|
||||
}
|
||||
```
|
||||
|
||||
出现这种问题的原因还是对于线程池认识不够,需要加强线程池的基础知识。
|
||||
|
||||
### Spring 内部线程池的坑
|
||||
|
||||
使用 Spring 内部线程池时,一定要手动自定义线程池,配置合理的参数,不然会出现生产问题(一个请求创建一个线程)。
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class ThreadPoolExecutorConfig {
|
||||
|
||||
@Bean(name="threadPoolExecutor")
|
||||
public Executor threadPoolExecutor(){
|
||||
ThreadPoolTaskExecutor threadPoolExecutor = new ThreadPoolTaskExecutor();
|
||||
int processNum = Runtime.getRuntime().availableProcessors(); // 返回可用处理器的Java虚拟机的数量
|
||||
int corePoolSize = (int) (processNum / (1 - 0.2));
|
||||
int maxPoolSize = (int) (processNum / (1 - 0.5));
|
||||
threadPoolExecutor.setCorePoolSize(corePoolSize); // 核心池大小
|
||||
threadPoolExecutor.setMaxPoolSize(maxPoolSize); // 最大线程数
|
||||
threadPoolExecutor.setQueueCapacity(maxPoolSize * 1000); // 队列长度
|
||||
threadPoolExecutor.setThreadPriority(Thread.MAX_PRIORITY);
|
||||
threadPoolExecutor.setDaemon(false);
|
||||
threadPoolExecutor.setKeepAliveSeconds(300);// 线程空闲时间
|
||||
threadPoolExecutor.setThreadNamePrefix("test-Executor-"); // 线程名字前缀
|
||||
return threadPoolExecutor;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 线程池和 ThreadLocal 共用的坑
|
||||
|
||||
线程池和 `ThreadLocal` 共用,可能会导致线程从 `ThreadLocal` 获取到的是旧值/脏数据。这是因为线程池会复用线程对象,与线程对象绑定的类的静态属性 `ThreadLocal` 变量也会被重用,这就导致一个线程可能获取到其他线程的 `ThreadLocal` 值。
|
||||
|
||||
不要以为代码中没有显示使用线程池就不存在线程池了,像常用的 Web 服务器 Tomcat 处理任务为了提高并发量,就使用到了线程池,并且使用的是基于原生 Java 线程池改进完善得到的自定义线程池。
|
||||
|
||||
当然了,你可以将 Tomcat 设置为单线程处理任务。不过,这并不合适,会严重影响其处理任务的速度。
|
||||
|
||||
```properties
|
||||
server.tomcat.max-threads=1
|
||||
```
|
||||
|
||||
解决上述问题比较建议的办法是使用阿里巴巴开源的 `TransmittableThreadLocal`(`TTL`)。`TransmittableThreadLocal` 类继承并加强了 JDK 内置的 `InheritableThreadLocal` 类,在使用线程池等会池化复用线程的执行组件情况下,提供 `ThreadLocal` 值的传递功能,解决异步执行时上下文传递的问题。
|
||||
|
||||
`TransmittableThreadLocal` 项目地址:<https://github.com/alibaba/transmittable-thread-local>。
|
||||
|
||||
<!-- @include: @article-footer.snippet.md -->
|
||||
@@ -0,0 +1,880 @@
|
||||
---
|
||||
title: Java 线程池详解
|
||||
description: Java线程池详解:深入讲解ThreadPoolExecutor核心参数配置、Executor框架体系、任务队列选择、拒绝策略、线程池工作原理及最佳实践。
|
||||
category: Java
|
||||
tag:
|
||||
- Java并发
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: Java线程池,ThreadPoolExecutor,Executor框架,线程池参数,拒绝策略,任务队列,线程池原理
|
||||
---
|
||||
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
|
||||
|
||||
这篇文章我会详细介绍一下线程池的基本概念以及核心原理。
|
||||
|
||||
## 线程池介绍
|
||||
|
||||
池化技术想必大家已经屡见不鲜了,线程池、数据库连接池、HTTP 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。
|
||||
|
||||
线程池提供了一种限制和管理资源(包括执行一个任务)的方式。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。使用线程池主要带来以下几个好处:
|
||||
|
||||
1. **降低资源消耗**:线程池里的线程是可以重复利用的。一旦线程完成了某个任务,它不会立即销毁,而是回到池子里等待下一个任务。这就避免了频繁创建和销毁线程带来的开销。
|
||||
2. **提高响应速度**:因为线程池里通常会维护一定数量的核心线程(或者说“常驻工人”),任务来了之后,可以直接交给这些已经存在的、空闲的线程去执行,省去了创建线程的时间,任务能够更快地得到处理。
|
||||
3. **提高线程的可管理性**:线程池允许我们统一管理池中的线程。我们可以配置线程池的大小(核心线程数、最大线程数)、任务队列的类型和大小、拒绝策略等。这样就能控制并发线程的总量,防止资源耗尽,保证系统的稳定性。同时,线程池通常也提供了监控接口,方便我们了解线程池的运行状态(比如有多少活跃线程、多少任务在排队等),便于调优。
|
||||
|
||||
## Executor 框架介绍
|
||||
|
||||
`Executor` 框架是 Java5 之后引进的,在 Java 5 之后,通过 `Executor` 来启动线程比使用 `Thread` 的 `start` 方法更好,除了更易管理,效率更好(用线程池实现,节约开销)外,还有关键的一点:有助于避免 this 逃逸问题。
|
||||
|
||||
> this 逃逸是指在构造函数返回之前其他线程就持有该对象的引用,调用尚未构造完全的对象的方法可能引发令人疑惑的错误。
|
||||
|
||||
`Executor` 框架不仅包括了线程池的管理,还提供了线程工厂、队列以及拒绝策略等,`Executor` 框架让并发编程变得更加简单。
|
||||
|
||||
`Executor` 框架结构主要由三大部分组成:
|
||||
|
||||
**1、任务(`Runnable` /`Callable`)**
|
||||
|
||||
执行任务需要实现的 **`Runnable` 接口** 或 **`Callable` 接口**。**`Runnable` 接口**或 **`Callable` 接口** 实现类都可以被 **`ThreadPoolExecutor`** 或 **`ScheduledThreadPoolExecutor`** 执行。
|
||||
|
||||
**2、任务的执行(`Executor`)**
|
||||
|
||||
如下图所示,包括任务执行机制的核心接口 **`Executor`**,以及继承自 `Executor` 接口的 **`ExecutorService` 接口。`ThreadPoolExecutor`** 和 **`ScheduledThreadPoolExecutor`** 这两个关键类实现了 **`ExecutorService`** 接口。
|
||||
|
||||

|
||||
|
||||
这里提了很多底层的类关系,但是,实际上我们需要更多关注的是 `ThreadPoolExecutor` 这个类,这个类在我们实际使用线程池的过程中,使用频率还是非常高的。
|
||||
|
||||
**注意:** 通过查看 `ScheduledThreadPoolExecutor` 源代码我们发现 `ScheduledThreadPoolExecutor` 实际上是继承了 `ThreadPoolExecutor` 并实现了 `ScheduledExecutorService`,而 `ScheduledExecutorService` 又实现了 `ExecutorService`,正如我们上面给出的类关系图显示的一样。
|
||||
|
||||
`ThreadPoolExecutor` 类描述:
|
||||
|
||||
```java
|
||||
//AbstractExecutorService实现了ExecutorService接口
|
||||
public class ThreadPoolExecutor extends AbstractExecutorService
|
||||
```
|
||||
|
||||
`ScheduledThreadPoolExecutor` 类描述:
|
||||
|
||||
```java
|
||||
//ScheduledExecutorService继承ExecutorService接口
|
||||
public class ScheduledThreadPoolExecutor
|
||||
extends ThreadPoolExecutor
|
||||
implements ScheduledExecutorService
|
||||
```
|
||||
|
||||
**3、异步计算的结果(`Future`)**
|
||||
|
||||
**`Future`** 接口以及 `Future` 接口的实现类 **`FutureTask`** 类都可以代表异步计算的结果。
|
||||
|
||||
当我们把 **`Runnable` 接口** 或 **`Callable` 接口** 的实现类提交给 **`ThreadPoolExecutor`** 或 **`ScheduledThreadPoolExecutor`** 执行。(调用 `submit()` 方法时会返回一个 **`FutureTask`** 对象)
|
||||
|
||||
**`Executor` 框架的使用示意图**:
|
||||
|
||||

|
||||
|
||||
1. 主线程首先要创建实现 `Runnable` 或者 `Callable` 接口的任务对象。
|
||||
2. 把创建完成的实现 `Runnable`/`Callable` 接口的 对象直接交给 `ExecutorService` 执行: `ExecutorService.execute(Runnable command)`)或者也可以把 `Runnable` 对象或 `Callable` 对象提交给 `ExecutorService` 执行(`ExecutorService.submit(Runnable task)` 或 `ExecutorService.submit(Callable <T> task)`)。
|
||||
3. 如果执行 `ExecutorService.submit(…)`,`ExecutorService` 将返回一个实现 `Future` 接口的对象(我们刚刚也提到过了执行 `execute()` 方法和 `submit()` 方法的区别,`submit()` 会返回一个 `FutureTask 对象)。由于 FutureTask` 实现了 `Runnable`,我们也可以创建 `FutureTask`,然后直接交给 `ExecutorService` 执行。
|
||||
4. 最后,主线程可以执行 `FutureTask.get()` 方法来等待任务执行完成。主线程也可以执行 `FutureTask.cancel(boolean mayInterruptIfRunning)` 来取消此任务的执行。
|
||||
|
||||
## ThreadPoolExecutor 类介绍(重要)
|
||||
|
||||
线程池实现类 `ThreadPoolExecutor` 是 `Executor` 框架最核心的类。
|
||||
|
||||
### 线程池参数分析
|
||||
|
||||
`ThreadPoolExecutor` 类中提供的四个构造方法。我们来看最长的那个,其余三个都是在这个构造方法的基础上产生(其他几个构造方法说白点都是给定某些默认参数的构造方法比如默认制定拒绝策略是什么)。
|
||||
|
||||
```java
|
||||
/**
|
||||
* 用给定的初始参数创建一个新的ThreadPoolExecutor。
|
||||
*/
|
||||
public ThreadPoolExecutor(int corePoolSize,//线程池的核心线程数量
|
||||
int maximumPoolSize,//线程池的最大线程数
|
||||
long keepAliveTime,//当线程数大于核心线程数时,多余的空闲线程存活的最长时间
|
||||
TimeUnit unit,//时间单位
|
||||
BlockingQueue<Runnable> workQueue,//任务队列,用来储存等待执行任务的队列
|
||||
ThreadFactory threadFactory,//线程工厂,用来创建线程,一般默认即可
|
||||
RejectedExecutionHandler handler//拒绝策略,当提交的任务过多而不能及时处理时,我们可以定制策略来处理任务
|
||||
) {
|
||||
if (corePoolSize < 0 ||
|
||||
maximumPoolSize <= 0 ||
|
||||
maximumPoolSize < corePoolSize ||
|
||||
keepAliveTime < 0)
|
||||
throw new IllegalArgumentException();
|
||||
if (workQueue == null || threadFactory == null || handler == null)
|
||||
throw new NullPointerException();
|
||||
this.corePoolSize = corePoolSize;
|
||||
this.maximumPoolSize = maximumPoolSize;
|
||||
this.workQueue = workQueue;
|
||||
this.keepAliveTime = unit.toNanos(keepAliveTime);
|
||||
this.threadFactory = threadFactory;
|
||||
this.handler = handler;
|
||||
}
|
||||
```
|
||||
|
||||
下面这些参数非常重要,在后面使用线程池的过程中你一定会用到!所以,务必拿着小本本记清楚。
|
||||
|
||||
`ThreadPoolExecutor` 3 个最重要的参数:
|
||||
|
||||
- `corePoolSize` : 任务队列未达到队列容量时,最大可以同时运行的线程数量。
|
||||
- `maximumPoolSize` : 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。
|
||||
- `workQueue`: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
|
||||
|
||||
`ThreadPoolExecutor` 其他常见参数 :
|
||||
|
||||
- `keepAliveTime`:线程池中的线程数量大于 `corePoolSize` 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 `keepAliveTime` 才会被回收销毁。
|
||||
- `unit` : `keepAliveTime` 参数的时间单位。
|
||||
- `threadFactory` :executor 创建新线程的时候会用到。
|
||||
- `handler` :拒绝策略(后面会单独详细介绍一下)。
|
||||
|
||||
下面这张图可以加深你对线程池中各个参数的相互关系的理解(图片来源:《Java 性能调优实战》):
|
||||
|
||||

|
||||
|
||||
### 线程池生命周期状态
|
||||
|
||||
`ThreadPoolExecutor` 使用 `ctl` 变量(`AtomicInteger` 类型)同时管理线程池的运行状态和工作线程数量。线程池共有 5 种状态:
|
||||
|
||||
- **运行中(`RUNNING`)**:接受新任务,并处理队列中的任务。线程池创建后的初始状态。
|
||||
- **关闭(`SHUTDOWN`)**:不再接受新任务,但会继续处理队列中已有的任务。调用 `shutdown()` 后进入。
|
||||
- **停止(`STOP`)**:不接受新任务,不处理队列中的任务,并尝试中断正在执行的任务。调用 `shutdownNow()` 后进入。
|
||||
- **整理中(`TIDYING`)**:所有任务已终止,工作线程数为 0,即将执行 `terminated()` 钩子方法。
|
||||
- **已终止(`TERMINATED`)**:`terminated()` 方法执行完毕,线程池彻底终结。
|
||||
|
||||
状态只能单向流转:运行中(`RUNNING`)→ 关闭(`SHUTDOWN`)→ 整理中(`TIDYING`)→ 已终止(`TERMINATED`),或者运行中(`RUNNING`)→ 停止(`STOP`)→ 整理中(`TIDYING`)→ 已终止(`TERMINATED`)。在关闭(`SHUTDOWN`)状态下再调用 `shutdownNow()` 也会转为停止(`STOP`)。
|
||||
|
||||
`shutdown()` 是“温和关闭”——中断空闲线程,但队列中的任务仍会执行完毕。`shutdownNow()` 是“强制关闭”——尝试中断所有正在运行的线程,并将队列中未执行的任务以 `List<Runnable>` 返回。`terminated()` 是一个空的钩子方法,可以通过继承 `ThreadPoolExecutor` 来重写它,用于在线程池终止后做清理工作。
|
||||
|
||||
### Worker 工作线程机制
|
||||
|
||||
`ThreadPoolExecutor` 将每个工作线程封装为内部类 `Worker`。`Worker` 继承了 AQS 并实现了 `Runnable` 接口。
|
||||
|
||||
**为什么 `Worker` 要继承 AQS?** `Worker` 实现了一个**不可重入的独占锁**,用于配合 `shutdown()` 区分线程是空闲还是正在工作——正在执行任务的 Worker 持有锁,`shutdown()` 对每个 Worker 尝试 `tryLock()`,失败则说明该线程正在工作,不会被中断。
|
||||
|
||||
**Worker 的生命周期:**
|
||||
|
||||
1. **创建**:`execute()` 判断需要新建线程时,调用 `addWorker()` 创建 `Worker` 实例,内部通过 `ThreadFactory` 创建线程。
|
||||
2. **运行**:线程启动后进入 `runWorker()` 的 `while` 循环,通过 `getTask()` 不断从队列取任务执行。核心线程用 `workQueue.take()`(阻塞等待),非核心线程用 `workQueue.poll(keepAliveTime, unit)`(超时等待)。
|
||||
3. **退出**:`getTask()` 返回 `null` 时 Worker 退出循环并清理。返回 `null` 的情况包括:线程池处于停止(`STOP`)状态、线程池处于关闭(`SHUTDOWN`)状态且队列为空、非核心线程等待超时、或运行时缩小了 `maximumPoolSize`。如果退出后工作线程数低于核心数,会自动补充一个新线程。
|
||||
|
||||
**`ThreadPoolExecutor` 拒绝策略定义:**
|
||||
|
||||
如果当前同时运行的线程数量达到最大线程数量并且队列也已经被放满了任务时,`ThreadPoolExecutor` 定义一些策略:
|
||||
|
||||
- `ThreadPoolExecutor.AbortPolicy`:抛出 `RejectedExecutionException` 来拒绝新任务的处理。
|
||||
- `ThreadPoolExecutor.CallerRunsPolicy`:调用执行自己的线程运行任务,也就是直接在调用 `execute` 方法的线程中运行(`run`)被拒绝的任务,如果执行程序已关闭,则会丢弃该任务。因此这种策略会降低对于新任务提交速度,影响程序的整体性能。如果您的应用程序可以承受此延迟并且你要求任何一个任务请求都要被执行的话,你可以选择这个策略。
|
||||
- `ThreadPoolExecutor.DiscardPolicy`:不处理新任务,直接丢弃掉。
|
||||
- `ThreadPoolExecutor.DiscardOldestPolicy`:此策略将丢弃最早的未处理的任务请求。
|
||||
|
||||
举个例子:
|
||||
|
||||
举个例子:Spring 通过 `ThreadPoolTaskExecutor` 或者我们直接通过 `ThreadPoolExecutor` 的构造函数创建线程池的时候,当我们不指定 `RejectedExecutionHandler` 拒绝策略来配置线程池的时候,默认使用的是 `AbortPolicy`。在这种拒绝策略下,如果队列满了,`ThreadPoolExecutor` 将抛出 `RejectedExecutionException` 异常来拒绝新来的任务,这代表你将丢失对这个任务的处理。如果不想丢弃任务的话,可以使用 `CallerRunsPolicy`。`CallerRunsPolicy` 和其他的几个策略不同,它既不会抛弃任务,也不会抛出异常,而是将任务回退给调用者,使用调用者的线程来执行任务
|
||||
|
||||
```java
|
||||
public static class CallerRunsPolicy implements RejectedExecutionHandler {
|
||||
|
||||
public CallerRunsPolicy() { }
|
||||
|
||||
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
|
||||
if (!e.isShutdown()) {
|
||||
// 直接主线程执行,而不是线程池中的线程执行
|
||||
r.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4 种拒绝策略的实际应用场景
|
||||
|
||||
上面介绍了 4 种内置拒绝策略的基本行为,下面结合实际生产经验,说明它们各自适合什么场景:
|
||||
|
||||
**`AbortPolicy`**:适用于对任务丢失零容忍的核心业务(如支付、转账)。任务被拒绝时调用方会收到 `RejectedExecutionException`,必须在业务代码中捕获并做补偿(如重试或持久化到数据库后补偿执行)。《阿里巴巴 Java 开发手册》指出,如果不做任何配置,队列满时会直接抛异常,开发者必须显式处理。
|
||||
|
||||
**`CallerRunsPolicy`**:适用于不允许丢弃任务、且允许降低提交速度的场景。由于任务在调用者线程中执行,调用者在此期间无法提交新任务,形成了一种天然的**反压(back-pressure)**机制。美团技术团队在《Java 线程池实现原理及其在美团业务中的实践》中提到,这是他们线上业务中较常使用的拒绝策略。但需要注意:如果提交任务的线程是 Web 容器的请求处理线程(如 Tomcat 的 Worker 线程),会导致该请求响应时间显著增加,在延迟敏感的场景中需谨慎。
|
||||
|
||||
**`DiscardPolicy`**:适用于任务允许丢失的非关键路径,如日志异步写入、监控指标上报。该策略完全静默(空实现),被拒绝的任务不会留下任何痕迹,排查问题时可能难以发现任务丢失。
|
||||
|
||||
**`DiscardOldestPolicy`**:适用于只关心最新数据、旧任务可被覆盖的场景,如实时行情推送、传感器数据采集。需要注意:如果使用了 `PriorityBlockingQueue`,`poll()` 弹出的是优先级最高的任务而非最旧的任务,可能导致重要任务被误丢。
|
||||
|
||||
**生产环境中的常见做法**:以上 4 种内置策略往往不能完全满足需求。Dubbo 框架自定义了 `AbortPolicyWithReport` 策略,在抛异常之外还会将被拒绝的任务信息 dump 到本地文件,方便事后排查。美团技术团队建议对线程池的拒绝次数进行监控和告警。常见的自定义策略思路包括:将被拒绝的任务写入数据库或消息队列后续补偿消费、递增监控计数器上报 Prometheus、或者调用 `workQueue.put(r)` 阻塞等待队列有空位(Netty 中有类似实现)。
|
||||
|
||||
### 线程池创建的两种方式
|
||||
|
||||
在 Java 中,创建线程池主要有两种方式:
|
||||
|
||||
**方式一:通过 `ThreadPoolExecutor` 构造函数直接创建(推荐)**
|
||||
|
||||

|
||||
|
||||
图中的“默认线程工厂”和“默认拒绝策略”,指的是当前构造函数没有显式传入对应参数时,`ThreadPoolExecutor` 会使用默认实现,并不是方法和说明错位。
|
||||
|
||||
这是最推荐的方式,因为它允许开发者明确指定线程池的核心参数,对线程池的运行行为有更精细的控制,从而避免资源耗尽的风险。
|
||||
|
||||
**方式二:通过 `Executors` 工具类创建(不推荐用于生产环境)**
|
||||
|
||||
`Executors` 工具类提供的创建线程池的方法如下图所示:
|
||||
|
||||

|
||||
|
||||
可以看出,通过 `Executors` 工具类可以创建多种类型的线程池,包括:
|
||||
|
||||
- `FixedThreadPool`:固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
|
||||
- `SingleThreadExecutor`: 只有一个线程的线程池。若多余一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
|
||||
- `CachedThreadPool`: 可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。
|
||||
- `ScheduledThreadPool`:给定的延迟后运行任务或者定期执行任务的线程池。
|
||||
|
||||
《阿里巴巴 Java 开发手册》强制线程池不允许使用 `Executors` 去创建,而是通过 `ThreadPoolExecutor` 构造函数的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险
|
||||
|
||||
`Executors` 返回线程池对象的弊端如下(后文会详细介绍到):
|
||||
|
||||
- `FixedThreadPool` 和 `SingleThreadExecutor`:使用的是阻塞队列 `LinkedBlockingQueue`,任务队列最大长度为 `Integer.MAX_VALUE`,可以看作是无界的,可能堆积大量的请求,从而导致 OOM。
|
||||
- `CachedThreadPool`:使用的是同步队列 `SynchronousQueue`, 允许创建的线程数量为 `Integer.MAX_VALUE`,如果任务数量过多且执行速度较慢,可能会创建大量的线程,从而导致 OOM。
|
||||
- `ScheduledThreadPool` 和 `SingleThreadScheduledExecutor`:使用的无界的延迟阻塞队列 `DelayedWorkQueue`,任务队列最大长度为 `Integer.MAX_VALUE`,可能堆积大量的请求,从而导致 OOM。
|
||||
|
||||
```java
|
||||
public static ExecutorService newFixedThreadPool(int nThreads) {
|
||||
// LinkedBlockingQueue 的默认长度为 Integer.MAX_VALUE,可以看作是无界的
|
||||
return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>());
|
||||
|
||||
}
|
||||
|
||||
public static ExecutorService newSingleThreadExecutor() {
|
||||
// LinkedBlockingQueue 的默认长度为 Integer.MAX_VALUE,可以看作是无界的
|
||||
return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()));
|
||||
|
||||
}
|
||||
|
||||
// 同步队列 SynchronousQueue,没有容量,最大线程数是 Integer.MAX_VALUE`
|
||||
public static ExecutorService newCachedThreadPool() {
|
||||
|
||||
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());
|
||||
|
||||
}
|
||||
|
||||
// DelayedWorkQueue(延迟阻塞队列)
|
||||
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
|
||||
return new ScheduledThreadPoolExecutor(corePoolSize);
|
||||
}
|
||||
public ScheduledThreadPoolExecutor(int corePoolSize) {
|
||||
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
|
||||
new DelayedWorkQueue());
|
||||
}
|
||||
```
|
||||
|
||||
### 线程池常用的阻塞队列总结
|
||||
|
||||
新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
|
||||
|
||||
不同的线程池会选用不同的阻塞队列,我们可以结合内置线程池来分析。
|
||||
|
||||
- 容量为 `Integer.MAX_VALUE` 的 `LinkedBlockingQueue`(无界队列):`FixedThreadPool` 和 `SingleThreadExector`。`FixedThreadPool` 最多只能创建核心线程数的线程(核心线程数和最大线程数相等),`SingleThreadExector` 只能创建一个线程(核心线程数和最大线程数都是 1),二者的任务队列永远不会被放满。
|
||||
- `SynchronousQueue`(同步队列):`CachedThreadPool`。`SynchronousQueue` 没有容量,不存储元素,目的是保证对于提交的任务,如果有空闲线程,则使用空闲线程来处理;否则新建一个线程来处理任务。也就是说,`CachedThreadPool` 的最大线程数是 `Integer.MAX_VALUE`,可以理解为线程数是可以无限扩展的,可能会创建大量线程,从而导致 OOM。
|
||||
- `DelayedWorkQueue`(延迟阻塞队列):`ScheduledThreadPool` 和 `SingleThreadScheduledExecutor`。`DelayedWorkQueue` 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。`DelayedWorkQueue` 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 `Integer.MAX_VALUE`,所以最多只能创建核心线程数的线程。
|
||||
|
||||
## 线程池原理分析(重要)
|
||||
|
||||
我们上面讲解了 `Executor` 框架以及 `ThreadPoolExecutor` 类,下面让我们实战一下,来通过写一个 `ThreadPoolExecutor` 的小 Demo 来回顾上面的内容。
|
||||
|
||||
### 线程池示例代码
|
||||
|
||||
首先创建一个 `Runnable` 接口的实现类(当然也可以是 `Callable` 接口,我们后面会介绍两者的区别。)
|
||||
|
||||
`MyRunnable.java`
|
||||
|
||||
```java
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 这是一个简单的Runnable类,需要大约5秒钟来执行其任务。
|
||||
* @author shuang.kou
|
||||
*/
|
||||
public class MyRunnable implements Runnable {
|
||||
|
||||
private String command;
|
||||
|
||||
public MyRunnable(String s) {
|
||||
this.command = s;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
System.out.println(Thread.currentThread().getName() + " Start. Time = " + new Date());
|
||||
processCommand();
|
||||
System.out.println(Thread.currentThread().getName() + " End. Time = " + new Date());
|
||||
}
|
||||
|
||||
private void processCommand() {
|
||||
try {
|
||||
Thread.sleep(5000);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return this.command;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
编写测试程序,我们这里以阿里巴巴推荐的使用 `ThreadPoolExecutor` 构造函数自定义参数的方式来创建线程池。
|
||||
|
||||
`ThreadPoolExecutorDemo.java`
|
||||
|
||||
```java
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class ThreadPoolExecutorDemo {
|
||||
|
||||
private static final int CORE_POOL_SIZE = 5;
|
||||
private static final int MAX_POOL_SIZE = 10;
|
||||
private static final int QUEUE_CAPACITY = 100;
|
||||
private static final Long KEEP_ALIVE_TIME = 1L;
|
||||
public static void main(String[] args) {
|
||||
|
||||
//使用阿里巴巴推荐的创建线程池的方式
|
||||
//通过ThreadPoolExecutor构造函数自定义参数创建
|
||||
ThreadPoolExecutor executor = new ThreadPoolExecutor(
|
||||
CORE_POOL_SIZE,
|
||||
MAX_POOL_SIZE,
|
||||
KEEP_ALIVE_TIME,
|
||||
TimeUnit.SECONDS,
|
||||
new ArrayBlockingQueue<>(QUEUE_CAPACITY),
|
||||
new ThreadPoolExecutor.CallerRunsPolicy());
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
//创建WorkerThread对象(WorkerThread类实现了Runnable 接口)
|
||||
Runnable worker = new MyRunnable("" + i);
|
||||
//执行Runnable
|
||||
executor.execute(worker);
|
||||
}
|
||||
//终止线程池
|
||||
executor.shutdown();
|
||||
while (!executor.isTerminated()) {
|
||||
}
|
||||
System.out.println("Finished all threads");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可以看到我们上面的代码指定了:
|
||||
|
||||
- `corePoolSize`: 核心线程数为 5。
|
||||
- `maximumPoolSize`:最大线程数 10
|
||||
- `keepAliveTime` : 等待时间为 1L。
|
||||
- `unit`: 等待时间的单位为 TimeUnit.SECONDS。
|
||||
- `workQueue`:任务队列为 `ArrayBlockingQueue`,并且容量为 100;
|
||||
- `handler`:拒绝策略为 `CallerRunsPolicy`。
|
||||
|
||||
**输出结构**:
|
||||
|
||||
```plain
|
||||
pool-1-thread-3 Start. Time = Sun Apr 12 11:14:37 CST 2020
|
||||
pool-1-thread-5 Start. Time = Sun Apr 12 11:14:37 CST 2020
|
||||
pool-1-thread-2 Start. Time = Sun Apr 12 11:14:37 CST 2020
|
||||
pool-1-thread-1 Start. Time = Sun Apr 12 11:14:37 CST 2020
|
||||
pool-1-thread-4 Start. Time = Sun Apr 12 11:14:37 CST 2020
|
||||
pool-1-thread-3 End. Time = Sun Apr 12 11:14:42 CST 2020
|
||||
pool-1-thread-4 End. Time = Sun Apr 12 11:14:42 CST 2020
|
||||
pool-1-thread-1 End. Time = Sun Apr 12 11:14:42 CST 2020
|
||||
pool-1-thread-5 End. Time = Sun Apr 12 11:14:42 CST 2020
|
||||
pool-1-thread-1 Start. Time = Sun Apr 12 11:14:42 CST 2020
|
||||
pool-1-thread-2 End. Time = Sun Apr 12 11:14:42 CST 2020
|
||||
pool-1-thread-5 Start. Time = Sun Apr 12 11:14:42 CST 2020
|
||||
pool-1-thread-4 Start. Time = Sun Apr 12 11:14:42 CST 2020
|
||||
pool-1-thread-3 Start. Time = Sun Apr 12 11:14:42 CST 2020
|
||||
pool-1-thread-2 Start. Time = Sun Apr 12 11:14:42 CST 2020
|
||||
pool-1-thread-1 End. Time = Sun Apr 12 11:14:47 CST 2020
|
||||
pool-1-thread-4 End. Time = Sun Apr 12 11:14:47 CST 2020
|
||||
pool-1-thread-5 End. Time = Sun Apr 12 11:14:47 CST 2020
|
||||
pool-1-thread-3 End. Time = Sun Apr 12 11:14:47 CST 2020
|
||||
pool-1-thread-2 End. Time = Sun Apr 12 11:14:47 CST 2020
|
||||
Finished all threads // 任务全部执行完了才会跳出来,因为executor.isTerminated()判断为true了才会跳出while循环,当且仅当调用 shutdown() 方法后,并且所有提交的任务完成后返回为 true
|
||||
|
||||
```
|
||||
|
||||
### 线程池原理分析
|
||||
|
||||
我们通过前面的代码输出结果可以看出:**线程池首先会先执行 5 个任务,然后这些任务有任务被执行完的话,就会去拿新的任务执行。** 大家可以先通过上面讲解的内容,分析一下到底是咋回事?(自己独立思考一会)
|
||||
|
||||
现在,我们就分析上面的输出内容来简单分析一下线程池原理。
|
||||
|
||||
为了搞懂线程池的原理,我们需要首先分析一下 `execute` 方法。 在示例代码中,我们使用 `executor.execute(worker)` 来提交一个任务到线程池中去。
|
||||
|
||||
这个方法非常重要,下面我们来看看它的源码:
|
||||
|
||||
```java
|
||||
// 存放线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount)
|
||||
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
|
||||
|
||||
private static int workerCountOf(int c) {
|
||||
return c & CAPACITY;
|
||||
}
|
||||
//任务队列
|
||||
private final BlockingQueue<Runnable> workQueue;
|
||||
|
||||
public void execute(Runnable command) {
|
||||
// 如果任务为null,则抛出异常。
|
||||
if (command == null)
|
||||
throw new NullPointerException();
|
||||
// ctl 中保存的线程池当前的一些状态信息
|
||||
int c = ctl.get();
|
||||
|
||||
// 下面会涉及到 3 步 操作
|
||||
// 1.首先判断当前线程池中的工作线程总数是否小于 corePoolSize
|
||||
// 如果小于的话,通过addWorker(command, true)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。
|
||||
if (workerCountOf(c) < corePoolSize) {
|
||||
if (addWorker(command, true))
|
||||
return;
|
||||
c = ctl.get();
|
||||
}
|
||||
// 2.如果当前工作线程总数大于等于 corePoolSize 的时候就会走到这里,表明没有走核心线程的创建分支。
|
||||
// 通过 isRunning 方法判断线程池状态,线程池处于 RUNNING 状态并且队列可以加入任务,该任务才会被加入进去
|
||||
if (isRunning(c) && workQueue.offer(command)) {
|
||||
int recheck = ctl.get();
|
||||
// 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。
|
||||
if (!isRunning(recheck) && remove(command))
|
||||
reject(command);
|
||||
// 如果当前工作线程数量为0,新创建一个线程并执行。
|
||||
else if (workerCountOf(recheck) == 0)
|
||||
addWorker(null, false);
|
||||
}
|
||||
//3. 通过addWorker(command, false)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。
|
||||
// 传入 false 代表增加线程时判断当前线程数是否少于 maxPoolSize
|
||||
//如果addWorker(command, false)执行失败,则通过reject()执行相应的拒绝策略的内容。
|
||||
else if (!addWorker(command, false))
|
||||
reject(command);
|
||||
}
|
||||
```
|
||||
|
||||
这里简单分析一下整个流程(对整个逻辑进行了简化,方便理解):
|
||||
|
||||
1. 如果当前工作线程总数小于核心线程数,那么就会新建一个线程来执行任务。
|
||||
2. 如果当前工作线程总数已经达到核心线程数,先尝试把任务放入任务队列中等待执行。
|
||||
3. 如果向任务队列投放任务失败(任务队列已经满了),并且当前工作线程总数小于最大线程数,就新建一个非核心线程来执行任务。
|
||||
4. 如果当前工作线程总数已经等同于最大线程数,任务队列也无法继续接收任务,那么当前任务会被拒绝,拒绝策略会调用 `RejectedExecutionHandler.rejectedExecution()` 方法。
|
||||
|
||||
> **补充说明**:很多人误以为非核心线程只在任务队列满的时候才会被创建,之后就“闲着”等销毁。实际上,非核心线程执行完初始任务后,并不会立刻销毁,而是会**主动从任务队列中拉取任务执行**(通过 `getTask()` 方法)。具体来说,核心线程使用 `workQueue.take()` 阻塞等待任务,而非核心线程使用 `workQueue.poll(keepAliveTime, unit)` ——如果在存活时间内从队列中取到了任务,就会继续执行;只有超时没有取到任务,非核心线程才会被回收。这意味着,即使新任务被放入了队列,空闲的非核心线程也会抢先从队列中取走任务来执行,而不是等队列满了才被动响应。
|
||||
|
||||

|
||||
|
||||
在 `execute` 方法中,多次调用 `addWorker` 方法。`addWorker` 这个方法主要用来创建新的工作线程,如果返回 true 说明创建和启动工作线程成功,否则的话返回的就是 false。
|
||||
|
||||
```java
|
||||
// 全局锁,并发操作必备
|
||||
private final ReentrantLock mainLock = new ReentrantLock();
|
||||
// 跟踪线程池的最大大小,只有在持有全局锁mainLock的前提下才能访问此集合
|
||||
private int largestPoolSize;
|
||||
// 工作线程集合,存放线程池中所有的(活跃的)工作线程,只有在持有全局锁mainLock的前提下才能访问此集合
|
||||
private final HashSet<Worker> workers = new HashSet<>();
|
||||
//获取线程池状态
|
||||
private static int runStateOf(int c) { return c & ~CAPACITY; }
|
||||
//判断线程池的状态是否为 Running
|
||||
private static boolean isRunning(int c) {
|
||||
return c < SHUTDOWN;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 添加新的工作线程到线程池
|
||||
* @param firstTask 要执行
|
||||
* @param core参数为true的话表示使用线程池的基本大小,为false使用线程池最大大小
|
||||
* @return 添加成功就返回true否则返回false
|
||||
*/
|
||||
private boolean addWorker(Runnable firstTask, boolean core) {
|
||||
retry:
|
||||
for (;;) {
|
||||
//这两句用来获取线程池的状态
|
||||
int c = ctl.get();
|
||||
int rs = runStateOf(c);
|
||||
|
||||
// Check if queue empty only if necessary.
|
||||
if (rs >= SHUTDOWN &&
|
||||
! (rs == SHUTDOWN &&
|
||||
firstTask == null &&
|
||||
! workQueue.isEmpty()))
|
||||
return false;
|
||||
|
||||
for (;;) {
|
||||
//获取线程池中工作的线程的数量
|
||||
int wc = workerCountOf(c);
|
||||
// core参数为false的话表明队列也满了,线程池大小变为 maximumPoolSize
|
||||
if (wc >= CAPACITY ||
|
||||
wc >= (core ? corePoolSize : maximumPoolSize))
|
||||
return false;
|
||||
//原子操作将workcount的数量加1
|
||||
if (compareAndIncrementWorkerCount(c))
|
||||
break retry;
|
||||
// 如果线程的状态改变了就再次执行上述操作
|
||||
c = ctl.get();
|
||||
if (runStateOf(c) != rs)
|
||||
continue retry;
|
||||
// else CAS failed due to workerCount change; retry inner loop
|
||||
}
|
||||
}
|
||||
// 标记工作线程是否启动成功
|
||||
boolean workerStarted = false;
|
||||
// 标记工作线程是否创建成功
|
||||
boolean workerAdded = false;
|
||||
Worker w = null;
|
||||
try {
|
||||
|
||||
w = new Worker(firstTask);
|
||||
final Thread t = w.thread;
|
||||
if (t != null) {
|
||||
// 加锁
|
||||
final ReentrantLock mainLock = this.mainLock;
|
||||
mainLock.lock();
|
||||
try {
|
||||
//获取线程池状态
|
||||
int rs = runStateOf(ctl.get());
|
||||
//rs < SHUTDOWN 如果线程池状态依然为RUNNING,并且线程的状态是存活的话,就会将工作线程添加到工作线程集合中
|
||||
//(rs=SHUTDOWN && firstTask == null)如果线程池状态小于STOP,也就是RUNNING或者SHUTDOWN状态下,同时传入的任务实例firstTask为null,则需要添加到工作线程集合和启动新的Worker
|
||||
// firstTask == null证明只新建线程而不执行任务
|
||||
if (rs < SHUTDOWN ||
|
||||
(rs == SHUTDOWN && firstTask == null)) {
|
||||
if (t.isAlive()) // precheck that t is startable
|
||||
throw new IllegalThreadStateException();
|
||||
workers.add(w);
|
||||
//更新当前工作线程的最大容量
|
||||
int s = workers.size();
|
||||
if (s > largestPoolSize)
|
||||
largestPoolSize = s;
|
||||
// 工作线程是否启动成功
|
||||
workerAdded = true;
|
||||
}
|
||||
} finally {
|
||||
// 释放锁
|
||||
mainLock.unlock();
|
||||
}
|
||||
//// 如果成功添加工作线程,则调用Worker内部的线程实例t的Thread#start()方法启动真实的线程实例
|
||||
if (workerAdded) {
|
||||
t.start();
|
||||
/// 标记线程启动成功
|
||||
workerStarted = true;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// 线程启动失败,需要从工作线程中移除对应的Worker
|
||||
if (! workerStarted)
|
||||
addWorkerFailed(w);
|
||||
}
|
||||
return workerStarted;
|
||||
}
|
||||
```
|
||||
|
||||
更多关于线程池源码分析的内容推荐这篇文章:硬核干货:[4W 字从源码上分析 JUC 线程池 ThreadPoolExecutor 的实现原理](https://www.cnblogs.com/throwable/p/13574306.html)。
|
||||
|
||||
现在,让我们在回到示例代码, 现在应该是不是很容易就可以搞懂它的原理了呢?
|
||||
|
||||
没搞懂的话,也没关系,可以看看我的分析:
|
||||
|
||||
> 我们在代码中模拟了 10 个任务,我们配置的核心线程数为 5、等待队列容量为 100,所以每次只可能存在 5 个任务同时执行,剩下的 5 个任务会被放到等待队列中去。当前的 5 个任务中如果有任务被执行完了,线程池就会去拿新的任务执行。
|
||||
|
||||
### 几个常见的对比
|
||||
|
||||
#### `Runnable` vs `Callable`
|
||||
|
||||
`Runnable` 自 Java 1.0 以来一直存在,但 `Callable` 仅在 Java 1.5 中引入,目的就是为了来处理 `Runnable` 不支持的用例。`Runnable` 接口不会返回结果或抛出检查异常,但是 `Callable` 接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用 `Runnable` 接口,这样代码看起来会更加简洁。
|
||||
|
||||
工具类 `Executors` 可以实现将 `Runnable` 对象转换成 `Callable` 对象。(`Executors.callable(Runnable task)` 或 `Executors.callable(Runnable task, Object result)`)。
|
||||
|
||||
`Runnable.java`
|
||||
|
||||
```java
|
||||
@FunctionalInterface
|
||||
public interface Runnable {
|
||||
/**
|
||||
* 被线程执行,没有返回值也无法抛出异常
|
||||
*/
|
||||
public abstract void run();
|
||||
}
|
||||
```
|
||||
|
||||
`Callable.java`
|
||||
|
||||
```java
|
||||
@FunctionalInterface
|
||||
public interface Callable<V> {
|
||||
/**
|
||||
* 计算结果,或在无法这样做时抛出异常。
|
||||
* @return 计算得出的结果
|
||||
* @throws 如果无法计算结果,则抛出异常
|
||||
*/
|
||||
V call() throws Exception;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
#### `execute()` vs `submit()`
|
||||
|
||||
`execute()` 和 `submit()` 是两种提交任务到线程池的方法,有一些区别:
|
||||
|
||||
- **返回值**:`execute()` 方法用于提交不需要返回值的任务。通常用于执行 `Runnable` 任务,无法判断任务是否被线程池成功执行。`submit()` 方法用于提交需要返回值的任务。可以提交 `Runnable` 或 `Callable` 任务。`submit()` 方法返回一个 `Future` 对象,通过这个 `Future` 对象可以判断任务是否执行成功,并获取任务的返回值(`get()` 方法会阻塞当前线程直到任务完成, `get(long timeout,TimeUnit unit)` 多了一个超时时间,如果在 `timeout` 时间内任务还没有执行完,就会抛出 `java.util.concurrent.TimeoutException`)。
|
||||
- **异常处理**:在使用 `submit()` 方法时,可以通过 `Future` 对象处理任务执行过程中抛出的异常;而在使用 `execute()` 方法时,异常处理需要通过自定义的 `ThreadFactory`(在线程工厂创建线程的时候设置 `UncaughtExceptionHandler` 对象来 处理异常)或 `ThreadPoolExecutor` 的 `afterExecute()` 方法来处理
|
||||
|
||||
示例 1:使用 `get()` 方法获取返回值。
|
||||
|
||||
```java
|
||||
// 这里只是为了演示使用,推荐使用 `ThreadPoolExecutor` 构造方法来创建线程池。
|
||||
ExecutorService executorService = Executors.newFixedThreadPool(3);
|
||||
|
||||
Future<String> submit = executorService.submit(() -> {
|
||||
try {
|
||||
Thread.sleep(5000L);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return "abc";
|
||||
});
|
||||
|
||||
String s = submit.get();
|
||||
System.out.println(s);
|
||||
executorService.shutdown();
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```plain
|
||||
abc
|
||||
```
|
||||
|
||||
示例 2:使用 `get(long timeout,TimeUnit unit)` 方法获取返回值。
|
||||
|
||||
```java
|
||||
ExecutorService executorService = Executors.newFixedThreadPool(3);
|
||||
|
||||
Future<String> submit = executorService.submit(() -> {
|
||||
try {
|
||||
Thread.sleep(5000L);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
return "abc";
|
||||
});
|
||||
|
||||
String s = submit.get(3, TimeUnit.SECONDS);
|
||||
System.out.println(s);
|
||||
executorService.shutdown();
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```plain
|
||||
Exception in thread "main" java.util.concurrent.TimeoutException
|
||||
at java.util.concurrent.FutureTask.get(FutureTask.java:205)
|
||||
```
|
||||
|
||||
#### `shutdown()`VS`shutdownNow()`
|
||||
|
||||
- **`shutdown()`** :关闭线程池,线程池的状态变为 `SHUTDOWN`。线程池不再接受新任务了,但是队列里的任务得执行完毕。
|
||||
- **`shutdownNow()`** :关闭线程池,线程池的状态变为 `STOP`。线程池会终止当前正在运行的任务,并停止处理排队的任务并返回正在等待执行的 List。
|
||||
|
||||
#### `isTerminated()` VS `isShutdown()`
|
||||
|
||||
- **`isShutDown`** 当调用 `shutdown()` 方法后返回为 true。
|
||||
- **`isTerminated`** 当调用 `shutdown()` 方法后,并且所有提交的任务完成后返回为 true
|
||||
|
||||
## 几种常见的内置线程池
|
||||
|
||||
### FixedThreadPool
|
||||
|
||||
#### 介绍
|
||||
|
||||
`FixedThreadPool` 被称为可重用固定线程数的线程池。通过 `Executors` 类中的相关源代码来看一下相关实现:
|
||||
|
||||
```java
|
||||
/**
|
||||
* 创建一个可重用固定数量线程的线程池
|
||||
*/
|
||||
public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
|
||||
return new ThreadPoolExecutor(nThreads, nThreads,
|
||||
0L, TimeUnit.MILLISECONDS,
|
||||
new LinkedBlockingQueue<Runnable>(),
|
||||
threadFactory);
|
||||
}
|
||||
```
|
||||
|
||||
另外还有一个 `FixedThreadPool` 的实现方法,和上面的类似,所以这里不多做阐述:
|
||||
|
||||
```java
|
||||
public static ExecutorService newFixedThreadPool(int nThreads) {
|
||||
return new ThreadPoolExecutor(nThreads, nThreads,
|
||||
0L, TimeUnit.MILLISECONDS,
|
||||
new LinkedBlockingQueue<Runnable>());
|
||||
}
|
||||
```
|
||||
|
||||
从上面源代码可以看出新创建的 `FixedThreadPool` 的 `corePoolSize` 和 `maximumPoolSize` 都被设置为 `nThreads`,这个 `nThreads` 参数是我们使用的时候自己传递的。
|
||||
|
||||
即使 `maximumPoolSize` 的值比 `corePoolSize` 大,也至多只会创建 `corePoolSize` 个线程。这是因为 `FixedThreadPool` 使用的是容量为 `Integer.MAX_VALUE` 的 `LinkedBlockingQueue`(无界队列),队列永远不会被放满。
|
||||
|
||||
#### 执行任务过程介绍
|
||||
|
||||
`FixedThreadPool` 的 `execute()` 方法运行示意图(该图片来源:《Java 并发编程的艺术》):
|
||||
|
||||

|
||||
|
||||
**上图说明:**
|
||||
|
||||
1. 如果当前工作线程总数小于 `corePoolSize`,如果再来新任务的话,就创建新的线程来执行任务;
|
||||
2. 当前工作线程总数达到 `corePoolSize` 后,如果再来新任务的话,会将任务加入 `LinkedBlockingQueue`;
|
||||
3. 线程池中的线程执行完 手头的任务后,会在循环中反复从 `LinkedBlockingQueue` 中获取任务来执行;
|
||||
|
||||
#### 为什么不推荐使用 `FixedThreadPool`?
|
||||
|
||||
`FixedThreadPool` 使用无界队列 `LinkedBlockingQueue`(队列的容量为 Integer.MAX_VALUE)作为线程池的工作队列会对线程池带来如下影响:
|
||||
|
||||
1. 当线程池中的线程数达到 `corePoolSize` 后,新任务将在无界队列中等待,因此线程池中的线程数不会超过 `corePoolSize`;
|
||||
2. 由于使用无界队列时 `maximumPoolSize` 将是一个无效参数,因为不可能存在任务队列满的情况。所以,通过创建 `FixedThreadPool` 的源码可以看出创建的 `FixedThreadPool` 的 `corePoolSize` 和 `maximumPoolSize` 被设置为同一个值。
|
||||
3. 由于 1 和 2,使用无界队列时 `keepAliveTime` 将是一个无效参数;
|
||||
4. 运行中的 `FixedThreadPool`(未执行 `shutdown()` 或 `shutdownNow()`)不会拒绝任务,在任务比较多的时候会导致 OOM(内存溢出)。
|
||||
|
||||
### SingleThreadExecutor
|
||||
|
||||
#### 介绍
|
||||
|
||||
`SingleThreadExecutor` 是只有一个线程的线程池。下面看看**SingleThreadExecutor 的实现:**
|
||||
|
||||
```java
|
||||
/**
|
||||
*返回只有一个线程的线程池
|
||||
*/
|
||||
public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {
|
||||
return new FinalizableDelegatedExecutorService
|
||||
(new ThreadPoolExecutor(1, 1,
|
||||
0L, TimeUnit.MILLISECONDS,
|
||||
new LinkedBlockingQueue<Runnable>(),
|
||||
threadFactory));
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
public static ExecutorService newSingleThreadExecutor() {
|
||||
return new FinalizableDelegatedExecutorService
|
||||
(new ThreadPoolExecutor(1, 1,
|
||||
0L, TimeUnit.MILLISECONDS,
|
||||
new LinkedBlockingQueue<Runnable>()));
|
||||
}
|
||||
```
|
||||
|
||||
从上面源代码可以看出新创建的 `SingleThreadExecutor` 的 `corePoolSize` 和 `maximumPoolSize` 都被设置为 1,其他参数和 `FixedThreadPool` 相同。
|
||||
|
||||
#### 执行任务过程介绍
|
||||
|
||||
`SingleThreadExecutor` 的运行示意图(该图片来源:《Java 并发编程的艺术》):
|
||||
|
||||

|
||||
|
||||
**上图说明** :
|
||||
|
||||
1. 如果当前运行的线程数少于 `corePoolSize`,则创建一个新的线程执行任务;
|
||||
2. 当前线程池中有一个运行的线程后,将任务加入 `LinkedBlockingQueue`
|
||||
3. 线程执行完当前的任务后,会在循环中反复从 `LinkedBlockingQueue` 中获取任务来执行;
|
||||
|
||||
#### 为什么不推荐使用 `SingleThreadExecutor`?
|
||||
|
||||
`SingleThreadExecutor` 和 `FixedThreadPool` 一样,使用的都是容量为 `Integer.MAX_VALUE` 的 `LinkedBlockingQueue`(无界队列)。`SingleThreadExecutor` 使用无界队列作为线程池的工作队列会对线程池带来的影响与 `FixedThreadPool` 相同。说简单点,就是可能会导致 OOM。
|
||||
|
||||
### CachedThreadPool
|
||||
|
||||
#### 介绍
|
||||
|
||||
`CachedThreadPool` 是一个会根据需要创建新线程的线程池。下面通过源码来看看 `CachedThreadPool` 的实现:
|
||||
|
||||
```java
|
||||
/**
|
||||
* 创建一个线程池,根据需要创建新线程,但会在先前构建的线程可用时重用它。
|
||||
*/
|
||||
public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
|
||||
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
|
||||
60L, TimeUnit.SECONDS,
|
||||
new SynchronousQueue<Runnable>(),
|
||||
threadFactory);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
```java
|
||||
public static ExecutorService newCachedThreadPool() {
|
||||
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
|
||||
60L, TimeUnit.SECONDS,
|
||||
new SynchronousQueue<Runnable>());
|
||||
}
|
||||
```
|
||||
|
||||
`CachedThreadPool` 的 `corePoolSize` 被设置为空(0),`maximumPoolSize` 被设置为 `Integer.MAX_VALUE`,即它是无界的,这也就意味着如果主线程提交任务的速度高于 `maximumPool` 中线程处理任务的速度时,`CachedThreadPool` 会不断创建新的线程。极端情况下,这样会导致耗尽 cpu 和内存资源。
|
||||
|
||||
#### 执行任务过程介绍
|
||||
|
||||
`CachedThreadPool` 的 `execute()` 方法的执行示意图(该图片来源:《Java 并发编程的艺术》):
|
||||
|
||||

|
||||
|
||||
**上图说明:**
|
||||
|
||||
1. 首先执行 `SynchronousQueue.offer(Runnable task)` 提交任务到任务队列。如果当前 `maximumPool` 中有闲线程正在执行 `SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS)`,那么主线程执行 offer 操作与空闲线程执行的 `poll` 操作配对成功,主线程把任务交给空闲线程执行,`execute()` 方法执行完成,否则执行下面的步骤 2;
|
||||
2. 当初始 `maximumPool` 为空,或者 `maximumPool` 中没有空闲线程时,将没有线程执行 `SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS)`。这种情况下,步骤 1 将失败,此时 `CachedThreadPool` 会创建新线程执行任务,execute 方法执行完成;
|
||||
|
||||
#### 为什么不推荐使用 `CachedThreadPool`?
|
||||
|
||||
`CachedThreadPool` 使用的是同步队列 `SynchronousQueue`, 允许创建的线程数量为 `Integer.MAX_VALUE`,可能会创建大量线程,从而导致 OOM。
|
||||
|
||||
### ScheduledThreadPool
|
||||
|
||||
#### 介绍
|
||||
|
||||
`ScheduledThreadPool` 用来在给定的延迟后运行任务或者定期执行任务。这个在实际项目中基本不会被用到,也不推荐使用,大家只需要简单了解一下即可。
|
||||
|
||||
```java
|
||||
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
|
||||
return new ScheduledThreadPoolExecutor(corePoolSize);
|
||||
}
|
||||
public ScheduledThreadPoolExecutor(int corePoolSize) {
|
||||
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
|
||||
new DelayedWorkQueue());
|
||||
}
|
||||
```
|
||||
|
||||
`ScheduledThreadPool` 是通过 `ScheduledThreadPoolExecutor` 创建的,使用的 `DelayedWorkQueue`(延迟阻塞队列)作为线程池的任务队列。
|
||||
|
||||
`DelayedWorkQueue` 的内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构,可以保证每次出队的任务都是当前队列中执行时间最靠前的。`DelayedWorkQueue` 添加元素满了之后会自动扩容原来容量的 1/2,即永远不会阻塞,最大扩容可达 `Integer.MAX_VALUE`,所以最多只能创建核心线程数的线程。
|
||||
|
||||
`ScheduledThreadPoolExecutor` 继承了 `ThreadPoolExecutor`,所以创建 `ScheduledThreadExecutor` 本质也是创建一个 `ThreadPoolExecutor` 线程池,只是传入的参数不相同。
|
||||
|
||||
```java
|
||||
public class ScheduledThreadPoolExecutor
|
||||
extends ThreadPoolExecutor
|
||||
implements ScheduledExecutorService
|
||||
```
|
||||
|
||||
#### ScheduledThreadPoolExecutor 和 Timer 对比
|
||||
|
||||
- `Timer` 对系统时钟的变化敏感,`ScheduledThreadPoolExecutor` 不是;
|
||||
- `Timer` 只有一个执行线程,因此长时间运行的任务可以延迟其他任务。 `ScheduledThreadPoolExecutor` 可以配置任意数量的线程。 此外,如果你想(通过提供 `ThreadFactory`),你可以完全控制创建的线程;
|
||||
- 在 `TimerTask` 中抛出的运行时异常会杀死一个线程,从而导致 `Timer` 死机即计划任务将不再运行。`ScheduledThreadExecutor` 不仅捕获运行时异常,还允许您在需要时处理它们(通过重写 `afterExecute` 方法 `ThreadPoolExecutor`)。抛出异常的任务将被取消,但其他任务将继续运行。
|
||||
|
||||
关于定时任务的详细介绍,可以看这篇文章:[Java 定时任务详解](https://javaguide.cn/system-design/schedule-task.html)。
|
||||
|
||||
## 线程池最佳实践
|
||||
|
||||
[Java 线程池最佳实践](https://javaguide.cn/java/concurrent/java-thread-pool-best-practices.html)这篇文章总结了一些使用线程池的时候应该注意的东西,实际项目使用线程池之前可以看看。
|
||||
|
||||
## 参考
|
||||
|
||||
- 《Java 并发编程的艺术》
|
||||
- [Java Scheduler ScheduledExecutorService ScheduledThreadPoolExecutor Example](https://www.journaldev.com/2340/java-scheduler-scheduledexecutorservice-scheduledthreadpoolexecutor-example "Java Scheduler ScheduledExecutorService ScheduledThreadPoolExecutor Example")
|
||||
- [java.util.concurrent.ScheduledThreadPoolExecutor Example](https://examples.javacodegeeks.com/core-java/util/concurrent/scheduledthreadpoolexecutor/java-util-concurrent-scheduledthreadpoolexecutor-example/ "java.util.concurrent.ScheduledThreadPoolExecutor Example")
|
||||
- [ThreadPoolExecutor – Java Thread Pool Example](https://www.journaldev.com/1069/threadpoolexecutor-java-thread-pool-example-executorservice "ThreadPoolExecutor – Java Thread Pool Example")
|
||||
|
||||
<!-- @include: @article-footer.snippet.md -->
|
||||
@@ -0,0 +1,250 @@
|
||||
---
|
||||
title: JMM(Java 内存模型)详解
|
||||
description: 深入解析Java内存模型JMM:详解CPU缓存模型、指令重排序机制、happens-before原则、内存可见性保证,理解多线程并发编程的底层规范。
|
||||
category: Java
|
||||
tag:
|
||||
- Java并发
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: JMM,Java内存模型,CPU缓存,指令重排序,happens-before,内存可见性,并发编程模型
|
||||
---
|
||||
|
||||
对于 Java 来说,你可以把 **JMM(Java 内存模型)** 看作是 Java 定义的并发编程相关的一组规范。除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的转化过程要遵守哪些并发相关的原则和规范。其主要目的是为了**简化多线程编程**,**增强程序的可移植性**。
|
||||
|
||||
JMM 主要定义了对于一个共享变量,当一个线程执行写操作后,该变量对其他线程的**可见性**。
|
||||
|
||||
要想透彻理解 JMM,我们需要从 **CPU 缓存模型**和**指令重排序**说起。
|
||||
|
||||
## 从 CPU 缓存模型说起
|
||||
|
||||
**为什么要弄一个 CPU 高速缓存呢?** 类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 **CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。**
|
||||
|
||||
我们甚至可以把 **内存看作外存的高速缓存**,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。
|
||||
|
||||
总结:**CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。**
|
||||
|
||||
为了更好地理解,我画了一个简单的 CPU Cache 示意图如下所示。
|
||||
|
||||
> **🐛 修正(参见:[issue#1848](https://github.com/Snailclimb/JavaGuide/issues/1848))**:对 CPU 缓存模型绘图不严谨的地方进行完善。
|
||||
|
||||

|
||||
|
||||
现代的 CPU Cache 通常分为三层,分别叫 L1,L2,L3 Cache。有些 CPU 可能还有 L4 Cache,这里不做讨论,并不常见
|
||||
|
||||
**CPU Cache 的工作方式:** 先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 **内存缓存不一致性的问题**!比如我执行一个 i++ 操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 i++ 运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。
|
||||
|
||||
**CPU 为了解决内存缓存不一致性问题可以通过制定缓存一致协议(比如 [MESI 协议](https://zh.wikipedia.org/wiki/MESI%E5%8D%8F%E8%AE%AE))或者其他手段来解决。** 这个缓存一致性协议指的是在 CPU 高速缓存与主内存交互的时候需要遵守的原则和规范。不同的 CPU 中,使用的缓存一致性协议通常也会有所不同。
|
||||
|
||||

|
||||
|
||||
我们的程序运行在操作系统之上,操作系统屏蔽了底层硬件的操作细节,将各种硬件资源虚拟化。于是,操作系统也就同样需要解决内存缓存不一致性问题。
|
||||
|
||||
操作系统通过 **内存模型(Memory Model)** 定义一系列规范来解决这个问题。无论是 Windows 系统,还是 Linux 系统,它们都有特定的内存模型。
|
||||
|
||||
## 指令重排序
|
||||
|
||||
说完了 CPU 缓存模型,我们再来看看另外一个比较重要的概念 **指令重排序**。
|
||||
|
||||
为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。
|
||||
|
||||
**什么是指令重排序?** 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。
|
||||
|
||||
常见的指令重排序有下面 2 种情况:
|
||||
|
||||
- **编译器优化重排**:编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
|
||||
- **指令并行重排**:现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
|
||||
|
||||
另外,内存系统也会有“重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。
|
||||
|
||||
Java 源代码会经历 **编译器优化重排 —> 指令并行重排 —> 内存系统重排** 的过程,最终才变成操作系统可执行的指令序列。
|
||||
|
||||
**指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致**,所以在多线程下,指令重排序可能会导致一些问题。
|
||||
|
||||
对于编译器优化重排和处理器的指令重排序(指令并行重排和内存系统重排都属于是处理器级别的指令重排序),处理该问题的方式不一样。
|
||||
|
||||
- 对于编译器,通过禁止特定类型的编译器重排序的方式来禁止重排序。
|
||||
|
||||
- 对于处理器,通过插入内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)的方式来禁止特定类型的处理器重排序。
|
||||
|
||||
> 内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它会在处理器写入值时,强制将写缓冲区中的数据刷新到主内存;在读取值之前,使处理器本地缓存中的相关数据失效,强制从主内存中加载最新值,从而保障变量的可见性。
|
||||
|
||||
## JMM(Java Memory Model)
|
||||
|
||||
### 什么是 JMM?为什么需要 JMM?
|
||||
|
||||
Java 是最早尝试提供内存模型的编程语言。由于早期内存模型存在一些缺陷(比如非常容易削弱编译器的优化能力),从 Java5 开始,Java 开始使用新的内存模型 [《JSR-133:Java Memory Model and Thread Specification》](http://www.cs.umd.edu/~pugh/java/memoryModel/CommunityReview.pdf)。
|
||||
|
||||
一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。
|
||||
|
||||
这只是 JMM 存在的其中一个原因。实际上,对于 Java 来说,你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
|
||||
|
||||
**为什么要遵守这些并发相关的原则和规范呢?** 这是因为并发编程下,像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。就比如说我们上面提到的指令重排序就可能会让多线程程序的执行出现问题,为此,JMM 抽象了 happens-before 原则(后文会详细介绍到)来解决这个指令重排序问题。
|
||||
|
||||
JMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 `volatile`、`synchronized`、各种 `Lock`)即可开发出并发安全的程序。
|
||||
|
||||
### JMM 是如何抽象线程和主内存之间的关系?
|
||||
|
||||
**Java 内存模型(JMM)** 抽象了线程和主内存之间的关系,就比如说线程之间的共享变量必须存储在主内存中。
|
||||
|
||||
在 JDK1.2 之前,Java 的内存模型实现总是从 **主存**(即共享内存)读取变量,是不需要进行特别的注意的。而在当前的 Java 内存模型下,线程可以把变量保存 **本地内存**(比如机器的寄存器)中,而不是直接在主存中进行读写。这就可能造成一个线程在主存中修改了一个变量的值,而另外一个线程还继续使用它在寄存器中的变量值的拷贝,造成数据的不一致。
|
||||
|
||||
这和我们上面讲到的 CPU 缓存模型非常相似。
|
||||
|
||||
**什么是主内存?什么是本地内存?**
|
||||
|
||||
- **主内存**:所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量,还是局部变量,类信息、常量、静态变量都是放在主内存中。为了获取更好的运行速度,虚拟机及硬件系统可能会让工作内存优先存储于寄存器和高速缓存中。
|
||||
- **本地内存**:每个线程都有一个私有的本地内存,本地内存存储了该线程已读 / 写共享变量的副本。每个线程只能操作自己本地内存中的变量,无法直接访问其他线程的本地内存。如果线程间需要通信,必须通过主内存来进行。本地内存是 JMM 抽象出来的一个概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
|
||||
|
||||
Java 内存模型的抽象示意图如下:
|
||||
|
||||

|
||||
|
||||
从上图来看,线程 1 与线程 2 之间如果要进行通信的话,必须要经历下面 2 个步骤:
|
||||
|
||||
1. 线程 1 把本地内存中修改过的共享变量副本的值同步到主内存中去。
|
||||
2. 线程 2 到主存中读取对应的共享变量的值。
|
||||
|
||||
也就是说,JMM 为共享变量提供了可见性的保障。
|
||||
|
||||
不过,多线程下,对主内存中的一个共享变量进行操作有可能诱发线程安全问题。举个例子:
|
||||
|
||||
1. 线程 1 和线程 2 分别对同一个共享变量进行操作,一个执行修改,一个执行读取。
|
||||
2. 线程 2 读取到的是线程 1 修改之前的值还是修改后的值并不确定,都有可能,因为线程 1 和线程 2 都是先将共享变量从主内存拷贝到对应线程的工作内存中。
|
||||
|
||||
关于主内存与工作内存直接的具体交互协议,即一个变量如何从主内存拷贝到工作内存,如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义来以下八种同步操作(了解即可,无需死记硬背):
|
||||
|
||||
- **锁定(lock)**: 作用于主内存中的变量,将他标记为一个线程独享变量。
|
||||
- **解锁(unlock)**: 作用于主内存中的变量,解除变量的锁定状态,被解除锁定状态的变量才能被其他线程锁定。
|
||||
- **read(读取)**:作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
|
||||
- **load(载入)**:把 read 操作从主内存中得到的变量值放入工作内存的变量的副本中。
|
||||
- **use(使用)**:把工作内存中的一个变量的值传给执行引擎,每当虚拟机遇到一个使用到变量的指令时都会使用该指令。
|
||||
- **assign(赋值)**:作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
|
||||
- **store(存储)**:作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
|
||||
- **write(写入)**:作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。
|
||||
|
||||
除了这 8 种同步操作之外,还规定了下面这些同步规则来保证这些同步操作的正确执行(了解即可,无需死记硬背):
|
||||
|
||||
- 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
|
||||
- 一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
|
||||
- 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
|
||||
- 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
|
||||
- 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定住的变量。
|
||||
- ……
|
||||
|
||||
### Java 内存区域和 JMM 有何区别?
|
||||
|
||||
这是一个比较常见的问题,很多初学者非常容易搞混。 **Java 内存区域和内存模型是完全不一样的两个东西**:
|
||||
|
||||
- JVM 内存结构和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
|
||||
- Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
|
||||
|
||||
### happens-before 原则是什么?
|
||||
|
||||
happens-before 这个概念最早诞生于 Leslie Lamport 于 1978 年发表的论文[《Time,Clocks and the Ordering of Events in a Distributed System》](https://lamport.azurewebsites.net/pubs/time-clocks.pdf)。在这篇论文中,Leslie Lamport 提出了[逻辑时钟](https://writings.sh/post/logical-clocks)的概念,这也成了第一个逻辑时钟算法。在分布式环境中,通过一系列规则来定义逻辑时钟的变化,从而能通过逻辑时钟来对分布式系统中的事件的先后顺序进行判断。**逻辑时钟并不度量时间本身,仅区分事件发生的前后顺序,其本质就是定义了一种 happens-before 关系。**
|
||||
|
||||
上面提到的 happens-before 这个概念诞生的背景并不是重点,简单了解即可。
|
||||
|
||||
JSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性。
|
||||
|
||||
**为什么需要 happens-before 原则?** happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。happens-before 原则的设计思想其实非常简单:
|
||||
|
||||
- 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
|
||||
- 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。
|
||||
|
||||
下面这张是我根据《Java 并发编程的艺术》这本书中的一张 JMM 设计思想示意图重新绘制的。
|
||||
|
||||

|
||||
|
||||
了解了 happens-before 原则的设计思想,我们再来看看 JSR-133 对 happens-before 原则的定义:
|
||||
|
||||
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。
|
||||
- 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须要按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按 happens-before 关系来执行的结果一致,那么 JMM 也允许这样的重排序。
|
||||
|
||||
我们看下面这段代码:
|
||||
|
||||
```java
|
||||
int userNum = getUserNum(); // 1
|
||||
int teacherNum = getTeacherNum(); // 2
|
||||
int totalNum = userNum + teacherNum; // 3
|
||||
```
|
||||
|
||||
- 1 happens-before 2
|
||||
- 2 happens-before 3
|
||||
- 1 happens-before 3
|
||||
|
||||
虽然 1 happens-before 2,但对 1 和 2 进行重排序不会影响代码的执行结果,所以 JMM 是允许编译器和处理器执行这种重排序的。但 1 和 2 必须是在 3 执行之前,也就是说 1,2 happens-before 3。
|
||||
|
||||
**happens-before 原则表达的意义其实并不是一个操作发生在另外一个操作的前面,虽然这从程序员的角度上来说也并无大碍。更准确地来说,它更想表达的意义是前一个操作的结果对于后一个操作是可见的,无论这两个操作是否在同一个线程里。**
|
||||
|
||||
举个例子:操作 1 happens-before 操作 2,即使操作 1 和操作 2 不在同一个线程内,JMM 也会保证操作 1 的结果对操作 2 是可见的。
|
||||
|
||||
### happens-before 常见规则有哪些?谈谈你的理解?
|
||||
|
||||
happens-before 的规则就 8 条,说多不多,重点了解下面列举的 5 条即可。全记是不可能的,很快就忘记了,意义不大,随时查阅即可。
|
||||
|
||||
1. **程序顺序规则**:一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作;
|
||||
2. **解锁规则**:解锁 happens-before 于加锁;
|
||||
3. **volatile 变量规则**:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。
|
||||
4. **传递规则**:如果 A happens-before B,且 B happens-before C,那么 A happens-before C;
|
||||
5. **线程启动规则**:Thread 对象的 `start()` 方法 happens-before 于此线程的每一个动作。
|
||||
|
||||
如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序。
|
||||
|
||||
### happens-before 和 JMM 什么关系?
|
||||
|
||||
happens-before 与 JMM 的关系如下图所示:
|
||||
|
||||

|
||||
|
||||
- JMM 向程序员提供了 **“ happens-before 规则 ”**(如程序顺序规则、`volatile` 变量规则等)。这是一种 **“ 强内存模型 ”** 的假象:程序员不需要关心底层复杂的重排序细节,只需要按照这些规则编写代码,就能保证多线程下的内存可见性。
|
||||
- JVM 在执行时,会将 happens-before 规则映射到具体的实现上。为了在保证正确性的前提下不丧失性能,JMM 只会 **“ 禁止影响执行结果的重排序 ”**。对于不影响单线程执行结果的重排序,JMM 是允许的。
|
||||
- 最底层是编译器和处理器真实的 **“ 重排序规则 ”**。
|
||||
|
||||
总结来说,JMM 就像是一个中间层:它向上通过 happens-before 为程序员提供简单的编程模型;向下通过禁止特定重排序,利用底层硬件性能。这种设计既保证了多线程的安全性,又最大限度释放了硬件的性能。
|
||||
|
||||
## 再看并发编程三个重要特性
|
||||
|
||||
### 原子性
|
||||
|
||||
一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。
|
||||
|
||||
在 Java 中,可以借助 `synchronized`、各种 `Lock` 以及各种原子类实现原子性。
|
||||
|
||||
`synchronized` 和各种 `Lock` 可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 `volatile` 或者 `final` 关键字)来保证原子操作。
|
||||
|
||||
### 可见性
|
||||
|
||||
当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。
|
||||
|
||||
在 Java 中,可以借助 `synchronized`、`volatile` 以及各种 `Lock` 实现可见性。
|
||||
|
||||
如果我们将变量声明为 `volatile`,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
|
||||
|
||||
### 有序性
|
||||
|
||||
由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。
|
||||
|
||||
我们上面讲重排序的时候也提到过:
|
||||
|
||||
> **指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致**,所以在多线程下,指令重排序可能会导致一些问题。
|
||||
|
||||
在 Java 中,`volatile` 关键字可以禁止指令进行重排序优化。
|
||||
|
||||
## 总结
|
||||
|
||||
- Java 是最早尝试提供内存模型的语言,其主要目的是为了简化多线程编程,增强程序可移植性的。
|
||||
- CPU 可以通过制定缓存一致协议(比如 [MESI 协议](https://zh.wikipedia.org/wiki/MESI%E5%8D%8F%E8%AE%AE))来解决内存缓存不一致性问题。
|
||||
- 为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序。 简单来说就是系统在执行代码的时候并不一定是按照你写的代码的顺序依次执行。**指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致**,所以在多线程下,指令重排序可能会导致一些问题。
|
||||
- 你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。
|
||||
- JSR 133 引入了 happens-before 这个概念来描述两个操作之间的内存可见性。
|
||||
|
||||
## 参考
|
||||
|
||||
- 《Java 并发编程的艺术》第三章 Java 内存模型
|
||||
- 《深入浅出 Java 多线程》:<http://concurrent.redspider.group/RedSpider.html>
|
||||
- Java 内存访问重排序的研究:<https://tech.meituan.com/2014/09/23/java-memory-reordering.html>
|
||||
- 嘿,同学,你要的 Java 内存模型 (JMM) 来了:<https://xie.infoq.cn/article/739920a92d0d27e2053174ef2>
|
||||
- JSR 133 (Java Memory Model) FAQ:<https://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html>
|
||||
|
||||
<!-- @include: @article-footer.snippet.md -->
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
title: 乐观锁和悲观锁详解
|
||||
description: 乐观锁与悲观锁深度对比:详解synchronized/ReentrantLock悲观锁实现、CAS/版本号乐观锁机制、适用场景分析、性能对比与选型建议。
|
||||
category: Java
|
||||
tag:
|
||||
- Java并发
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: 乐观锁,悲观锁,synchronized,ReentrantLock,CAS,版本号机制,并发控制,锁优化
|
||||
---
|
||||
|
||||
如果将悲观锁(Pessimistic Lock)和乐观锁(Optimistic Lock)对应到现实生活中来。悲观锁有点像是一位比较悲观(也可以说是未雨绸缪)的人,总是会假设最坏的情况,避免出现问题。乐观锁有点像是一位比较乐观的人,总是会假设最好的情况,在要出现问题之前快速解决问题。
|
||||
|
||||
## 什么是悲观锁?
|
||||
|
||||
悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,**共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程**。
|
||||
|
||||
像 Java 中 `synchronized` 和 `ReentrantLock` 等独占锁就是悲观锁思想的实现。
|
||||
|
||||
```java
|
||||
public void performSynchronisedTask() {
|
||||
synchronized (this) {
|
||||
// 需要同步的操作
|
||||
}
|
||||
}
|
||||
|
||||
private Lock lock = new ReentrantLock();
|
||||
lock.lock();
|
||||
try {
|
||||
// 需要同步的操作
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
```
|
||||
|
||||
高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。并且,悲观锁还可能会存在死锁问题(线程获得锁的顺序不当时),影响代码的正常运行。
|
||||
|
||||
## 什么是乐观锁?
|
||||
|
||||
乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。
|
||||
|
||||
在 Java 中 `java.util.concurrent.atomic` 包下面的原子变量类(比如 `AtomicInteger`、`LongAdder`)就是使用了乐观锁的一种实现方式 **CAS** 实现的。
|
||||

|
||||
|
||||
```java
|
||||
// LongAdder 在高并发场景下会比 AtomicInteger 和 AtomicLong 的性能更好
|
||||
// 代价就是会消耗更多的内存空间(空间换时间)
|
||||
LongAdder sum = new LongAdder();
|
||||
sum.increment();
|
||||
```
|
||||
|
||||
高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败并重试,这样同样会非常影响性能,导致 CPU 飙升。
|
||||
|
||||
不过,大量失败重试的问题也是可以解决的,像我们前面提到的 `LongAdder` 以空间换时间的方式就解决了这个问题。
|
||||
|
||||
理论上来说:
|
||||
|
||||
- 悲观锁通常多用于写比较多的情况(多写场景,竞争激烈),这样可以避免频繁失败和重试影响性能,悲观锁的开销是固定的。不过,如果乐观锁解决了频繁失败和重试这个问题的话(比如 `LongAdder`),也是可以考虑使用乐观锁的,要视实际情况而定。
|
||||
- 乐观锁通常多用于写比较少的情况(多读场景,竞争较少),这样可以避免频繁加锁影响性能。不过,乐观锁主要针对的对象是单个共享变量(参考 `java.util.concurrent.atomic` 包下面的原子变量类)。
|
||||
|
||||
## 如何实现乐观锁?
|
||||
|
||||
乐观锁一般会使用版本号机制或 CAS 算法实现,CAS 算法相对来说更多一些,这里需要格外注意。
|
||||
|
||||
### 版本号机制
|
||||
|
||||
一般是在数据表中加上一个数据版本号 `version` 字段,表示数据被修改的次数。当数据被修改时,`version` 值会加一。当线程 A 要更新数据值时,在读取数据的同时也会读取 `version` 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 `version` 值相等时才更新,否则重试更新操作,直到更新成功。
|
||||
|
||||
**举一个简单的例子**:假设数据库中帐户信息表中有一个 version 字段,当前值为 1;而当前帐户余额字段(`balance`)为 \$100。
|
||||
|
||||
1. 操作员 A 此时将其读出(`version`=1),并从其帐户余额中扣除 $50($100-\$50)。
|
||||
2. 在操作员 A 操作的过程中,操作员 B 也读入此用户信息(`version`=1),并从其帐户余额中扣除 $20($100-\$20)。
|
||||
3. 操作员 A 完成了修改工作,将数据版本号(`version`=1),连同帐户扣除后余额(`balance`=\$50),提交至数据库更新,此时由于提交数据版本等于数据库记录当前版本,数据被更新,数据库记录 `version` 更新为 2。
|
||||
4. 操作员 B 完成了操作,也将版本号(`version`=1)试图向数据库提交数据(`balance`=\$80),但此时比对数据库记录版本时发现,操作员 B 提交的数据版本号为 1,而数据库记录当前版本为 2,不满足 “ 提交版本必须等于当前版本才能执行更新 ” 的乐观锁策略,因此,操作员 B 的提交被驳回。
|
||||
|
||||
这样就避免了操作员 B 用基于 `version`=1 的旧数据修改的结果覆盖操作员 A 的操作结果的可能。
|
||||
|
||||
### CAS 算法
|
||||
|
||||
CAS 的全称是 **Compare And Swap(比较与交换)**,用于实现乐观锁,被广泛应用于各大框架中。CAS 的思想很简单,就是用一个预期值和要更新的变量值进行比较,两值相等才会进行更新。
|
||||
|
||||
CAS 是一个原子操作,底层依赖于一条 CPU 的原子指令。
|
||||
|
||||
> **原子操作** 即最小不可拆分的操作,也就是说操作一旦开始,就不能被打断,直到操作完成。
|
||||
|
||||
CAS 涉及到三个操作数:
|
||||
|
||||
- **V**:要更新的变量值(Var)
|
||||
- **E**:预期值(Expected)
|
||||
- **N**:拟写入的新值(New)
|
||||
|
||||
当且仅当 V 的值等于 E 时,CAS 通过原子方式用新值 N 来更新 V 的值。如果不等,说明已经有其它线程更新了 V,则当前线程放弃更新。
|
||||
|
||||
**举一个简单的例子**:线程 A 要修改变量 i 的值为 6,i 原值为 1(V = 1,E=1,N=6,假设不存在 ABA 问题)。
|
||||
|
||||
1. i 与 1 进行比较,如果相等, 则说明没被其他线程修改,可以被设置为 6。
|
||||
2. i 与 1 进行比较,如果不相等,则说明被其他线程修改,当前线程放弃更新,CAS 操作失败。
|
||||
|
||||
当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。
|
||||
|
||||
关于 CAS 的进一步介绍,可以阅读读者写的这篇文章:[CAS 详解](./cas.md),其中详细提到了 Java 中 CAS 的实现以及 CAS 存在的一些问题。
|
||||
|
||||
## 总结
|
||||
|
||||
本文详细介绍了乐观锁和悲观锁的概念以及乐观锁常见实现方式:
|
||||
|
||||
- 悲观锁基于悲观的假设,认为共享资源在每次访问时都会发生冲突,因此在每次操作时都会加锁。这种锁机制会导致其他线程阻塞,直到锁被释放。Java 中的 `synchronized` 和 `ReentrantLock` 是悲观锁的典型实现方式。虽然悲观锁能有效避免数据竞争,但在高并发场景下会导致线程阻塞、上下文切换频繁,从而影响系统性能,并且还可能引发死锁问题。
|
||||
- 乐观锁基于乐观的假设,认为共享资源在每次访问时不会发生冲突,因此无须加锁,只需在提交修改时验证数据是否被其他线程修改。Java 中的 `AtomicInteger` 和 `LongAdder` 等类通过 CAS(Compare-And-Swap)算法实现了乐观锁。乐观锁避免了线程阻塞和死锁问题,在读多写少的场景中性能优越。但在写操作频繁的情况下,可能会导致大量重试和失败,从而影响性能。
|
||||
- 乐观锁主要通过版本号机制或 CAS 算法实现。版本号机制通过比较版本号确保数据一致性,而 CAS 通过硬件指令实现原子操作,直接比较和交换变量值。
|
||||
|
||||
悲观锁和乐观锁各有优缺点,适用于不同的应用场景。在实际开发中,选择合适的锁机制能够有效提升系统的并发性能和稳定性。
|
||||
|
||||
## 参考
|
||||
|
||||
- 《Java 并发编程核心 78 讲》
|
||||
- 通俗易懂 悲观锁、乐观锁、可重入锁、自旋锁、偏向锁、轻量/重量级锁、读写锁、各种锁及其 Java 实现!:<https://zhuanlan.zhihu.com/p/71156910>
|
||||
|
||||
<!-- @include: @article-footer.snippet.md -->
|
||||
@@ -0,0 +1,919 @@
|
||||
---
|
||||
title: ThreadLocal 详解
|
||||
description: ThreadLocal深度解析:详解ThreadLocal线程本地变量原理、ThreadLocalMap实现机制、弱引用与内存泄漏问题、使用场景与最佳实践。
|
||||
category: Java
|
||||
tag:
|
||||
- Java并发
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: ThreadLocal,线程本地变量,ThreadLocalMap,内存泄漏,弱引用,ThreadLocal原理,线程隔离
|
||||
---
|
||||
|
||||
> 本文来自一枝花算不算浪漫投稿, 原文地址:[https://juejin.cn/post/6844904151567040519](https://juejin.cn/post/6844904151567040519)。
|
||||
|
||||
### 前言
|
||||
|
||||

|
||||
|
||||
**全文共 10000+字,31 张图,这篇文章同样耗费了不少的时间和精力才创作完成,原创不易,请大家点点关注+在看,感谢。**
|
||||
|
||||
对于 `ThreadLocal`,大家的第一反应可能是很简单呀,线程的变量副本,每个线程隔离。那这里有几个问题大家可以思考一下:
|
||||
|
||||
- `ThreadLocal` 的 key 是**弱引用**,那么在 `ThreadLocal.get()` 的时候,发生**GC**之后,key 是否为**null**?
|
||||
- `ThreadLocal` 中 `ThreadLocalMap` 的**数据结构**?
|
||||
- `ThreadLocalMap` 的**Hash 算法**?
|
||||
- `ThreadLocalMap` 中**Hash 冲突**如何解决?
|
||||
- `ThreadLocalMap` 的**扩容机制**?
|
||||
- `ThreadLocalMap` 中**过期 key 的清理机制**?**探测式清理**和**启发式清理**流程?
|
||||
- `ThreadLocalMap.set()` 方法实现原理?
|
||||
- `ThreadLocalMap.get()` 方法实现原理?
|
||||
- 项目中 `ThreadLocal` 使用情况?遇到的坑?
|
||||
- ……
|
||||
|
||||
上述的一些问题你是否都已经掌握的很清楚了呢?本文将围绕这些问题使用图文方式来剖析 `ThreadLocal` 的**点点滴滴**。
|
||||
|
||||
### 目录
|
||||
|
||||
**注明:** 本文源码基于 `JDK 1.8`
|
||||
|
||||
### `ThreadLocal` 代码演示
|
||||
|
||||
我们先看下 `ThreadLocal` 使用示例:
|
||||
|
||||
```java
|
||||
public class ThreadLocalTest {
|
||||
private List<String> messages = Lists.newArrayList();
|
||||
|
||||
public static final ThreadLocal<ThreadLocalTest> holder = ThreadLocal.withInitial(ThreadLocalTest::new);
|
||||
|
||||
public static void add(String message) {
|
||||
holder.get().messages.add(message);
|
||||
}
|
||||
|
||||
public static List<String> clear() {
|
||||
List<String> messages = holder.get().messages;
|
||||
holder.remove();
|
||||
|
||||
System.out.println("size: " + holder.get().messages.size());
|
||||
return messages;
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
ThreadLocalTest.add("一枝花算不算浪漫");
|
||||
System.out.println(holder.get().messages);
|
||||
ThreadLocalTest.clear();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
打印结果:
|
||||
|
||||
```java
|
||||
[一枝花算不算浪漫]
|
||||
size: 0
|
||||
```
|
||||
|
||||
`ThreadLocal` 对象可以提供线程局部变量,每个线程 `Thread` 拥有一份自己的**副本变量**,多个线程互不干扰。
|
||||
|
||||
### `ThreadLocal` 的数据结构
|
||||
|
||||

|
||||
|
||||
`Thread` 类有一个类型为 `ThreadLocal.ThreadLocalMap` 的实例变量 `threadLocals`,也就是说每个线程有一个自己的 `ThreadLocalMap`。
|
||||
|
||||
`ThreadLocalMap` 有自己的独立实现,可以简单地将它的 `key` 视作 `ThreadLocal`,`value` 为代码中放入的值(实际上 `key` 并不是 `ThreadLocal` 本身,而是它的一个**弱引用**)。
|
||||
|
||||
每个线程在往 `ThreadLocal` 里放值的时候,都会往自己的 `ThreadLocalMap` 里存,读也是以 `ThreadLocal` 作为引用,在自己的 `map` 里找对应的 `key`,从而实现了**线程隔离**。
|
||||
|
||||
`ThreadLocalMap` 有点类似 `HashMap` 的结构,只是 `HashMap` 是由**数组+链表**实现的,而 `ThreadLocalMap` 中并没有**链表**结构。
|
||||
|
||||
我们还要注意 `Entry`, 它的 `key` 是 `ThreadLocal<?> k`,继承自 `WeakReference`, 也就是我们常说的弱引用类型。
|
||||
|
||||
### GC 之后 key 是否为 null?
|
||||
|
||||
回应开头的那个问题, `ThreadLocal` 的 `key` 是弱引用,那么在 `ThreadLocal.get()` 的时候,发生 `GC` 之后,`key` 是否是 `null`?
|
||||
|
||||
为了搞清楚这个问题,我们需要搞清楚 `Java` 的**四种引用类型**:
|
||||
|
||||
- **强引用**:我们常常 new 出来的对象就是强引用类型,只要强引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足的时候
|
||||
- **软引用**:使用 SoftReference 修饰的对象被称为软引用,软引用指向的对象在内存要溢出的时候被回收
|
||||
- **弱引用**:使用 WeakReference 修饰的对象被称为弱引用,只要发生垃圾回收,若这个对象只被弱引用指向,那么就会被回收
|
||||
- **虚引用**:虚引用是最弱的引用,在 Java 中使用 PhantomReference 进行定义。虚引用中唯一的作用就是用队列接收对象即将死亡的通知
|
||||
|
||||
接着再来看下代码,我们使用反射的方式来看看 `GC` 后 `ThreadLocal` 中的数据情况:(下面代码来源自:<https://blog.csdn.net/thewindkee/article/details/103726942> 本地运行演示 GC 回收场景)
|
||||
|
||||
```java
|
||||
public class ThreadLocalDemo {
|
||||
|
||||
public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException, InterruptedException {
|
||||
Thread t = new Thread(()->test("abc",false));
|
||||
t.start();
|
||||
t.join();
|
||||
System.out.println("--gc后--");
|
||||
Thread t2 = new Thread(() -> test("def", true));
|
||||
t2.start();
|
||||
t2.join();
|
||||
}
|
||||
|
||||
private static void test(String s,boolean isGC) {
|
||||
try {
|
||||
new ThreadLocal<>().set(s);
|
||||
if (isGC) {
|
||||
System.gc();
|
||||
}
|
||||
Thread t = Thread.currentThread();
|
||||
Class<? extends Thread> clz = t.getClass();
|
||||
Field field = clz.getDeclaredField("threadLocals");
|
||||
field.setAccessible(true);
|
||||
Object ThreadLocalMap = field.get(t);
|
||||
Class<?> tlmClass = ThreadLocalMap.getClass();
|
||||
Field tableField = tlmClass.getDeclaredField("table");
|
||||
tableField.setAccessible(true);
|
||||
Object[] arr = (Object[]) tableField.get(ThreadLocalMap);
|
||||
for (Object o : arr) {
|
||||
if (o != null) {
|
||||
Class<?> entryClass = o.getClass();
|
||||
Field valueField = entryClass.getDeclaredField("value");
|
||||
Field referenceField = entryClass.getSuperclass().getSuperclass().getDeclaredField("referent");
|
||||
valueField.setAccessible(true);
|
||||
referenceField.setAccessible(true);
|
||||
System.out.println(String.format("弱引用key:%s,值:%s", referenceField.get(o), valueField.get(o)));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
结果如下:
|
||||
|
||||
```java
|
||||
弱引用key:java.lang.ThreadLocal@433619b6,值:abc
|
||||
弱引用key:java.lang.ThreadLocal@418a15e3,值:java.lang.ref.SoftReference@bf97a12
|
||||
--gc后--
|
||||
弱引用key:null,值:def
|
||||
```
|
||||
|
||||

|
||||
|
||||
如图所示,因为这里创建的 `ThreadLocal` 并没有指向任何值,也就是没有任何引用:
|
||||
|
||||
```java
|
||||
new ThreadLocal<>().set(s);
|
||||
```
|
||||
|
||||
所以这里在 `GC` 之后,`key` 就会被回收,我们看到上面 `debug` 中的 `referent=null`, 如果**改动一下代码:**
|
||||
|
||||

|
||||
|
||||
这个问题刚开始看,如果没有过多思考,**弱引用**,还有**垃圾回收**,那么肯定会觉得是 `null`。
|
||||
|
||||
其实是不对的,因为题目说的是在做 `ThreadLocal.get()` 操作,证明其实还是有**强引用**存在的,所以 `key` 并不为 `null`,如下图所示,`ThreadLocal` 的**强引用**仍然是存在的。
|
||||
|
||||

|
||||
|
||||
如果我们的**强引用**不存在的话,那么 `key` 就会被回收,也就是会出现我们 `value` 没被回收,`key` 被回收,导致 `value` 永远存在,出现内存泄漏。
|
||||
|
||||
### `ThreadLocal.set()` 方法源码详解
|
||||
|
||||

|
||||
|
||||
`ThreadLocal` 中的 `set` 方法原理如上图所示,很简单,主要是判断 `ThreadLocalMap` 是否存在,然后使用 `ThreadLocal` 中的 `set` 方法进行数据处理。
|
||||
|
||||
代码如下:
|
||||
|
||||
```java
|
||||
public void set(T value) {
|
||||
Thread t = Thread.currentThread();
|
||||
ThreadLocalMap map = getMap(t);
|
||||
if (map != null)
|
||||
map.set(this, value);
|
||||
else
|
||||
createMap(t, value);
|
||||
}
|
||||
|
||||
void createMap(Thread t, T firstValue) {
|
||||
t.threadLocals = new ThreadLocalMap(this, firstValue);
|
||||
}
|
||||
```
|
||||
|
||||
主要的核心逻辑还是在 `ThreadLocalMap` 中的,一步步往下看,后面还有更详细的剖析。
|
||||
|
||||
### `ThreadLocalMap` Hash 算法
|
||||
|
||||
既然是 `Map` 结构,那么 `ThreadLocalMap` 当然也要实现自己的 `hash` 算法来解决散列表数组冲突问题。
|
||||
|
||||
```java
|
||||
int i = key.threadLocalHashCode & (len-1);
|
||||
```
|
||||
|
||||
`ThreadLocalMap` 中 `hash` 算法很简单,这里 `i` 就是当前 key 在散列表中对应的数组下标位置。
|
||||
|
||||
这里最关键的就是 `threadLocalHashCode` 值的计算,`ThreadLocal` 中有一个属性为 `HASH_INCREMENT = 0x61c88647`
|
||||
|
||||
```java
|
||||
public class ThreadLocal<T> {
|
||||
private final int threadLocalHashCode = nextHashCode();
|
||||
|
||||
private static AtomicInteger nextHashCode = new AtomicInteger();
|
||||
|
||||
private static final int HASH_INCREMENT = 0x61c88647;
|
||||
|
||||
private static int nextHashCode() {
|
||||
return nextHashCode.getAndAdd(HASH_INCREMENT);
|
||||
}
|
||||
|
||||
static class ThreadLocalMap {
|
||||
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
|
||||
table = new Entry[INITIAL_CAPACITY];
|
||||
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
|
||||
|
||||
table[i] = new Entry(firstKey, firstValue);
|
||||
size = 1;
|
||||
setThreshold(INITIAL_CAPACITY);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
每当创建一个 `ThreadLocal` 对象,这个 `ThreadLocal.nextHashCode` 这个值就会增长 `0x61c88647`。
|
||||
|
||||
这个值很特殊,它是**斐波那契数** 也叫 **黄金分割数**。`hash` 增量为 这个数字,带来的好处就是 `hash` **分布非常均匀**。
|
||||
|
||||
我们自己可以尝试下:
|
||||
|
||||

|
||||
|
||||
可以看到产生的哈希码分布很均匀,这里不去细纠**斐波那契**具体算法,感兴趣的可以自行查阅相关资料。
|
||||
|
||||
### `ThreadLocalMap` Hash 冲突
|
||||
|
||||
> **注明:** 下面所有示例图中,**绿色块**`Entry` 代表**正常数据**,**灰色块**代表 `Entry` 的 `key` 值为 `null`,**已被垃圾回收**。**白色块**表示 `Entry` 为 `null`。
|
||||
|
||||
虽然 `ThreadLocalMap` 中使用了**黄金分割数**来作为 `hash` 计算因子,大大减少了 `Hash` 冲突的概率,但是仍然会存在冲突。
|
||||
|
||||
`HashMap` 中解决冲突的方法是在数组上构造一个**链表**结构,冲突的数据挂载到链表上,如果链表长度超过一定数量则会转化成**红黑树**。
|
||||
|
||||
而 `ThreadLocalMap` 中并没有链表结构,所以这里不能使用 `HashMap` 解决冲突的方式了。
|
||||
|
||||

|
||||
|
||||
如上图所示,如果我们插入一个 `value=27` 的数据,通过 `hash` 计算后应该落入槽位 4 中,而槽位 4 已经有了 `Entry` 数据。
|
||||
|
||||
此时就会线性向后查找,一直找到 `Entry` 为 `null` 的槽位才会停止查找,将当前元素放入此槽位中。当然迭代过程中还有其他的情况,比如遇到了 `Entry` 不为 `null` 且 `key` 值相等的情况,还有 `Entry` 中的 `key` 值为 `null` 的情况等等都会有不同的处理,后面会一一详细讲解。
|
||||
|
||||
这里还画了一个 `Entry` 中的 `key` 为 `null` 的数据(**Entry=2 的灰色块数据**),因为 `key` 值是**弱引用**类型,所以会有这种数据存在。在 `set` 过程中,如果遇到了 `key` 过期的 `Entry` 数据,实际上是会进行一轮**探测式清理**操作的,具体操作方式后面会讲到。
|
||||
|
||||
### `ThreadLocalMap.set()` 详解
|
||||
|
||||
#### `ThreadLocalMap.set()` 原理图解
|
||||
|
||||
看完了 `ThreadLocal` **hash 算法**后,我们再来看 `set` 是如何实现的。
|
||||
|
||||
往 `ThreadLocalMap` 中 `set` 数据(**新增**或者**更新**数据)分为好几种情况,针对不同的情况我们画图来说明。
|
||||
|
||||
**第一种情况:** 通过 `hash` 计算后的槽位对应的 `Entry` 数据为空:
|
||||
|
||||

|
||||
|
||||
这里直接将数据放到该槽位即可。
|
||||
|
||||
**第二种情况:** 槽位数据不为空,`key` 值与当前 `ThreadLocal` 通过 `hash` 计算获取的 `key` 值一致:
|
||||
|
||||

|
||||
|
||||
这里直接更新该槽位的数据。
|
||||
|
||||
**第三种情况:** 槽位数据不为空,往后遍历过程中,在找到 `Entry` 为 `null` 的槽位之前,没有遇到 `key` 过期的 `Entry`:
|
||||
|
||||

|
||||
|
||||
遍历散列数组,线性往后查找,如果找到 `Entry` 为 `null` 的槽位,则将数据放入该槽位中,或者往后遍历过程中,遇到了**key 值相等**的数据,直接更新即可。
|
||||
|
||||
**第四种情况:** 槽位数据不为空,往后遍历过程中,在找到 `Entry` 为 `null` 的槽位之前,遇到 `key` 过期的 `Entry`,如下图,往后遍历过程中,遇到了 `index=7` 的槽位数据 `Entry` 的 `key=null`:
|
||||
|
||||

|
||||
|
||||
散列数组下标为 7 位置对应的 `Entry` 数据 `key` 为 `null`,表明此数据 `key` 值已经被垃圾回收掉了,此时就会执行 `replaceStaleEntry()` 方法,该方法含义是**替换过期数据的逻辑**,以**index=7**位起点开始遍历,进行探测式数据清理工作。
|
||||
|
||||
初始化探测式清理过期数据扫描的开始位置:`slotToExpunge = staleSlot = 7`
|
||||
|
||||
以当前 `staleSlot` 开始 向前迭代查找,找其他过期的数据,然后更新过期数据起始扫描下标 `slotToExpunge`。`for` 循环迭代,直到碰到 `Entry` 为 `null` 结束。
|
||||
|
||||
如果找到了过期的数据,继续向前迭代,直到遇到 `Entry=null` 的槽位才停止迭代,如下图所示,**slotToExpunge 被更新为 0**:
|
||||
|
||||

|
||||
|
||||
以当前节点(`index=7`)向前迭代,检测是否有过期的 `Entry` 数据,如果有则更新 `slotToExpunge` 值。碰到 `null` 则结束探测。以上图为例 `slotToExpunge` 被更新为 0。
|
||||
|
||||
上面向前迭代的操作是为了更新探测清理过期数据的起始下标 `slotToExpunge` 的值,这个值在后面会讲解,它是用来判断当前过期槽位 `staleSlot` 之前是否还有过期元素。
|
||||
|
||||
接着开始以 `staleSlot` 位置(`index=7`)向后迭代,**如果找到了相同 key 值的 Entry 数据:**
|
||||
|
||||

|
||||
|
||||
从当前节点 `staleSlot` 向后查找 `key` 值相等的 `Entry` 元素,找到后更新 `Entry` 的值并交换 `staleSlot` 元素的位置(`staleSlot` 位置为过期元素),更新 `Entry` 数据,然后开始进行过期 `Entry` 的清理工作,如下图所示:
|
||||
|
||||
向后遍历过程中,如果没有找到相同 key 值的 Entry 数据:
|
||||
|
||||

|
||||
|
||||
从当前节点 `staleSlot` 向后查找 `key` 值相等的 `Entry` 元素,直到 `Entry` 为 `null` 则停止寻找。通过上图可知,此时 `table` 中没有 `key` 值相同的 `Entry`。
|
||||
|
||||
创建新的 `Entry`,替换 `table[stableSlot]` 位置:
|
||||
|
||||

|
||||
|
||||
替换完成后也是进行过期元素清理工作,清理工作主要是有两个方法:`expungeStaleEntry()` 和 `cleanSomeSlots()`,具体细节后面会讲到,请继续往后看。
|
||||
|
||||
#### `ThreadLocalMap.set()` 源码详解
|
||||
|
||||
上面已经用图的方式解析了 `set()` 实现的原理,其实已经很清晰了,我们接着再看下源码:
|
||||
|
||||
`java.lang.ThreadLocal`.`ThreadLocalMap.set()`:
|
||||
|
||||
```java
|
||||
private void set(ThreadLocal<?> key, Object value) {
|
||||
Entry[] tab = table;
|
||||
int len = tab.length;
|
||||
int i = key.threadLocalHashCode & (len-1);
|
||||
|
||||
for (Entry e = tab[i];
|
||||
e != null;
|
||||
e = tab[i = nextIndex(i, len)]) {
|
||||
ThreadLocal<?> k = e.get();
|
||||
|
||||
if (k == key) {
|
||||
e.value = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (k == null) {
|
||||
replaceStaleEntry(key, value, i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
tab[i] = new Entry(key, value);
|
||||
int sz = ++size;
|
||||
if (!cleanSomeSlots(i, sz) && sz >= threshold)
|
||||
rehash();
|
||||
}
|
||||
```
|
||||
|
||||
这里会通过 `key` 来计算在散列表中的对应位置,然后以当前 `key` 对应的桶的位置向后查找,找到可以使用的桶。
|
||||
|
||||
```java
|
||||
Entry[] tab = table;
|
||||
int len = tab.length;
|
||||
int i = key.threadLocalHashCode & (len-1);
|
||||
```
|
||||
|
||||
什么情况下桶才是可以使用的呢?
|
||||
|
||||
1. `k = key` 说明是替换操作,可以使用
|
||||
2. 碰到一个过期的桶,执行替换逻辑,占用过期桶
|
||||
3. 查找过程中,碰到桶中 `Entry=null` 的情况,直接使用
|
||||
|
||||
接着就是执行 `for` 循环遍历,向后查找,我们先看下 `nextIndex()`、`prevIndex()` 方法实现:
|
||||
|
||||

|
||||
|
||||
```java
|
||||
private static int nextIndex(int i, int len) {
|
||||
return ((i + 1 < len) ? i + 1 : 0);
|
||||
}
|
||||
|
||||
private static int prevIndex(int i, int len) {
|
||||
return ((i - 1 >= 0) ? i - 1 : len - 1);
|
||||
}
|
||||
```
|
||||
|
||||
接着看剩下 `for` 循环中的逻辑:
|
||||
|
||||
1. 遍历当前 `key` 值对应的桶中 `Entry` 数据为空,这说明散列数组这里没有数据冲突,跳出 `for` 循环,直接 `set` 数据到对应的桶中
|
||||
2. 如果 `key` 值对应的桶中 `Entry` 数据不为空
|
||||
2.1 如果 `k = key`,说明当前 `set` 操作是一个替换操作,做替换逻辑,直接返回
|
||||
2.2 如果 `key = null`,说明当前桶位置的 `Entry` 是过期数据,执行 `replaceStaleEntry()` 方法(核心方法),然后返回
|
||||
3. `for` 循环执行完毕,继续往下执行说明向后迭代的过程中遇到了 `entry` 为 `null` 的情况
|
||||
3.1 在 `Entry` 为 `null` 的桶中创建一个新的 `Entry` 对象
|
||||
3.2 执行 `++size` 操作
|
||||
4. 调用 `cleanSomeSlots()` 做一次启发式清理工作,清理散列数组中 `Entry` 的 `key` 过期的数据
|
||||
4.1 如果清理工作完成后,未清理到任何数据,且 `size` 超过了阈值(数组长度的 2/3),进行 `rehash()` 操作
|
||||
4.2 `rehash()` 中会先进行一轮探测式清理,清理过期 `key`,清理完成后如果**size >= threshold - threshold / 4**,就会执行真正的扩容逻辑(扩容逻辑往后看)
|
||||
|
||||
接着重点看下 `replaceStaleEntry()` 方法,`replaceStaleEntry()` 方法提供替换过期数据的功能,我们可以对应上面**第四种情况**的原理图来再回顾下,具体代码如下:
|
||||
|
||||
`java.lang.ThreadLocal.ThreadLocalMap.replaceStaleEntry()`:
|
||||
|
||||
```java
|
||||
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
|
||||
int staleSlot) {
|
||||
Entry[] tab = table;
|
||||
int len = tab.length;
|
||||
Entry e;
|
||||
|
||||
int slotToExpunge = staleSlot;
|
||||
for (int i = prevIndex(staleSlot, len);
|
||||
(e = tab[i]) != null;
|
||||
i = prevIndex(i, len))
|
||||
|
||||
if (e.get() == null)
|
||||
slotToExpunge = i;
|
||||
|
||||
for (int i = nextIndex(staleSlot, len);
|
||||
(e = tab[i]) != null;
|
||||
i = nextIndex(i, len)) {
|
||||
|
||||
ThreadLocal<?> k = e.get();
|
||||
|
||||
if (k == key) {
|
||||
e.value = value;
|
||||
|
||||
tab[i] = tab[staleSlot];
|
||||
tab[staleSlot] = e;
|
||||
|
||||
if (slotToExpunge == staleSlot)
|
||||
slotToExpunge = i;
|
||||
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
|
||||
return;
|
||||
}
|
||||
|
||||
if (k == null && slotToExpunge == staleSlot)
|
||||
slotToExpunge = i;
|
||||
}
|
||||
|
||||
tab[staleSlot].value = null;
|
||||
tab[staleSlot] = new Entry(key, value);
|
||||
|
||||
if (slotToExpunge != staleSlot)
|
||||
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
|
||||
}
|
||||
```
|
||||
|
||||
`slotToExpunge` 表示开始探测式清理过期数据的开始下标,默认从当前的 `staleSlot` 开始。以当前的 `staleSlot` 开始,向前迭代查找,找到没有过期的数据,`for` 循环一直碰到 `Entry` 为 `null` 才会结束。如果向前找到了过期数据,更新探测清理过期数据的开始下标为 i,即 `slotToExpunge=i`
|
||||
|
||||
```java
|
||||
for (int i = prevIndex(staleSlot, len);
|
||||
(e = tab[i]) != null;
|
||||
i = prevIndex(i, len)){
|
||||
|
||||
if (e.get() == null){
|
||||
slotToExpunge = i;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
接着开始从 `staleSlot` 向后查找,也是碰到 `Entry` 为 `null` 的桶结束。
|
||||
如果迭代过程中,**碰到 k == key**,这说明这里是替换逻辑,替换新数据并且交换当前 `staleSlot` 位置。如果 `slotToExpunge == staleSlot`,这说明 `replaceStaleEntry()` 一开始向前查找过期数据时并未找到过期的 `Entry` 数据,接着向后查找过程中也未发现过期数据,修改开始探测式清理过期数据的下标为当前循环的 index,即 `slotToExpunge = i`。最后调用 `cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);` 进行启发式过期数据清理。
|
||||
|
||||
```java
|
||||
if (k == key) {
|
||||
e.value = value;
|
||||
|
||||
tab[i] = tab[staleSlot];
|
||||
tab[staleSlot] = e;
|
||||
|
||||
if (slotToExpunge == staleSlot)
|
||||
slotToExpunge = i;
|
||||
|
||||
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
`cleanSomeSlots()` 和 `expungeStaleEntry()` 方法后面都会细讲,这两个是和清理相关的方法,一个是过期 `key` 相关 `Entry` 的启发式清理(`Heuristically scan`),另一个是过期 `key` 相关 `Entry` 的探测式清理。
|
||||
|
||||
**如果 k != key**则会接着往下走,`k == null` 说明当前遍历的 `Entry` 是一个过期数据,`slotToExpunge == staleSlot` 说明,一开始的向前查找数据并未找到过期的 `Entry`。如果条件成立,则更新 `slotToExpunge` 为当前位置,这个前提是前驱节点扫描时未发现过期数据。
|
||||
|
||||
```java
|
||||
if (k == null && slotToExpunge == staleSlot)
|
||||
slotToExpunge = i;
|
||||
```
|
||||
|
||||
往后迭代的过程中如果没有找到 `k == key` 的数据,且碰到 `Entry` 为 `null` 的数据,则结束当前的迭代操作。此时说明这里是一个添加的逻辑,将新的数据添加到 `table[staleSlot]` 对应的 `slot` 中。
|
||||
|
||||
```java
|
||||
tab[staleSlot].value = null;
|
||||
tab[staleSlot] = new Entry(key, value);
|
||||
```
|
||||
|
||||
最后判断除了 `staleSlot` 以外,还发现了其他过期的 `slot` 数据,就要开启清理数据的逻辑:
|
||||
|
||||
```java
|
||||
if (slotToExpunge != staleSlot)
|
||||
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
|
||||
```
|
||||
|
||||
### `ThreadLocalMap` 过期 key 的探测式清理流程
|
||||
|
||||
上面我们有提及 `ThreadLocalMap` 的两种过期 `key` 数据清理方式:**探测式清理**和**启发式清理**。
|
||||
|
||||
我们先讲下探测式清理,也就是 `expungeStaleEntry` 方法,遍历散列数组,从开始位置向后探测清理过期数据,将过期数据的 `Entry` 设置为 `null`,沿途中碰到未过期的数据则将此数据 `rehash` 后重新在 `table` 数组中定位,如果定位的位置已经有了数据,则会将未过期的数据放到最靠近此位置的 `Entry=null` 的桶中,使 `rehash` 后的 `Entry` 数据距离正确的桶的位置更近一些。操作逻辑如下:
|
||||
|
||||

|
||||
|
||||
如上图,`set(27)` 经过 hash 计算后应该落到 `index=4` 的桶中,由于 `index=4` 桶已经有了数据,所以往后迭代最终数据放入到 `index=7` 的桶中,放入后一段时间后 `index=5` 中的 `Entry` 数据 `key` 变为了 `null`
|
||||
|
||||

|
||||
|
||||
如果再有其他数据 `set` 到 `map` 中,就会触发**探测式清理**操作。
|
||||
|
||||
如上图,执行**探测式清理**后,`index=5` 的数据被清理掉,继续往后迭代,到 `index=7` 的元素时,经过 `rehash` 后发现该元素正确的 `index=4`,而此位置已经有了数据,往后查找离 `index=4` 最近的 `Entry=null` 的节点(刚被探测式清理掉的数据:`index=5`),找到后移动 `index= 7` 的数据到 `index=5` 中,此时桶的位置离正确的位置 `index=4` 更近了。
|
||||
|
||||
经过一轮探测式清理后,`key` 过期的数据会被清理掉,没过期的数据经过 `rehash` 重定位后所处的桶位置理论上更接近 `i= key.hashCode & (tab.len - 1)` 的位置。这种优化会提高整个散列表查询性能。
|
||||
|
||||
接着看下 `expungeStaleEntry()` 具体流程,我们还是以先原理图后源码讲解的方式来一步步梳理:
|
||||
|
||||

|
||||
|
||||
我们假设 `expungeStaleEntry(3)` 来调用此方法,如上图所示,我们可以看到 `ThreadLocalMap` 中 `table` 的数据情况,接着执行清理操作:
|
||||
|
||||

|
||||
|
||||
第一步是清空当前 `staleSlot` 位置的数据,`index=3` 位置的 `Entry` 变成了 `null`。然后接着往后探测:
|
||||
|
||||

|
||||
|
||||
执行完第二步后,index=4 的元素挪到 index=3 的槽位中。
|
||||
|
||||
继续往后迭代检查,碰到正常数据,计算该数据位置是否偏移,如果被偏移,则重新计算 `slot` 位置,目的是让正常数据尽可能存放在正确位置或离正确位置更近的位置
|
||||
|
||||

|
||||
|
||||
在往后迭代的过程中碰到空的槽位,终止探测,这样一轮探测式清理工作就完成了,接着我们继续看看具体**实现源代码**:
|
||||
|
||||
```java
|
||||
private int expungeStaleEntry(int staleSlot) {
|
||||
Entry[] tab = table;
|
||||
int len = tab.length;
|
||||
|
||||
tab[staleSlot].value = null;
|
||||
tab[staleSlot] = null;
|
||||
size--;
|
||||
|
||||
Entry e;
|
||||
int i;
|
||||
for (i = nextIndex(staleSlot, len);
|
||||
(e = tab[i]) != null;
|
||||
i = nextIndex(i, len)) {
|
||||
ThreadLocal<?> k = e.get();
|
||||
if (k == null) {
|
||||
e.value = null;
|
||||
tab[i] = null;
|
||||
size--;
|
||||
} else {
|
||||
int h = k.threadLocalHashCode & (len - 1);
|
||||
if (h != i) {
|
||||
tab[i] = null;
|
||||
|
||||
while (tab[h] != null)
|
||||
h = nextIndex(h, len);
|
||||
tab[h] = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
return i;
|
||||
}
|
||||
```
|
||||
|
||||
这里我们还是以 `staleSlot=3` 来做示例说明,首先是将 `tab[staleSlot]` 槽位的数据清空,然后设置 `size--`
|
||||
接着以 `staleSlot` 位置往后迭代,如果遇到 `k==null` 的过期数据,也是清空该槽位数据,然后 `size--`
|
||||
|
||||
```java
|
||||
ThreadLocal<?> k = e.get();
|
||||
|
||||
if (k == null) {
|
||||
e.value = null;
|
||||
tab[i] = null;
|
||||
size--;
|
||||
}
|
||||
```
|
||||
|
||||
如果 `key` 没有过期,重新计算当前 `key` 的下标位置是不是当前槽位下标位置,如果不是,那么说明产生了 `hash` 冲突,此时以新计算出来正确的槽位位置往后迭代,找到最近一个可以存放 `entry` 的位置。
|
||||
|
||||
```java
|
||||
int h = k.threadLocalHashCode & (len - 1);
|
||||
if (h != i) {
|
||||
tab[i] = null;
|
||||
|
||||
while (tab[h] != null)
|
||||
h = nextIndex(h, len);
|
||||
|
||||
tab[h] = e;
|
||||
}
|
||||
```
|
||||
|
||||
这里是处理正常的产生 `Hash` 冲突的数据,经过迭代后,有过 `Hash` 冲突数据的 `Entry` 位置会更靠近正确位置,这样的话,查询的时候 效率才会更高。
|
||||
|
||||
### `ThreadLocalMap` 扩容机制
|
||||
|
||||
在 `ThreadLocalMap.set()` 方法的最后,如果执行完启发式清理工作后,未清理到任何数据,且当前散列数组中 `Entry` 的数量已经达到了列表的扩容阈值 `(len*2/3)`,就开始执行 `rehash()` 逻辑:
|
||||
|
||||
```java
|
||||
if (!cleanSomeSlots(i, sz) && sz >= threshold)
|
||||
rehash();
|
||||
```
|
||||
|
||||
接着看下 `rehash()` 具体实现:
|
||||
|
||||
```java
|
||||
private void rehash() {
|
||||
expungeStaleEntries();
|
||||
|
||||
if (size >= threshold - threshold / 4)
|
||||
resize();
|
||||
}
|
||||
|
||||
private void expungeStaleEntries() {
|
||||
Entry[] tab = table;
|
||||
int len = tab.length;
|
||||
for (int j = 0; j < len; j++) {
|
||||
Entry e = tab[j];
|
||||
if (e != null && e.get() == null)
|
||||
expungeStaleEntry(j);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这里首先是会进行探测式清理工作,从 `table` 的起始位置往后清理,上面有分析清理的详细流程。清理完成之后,`table` 中可能有一些 `key` 为 `null` 的 `Entry` 数据被清理掉,所以此时通过判断 `size >= threshold - threshold / 4` 也就是 `size >= threshold * 3/4` 来决定是否扩容。
|
||||
|
||||
我们还记得上面进行 `rehash()` 的阈值是 `size >= threshold`,所以当面试官套路我们 `ThreadLocalMap` 扩容机制的时候 我们一定要说清楚这两个步骤:
|
||||
|
||||

|
||||
|
||||
接着看看具体的 `resize()` 方法,为了方便演示,我们以 `oldTab.len=8` 来举例:
|
||||
|
||||

|
||||
|
||||
扩容后的 `tab` 的大小为 `oldLen * 2`,然后遍历老的散列表,重新计算 `hash` 位置,然后放到新的 `tab` 数组中,如果出现 `hash` 冲突则往后寻找最近的 `entry` 为 `null` 的槽位,遍历完成之后,`oldTab` 中所有的 `entry` 数据都已经放入到新的 `tab` 中了。重新计算 `tab` 下次扩容的**阈值**,具体代码如下:
|
||||
|
||||
```java
|
||||
private void resize() {
|
||||
Entry[] oldTab = table;
|
||||
int oldLen = oldTab.length;
|
||||
int newLen = oldLen * 2;
|
||||
Entry[] newTab = new Entry[newLen];
|
||||
int count = 0;
|
||||
|
||||
for (int j = 0; j < oldLen; ++j) {
|
||||
Entry e = oldTab[j];
|
||||
if (e != null) {
|
||||
ThreadLocal<?> k = e.get();
|
||||
if (k == null) {
|
||||
e.value = null;
|
||||
} else {
|
||||
int h = k.threadLocalHashCode & (newLen - 1);
|
||||
while (newTab[h] != null)
|
||||
h = nextIndex(h, newLen);
|
||||
newTab[h] = e;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setThreshold(newLen);
|
||||
size = count;
|
||||
table = newTab;
|
||||
}
|
||||
```
|
||||
|
||||
### `ThreadLocalMap.get()` 详解
|
||||
|
||||
上面已经看完了 `set()` 方法的源码,其中包括 `set` 数据、清理数据、优化数据桶的位置等操作,接着看看 `get()` 操作的原理。
|
||||
|
||||
#### `ThreadLocalMap.get()` 图解
|
||||
|
||||
**第一种情况:** 通过查找 `key` 值计算出散列表中 `slot` 位置,然后该 `slot` 位置中的 `Entry.key` 和查找的 `key` 一致,则直接返回:
|
||||
|
||||

|
||||
|
||||
**第二种情况:** `slot` 位置中的 `Entry.key` 和要查找的 `key` 不一致:
|
||||
|
||||

|
||||
|
||||
我们以 `get(ThreadLocal1)` 为例,通过 `hash` 计算后,正确的 `slot` 位置应该是 4,而 `index=4` 的槽位已经有了数据,且 `key` 值不等于 `ThreadLocal1`,所以需要继续往后迭代查找。
|
||||
|
||||
迭代到 `index=5` 的数据时,此时 `Entry.key=null`,触发一次探测式数据回收操作,执行 `expungeStaleEntry()` 方法,执行完后,`index 5,8` 的数据都会被回收,而 `index 6,7` 的数据都会前移。`index 6,7` 前移之后,继续从 `index=5` 往后迭代,于是就在 `index=6` 找到了 `key` 值相等的 `Entry` 数据,如下图所示:
|
||||
|
||||

|
||||
|
||||
#### `ThreadLocalMap.get()` 源码详解
|
||||
|
||||
`java.lang.ThreadLocal.ThreadLocalMap.getEntry()`:
|
||||
|
||||
```java
|
||||
private Entry getEntry(ThreadLocal<?> key) {
|
||||
int i = key.threadLocalHashCode & (table.length - 1);
|
||||
Entry e = table[i];
|
||||
if (e != null && e.get() == key)
|
||||
return e;
|
||||
else
|
||||
return getEntryAfterMiss(key, i, e);
|
||||
}
|
||||
|
||||
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
|
||||
Entry[] tab = table;
|
||||
int len = tab.length;
|
||||
|
||||
while (e != null) {
|
||||
ThreadLocal<?> k = e.get();
|
||||
if (k == key)
|
||||
return e;
|
||||
if (k == null)
|
||||
expungeStaleEntry(i);
|
||||
else
|
||||
i = nextIndex(i, len);
|
||||
e = tab[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### `ThreadLocalMap` 过期 key 的启发式清理流程
|
||||
|
||||
上面多次提及到 `ThreadLocalMap` 过期 key 的两种清理方式:**探测式清理(expungeStaleEntry())**、**启发式清理(cleanSomeSlots())**
|
||||
|
||||
探测式清理是以当前 `Entry` 往后清理,遇到值为 `null` 则结束清理,属于**线性探测清理**。
|
||||
|
||||
而启发式清理被作者定义为:**Heuristically scan some cells looking for stale entries**.
|
||||
|
||||

|
||||
|
||||
具体代码如下:
|
||||
|
||||
```java
|
||||
private boolean cleanSomeSlots(int i, int n) {
|
||||
boolean removed = false;
|
||||
Entry[] tab = table;
|
||||
int len = tab.length;
|
||||
do {
|
||||
i = nextIndex(i, len);
|
||||
Entry e = tab[i];
|
||||
if (e != null && e.get() == null) {
|
||||
n = len;
|
||||
removed = true;
|
||||
i = expungeStaleEntry(i);
|
||||
}
|
||||
} while ( (n >>>= 1) != 0);
|
||||
return removed;
|
||||
}
|
||||
```
|
||||
|
||||
### `InheritableThreadLocal`
|
||||
|
||||
我们使用 `ThreadLocal` 的时候,在异步场景下是无法给子线程共享父线程中创建的线程副本数据的。
|
||||
|
||||
为了解决这个问题,JDK 中还有一个 `InheritableThreadLocal` 类,我们来看一个例子:
|
||||
|
||||
```java
|
||||
public class InheritableThreadLocalDemo {
|
||||
public static void main(String[] args) {
|
||||
ThreadLocal<String> ThreadLocal = new ThreadLocal<>();
|
||||
ThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();
|
||||
ThreadLocal.set("父类数据:threadLocal");
|
||||
inheritableThreadLocal.set("父类数据:inheritableThreadLocal");
|
||||
|
||||
new Thread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
System.out.println("子线程获取父类ThreadLocal数据:" + ThreadLocal.get());
|
||||
System.out.println("子线程获取父类inheritableThreadLocal数据:" + inheritableThreadLocal.get());
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
打印结果:
|
||||
|
||||
```java
|
||||
子线程获取父类ThreadLocal数据:null
|
||||
子线程获取父类inheritableThreadLocal数据:父类数据:inheritableThreadLocal
|
||||
```
|
||||
|
||||
实现原理是子线程是通过在父线程中通过调用 `new Thread()` 方法来创建子线程,`Thread#init` 方法在 `Thread` 的构造方法中被调用。在 `init` 方法中拷贝父线程数据到子线程中:
|
||||
|
||||
```java
|
||||
private void init(ThreadGroup g, Runnable target, String name,
|
||||
long stackSize, AccessControlContext acc,
|
||||
boolean inheritThreadLocals) {
|
||||
if (name == null) {
|
||||
throw new NullPointerException("name cannot be null");
|
||||
}
|
||||
|
||||
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
|
||||
this.inheritableThreadLocals =
|
||||
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
|
||||
this.stackSize = stackSize;
|
||||
tid = nextThreadID();
|
||||
}
|
||||
```
|
||||
|
||||
但 `InheritableThreadLocal` 仍然有缺陷,一般我们做异步化处理都是使用的线程池,而 `InheritableThreadLocal` 是在 `new Thread` 中的 `init()` 方法给赋值的,而线程池是线程复用的逻辑,所以这里会存在问题。
|
||||
|
||||
当然,有问题出现就会有解决问题的方案,阿里巴巴开源了一个 `TransmittableThreadLocal` 组件就可以解决这个问题,这里就不再延伸,感兴趣的可自行查阅资料。
|
||||
|
||||
### `ThreadLocal` 项目中使用实战
|
||||
|
||||
#### `ThreadLocal` 使用场景
|
||||
|
||||
我们现在项目中日志记录用的是 `ELK+Logstash`,最后在 `Kibana` 中进行展示和检索。
|
||||
|
||||
现在都是分布式系统统一对外提供服务,项目间调用的关系可以通过 `traceId` 来关联,但是不同项目之间如何传递 `traceId` 呢?
|
||||
|
||||
这里我们使用 `org.slf4j.MDC` 来实现此功能,内部就是通过 `ThreadLocal` 来实现的,具体实现如下:
|
||||
|
||||
当前端发送请求到**服务 A**时,**服务 A**会生成一个类似 `UUID` 的 `traceId` 字符串,将此字符串放入当前线程的 `ThreadLocal` 中,在调用**服务 B**的时候,将 `traceId` 写入到请求的 `Header` 中,**服务 B**在接收请求时会先判断请求的 `Header` 中是否有 `traceId`,如果存在则写入自己线程的 `ThreadLocal` 中。
|
||||
|
||||

|
||||
|
||||
图中的 `requestId` 即为我们各个系统链路关联的 `traceId`,系统间互相调用,通过这个 `requestId` 即可找到对应链路,这里还有会有一些其他场景:
|
||||
|
||||

|
||||
|
||||
针对于这些场景,我们都可以有相应的解决方案,如下所示
|
||||
|
||||
#### Feign 远程调用解决方案
|
||||
|
||||
**服务发送请求:**
|
||||
|
||||
```java
|
||||
@Component
|
||||
@Slf4j
|
||||
public class FeignInvokeInterceptor implements RequestInterceptor {
|
||||
|
||||
@Override
|
||||
public void apply(RequestTemplate template) {
|
||||
String requestId = MDC.get("requestId");
|
||||
if (StringUtils.isNotBlank(requestId)) {
|
||||
template.header("requestId", requestId);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**服务接收请求:**
|
||||
|
||||
```java
|
||||
@Slf4j
|
||||
@Component
|
||||
public class LogInterceptor extends HandlerInterceptorAdapter {
|
||||
|
||||
@Override
|
||||
public void afterCompletion(HttpServletRequest arg0, HttpServletResponse arg1, Object arg2, Exception arg3) {
|
||||
MDC.remove("requestId");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postHandle(HttpServletRequest arg0, HttpServletResponse arg1, Object arg2, ModelAndView arg3) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
|
||||
String requestId = request.getHeader(BaseConstant.REQUEST_ID_KEY);
|
||||
if (StringUtils.isBlank(requestId)) {
|
||||
requestId = UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
MDC.put("requestId", requestId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 线程池异步调用,requestId 传递
|
||||
|
||||
因为 `MDC` 是基于 `ThreadLocal` 去实现的,异步过程中,子线程并没有办法获取到父线程 `ThreadLocal` 存储的数据,所以这里可以自定义线程池执行器,修改其中的 `run()` 方法:
|
||||
|
||||
```java
|
||||
public class MyThreadPoolTaskExecutor extends ThreadPoolTaskExecutor {
|
||||
|
||||
@Override
|
||||
public void execute(Runnable runnable) {
|
||||
Map<String, String> context = MDC.getCopyOfContextMap();
|
||||
super.execute(() -> run(runnable, context));
|
||||
}
|
||||
|
||||
@Override
|
||||
private void run(Runnable runnable, Map<String, String> context) {
|
||||
if (context != null) {
|
||||
MDC.setContextMap(context);
|
||||
}
|
||||
try {
|
||||
runnable.run();
|
||||
} finally {
|
||||
MDC.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 使用 MQ 发送消息给第三方系统
|
||||
|
||||
在 MQ 发送的消息体中自定义属性 `requestId`,接收方消费消息后,自己解析 `requestId` 使用即可。
|
||||
|
||||
<!-- @include: @article-footer.snippet.md -->
|
||||
@@ -0,0 +1,458 @@
|
||||
---
|
||||
title: 虚拟线程常见问题总结
|
||||
description: Java 21 虚拟线程详解:梳理 Virtual Threads 的定位、调度原理、与平台线程的区别、适用场景、创建方式、性能边界、Spring Boot 接入方式和实践注意事项。
|
||||
category: Java
|
||||
tag:
|
||||
- Java并发
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: Java虚拟线程,Virtual Threads,Project Loom,Java 21新特性,轻量级线程,协程,虚拟线程原理
|
||||
---
|
||||
|
||||
<!-- @include: @article-header.snippet.md -->
|
||||
|
||||
一个 Web 请求进来,代码要查数据库、调远程接口、读写文件。按传统的同步写法,这个请求会占住一个平台线程,哪怕大部分时间都在等 I/O。
|
||||
|
||||
线程池能缓解线程创建成本,但不能改变一个事实:平台线程数量仍然受操作系统线程数量、内存和调度成本限制。当并发请求继续增加时,线程池里的线程会被排队任务占满,吞吐量很快卡住。
|
||||
|
||||
虚拟线程就是为这个问题来的。它让我们继续用简单的同步阻塞代码,同时让等待 I/O 的任务不再长期占着昂贵的平台线程。
|
||||
|
||||
## 什么是虚拟线程?
|
||||
|
||||
虚拟线程(Virtual Thread)是 Java 21 正式引入的一种轻量级线程,也是 `java.lang.Thread` 的一种实现。它由 JDK 管理和调度,而不是直接和某个操作系统线程一一绑定。
|
||||
|
||||
平台线程(Platform Thread)通常是对操作系统线程的薄封装。一个平台线程运行时,会在整个生命周期内占用一个操作系统线程。虚拟线程不一样:它运行 Java 代码时需要挂载到某个平台线程上;当它执行可挂起的阻塞操作时,JDK 可以把它从平台线程上卸载下来,让这个平台线程去执行别的虚拟线程。
|
||||
|
||||
因此,虚拟线程的数量可以远大于平台线程数量。官方文档里用虚拟内存做类比:操作系统把大量虚拟地址映射到有限物理内存,Java 运行时则把大量虚拟线程映射到较少的平台线程。
|
||||
|
||||
虚拟线程的几个关键点:
|
||||
|
||||
- 虚拟线程仍然是 `Thread`,支持 `ThreadLocal`、中断、异常栈、调试和 JFR 观测。
|
||||
- 虚拟线程适合大量阻塞等待的任务,比如 HTTP 调用、数据库查询、消息队列访问、文件或网络 I/O。
|
||||
- 虚拟线程不是更快的 CPU 执行单元,不会让一段纯计算代码跑得更快。
|
||||
- 虚拟线程很便宜,通常应该“每个任务一个虚拟线程”,而不是像平台线程一样池化复用。
|
||||
|
||||
## 虚拟线程和平台线程有什么关系?
|
||||
|
||||
在 Java 里,虚拟线程、平台线程和操作系统线程大致是这样的关系:
|
||||
|
||||

|
||||
|
||||
在 Windows、Linux 等主流操作系统中,HotSpot JVM 的平台线程通常采用一对一线程模型,也就是一个平台线程对应一个操作系统线程。虚拟线程引入后,JDK 在平台线程之上又加了一层调度:
|
||||
|
||||
- 虚拟线程是任务的承载者,业务代码看到的 `Thread.currentThread()` 返回的是虚拟线程本身。
|
||||
- 平台线程是虚拟线程的载体(Carrier Thread),负责真正执行虚拟线程里的 Java 代码。
|
||||
- 操作系统仍然只调度平台线程,不知道虚拟线程的存在。
|
||||
|
||||
一个虚拟线程开始执行时,会被 JDK 调度器挂载(mount)到某个平台线程上。执行到阻塞 I/O、`BlockingQueue.take()`、`Future.get()` 等支持挂起的阻塞点时,虚拟线程可以卸载(unmount),平台线程被释放出来继续执行其他虚拟线程。等阻塞操作就绪后,虚拟线程再被提交回调度器,挂载到某个平台线程上继续执行。
|
||||
|
||||
这个挂载和卸载过程对业务代码是透明的。你写的仍然是普通同步代码:
|
||||
|
||||
```java
|
||||
String body = httpClient.send(request, BodyHandlers.ofString()).body();
|
||||
Result result = repository.query(body);
|
||||
return service.handle(result);
|
||||
```
|
||||
|
||||
如果这些调用内部发生阻塞,虚拟线程可以挂起自己;如果换成平台线程,这个线程会一直占住对应的操作系统线程。
|
||||
|
||||
## Project Loom 和虚拟线程是什么关系?
|
||||
|
||||
Project Loom 是 OpenJDK 中改进 Java 并发模型的项目,虚拟线程是 Loom 最重要的成果之一。虚拟线程先后在 JDK 19、JDK 20 中预览,最终通过 [JEP 444](https://openjdk.org/jeps/444) 在 JDK 21 转正。
|
||||
|
||||
Loom 不只是加了一个轻量级线程 API。它还推动了 JDK 阻塞 I/O、调试、JFR、线程转储等配套能力的调整,让传统的 thread-per-request 编程风格在高并发 I/O 场景下重新变得可扩展。
|
||||
|
||||
这也是虚拟线程和普通“协程库”的一个重要区别:虚拟线程被纳入了 Java 平台的线程模型。调试器、Profiler、JFR、线程 dump 都能以线程为单位理解它,而不是把业务调用链拆成一堆回调阶段。
|
||||
|
||||
## 虚拟线程解决了什么问题?
|
||||
|
||||
很多服务端程序天然适合“一个请求一个线程”的模型。它的好处很明显:代码顺序执行,异常可以沿着调用栈抛出,调试器能一步步跟进去,线程 dump 也能看到请求卡在哪里。
|
||||
|
||||
问题在于平台线程太贵。
|
||||
|
||||
假设一个接口平均耗时 50ms,系统要达到 2000 QPS,按 Little's Law 粗略估算,需要同时处理约 100 个请求。如果接口平均耗时变成 500ms,同样 2000 QPS 就需要约 1000 个并发请求。每个请求都占一个平台线程时,线程数量很容易先于 CPU、网络带宽、数据库连接等资源成为瓶颈。
|
||||
|
||||
异步编程、Reactive 编程可以把线程从等待 I/O 中释放出来,但代价也很明显:调用链被拆成回调、`CompletableFuture` 链或响应式流水线,异常处理、调试、火焰图和线程上下文都会变复杂。
|
||||
|
||||
虚拟线程试图保留同步代码的可读性,同时降低阻塞等待时占用平台线程的成本。它提升的主要是吞吐能力和并发承载能力,不是单个请求的执行速度。
|
||||
|
||||
## 虚拟线程适合哪些场景?
|
||||
|
||||
虚拟线程最适合下面这类任务:
|
||||
|
||||
- 并发任务数量很多,通常是成千上万级别。
|
||||
- 任务大部分时间在等待 I/O,比如数据库、Redis、HTTP/RPC、消息队列、文件和网络读写。
|
||||
- 现有代码主要是同步阻塞模型,不想为了扩展性改成复杂的异步链。
|
||||
- 希望保留传统调用栈,方便调试、压测分析和线上排查。
|
||||
|
||||
典型场景包括:
|
||||
|
||||
- Spring MVC / Servlet 接口中调用数据库和外部 HTTP 服务。
|
||||
- 后台任务批量调用第三方接口。
|
||||
- 网关或聚合服务并发调用多个下游服务。
|
||||
- 消息消费逻辑中包含阻塞式数据库写入或远程调用。
|
||||
|
||||
虚拟线程不适合把 CPU 密集型任务“变快”。如果任务主要是在算哈希、压缩图片、排序大数组、跑复杂规则引擎,线程数量超过 CPU 核心数之后,吞吐通常不会继续提高。CPU 密集型工作仍然应该关注算法、数据结构、批处理、并行流、专门的计算线程池或本地化优化。
|
||||
|
||||
## 如何创建虚拟线程?
|
||||
|
||||
JDK 21 中常见的创建方式有四种。
|
||||
|
||||
### 使用 `Thread.startVirtualThread()`
|
||||
|
||||
适合启动一个很简单的虚拟线程:
|
||||
|
||||
```java
|
||||
public class VirtualThreadDemo {
|
||||
public static void main(String[] args) throws InterruptedException {
|
||||
Thread thread = Thread.startVirtualThread(() -> {
|
||||
System.out.println(Thread.currentThread());
|
||||
});
|
||||
|
||||
thread.join();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
需要注意的是,虚拟线程是守护线程。如果 `main` 方法不等待它结束,JVM 可能直接退出,导致任务还没来得及执行完。
|
||||
|
||||
### 使用 `Thread.ofVirtual()`
|
||||
|
||||
`Thread.ofVirtual()` 返回一个 `Thread.Builder.OfVirtual`,可以设置线程名,也可以选择创建后立即启动或先不启动:
|
||||
|
||||
```java
|
||||
public class VirtualThreadDemo {
|
||||
public static void main(String[] args) throws InterruptedException {
|
||||
Thread unstarted = Thread.ofVirtual()
|
||||
.name("order-query")
|
||||
.unstarted(() -> System.out.println("query order"));
|
||||
|
||||
unstarted.start();
|
||||
unstarted.join();
|
||||
|
||||
Thread started = Thread.ofVirtual()
|
||||
.name("payment-query")
|
||||
.start(() -> System.out.println("query payment"));
|
||||
|
||||
started.join();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 使用 `ThreadFactory`
|
||||
|
||||
如果你希望统一线程命名,或者把线程工厂交给框架使用,可以通过 `ThreadFactory` 创建虚拟线程:
|
||||
|
||||
```java
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
|
||||
public class VirtualThreadDemo {
|
||||
public static void main(String[] args) throws InterruptedException {
|
||||
ThreadFactory factory = Thread.ofVirtual()
|
||||
.name("worker-", 0)
|
||||
.factory();
|
||||
|
||||
Thread thread = factory.newThread(() -> {
|
||||
System.out.println(Thread.currentThread().getName());
|
||||
});
|
||||
|
||||
thread.start();
|
||||
thread.join();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 使用 `Executors.newVirtualThreadPerTaskExecutor()`
|
||||
|
||||
业务开发中最常见的是这种方式。它会为每个提交的任务创建一个新的虚拟线程:
|
||||
|
||||
```java
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
public class VirtualThreadDemo {
|
||||
public static void main(String[] args) throws Exception {
|
||||
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
Future<String> future = executor.submit(() -> {
|
||||
return "hello virtual thread";
|
||||
});
|
||||
|
||||
System.out.println(future.get());
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这里的 `ExecutorService` 不是传统意义上的线程池。它不会维护一组固定虚拟线程来复用,而是每个任务一个新的虚拟线程。`try-with-resources` 结束时会调用 `close()`,等待已提交任务完成。
|
||||
|
||||
## 虚拟线程要不要池化?
|
||||
|
||||
不要池化虚拟线程。
|
||||
|
||||
线程池的主要目标是复用昂贵的平台线程,并顺便限制并发。虚拟线程本身不是稀缺资源,池化它们通常没有意义,还会把“每个任务一个线程”的模型重新绕回旧思路。
|
||||
|
||||
如果你的真实目标是限制访问某个资源的并发量,应该限制资源,而不是限制虚拟线程数量。比如某个老系统最多只能承受 20 个并发请求,可以用 `Semaphore` 控制并发:
|
||||
|
||||
```java
|
||||
import java.util.concurrent.Semaphore;
|
||||
|
||||
public class OldServiceClient {
|
||||
private static final Semaphore LIMIT = new Semaphore(20);
|
||||
|
||||
public String call() throws InterruptedException {
|
||||
LIMIT.acquire();
|
||||
try {
|
||||
return doCall();
|
||||
} finally {
|
||||
LIMIT.release();
|
||||
}
|
||||
}
|
||||
|
||||
private String doCall() {
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果瓶颈是数据库连接,那就调整连接池大小;如果瓶颈是下游接口限流,那就做限流、熔断和重试退避。虚拟线程能让等待变便宜,但不能让数据库连接、下游容量、CPU 和内存变无限。
|
||||
|
||||
## 虚拟线程和平台线程性能对比
|
||||
|
||||
先给结论:虚拟线程不是“跑得更快的线程”,而是“可以创建很多、阻塞成本更低的线程”。它通常能提升 I/O 密集型服务的吞吐,但不会降低一次数据库查询或一次 HTTP 调用本身的耗时。
|
||||
|
||||
下面这个例子模拟 10,000 个阻塞 1 秒的任务:
|
||||
|
||||
```java
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
public class VirtualThreadCompareDemo {
|
||||
public static void main(String[] args) {
|
||||
long start = System.currentTimeMillis();
|
||||
|
||||
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||
IntStream.range(0, 10_000).forEach(i -> {
|
||||
executor.submit(() -> {
|
||||
Thread.sleep(Duration.ofSeconds(1));
|
||||
return i;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
System.out.println("cost: " + (System.currentTimeMillis() - start) + "ms");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果把它换成 `Executors.newFixedThreadPool(200)`,同一时间最多只有 200 个任务在执行,10,000 个任务会被分批处理。按每批 1 秒粗略估计,总耗时接近 50 秒。虚拟线程版本可以让这 10,000 个任务几乎同时进入等待状态,平台线程在等待期间被释放出来,总耗时更接近单个任务的等待时间。
|
||||
|
||||
这个例子只能说明虚拟线程对“阻塞等待”友好,不是严谨基准测试。真实服务要看数据库连接池、HTTP 客户端连接池、下游限流、GC、对象分配、锁竞争、容器 CPU 配额等因素。
|
||||
|
||||
## 虚拟线程的底层原理是什么?
|
||||
|
||||
可以把虚拟线程的执行过程拆成三件事:调度、挂载/卸载、栈管理。
|
||||
|
||||
### 调度
|
||||
|
||||
平台线程依赖操作系统调度。虚拟线程由 JDK 自己的调度器调度,再由平台线程承载执行。JEP 444 中说明,虚拟线程调度器是一个采用 FIFO 模式的 work-stealing `ForkJoinPool`,它和并行流使用的 common pool 不是同一个池。
|
||||
|
||||
默认情况下,调度器的并行度和可用处理器数量相关,可以通过下面的系统属性调整:
|
||||
|
||||
- `jdk.virtualThreadScheduler.parallelism`:调度器目标并行度。
|
||||
- `jdk.virtualThreadScheduler.maxPoolSize`:调度器可扩展的平台线程上限。
|
||||
|
||||
大多数业务系统不需要改这两个参数。优先排查连接池、限流、锁和阻塞点,通常比调整调度器参数更有效。
|
||||
|
||||
### 挂载和卸载
|
||||
|
||||
虚拟线程执行 Java 代码时,会挂载到某个平台线程上。遇到支持挂起的阻塞操作时,虚拟线程可以保存当前执行状态并卸载,平台线程继续服务其他虚拟线程。
|
||||
|
||||
常见的 JDK 阻塞操作已经为虚拟线程做了适配。例如网络 I/O、`BlockingQueue`、`Future.get()` 等,在虚拟线程中阻塞时通常不会长期占住底层平台线程。
|
||||
|
||||
不是所有阻塞都能卸载。JDK 21 到 JDK 23 中,虚拟线程在 `synchronized` 代码块或方法中阻塞,会出现 Pinning,也就是被固定在载体线程上。JDK 24 的 [JEP 491](https://openjdk.org/jeps/491) 改进了这一点,使虚拟线程在 `synchronized` 中阻塞时也能释放底层平台线程,消除了绝大多数由 `synchronized` 带来的 Pinning 场景。调用 native 方法或 Foreign Function & Memory API 相关代码时,仍然要关注剩余的 Pinning 风险。
|
||||
|
||||
### 栈管理
|
||||
|
||||
平台线程通常使用固定大小的操作系统线程栈。虚拟线程的栈以栈块对象的形式存放在 Java 堆中,可以随着执行过程增长和收缩。这也是虚拟线程能够大量创建的重要原因之一。
|
||||
|
||||
不过,这并不代表虚拟线程没有内存成本。每个虚拟线程仍然是对象,也有栈块、局部变量、`ThreadLocal` 等内存开销。百万级虚拟线程不是免费的,只是比百万级平台线程现实得多。
|
||||
|
||||
## 什么是 Pinning?
|
||||
|
||||
Pinning 可以理解为“虚拟线程暂时没法从载体线程上卸载”。虚拟线程被固定在某个平台线程上后,它在阻塞期间会连带占住底层操作系统线程,扩展性也会跟着变差。
|
||||
|
||||
在 JDK 21 到 JDK 23 中,最典型的 Pinning 场景是:虚拟线程在 `synchronized` 代码块或方法里执行阻塞 I/O。
|
||||
|
||||
```java
|
||||
public synchronized String load() throws IOException {
|
||||
return remoteClient.get("/config"); // JDK 21-23 中,这里阻塞时可能固定载体线程
|
||||
}
|
||||
```
|
||||
|
||||
短小、纯内存操作的 `synchronized` 问题不大。真正需要关注的是高频路径上持锁执行慢 I/O,例如持有对象锁时查数据库、调远程接口、读大文件。
|
||||
|
||||
如果你使用 JDK 21 到 JDK 23,可以考虑:
|
||||
|
||||
- 避免在 `synchronized` 内部执行慢 I/O。
|
||||
- 对高频阻塞锁场景使用 `ReentrantLock`,并用 `try/finally` 释放锁。
|
||||
- 用 JFR 观察 `jdk.VirtualThreadPinned` 事件。
|
||||
- 临时使用 `-Djdk.tracePinnedThreads=full` 定位被固定的调用栈。
|
||||
|
||||
如果你使用 JDK 24 或更高版本,`synchronized` 导致的主要 Pinning 问题已经被 JEP 491 解决。选择 `synchronized` 还是 `java.util.concurrent.locks`,可以重新回到代码语义、可维护性和锁能力本身来判断。
|
||||
|
||||
## 使用虚拟线程有哪些注意事项?
|
||||
|
||||
### 不要把虚拟线程当作提速 CPU 的工具
|
||||
|
||||
虚拟线程提升的是等待型任务的并发承载能力。CPU 密集型任务最终还是要抢 CPU 时间片,虚拟线程数量再多也不能突破 CPU 核心数的物理限制。
|
||||
|
||||
### 不要用线程池思维限制虚拟线程
|
||||
|
||||
不要创建固定数量的虚拟线程池。需要限制并发时,用 `Semaphore`、连接池、限流器或队列容量限制具体资源。
|
||||
|
||||
### 小心 `ThreadLocal` 缓存大对象
|
||||
|
||||
Java 21 正式版保证虚拟线程支持 `ThreadLocal`,这有利于兼容老代码和框架。但不要用 `ThreadLocal` 给每个虚拟线程缓存大对象。
|
||||
|
||||
以前在线程池里,一个 `ThreadLocal<SimpleDateFormat>` 可能只对应几十或几百个平台线程。迁移到虚拟线程后,如果每个任务一个虚拟线程,同样的写法可能变成每个任务创建一份缓存对象,内存和分配压力会被放大。
|
||||
|
||||
如果只是传递请求上下文、用户 ID、Trace ID,通常问题不大。如果是缓存数据库连接、大数组、复杂 formatter、客户端对象,就要重新评估。JDK 25 通过 JEP 506 将 Scoped Values 转正,它更适合在大量虚拟线程之间传递不可变上下文。
|
||||
|
||||
### 虚拟线程不会消除线程安全问题
|
||||
|
||||
虚拟线程让创建线程更便宜,也意味着你更容易同时跑起大量并发任务。原来因为线程池较小而没暴露的数据竞争,切到虚拟线程后可能更容易出现。
|
||||
|
||||
需要继续遵守并发编程的基本规则:共享可变状态要加锁或隔离,数据库连接、会话对象、非线程安全客户端不要被多个虚拟线程同时乱用。
|
||||
|
||||
### 注意连接池和下游容量
|
||||
|
||||
很多服务迁移到虚拟线程后,第一个瓶颈不再是业务线程池,而是数据库连接池、HTTP 连接池、Redis 连接数或下游限流。
|
||||
|
||||
这不是虚拟线程的问题。虚拟线程只是让更多任务有机会同时推进,真正的共享资源仍然要按容量管理。压测时建议同时观察:
|
||||
|
||||
- 应用 QPS、响应时间和错误率。
|
||||
- 数据库连接池活跃连接、等待队列和超时数。
|
||||
- HTTP 客户端连接池和下游 429/5xx。
|
||||
- CPU、堆内存、GC、对象分配速率。
|
||||
- JFR 中的虚拟线程事件和锁竞争。
|
||||
|
||||
### 不要混用太多异步模型
|
||||
|
||||
虚拟线程最适合同步阻塞代码。已经用 Reactive/WebFlux/Netty 写成全链路异步的系统,不一定能因为打开虚拟线程就获得明显收益。
|
||||
|
||||
更麻烦的是混用模型:外层虚拟线程,内层又大量使用异步回调和线程池,排查时可能同时面对虚拟线程、事件循环、业务线程池、连接池几套上下文。迁移时最好先挑同步阻塞链路试点,而不是全系统一键替换。
|
||||
|
||||
## Spring Boot 如何开启虚拟线程?
|
||||
|
||||
Spring Boot 3.2 开始提供了比较直接的开关。使用 Java 21 或更高版本时,可以在配置中开启:
|
||||
|
||||
```properties
|
||||
spring.threads.virtual.enabled=true
|
||||
```
|
||||
|
||||
Spring Boot 官方文档还提到几个实践点:
|
||||
|
||||
- 开启虚拟线程后,配置传统线程池大小的部分属性不再按原来的方式生效,因为虚拟线程调度依赖 JVM 范围内的平台线程池。
|
||||
- 虚拟线程是守护线程。如果应用依赖 `@Scheduled` 等后台任务保持 JVM 存活,建议设置 `spring.main.keep-alive=true`。
|
||||
- Spring Boot 官方目前建议 Java 24 或更高版本获得更好的虚拟线程体验,主要和 Pinning 改进有关。
|
||||
|
||||
一个简单配置如下:
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
threads:
|
||||
virtual:
|
||||
enabled: true
|
||||
main:
|
||||
keep-alive: true
|
||||
```
|
||||
|
||||
开启之后,不代表所有接口都会变快。它更可能改善的是同步阻塞、I/O 等待明显、并发较高的接口。如果接口主要耗在 CPU、锁竞争、慢 SQL 本身或下游限流上,虚拟线程只能让问题更早暴露。
|
||||
|
||||
## 如何排查虚拟线程问题?
|
||||
|
||||
JDK 已经为虚拟线程补了不少观测能力。
|
||||
|
||||
### 使用 `jcmd` 导出线程转储
|
||||
|
||||
传统 `jstack` 面对成千上万个虚拟线程时不太合适。JDK 提供了新的线程转储能力:
|
||||
|
||||
```bash
|
||||
jcmd <pid> Thread.dump_to_file -format=json thread-dump.json
|
||||
```
|
||||
|
||||
也可以导出文本格式:
|
||||
|
||||
```bash
|
||||
jcmd <pid> Thread.dump_to_file -format=text thread-dump.txt
|
||||
```
|
||||
|
||||
JSON 格式更适合工具分析,尤其是虚拟线程数量很多时。
|
||||
|
||||
### 使用 JFR 观察虚拟线程事件
|
||||
|
||||
JFR 中和虚拟线程相关的事件包括:
|
||||
|
||||
- `jdk.VirtualThreadStart`
|
||||
- `jdk.VirtualThreadEnd`
|
||||
- `jdk.VirtualThreadPinned`
|
||||
- `jdk.VirtualThreadSubmitFailed`
|
||||
|
||||
其中 `jdk.VirtualThreadPinned` 对排查 Pinning 很有用。JDK 24 以后,`synchronized` 相关 Pinning 大多被解决,但 native/FFM 等剩余场景仍然可以通过 JFR 观察。
|
||||
|
||||
### 临时打开 Pinning 栈追踪
|
||||
|
||||
在 JDK 21 到 JDK 23 中,可以临时使用:
|
||||
|
||||
```bash
|
||||
-Djdk.tracePinnedThreads=full
|
||||
```
|
||||
|
||||
它会在虚拟线程阻塞且被固定时打印调用栈,适合本地或测试环境定位问题。JDK 24 的 JEP 491 之后,`synchronized` 相关的主要 Pinning 场景已经改进;native/FFM 等剩余边界仍然建议结合 JFR 和线程转储判断。
|
||||
|
||||
## 虚拟线程常见面试题
|
||||
|
||||
### 虚拟线程和平台线程的区别是什么?
|
||||
|
||||
平台线程通常和操作系统线程一一对应,创建和上下文切换成本较高,数量有限。虚拟线程由 JDK 调度,可以把大量虚拟线程映射到少量平台线程上。虚拟线程阻塞等待 I/O 时,通常可以从载体线程卸载,让平台线程继续执行其他虚拟线程。
|
||||
|
||||
### 虚拟线程为什么适合 I/O 密集型任务?
|
||||
|
||||
I/O 密集型任务大部分时间在等待外部资源。平台线程等待时会占住操作系统线程;虚拟线程等待时可以挂起自己并释放载体线程。这样同样数量的平台线程可以承载更多并发任务。
|
||||
|
||||
### 虚拟线程适合 CPU 密集型任务吗?
|
||||
|
||||
不适合把它当作 CPU 提速工具。CPU 密集型任务需要真实 CPU 时间,线程数超过核心数后只会增加调度竞争。虚拟线程能改善高并发等待,不会让单个计算任务更快。
|
||||
|
||||
### 虚拟线程需要池化吗?
|
||||
|
||||
不需要,也不建议。虚拟线程便宜,应该按任务创建。需要限制并发时,限制具体资源,比如数据库连接池、HTTP 连接池、`Semaphore`、限流器,而不是池化虚拟线程。
|
||||
|
||||
### 虚拟线程和协程一样吗?
|
||||
|
||||
它们都属于轻量级并发的思路,但 Java 虚拟线程是 `java.lang.Thread` 的实现,纳入了 Java 原有线程模型。业务代码不需要写 `async/await`,也不需要手动 yield。和 Go goroutine 相比,虚拟线程更强调兼容 Java 既有线程 API、调试工具和阻塞式代码风格。对开发者来说,它更像“便宜很多的线程”。
|
||||
|
||||
### 使用虚拟线程后还需要 Reactive 编程吗?
|
||||
|
||||
看场景。很多同步阻塞的服务端接口可以用虚拟线程获得更好的可读性和足够的吞吐,不必为了释放线程写复杂回调链。但 Reactive 仍然适合流式处理、背压、事件驱动、长连接和已经全链路异步化的系统。虚拟线程不是替代所有异步模型的银弹。
|
||||
|
||||
### JDK 21 里的 `synchronized` 还能不能用?
|
||||
|
||||
可以用,但要注意边界。JDK 21 到 JDK 23 中,虚拟线程在 `synchronized` 内部执行阻塞操作可能出现 Pinning。短小的内存同步问题不大,高频路径上不要持有 `synchronized` 锁执行慢 I/O。JDK 24 通过 JEP 491 改进后,`synchronized` 相关 Pinning 已经基本解决。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [JEP 444: Virtual Threads](https://openjdk.org/jeps/444)
|
||||
- [Oracle Java 21 Documentation: Virtual Threads](https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html)
|
||||
- [JEP 491: Synchronize Virtual Threads without Pinning](https://openjdk.org/jeps/491)
|
||||
- [JEP 506: Scoped Values](https://openjdk.org/jeps/506)
|
||||
- [Spring Boot Reference Documentation: Virtual threads](https://docs.spring.io/spring-boot/reference/features/spring-application.html#features.spring-application.virtual-threads)
|
||||
- [Spring Blog: Embracing Virtual Threads](https://spring.io/blog/2022/10/11/embracing-virtual-threads/)
|
||||
- [Inside Java: Managing Throughput with Virtual Threads](https://inside.java/2024/02/04/sip094/)
|
||||
- [Quarkus Blog: When Quarkus meets Virtual Threads](https://quarkus.io/blog/virtual-thread-1/)
|
||||
|
||||
<!-- @include: @article-footer.snippet.md -->
|
||||