并发 vs 并行:别再傻傻分不清了,一文讲透 Java 并发编程的第一课
「Java 进阶之路」系列 Day01
写在前面
如果你去面试,被问到"什么是并发,什么是并行",十有八九会脱口而出:"并发是同时处理多个任务,并行是同时执行多个任务"------然后被追问一句"那到底有什么区别"就卡壳了。
这篇文章不讲虚的,用一张图 + 一个类比 + 一段能跑的代码,把这个"看似简单实则容易讲不清"的基础问题彻底讲透,同时把线程的生命周期也一起捋顺,作为整个并发系列的第一篇。
一、是什么:并发不等于并行
先说结论:
并发(Concurrency) :CPU 在多个任务之间快速切换,看起来像"同时进行",但某一时刻可能只有一个任务在真正执行。 并行(Parallelism):多核 CPU 上,多个任务真正地在同一时刻执行。
一个经典类比:
- 你一个人(单核 CPU)一边接电话一边写代码,看起来是"同时"在做两件事,其实是电话响了就切过去接,挂了再切回来写代码------这是并发。
- 你和同事两个人(多核 CPU),一个人打电话,另一个人写代码,两件事真正同时发生------这是并行。
放到 Java 里:单核机器上开 100 个线程,操作系统靠时间片轮转让它们"看起来"在同时跑,这是并发;4 核机器上 4 个线程分别跑在不同核心上,才是真正的并行。并发是一种编程模型,并行是一种执行状态,并发程序不一定并行执行,但并行执行一定是并发的一种表现形式。
为什么要引入并发
一句话理解并发编程的本质:让多个任务"同时推进",提高资源利用率。
| 优点 | 说明 |
|---|---|
| 提升性能(最核心) | 线程在等 IO 的时候,CPU 可以先去处理别的任务,不用干等着 |
| 提高吞吐量(TPS/QPS) | 单位时间能处理更多请求 |
| 更好地利用资源 | IO 等待期间 CPU 不被浪费 |
| 提升用户体验 | 耗时操作不阻塞主线程(比如 Android 主线程、Web 请求线程) |
但并发不是免费的午餐:
| 缺点 | 说明 |
|---|---|
| 代码复杂度暴增 | 执行顺序不确定,难以理解和维护 |
| 线程安全问题 | 竞态条件、数据不一致、覆盖写 |
| 死锁 | 两个线程互相等待对方持有的锁,永久卡死 |
| 调试困难 | 并发 bug 往往是"线上偶现、本地必现不了" |
| 性能不一定提升 | 线程切换、锁竞争本身也有开销 |
| 资源消耗增加 | 线程不是免费的,每个线程默认占用 1MB 左右的栈内存 |
记住这句话:并发解决的是"等待问题"(IO 等待、资源等待),不是"计算问题"。如果任务本身是纯 CPU 密集型计算,堆再多线程也不会让单核跑得更快------这时候需要的是并行(多核),而不是并发(单核切换)。
二、线程的生命周期:6 种状态到底怎么流转
理解了并发的本质,再来看承载并发的最小单位------线程,它一生要经历 6 种状态:
| 状态 | 触发条件 | 说明 |
|---|---|---|
NEW |
创建线程对象,未调用 start() |
不占 CPU,还没进入调度 |
RUNNABLE |
调用 start() 后 |
有资格运行,但不代表正在运行,可能在排队等 CPU 时间片 |
BLOCKED |
等待 synchronized 锁 |
抢同一把锁没抢到,进入此状态 |
WAITING |
调用 wait()、join() 等 |
主动等待,不占 CPU,必须被显式唤醒 |
TIMED_WAITING |
调用 sleep(t)、wait(t)、join(t) |
等待指定时间后自动醒来 |
TERMINATED |
线程执行完毕 | 终态,不可再次 start() |
流转关系画成图:
一段代码看清 NEW → RUNNABLE → TERMINATED
java
public class ThreadStateDemo {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
try {
Thread.sleep(1000); // 进入 TIMED_WAITING
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
System.out.println("刚 new 完:" + t.getState()); // NEW
t.start();
System.out.println("start() 之后:" + t.getState()); // RUNNABLE
Thread.sleep(200); // 留点时间让子线程进入 sleep
System.out.println("子线程 sleep 中:" + t.getState()); // TIMED_WAITING
t.join(); // 主线程等待子线程结束
System.out.println("执行完毕:" + t.getState()); // TERMINATED
}
}
两个最容易混淆的方法:sleep vs wait
这是面试高频送分题,但很多人答不全:
| 对比项 | sleep() |
wait() |
|---|---|---|
| 所属类 | Thread 的静态方法 |
Object 的实例方法 |
| 是否释放锁 | 不释放 | 释放 |
| 使用限制 | 任何地方都能用 | 必须在 synchronized 同步块/方法内使用,否则抛 IllegalMonitorStateException |
| 唤醒方式 | 时间到自动醒 | 时间到(wait(t))或被 notify()/notifyAll() 唤醒 |
一句话记忆:sleep 是"占着位置不干活",wait 是"让出位置去排队" 。这也是为什么生产者-消费者模型必须用 wait/notify 而不是 sleep------用 sleep 的话线程会一直攥着锁不放,别的线程根本没机会拿到锁去改变条件。
三、排查技巧:从线程状态反推问题现象
这部分是纯经验,建议直接背下来,线上排查故障时能救命:
| 现象 | 可能原因 |
|---|---|
| 接口卡死、请求没响应 | 大概率有线程卡在 WAITING 或 BLOCKED 状态(锁没释放、下游一直没返回) |
| CPU 飙高 | 大量线程处于 RUNNABLE 状态,频繁抢占 CPU、上下文切换开销大 |
| 程序迟迟不退出 | 有线程没到 TERMINATED,可能是死循环,或者卡在某个阻塞调用上 |
排查手段:拿到 CPU 占用最高的线程 ID(top -Hp <pid>),转成 16 进制后去线程 dump(jstack)里搜对应的 nid,看它当时的状态和堆栈,基本就能定位到问题代码行。
四、面试追问
Q1:并发和并行的区别,能不能用一个生活化的例子讲清楚?
并发是单核靠时间片切换"看起来同时",并行是多核"真正同时"。类比:一个人一边接电话一边写代码是并发(来回切换);两个人分别打电话、写代码是并行(同时发生)。
Q2:单核 CPU 上开多线程有意义吗?为什么?
有意义。因为并发解决的是"等待问题"而不是"计算问题"------单核多线程时,一个线程在等 IO(比如网络、磁盘)的空档,CPU 可以切去执行另一个线程,避免空等浪费。但如果任务是纯 CPU 密集型计算,单核开再多线程也不会变快,还会因为线程切换产生额外开销。
Q3:start() 和 run() 直接调用有什么区别?
start() 会向操作系统申请新线程,真正让代码跑在新的调用栈上,状态变为 RUNNABLE;直接调用 run() 只是把它当成一个普通方法在当前线程里同步执行,根本不会创建新线程,也不会有并发效果。
Q4:sleep() 和 wait() 的区别,为什么生产者-消费者模型不能用 sleep 代替 wait?
核心区别是"是否释放锁":sleep 睡眠期间不释放锁,wait 会释放锁并且必须在 synchronized 内调用。生产者-消费者模型里,消费者发现队列为空需要等待,如果用 sleep,它会攥着锁睡觉,生产者根本抢不到锁去放数据,双方永久卡住;用 wait 则会释放锁,让生产者有机会进来把数据放进去,再 notify 唤醒消费者。
Q5:线程调用 sleep() 期间,它持有的锁会被释放吗?
不会。这也是 sleep 和 wait 最本质的区别,也是很多人写出"假死锁"代码的原因------以为 sleep 一下让别的线程先跑,结果因为锁没释放,其他线程照样进不来。
下一篇预告
Day02 会讲讲创建线程的 3 种方式------继承 Thread、实现 Runnable、实现 Callable,它们各自的优缺点,以及为什么大部分场景下更推荐 Runnable/Callable。