Java 并发基础(一)

什么是线程?

要讲清楚线程,必须先从进程说起。线程并非独立存在,而是进程内部的一个实体。简单来说,进程是系统进行资源分配(内存、文件句柄、信号处理等)和调度的基本单位。它为程序提供了一个独立的“容器”或“空间”。而线程则是进程中的一条执行路径。一个进程至少包含一个线程(主线程),多个线程共同共享进程所拥有的资源。这就像餐厅和厨师的关系,进程就相当于一家餐厅,而线程就是这家餐厅干活的厨师,一个店里可以只有一个厨师(单线程),也可以雇好几个厨师共享厨房(多线程)。

在操作系统分配资源时,内存等资源是划拨给进程的,但 CPU 资源却很特殊——它是直接分配给线程的。因为真正占用 CPU 运行的是线程,所以线程也被称为 CPU 分配的基本单位。以 Java 为例,当我们启动 main 函数时,实际上是启动了一个 JVM 进程,而 main 函数所在的线程就是该进程中的主线程。

JVM 进程运行时内存结构
线程私有区域 (Private)
Thread-Main
程序计数器
虚拟机栈
Thread-2
程序计数器
虚拟机栈
...
* 每个线程拥有独立的 PC 和 Stack
线程共享区域 (Shared)
堆 (Heap)
存放对象实例,全线程共享。
方法区 (Method Area)
类信息、常量、静态变量。
线程是 CPU 分配的基本单位。

在这个进程内部,所有线程共享堆内存和方法区资源。堆是进程中最大的一块内存,主要存放通过 new 操作创建的对象实例;方法区则用来存放加载的类、常量及静态变量等公共信息。然而,为了保证线程能够独立且正确地运行,每个线程都拥有私有的程序计数器和栈区域。

之所以将程序计数器设计为线程私有,是因为 CPU 通常采用“时间片轮转”方式让线程轮流占用。当一个线程的时间片用完、被迫让出 CPU 时,程序计数器会精准记录下当前的执行地址。待下次重新分配到时间片,线程便能从这个私有计数器指向的位置继续执行,确保了任务的连续性(需注意,执行 Native 方法时计数器记录的是 undefined,只有 Java 代码才会记录指令地址)。同时,每个线程自带的栈资源用于存储私有的局部变量和调用栈帧,这部分数据对其他线程是不可见的,从而保障了线程内部数据的安全与独立。这种 “资源共享、执行隔离” 的架构,既保证了多线程协作的高效性,也通过私有区域的设计维持了复杂并发环境下的运行秩序。

为什么要搞得这么麻烦,不停使用线程进行上下文切换呢?因为它解决了两个 “痛点”:

  • 第一,等待不再是浪费:如果厨师 A 发现排骨得炖半小时,在没有线程的世界里,他必须在那原地发呆,整个厨房就瘫痪了。有了线程,他可以去 “挂起” 等待(wait),让出灶台给厨师 B 去炒个快餐。 本质上,它是为了让 CPU 永远在忙碌,不被慢速的 IO(等快递、等煮饭)给拖累。
  • 第二,共享比搬家更省力:如果我们要再开一个进程,相当于要再盖一个一模一样的厨房,搬入一模一样的食材。而线程只需要多请一个工人,大家共用一个冰箱(堆内存)就行了。所以本质上,它是为了在多个任务之间实现 “极低成本” 的切换和沟通。

综上所述:线程就是操作系统为了解耦 “资源” 与 “执行” 而引入的抽象。它通过私有的程序计数器(记录进度)和私有的栈(记录过程数据),在共享的进程资源(内存空间)之上,构建出了多条可以独立运行的指令轨迹。每一个 Thread 对象在底层都对应着内核里的一个调度实体,它在抢夺 CPU 时间片的同时,始终被限制在进程划定的内存边界内。


线程的创建与运行

Java 中有三种线程创建方式,分别为:

  • 实现 Runnable 接口的 run 方法。
  • 继承 Thread 类 并重写 run 的方法。
  • 使用 FutureTask 方式。

首先来看继承 Thread 类方式的实现:

1
2
3
4
5
6
7
8
9
10
11
public static class MyThread extends Thread {
@Override
public void run() {
System.out.println("hello owlias.");
}
}

public static void main(String[] args) {
MyThread t1 = new MyThread();
t1.start();
}

如上代码中的 MyThread 类继承了 Thread 类,并重写了 run() 方法。在 main 函数里 面创建了一个MyThread的实例,然后调用该实例的start方法启动了线程。需要注意的是, 当创建完 thread 对象后该线程并没有被启动执行,直到调用了 start 方法后才真正启动了线程。其实调用 start 方法后线程并没有马上执行而是处于就绪状态,这个就绪状态是指该 线程已经获取了除 CPU 资源外的其他资源,等待获取 CPU 资源后才会真正处于运行状态。 一旦 run 方法执行完毕,该线程就处于终止状态。

下面看实现 Runnable 接口的 run 方法方式。

1
2
3
4
5
6
7
8
9
10
11
static class MyRunnableTask implements Runnable {
@Override
public void run() {
System.out.println("hello owlias.");
}
}
public static void main(String[] args) {
RunnableTask task = new RunnableTask();
new Thread(task).start();
new Thread(task).start();
}

如上面代码所示,两个线程共用一个 task 代码逻辑,如果需要,可以给 MyRunnableTask 添加参数进行任务区分。另外,MyRunnableTask 可以继承其他类。但是上面介绍的两种方式 都有一个缺点,就是任务没有返回值。下面看最后一种,即使用 FutureTask 的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static class CallableTask implements Callable<String> {
@Override
public String call() throws Exception {
return "hello owlias.";
}
}

