Lazy loaded image
Java丨用源码理解线程生命周期与通信机制
Words 8010Read Time 21 min
2025-10-12
type
Post
status
Published
date
Oct 12, 2025
slug
Java2
summary
tags
技术探索
category
icon
password
学 Java 并发,绕不开线程。
ThreadRunnableCallable、线程池、wait/notifypark/unparkSemaphoreCountDownLatchCyclicBarrier,这些内容看起来分散,其实都围绕一个核心问题:
一个任务如何被线程执行?
一个线程如何从创建走到结束?
多个线程之间如何等待、唤醒、协作?
并发工具类底层为什么能阻塞线程,又为什么能唤醒线程?
这篇文章就把 Java 线程的创建方式、生命周期、通信机制和常见同步工具串起来讲清楚。

一、Java 中创建线程有哪些方式?

Java 中创建线程,常见有几种方式:
继承 Thread
实现 Runnable
实现 Callable
使用线程池。
使用 CompletableFuture
使用虚拟线程。
不过实际项目里,不建议频繁手动 new Thread(),更推荐使用线程池统一管理线程资源。
原因很简单:
线程创建和销毁有成本。
线程数量需要控制。
线程名称需要规范。
任务异常需要统一处理。
不同业务之间需要资源隔离。

二、继承 Thread 创建线程

最直接的方式是继承 Thread,重写 run() 方法。
这段代码的关键点:
run() 只是线程执行体。
start() 才是真正启动线程。
继承 Thread 会把任务逻辑和线程对象绑定在一起,扩展性一般。
Java 是单继承,如果类已经继承了其他父类,就不能再继承 Thread
所以这种方式适合理解线程原理,但项目中用得不多。

三、实现 Runnable 创建线程

更常见的方式是实现 Runnable
这段代码比继承 Thread 更推荐,因为它把“任务”和“线程”拆开了。
Runnable 负责描述任务。
Thread 负责创建线程并执行任务。
同一个 Runnable 可以交给不同线程执行。
后续也可以很自然地交给线程池执行。
Java 8 以后,可以写得更简洁:

四、实现 Callable 创建有返回值的任务

Runnable 没有返回值,也不能直接抛出受检异常。
如果任务需要返回结果,可以使用 Callable
这段代码要关注两个点:
Callable 的核心方法是 call(),有返回值。
FutureTask 既是任务包装器,也是结果容器。
但要注意,futureTask.get() 是阻塞方法。
如果任务一直不结束,调用 get() 的线程也会一直等待。
所以实际项目中更推荐设置超时时间:
这段代码背后的工程意义是:
任何跨线程等待都应该考虑超时。
尤其是远程调用、文件处理、批量任务、复杂计算,不要让调用方无限阻塞。

五、使用线程池执行任务

项目中更推荐使用线程池,而不是手动创建线程。
这段代码有几个很重要的细节:
不直接使用 Executors.newFixedThreadPool(),而是显式创建 ThreadPoolExecutor
队列使用有界队列,避免任务无限堆积。
自定义线程名称,方便排查问题。
明确拒绝策略,避免线程池满了以后行为不可控。
execute()submit() 也有区别:
execute() 只提交任务,不返回结果。
submit() 会返回 Future,可以获取结果或异常。
submit() 中的异常会被封装到 Future 里,不调用 get() 可能感知不到异常。
这个点在实际排查线程池任务异常时非常关键。

六、CompletableFuture 执行异步任务

CompletableFuture 更适合异步编排。
这段代码有一个非常重要的实践点:
使用 CompletableFuture 时,重要业务尽量传入自定义线程池。
不要随意使用默认的 ForkJoinPool.commonPool()
多个业务共用公共线程池,容易互相影响。

七、虚拟线程创建任务

Java 21 之后,可以使用虚拟线程。
虚拟线程的优势是轻量,适合大量阻塞 IO。
但它不代表可以无限制访问数据库、Redis 或下游接口。
虚拟线程降低的是线程成本。
它不会自动增加数据库连接数。
它不会自动提升下游接口容量。
它也不能让 CPU 密集型任务变得更快。

八、start 和 run 到底有什么区别?

这是线程中最容易被误用的地方。
先看一段代码:
执行结果大致是:
第一次打印来自 main 线程。
第二次打印来自 worker-thread 线程。
为什么会这样?
因为 run() 只是普通方法,start() 才会触发线程启动。
可以看一下 Thread.start() 的核心逻辑。下面是简化后的源码结构,用来理解流程:
这段源码说明了几个关键点:
start() 是同步方法,避免并发重复启动同一个线程。
一个 Thread 对象只能启动一次。
真正创建线程的是 native 方法 start0()
start0() 成功后,新线程最终会执行 run()
直接调用 run() 完全绕过了线程创建过程。
所以一句话总结:
run() 是任务入口。
start() 是线程入口。

九、Runnable 和 Callable 的区别

