0%

从0开始学JAVA 4 JAVA线程方法汇总

万字长文梳理JAVA线程方法的基础应用

第一章:多线程基础

在 Java 中,线程(Thread)是操作系统调度的最小单元。理解多线程,首先要从如何启动它、它经历过哪些状态、以及我们如何控制它开始。

线程的创建方式

虽然最终都是通过 Thread.start() 启动,但 Java 提供了三种主要的任务定义方式

  • 继承 Thread
    • 做法:定义一个类继承 Thread 并重写 run() 方法。
    • 缺点:Java 是单继承的,继承了 Thread 就不能再继承其他类(如 BaseService),灵活性差。

我们直接new一个Tread类:

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args){  
Thread thread = new Thread() {
@Override
public void run() {
super.run();
System.out.println("run: " + this.getState());

}
};

System.out.println("main: " + thread.getState());
thread.start();
}
  • 实现 Runnable 接口(推荐):
    • 做法:实现 run() 方法,并将其作为参数传递给 Thread 构造函数。
    • 优点:解耦了“任务逻辑”与“线程资源”。多个线程可以执行同一个 Runnable 实例,适合资源共享。

Runnable 接口Runnable 是 Java 中一个功能性的接口,它定义了一个 run() 方法。实现这个接口的类(如 TaskRunnable)表示一个任务,这个任务可以被一个线程执行。

ThreadRunnableThread 类用于表示一个执行的线程,但它并不直接定义要做什么。Thread 需要一个 Runnable 对象,来确定线程启动后应该执行什么样的任务。所以,Thread 会将一个 Runnable 对象传给其构造方法,这样线程就知道了执行什么任务。

我们实现一个runnable接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public static void main(String[] args) {  
TaskRunnable taskRunnable = new TaskRunnable();
Thread thread = new Thread(taskRunnable);
thread.start();
try {
Thread.sleep(10000);
taskRunnable.cancel();

}catch (InterruptedException e){
throw new RuntimeException(e);
}
}

static class TaskRunnable implements Runnable {

boolean cancel;

@Override
public void run() {
while (true) {
if (cancel){
break;
}

try{
Thread.sleep(1000);
}catch (InterruptedException e){
throw new RuntimeException(e);
}

System.out.println("running");
}
}

public void cancel() {
cancel = true;
}
}
  • 实现 Callable 接口
    • 做法:实现 call() 方法,配合 FutureTask 或线程池使用。
    • 核心区别call() 方法有返回值允许抛出异常,而 run() 不行。

我们来实现一个Callable 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class CallableBaseDemo {
public static void main(String[] args) throws Exception {
// 1. 定义任务:实现 Callable 接口,泛型 String 是返回值的类型
Callable<String> myTask = new Callable<String>() {
@Override
public String call() throws Exception {
Thread.sleep(2000); // 模拟耗时操作
return "任务执行完毕:这是从子线程返回的结果!";
}
};

// 2. 包装任务:由于 Thread 构造方法只接收 Runnable,
// 我们需要用 FutureTask 包装一下。FutureTask 实现了 Runnable 接口。
FutureTask<String> futureTask = new FutureTask<>(myTask);

// 3. 启动线程
Thread thread = new Thread(futureTask);
thread.start();

System.out.println("主线程:已经启动子线程,我现在可以做点别的事...");

// 4. 获取结果:get() 方法会阻塞当前线程,直到子线程执行完毕并返回结果
String result = futureTask.get();
System.out.println("主线程收到的结果: " + result);
}
}

综上,三种实现方式的区别在于:

特性 Thread Runnable Callable
继承性 限制单继承 无限制 无限制
返回值 有 (Future获取)
异常抛出 只能内部捕获 只能内部捕获 允许 throws 抛出

线程的生命周期与状态转换 (State Machine)

Java 线程在生命周期内并不是一直占着 CPU 不放,它会在多种状态间切换。通过 thread.getState() 可以获取以下六种状态:

  1. NEW (新建)new Thread() 之后,调用 start() 之前。

  2. RUNNABLE (可运行):调用了 start()。在 Java 中,就绪(Ready)和运行中(Running)统称为“可运行”。

  3. BLOCKED (阻塞):等待获取 synchronized 锁。

  4. WAITING (等待):调用了 wait()join()LockSupport.park()。需要被其他线程显式唤醒。

  5. TIMED_WAITING (超时等待):调用了 sleep(ms)wait(ms) 等。时间到了自动苏醒。

  6. TERMINATED (终止)run() 方法执行完毕或因异常退出。

基础控制方法

这一节介绍的是 Java Thread 类中提供的几个核心控制方法,它们决定了线程在执行过程中的行为。

1. sleep(long millis)

sleep 是静态方法,使当前线程进入 TIMED_WAITING 状态,暂停执行一段时间。

  • 锁的行为:线程在睡眠时不会释放它持有的任何锁(Monitor Lock)。
  • 异常处理:睡眠中的线程如果被中断,会抛出 InterruptedException
1
2
3
4
5
try {
Thread.sleep(1000); // 暂停1秒
} catch (InterruptedException e) {
// 线程在休眠中被中断时的处理逻辑
}
2. yield()

yield 也是静态方法,它表示当前线程愿意让出 CPU 的使用权。

  • 调度结果:调用后,线程从“运行中”变为“就绪”状态。
  • 局限性:这只是一个“建议”,线程调度器可能会忽略它。如果当前没有其他同优先级的线程,该线程可能立即再次获得 CPU。
  • 锁的行为:同样不会释放锁。
3. join()

join 是成员方法,用于同步多个线程的执行顺序。

  • 作用:如果在线程 A 中调用 B.join(),则 A 线程会进入 WAITING 状态,直到 B 线程执行完毕。
  • 原理:底层调用的是 Object.wait() 方法,因此调用者会释放掉 B 线程对象的锁。
1
2
3
Thread t1 = new Thread(() -> { /* 耗时任务 */ });
t1.start();
t1.join(); // 主线程在此阻塞,直到 t1 彻底运行结束
4. interrupt()

Java 采用的是协作式的中断机制,而不是暴力停止。

  • interrupt():将目标线程的中断标志位设为 true
  • isInterrupted():实例方法,判断线程是否被中断。
  • 静态 interrupted():检查当前线程的中断状态,并清除标志位。
  • 特殊情况:如果线程正阻塞在 sleepwaitjoin 等方法上,interrupt() 会使这些方法抛出异常,并自动清除中断状态(变回 false)。
