快速入门
为什么要学习并发编程
硬件革命:从主频竞赛到多核时代
学习并发编程的首要动因,源于硬件发展路线的根本性转变。大约在 2005 年前后,CPU 单核主频的提升逐渐触及物理极限和功耗瓶颈,经典意义上的“摩尔定律”在单核性能层面开始失效。
硬件工程师的应对之策,是将多个计算核心集成到同一颗芯片之上。自此,无论是个人电脑还是服务器,CPU 的核心数量都开始快速增长。然而,这也带来了新的问题:单线程程序在一颗 8 核 CPU 上,理论上只能利用约 12.5% 的算力,造成了巨大的资源浪费。
并发编程,正是将多核 CPU 的理论计算能力转化为实际应用性能的关键技术手段。
现实需求:性能、响应性与复杂业务场景
其次,业务规模的扩张和技术架构的演进,也对软件提出了更直接、更现实的要求。
- 提升性能与吞吐量:无论是处理海量数据(如图像处理、大数据分析),还是支撑高并发访问的 Web 服务,并发编程都能将大任务拆分为多个子任务并行执行,从而显著缩短整体处理时间。
- 保障系统响应性:在图形界面应用或实时系统中,任何耗时操作(如网络请求、磁盘 I/O)都不应阻塞主线程。并发编程允许这些任务在后台执行,确保前台交互始终流畅。
- 契合现代软件架构:微服务和分布式系统本质上是一种更宏观层面的并发模型。在服务内部,从 Web 服务器(如 Tomcat)到主流框架(如 Spring),其默认工作机制本身就是多线程的。如果缺乏并发知识,就难以真正理解这些系统的运行原理。
职业发展:区分编程能力层级的重要标尺
最后,从个人成长和职业发展的角度来看,并发编程是衡量开发者技术深度和问题解决能力的重要维度。
- 应对复杂系统挑战:并发编程涉及操作系统、内存模型、CPU 调度等底层原理。编写正确的并发程序,必须严谨地处理资源竞争、死锁、内存可见性等问题,这对开发者构建稳健系统的能力是一次全面锻炼。
- 提升职业竞争力:在大厂技术面试中,并发几乎是必考内容;而在系统优化、性能调优和线上故障排查等高阶工作中,对并发问题的分析与定位能力更是不可或缺。它标志着一个开发者是否能够从“实现功能”,迈向“设计高性能、高可靠系统”的层次。
基础概念
进程和线程
进程:从静态代码到动态实例
我们日常接触的应用程序(App),本质上是由指令和数据构成的集合。在未被激活前,它们只是静止在磁盘、U盘或网络存储中的二进制代码。然而,一旦程序被运行,操作系统便会将指令载入 CPU、将数据载入内存,并协调磁盘与网络等 I/O 设备,这种为了加载指令、管理内存与 I/O 而开启的动态实体,便是进程。
从这个角度看,程序是静态且沉睡的,而进程则是活生生的、动态运行着的程序实例。进程赋予了代码生命:有些程序支持开启多个实例(如浏览器、记事本),有些则出于逻辑限制仅允许单实例运行。在系统内部,进程又被划分为负责维持底层功能的“系统进程”和由用户发起的“用户进程”。归根结底,站在操作系统的视角,进程不仅是程序运行的载体,更是操作系统进行资源分配(尤其是内存空间)的最小单位。
线程:CPU 调度的精灵
在多任务并行的环境下,有限的 CPU 核心必须在海量程序之间频繁切换。为了实现这种高效的资源协调,操作系统引入了 CPU 调度机制,而线程正是这种调度的最小单位。
作为进程内部的执行实体,线程无法脱离进程独立存在。它被形象地称为“轻量级进程(Lightweight Process)”——在早期 Linux 内核中,线程的实现几乎是复用了进程的逻辑,直到后来才演化出独立的 API 规范。与进程不同,线程极其“精简”,它本身几乎不拥有系统资源,仅维持运行所必需的程序计数器、寄存器和栈等上下文信息。然而,这种简练背后隐藏着强大的协作能力:同一进程下的所有线程共享该进程拥有的全部资源,如内存地址空间和打开的文件描述符。
这种架构决定了进程与线程在协作模式上的本质差异。进程是相互独立的“资源容器”,彼此之间筑有严格的隔离墙,跨进程通信(IPC)无论是本地的管道、信号量,还是跨网络的 HTTP 协议,都显得沉重且复杂。相比之下,线程则是进程的子集,由于它们共处一室、共享内存,线程间的通信异常便捷,往往通过读写同一个变量即可完成。但也正因如此,线程的上下文切换成本远低于进程,在带来高性能的同时,也要求开发者必须更谨慎地处理多线程间的资源竞争。
CPU核心数和线程数的关系
目前主流 CPU 均采用多核架构,而线程作为 CPU 调度的最小单位,其运行数量与核心数密切相关。在物理层面,一个 CPU 内核在同一时刻只能运行一个线程,即内核数与同时运行的线程数是 1:1 的关系。这意味着,一颗 8 核 CPU 在物理上可以同时执行 8 个线程的代码。
但随着 Intel 引入超线程技术,“逻辑处理器”的概念打破了这种比例,使核心数与线程数形成了 1:2 的关系。这也就是为什么在 Windows 任务管理器中,我们经常能看到内核数为 6、而逻辑处理器数为 12 的现象。
在 Java 编程实践中,我们可以通过 Runtime.getRuntime().availableProcessors() 来获取当前的 CPU 核心数。需要注意的是,这个方法返回的其实是逻辑处理器的数量。在并发编程中,准确获取核心数非常重要,因为后续所有的性能优化和资源调配,往往都与这个数值密切相关。
上下文
操作系统在多个进程或线程之间进行调度时,由于每个线程在使用 CPU 时都要利用其中的资源(如 CPU 寄存器和程序计数器),为了保证线程在调度前后能正常执行,就有了“上下文切换”的概念。简单来说,它就是 CPU 从一个执行任务转移到另一个任务的过程。
上下文就是 CPU 寄存器和程序计数器在任何时间点的内容。
- 寄存器:是 CPU 内部极快的内存,通过存储常用值来加快程序执行。
- 程序计数器:是一种专门的寄存器,指示 CPU 当前执行到了哪一行指令,或者下一条该执行哪行。
上下文切换的具体活动:内核(操作系统的核心)对 CPU 上的进程或线程执行以下操作:
- 暂停当前进程,并将该进程的 CPU 状态(即上下文)存储在内存的某个地方。
- 从内存中获取下一个进程的上下文,并在 CPU 的寄存器中恢复它。
- 返回到程序计数器指示的位置,恢复进程执行。
从数据角度看,程序员视角下的上下文是方法调用中的局部变量与资源;而从线程视角看,则是调用栈中存储的各类信息。
上下文切换的成本:上下文切换通常是计算密集型的,因为它涉及数据在寄存器和缓存中的来回拷贝。就 CPU 时间而言,一次切换大概需要 5,000 到 20,000 个时钟周期。相比之下,一个简单指令的执行仅需几个到十几个时钟周期。可见,频繁的上下文切换成本是非常巨大的。
并发和并行
什么是并发 (Concurrent)?
并发是指应用能够交替执行不同的任务。例如,在单核 CPU 下执行多线程,其实并不是真正的同时执行。系统是以你几乎察觉不到的速度,在两个任务之间不断切换,从而达到一种“同时执行”的视觉效果。这种线程轮流使用 CPU 的做法,我们可以总结为一句话:微观串行,宏观并行。
什么是并行 (Parallel)?
并行是指应用能够同时执行不同的任务。生活中最简单的例子就是:你一边吃饭一边打电话,这两件事在同一时刻都在进行。在多核 CPU 环境下,每个核心(core)都可以独立调度运行线程,此时线程之间就是真正的并行关系。
线程的使用
Java 天生就是多线程的
很多人认为,一个 Java 程序从 main() 方法开始按顺序执行,就是一个单线程程序。但事实并非如此,Java 从诞生之初就是多线程的。执行 main() 方法的确实是一个名为 main 的线程,但在它背后,JVM 还默默启动了许多支撑程序运行的辅助线程。
我们可以通过以下代码,利用 Java 虚拟机提供的线程管理接口 ThreadMXBean,来观察一个简单的“Hello World”背后到底运行着多少线程:
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
public class MainThread {
public static void main(String[] args) {
// 获取 Java 虚拟机线程系统的管理接口
ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean();
// 仅获取线程和线程堆栈信息,不需要同步的 monitor 等信息
ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
// 遍历并打印线程 ID 和线程名称
for (ThreadInfo threadInfo : threadInfos) {
System.out.println("[" + threadInfo.getThreadId() + "] " + threadInfo.getThreadName());
}
}
}运行这段代码,你会发现即便我们没有手动开启任何线程,控制台也会输出一组线程信息。
- [1] main:主线程,执行你写的代码入口。
- [9] Reference Handler:引用处理器,负责处理各种引用对象的清理。
- [10] Finalizer:终结者线程,负责调用对象的
finalize方法。 - [11] Signal Dispatcher:信号分发器,处理操作系统发给 JVM 的各种信号。
- [18] Common-Cleaner:这是 Java 9 引入的,用于更高效地清理过期对象(它是
Cleaner机制的核心)。 - [19] Monitor Ctrl-Break:这是由 IDE(如 IntelliJ IDEA)注入的线程,用于监听中断信号。
- [20] Notification Thread:通知线程,通常用于 JMX(Java 管理扩展)的通知发送。
正是因为有了这些 JVM 自行启动的背景线程,Java 才能实现垃圾回收、信号处理和性能监控等核心功能。这有力地证明了:Java 程序天生就是多线程的。
线程的简单使用
无返回值的任务:Thread 与 Runnable
这是最基础的两种创建方式。虽然可以直接继承 Thread 类,但通常更推荐实现 Runnable 接口,因为这样可以将任务逻辑与线程对象解耦,方便后续将任务提交给线程池处理。
方式一:直接使用 Thread 类或者继承 Thread 类。
// 使用 Thread 类或其子类,无返回值
Thread t1 = new Thread("t1") {
@Override
public void run() {
// 要执行的任务
System.out.println("执行 Thread 任务");
}
};
// 启动线程
t1.start();方式二:实现 Runnable 接口 这种方式将任务(Runnable)和执行器(Thread)分开,代码结构更清晰。
// 实现 Runnable 接口,任务与线程分离
Runnable runnable = () -> {
System.out.println("执行 Runnable 任务");
};
// 创建线程对象并传入任务
Thread t2 = new Thread(runnable, "t2");
t2.start();有返回值的任务:FutureTask
如果你希望在线程执行完后拿到一个计算结果,就需要用到 FutureTask。它接收一个 Callable 接口,其 call 方法支持返回值,并且可以抛出异常。
方式三:FutureTask 配合 Thread 这种方式可以看作是增强版的任务定义,适合需要异步计算并获取结果的场景。
// 使用 FutureTask 包装 Callable 任务
FutureTask<Integer> t3 = new FutureTask<>(() -> {
System.out.println("执行 FutureTask 任务");
return 100;
});
// 参数1 是任务对象; 参数2 是线程名字。使用线程名方便调试
new Thread(t3, "t3").start();
// get() 方法会阻塞主线程,直到子线程任务执行完毕并返回结果
Integer result = t3.get();
System.out.println("结果是: " + result);注意:
- 启动开关:无论使用哪种方式,只有调用了 start() 方法,操作系统才会真正创建一个新线程去执行代码。如果直接调用 run(),它只会在当前线程中作为一个普通方法运行。
- 线程命名:在实际开发中,强烈建议为每个线程起一个有意义的名字(如上述代码中的 "t3")。当程序出现问题需要查看堆栈日志时,清晰的名称能帮你快速定位故障点
- 阻塞风险:FutureTask 的 get() 方法是同步阻塞的。如果子线程执行时间过长,主线程会一直卡在获取结果的地方,在复杂的并发场景中需要注意超时控制。
在理解了如何创建线程后,我们有必要深入了解一下这几个核心接口与类之间的逻辑关系。它们的设计非常精巧,解决了“如何让线程拥有返回值”的问题。