Runnable 的源码非常简单:
Callable 的源码也很简单:
二者区别可以这样理解:
Runnable 适合只执行动作,不关心返回结果的任务。
Callable 适合需要返回结果,或者需要向外抛出异常的任务。
Callable 通常配合 FutureFutureTask 或线程池的 submit() 使用。
再看一个更贴近实际业务的例子:
不过这段代码里用了 Executors.newFixedThreadPool(2),只是为了演示 API。
真正项目中,还是建议显式创建 ThreadPoolExecutor,避免无界队列带来的任务堆积风险。

十、Java 线程有哪些状态?

Java 线程状态定义在 Thread.State 枚举里。
简化后的源码如下:
这几个状态可以串成一条主线:
NEW:线程对象刚创建。
RUNNABLE:调用 start() 后,线程可以被调度执行。
BLOCKED:等待进入 synchronized 锁。
WAITING:无限期等待其他线程唤醒。
TIMED_WAITING:限时等待。
TERMINATED:线程执行结束。
这里有一个容易混淆的点:
BLOCKED 通常和 synchronized 锁竞争有关。
WAITING 通常和 wait()join()park() 有关。
ReentrantLock 竞争失败后,线程通常不是 BLOCKED,而是通过 AQS 进入等待队列,再由 LockSupport.park() 挂起。

十一、用代码观察线程状态

下面用一段代码观察 TIMED_WAITING
再看 BLOCKED
最后看 WAITING
这三段代码可以帮你建立一个清晰直觉:
sleep() 会让线程限时等待,但不释放锁。
等待进入 synchronized 会进入 BLOCKED
wait() 会释放锁,并进入 WAITING

十二、sleep 和 wait 有什么区别?

sleep()Thread 的静态方法。
wait()Object 的实例方法。
二者最大的区别是:
sleep() 不会释放锁。
wait() 会释放锁。
看一段代码:
再看 wait()
可以总结为:
sleep() 用来让当前线程暂停一段时间。
wait() 用来做线程间协作。
sleep() 不需要持有对象锁。
wait() 必须在 synchronized 中调用。
sleep() 不释放锁。
wait() 会释放锁。

十三、wait/notify/notifyAll 的正确用法

wait/notify 必须配合 synchronized 使用。
原因是它们依赖对象监视器,也就是对象锁。
错误写法:
正确写法:
这段代码有几个必须记住的点:
wait() 必须写在 synchronized 内。
notify()notifyAll() 也必须写在 synchronized 内。
wait() 会释放锁。
notify() 不会立即释放锁。
被唤醒的线程需要重新竞争锁。
判断条件必须使用 while,不要使用 if
为什么一定要用 while
因为线程被唤醒,不代表条件一定满足。
可能出现几种情况:
虚假唤醒。
其他线程先一步消费了条件。
notifyAll() 唤醒多个线程,但只有部分线程能满足条件。
被唤醒后还要重新竞争锁,拿到锁时条件可能已经变化。
所以标准模板一定是:

十四、wait/notify 实现一个简单阻塞队列

下面用 wait/notifyAll 实现一个简化版阻塞队列。
这段代码虽然简单,但已经包含了阻塞队列最核心的思想:
用锁保护共享队列。
条件不满足时,线程进入等待。
条件变化后,唤醒等待线程。
被唤醒后重新检查条件。
生产者和消费者通过同一把锁协作。
不过实际项目中,不建议自己写阻塞队列。
应该直接使用 JDK 提供的 ArrayBlockingQueueLinkedBlockingQueueDelayQueue 等。

十五、notify 和 notifyAll 怎么选择?

notify() 只唤醒一个等待线程。
notifyAll() 会唤醒所有等待线程。
notify() 看起来更高效,但更容易出问题。
比如一个队列中既有生产者等待,又有消费者等待,如果 notify() 恰好唤醒了一个仍然无法继续执行的线程,就可能导致程序推进异常。
所以在复杂条件等待场景中,通常更推荐 notifyAll()
简单场景可以使用 notify()
多条件、多角色等待时,优先考虑 notifyAll()
更复杂的场景建议使用 Lock + Condition,可以拆分多个条件队列。

十六、LockSupport 的 park/unpark

wait/notify 有几个限制:
必须在 synchronized 内使用。
必须先等待,再唤醒,否则可能丢失通知。
只能基于对象监视器等待队列。
LockSupport.park/unpark 更底层,也更灵活。
先看例子:
park/unpark 的关键是“许可”模型。
每个线程最多有一个许可。
unpark(thread) 会给目标线程发放一个许可。
park() 会消费这个许可。
如果调用 park() 时已经有许可,则不会阻塞。
如果没有许可,park() 才会阻塞。
也就是说,unpark() 可以先于 park() 调用。
需要注意一点:
上面这段代码中,unpark(worker) 是否一定先于 park() 执行,取决于线程调度。
如果想稳定演示“先 unpark 再 park”,可以这样写:
这段代码说明:
wait/notify 更像对象监视器上的等待通知。
park/unpark 更像线程级别的许可控制。
unpark 可以先于 park,不容易丢失信号。
AQS、ReentrantLock、线程池底层都大量使用了 LockSupport

十七、LockSupport 的简化源码理解