1
2
3
while (!Thread.currentThread().isInterrupted()) {
// 执行业务逻辑
}
5. 守护线程 (Daemon Thread)

Java 中的线程分为“用户线程”和“守护线程”

  • 定义:守护线程是为用户线程服务的(如 GC 垃圾回收)。
  • 退出机制:当 JVM 中所有的用户线程都运行结束时,守护线程会随 JVM 一起直接退出,无论其任务是否完成。
  • 限制:必须在 thread.start() 之前调用 setDaemon(true)

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args){  
Thread daemonThread = new Thread() {
@Override
public void run(){
super.run();
System.out.println(getName() + " " + System.currentTimeMillis());
}
}
};

daemonThread.setName("B");
daemonThread.setDaemon(true);

daemonThread.start();
}

第二章:线程安全与内存模型 (Safety & JMM)

线程安全的核心在于:当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。

什么是线程安全?

要实现线程安全,必须保证并发编程的三大特性:

  1. 原子性 (Atomicity):一个或多个操作,要么全部执行且在执行过程中不被任何因素打断,要么全部不执行。

    • 典型问题i++ 包含读、加、写三步,在多线程下不具备原子性。
  2. 可见性 (Visibility):当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

    • 原因:JMM 规定线程有工作内存,修改变量后不会立即同步到主内存。
  3. 有序性 (Ordering):程序执行的顺序按照代码的先后顺序执行。

    • 原因:编译器和处理器为了优化性能,往往会对指令进行重排序

synchronized 关键字

synchronized 是一种互斥锁,它通过控制多个线程对共享资源的访问,一次只允许一个线程执行,从而同时保证了原子性、可见性和有序性。

synchronized 底层依赖 JVM 的 Monitor(监视器锁)。编译后会生成 monitorentermonitorexit 指令。每个对象头中都有 Mark Word,记录了锁的状态(偏向锁、轻量级锁、重量级锁)。

下面是两个不同的应用:

synchronized 方法
在一个方法上使用 synchronized,可以保证同一时刻只能有一个线程访问该方法。这对于涉及共享资源的操作很有用,防止多个线程同时修改资源导致状态不一致。

1
2
3
4
public synchronized void someMethod() {
// 同步的代码块
// 执行一些对共享资源的操作
}

synchronized 代码块
有时候我们并不需要整个方法都保持同步,而是只需要某部分代码在执行时是互斥的。此时,我们可以使用 synchronized 代码块。通过指定一个对象锁来控制代码块的同步。

1
2
3
synchronized (锁对象) {
// 需要同步的代码
}
  • 锁对象:它可以是任意对象,通常是一个共享对象。synchronized 关键字会将该对象作为锁,只允许一个线程在同一时间进入该代码块。

  • 如果多个线程试图同时进入该同步代码块,它们必须等待其他线程释放该锁。

  • 方法级锁:当你用 synchronized 修饰一个方法时,锁的粒度是方法级别的。只有一个线程能访问方法中的同步部分。

  • 代码块级锁:使用 synchronized 代码块时,锁的粒度是代码块级别的。它只锁定特定的资源,而不是整个方法,这样可以提高性能,因为不必要的代码部分不会被同步。

wait() 方法使当前线程进入 等待状态,直到另一个线程发出通知(通过 notify()notifyAll())。当线程调用 wait() 时,它会释放当前持有的对象锁,并且进入 等待队列,直到被其他线程唤醒。

wait() 必须在 同步代码块同步方法 中调用,因为它需要获得对象的监视器锁(即 synchronized 锁)。当线程在同步方法或同步代码块中调用 wait() 时,它会释放对象的锁,允许其他线程获得锁。

1
2
3
4
5
6
synchronized (someObject) {
while (conditionNotMet) {
someObject.wait(); // 线程在条件不满足时等待
}
// 执行后续操作
}

notify() 方法唤醒 一个等待中的线程。当线程调用 notify() 时,它会从等待队列中随机选择一个线程,并将其唤醒,唤醒后的线程会尝试重新获取对象锁,获得锁后才会继续执行。

  • notify() 必须在 同步代码块同步方法 中调用。
  • 被唤醒的线程不会立即执行,而是进入 可运行状态,等待获取对象锁。

notifyAll() 方法唤醒 所有等待中的线程。与 notify() 不同的是,notifyAll() 会唤醒所有等待该对象锁的线程。所有唤醒的线程会争夺锁,只有获得锁的线程才会继续执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class SharedBuffer {
private int data = -1;

// 生产者
public synchronized void produce(int value) throws InterruptedException {
while (data != -1) { // 如果数据已存在,生产者等待
wait();
}
data = value; // 生产数据
System.out.println("Produced: " + value);
notify(); // 唤醒消费者
}

// 消费者
public synchronized void consume() throws InterruptedException {
while (data == -1) { // 如果数据为空,消费者等待
wait();
}
System.out.println("Consumed: " + data);
data = -1; // 消费数据
notify(); // 唤醒生产者
}
}

public class WaitNotifyExample {
public static void main(String[] args) {
SharedBuffer buffer = new SharedBuffer();

// 生产者线程
Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 10; i++) {
buffer.produce(i);
Thread.sleep(100); // 模拟生产的时间
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});

// 消费者线程
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 10; i++) {
buffer.consume();
Thread.sleep(150); // 模拟消费的时间
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});

producer.start();
consumer.start();
}
}

volatile 关键字

volatile 是 Java 提供的最轻量级的同步机制,它保证了可见性有序性,但不保证原子性

  • 可见性原理:写一个 volatile 变量时,JMM 会把该线程对应的工作内存值立即刷新到主内存;读时,会使本地内存失效,直接从主内存读取。
  • 禁止重排序:通过插入内存屏障,确保编译器不会为了优化而改变指令顺序。

为了举例,我们介绍java中的双重检查锁定(Double-Checked Locking,DCL),主要是为了解决单例模式(Singleton)在多线程环境下的性能和安全平衡问题。

如果没有 DCL,我们最简单的同步写法是:

1
2
3
4
public synchronized static Singleton getInstance() {
if (instance == null) instance = new Singleton();
return instance;
}