public static void main(String[] args) {
FutureTask<String> futureTask = new FutureTask<>(new CallableTask());
new Thread(futureTask).start();
try {
String result = futureTask.get();
System.out.println(result);
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}

如上代码中的 CallableTask 类实现了 Callable 接口的 call() 方法。在 main 函数内首先创 建了一个 FutrueTask 对象(构造函数为 CallableTask 的实例),然后使用创建的 FutrueTask 对象作为任务创建了一个线程并且启动它,最后通过 futureTask.get() 等待任务执行完毕并返回结果。


线程通知与等待

Java 中的 Object 类是所有类的父类,鉴于继承机制,Java 把所有类都需要的方法放到了 Object 类里面,其中就包含本节要讲的通知与等待系列函数。

wait() 函数:

当一个线程调用一个共享变量的 wait() 方法时,该调用线程会被阻塞挂起,直到发生下面几件事情之一才返回:

  • 其他线程调用了该共享对象的 notify() 或者 notifyAll() 方法;
  • 其他线程调用了该线程的 interrupt() 方法,该线程抛出 InterruptedException 异常返回。

另外需要注意的是,如果调用 wait() 方法的线程没有事先获取该对象的监视器锁,则调用 wait() 方法时调用线程会抛出 IllegalMonitorStateException 异常。那么一个线程如何才能获取一个共享变量的监视器锁呢?

第一,执行 synchronized 同步代码块时,使用该共享变量作为参数。

1
2
3
synchronized(共享变量){
// TODO
}

第二,用该共享变量的方法,并且该方法使用了 synchronized 修饰。

1
2
3
synchronized void add(int a,int b){
// TODO
}

另外需要注意的是,一个线程可以从挂起状态变为可以运行状态(也就是被唤醒), 即使该线程没有被其他线程调用 notify()、notifyAll() 方法进行通知,或者被中断,或者等待超时,这就是所谓的虚假唤醒

虽然虚假唤醒在应用实践中很少发生,但要防患于未然,做法就是不停地去测试该线程被唤醒的条件是否满足,不满足则继续等待,也就是说在一个循环中调用 wait() 方法进行防范。退出循环的条件是满足了唤醒该线程的条件。

1
2
3
4
5
6
7
// 调用共享变量 wait() 方法的标准姿势
// 首先通过同步块获取 obj 上面的监视器锁,然后在 while 循环内调用 obj 的 wait() 方法。
synchronized (obj) {
while (条件不满足){
obj.wait();
}
}

一个简单的生产者和消费者示例:

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
/**
* 一个典型的生产者-消费者(Producer-Consumer)模型
*/
public class ThreadTest {
private static final int MAX_SIZE = 3;
private static final Queue<Integer> queue = new LinkedList<>();

public static void main(String[] args) {

// A 生产线程
new Thread(() -> {
int ele = 0;
while (true) {
synchronized (queue) {
// 1.检查队列是否已满
while (queue.size() == MAX_SIZE) {
try {
// 挂起当前线程,并释放通过同步块获取的 queue 上的锁,让消费者线程可以获取该锁,然后获取队列里面的元素
queue.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}

// 2.生产元素,并通知消费者线程
System.out.println("["+ Thread.currentThread().getName() +"] 生产元素: " + ele);
queue.add(ele++);
queue.notifyAll();

// 模拟生产耗时
try { TimeUnit.MILLISECONDS.sleep(500); } catch (InterruptedException ignored) {}
}
}
}, "Producer").start();

// B 消费者线程
new Thread(() -> {
while (true) {
synchronized (queue) {
// 1.检查队列是否为空
while (queue.isEmpty()) {
try {
// 挂起当前线程,并释放通过同步块获取的queue上的锁,让生产者线程可以获取该锁,将生产元素放入队列
queue.wait();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}

// 2.消费元素,并通知唤醒生产者线程
Integer val = queue.poll();
System.out.println("["+ Thread.currentThread().getName() +"] 消费元素: " + val);
queue.notifyAll();

// 模拟消费耗时
try { TimeUnit.MILLISECONDS.sleep(1000); } catch (InterruptedException ignored) {}
}
}
}, "Consumer").start();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
[Producer] 生产元素: 0
[Producer] 生产元素: 1
[Producer] 生产元素: 2
[Consumer] 消费元素: 0
[Consumer] 消费元素: 1
[Consumer] 消费元素: 2
[Producer] 生产元素: 3
[Producer] 生产元素: 4
[Producer] 生产元素: 5
[Consumer] 消费元素: 3
[Consumer] 消费元素: 4
[Consumer] 消费元素: 5
...

在如上代码中假如生产者线程 A 首先通过 synchronized 获取到了 queue 上的锁,那么后续所有企图生产元素的线程和消费线程将会在获取该监视器锁的地方被阻塞挂起。线程 A 获取锁后发现当前队列已满会调用 queue.wait() 方法阻塞自己,然后释放获取的 queue 上的锁。线程 A 释放锁后,其他生产者线程和所有消费者线程中会有一个线程获取 queue 上的锁进而进入同步块。

另外,当前线程调用共享变量的 wait() 方法后只会释放当前共享变量上的锁,如果当前线程还持有其他共享变量的锁,则这些锁是不会被释放的。下面来看一个 例子。

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
// 创建资源
private static volatile Object resourceA = new Object();
private static volatile Object resourceB = new Object();

public static void main(String[] args) throws InterruptedException {
// 创建线程
Thread threadA = new Thread(() -> {
try {
// 获取resourceA共享资源的监视器锁
synchronized (resourceA) {
System.out.println("threadA get resourceA lock");
// 获取resourceB共享资源的监视器锁
synchronized (resourceB) {
System.out.println("threadA get resourceB lock");
// 线程A阻塞,并释放获取到的resourceA的锁
System.out.println("threadA release resourceA lock");
resourceA.wait();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});

// 创建线程
Thread threadB = new Thread(() -> {
try {
//休眠1s
Thread.sleep(1000);
// 获取resourceA共享资源的监视器锁
synchronized (resourceA) {
System.out.println("threadB get resourceA lock");
System.out.println("threadB try get resourceB lock...");
// 获取resourceB共享资源的监视器锁
synchronized (resourceB) {
System.out.println("threadB get resourceB lock");
// 线程B阻塞,并释放获取到的resourceA的锁
System.out.println("threadB release resourceA lock");
resourceA.wait();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
});

// 启动线程
threadA.start();
threadB.start();
// 等待两个线程结束
threadA.join();
threadB.join();
System.out.println("main over");
}

输出结果如下 :

1
2
3
4
5
threadA get resourceA lock
threadA get resourceB lock
threadA release resourceA lock
threadB get resourceA lock
threadB try get resourceB lock... 【程序卡在这儿】

如上代码中,在 main 函数里面启动了线程 A 和线程 B,为了让线程 A 先获取到锁, 这里让线程 B 先休眠了 1s,线程 A 先后获取到共享变量 resourceA 和共享变量 resourceB 上的锁,然后调用了 resourceA 的 wait() 方法阻塞自己,阻塞自己后线程 A 释放掉获取的 resourceA 上的锁。

线程 B 休眠结束后会首先尝试获取 resourceA 上的锁,如果当时线程 A 还没有调用 wait() 方法释放该锁,那么线程 B 会被阻塞,当线程 A 释放了 resourceA 上的锁后,线程 B 就会获取到 resourceA 上的锁,然后尝试获取 resourceB 上的锁。由于线程 A 调用的是 resourceA 上的 wait() 方法,所以线程 A 挂起自己后并没有释放获取到的 resourceB 上的锁, 所以线程 B 尝试获取 resourceB 上的锁时会被阻塞。

这就证明了当线程调用共享对象的 wait() 方法时,当前线程只会释放当前共享对象的锁,当前线程持有的其他共享对象的监视器锁并不会被释放。最后再举一个例子进行说明。当一个线程调用共享对象的 wait() 方法被阻塞挂起后,如果其他线程中断了该线程,则该线程会抛出 InterruptedException 异常并返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class DemoTest {
static Object obj = new Object();

public static void main(String[] args) throws InterruptedException {
// 创建线程
Thread threadA = new Thread(() -> {
try {
System.out.println("---begin---"); // 阻塞当前线程
synchronized (obj) {
obj.wait();
}
System.out.println("---end---");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
threadA.start();

Thread.sleep(1000);
System.out.println("---begin interrupt threadA---");
threadA.interrupt();
System.out.println("---end interrupt threadA---");
}
}
1
2
3
4
5
6
7
8
---begin---
---begin interrupt threadA---
---end interrupt threadA---
java.lang.InterruptedException
at java.base/java.lang.Object.wait(Native Method)
at java.base/java.lang.Object.wait(Object.java:338)
at com.demo.DemoTest.lambda$main$0(DemoTest.java:12)
at java.base/java.lang.Thread.run(Thread.java:842)

在如上代码中,threadA 调用共享对象 obj 的 wait() 方法后阻塞挂起了自己,然后 主线程在休眠 1s 后中断了 threadA 线程,中断后 threadA 在 obj.wait() 处抛出 java.lang. InterruptedException 异常而返回并终止。

wait(long timeout) 函数

该方法相比 wait() 方法多了一个超时参数,它的不同之处在于,如果一个线程调用共 享对象的该方法挂起后,没有在指定的 timeout ms 时间内被其他线程调用该共享变量的 notify() 或者 notifyAll() 方法唤醒,那么该函数还是会因为超时而返回。如果将 timeout 设 置为 0 则和 wait 方法效果一样,因为在 wait 方法内部就是调用了 wait(0)。

notify() 函数

一个线程调用共享对象的 notify() 方法后,会唤醒一个在该共享变量上调用 wait 系列方法后被挂起的线程。一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线程是随机的。此外,被唤醒的线程不能马上从 wait 方法返回并继续执行,它必须在获取了共享对象的监视器锁后才可以返回 , 也就是唤醒它的线程释放了共享变量上的监视器锁后,被唤醒的线程也不一定会获取到共享对象的监视器锁,这是因为该线程还需要和其他线程一起竞争该锁,只有该线程竞争到了共享变量的监视器锁后才可以继续执行。类似 wait 系列方法,只有当前线程获取到了共享变量的监视器锁后,才可以调用共享变量的 notify() 方法,否则会抛出 IllegalMonitorStateException 异常。

notifyAll() 函数

不同于在共享变量上调用 notify() 函数会唤醒被阻塞到该共享变量上的一个线程,notifyAll() 方法则会唤醒所有在该共享变量上由于调用 wait 系列方法而被挂起的线程。

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
public class DemoTest {
// 创建资源
private static volatile Object resourceA = new Object();

public static void main(String[] args) throws InterruptedException {
// 创建线程
Thread threadA = new Thread(() -> {
// 获取resourceA共享资源的监视器锁
synchronized (resourceA) {
System.out.println("threadA get resourceA lock");
try {
System.out.println("threadA begin wait");
resourceA.wait();
System.out.println("threadA end wait");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});

// 创建线程
Thread threadB = new Thread(() -> {
synchronized (resourceA) {
System.out.println("threadB get resourceA lock");
try {
System.out.println("threadB begin wait");
resourceA.wait();
System.out.println("threadB end wait");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});

// 创建线程
Thread threadC = new Thread(() -> {
synchronized (resourceA) {
System.out.println("threadC begin notify");
resourceA.notify();
}
});

// 启动线程
threadA.start();
threadB.start();
TimeUnit.SECONDS.sleep(1);
threadC.start();

// 等待线程结束
threadA.join();
threadB.join();
threadC.join();
System.out.println("main over");
}
}
1
2
3
4
5
6
threadA get resourceA lock
threadA begin wait
threadB get resourceA lock
threadB begin wait
threadC begin notify
threadA end wait 【程序停在这儿】

如上代码开启了三个线程,其中线程 A 和线程 B 分别调用了共享资源 resourceA 的 wait() 方法,线程 C 则调用了 nofity() 方法。这里启动线程 C 前首先调用 sleep 方法让主线 程休眠 1s,这样做的目的是让线程 A 和线程 B 全部执行到调用 wait 方法后再调用线程 C 的 notify 方法。这个例子试图在线程 A 和线程 B 都因调用共享资源 resourceA 的 wait() 方法而被阻塞后,让线程 C 再调用 resourceA 的 notify() 方法,从而唤醒线程 A 和线程 B。 但是从执行结果来看,只有一个线程 A 被唤醒,线程 B 没有被唤醒 :

从输出结果可知线程调度器这次先调度了线程 A 占用 CPU 来运行,线程 A 首先获取 resourceA 上面的锁,然后调用 resourceA 的 wait() 方法挂起当前线程并释放获取到的锁,然后线程 B 获取到 resourceA 上的锁并调用 resourceA 的 wait() 方法,此时线程 B 也被阻塞挂起并释放了 resourceA 上的锁,到这里线程 A 和线程 B 都被放到了 resourceA 的 阻塞集合里面。线程 C 休眠结束后在共享资源 resourceA 上调用了 notify() 方法,这会激活 resourceA 的阻塞集合里面的一个线程,这里激活了线程 A,所以线程 A 调用的 wait() 方法返回了,线程 A 执行完毕。而线程 B 还处于阻塞状态。如果把线程 C 调用的 notify() 方法改为调用 notifyAll() 方法,则执行结果如下。

1
2
3
4
5
6
7
8
threadA get resourceA lock
threadA begin wait
threadB get resourceA lock
threadB begin wait
threadC begin notify
threadA end wait
threadB end wait
main over

从输入结果可知线程 A 和线程 B 被挂起后,线程 C 调用 notifyAll() 方法会唤醒 resourceA 的等待集合里面的所有线程,这里线程 A 和线程 B 都会被唤醒,只是线程 B 先获取到 resourceA 上的锁,然后从 wait() 方法返回。线程 B 执行完毕后,线程 A 又获取了 resourceA 上的锁,然后从 wait() 方法返回。线程 A 执行完毕后,主线程返回,然后打印输出。

一个需要注意的地方是,在共享变量上调用 notifyAll() 方法只会唤醒调用这个方法前调用了 wait 系列函数而被放入共享变量等待集合里面的线程。如果调用 notifyAll() 方法后一个线程调用了该共享变量的 wait() 方法而被放入阻塞集合,则该线程是不会被唤醒的。 尝试把主线程里面休眠 1s 的代码注释掉,再运行程序会有一定概率输出下面的结果。

1
2
3
4
5
6
threadA get resourceA lock
threadA begin wait
threadC begin notify
threadB get resourceA lock
threadB begin wait
threadA end wait

也就是在线程 B 调用共享变量的 wait() 方法前线程 C 调用了共享变量的 notifyAll 方法, 这样,只有线程 A 被唤醒,而线程 B 并没有被唤醒,还是处于阻塞状态。


等待线程执行终止的 join

在项目实践中经常会遇到一个场景,就是需要等待某几件事情完成后才能继续往下执 行,比如多个线程加载资源,需要等待多个线程全部加载完毕再汇总处理。Thread 类中有 一个 join 方法就可以做这个事情,前面介绍的等待通知方法是 Object 类中的方法,而 join 方法则是 Thread 类直接提供的。join 是无参且返回值为 void 的方法。下面来看一个简单 的例子。

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
public class DemoTest {
public static void main(String[] args) throws InterruptedException {
Thread threadOne = new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("child threadOne over!");
});

Thread threadTwo = new Thread(() -> {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("child threadTwo over!");
});

// 启动子线程
threadOne.start();
threadTwo.start();
System.out.println("wait all child thread over!");
// 等待子线程执行完毕,返回
threadOne.join();
threadTwo.join();
System.out.println("all child thread over!");
}
}

如上代码在主线程里面启动了两个子线程,然后分别调用了它们的 join() 方法,那么主线程首先会在调用 threadOne.join() 方法后被阻塞,等待 threadOne 执行完毕后返回。 threadOne 执行完毕后 threadOne.join() 就会返回,然后主线程调用 threadTwo.join() 方法后 再次被阻塞,等待 threadTwo 执行完毕后返回。这里只是为了演示 join 方法的作用,在这 种情况下使用后面会讲到的 CountDownLatch 是个不错的选择。

另外,线程 A 调用线程 B 的 join 方法后会被阻塞,当其他线程调用了线程 A 的 interrupt() 方法中断了线程 A 时,线程 A 会抛出 InterruptedException 异常而返回。下面通过一个例子来加深理解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
System.out.println("t1 开跑");
for (;;) {}
});

Thread mt = Thread.currentThread();
Thread t2 = new Thread(() -> {
try{ TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) {} // 延迟1秒
mt.interrupt();
});

t1.start();
t2.start();
try {
t1.join();
} catch (InterruptedException e) {
System.out.println("主线程:" + e);
}
}
1
2
t1 开跑
主线程:java.lang.InterruptedException 【程序卡在这里】


让线程睡眠的 sleep

某个线程调用 sleep 后的效果:调用线程会暂时让出指定时间的执行权(这期该线程不参与 CPU 的调度),但该线程会继续持有所拥有的监视器资源。指定的睡眠时间到了该函数会正常返回,线程就处于就绪状态,然后参与 CPU 的调度,获取到 CPU 资源后就能继续运行了。如果在睡眠期间其他线程调用了该线程的 interrupt() 方法中断了该线程 , 则该线程会在调用 sleep 方法的地方抛出 InterruptedException 异常而返回。

示例1:

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
public static void main(String[] args) {
Lock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
lock.lock();
try {
System.out.println("t1 开始睡");
TimeUnit.SECONDS.sleep(10);
System.out.println("t1 醒了");
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
});
Thread t2 = new Thread(() -> {
lock.lock();
try {
System.out.println("t2 开始睡");
TimeUnit.SECONDS.sleep(10);
System.out.println("t2 醒了");
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
});
t1.start();
t2.start();
}
1
2
3
4
t1 开始睡 【过了10秒】
t1 醒了
t2 开始睡 【又过了10秒】
t2 醒了

从执行结果来看,线程 1 先获取了锁,那么线程 1 会先输出一行,然后调用 sleep 方法让自己睡眠 10s,在线程 1 睡眠的这 10s 内那个独占锁 lock 还是线程 1 自己持有,线程 2 会一直阻塞直到线程 1 醒来后执行 unlock 释放锁。

示例2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void main(String[] args) throws InterruptedException {
Lock lock = new ReentrantLock();
Thread t1 = new Thread(() -> {
lock.lock();
try {
System.out.println("t1 开始睡");
TimeUnit.SECONDS.sleep(10);
System.out.println("t1 醒了");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}, "t1");

t1.start();
TimeUnit.SECONDS.sleep(2);
t1.interrupt();
}
1
2
3
4
5
6
7
t1 开始睡 【2秒后】
java.lang.InterruptedException: sleep interrupted
at java.base/java.lang.Thread.sleep(Native Method)
at java.base/java.lang.Thread.sleep(Thread.java:346)
at java.base/java.util.concurrent.TimeUnit.sleep(TimeUnit.java:446)
at com.demo.DemoTest.lambda$main$0(DemoTest.java:14)
at java.base/java.lang.Thread.run(Thread.java:842)


让出 CPU 执行权的 yield

yield 方法的作用:暗示线程调度器当前线程请求让出自己的 CPU 使用,但是线程调度器可以无条件忽略这个暗 示。当一个线程调用 yield 方法时,当前线程会让出 CPU 使用权,然后处于就绪状态,线程调度器会从线程就绪队列里面获取一个线程优先级最高的线程,当然也有可能会调度到刚刚让出 CPU 的那个线程来获取 CPU 执行权。sleep 与 yield 方法的区别在于,当线程调用 sleep 方法时调用线程会被阻塞挂起指定的时间,在这期间线程调度器不会去调度该线程。而调用 yield 方法时,线程只是让出自己剩余的时间片,并没有被阻塞挂起,而是处于就绪状态,线程调度器下一次调度 时就有可能调度到当前线程执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static class YTask implements Runnable {
@Override
public void run() {
String threadName = Thread.currentThread().getName();
for (int i = 0; i < 5; i++) {
if (i % 5 == 0) {
System.out.println(threadName + " 开始 yield..");
//Thread.yield(); // 当前线程让出CPU执行权,放弃时间片,进行下一轮调度
}
}
System.out.println(threadName + " 结束!");
}
}
public static void main(String[] args) throws InterruptedException {
new Thread(new YTask()).start();
new Thread(new YTask()).start();
new Thread(new YTask()).start();
}
1
2
3
4
5
6
Thread-0 开始 yield..
Thread-2 开始 yield..
Thread-1 开始 yield..
Thread-2 结束!
Thread-0 结束!
Thread-1 结束!

当 Thread.yield() 这行代码打开后(一般会看到谁先开始就谁先结束):

1
2
3
4
5
6
Thread-1 开始 yield..
Thread-2 开始 yield..
Thread-0 开始 yield..
Thread-1 结束!
Thread-2 结束!
Thread-0 结束!


线程的中断

Java 中的线程中断是一种线程间的协作模式,通过设置线程的中断标志并不能直接终止该线程的执行,而是被中断的线程根据中断状态自行处理。关键的三个方法:

  • void interrupt() :中断线程。当线程 A 运行时,线程 B 可以调用线程 A 的 interrupt() 方法来设置线程 A 的中断标志为 true 并立即返回。设置标志仅仅是设 置标志,线程 A 实际并没有被中断,它会继续往下执行。如果线程 A 因为调用了 wait 系列函数、join 方法或者 sleep 方法而被阻塞挂起,这时候若线程 B 调用线程 A 的 interrupt() 方法,线程 A 会在调用这些方法的地方抛出 InterruptedException 异 常而返回。
  • boolean isInterrupted():检测当前线程是否被中断,如果是返回 true,否则返 回 false。
  • static boolean interrupted():检测当前线程是否被中断,如果是则返回 true 并且清除 中断标志,否则返 回 false。

下面看一个线程使用 Interrupted 优雅退出的经典例子,代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
public void run() {
try {
// ...
while (!Thread.currentThread().isInterrupted() && more work to do){
// ...
}
} catch (InterruptedException e) {
// 线程被打断
} finally {
// 清理逻辑
}
}

在看一个具体的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
String threadName = Thread.currentThread().getName();
while (!Thread.currentThread().isInterrupted()) {
System.out.println(threadName + " 执行中..");
try {
TimeUnit.MILLISECONDS.sleep(300);
} catch (InterruptedException e){
System.err.println(threadName + " 被打断!");
break;
}
}
}, "t1");
t1.start();

System.out.println("在主线程中等一秒打断t1..");
TimeUnit.SECONDS.sleep(1);
t1.interrupt();

t1.join(); // 等待子线程执行完毕
System.out.println("主线程退出");
}
1
2
3
4
5
6
7
在主线程中等一秒打断t1..
t1 执行中..
t1 执行中..
t1 执行中..
t1 执行中..
主线程退出
t1 被打断!


线程死锁

死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象, 在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。计算机科学家 Edward G. Coffman 总结了死锁发生的四个必要条件。只要其中任何一个条件不成立,死锁就不会发生。

  • 互斥条件 (Mutual Exclusion):本质是资源不能共享。在一段时间内,某资源只能由一个进程占用。如果此时还有其他进程请求该资源,请求者只能等待,直到占有者释放。
  • 请求与保持条件 (Hold and Wait):进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有。此时请求进程被阻塞,但对自己已获得的资源保持不放(贪心算法,占着碗里的,看着锅里的)。
  • 不剥夺条件 (No Preemption):进程已获得的资源在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
  • 循环等待条件 (Circular Wait):逻辑上必须成环,即进程 P0 等待 P1 占有的资源,P1​ 等待 P2​ 占有的资源……最后 Pn 等待 P0 占有的资源。

从数学和系统的角度看,死锁发生的深层原因通常是因为系统进入了不安全状态。在资源有限的情况下,如果系统无法找到一个 “安全序列”(即按某种顺序为每个进程分配资源,使所有人都能顺利完成),那么系统就可能滑向死锁。针对上述四个必要条件,常见的解决策略包括:

  • 破坏请求与保持:进程开始前一次性申请所有资源。
  • 破坏不剥夺:如果申请新资源被拒,则主动释放已占有的所有资源。
  • 破坏循环等待:对资源进行编号,所有进程必须按编号顺序申请资源(这是最常用的手段)。

死锁的示例:

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
public static void main(String[] args) throws InterruptedException {
Object resourceA = new Object();
Object resourceB = new Object();

Thread threadA = new Thread(() -> {
synchronized (resourceA) {
System.out.println(Thread.currentThread().getName() + " 获得了资源A");
try { TimeUnit.MILLISECONDS.sleep(1000); } catch (InterruptedException e) { }
System.out.println(Thread.currentThread().getName() + " 准备获取资源B");
synchronized (resourceB) {
System.out.println(Thread.currentThread().getName() + " 获得了资源B");
}
}
});

Thread threadB = new Thread(() -> {
synchronized (resourceB) {
System.out.println(Thread.currentThread().getName() + " 获得了资源B");
try { TimeUnit.MILLISECONDS.sleep(1000); } catch (InterruptedException e) { }
System.out.println(Thread.currentThread().getName() + " 准备获取资源A");
synchronized (resourceA) {
System.out.println(Thread.currentThread().getName() + " 获得了资源A");
}
}
});

threadA.start();
threadB.start();
}
1
2
3
4
Thread-0 获得了资源A
Thread-1 获得了资源B
Thread-1 准备获取资源A
Thread-0 准备获取资源B 【程序卡在这儿】

我们对以上程序稍加改造,申请资源按编号顺序申请,此时资源的有序性破坏了资源的请求并持有条件和环路等待条件,因此也就避免了死锁。

1
2
3
4
5
6
7
8
9
10
Thread threadB = new Thread(() -> {
synchronized (resourceA) {
System.out.println(Thread.currentThread().getName() + " 获得了资源A");
try { TimeUnit.MILLISECONDS.sleep(1000); } catch (InterruptedException e) { }
System.out.println(Thread.currentThread().getName() + " 准备获取资源B");
synchronized (resourceB) {
System.out.println(Thread.currentThread().getName() + " 获得了资源B");
}
}
});


用户线程和守护线程

Java 中的线程分为两类,分别为 user 线程(用户线程) 和 daemon 线程(守护线程) 。 在 JVM 启动时会调用 main 函数,main 函数所在的线程就是一个用户线程,其实在 JVM 内部同时还启动了很多守护线程,比如垃圾回收线程。只要任何一个用户线程还在运行,Java 虚拟机(JVM)就会继续运行,不会退出。而对于守护线程,其生死取决于用户线程,当所有的用户线程都结束执行时,JVM 会发现 “已经没有需要服务的对象了”,此时它会强制终止所有的守护线程并退出。典型的守护线程:

  • 垃圾回收器 (GC): 它是最著名的守护线程。它在后台扫描无用对象,只要程序还在跑,它就工作;程序停了,它也就没必要存在了。
  • 内存监控、日志记录: 负责定期检测系统状态的辅助任务。

在程序中创建一个守护线程:

1
2
3
4
5
6
7
8
Thread backgroundTask = new Thread(() -> {
while (true) {
// 执行一些后台清理工作
}
});
// 将其设置为守护线程(注意:设置必须在 start 方法调用之前完成,否则会抛出 IllegalThreadStateException 异常)
backgroundTask.setDaemon(true);
backgroundTask.start();

下面这个例子说明了用户线程和守护线程的区别:

1
2
3
4
5
6
public static void main(String[] args) {
new Thread(() -> {
for(;;) {}
}, "t1").start();
System.out.print("主线程结束");
}

如上代码在 main 线程中创建了一个 t1 线程。 从运行代码的结果看,main 线程已经运行结束了,但在 IDE 的输出结果右上侧的红色方块说明,JVM 进程并没有退出。

1
2
3
% jps
73473 Jps
73327 DemoTest

这个结果说明了当父线程结束后,子线程还是可以继续存在的,也就是子线程的生命 周期并不受父线程的影响。这也说明了在用户线程还存在的情况下 JVM 进程并不会终止。 那么我们把上面的 t1 线程设置为守护线程后,再来运行看看会有什么结果 :

1
2
3
4
5
6
7
8
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
for (;;) { }
}, "t1");
t1.setDaemon(true);
t1.start();
System.out.print("主线程结束");
}

输出结果显示,JVM 进程已经终止了, 执行 ps -eaf |grep java 也看不到 JVM 进程了。在这个例子中,main 函数是唯一的用户线 程,thread线程是守护线程,当main线程运行结束后,JVM发现当前已经没有用户线程了, 就会终止 JVM 进程。由于这里的守护线程执行的任务是一个死循环,这也说明了如果当 前进程中不存在用户线程,但是还存在正在执行任务的守护线程,则 JVM 不等守护线程运行完毕就会结束 JVM 进程。

main 线程运行结束后,JVM 会自动启动一个叫作 DestroyJavaVM 的线程:

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
/**
* JavaMain - JVM 启动器的入口函数(C 语言实现)
* @param _args: 启动参数,包含类名、方法参数、VM 选项等
*/
int JNICALL
JavaMain(void * _args)
{
/**
* 1. 寻找并执行 Java 层的 main 函数
* env: JNI 环境指针,用于 C 与 Java 的交互
* mainClass: 已经加载好的包含 main 方法的 Java 类(如 YourApp.class)
* mainID: main 方法的方法 ID(通过 GetStaticMethodID 获取)
* mainArgs: 传递给 Java main(String[] args) 的参数数组
*/
(*env)->CallStaticVoidMethod(env, mainClass, mainID, mainArgs);

/**
* 2. 检查 Java 代码执行过程中是否抛出了未捕获的异常
* ExceptionOccurred 会返回当前线程挂起的异常对象。
* 如果没有异常(NULL),则返回 0(成功);
* 如果有异常,说明 Java 程序崩溃,返回 1(错误状态)。
*/
ret = (*env)->ExceptionOccurred(env) == NULL ? 0 : 1;

/**
* 3. 核心机制:销毁虚拟机与线程等待
* LEAVE() 是一个宏,其核心逻辑是调用 DestroyJavaVM(vm)。
* 这里的逻辑非常关键(即你之前关心的线程分类):
* a. 调用此函数的线程(通常是主线程)会等待所有“非守护线程”(User Threads)执行完毕。
* b. 只要有一个用户线程(如你手动 new Thread 并启动的任务)还在跑,
* DestroyJavaVM 就会在此处阻塞(Block),进程不会退出。
* c. 一旦所有用户线程结束,JVM 就会启动关闭序列。
* d. 此时,所有的“守护线程”(Daemon Threads,如 GC、Finalizer 等)
* 会被强制终止,不管它们是否正在运行或是否有未完成的任务。
*/
LEAVE();

// 函数返回退出码给操作系统
return ret;
}
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
/**
* LEAVE() 宏定义 - 负责清理当前线程并关闭 JVM
* 使用 do { ... } while (JNI_FALSE) 结构是为了确保宏在各种代码块中
* 都能保持语法一致性,并提供独立的局部作用域。
*/
#define LEAVE() \
do { \
/**
* 1. 将当前 native 线程从 JVM 中“剥离” (Detach)
* 在 JNI 调用中,当前 C 线程(主线程)是附着在 VM 上的。
* 调用 DetachCurrentThread 是为了告诉 VM:这个线程不再需要执行 Java 代码了。
* 如果剥离失败(!= JNI_OK),则报告错误并将退出码置为 1。
*/ \
if ((*vm)->DetachCurrentThread(vm) != JNI_OK) { \
JLI_ReportErrorMessage(JVM_ERROR2); \
ret = 1; \
} \
\
/**
* 2. 核心:销毁虚拟机实例
* 这是一个阻塞调用,也是区分用户线程与守护线程的分水岭。
* JNI_TRUE 保证了这个逻辑一定会执行。
*/ \
if (JNI_TRUE) { \
/* DestroyJavaVM 的内部逻辑:
* a. 等待所有用户线程(User Threads)退出。
* b. 只要系统里还有一个非守护线程在跑,代码就会卡在这里。
* c. 当仅剩守护线程时,VM 开始关闭,直接杀掉所有守护线程。
*/ \
(*vm)->DestroyJavaVM(vm); \
} \
\
/* 3. 最终返回退出状态码给操作系统 */ \
return ret; \
} while (JNI_FALSE)
  • JVM “僵尸” 进程:很多时候 Spring 应用停止了但进程还在,本质就是因为某个非守护线程还没退出,导致底层的 DestroyJavaVM 在苦苦等待。
  • 资源回收风险:守护线程在 DestroyJavaVM 执行到最后阶段时是被强制暴力杀掉的。如果您在守护线程中维护了需要持久化的 Redis 缓冲区或本地文件缓存,这种暴力退出会导致数据丢失。

可以将 DestroyJavaVM 看作是一个逻辑终点:它定义了系统从 “运行态” 转换为“ 不存在态” 的判定准则——即 “所有非辅助性质的任务(用户线程)均已达到终止状态”。守护线程就像是依附在用户线程上的 “影子”。当灯(用户线程)熄灭时,影子(守护线程)会瞬间消失,假如在守护线程中存在 finally 的逻辑,有可能最终没有任何缓冲时间去执行 finally 块。几种 finally 块失效的情况:

  • 守护线程随 JVM 退出:如果 finally 逻辑位于守护线程中,且此时所有用户线程已执行完毕,JVM 退出会导致 finally 被跳过。
  • 调用了 System.exit():如果在 try 或 catch 块中显式调用了 System.exit(int),JVM 会立即启动关闭序列。这种情况下,所有线程(无论是用户线程还是守护线程)的 finally 块都不会执行。
  • 无限循环或线程死锁:如果 try 块中发生了死锁,或者进入了无法跳出的死循环,程序永远无法到达 finally 的入口。
  • 硬件层面的“暴力”故障:
    • OOM 导致 JVM 崩溃: 虽然普通的 Exception 会触发 finally,但某些严重的 VirtualMachineError 可能导致 JVM 瞬间崩溃。
    • 操作系统断电/强制杀进程:外部通过 kill -9 杀死 Java 进程,操作系统直接回收内存,JVM 根本没有机会运行后续字节码。

在实际的开发实践中,这里有两个关键的回避策略:

  • 第一 ,不要在守护线程中处理“状态一致性”逻辑:永远不要在守护线程的 finally 中关闭数据库连接、解锁分布式锁或写入关键审计日志。因为你无法保证这些逻辑一定会被触发。
  • 第二,利用 JVM 关闭钩子 (Shutdown Hook):如果你有必须在程序退出前完成的清理工作,应该使用 Runtime.getRuntime().addShutdownHook(Thread t)。Spring 框架的 DisposableBean 或 @PreDestroy 注解本质上就是挂载在 Shutdown Hook 上的。即使是守护线程被中断,Shutdown Hook 仍有最后的机会运行(只要不是 kill -9)。如果确实需要后台清理,请务必使用 Spring 的优雅停机(Graceful Shutdown)机制,或者手动注册 Runtime.addShutdownHook。

在 Linux/Unix 系统中,kill 命令并不是真的为了“杀死”进程,而是为了向进程发送信号(Signal)。

  • kill -15 (SIGTERM): 这是默认信号。它像是一个 “礼貌的请求”,告诉进程:“嘿,请你关机吧。”进程收到后,可以先存盘、关数据库连接,最后再退出。JVM 的 Shutdown Hook 就是在收到这个信号时触发的。这就像塔台通知飞行员准备降落。飞行员放下起落架,通知乘客(执行 Shutdown Hook),然后平稳降落在跑道上。
  • kill -9 (SIGKILL): 这是一个 “强制指令”。它不是发给进程的,而是发给操作系统内核的。它告诉内核:“立刻停掉这个进程的 CPU 调度,并回收它的内存”。这就像塔台直接发射了一枚导弹。飞机瞬间在空中消失,飞行员根本没机会广播,乘客也来不及做任何反应。
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
/**
* 一个简单的示例:说明守护线程中的 finally 块被 “吞掉” 现象
*/
public static void main(String[] args) throws InterruptedException {
Thread daemonThread = new Thread(() -> {
try {
System.out.println("守护线程:开始运行...");
while (true) {
// 模拟持续不断的后台任务
Thread.sleep(500);
System.out.println("守护线程:正在工作...");
}
} catch (InterruptedException e) {
System.out.println("守护线程:被中断了!");
} finally {
// 关键点:如果 JVM 正常退出,这里理应执行
System.out.println("【警告】守护线程的 finally 块被执行了!(通常你看不到这一行)");
}
});

// 设置为守护线程
daemonThread.setDaemon(true);
daemonThread.start();

// 主线程(用户线程)只运行 2 秒
Thread.sleep(2000);
System.out.println("主线程(用户线程):即将结束,JVM 准备关闭...");
}
1
2
3
4
5
6
7
守护线程:开始运行...
守护线程:正在工作...
守护线程:正在工作...
守护线程:正在工作...
主线程(用户线程):即将结束,JVM 准备关闭...

Process finished with exit code 0

我们将之前的代码进行改良:虽然守护线程依然会被强制关闭,但我们注册一个 Shutdown Hook 来确保清理逻辑得以执行。

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
public static void main(String[] args) throws InterruptedException {
Thread daemonThread = new Thread(() -> {
try {
System.out.println("守护线程:开始运行...");
while (true) {
// 模拟持续不断的后台任务
Thread.sleep(500);
System.out.println("守护线程:正在工作...");
}
} catch (InterruptedException e) {
System.out.println("守护线程:被中断了!");
} finally {
// 关键点:如果 JVM 正常退出,这里理应执行
System.out.println("【警告】守护线程的 finally 块被执行了!(通常你看不到这一行)");
}
});
daemonThread.setDaemon(true);
daemonThread.start();

// 注册 JVM 关闭钩子(挽救逻辑的核心)
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("\n[JVM 通知] 检测到关闭信号,启动 Shutdown Hook...");
System.out.println("[清理开始] 正在保存未完成的任务到磁盘/数据库...");
// 模拟清理耗时
try { Thread.sleep(1000); } catch (InterruptedException e) {}
System.out.println("[清理完成] 资源已安全释放,再见!");
}));

// 主线程(用户线程)只运行 2 秒
Thread.sleep(2000);
System.out.println("主线程(用户线程):即将结束,JVM 准备关闭...");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
守护线程:开始运行...
守护线程:正在工作...
守护线程:正在工作...
守护线程:正在工作...
主线程(用户线程):即将结束,JVM 准备关闭...

[JVM 通知] 检测到关闭信号,启动 Shutdown Hook...
[清理开始] 正在保存未完成的任务到磁盘/数据库...
守护线程:正在工作...
守护线程:正在工作...
[清理完成] 资源已安全释放,再见

Process finished with exit code 0

注意:在生产环境中通常不需要手动写 addShutdownHook,因为:

  • Spring 的优雅停机:Spring 已经在内部注册了关闭钩子。当它收到退出信号时,会依次调用所有 Bean 的 @PreDestroy 方法或 DisposableBean.destroy()。
  • 如果实在要加,那么务必不要在 Hook 中做耗时太久的操作: 操作系统通常只给 JVM 几秒钟的宽限时间,超时会被强制 kill -9。
  • 不要在 Hook 中注册新的 Hook: 会抛出异常。


ThreadLocal 的介绍

多线程访问同一个共享变量时特别容易出现并发问题,特别是在多个线程需要对一个 共享变量进行写入时。为了保证线程安全,一般使用者在访问共享变量时需要进行适当的同步。同步的措施一般是加锁,但是加锁的逻辑是比较重的,它有可能使得原本可以并行的逻辑,强制为了串行执行。那么有没有一种方式可以做到,当创建一个变量后,每个线程对其进行访问的时候访问的是自己线程的变量呢?其实 ThreadLocal 就可以做这件事情。ThreadLocal 是 java.lang 包提供的,如果你创建了一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本。当多个线程操作这个变量时,实际操作的是自己本地内存里面的变量,从而避免了线程安全问题。

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 DemoTest {
// 通常建议将 ThreadLocal 定义为 static,这样可以避免重复创建无意义的 Key 实例。
private static ThreadLocal<String> localVar = new ThreadLocal<>();

static void print() {
System.out.println(Thread.currentThread().getName() + ":" + localVar.get());
}

public static void main(String[] args) {
Thread t1 = new Thread(() -> {
localVar.set("t1 val");
print();
localVar.remove();
print();
}, "t1");
Thread t2 = new Thread(() -> {
localVar.set("t2 val");
print();
localVar.remove();
print();
}, "t2");

t1.start();
t2.start();
}
}
1
2
3
4
t1:t1 val
t2:t2 val
t1:null
t2:null

很多人误以为 ThreadLocal 内部维护了一个 Map,把线程作为 Key,这是错误的。真实的结构是:每个线程(Thread 实例)内部持有一个名为 threadLocals 的成员变量,其类型是 ThreadLocal.ThreadLocalMap。

  • ThreadLocal 实例:仅仅是一个 “代理人”。
  • ThreadLocalMap:才是真正的存储容器。
  • Entry:Map 里的节点。它的 Key 是 ThreadLocal 实例的弱引用,Value 是你存进去的对象。

底层哈希寻址:我们知道 HashMap 使用拉链法(链表/红黑树)解决冲突,但 ThreadLocalMap 为了极致的内存效率和简单的结构,使用了线性探测法(Linear Probing)。

1
2
3
4
5
private static AtomicInteger nextHashCode = new AtomicInteger();
// 斐波那契散列增量,它是黄金比例乘以 2^31 后取整并转换为有符号整数的等效无符号表示
// 它是为了让连续生成的 ThreadLocal 实例在 Map 数组中分布得极其均匀,尽量减少冲突。
// 当计算出的索引 i 已经被占用时:它不会挂链表,而是直接检查 i + 1,如果 i + 1 也满了,检查 i + 2,直到找到空位。
private static final int HASH_INCREMENT = 0x61c88647;

Entry 的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // Key 是弱引用
value = v; // Value 是强引用
}
}

// Thread 里面的 threadLocals 之所以被设计为 map 结构,是因为每个线程可以关 联多个 ThreadLocal 变量。

// Thread 中属性 ThreadLocal.ThreadLocalMap threadLocals = null; // 第一次ThreadLocal#set初始化
// ThreadLocal.ThreadLocalMap 其中属性 Entry[]
// Entry 的 key 为本线程的 ThreadLocal,value是存放的值(通过 ThreadLocal#set 来绑定)

为什么 Key 要用弱引用呢?如果是强引用,只要线程不死亡,ThreadLocal 实例就永远无法被 GC 回收(即使你把外部引用设为 null)。使用弱引用后,一旦你把外部的 ThreadLocal 变量置为 null,下次 GC 时,Entry 中的 Key 就会被回收,Key 变为 null。需要注意,虽然 Key 变成了 null,但 Value 是强引用。只要线程还在运行(比如在线程池里被复用),这条从 Thread -> ThreadLocalMap -> Entry -> Value 的引用链就一直存在,Value 永远不会被回收。为了自救,ThreadLocalMap 在执行 get()、set() 或 remove() 时,会顺便扫一遍附近的 Entry。

  • 探测式清理 (Expunge Stale Entry):从当前位置往后找,遇到 key == null 的 Entry,直接把 value 置为 null,并将该 Entry 移出数组。同时,它还会顺带对后面的 Entry 进行 “重哈希”,把原本因为冲突而挪位的元素搬回更靠近原始索引的地方(数学上的优化)。
  • 启发式清理 (Heuristic Scan):在 set 时触发,对数级(log n)地扫描一些槽位,发现过期的就清理。

下面我们就来看看 ThreadLocal 的 set、get 及 remove 方法的实现逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t); // 将当前线程作为key,去查找对应的线程变量,找到则设置
if (map != null) {
// 如果 getMap(t) 的返回值不为空,则把 value 值设置到 threadLocals 中,也就是把当前 变量值放入当前线程的内存变量 threadLocals 中。
// threadLocals 是一个 HashMap 结构,其中 key 就是当前 ThreadLocal 的实例对象引用,value 是通过 set 方法传递的值。
map.set(this, value);
} else {
// 如果 getMap(t) 返回空值则说明是第一次调用 set 方法,这时创建当前线程的 threadLocals 变量。
createMap(t, value);
}
}

// getMap(t) 的作用是获取线程自己的变量 threadLocals,Threadlocal 变量被绑定到了线程的成员变量上。
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

// 创建当前线程的 threadLocals 变量。
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t); // 获取当前线程的threadLocals变量
if (map != null) { // 如果threadLocals不为null,则返回对应本地变量的值
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue(); // threadLocals为空则初始化当前线程的threadLocals成员变量
}
1
2
3
4
5
6
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null) {
m.remove(this);
}
}