Runnable 是一个接口,其中只声明了一个 run() 方法。由于该方法的返回值为 void 类型,因此在执行完任务之后无法返回任何结果。相比之下,Callable 位于 java.util.concurrent 包下,它同样是一个接口,但声明的是 call() 方法。这是一个泛型接口,call() 函数返回的类型由传递进来的泛型参数 V 决定。
Future 接口则负责对具体的 Runnable 或 Callable 任务的执行结果进行管理,它的核心功能包括:
- 取消任务的执行
- 查询任务是否完成
- 获取任务的执行结果(调用 get() 方法会阻塞,直到任务返回结果)
由于 Future 本身只是一个接口,无法直接创建对象使用,因此就有了 FutureTask。FutureTask 的设计非常巧妙,它实现了 RunnableFuture 接口,而 RunnableFuture 同时继承了 Runnable 和 Future。这种继承关系决定了 FutureTask 的双重身份:
- 作为 Runnable,它可以被 Thread 线程执行。
- 作为 Future,它可以拿到 Callable 运行后的返回值。
在实际开发中,由于 Thread 类的构造方法只支持传入 Runnable 实例,而不支持直接传入 Callable。为了让 Thread 能运行具有返回值的任务,我们需要通过 FutureTask 进行中转:
- 把一个 Callable 实例包装成 FutureTask。
- 将这个 FutureTask 作为 Runnable 传递给 Thread。
- 通过 FutureTask 对象的 get() 方法拿到最终的运算结果。
实例化一个 FutureTask 通常有两种方法。一种是直接传入一个 Callable 实例,这是最常用的方式;另一种是传入一个 Runnable 任务并预设一个返回值,当任务执行完后,get() 方法会返回该预设值。
常见问题
创建线程有几种方式?
这个答案在技术圈确实版本繁多,有说 2,3,4,5 种的,从“官方定义”与“底层本质”两个维度来切入。
关于创建线程的方式,参考 Thread 类源码上的注释:官方认为 Java 中创建执行线程的方式有两种。
- 派生自 Thread 类:即继承 Thread 类并重写其 run() 方法。
- 实现 Runnable 接口:定义任务逻辑并将其作为参数传递给 Thread 的构造函数。
当然,虽然在实际开发中我们经常用到 Callable 或线程池,但深入到底层逻辑时,你会发现:
- 本质只有一种方式:无论表面上怎么封装,最终都是通过
new Thread()创建对象,并调用start()方法来请求操作系统开启新线程。 - Callable 的本质:基于 Callable 接口的方式,最终需要通过 FutureTask 包装成 Runnable。因此,它在本质上可以归类为实现 Runnable 接口的一种变体,只是增加了返回值和异常处理的能力。
- 线程池的本质:线程池(ThreadPool)属于池化技术,核心在于对线程资源的复用和任务调度。它管理的是已经存在的线程,而不是一种“新”的线程创建方式。
总结来说,遵循官方的说法 —— 即“有两种方式创建线程用以执行”——是最严谨的回答。
run 和 start 运行的区别
Thread 类是 Java 对线程概念的抽象。可以这样理解:通过 new Thread() 创建的只是一个普通的 Java 实例对象,此时它并没有与操作系统中真正的线程产生联系。只有在执行了 start() 方法后,才算实现了真正意义上的启动线程。
从 Thread 的源码中可以看到,start() 方法内部调用了 start0()。由于 start0() 是一个 native 方法,这说明线程的启动是深度依赖操作系统底层实现的。
在执行逻辑上,start() 与 run() 有着本质的区别:
- start() 方法:它负责让线程进入就绪队列等待 CPU 分配资源。一旦获取到 CPU 时间片,系统会自动调用我们实现的
run()方法。需要注意的是,start()方法不能重复调用,否则会抛出IllegalThreadStateException异常。 - run() 方法:它是业务逻辑的实现地。本质上,
run()与普通类的成员方法没有任何区别,它可以被单独调用,也可以重复执行。但要记住,直接调用run()只会在当前线程中同步执行代码,并不会开启新的线程。
总结来说,start() 是启动引擎,而 run() 是引擎启动后要执行的任务。
线程的生命周期
操作系统层面的五种状态