每次调用 getInstance() 都要加锁,而实际上只有第一次创建实例时才需要同步,后续读取直接返回即可。加锁是非常重的操作,这会严重拖慢性能。那么,我们可以先判断是否为 null,如果不为 null 直接返回;如果为 null,再进入同步块创建对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {
// 必须加 volatile,防止指令重排导致拿到尚未初始化的对象
private static volatile Singleton instance;

public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
// 这里包含三步:1.分配空间 2.初始化 3.指向地址
// 重排序可能导致:1 -> 3 -> 2,使得其他线程拿到非 null 但未初始化的对象
}
}
}
return instance;
}
}

我们来看 原来的instance = new Singleton(); 这一行。在 JVM 层面,它其实被分成了 3 个步骤执行:

  1. 分配内存空间(给这个对象找块地方)。
  2. 初始化对象(执行构造函数里的逻辑)。
  3. 将 instance 变量指向分配的内存地址(此时 instance 不再是 null 了)。

问题出在编译器的优化上: 由于步骤 2 和步骤 3 之间没有必然的依赖关系,编译器或 CPU 可能会为了提高效率进行指令重排序,把顺序变成: 1(分配内存) -> 3(指向地址) -> 2(初始化对象)

这会导致下面发生的灾难场景:

  1. 线程 A 进入同步块,执行 instance = new Singleton()
  2. 由于重排序,线程 A 先执行了步骤 3(指向地址),此时 instance 已经不是 null 了,但步骤 2(初始化)还没完成
  3. 就在这一瞬间,线程 B 调用 getInstance(),它判断 instance != null,于是开心地直接把这个 instance 拿去用了。
  4. 结果线程 B 在使用对象时,发现由于还没初始化,内部属性全是空的,直接报出 空指针异常(NPE) 或逻辑错误。

instance 变量上加上 volatile 关键字后,会发挥两个核心作用:

  1. 禁止指令重排序:它会插入一个“内存屏障”,强制保证执行顺序必须是 1 -> 2 -> 3。这样当线程 B 拿到对象时,它一定已经是初始化完成的。
  2. 保证可见性:一旦线程 A 完成了对象的创建,线程 B 能够立即从主内存中看到 instance 的最新值。

原子类 Atomic

对于简单的数值运算,使用 synchronized 太重,频繁的线程上下文切换会损耗性能。Atomic 系列工具类(如 AtomicInteger)采用 CAS (Compare And Swap) 机制实现。

  • CAS 原理:包含三个参数:变量内存地址 (V)、预期原值 (A) 和新值 (B)。
    • 只有当 V 的实际值等于 A 时,才将 V 修改为 B。否则说明变量已被修改,当前线程会通过自旋(死循环)不断尝试,直到成功。

从概念上来说,原子变量 = 操作不可被打断的变量
对它的操作要么全部执行完,要么完全不执行,不会被线程切换中断,因此天然线程安全,不需要 synchronized。

对于普通变量操作,如i++,看起来是一句,但实际上包含三个步骤:

  1. 读取 i
  2. i + 1
  3. 写回 i

如果两个线程同时执行,就可能出现:

  • 两个线程都读到旧值
  • 最终只加一次

这就出现了典型的 竞态条件(Race Condition)
举个简单的例子

1
2
3
4
5
private AtomicInteger count = new AtomicInteger(0);

public void increment() {
count.incrementAndGet(); // 等同于非原子的 count++
}

线程封闭 (ThreadLocal)

如果说锁是解决“如何同步访问共享数据”,那么 ThreadLocal 则是“如何不共享数据”。它为每个线程提供一份独立的变量副本,实现线程间的数据隔离。

  • 核心用途:解决数据库连接、Session 管理等场景下的线程安全问题。
    举个简单的例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ThreadLocalDemo {
private static final ThreadLocal<Integer> localValue = ThreadLocal.withInitial(() -> 0);

public static void main(String[] args) {
new Thread(() -> {
localValue.set(10);
System.out.println(Thread.currentThread().getName() + " : " + localValue.get());
}, "Thread-A").start();

new Thread(() -> {
localValue.set(20);
System.out.println(Thread.currentThread().getName() + " : " + localValue.get());
}, "Thread-B").start();
}
}

第三章:JUC 显式锁 (Explicit Locks)

在 Java 并发工具包(JUC)中,java.util.concurrent.locks 提供了比 synchronized 更灵活、更强大的锁定机制。synchronized 又被称为内置锁,其不需要程序员过多的手动操作,而基于Lock接口实现的为显式锁,需要程序员一些手动操作才能执行。

Lock 接口与 ReentrantLock

ReentrantLock 是最常用的显式锁,它不仅完全覆盖了 synchronized 的功能,还提供了许多高级特性:

  • 手动规范:使用显式锁必须遵循特定的模板。由于它不会自动释放锁,为了防止死锁,必须finally 块中调用 unlock()
1
2
3
4
5
6
7
8
Lock lock = new ReentrantLock();

lock.lock();
try {
// 临界区
} finally {
lock.unlock();
}
  • 可重入性:同一个线程可以多次获得同一把锁,内部通过计数器实现。

  • 可中断性:使用 lockInterruptibly() 获取锁时,如果线程正在等待锁,它可以响应中断信号并停止等待。

  • 公平锁与非公平锁:构造函数支持传入 boolean 值。公平锁会严格按照线程排队的顺序给锁,而非公平锁(默认)允许插队,通常拥有更高的吞吐量。

1
Lock lock = new ReentrantLock(true); // 公平锁
  • 可轮询获取锁(tryLock): 线程可以“试一试”拿锁,拿不到直接走,不会阻塞。
1
2
3
4
5
6
7
8
9
if (lock.tryLock(2, TimeUnit.SECONDS)) {
try {
// 拿到锁才执行
} finally {
lock.unlock();
}
} else {
// 拿不到锁,执行别的逻辑
}

举个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
Lock lock = new ReentrantLock();
lock.lock(); // 阻塞式获取
try {
// 临界区代码
} finally {
lock.unlock(); // 必须在 finally 中释放
}

// 尝试获取锁:拿不到就走,不阻塞
if (lock.tryLock()) {
try { /* 业务逻辑 */ } finally { lock.unlock(); }
}

ReadWriteLock 读写锁

在很多场景下,数据是“读多写少”的。如果用普通的互斥锁,多个线程同时读也会互相排队,效率低下。ReentrantReadWriteLock 实现了“读写分离”。读写锁是对普通互斥锁的升级,允许多个线程同时读,但写操作只能有一个,并且写时不能读,即:

  • 读读不互斥(并发度高)
  • 读写互斥
  • 写写互斥