InheritableThreadLocal 类

实际上 ThreadLocal 是不支持继承性的,来看示例:

1
2
3
4
5
6
public static void main(String[] args) {
localVar.set("haha");
Thread thread = new Thread(() -> System.out.println("thread:" + localVar.get()));
thread.start();
System.out.println("main:" + localVar.get());
}
1
2
main:haha
thread:null

同一个 ThreadLocal 变量在父线程中被设置值后,在子线程中是获取不到的。 这应该是正常现象,因为在子线程 thread 里面调用 get 方法时当前线程 为 thread 线程,而这里调用 set 方法设置线程变量的是 main 线程,两者是不同的线程,自然子线程访问时返回 null。那么有没有办法让子线程能访问到父线程中的值?答案是有,那就是 InheritableThreadLocal,它继承自 ThreadLocal,自身提供了一个特性,就是让子线程可以访问在父线程中设置的本地变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 在 InheritableThreadLocal 的世界里,变量 inheritableThreadLocals 替代了 threadLocals。
public class InheritableThreadLocal<T> extends ThreadLocal<T> {
public InheritableThreadLocal() {}

// (1)
protected T childValue(T parentValue) {
return parentValue;
}

// (2) 当调用 get 方法获取当前线程内部的 map 变量时,获取 的是 inheritableThreadLocals 而不再是 threadLocals。
ThreadLocalMap getMap(Thread t) {
return t.inheritableThreadLocals;
}

// (3) 重写了 createMap 方法,那么现在当第 一次调用 set 方法时,创建的是当前线程的 inheritableThreadLocals 变量的实例而不再是 threadLocals。
void createMap(Thread t, T firstValue) {
t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
}
}