在操作系统层面,线程的生命周期通常被划分为五种状态。这种描述方式更接近硬件与内核的实际调度逻辑。
- 初始状态:仅在编程语言层面创建了线程对象(如
new Thread()),此时尚未与操作系统内核线程建立关联。 - 可运行状态(就绪状态):线程已经与操作系统线程关联,具备了运行条件,正在等待 CPU 的调度。
- 运行状态:线程获取了 CPU 时间片,正在执行指令。当时间片用完后,线程会退回“可运行状态”,这会导致上下文切换。
- 阻塞状态:线程调用了阻塞 API(如 BIO 读写文件)。此时线程不再占用 CPU,也会触发上下文切换。当 I/O 操作完成后,系统会唤醒线程,将其转为“可运行状态”等待调度。
- 终止状态:线程执行完毕,生命周期结束,不会再转换为其他任何状态。
Java API 层面的六种状态

在 Java 中,线程的状态定义在 Thread.State 枚举中,共分为六种。相比于操作系统模型,Java 将“就绪”和“运行”两种状态笼统地合并为了“RUNNABLE”。
- 初始 (NEW):线程对象已创建,但尚未调用
start()方法。 - 运行 (RUNNABLE):这是一个复合状态。它包括了正在等待 CPU 调度的“就绪”状态和正在执行的“运行中”状态。
- 阻塞 (BLOCKED):线程因为在等待获取一个监视器锁(Monitor Lock)而进入的状态。
- 等待 (WAITING):线程进入无限期等待状态,需要等待其他线程做出特定动作(如通知
notify或中断)。 - 超时等待 (TIMED_WAITING):与 WAITING 类似,但在指定时间后会自动返回,不再需要无限期等待。
- 终止 (TERMINATED):线程执行任务完毕后的状态。
线程的常见方法
常用实例方法
这些方法通常作用于具体的线程对象。
| 方法名 | 功能说明 | 注意事项 |
|---|---|---|
| start() | 启动新线程 | 让线程进入就绪状态,代码不一定立即运行。每个对象只能调用一次,否则抛出 IllegalThreadStateException。 |
| run() | 线程启动后执行的代码 | 如果传入了 Runnable 则调用其 run,否则默认无操作。子类可以覆盖此方法。 |
| join() / join(long n) | 等待线程结束 | 使当前线程等待目标线程运行结束,带参数的版本表示最多等待 n 毫秒。 |
| getId() | 获取线程 ID | ID 是线程的唯一长整型标识。 |
| getName() / setName() | 获取或修改线程名 | 建议在创建线程时设置具有业务意义的名称。 |
| getPriority() / setPriority() | 获取或修改优先级 | 范围 1~10,数值越大被 CPU 调度的概率通常越高。 |
| getState() | 获取线程状态 | 返回前面提到的 NEW, RUNNABLE, BLOCKED 等 6 种状态之一。 |
| isAlive() | 判断线程是否存活 | 只要线程还没有运行完毕,就返回 true。 |
| interrupt() | 中断线程 | 如果线程在 sleep、wait、join 过程中被中断,会抛出异常并清除中断标记;如果是正常运行的线程被中断,则会设置中断标记。 |
| isInterrupted() | 判断是否被中断 | 仅查询中断状态,不会清除中断标记。 |
常用静态方法
静态方法通过 Thread.methodName() 调用,通常作用于当前正在执行的线程。
| 方法名 | 功能说明 | 注意事项 |
|---|---|---|
| currentThread() | 获取当前执行的线程 | 返回当前代码正在哪个线程中运行。 |
| sleep(long n) | 强制休眠 | 让当前线程休眠 n 毫秒,期间会让出 CPU 时间片给其他线程。 |
| yield() | 启发式让出 CPU | 提示调度器当前线程可以交出 CPU 使用权,但调度器可能会忽略这个提示。 |
| interrupted() | 判断并清除中断位 | 返回当前线程的中断状态,并且会清除中断标记(将标记重置为 false)。 |
不推荐使用的过时方法
在 Thread 类中,有一些方法已经被官方标记为过时(Deprecated)。这些方法在调用时容易破坏代码的同步性,甚至造成线程死锁,因此在开发中应坚决避免。
- stop():强行停止线程。这会导致线程立即释放持有的所有锁,可能使对象处于状态不一致的情况。
- suspend():挂起(暂停)线程。它在暂停时不释放锁,如果另一个线程需要这把锁来唤醒它,就会发生死锁。
- resume():恢复运行。它必须配合 suspend() 使用,同样存在死锁风险。
在处理线程中断时,interrupt() 是最推荐的方式。它通过“打招呼”的方式协作停止线程,而不是像 stop() 那样暴力地中断程序逻辑。
sleep 与 yield
sleep 方法
调用 sleep 会让当前线程从 Running 进入 Timed Waiting(阻塞)状态。
- 不释放锁:如果当前线程持有某个对象的锁,
sleep期间它依然握着这把锁,其他线程无法进入该对象的同步代码块。 - 可被打断:其他线程可以使用
interrupt方法打断正在睡眠的线程,此时sleep会抛出InterruptedException。 - 不保证立即执行:睡眠结束后,线程会进入就绪队列,未必会立刻得到 CPU 执行权,这取决于系统的调度。
- 可读性建议:在实际开发中,建议使用
TimeUnit.SECONDS.sleep(3)代替Thread.sleep(3000),这样代码的可读性更好。
Thread t1 = new Thread(() -> {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行完成");
}, "t1");
t1.start();
System.out.println("线程 t1 刚启动的状态:" + t1.getState());
// 主线程短暂睡眠,确保 t1 已经进入 sleep
Thread.sleep(500);
System.out.println("线程 t1 正在 sleep 的状态:" + t1.getState());输出:
线程 t1 刚启动的状态:RUNNABLE
线程 t1 正在 sleep 的状态:TIMED_WAITING
执行完成实际应用:防止 CPU 空转 在没有利用 CPU 进行计算时,不要让 while(true) 这种循环空转,否则会白白浪费 CPU 资源。此时可以使用 sleep 来让出 CPU:
while(true) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}虽然 wait 或条件变量也能达到类似效果,但它们通常需要加锁并配合唤醒操作。sleep 则更适用于无需锁同步、单纯想降低 CPU 占用率的场景。
yield 方法
调用 yield 会释放 CPU 资源,让当前线程从 Running 进入 Runnable(就绪)状态,不会释放对象锁。
- 状态差异:线程只是退回到就绪队列,而不是阻塞。这意味着它依然有权利争抢下一次的 CPU 时间片。
- 调度偏好:它会把执行机会让给优先级更高(或至少相同)的线程。如果没有其他符合条件的线程,调用
yield的线程可能会被调度器再次选中立即执行。 - 依赖操作系统:
yield只是给调度器一个“提示”,具体的实现完全依赖于操作系统的任务调度器,调度器甚至可以忽略这个提示。
实战案例:ConcurrentHashMap 中的优化 在 ConcurrentHashMap 的 initTable 方法中,我们就看到了 yield 的巧妙运用:
/**
* Initializes table, using the size recorded in sizeCtl.
*/
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
if ((sc = sizeCtl) < 0)
Thread.yield(); // lost initialization race; just spin
else if (U.compareAndSetInt(this, SIZECTL, sc, -1)) {
try {
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2);
}
} finally {
sizeCtl = sc;
}
break;
}
}
return tab;
}当多个线程尝试同时初始化 table 时,由于初始化操作只允许一个线程完成,其他线程必须等待。但初始化过程通常极快,如果直接让这些线程进入阻塞状态(如使用锁或等待),会引发昂贵的上下文切换开销。
为了性能最优,Doug Lea 大师让这些不执行初始化操作的线程执行 yield()。这样它们只是暂时交出 CPU,让执行初始化的线程能跑得更快,一旦初始化完成,这些线程可以迅速恢复运行,避免了深度阻塞带来的性能损耗。
核心对比
| 特性 | sleep | yield |
|---|---|---|
| 状态转换 | Running -> Timed Waiting (阻塞) | Running -> Runnable (就绪) |
| 调度机率 | 睡眠结束后需重新排队 | 给相同或更高优先级的线程机会 |
| 打断机制 | 会抛出 InterruptedException | 不会抛出异常 |
| 适用场景 | 固定时间的暂停、防 CPU 空转 | 短时间的让步、优化上下文切换开销 |
线程的优先级
线程优先级的作用是向调度器建议优先调度该线程,但它仅仅是一个提示,调度器完全可以忽略它。在实际运行中,如果 CPU 比较繁忙,优先级高的线程通常会获得更多的时间片;但在 CPU 闲置时,优先级的高低几乎不起作用。
在 Java 中,线程优先级通过一个整型成员变量 priority 来控制,范围从 1 到 10,在构建线程时,可以通过 setPriority(int) 方法进行修改,每个线程的默认优先级是 5,理论上,优先级高的线程分配到的时间片数量会多于优先级低的线程。
设置策略与差异性
在设置线程优先级时,通常遵循以下经验法则:
- 频繁阻塞的线程(如进行休眠或 I/O 操作):建议设置较高的优先级。
- 偏重计算的线程(需要大量 CPU 时间进行运算):建议设置较低的优先级,以确保处理器不会被单个任务独占。
需要注意的是,线程规划在不同的 JVM 以及操作系统上存在显著差异。有些操作系统甚至会完全忽略 Java 对线程优先级的设定。因此,编写程序时不能过度依赖优先级来保障业务逻辑的正确性。
我们可以通过以下代码观察优先级对线程执行频率的影响。你可以尝试取消代码中关于优先级设置的注释,观察控制台输出频率的变化。
Runnable task1 = () -> {
int count = 0;
for (;;) {
System.out.println("t1---->" + count++);
}
};
Runnable task2 = () -> {
int count = 0;
for (;;) {
// 也可以配合 yield() 观察对调度的影响
// Thread.yield();
System.out.println("t2---->" + count++);
}
};
Thread t1 = new Thread(task1, "t1");
Thread t2 = new Thread(task2, "t2");
// 设置优先级:t1 最小,t2 最大
t1.setPriority(Thread.MIN_PRIORITY);
t2.setPriority(Thread.MAX_PRIORITY);
t1.start();
t2.start();join 方法
join 方法的核心作用是等待调用该方法的线程执行结束。它通常用于需要等待异步线程的处理结果,才能继续运行后续逻辑的场景。
为什么需要 join?
我们先看一段代码,观察在多线程并行的情况下,打印出的 count 结果是什么:
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
System.out.println("开始执行");
Thread t1 = new Thread(() -> {
System.out.println("t1 开始执行");
try {
// 模拟耗时任务
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
count = 5;
System.out.println("t1 执行完成");
},"t1");
t1.start();
System.out.println("结果为: " + count);
System.out.println("main 执行完成");
}实际输出的结果为 0。原因在于主线程和线程 t1 是并行执行的,t1 线程需要 1 秒之后才能完成计算并赋值 count=5。而主线程在启动 t1 后立即就去尝试打印 count 的结果,此时主线程“跑”得更快,拿到的自然是初始值 0。
为了拿到正确的结果,主线程必须等待 t1 执行完毕。通过在 t1.start() 之后立即调用 t1.join(),会使主线程进入等待状态,直到 t1 运行结束。
从调用方的角度来看,这涉及到同步与异步的概念:
- 同步:需要等待结果返回,才能继续运行。
- 异步:不需要等待结果返回,就能继续运行。
t1.start();
// 实现同步等待
t1.join();
// 此时结果为 5
System.out.println("结果为: " + count);
System.out.println("执行完成");如何保证 T1、T2、T3 顺序执行?
我们可以利用 join() 方法将两个交替执行的线程“合并”为顺序执行。具体做法是在 T2 中调用 t1.join(),在 T3 中调用 t2.join()。
Thread t1 = new Thread(() -> {
System.out.println("线程 t1 执行完成");
}, "t1");
Thread t2 = new Thread(() -> {
try {
t1.join(); // 只有 t1 结束,t2 才会继续
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程 t2 执行完成");
}, "t2");
Thread t3 = new Thread(() -> {
try {
t2.join(); // 只有 t2 结束,t3 才会继续
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("线程 t3 执行完成");
}, "t3");
t1.start();
t2.start();
t3.start();即使三个线程同时被启动,通过 join 的链式等待,它们最终会按照 T1 -> T2 -> T3 的逻辑顺序执行完剩下的代码。
守护线程
默认情况下,Java 进程需要等待所有线程都运行结束,才会退出。但有一种特殊的线程叫做守护线程(Daemon Thread),只要其它非守护线程(用户线程)运行结束了,即使守护线程的代码没有执行完,也会被强制结束。
System.out.println("开始运行...");
Thread t1 = new Thread(() -> {
System.out.println("t1 开始运行...");
try {
Thread.sleep(3000); // 模拟耗时 3 秒的任务
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t1 运行结束...");
}, "t1");
// 设置 t1 线程为守护线程
t1.setDaemon(true);
t1.start();
try {
Thread.sleep(1000); // 主线程只运行 1 秒
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("main 运行结束...");输出分析: 在上述程序中,主线程(main)在 1 秒后运行结束,此时唯一的非守护线程消失了。尽管守护线程 t1 还需要 2 秒才能执行完,但 JVM 会直接强制退出,因此控制台不会打印出 “t1 运行结束...” 这行字。
守护线程的应用场景
守护线程的作用非常重要,它主要用于支撑系统运行的后台任务。
- 垃圾回收器(GC):JVM 中的垃圾回收器就采用了守护线程。如果一个程序中没有任何用户线程了,自然也就不会再产生垃圾,垃圾回收器也就不需要继续工作,随 JVM 一起退出即可。
- 心跳检测与事件监听:在很多中间件中,负责定时异步执行的心跳检测、健康检查或事件监听任务通常被设置为守护线程。当主进程退出时,这些后台任务会自动终止,不会阻止 JVM 的关闭。
- 后台清理任务:例如缓存的定期清理、临时文件的删除等。
总的来说,如果你有一些后台任务,希望它们在程序主逻辑结束时自动“陪葬”,而不要阻碍 JVM 进程的正常关闭,那么采用守护线程是最佳选择。
线程的终止
线程的自然终止通常有两种情况:一是 run() 方法中的逻辑执行完毕;二是线程执行过程中抛出了一个未处理的异常,导致线程提前结束。
关于如何正确终止正在运行的线程,首先要明确的是,绝对不能使用 stop() 方法。尽管它能立即释放 CPU 和锁资源,但它是极度不安全的。stop() 会导致线程立即抛出 ThreadDeath 异常,这种强制释放锁的行为是不可控的,容易导致数据不一致。
例如在转账场景中,如果 1 号账户扣款后线程被强制 stop,2 号账户将无法完成加钱,而此时锁已释放,其他线程看到的是一个错误的状态:
public class ThreadStopDemo {
private static final Object lock = new Object();
private static int account1 = 1000;
private static int account2 = 0;
public static void main(String[] args) throws InterruptedException {
Thread threadA = new Thread(() -> {
synchronized (lock) {
try {
System.out.println("开始转账...");
// 1号账户减少
account1 -= 100;
// 模拟转账中的耗时
Thread.sleep(100);
// 2号账户增加
account2 += 100;
System.out.println("转账结束...");
} catch (Throwable t) {
System.out.println("线程A被强制结束");
t.printStackTrace();
}
}
}, "threadA");
threadA.start();
// 等待线程A开始执行
Thread.sleep(50);
// 强制停止线程A
threadA.stop();
System.out.println("1号账户余额: " + account1);
System.out.println("2号账户余额: " + account2);
}
}安全的中止方式是利用中断机制(Interrupt)。这是一种协作机制,调用 interrupt() 相当于给线程打个招呼,告诉它应该中断了,但线程可以自行决定何时停止。线程通过检查中断标志位来响应:
- isInterrupted():判断线程是否被中断,不会清除标志位。
- Thread.interrupted():静态方法,判断当前线程是否被中断,且会清除中断标志位(重置为 false)。
如果线程处于阻塞状态(如调用了 sleep、join 或 wait),检测到中断后会抛出 InterruptedException 异常,并立即清除中断标志位。
不建议自定义标志位(如 volatile boolean)来中止线程,原因如下:
- 原生中断机制能唤醒处于 sleep 等阻塞状态的线程,而自定义标志位必须等待阻塞结束才能检查。
- sleep 等方法本身就支持中断检查,使用中断位可以避免重复定义变量,减少资源消耗。
需要注意,处于死锁状态的线程无法被中断。
中断正常运行的线程时,中断状态不会被清空:
Thread t1 = new Thread(() -> {
while (true) {
Thread current = Thread.currentThread();
boolean interrupted = current.isInterrupted();
if (interrupted) {
System.out.println("中断状态: " + interrupted);
break;
}
}
}, "t1");
t1.start();
// 中断线程 t1
t1.interrupt();
System.out.println("主线程检测 t1 中断状态: " + t1.isInterrupted());如果是中断处于 sleep 等阻塞状态的线程,由于抛出异常后会清除状态,结果会有所不同:
Thread t1 = new Thread(() -> {
while (true) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
// 抛出异常后,中断标志位会被清空重置为 false
e.printStackTrace();
}
}
}, "t1");
t1.start();
// 主线程休眠,确保 t1 先进入 sleep
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 中断线程 t1
t1.interrupt();
// 此时输出大概率为 false,因为标志位已被异常机制清除
System.out.println("中断状态: " + t1.isInterrupted());为了避免阻塞状态的线程,由于抛出异常后会清除状态,我们需要使用两阶段终止模式,在并发编程中,这是一个非常经典且实用的设计模式,专门用来解决“如何优雅地停止一个线程”的问题。
它的核心逻辑就像是一个员工离职:公司不能直接把他“踢出”大门(对应 stop()),而是先发一个离职通知(第一阶段),员工收到通知后,把手头的工作交接完、清理好办公桌,然后自己走人(第二阶段)。
在 Java 中实现两阶段终止,主要依赖于我们之前聊过的中断机制。其具体的实现逻辑如下:
- 第一阶段:发送信号。在主线程中调用目标线程的
interrupt()方法。这并不会强制杀掉线程,只是将其中断标志位设为true。 - 第二阶段:响应信号并处理。目标线程在循环中不断检查自己的中断状态。
- 如果线程处于正常运行状态,通过
isInterrupted()检测到中断,直接跳出循环。 - 如果线程处于阻塞状态(如
sleep),它会抛出InterruptedException。关键点在于:抛出异常后中断标志位会被清除,所以我们需要在catch块中手动再次调用interrupt(),把标志位补回来,这样下次循环判断时才能正确退出。
- 如果线程处于正常运行状态,通过
下面是一个具体的代码示例,演示了如何优雅地停止一个后台监控线程:
public class TwoPhaseTermination {
private Thread monitorThread;
// 启动监控线程
public void start() {
monitorThread = new Thread(() -> {
while (true) {
Thread current = Thread.currentThread();
// 检查中断标志位
if (current.isInterrupted()) {
System.out.println("正在清理资源并退出...");
break;
}
try {
// 模拟每秒执行一次监控逻辑
Thread.sleep(1000);
System.out.println("执行监控记录中...");
} catch (InterruptedException e) {
System.out.println("在睡眠中被中断,准备重置中断标志位");
// 重新设置中断标志位,因为 sleep 抛出异常后会清除它
current.interrupt();
}
}
}, "monitor");
monitorThread.start();
}
// 停止监控线程
public void stop() {
if (monitorThread != null) {
monitorThread.interrupt();
}
}
public static void main(String[] args) throws InterruptedException {
TwoPhaseTermination tpt = new TwoPhaseTermination();
tpt.start();
// 运行 3.5 秒后停止
Thread.sleep(3500);
System.out.println("主线程发起停止请求");
tpt.stop();
}
}这个模式之所以被称为“两阶段”,是因为它区分了发出指令和执行清理任务并退出这两个独立的过程。它的好处非常明显:
- 安全性:给予了线程清理资源(如关闭文件、释放数据库连接)的机会,避免了
stop()导致的数据损坏。 - 响应性:通过利用
InterruptedException,即使线程正处于sleep状态,也能迅速被唤醒并做出反应。
线程的调度机制
线程调度是指系统为线程分配 CPU 使用权的过程,主要调度方式有两种:
- 协同式线程调度:在这种模式下,线程执行的时间由线程本身来控制。线程把自己的工作执行完之后,要主动通知系统切换到另外一个线程上。这种方式实现起来比较简单,由于是做完才切换,基本没有线程同步的问题。但缺点非常致命:如果一个线程出了问题迟迟不交回控制权,整个程序就会一直阻塞在那里。
- 抢占式线程调度:在这种模式下,每个线程执行的时间以及是否切换都由系统决定。由于执行时间不可控,这种机制避免了“一个线程导致整个进程阻塞”的问题,系统的稳定性更高。
Java 采用的是抢占式调度。在 Java 中,虽然可以通过 Thread.yield() 让出 CPU 执行时间,但线程本身并没有办法主动“获取”执行时间。线程唯一能做的手段是设置优先级。Java 设置了 10 个级别的优先级(1~10),当多个线程同时处于就绪(Ready)状态时,优先级越高的线程越容易被系统选中并分配到时间片。
不过需要注意的是,优先级也仅仅是一个“提示”,最终的调度结果还是由操作系统的任务调度器说了算。
Java 线程模型
任何语言实现线程主要有三种方式:使用内核线程实现(1:1 实现),使用用户线程实现(1:N 实现),使用用户线程加轻量级进程混合实现(N:M 实现)。
内核线程实现
使用内核线程实现的方式也被称为 1:1 实现。内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。
由于内核线程的支持,每个线程都成为一个独立的调度单元,即使其中某一个在系统调用中被阻塞了,也不会影响整个进程继续工作,相关的调度工作也不需要额外考虑,已经由操作系统处理了。
局限性:
- 由于是基于内核线程实现的,各种线程操作(如创建、析构及同步)都需要进行系统调用。而系统调用的代价相对较高,需要在**用户态(User Mode)和内核态(Kernel Mode)**中来回切换。
- 每个语言层面的线程都需要有一个内核线程的支持,因此要消耗一定的内核资源(如内核线程的栈空间),系统支持的线程数量是有限的。
Kernel Mode 在内核模式下,执行代码可以完全且不受限制地访问底层硬件。它可以执行任何 CPU 指令和引用任何内存地址。内核模式通常为操作系统的最低级别、最受信任的功能保留。内核模式下的崩溃是灾难性的,会让整个电脑瘫痪。
User Mode 在用户模式下,执行代码不能直接访问硬件或引用内存。在用户模式下运行的代码必须委托给系统 API 来访问硬件或内存。由于这种隔离提供的保护,用户模式下的崩溃总是可恢复的。
Linux 系统中线程实现方式:
- LinuxThreads:linux/glibc 包在 2.3.2 之前只实现了 LinuxThreads。
- NPTL (Native POSIX Thread Library):为 POSIX 标准线程库。POSIX 标准定义了一套线程操作相关的函数库
pthread,用于方便程序员操作管理线程。pthread_create是类 Unix 操作系统创建线程的函数。 - 查看命令:
getconf GNU_LIBPTHREAD_VERSION
用户线程实现
严格意义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。
如果程序实现得当,这种线程不需要切换到内核态,因此操作是非常快速且低消耗的,也能支持规模更大的线程数量。部分高性能数据库中的多线程就是由用户线程实现的。
由于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题。由于操作系统只把处理器资源分配到进程,诸如“阻塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来会异常困难。
Java 语言曾经使用过用户线程,最终又放弃了。但近年来许多新的编程语言又普遍支持了用户线程,譬如 Golang。
混合实现
这种方式是将内核线程与用户线程一起使用,被称为 N:M 实现。在这种混合实现下,既存在用户线程,也存在内核线程。
用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,可以支持大规模的用户线程并发。同时又可以使用内核提供的线程调度功能及处理器映射,用户线程的系统调用通过内核线程来完成。在这种模式中,用户线程与内核线程的数量比是不定的。
Java 线程的实现
Java 线程在早期的 Classic 虚拟机上(JDK 1.2 以前)是用户线程实现的。但从 JDK 1.3 起,主流商用 Java 虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用 1:1 的线程模型。
以 HotSpot 为例,它的每一个 Java 线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构。所以 HotSpot 自己是不会去干涉线程调度的,全权交给底下的操作系统去处理。

这就是 Java 线程调度是抢占式调度的原因。而且 Java 中的线程优先级是通过映射到操作系统的原生线程上实现的,所以线程的调度最终取决于操作系统,Java 优先级并不是特别靠谱。
虚拟线程 (Virtual Threads)
在 Java 21 中,引入了虚拟线程,这是一种用户级线程。它是 Java 中的一种轻量级线程,旨在解决传统线程模型中的一些限制,允许创建数千甚至数万个虚拟线程,而无需占用大量操作系统资源。
- 适用场景:执行阻塞式任务。在阻塞期间,可以将 CPU 资源让渡给其他任务。
- 限制:不适合 CPU 密集计算或非阻塞任务。虚拟线程并不会运行得更快,而是增加了规模。
- 性质:轻量级资源,用完即抛,不需要池化。

代码示例:
public class VTDemo {
public static void main(String[] args) throws InterruptedException {
// 平台线程
Thread.ofPlatform().start(() -> {
System.out.println(Thread.currentThread());
});
// 虚拟线程
Thread vt = Thread.ofVirtual().start(() -> {
System.out.println(Thread.currentThread());
});
// 等待虚拟线程打印完毕再退出主程序
vt.join();
}
}输出:
Thread[#21,Thread-0,5,main]
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1线程间的共享
Synchronized 内置锁
线程开始运行后拥有独立的栈空间,如同脚本一样按照既定代码执行。若线程孤立运行,其价值有限;若多个线程能相互配合、共享数据,将产生巨大的价值。
Java 支持多个线程同时访问一个对象或对象的成员变量。通过 synchronized 关键字(又称内置锁),可以修饰方法或代码块。它确保了多个线程在同一时刻只能有一个线程处于同步块或同步方法中,从而保证了线程对变量访问的原子性、可见性和排他性。
简单使用示例
public class SynTest {
private long count = 0;
private Object obj = new Object(); // 作为一个专用锁对象
public long getCount() {
return count;
}
public void setCount(long count) {
this.count = count;
}
/**
* 方式1:同步代码块
* 锁对象:obj
*/
public void incCount() {
synchronized (obj) {
count++;
}
}
/**
* 方式2:同步方法
* 锁对象:当前实例对象 (this)
*/
public synchronized void incCount2() {
count++;
}
/**
* 方式3:同步代码块
* 锁对象:当前实例对象 (this)
*/
public void incCount3() {
synchronized (this) {
count++;
}
}
private static class Count extends Thread {
private SynTest syncTest;
public Count(SynTest syncTest) {
this.syncTest = syncTest;
}
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
syncTest.incCount();
}
}
}
public static void main(String[] args) throws InterruptedException {
SynTest syncTest = new SynTest();
// 启动两个线程操作同一个对象
Count count1 = new Count(syncTest);
Count count2 = new Count(syncTest);
count1.start();
count2.start();
Thread.sleep(50);
System.out.println(syncTest.count); // 预期结果:20000
}
}对象锁与类锁的辨析
核心原则:synchronized 锁住的永远是对象。只有当多个线程锁住的是同一个对象时,才能保证同步。
- 对象锁:锁住的是类的实例(
this)。如果不加static,synchronized默认锁的是当前实例对象。 - 类锁:在 Java 语言规范中并没有“类锁”这个具体术语,这通常是对锁住
Class对象的俗称。- 当
synchronized修饰static方法时,锁住的是当前类的Class对象(该对象在方法区中全局唯一)。 - 或者显式使用
synchronized(Test.class)。
- 当
示例对比:
/**
* 对象锁
* 锁的是当前实例对象 this
*/
public synchronized void incCount() {
count++;
}
/**
* 类锁
* 锁的是当前类的 Class 对象
*/
public synchronized static void incCountStatic() {
count++;
}
// 类锁的另一种写法
private static Object o = new Object();
private static void incCountBlock() {
synchronized(o) { // 或者是 synchronized(MyClass.class)
count++;
}
}Volatile 关键字
volatile 是轻量级的同步机制,它主要保证了变量的可见性。即当一个线程修改了某个变量的值,这个新值对其他线程来说是立即可见的。
典型使用场景
public class VolatileTest {
// 尝试去掉 volatile 关键字运行,查看程序是否会停止
private volatile static boolean ready;
private static int number;
private static class PrintThread extends Thread {
@Override
public void run() {
System.out.println("PrintThread is running.......");
while (!ready) {
// 无限循环,等待 ready 变为 true
// 注意:如果此处有 System.out.println,可能会强制刷新内存导致 volatile 效果不明显
}
System.out.println("number = " + number);
}
}
public static void main(String[] args) throws InterruptedException {
new PrintThread().start();
Thread.sleep(1000);
number = 50;
ready = true; // 修改 volatile 变量
Thread.sleep(5000);
System.out.println("main is ended!");
}
}现象解析:
- 不加 volatile:子线程可能读取的是 CPU 缓存中的旧值(false),导致无法感知主线程对
ready的修改,从而陷入死循环。 - 加 volatile:主线程修改
ready后,会强制将值刷新回主内存,并使子线程缓存失效,子线程能立即读取到最新的true,从而退出循环。
Volatile 的局限性
volatile 不能保证原子性。 如果多个线程同时对 volatile 变量进行写操作(如 i++),仍然是不安全的。 适用场景:一个线程写,多个线程读。
ThreadLocal
ThreadLocal 与 Synchronized 的比较
- Synchronized:利用锁机制,以时间换空间。让多个线程排队访问同一个共享资源,确保同一时刻只有一个线程操作。
- ThreadLocal:利用副本机制,以空间换时间。为每个线程提供变量的独立副本,实现数据隔离,互不干扰。
典型应用:Spring 的事务管理。Spring 将数据库连接(Connection)放入 ThreadLocal 中,确保在同一个线程的事务操作中使用的是同一个连接,且不同线程间的连接互不影响。
核心方法说明
| 方法 | 描述 |
|---|---|
void set(Object value) | 设置当前线程的线程局部变量的值。 |
public Object get() | 返回当前线程所对应的线程局部变量的值。 |
protected Object initialValue() | 返回初始值。这是一个 protected 方法,设计用于子类覆盖。默认返回 null。 |
public void remove() | 删除当前线程局部变量的值。虽非必须(线程结束会自动回收),但显式调用有助于加快内存回收。 |
基本使用示例
public class UseThreadLocal {
// 初始化 ThreadLocal,设置默认值
private static ThreadLocal<Integer> intLocal = new ThreadLocal<Integer>() {
@Override
protected Integer initialValue() {
return 1;
}
};
public void startThreadArray() {
Thread[] runs = new Thread[3];
for (int i = 0; i < runs.length; i++) {
runs[i] = new Thread(new TestThread(i));
}
for (Thread run : runs) {
run.start();
}
}
public static class TestThread implements Runnable {
int id;
public TestThread(int id) {
this.id = id;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ": start");
Integer s = intLocal.get(); // 获取当前线程的副本
s = s + id;
intLocal.set(s); // 修改当前线程的副本
System.out.println(Thread.currentThread().getName() + ": " + intLocal.get());
// 最佳实践:使用完后清理
intLocal.remove();
}
}
public static void main(String[] args) {
UseThreadLocal test = new UseThreadLocal();
test.startThreadArray();
}
}输出:
Thread-1: start
Thread-0: start
Thread-2: start
Thread-0: 1
Thread-2: 3
Thread-1: 2内存泄漏问题简单分析
ThreadLocal 的实现依赖于线程内部的 ThreadLocalMap。
- 引用关系:
ThreadLocalMap的 Key 是ThreadLocal实例,Value 是我们存储的对象。 - 弱引用 Key:
ThreadLocalMap中的 Entry 对 Key(ThreadLocal 实例)持有的是弱引用。 - 泄漏原理:
- 如果
ThreadLocal实例在外部没有强引用指向它,GC 时它会被回收。 - 此时,Map 中 Entry 的 Key 变为
null。 - 但是,Value(例如一个大对象)是强引用,只要线程(Current Thread)不结束,
ThreadLocalMap依然存在,Value 就无法被回收,从而导致内存泄漏。
- 如果
- 解决机制:
ThreadLocal的get()、set()、remove()方法在调用时,会尝试清除 Key 为null的 Entry。 - 最佳实践:
- 使用完
ThreadLocal后,务必手动调用remove()方法。 - 在线程池中使用时尤为重要,因为线程池中的线程会被复用,不会立即销毁。
- 使用完
等待/通知机制
线程之间相互配合,完成某项工作。比如:一个线程修改了一个对象的值,而另一个线程感知到了变化,然后进行相应的操作。整个过程开始于一个线程,而最终执行又是另一个线程。前者是生产者,后者就是消费者,这种模式隔离了“做什么”(what)和“怎么做”(How)。
简单的办法是让消费者线程不断地循环检查变量是否符合预期,在while循环中设置不满足的条件,如果条件满足则退出 while 循环,从而完成消费者的工作。但这种方法存在如下问题:
- 难以确保及时性。
- 难以降低开销。如果降低睡眠的时间,比如休眠1毫秒,这样消费者能更加迅速地发现条件变化,但是却可能消耗更多的处理器资源,造成无端的浪费。
等待/通知机制则可以很好的避免上述问题。
Object#wait/notify/notifyAll
等待通知机制可以基于对象的 wait 和 notify 方法来实现。在一个线程内调用该线程锁对象的 wait 方法,线程将进入等待队列进行等待直到被唤醒。
- notify():通知一个在对象上等待的线程,使其从 wait 方法返回,而返回的前提是该线程获取到了对象的锁,没有获得锁的线程重新进入 WAITING 状态。
- notifyAll():通知所有等待在该对象上的线程。尽可能用 notifyAll(),谨慎使用 notify(),因为 notify() 只会唤醒一个线程,我们无法确保被唤醒的这个线程一定就是我们需要唤醒的线程。
- wait():调用该方法的线程进入 WAITING 状态,只有等待另外线程的通知或被中断才会返回。需要注意,调用 wait() 方法后,会释放对象的锁。
- wait(long):超时等待一段时间,这里的参数时间是毫秒,也就是等待长达 n 毫秒,如果没有通知就超时返回。
- wait(long, int):对于超时时间更细粒度的控制,可以达到纳秒。
等待方遵循如下原则:
- 获取对象的锁。
- 如果条件不满足,那么调用对象的 wait() 方法,被通知后仍要检查条件。
- 条件满足则执行对应的逻辑。
synchronized(对象) {
while (条件不满足) {
对象.wait();
}
对应的逻辑;
}通知方遵循如下原则:
- 获得对象的锁。
- 改变条件。
- 通知所有等待在对象上的线程。
synchronized(对象) {
改变条件
对象.notifyAll();
}示例
public class WaitDemo {
public static void main(String[] args) throws InterruptedException {
Object locker = new Object();
Thread t1 = new Thread(() -> {
try {
System.out.println("wait开始");
synchronized (locker) {
locker.wait();
}
System.out.println("wait结束");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t1.start();
//保证t1先启动,wait()先执行
Thread.sleep(1000);
Thread t2 = new Thread(() -> {
synchronized (locker) {
System.out.println("notify开始");
locker.notifyAll();
System.out.println("notify结束");
}
});
t2.start();
}
}输出:
wait开始
notify开始
notify结束
wait结束LockSupport#park/unpark
LockSupport 是 JDK 中用来实现线程阻塞和唤醒的工具。线程调用 park 则等待“许可”,调用 unpark 则为指定线程提供“许可”。LockSupport 类似于二元信号量(只有1个许可证可供使用),如果这个许可还没有被占用,当前线程获取许可并继续执行;如果许可已经被占用,当前线程阻塞,等待获取许可。使用它可以在任何场合使线程阻塞,可以指定任何线程进行唤醒,并且不用担心阻塞和唤醒操作的顺序,但要注意连续多次唤醒的效果和一次唤醒是一样的。
Java 锁和同步器框架的核心 AQS:AbstractQueuedSynchronizer,就是通过调用 LockSupport.park() 和LockSupport.unpark() 实现线程的阻塞和唤醒的。
public class LockSupportDemo {
public static void main(String[] args) throws InterruptedException {
Thread parkThread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("ParkThread开始执行");
// 当没有『许可』时,当前线程暂停运行;有『许可』时,用掉这个『许可』,当前线程恢复运行
LockSupport.park();
System.out.println("ParkThread执行完成");
}
});
parkThread.start();
Thread.sleep(1000);
System.out.println("唤醒parkThread");
// 给线程 parkThread 发放『许可』(多次连续调用 unpark 只会发放一个『许可』)
LockSupport.unpark(parkThread);
}
}输出:
ParkThread开始执行
唤醒parkThread
ParkThread执行完成LockSupport 与 wait/notify 的主要区别:
- LockSupport.park() 和 unpark() 可以在任何地方调用,而 wait 和 notify 只能在 synchronized 代码段中调用。
- LockSupport 允许先调用 unpark(Thread t),后调用 park()。如果 thread1 先调用 unpark(thread2),然后线程2后调用 park(),线程2是不会阻塞的。而如果线程1先调用 notify,然后线程2再调用 wait的话,线程2是会被阻塞的。