一、概念速览
先记住一幅画
程序是开店说明书,进程是已经营业的店,线程是店里干活的员工。
员工多了,可以分头推进工作;但能不能同一时刻一起计算,还要看有多少 CPU 执行能力。
早上七点,你的早餐店开门了。小李煎蛋,小王准备豆浆,你负责接单。
小李等食材送来时,你可以继续接单,不必全店一起停下来。但如果两个人一起修改剩余鸡蛋数量,就得约好谁先改,避免记错账。
这就是本篇一直使用的场景:怎样让几个人合作,又不把事情做乱。
| 早餐店里的东西 | 对应计算机里的什么 | 先这样理解 |
|---|---|---|
| 还没执行的开店说明书 | 程序 | 写好的代码和指令 |
| 一家已经营业的店 | 进程(Process) | 程序运行起来的一份实例,有自己的运行资源 |
| 店里干活的员工 | 线程(Thread) | 进程里的一条执行路线 |
| 商场统一分配的灶台 | CPU 核心的执行能力 | 员工轮流或同时使用它来“计算” |
| 店里的公共备料区、库存账本 | 进程内的共享资源,如 Java 堆中的对象 | 多个员工可能操作同一份数据 |
| 每个人自己的工作便签 | 线程自己的栈、执行位置 | 各自记住干到了哪里 |
这里的“灶台”只用来说明 CPU 执行机会。现实中员工离开灶台还会干活;计算机中线程执行指令需要 CPU。现代 CPU 还有硬件线程等细节,先不展开,也别把一个核心理解成只能运行一个进程。
阅读路线:先看店和员工是什么 → 再看怎样合作 → 最后运行几段代码。 本文基础顺序参考 Oracle 并发教程,示例使用 Java 8 语法;涉及虚拟线程时单独注明 Java 21 的区别。
二、进程与线程
2.1 程序与进程
电脑上保存着一个 Java 程序,好比抽屉里放着一份开店说明书。没启动时,它不会自己接单。
启动程序后,系统给它准备内存、文件等运行资源,它开始工作,就有了进程。用同一份程序启动两次,通常会得到两个不同的进程,就像同一套经营方案开了两家店。
原文说“进程是资源分配的基本单位”,可以先翻译成:
系统要为正在运行的程序安排一套资源,通常按进程来组织。
不同进程通常拥有相互隔离的地址空间。一家店的员工,不能随便伸手修改隔壁店的库存账本。要交换信息,得走约定好的渠道。
2.2 线程
一家店可以只有你一个人,也可以请小李、小王一起工作。
对应到 Java:一个进程里可以有多个线程。每个线程按自己的代码顺序向前执行;系统可以在这些执行路线之间安排运行机会。
原文说“线程是调度的基本单位”,可以先翻译成:
安排 CPU 执行机会时,要安排到具体干活的线程。
进程回答“这是哪家店,资源归谁”;线程回答“这次轮到谁干活”。Oracle 的进程与线程教程也先从运行资源和执行路线区分两者。
2.3 进程与线程的区别
小李、小王共用店里的库存账本,不必每次都把账本快递给对方。这就是同一进程内多个线程共享内存带来的方便。
但方便不等于安全。假设账本写着“还剩 10 个鸡蛋”:
- 小李看到 10,准备减去 1。
- 小王也看到 10,也准备减去 1。
- 两个人都写回 9。
实际用了两个鸡蛋,账上却只少一个。共享资源需要协作规则,这就是后面学习锁、可见性等知识的起点。
员工也有自己的工作便签。小李记着“煎到第 3 个蛋”,小王记着“正在处理第 8 单”,不需要共用一张进度纸。
| 比较点 | 进程:不同店铺 | 线程:同店员工 |
|---|---|---|
| 数据与资源 | 通常隔离,需要专门渠道交换数据 | 共享所在进程的资源,但也有自己的栈等数据 |
| 合作方式 | 好比打电话、传单据 | 可以访问同一个共享对象,仍需保证协作正确 |
| 创建与切换 | 通常涉及更多资源管理 | 平台线程通常更轻,但创建、切换也有成本 |
| 出错影响 | 隔离提供一定保护 | 共享数据被改坏,可能影响同进程其他线程 |
“线程不拥有资源”说得太绝对。应理解成:线程共享进程的许多资源,也保留自己的执行所需信息。
2.4 main 与主线程
执行 Java 启动命令时,JVM 已经开始运行,随后主线程执行 main()。
所以,不是 main() 自己创建了进程。它更像店开起来后,先开始接单的你。
你可以再安排小李上班,也就是创建并启动另一个线程。但你下班后,小李是否继续工作,要看线程自己的运行情况和 JVM 退出规则;普通线程不会因为创建它的线程结束就自动停止。
三、并发、并行、同步与异步
3.1 并发
为了看清区别,先假设只有一个执行位置,不能同一瞬间做两份计算。
小李用一下,换小王用一下,再换回小李。从一段时间来看,两份工作都在往前推进:
同一个执行位置:小李 → 小王 → 小李 → 小王这叫并发:多个任务在一段时间内都有进展,不要求同一瞬间都在执行。
注意,“同一时刻交替执行”容易把人绕晕。交替发生在一段时间内,不能把“轮流”说成同一瞬间一起做。
3.2 并行
现在有两个可同时工作的执行位置。小李、小王可以在同一时刻分别工作:
执行位置 1:小李 ─────────→
执行位置 2:小王 ─────────→这叫并行:多个任务在同一时刻实际执行。
记法:并发看“这段时间都在推进”,并行看“这一刻确实一起做”。 并发也能发生在多核机器上,不能把它限定成“只有单核才有”。
3.3 同步与异步
你让小李煎蛋,现在要处理下一位顾客。
| 你的做法 | 对应概念 |
|---|---|
| 必须等小李把煎蛋交过来,才能继续后面的流程 | 同步:当前流程要等这次操作完成 |
| 交代后先去接下一单,之后再取结果或接收通知 | 异步:发起操作后可以先推进其他工作 |
判断时就问一句:发起这件事之后,我的后续流程是否必须等它完成?
这里的同步/异步描述的是任务调用与完成的关系。它也不完全等同于阻塞/非阻塞;“等”可以通过不同机制实现,本篇先理解等待关系。
另一个常见词是“线程同步”,意思是给线程之间安排协作顺序、保护共享数据,例如两个人改账本要轮流。它和上面的“同步调用”侧重点不同。
3.4 异步与并行的区别
你让小李煎蛋,自己接单,是异步安排。
但如果只有一个 CPU 执行位置,你们仍要轮流计算;有足够的执行位置时,才可能并行。异步安排不保证并行执行。
反过来,两个人即使可以同时工作,你仍可以要求“等小李做完,再交给顾客”。这时实际工作可能并行,但你的后续步骤仍有等待关系。
3.5 多线程的作用与开销
如果小李在等食材送达,你还可以继续接单。对应到程序:一个线程等网络或磁盘时,其他线程可以利用空出来的执行机会。
如果所有员工都在争同一个灶台,再多请人也不会凭空多出灶台,反而多了换人和协调的时间。
原文几个词可以这样读:
| 词语 | 早餐店里的意思 |
|---|---|
| 资源利用率 | 有活可干时,尽量别让设备闲着 |
| 吞吐量 | 每分钟完成多少单 |
| 响应时间 | 一位顾客从下单到拿到早餐,等了多久 |
| 进程的动态性 | 店从开张、营业到打烊,有自己的生命周期 |
| 进程的独立性 | 各店有各店的资源和运行环境 |
| 进程的异步性 | 每家店进度不同,不能假定它们按固定节奏推进 |
| 进程的结构性 | 系统会记录店的身份、资源和运行状态,不是只有一份代码 |
这些特征能从场景里理解就够了,不必先背一串名词。原文的“并发性”就是多家店的工作可以在同一段时间里推进。
四、线程常用操作
4.1 Thread、Runnable 与 Callable
先把“谁来做”和“做什么”分开:
| Java 对象 | 店里的角色 | 关键点 |
|---|---|---|
Thread | 一位员工的安排 | 启动后有一条新的执行路线 |
Runnable | 一张“做这些事”的任务单 | run() 不直接返回业务结果 |
Callable<V> | 一张“做完要交回结果”的任务单 | call() 返回 V 类型结果,也可声明抛出异常 |
FutureTask<V> | 带取餐凭证的任务单 | 能交给线程执行,也能用 get() 取结果 |
继承 Thread,相当于把员工和工作内容写在一个类里。实现 Runnable,相当于把任务单独写好,再交给员工,职责更容易分清,也不用占掉类唯一的父类位置。
Thread 的任务构造器接收 Runnable,不能直接接收 Callable。FutureTask 实现了 Runnable,可以把 Callable 装进去,再交给 Thread。
任务单不会自己工作。 创建一个 Runnable、Callable 或 FutureTask,并不等于已经启动线程。可对照 Oracle 的启动线程教程与 FutureTask 文档。
同一个 Runnable 可以交给多个线程,但若它们修改同一份数据,仍须考虑安全问题。真正开发时也常把任务交给线程池,见 08-线程池使用与原理。
4.2 start 与 run
你拿到一张“煎蛋”的任务单:
- 直接调用
run():你亲自照任务单干活。执行者仍是当前线程。 - 调用
start():启动另一个线程,由它执行run()中的工作;你可以继续往下走。
start() 不是“这位员工立刻完成任务”。启动后,何时得到 CPU 执行机会由调度决定,也不能靠启动先后判断谁先完成。
同一个线程对象只能 start() 一次。员工这次工作已经结束,想重新安排一条执行路线,需要新的线程对象。
4.3 sleep 与 yield
sleep(1000):当前干活的员工说“我暂停约一秒”。这期间他不执行代码;时间到了,还得等系统安排执行机会,并不保证一秒整立刻继续。
yield():员工说“我愿意让别人先做”。系统可以接受,也可以忽略,所以不能拿它保证执行顺序。
特别容易混的是:暂时不使用 CPU,不代表归还手里的锁。
锁可以想成公共账本唯一的钥匙。你拿着钥匙休息,别人仍打不开账本。sleep() 不会释放已经持有的监视器锁;yield() 也不会替你释放锁。
Thread.sleep() 是静态方法,暂停的永远是调用它的当前线程。别用“拿某个线程对象调用 sleep”来表达让那个线程睡觉。sleep(0) 也不能保证“立即换人”。详见 sleep 教程和 Thread API。
4.4 interrupt 中断
你对小李说:“准备打烊,请结束手里的工作。”
这好比调用 t.interrupt():发出中断请求,线程要靠自己的代码决定如何响应。 它不是把线程直接杀死,也不是 CPU 的换人按钮。
| 被通知时正在做什么 | 常见 Java 行为 |
|---|---|
| 正常执行代码 | 设置中断标记;代码需要检查它,并决定是否退出 |
在 sleep()、wait()、join() 中等待 | 抛出 InterruptedException,同时清除中断标记 |
在 LockSupport.park() 中等待 | 可以返回;不会因此清除中断标记 |
等待进入 synchronized | 不会因为 interrupt() 就取消拿锁等待 |
三种方法可以想成员工胸前的“收到停工通知”标记:
t.interrupt():给指定员工发通知。t.isInterrupted():看看指定员工的标记,看完保留。Thread.interrupted():查看当前员工自己的标记,看完清除。
连续调用两次 Thread.interrupted(),如果中间没有新中断,第二次会是 false。不能说“无论什么情况,第二次一定是 false”。
抓到 InterruptedException 后,可以直接结束当前任务;如果要交给外层循环处理,常见做法是重新设置中断标记。不要只打印异常然后无限继续工作。响应方式由任务设计决定,见 Oracle 中断教程。
4.5 join 与 get
你想把煎蛋装进早餐袋,必须等小李完成。
- 在
main中执行cook.join():main 等 cook 线程结束。 - 在
main中执行task.get():main 取任务结果;没做好就等。
记住主语:谁调用等待方法,谁等待。 join() 不会让小李霸占 CPU,也不会把其他员工都停下来。可参见 join 官方教程。
join() 不直接返回业务结果,get() 可以返回结果。即使任务原先是异步启动的,你随后立刻 get(),当前线程也可能在这里等住。
join(1000) 表示等待目标结束,但设置了等待时限;返回后目标可能还活着,可以再看 isAlive()。join(0) 表示不设置这个超时限制。任务自己的失败会由 FutureTask.get() 以 ExecutionException 报告,调用者等待时被中断则会收到 InterruptedException。
不要拿 sleep(1000) 代替 join()。等一秒好比猜测小李已经做好,join() 才是在等他实际结束。
4.6 park 与 unpark
LockSupport.park() 可以先想成:员工到等候区,没放行票就等待。unpark(t) 给指定员工一张票。
- 票可以先发,再等员工来
park()消耗。 - 最多记一张票,连续发两次不会攒成两张。
- 中断也能让
park()返回,而且中断标记仍保留。标记为 true 时,再次park()通常会直接返回。
park() 还允许在没有收到预期信号时返回,叫“虚假唤醒”。因此恢复执行后仍要检查“现在真的可以继续了吗”,不能把“返回了”当成条件一定满足。细节见 LockSupport 文档和 02-线程同步与通信。
4.7 守护线程
普通线程好比还在处理正式订单的员工。守护线程好比在后台定时整理提示牌的助手。
当所有已启动的非守护线程都结束时,JVM 可以开始退出,不会为了守护线程的未完工作继续运行。
因此,不要把必须完成的保存、结算工作只交给守护线程,并指望它一定做完或一定执行清理代码。
对于这里使用的平台线程,setDaemon(true) 要在启动前调用。JVM 是运行环境,不是“一个守护线程”;守护线程和操作系统里的守护进程,也不是同一个概念。
4.8 两阶段终止
合理的打烊方式是:
- 你发出“准备下班”的通知。
- 员工收到后退出工作循环,归还物品、做好清理,再结束。
这就是两阶段终止的思路。关键在于员工配合,interrupt() 本身不保证任务一定停止。
不要使用 Thread.stop() 强行终止线程。它的历史问题之一,是可能在数据只改了一半时释放监视器锁,把坏账交给别人;并非简单的“绝不会释放锁”。suspend() 暂停时仍可能拿着锁,也容易把合作卡死;resume() 是配套的旧操作。这些方法早已废弃,Java 21 中调用它们会抛出不支持操作的异常,见 Thread 文档。
System.exit() 则好比关掉整家店,用它仅仅停止一个员工,会把范围扩大到整个 JVM。
五、线程状态
5.1 六种线程状态
别先背英文,先问:他没开始干、能干、被钥匙挡住、在等事情,还是已经结束?
| 状态名 | 店里的画面 | Java 中的典型情况 |
|---|---|---|
NEW:新建 | 员工安排好了,还没叫他上班 | new Thread(...) 后,尚未调用 start() |
RUNNABLE:可运行 | 已经上岗,正在用设备,或等设备轮到自己 | 可能正在执行,也可能等待 CPU 执行机会 |
BLOCKED:等监视器锁 | 想进账房,但钥匙被别人拿着 | 等待进入或重新进入 synchronized 保护的区域 |
WAITING:不设超时地等 | 等一个具体信号或等另一个员工结束 | 无超时的 wait()、join()、park() 等 |
TIMED_WAITING:设了时限的等 | 休息一段时间,或只等一会儿 | sleep()、带正数超时的 wait()、join() 等 |
TERMINATED:结束 | 这次工作已经结束 | run() 正常返回,或因未捕获异常结束 |
这是 Java 对线程的分类,不等同于操作系统线程状态。Thread.State 文档明确区分了六种状态。
5.2 RUNNABLE 可运行
你已经准备好煎蛋,只差灶台轮到自己。你有工作能力,暂时没获得设备。
Java 把“正在执行”和“等待 CPU 执行机会”的情况都放进 RUNNABLE。所以看到这个状态,不能断言“它这一瞬间一定在 CPU 上跑”。
5.3 BLOCKED 与 WAITING
BLOCKED:我想进账房工作,但拿不到 synchronized 对应的钥匙。
WAITING:我主动等一个事件,例如另一个员工做完、条件变化或收到放行票。
日常说“线程阻塞了”是泛指暂时走不下去;Java 的 BLOCKED 则有特定含义。比如等待 ReentrantLock 时,可能看到 WAITING,不能把所有等锁都归成 BLOCKED。
WAITING 也不等于只能由 notify() 唤醒:join() 可以因为目标线程结束而返回,park() 可以因为 unpark() 或中断返回。采用什么等待方式,就要看对应的恢复条件。
5.4 sleep 与 wait
这是下一篇的内容,这里先认识区别:
| 操作 | 早餐店里的画面 | 对锁的影响 |
|---|---|---|
Thread.sleep(...) | 我自己暂停一会儿 | 不会因此释放已持有的监视器锁 |
obj.wait() | 暂时交回这间账房的钥匙,等条件变化 | 必须先持有 obj 的监视器锁;等待时释放这把锁 |
wait() 不会顺手释放你持有的其他锁。收到通知后,也得重新拿到对应锁,才能从 wait() 返回;通知不是直接把钥匙交给你。
5.5 线程状态转换
安排员工 NEW
└─ start() → 可以工作 RUNNABLE
├─ sleep(正数时间) → TIMED_WAITING
├─ 等另一个尚未结束的线程 join() → WAITING
├─ 拿不到 synchronized 的锁 → BLOCKED
└─ run() 结束 → TERMINATED等待结束通常意味着可以重新竞争执行机会,不保证立刻执行。wait() 被通知后,如果还拿不到锁,还可能先进入 BLOCKED。
线程结束后,不能把同一个对象重新 start(),就像这次工作记录已经结单,下一次要新建安排。
六、线程原理
6.1 栈、栈帧与程序计数器
小李正在执行“准备早餐”,里面又调用“煎蛋”,煎蛋里面又要“打蛋”。他得记住:当前做哪一步,做完后回到哪里。
可以想成一摞便签:
最上面:打蛋() ← 当前正在处理
中间: 煎蛋() ← 等打蛋返回后继续
最下面:准备早餐() ← 等煎蛋返回后继续| 名词 | 生活画面 | 实际作用 |
|---|---|---|
| Java 虚拟机栈 | 每位员工自己的一摞便签 | 保存这个线程的方法调用信息 |
| 栈帧 | 一次方法调用对应的一张便签 | 保存局部变量、计算中的中间信息等 |
| 当前栈帧 | 最上面正在处理的那张 | 对应当前执行的方法 |
| 程序计数器(PC) | 当前步骤的定位标记 | 执行非 native 方法时,记录当前 JVM 指令的位置 |
| 局部变量 | 当前便签上的临时数字 | 例如这个方法里使用的计数值 |
| 操作数栈 | 便签旁的临时计算区 | 暂存参与计算的值和中间结果 |
方法调用时放上新便签,方法结束时撤下这张,回到下一张继续。这是理解调用栈的记忆画面,实际定义见 JVM 规范的运行时数据区与栈帧。
“每个线程有自己的局部信息”不代表它访问的对象也一定独占。便签上可以写着同一个公共账本的位置,两个人仍可能操作同一对象。
6.2 上下文切换
小李用了一会儿灶台,系统安排小王接着用。换之前,要保留小李的执行位置等状态;以后轮回来,才能接着做。
这叫上下文切换。可以把“上下文”翻译为:继续工作所需的现场信息。
线程用完本次运行机会、主动等待,或系统安排其他线程运行,都可能发生切换。保存和恢复执行状态有成本,所以不是线程越多越快。
不要想成每次都把 Java 栈完整复印一遍,也不要把 JVM 的 PC、Java 栈和操作系统保存的机器寄存器混成同一层东西。这个类比只负责解释“恢复工作需要记住现场”。
6.3 线程调度
抢占式调度:由系统安排运行机会,就像商场管理员可以说“这一轮到小王”。本篇使用的平台线程通常由操作系统调度,不要求正在工作的线程主动让出后,别人才有机会。
协作式调度:主要依靠执行者在约定位置主动交还机会,就像员工说“我先等送货,你来用”。具体是否会饿死别人、是否需要同步,要看实现和共享数据,不能说协作式就没有同步问题。
线程优先级好比向管理员表达“这单比较急”。不能依赖 setPriority() 保证谁先开始、谁先完成,也不能依赖 yield() 实现稳定排序。
原文的“用户态/内核态”可以先理解为:普通员工的工作权限和系统管理员的管理权限不同。 操作系统管理平台线程需要相应的系统机制,但不能由此推出“任何 Java 方法调用或状态变化都必然切换一次权限”。
6.4 虚拟线程与协程
原文把这部分写成“未来优化”,现在需要补一个时间范围:Java 21 已正式提供虚拟线程。
- 本篇
new Thread(...)创建的是平台线程,通常对应操作系统线程。 - Java 21 的虚拟线程由 Java 运行时调度到承载它们的平台线程上,不能再把所有 Java 线程都说成“与操作系统线程一对一”。
沿用店里的场景:可以把虚拟线程想成很多份独立的员工工作进度记录,由少量实际值班人员承接;某份工作等待送货时,在支持释放承载线程的场景中,可以先让其他工作继续。
这只是理解调度层次的补充类比。虚拟线程不会增加 CPU 核心,主要适合大量等待 I/O 的任务,不能保证让纯计算任务更快。依据为 Java 21 Thread 文档的平台线程与虚拟线程说明。
“协程”可以先理解成能暂停、恢复的执行过程。“有栈”保留调用栈,“无栈”通常用状态记录保存执行进度;“纤程”是某些实现采用的相关称呼。它们不是所有语言通用的一套固定分类,更不能一概说成“多个任务只能映射到一个内核线程”。第一次学本篇,知道这条路存在即可。
6.5 进程通信
同店员工能共用账本,不同店通常不能直接访问对方的普通内存。它们需要约好的通信方式,简称 IPC:进程间通信。
| 方式 | 生活例子 | 先知道什么 |
|---|---|---|
| 管道 | 一条按顺序传内容的传递通道 | Linux 普通管道是单向字节流;双向交流通常需要两条 |
| 命名管道(FIFO) | 给传递通道设一个大家能找到的地址 | 无亲缘关系的进程也能按路径找到它;不能理解成普通磁盘文件里存消息 |
| 消息队列 | 把一张张订单放进收件箱 | 按消息交换;具体读取规则与存续期取决于采用哪种实现 |
| 共享内存 | 双方约定使用同一块公共公告板 | 能共同访问数据,但仍要协调谁何时修改 |
| 信号量 | 入口发有限数量的通行牌 | 主要用于协调进入人数、资源使用,不是传整份订单内容 |
| Socket:套接字 | 店之间建立联络端点 | 可以用于网络,也有用于同机进程通信的 Unix 域套接字 |
匿名管道常见于相关进程之间;命名管道提供可寻址的路径。管道与 FIFO 的具体行为可看 Linux pipe 手册,共享内存见 shm_overview,本地套接字见 unix。
跨机器交流通常经网络并遵守约定的协议,例如 HTTP。Socket 也能本地通信,所以不能说“它只能用于不同机器”。
同进程的线程可以共享对象,再通过锁、等待/通知、join() 等安排协作。volatile 主要解决可见性和一定的顺序约束,不能保证所有共享操作安全;InheritableThreadLocal 主要在子线程创建时传递初始值,也不能当成两个线程随时交流的公共账本。这些留到 04-Java内存模型 与 06-ThreadLocal 继续学。
七、代码示例
7.1 运行环境
使用 JDK 8 或更高版本,不需要 Spring、Maven、第三方依赖,也不需要创建复杂项目。
如果使用 IntelliJ IDEA,在现有学习项目的 src 中分别新建下面的类,粘贴完整代码,运行对应 main() 即可。每个示例独立运行。
如果使用终端,将文件放在同一个学习目录,按各示例下的命令编译运行。代码没有声明包名;如果你的 IDE 自动添加了 package,按项目原有包结构放置即可。
7.2 创建线程:start、run、join
要解决的问题:煎蛋和准备豆浆可以分头推进,你先接单,最后等两样都做好再交付。
这里顺便对比三件事:直接 run()、继承 Thread 启动员工、把 Runnable 任务交给线程。文件名为 BreakfastDemo.java。
public class BreakfastDemo {
// 方式一:继承 Thread,把“员工”和“煎蛋任务”写在一个类里。
static class EggCook extends Thread {
EggCook() {
super("煎蛋员工"); // 设置名字,方便看清每行由谁执行。
}
@Override
public void run() {
// currentThread:取得真正执行到这里的线程。
// getName:查看它的名字,不是只看当前对象叫什么。
System.out.println(Thread.currentThread().getName() + ":开始煎蛋");
try {
// sleep:只为模拟耗时,不负责保证两个线程的先后顺序。
Thread.sleep(300);
} catch (InterruptedException e) {
// 收到停工通知后,恢复标记并退出,不继续假装做完。
Thread.currentThread().interrupt();
return;
}
System.out.println(Thread.currentThread().getName() + ":煎蛋完成");
}
}
public static void main(String[] args) throws InterruptedException {
System.out.println("先试直接调用 run():");
EggCook direct = new EggCook();
direct.run(); // 普通方法调用,输出的执行者是 main。
System.out.println("直接 run 后,对象状态:" + direct.getState());
System.out.println("再试启动两位员工:");
EggCook egg = new EggCook();
// 方式二:Runnable 只描述任务,由 Thread 执行。
Runnable soyTask = () -> {
System.out.println(Thread.currentThread().getName() + ":准备豆浆");
};
Thread soy = new Thread(soyTask, "豆浆员工");
egg.start(); // 启动另一条执行路线,之后由它执行 run。
soy.start(); // 启动豆浆员工,不能保证谁先输出。
System.out.println("main:我先接下一位顾客的订单");
egg.join(); // main 等煎蛋员工结束,其他线程仍可以工作。
soy.join(); // main 等豆浆员工结束;如果已结束,直接返回。
System.out.println("main:早餐齐了,可以交付");
System.out.println("煎蛋员工状态:" + egg.getState());
}
}终端运行方式:
javac -encoding UTF-8 BreakfastDemo.java
java -Dfile.encoding=UTF-8 BreakfastDemo无需输入。观察下面三个确定的现象:
- 第一段虽然调用了
EggCook的run(),执行者名字却是main。直接调用没有启动该线程,因此direct仍是NEW。 - 第二段出现“煎蛋员工”“豆浆员工”,说明启动了新线程。它们与 main 的部分输出顺序可能变化。
- “早餐齐了”一定在两个员工的任务结束后输出;正常结束后的
egg为TERMINATED。
从结果反推概念
run()是工作内容,start()是启动执行路线,join()是调用者等待目标结束。
删掉join()后,“早餐齐了”就没有保证等两位员工做完。不要用某一次碰巧出现的打印顺序当规则。
7.3 获取结果:Callable、FutureTask
要解决的问题:你不仅想知道小李结束了,还想拿到他做好的那份早餐。
这里用 Callable<String> 表示会交回字符串的任务,用 FutureTask<String> 保存任务完成状态和结果。文件名为 BreakfastResultDemo.java。
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class BreakfastResultDemo {
public static void main(String[] args)
throws InterruptedException, ExecutionException {
// Callable:任务完成时返回结果;这里交回一段早餐描述。
Callable<String> order = () -> {
Thread.sleep(300); // 模拟准备过程,可抛出 InterruptedException。
return "一份煎蛋和豆浆";
};
// FutureTask:既能当 Runnable 交给线程,也能让调用者取结果。
FutureTask<String> receipt = new FutureTask<>(order);
Thread cook = new Thread(receipt, "套餐员工");
cook.start(); // 开始异步准备;只创建 receipt 不会执行任务。
System.out.println("main:员工准备早餐,我先接下一单");
// get:任务完成就取结果,没完成则 main 在这里等待。
// 任务执行失败时抛出 ExecutionException,可从其 cause 查看原异常。
String breakfast = receipt.get();
System.out.println("main:拿到 " + breakfast);
}
}终端运行方式:
javac -encoding UTF-8 BreakfastResultDemo.java
java -Dfile.encoding=UTF-8 BreakfastResultDemo无需输入,预期输出:
main:员工准备早餐,我先接下一单
main:拿到 一份煎蛋和豆浆这段代码实际做的是:另一个线程执行任务,main 先输出接单消息,然后在 get() 处等待并取得字符串。任务异步执行,取结果时仍可以同步等待。
Runnable.run() 不允许声明新增的受检异常,但仍可能抛出运行时异常;不能概括成“run 不能抛异常”。普通新线程里的异常,也不会自动被 main 包住 start() 的 try/catch 接住。通过 FutureTask 执行时,它会记录任务失败,再由 get() 报告。
7.4 停止线程:interrupt 与收尾
要解决的问题:后台员工每隔一秒检查一次库存,你希望通知他结束,并等他把最后的整理做完。
这里的员工只是打印模拟信息,不会读写数据库或文件。文件名为 ClosingDemo.java。
public class ClosingDemo {
public static void main(String[] args) throws InterruptedException {
Thread monitor = new Thread(() -> {
// currentThread:保存正在执行任务的线程,便于检查中断标记。
Thread self = Thread.currentThread();
try {
// isInterrupted:只读取中断标记,不会清除。
while (!self.isInterrupted()) {
try {
System.out.println("库存员工:检查一次库存");
Thread.sleep(1000); // 当前员工暂时休息。
} catch (InterruptedException e) {
// sleep 被中断时会清除标记,所以重新标记。
// 下一次检查循环条件时,就能退出工作循环。
self.interrupt();
}
}
} finally {
// 模拟收尾。在正常协作退出时,会先执行这里再结束线程。
// 此处不依赖 sleep,不需要继续等待外部资源。
System.out.println("库存员工:整理记录,归还物品,下班");
}
}, "库存员工");
monitor.start(); // 第一阶段开始前,先让员工正常工作。
Thread.sleep(2200); // 只用于演示一段营业时间,不用它保证员工已就绪。
System.out.println("main:准备打烊");
monitor.interrupt(); // 第一阶段:发出停工请求。
monitor.join(); // 等员工退出循环、完成第二阶段收尾并结束。
System.out.println("main:员工已收工");
}
}终端运行方式:
javac -encoding UTF-8 ClosingDemo.java
java -Dfile.encoding=UTF-8 ClosingDemo无需输入。库存检查次数受实际调度影响,不必死记;正常运行时最后会看到“整理记录,归还物品,下班”,之后才是“main:员工已收工”。
代码同时处理了两种时机:正常执行期间收到中断,在循环检查时退出;休眠期间收到中断,先进入 catch,恢复标记后再退出。重新设置标记,是为了让本例统一走循环外的收尾逻辑。 不是所有 catch 都必须照抄这套写法。
7.5 查看进程与线程
先给线程起清楚的名字,再查看状态,比只看“程序不动了”容易定位。
在安装了 JDK 的本机终端,可以使用这些只读命令:
# 列出可见的本机 Java 进程,找到目标进程的数字编号 PID。
jps -l
# 示例:如果目标编号是 12345,查看它的线程快照。
# 运行前将 12345 替换为实际 PID;程序必须仍在运行。
jstack 12345
# 打开图形工具,再选择要观察的本机 Java 进程。
jconsole在 jstack 中,先找线程名,再看状态和方法调用记录。例如 ClosingDemo 暂停期间,可能看到“库存员工”在 TIMED_WAITING,调用位置包含 Thread.sleep。
三个示例都很短,可能来不及查看就已结束。若要观察 ClosingDemo,可以临时把 main 的营业时间改长,例如 30000 毫秒;观察后仍由 main 发中断并等待收工。
Windows 也可以先用任务管理器看进程;Linux 常用 ps -ef 查看进程,ps -T -p 12345 查看指定进程的线程,或用 top -H -p 12345 动态观察。把示例 PID 换成真实编号即可;这些系统工具的选项不要直接混用到 macOS。
7.6 常见运行问题
| 现象 | 先检查什么 |
|---|---|
提示找不到 javac | 终端是否配置了 JDK;IDE 是否选择了可用 JDK |
| 提示 public 类名和文件名不一致 | 文件名是否和 public class 完全一致 |
| 提示找不到主类 | 是否在编译输出目录运行;是否添加了 package 却仍用无包名命令 |
| 提示找不到 Callable、FutureTask 等 | 是否保留了示例顶部的 import |
| 终端中文变成问号或乱码 | 保留命令中的 -Dfile.encoding=UTF-8;IDE 的文件与控制台编码也选 UTF-8 |
| 两个线程打印顺序和预想不同 | 没有顺序约束的部分,本来就不能保证先后 |
| 重复调用 start 抛异常 | 同一个线程对象只能启动一次 |
| get 一直等不到结果 | FutureTask 是否真的交给线程执行并启动,任务是否能结束 |
| 发中断后线程仍一直工作 | 任务是否检查标记,是否把中断异常吞掉后又继续循环 |
| jstack 提示进程不存在 | 示例是否已结束,PID 是否正确 |
八、面试问答
8.1 回答思路
先说资源归谁 → 再说谁执行 → 说明共享与隔离 → 补上并发不等于并行。
8.2 回答示例
进程可以理解为程序运行起来的一份实例,它有自己的运行资源。线程是进程中的执行路线,同一个进程可以有多个线程,它们共享进程内的许多资源,但各自有自己的栈和执行位置等信息。多线程方便任务分工,也会带来共享数据的安全问题。另外,多线程不一定同时执行:任务在一段时间内交替推进属于并发,同一时刻实际一起执行才是并行。
讲不顺时,就回想:店有自己的资源,员工分头干活,共用账本要守规则。
8.3 常见追问
直接调用 run,为什么没有新线程?
因为它仍是当前线程的一次普通方法调用。start() 才负责启动新的执行路线,由新线程执行任务;不能把工作内容和启动动作混为一谈。
join 到底让谁等?
当前调用者等目标线程结束。例如 main 调用 cook.join(),等的是 main,目标 cook 仍按正常调度运行,其他线程也可以继续执行。
interrupt 能立即停止线程吗?
不能保证。普通执行代码需要主动检查中断标记;sleep() 等可中断等待会抛出异常,任务也要正确处理。它是一种协作机制。
九、重点回顾
- 哪家店在营业? —— 进程,管理一套运行资源。
- 具体谁在干活? —— 线程,按自己的执行路线推进。
- 轮流做,还是这一刻一起做? —— 区分并发与并行。
- 交代完能继续做别的,还是后续必须等? —— 区分这次任务调用的异步与同步。
- 卡在哪儿:等 CPU、等钥匙、等消息,还是休息? —— 根据原因理解线程状态。
常用操作就回想这几句话:
start叫人来干,run是干什么。
sleep自己暂停,join等别人结束,get等结果。
interrupt发通知,线程配合才收工。
让出 CPU 和归还锁,是两件不同的事。
接下来读 02-线程同步与通信 时,继续沿用这家店:重点看“公共账本怎样轮流改,等消息时钥匙该怎么交”。
十、参考资料
本文保留原笔记的入门主题,按 Oracle 并发教程的“进程与线程 → 启动线程 → 暂停 → 中断 → 等待结束”主线展开,并把状态、运行原理作为补充。生活故事用于记忆;Java 行为以对应官方说明为依据。
- Oracle Java 并发教程目录:基础顺序参考;该版教程面向 JDK 8。
- 进程与线程、定义与启动线程、sleep、interrupt、join:基础行为。
- Java 8 Thread API、Object 的 wait/notify、旧线程控制方法为何废弃:配合基础示例、监视器等待与旧方法说明。
- Java 21 Thread API、Thread.State、FutureTask、LockSupport:状态、任务结果、等待工具,以及虚拟线程和旧 API 的版本边界。
- JVM 规范:运行时数据区、栈帧:执行进度的内存结构。
- Linux 手册:管道与 FIFO、共享内存、Unix 域套接字:进程通信补充,具体细节具有操作系统边界。
原笔记的课程来源:黑马程序员 Java 并发课程。本次改写未逐段复核视频,以文中已查阅的官方资料校正具体结论。资料核对日期:2026-11-05。