下面我们看一下重写的代码 (1) 何时执行,以及如何让子线程可以访问父线程的本 地变量。这要从创建 Thread 的代码说起,打开 Thread 类的默认构造函数,最终都会执行到这里,代码如下。

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
/**
* Thread 中:
*/
private Thread(ThreadGroup g, Runnable target, String name,
long stackSize, AccessControlContext acc,
boolean inheritThreadLocals) {
// ...
// 获取了当前线程(这 里是指 main 函数所在的线程,也就是父线程)
Thread parent = currentThread(); // 获取当前线程
// ...
if (inheritThreadLocals && parent.inheritableThreadLocals != null) // 如果父线程的inheritableThreadLocals变量不为null
this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals); // 设置子线程中的inheritableThreadLocals变量
this.stackSize = stackSize;
this.tid = nextThreadID();
}

// 在 createInheritedMap 内 部 使 用 父 线 程 的 inheritableThreadLocals 变量作为构造函数创建了一个新的 ThreadLocalMap 变量,然后赋值给了子线程的 inheritableThreadLocals 变量。
static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
return new ThreadLocalMap(parentMap);
}


/**
* ThreadLocal 中:
*/
private ThreadLocalMap(ThreadLocal.ThreadLocalMap parentMap) {
ThreadLocal.ThreadLocalMap.Entry[] parentTable = parentMap.table;
int len = parentTable.length;
setThreshold(len);
table = new ThreadLocal.ThreadLocalMap.Entry[len];

for (ThreadLocal.ThreadLocalMap.Entry e : parentTable) {
if (e != null) {
@SuppressWarnings("unchecked")
ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
if (key != null) {
// 调用重写的方法
Object value = key.childValue(e.value);
ThreadLocal.ThreadLocalMap.Entry c = new ThreadLocal.ThreadLocalMap.Entry(key, value);
int h = key.threadLocalHashCode & (len - 1);
while (table[h] != null)
h = nextIndex(h, len);
table[h] = c;
size++;
}
}
}
}