ReentrantReadWriteLock

1
2
3
ReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();

多个线程可以同时拿:

1
2
3
4
5
6
readLock.lock();
try {
// 读取共享数据
} finally {
readLock.unlock();
}

只有一个线程能拿:

1
2
3
4
5
6
writeLock.lock();
try {
// 修改共享数据
} finally {
writeLock.unlock();
}

下面是一个简单的读写锁的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class ReentrantReadWriteLockDEMO {  
private final Map<String, String> map = new HashMap<>();
private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private final Lock readLock = readWriteLock.readLock();
private final Lock writeLock = readWriteLock.writeLock();

public String get(String key) {
readLock.lock();
try{
String val = map.get(key);
System.out.println(Thread.currentThread().getName() + " get " + key + " " + val);
return val;
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
readLock.unlock();
}
}

public void put(String key, String val) {
writeLock.lock();
try{
map.put(key, val);
System.out.println(Thread.currentThread().getName() + " put " + key + " " + val);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
writeLock.unlock();
}
}

public static void main(String[] args) throws InterruptedException {
ReentrantReadWriteLockDEMO cache = new ReentrantReadWriteLockDEMO();

Thread t1 = new Thread(() -> {
while (true) {
String name = cache.get("name");
try { Thread.sleep(100); } catch (Exception ignored) {}
}
});

Thread t2 = new Thread(() -> {
while (true) {
String name = cache.get("name");
try { Thread.sleep(100); } catch (Exception ignored) {}
}
});

Thread t3 = new Thread(() -> {
int cnt = 0;
while (true) {
cache.put("name", "value-" + (cnt++));
try { Thread.sleep(500); } catch (Exception e) {}
}
});

t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
}
}

Condition 机制

Condition 是为了配合 Lock 使用的,它用来替代 Object.wait()/notify()。它的最大优势在于:一个锁可以关联多个 Condition(条件变量)

synchronized 中,所有的线程都挤在一个等待队列里,调用 notifyAll() 会唤醒所有人。而 Condition 可以实现“精准唤醒”。

在 synchronized 里我们用:
• wait()
• notify()
• notifyAll()

在 显式锁(ReentrantLock) 中,我们不能再用它们,要用:
• await() 代替 wait()
• signal() 代替 notify()
• signalAll() 代替 notifyAll()

一个典型的应用场景是生产消费者模型, 我们可以创建两个条件:notFull(不满)和 notEmpty(不空)。生产者只唤醒等待 notEmpty 的消费者,互不干扰。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Depot {
private int count = 0;
private final int MAX = 10;

private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Condition notFull = lock.newCondition();

public void put() {
lock.lock();
try {
while (count == MAX) {
notFull.await(); // 等待仓库有空位
}
count++;
System.out.println("生产,总数 = " + count);
notEmpty.signal(); // 唤醒等待取货的线程
} finally {
lock.unlock();
}
}

public void take() {
lock.lock();
try {
while (count == 0) {
notEmpty.await(); // 等待仓库非空
}
count--;
System.out.println("消费,总数 = " + count);
notFull.signal(); // 唤醒等待生产的线程
} finally {
lock.unlock();
}
}
}

锁的降级 (写锁 -> 读锁)

锁降级是指:线程持有写锁 -> 获取读锁 -> 释放写锁

这么做主要是为了保证数据的可见性原子性。如果你先释放写锁再获取读锁,在中间的空隙,数据可能被其他线程修改了。通过锁降级,线程可以无缝地从“修改者”切换为“观察者”,且在这个过程中,数据不会被他人篡改。

下面是一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
writeLock.lock();
try {
// 1. 修改数据
// 2. 降级:在释放写锁前先获取读锁
readLock.lock();
} finally {
writeLock.unlock(); // 3. 释放写锁,此时依然持有读锁
}

try {
// 4. 使用数据
} finally {
readLock.unlock(); // 5. 最后释放读锁
}

第四章:并发协作工具类 (Synchronization Tools)

JUC(Java 并发工具包)除了提供锁之外,还提供了一系列非常有用的协作工具。如果说锁是解决资源竞争的,那么这些工具就是解决线程配合的。

倒计时器 CountDownLatch

CountDownLatch 是一个同步工具类,用来让一个或多个线程等待其他线程完成任务。
就像一个倒计时器:
• 初始有一个计数 count
• 每次调用 countDown(),计数减 1
• 当计数变成 0 时,所有在 await() 等待的线程都会继续执行

典型场景:等待多个任务都完成后继续执行
例如:
• 主线程启动 3 个子线程
• 每个子线程完成后执行 countDown()
• 主线程调用 await(),一直等待所有子线程完成
• 所有子线程完成后,主线程继续执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import java.util.concurrent.CountDownLatch;

public class Demo {
public static void main(String[] args) throws InterruptedException {
int taskCount = 3;
CountDownLatch latch = new CountDownLatch(taskCount);

for (int i = 0; i < taskCount; i++) {
int id = i;
new Thread(() -> {
try {
System.out.println("子线程 " + id + " 开始任务");
Thread.sleep(1000 + id * 500);
System.out.println("子线程 " + id + " 完成任务");
} catch (Exception e) { }
finally {
latch.countDown(); // 计数 -1
}
}).start();
}

System.out.println("主线程等待所有子任务完成...");
latch.await(); // 等待 count 变为 0

System.out.println("所有子任务完成,主线程继续执行");
}
}

循环栅栏 CyclicBarrier

CyclicBarrier 是一种同步屏障,让一组线程必须等到“全部都到达屏障点”,然后同时继续执行。
特点:
• 屏障点(barrier)可以重复使用(循环 cyclic)
• 线程到达 barrier 后会阻塞
• 当所有线程都到达后,自动释放所有线程

假设要 3 个线程同时开始下一步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.concurrent.CyclicBarrier;

public class Demo {
public static void main(String[] args) {
CyclicBarrier barrier = new CyclicBarrier(3);

for (int i = 0; i < 3; i++) {
int id = i;
new Thread(() -> {
try {
System.out.println("线程 " + id + " 到达屏障点");
barrier.await(); // 阻塞,直到 3 个线程都调用
System.out.println("线程 " + id + " 通过屏障,继续执行");
} catch (Exception e) {}
}).start();
}
}
}

输出:

1
2
3
4
5
6
7
线程 0 到达屏障点
线程 2 到达屏障点
线程 1 到达屏障点
(满足3个线程到达)
线程 2 通过屏障,继续执行
线程 1 通过屏障,继续执行
线程 0 通过屏障,继续执行

CyclicBarrier 与 CountDownLatch 的区别

特性 CyclicBarrier CountDownLatch
作用 让多个线程“集合,然后一起继续” 让线程等待其他线程做完某件事
是否可重用 ✔ 可重复使用(cyclic) ❌ 不能重置(一次性)
是否所有线程都会等待 ✔ 是 ❌ 通常只有一个或少量线程等待
调用方法 await() await() + countDown()
谁减计数 每个线程调用 await() 都算一个到达 调用 countDown() 的线程
信号方向 大家互相等待 一些线程等待,另一些负责 countDown

信号量 Semaphore

**Semaphore(信号量)**是Java 并发编程里一个非常核心的同步工具,主要用于控制同时访问某个资源的线程数量。

Semaphore = 一个“许可证”计数器,用来限制并发访问数量。
比如你有 3 台打印机,但有 10 个线程要打印文件。
Semaphore(3) 就相当于发 3 张“许可证”,同时只允许 3 个线程使用打印机。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Semaphore semaphore = new Semaphore(3); // 有 3 个许可证

public void print() {
try {
semaphore.acquire(); // 请求一个许可证,如果没有就阻塞
System.out.println(Thread.currentThread().getName() + " 开始打印...");
Thread.sleep(1000); // 模拟任务
System.out.println(Thread.currentThread().getName() + " 打印结束");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release(); // 释放一个许可证
}
}

Semaphore 常用方法

方法 含义
acquire() 获取一个许可证,没有则阻塞
acquire(int n) 一次获取 n 个许可证
tryAcquire() 尝试获取,不阻塞,获取不到返回 false
release() 释放许可证
availablePermits() 查看剩余许可证数量

Semaphore vs Lock / synchronized

对比对象 Semaphore Lock / synchronized
核心用途 限制并发数量 同一时间只有一个线程进入临界区(互斥)
支持多少线程同时进入 多个(取决于 permits) 只能 1 个
是否可作为锁使用 可以(permits=1 时类似 mutex) 是锁

公平/非公平

1
2
new Semaphore(3, true); // 公平,按排队顺序
new Semaphore(3, false); // 非公平(默认),速度更快

阶段同步 Phaser

Phaser 是 Java 并发里 最灵活、可重用、可动态注册线程的“阶段同步器”
可以理解为:一个可以分成很多阶段(phase)的同步器,每个阶段都要所有“参与者”到齐后才能进入下一阶段,并且参与者数量还可以动态增减。

工具 问题
CountDownLatch 一次性等待,不能复用,不能动态增加线程
CyclicBarrier 可复用,但是参与线程数固定,不能动态变化
Phaser ✔ 可复用 ✔ 可多阶段 ✔ 可动态增加/减少线程 ✔ 更灵活

基础使用:

1
2
3
4
5
6
7
8
9
10
11
12
Phaser phaser = new Phaser(3); // 3 个参与者

for (int i = 0; i < 3; i++) {
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " 执行阶段1");
phaser.arriveAndAwaitAdvance(); // 等别人
System.out.println(Thread.currentThread().getName() + " 执行阶段2");
phaser.arriveAndAwaitAdvance();
System.out.println(Thread.currentThread().getName() + " 执行阶段3");
phaser.arriveAndAwaitAdvance();
}).start();
}

输出为:

1
2
3
4
5
6
T1 执行阶段1
T2 执行阶段1
T3 执行阶段1
--- 阶段1结束 ---
T1 执行阶段2
...

核心方法:

方法 意义
arrive() 到达当前阶段,不等待
arriveAndAwaitAdvance() 到达并等待其他线程(最常用)
arriveAndDeregister() 到达后退出,不再参加后续阶段

phaser可以动态注册线程:

1
phaser.register(); // 新线程加入同步控制

一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Phaser phaser = new Phaser(1); // main 是 1 个参与者

class Player implements Runnable {
Phaser ph;
Player(Phaser ph) { this.ph = ph; ph.register(); }

@Override
public void run() {
System.out.println(Thread.currentThread().getName() + " 准备进入游戏");
ph.arriveAndAwaitAdvance();

System.out.println(Thread.currentThread().getName() + " 加载地图");
ph.arriveAndAwaitAdvance();

System.out.println(Thread.currentThread().getName() + " 加载角色");
ph.arriveAndAwaitAdvance();

System.out.println(Thread.currentThread().getName() + " 完成所有加载");
ph.arriveAndDeregister();
}
}

public static void main(String[] args) {
Phaser ph = new Phaser(1); // main

new Thread(new Player(ph), "玩家1").start();
new Thread(new Player(ph), "玩家2").start();

ph.arriveAndAwaitAdvance(); // 主线程同步各阶段
ph.arriveAndAwaitAdvance();
ph.arriveAndAwaitAdvance();
}

数据交换 Exchanger

Exchanger 用于两个线程之间的数据交换。它提供一个同步点,在这个点,两个线程可以交换彼此的数据。

  • 核心逻辑:当一个线程调用 exchange() 时,它会阻塞直到第二个线程也调用该方法。然后两个线程交换数据并返回。
  • 特性:只能成对使用(2个线程)。
  • 场景:两个流水线环节的数据传递,或者遗传算法中的交叉操作。

一个简单的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Exchanger<String> exchanger = new Exchanger<>();

new Thread(() -> {
try {
String data = "A线程数据";
String result = exchanger.exchange(data);
System.out.println("A 得到:" + result);
} catch (Exception ignored) {}
}).start();

new Thread(() -> {
try {
String data = "B线程数据";
String result = exchanger.exchange(data);
System.out.println("B 得到:" + result);
} catch (Exception ignored) {}
}).start();

第五章:线程池架构 (Executor Framework)

在实际生产开发中,我们几乎从不手动 new Thread()。为了节省系统资源并提高响应速度,我们会使用线程池来管理线程。

为什么要用线程池?

创建一个 Java 线程需要调用操作系统的内核 API,这是一个昂贵的操作。

  • 降低资源消耗:通过池化技术重复利用已创建的线程,减少线程创建和销毁的性能开销。
  • 提高响应速度:任务到达时,无需等待线程创建即可立即执行。
  • 提高线程的可管理性:线程是稀缺资源,使用线程池可以统一分配、调优和监控,防止无限制创建线程导致系统崩溃(OOM)。

