type
Post
status
Published
date
Oct 12, 2025
slug
Java2
summary
tags
技术探索
category
icon
password
学 Java 并发,绕不开线程。
Thread、Runnable、Callable、线程池、wait/notify、park/unpark、Semaphore、CountDownLatch、CyclicBarrier,这些内容看起来分散,其实都围绕一个核心问题:一个任务如何被线程执行?一个线程如何从创建走到结束?多个线程之间如何等待、唤醒、协作?并发工具类底层为什么能阻塞线程,又为什么能唤醒线程?
这篇文章就把 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通常配合Future、FutureTask或线程池的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 提供的
ArrayBlockingQueue、LinkedBlockingQueue、DelayQueue 等。十五、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 是循环屏障。它的特点是:多个线程互相等待,等大家都到齐后,再一起继续执行。
CyclicBarrier 和 CountDownLatch 很容易混淆。区别是:
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、线程池底层实现,就会顺很多。因为很多高级并发工具,本质上都是在解决三个问题:
线程什么时候阻塞?线程阻塞后放到哪里?条件满足后如何把线程安全地唤醒?
分享