InheritableThreadLocal 类通过重写代码(2)和(3)让本地变量保存到了具体线程的 inheritableThreadLocals 变量里面,那么线程在通过 InheritableThreadLocal 类实例 的 set 或者 get 方法设置变量时,就会创建当前线程的 inheritableThreadLocals 变量。当父 线程创建子线程时,构造函数会把父线程中 inheritableThreadLocals 变量里面的本地变量 复制一份保存到子线程的 inheritableThreadLocals 变量里面。

1
2
3
4
5
6
7
8
9
10
11
private static ThreadLocal<String> localVar = new InheritableThreadLocal<>();

public static void main(String[] args) {
localVar.set("haha");
Thread thread = new Thread(() -> {
System.out.println("t1:" + localVar.get());
new Thread(() -> System.out.println("t2:" + localVar.get())).start();
}, "t1");
thread.start();
System.out.println("main:" + localVar.get());
}
1
2
3
main:haha
t1:haha
t2:haha

可以看到,现在可以子线程、孙子线程 也能正常获取到线程变量的值了。这个 InheritableThreadLocal 应用场景有哪些呢?

  • 比如子线程需要使用存放在 threadlocal 变量中的用户登录信息
  • 再比如一些中间件需要把统一的 id 追踪的整个调用链路记录下来。

其实子线程使用父线程中的 threadlocal 方 法有多种方式,比如创建线程时传入父线程中的变量,并将其复制到子线程中,或者在父线程中构造一个 map 作为参数传递给子线程,但是在这些情况下使用 InheritableThreadLocal 无疑是更加简便的。