ThreadPoolExecutor 核心参数

所有的线程池底层都是 ThreadPoolExecutor,包含七个核心的参数:

参数 名称 作用
corePoolSize 核心线程数 线程池中常驻的“保底”线程数量,即使它们闲着也不会被销毁。
maximumPoolSize 最大线程数 线程池允许创建的最大线程总量。
keepAliveTime 存活时间 当线程数大于核心线程数时,多余的空闲线程能活多久。
unit 时间单位 keepAliveTime 的时间单位(秒、毫秒等)。
workQueue 任务队列 存放待执行任务的阻塞队列(BlockingQueue)。
threadFactory 线程工厂 用于创建新线程,通常在这里给线程起个有意义的名字。
handler 拒绝策略 当队列满了且线程数达到最大值时,如何处理新来的任务。

线程池工作流程与拒绝策略

线程池的处理逻辑遵循“先核心,再队列,后最大”的原则:

  1. 提交任务:如果当前线程数 < corePoolSize,直接创建新线程执行。
  2. 进入队列:如果线程数 ≥ corePoolSize,任务进入 workQueue 排队。
  3. 尝试扩容:如果队列满了且线程数 < maximumPoolSize,创建非核心线程执行。
  4. 触发拒绝:如果队列满了且线程数 = maximumPoolSize,执行拒绝策略。

JDK 自带的四种拒绝策略:

  • AbortPolicy(默认):直接抛出 RejectedExecutionException 异常,阻止系统正常工作。
  • CallerRunsPolicy:“谁提交谁执行”。让提交任务的线程(比如主线程)自己去跑这个任务,从而降低任务提交速度。
  • DiscardPolicy:直接丢弃任务,不予任何处理也不报错。
  • DiscardOldestPolicy:丢弃队列里最老的一个任务,尝试再次提交当前任务。

工厂类 Executors

常用方法:
• Executors.newFixedThreadPool(n):固定大小线程池
• Executors.newCachedThreadPool():弹性线程池
• Executors.newSingleThreadExecutor():单线程
• Executors.newScheduledThreadPool(n):定时任务线程池

ExecutorService(线程池接口)
1
2
3
4
5
6
7
8
9
10
11
Executor

├── ExecutorService
│ │
│ └── AbstractExecutorService
│ │
│ └── ThreadPoolExecutor ← 普通线程池核心

└── ScheduledExecutorService

└── ScheduledThreadPoolExecutor ← 定时任务线程池核心

常用方法:
• submit():提交任务
• shutdown():优雅关闭
• shutdownNow():强制终止

ScheduledExecutorService

用于执行定时任务、周期任务。
包含:
schedule()
scheduleAtFixedRate()
scheduleWithFixedDelay()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.util.concurrent.*;

public class ScheduledExample {
public static void main(String[] args) {

// 创建一个具有2个线程的定时任务线程池
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);

// 1. 延迟 3 秒执行一次任务
scheduler.schedule(() -> {
System.out.println("任务 A:延迟 3 秒执行 -> " + System.currentTimeMillis());
}, 3, TimeUnit.SECONDS);

// 2. 每隔 2 秒执行一次任务(固定频率)
scheduler.scheduleAtFixedRate(() -> {
System.out.println("任务 B:每隔 2 秒执行 -> " + System.currentTimeMillis());
}, 1, 2, TimeUnit.SECONDS);

// 程序不退出,保持执行
// 若想停止,稍后调用 scheduler.shutdown();
}
}

ForkJoinPool (分治思想)

ForkJoinPool 是一种专门用于“分而治之(Fork)+ 合并结果(Join)”的高效并行计算线程池,它用来处理大量可以拆分的任务,自动进行任务窃取(work-stealing)以提升 CPU 利用率。

你一般只需要继承:
• RecursiveAction(void 任务)
• RecursiveTask(有返回值任务)

大数组求和

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import java.util.concurrent.*;

public class SumTask extends RecursiveTask<Long> {

private static final int THRESHOLD = 10000;
private long[] arr;
private int start, end;

public SumTask(long[] arr, int start, int end) {
this.arr = arr;
this.start = start;
this.end = end;
}

@Override
protected Long compute() {
int length = end - start;
if (length <= THRESHOLD) { // 小任务直接算
long sum = 0;
for (int i = start; i < end; i++) sum += arr[i];
return sum;
}

int mid = start + length / 2;

SumTask left = new SumTask(arr, start, mid);
SumTask right = new SumTask(arr, mid, end);

// 分裂
left.fork();
right.fork();

// 合并
return left.join() + right.join();
}

public static void main(String[] args) {
ForkJoinPool pool = new ForkJoinPool();

long[] arr = new long[1000000];
for (int i = 0; i < arr.length; i++) arr[i] = i;

long result = pool.invoke(new SumTask(arr, 0, arr.length));

System.out.println("总和:" + result);
}
}

Future

Future 表示一个异步计算的结果,你可以在提交任务后立即得到 Future 对象,以后再从这个对象中取结果。
其接口为:

1
2
3
4
5
6
7
8
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException, ExecutionException;
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutException;
}

future的最常见来源:线程池的 submit() 方法。

1
2
3
4
5
ExecutorService pool = Executors.newFixedThreadPool(3);

Future<Integer> future = pool.submit(() -> {
return 10;
});

得到的 future 就是异步结果句柄。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ExecutorService pool = Executors.newFixedThreadPool(2);

Future<String> future = pool.submit(() -> {
// 模拟耗时任务
Thread.sleep(2000);
return "任务完成";
});

System.out.println("我先做点别的事...");

// 得到结果(阻塞)
String result = future.get();

System.out.println(result);

输出:

1
2
我先做点别的事...
任务完成

因此,future可以 通过 get() 方法获取异步执行的结果(阻塞等待)。
同时,也可以通过cancel取消任务,通过isDone判断任务是否完成,通过get(timeout)等待固定时间,如果任务还没完成,则抛 TimeoutException。

第六章:异步编程 (Asynchronous Programming)

Future 的局限性

虽然 Future 开启了异步执行的先河,但它在处理复杂业务逻辑时非常笨拙:

  • 阻塞等待:要获取结果只能调用 get(),如果子线程没跑完,主线程必须在此阻塞,这实际上把异步变成了同步等待。
  • 轮询耗能:如果不阻塞,就得用 isDone() 开启死循环轮询,极度消耗 CPU。
  • 无法回调:无法表达“任务完成后自动执行下一步”的逻辑。
  • 编排困难:很难将多个 Future 的结果组合在一起,代码会嵌套得非常难看(回调地狱)。