LockSupport 的源码本质上是对底层能力的封装。简化结构如下:
这里最值得关注的是:
park() 阻塞的是当前线程。
unpark(Thread thread) 唤醒的是指定线程。
它不需要先持有某个对象锁。
它是 AQS 实现线程挂起和唤醒的基础能力。
还有一个细节:
park() 因为中断返回后,不会自动清除中断标记。
这个点在写底层并发代码时很重要:
线程被唤醒不一定是因为 unpark(),也可能是因为中断或虚假返回。
所以使用 park() 时,也要配合条件循环。

十八、wait/notify 和 park/unpark 的区别

可以这样理解:
wait/notify 依赖对象锁,必须在 synchronized 中使用。
park/unpark 不依赖对象锁,可以直接阻塞和唤醒指定线程。
wait() 会释放对象锁。
park() 不涉及对象锁释放。
notify() 不能指定唤醒哪个线程。
unpark() 可以指定唤醒哪个线程。
notify() 先于 wait() 执行会丢失通知。
unpark() 先于 park() 执行不会丢失许可。
不过二者都要注意一点:
被唤醒不代表条件一定满足。
正确做法都是在循环中检查条件。

十九、Semaphore:控制并发许可证

Semaphore 是信号量,用来控制同时访问某个资源的线程数。
比如限制最多 3 个线程同时处理任务:
这段代码适合理解限流和资源池:
数据库连接池可以用类似思想控制连接数量。
接口调用可以用类似思想限制并发数。
文件上传、批处理任务也可以用它做并发控制。
Semaphore 还有一个常见用法:尝试获取许可证,拿不到就降级。
这个写法的工程价值是:
不让请求无限等待。
系统繁忙时快速失败。
避免线程大量堆积。
可以保护下游资源。

二十、CountDownLatch:一个线程等待多个线程完成

CountDownLatch 可以理解为倒计时门闩。
一个线程等待多个线程完成任务,等计数归零后再继续执行。
这段代码最重要的是:
countDown() 要放在 finally 中。
否则某个任务异常退出,没有递减计数,等待线程可能永远无法继续。
更稳妥的写法是给 await() 加超时:
CountDownLatch 的特点:
一次性使用。
计数不能重置。
适合一个线程等待多个线程完成。
常用于服务启动初始化、批量任务聚合、并行查询结果汇总。

二十一、CyclicBarrier:多个线程互相等待

CyclicBarrier 是循环屏障。
它的特点是:多个线程互相等待,等大家都到齐后,再一起继续执行。
CyclicBarrierCountDownLatch 很容易混淆。
区别是:
CountDownLatch 是一个线程等待多个线程。
CyclicBarrier 是多个线程互相等待。
CountDownLatch 不能复用。
CyclicBarrier 可以重复使用。
CyclicBarrier 可以设置屏障回调。
看一个复用的例子:
这个例子说明:
CyclicBarrier 适合分阶段任务。
每一阶段所有线程都完成后,再进入下一阶段。
比如多线程计算、游戏回合、批处理分阶段汇总。

二十二、Semaphore、CountDownLatch、CyclicBarrier 怎么选?

可以这样记:
Semaphore:控制同时进入临界区的线程数量。
CountDownLatch:一个线程等待多个线程完成。
CyclicBarrier:多个线程互相等待,到齐后继续。
实际场景中:
限制接口并发数,用 Semaphore
主线程等待多个初始化任务,用 CountDownLatch
多个工作线程分阶段协同,用 CyclicBarrier

二十三、线程协作中的几个常见坑

1. 调用 wait 前没有持有锁

正确做法:

2. 用 if 判断等待条件

错误写法:
正确写法:

3. notify 后以为线程会立刻执行

要记住:
notify() 只是把等待线程从等待队列移动出来。
被唤醒线程要等当前线程退出 synchronized 后,才能重新竞争锁。

4. CountDownLatch 没有在 finally 中 countDown

错误写法:
正确写法:

5. Semaphore 只获取不释放

错误写法:
正确写法:

二十四、总结

本文从线程创建方式讲到了线程生命周期,再讲到线程间通信和常用同步工具。
核心可以这样串起来:
Thread 是线程对象。
Runnable 描述无返回值任务。
Callable 描述有返回值任务。
start() 会启动新线程。
run() 只是普通方法调用。
NEW 表示线程刚创建。
RUNNABLE 表示线程可运行。
BLOCKED 通常表示等待进入 synchronized 锁。
WAITING 表示无限期等待。
TIMED_WAITING 表示限时等待。
TERMINATED 表示线程结束。
线程通信部分,可以这样记:
sleep() 不释放锁。
wait() 会释放锁。
wait/notify 必须配合 synchronized
park/unpark 不依赖对象锁。
unpark 可以先于 park 执行。
被唤醒不代表条件一定满足,必须循环检查条件。
同步工具部分,可以这样记:
Semaphore 控制并发许可证。
CountDownLatch 适合一个线程等待多个线程。
CyclicBarrier 适合多个线程分阶段互相等待。
理解这些内容以后,再去看 ReentrantLock、AQS、线程池底层实现,就会顺很多。
因为很多高级并发工具,本质上都是在解决三个问题:
线程什么时候阻塞?
线程阻塞后放到哪里?
条件满足后如何把线程安全地唤醒?
回到首页