CompletableFuture

JDK 8 引入的 CompletableFuture 是异步编程的里程碑。它实现了 FutureCompletionStage 两个接口,支持任务的回调、组合和链式编排。

异步任务的启动与执行策略

任务的启动通常依赖于静态工厂方法。supplyAsync 用于启动具有返回值的异步计算,而 runAsync 则适用于仅需执行特定动作且无需返回结果的场景。在资源调度层面,若不指定 Executor,这些任务默认由 ForkJoinPool.commonPool() 承载,但在生产环境下,通常建议传入自定义线程池以实现业务间的资源隔离与监控。

异步链路的回调处理机制

在任务完成后,CompletableFuture 支持通过链式调用定义回调逻辑。thenApply 方法能够获取前一阶段的执行结果并对其进行转换,产生一个新的返回值,从而构建出流水线式的处理链路。与之相对,thenAccept 仅用于消费前序任务的结果而不产生新的输出,而 thenRun 则完全忽略结果,仅在任务完成这一特定时机触发后续动作。

多任务的逻辑编排与协同

针对复杂的并发场景,CompletableFuture 提供了强大的编排能力。对于存在先后依赖关系的串行任务,thenCompose 可以将前一个任务的输出平滑传递给下一个异步任务;而对于互不依赖、需并行执行的任务,thenCombine 支持在两个任务全部完成后对其结果进行聚合。在更宏观的层面,allOf 提供了阻塞式同步机制,确保所有并行的异步任务全部完成后再向下执行,而 anyOf 则实现了竞争逻辑,只要其中任意一个任务率先完成即返回其结果。

异步链路的异常捕获与容错

异步编程中的异常传播与传统同步代码有所不同,需要专门的兜底机制。exceptionally 方法类似于同步代码中的 catch 块,能够在链路出现异常时拦截错误并返回一个预设的默认值,确保后续链路不会因未捕获的异常而中断。此外,handle 方法提供了更全面的控制,无论前序任务是正常结束还是抛出异常,它均能接收到执行结果或异常对象,并进行统一的处理与转换。

举一个链式流水线的例子

1
2
3
4
5
6
7
8
9
CompletableFuture.supplyAsync(() -> "查询商品ID:123")
.thenApply(id -> "获取商品价格: $99") // 链式处理
.thenCombine(CompletableFuture.supplyAsync(() -> "获取优惠券: -$10"),
(price, coupon) -> price + " " + coupon) // 组合两个异步任务
.thenAccept(finalResult -> System.out.println("最终报价: " + finalResult))
.exceptionally(e -> {
System.err.println("出错: " + e.getMessage());
return null;
});

CompletionService

CompletionService<V> 是一个接口,用于 提交异步任务并按完成顺序获取结果。它结合了 Executor(线程池)和 BlockingQueue 的功能。任务完成顺序与提交顺序可能不同,使用 CompletionService 可以 按照完成顺序 获取任务结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.concurrent.*;

public class CompletionServiceExample {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(3);
CompletionService<String> service = new ExecutorCompletionService<>(pool);

// 提交任务
service.submit(() -> { Thread.sleep(300); return "Task 1"; });
service.submit(() -> { Thread.sleep(100); return "Task 2"; });
service.submit(() -> { Thread.sleep(200); return "Task 3"; });

// 按完成顺序获取结果
for (int i = 0; i < 3; i++) {
Future<String> future = service.take(); // 阻塞直到有任务完成
System.out.println(future.get());
}

pool.shutdown();
}
}

第七章:并发容器 (Concurrent Collections)

ConcurrentHashMap 原理

ConcurrentHashMap 是为了解决 Hashtable 全局锁定导致并发效率低下的问题而设计的。在 Java 8 及其后续版本中,它舍弃了早期版本中的分段锁(Segment)设计,转而采用 Node 数组 + 链表 + 红黑树 的结构,并结合 CAS(Compare-And-Swap)synchronized 实现更细粒度的锁控制。

在执行读操作时,ConcurrentHashMap 完全不加锁,通过 volatile 关键字保证了数据的可见性,这使得它在读取密集的场景下表现极佳。在执行写操作时,它仅锁定当前正在操作的哈希桶(即 Node 数组的头节点),而不是整个 Map。这种“局部锁定”的策略允许多个线程同时对不同的哈希桶进行写入。此外,当链表长度超过阈值(默认为 8)且数组容量大于 64 时,链表会转化为红黑树,以保证在高碰撞情况下的查询效率仍能维持在 O(logn)O(\log n)

CopyOnWriteArrayList

CopyOnWriteArrayList 采用了一种极其独特的“写时复制”(Copy-On-Write)策略。它的核心思想是:当你对容器进行修改(添加、删除、设置元素)时,它并不直接修改当前数组,而是先将原数组拷贝一份副本,在副本上完成修改操作,最后再将原数组的引用指向这个新副本。

这种机制带来了显著的特性:读操作完全无锁且不阻塞。当一个线程在遍历列表时,即使有另一个线程正在进行修改,遍历线程看到的依然是修改前的旧数组镜像,因此它天然地解决了并发修改异常(ConcurrentModificationException)。然而,这种设计也存在明显的权衡,即每次修改都会导致数组拷贝,内存开销较大。因此,它仅适用于读多写极少且对实时性要求不是极高的场景(例如配置白名单或监听器列表)。

1
2
3
4
5
6
// 写操作示例:内部会加锁并复制数组
List<String> list = new CopyOnWriteArrayList<>();
list.add("data");

// 读操作示例:直接读取引用,性能极高
String val = list.get(0);

阻塞队列 BlockingQueue 全家桶

BlockingQueue 是实现“生产者-消费者”模型的灵魂组件。它与普通队列的区别在于支持阻塞机制:当队列为空时,尝试取出的线程会被阻塞直到有数据可用;当队列满时,尝试存入的线程会被阻塞直到队列出现空位。

JUC 提供了多种不同特性的实现类。ArrayBlockingQueue 基于定长数组,强制要求设置初始容量,适合任务量稳定的系统。LinkedBlockingQueue 基于链表,默认容量为 Integer.MAX_VALUE,如果不设限可能会导致内存溢出。SynchronousQueue 则更为特殊,它不存储任何元素,每一个插入操作必须等待另一个线程的移除操作,是线程间“手递手”传递数据的桥梁。此外,还有支持优先级的 PriorityBlockingQueue 和处理延迟任务的 DelayQueue。这些队列通过内部的 ReentrantLockCondition 机制,优雅地解决了线程间的同步调度问题。

第八章 死锁

在多线程环境下,死锁(Deadlock) 是最令人头疼的并发问题之一。它发生在两个或多个线程互相持有对方所需的资源,导致所有相关线程都进入无限期的等待状态。

死锁的四大必要条件

根据经典操作系统理论,死锁的发生必须同时满足以下四个条件。如果能破坏其中任何一个,死锁就不会发生:

  1. 互斥条件(Mutual Exclusion)
    资源是独占的。在某一时刻,一个资源(如一个对象锁)只能被一个线程持有。如果其他线程请求该资源,必须等待直到持有者释放。
  2. 占有且等待(Hold and Wait)
    一个线程已经持有了至少一个资源,但又提出了新的资源请求,而该资源正被其他线程占有。此时请求线程阻塞,但对自己已获得的资源保持不放。
  3. 不可剥夺(No Preemption)
    线程已获得的资源在未使用完之前,不能被其他线程强行夺走,只能由该线程在完成任务后主动释放。
  4. 循环等待(Circular Wait)
    存在一个线程的资源循环链。例如,线程 T1T1 等待 T2T2 占有的资源,T2T2 等待 T3T3 的资源……最后 TnTn 又在等待 T1T1 占有的资源。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Object lockA = new Object();
Object lockB = new Object();

// 线程 t1:先拿 A,再拿 B
Thread t1 = new Thread(() -> {
synchronized (lockA) {
try { Thread.sleep(100); } catch (InterruptedException e) {} // 确保 t2 拿到了 B
synchronized (lockB) {
System.out.println("Thread 1 acquired both locks");
}
}
});

// 线程 t2:先拿 B,再拿 A
Thread t2 = new Thread(() -> {
synchronized (lockB) {
try { Thread.sleep(100); } catch (InterruptedException e) {} // 确保 t1 拿到了 A
synchronized (lockA) {
System.out.println("Thread 2 acquired both locks");
}
}
});

t1.start();
t2.start();

在这个例子中,Thread.sleep(100) 是产生死锁的关键催化剂。它保证了 t1 锁住 lockA 的同时,t2 有足够的时间锁住 lockB。此时,t1t1 试图获取 lockB 时会因为“互斥”被阻塞,同时它“占有且等待”着 lockA;t2t2 同理。由于锁是“不可剥夺”的,最终形成了 t1lockBt2lockAt1t1 \rightarrow lockB \rightarrow t2 \rightarrow lockA \rightarrow t1 的循环等待。

如何排查死锁?
当程序突然“卡死”且 CPU 占用率极低时,极有可能是发生了死锁。你可以使用 JDK 自带的工具进行检测:

  1. jcmd / jps:获取运行中的 Java 进程 ID。
  2. jstack <pid>:打印线程堆栈。如果存在死锁,jstack 会在末尾明确提示 Found one Java-level deadlock,并详细列出是哪些线程在哪个对象上互相折磨。

如何预防死锁?

  1. 固定加锁顺序(破坏循环等待):这是最常用的方案。让 t1t2 都按照“先拿 A 再拿 B”的顺序去竞争,这样就不会形成环路。
  2. 尝试性获取锁(破坏不可剥夺):使用 ReentrantLocktryLock(long time, TimeUnit unit) 方法。如果线程在规定时间内拿不到下一把锁,就主动释放已经拿到的锁,退回并重试。
  3. 减小锁粒度:尽量不要在一个同步块中嵌套另一个同步块。

第九章 Thread内存泄漏

在多线程开发中,除了死锁,另一个隐蔽且高发的陷阱是 ThreadLocal 导致的内存泄漏。这通常发生在长生命周期的线程(如线程池中的线程)中。

ThreadLocal 内存泄漏的根源

ThreadLocal 的原理是为每个线程维护一个私有的 ThreadLocalMap。这个 Map 的特殊之处在于它的内部结构:其 Key(ThreadLocal 实例)是弱引用(WeakReference),而 Value(存储的数据)是强引用

这种设计的初衷是为了保护内存:当外部不再持有 ThreadLocal 实例的强引用时,Key 会在下一次 GC 时被回收,变成 null。然而,问题也就此产生。虽然 Key 消失了,但 Value 依然被线程的 ThreadLocalMap 以强引用的方式持有

如果这个线程是普通线程,执行完任务就销毁了,那么整个 Map 都会被回收。但现代应用几乎都在使用线程池。线程池中的线程是高度复用的,这意味着:

  1. 线程永远不会结束。
  2. ThreadLocalMap 里的那块 Value 内存会一直被占用。
  3. 由于 Key 变成了 null,你再也没有办法通过正常途径访问到这块 Value,它成了一块“幽灵内存”。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ThreadLocalLeakDemo {
private static ThreadLocal<byte[]> localData = new ThreadLocal<>();

public static void main(String[] args) {
// 模拟一个固定大小为 5 的线程池
ExecutorService pool = Executors.newFixedThreadPool(5);

for (int i = 0; i < 50; i++) {
pool.execute(() -> {
// 每个任务占用 5MB 内存
localData.set(new byte[1024 * 1024 * 5]);
System.out.println(Thread.currentThread().getName() + " 设置了数据");

// 注意:这里没有调用 localData.remove()
});
}
}
}

在上述代码中,虽然任务执行完了,但由于线程池里的线程没死,每个线程持有的 5MB 字节数组会一直驻留在内存中。如果任务不断提交,内存占用会持续攀升,最终导致 OutOfMemoryError

使用 jvisualvmMAT (Memory Analyzer Tool) 查看堆转储(Heap Dump)。如果你发现 ThreadLocalMap 里的 Entry 数量异常多,且其 value 字段占用了大量内存,基本可以确定是泄漏。

在代码中使用 ThreadLocal 时,务必遵循 try-finally 模式,在 finally 块中调用 remove() 方法。这会清除当前线程 Map 中对应的 Entry。

1
2
3
4
5
6
7
try {
localData.set(value);
// 执行业务逻辑
} finally {
// 关键:任务结束前一定要清理,尤其是在线程池环境下
localData.remove();
}