臭名昭著aTrust隔离计划1.臭名昭著aTrust隔离计划2.VPN原理与aTrust隔离网络实践3.docker-easyconnect到底做了什么4.TUN(tunnel-隧道-虚拟网卡)模式
个人项目
personal(个人展示)GitHub Actions 自动部署个人展示项目
基于Quartz搭建的个人博客1.Quartz个人博客使用教程2.使用 rsync 增量部署 Quartz 博客3.使用 GitHub Actions 自动部署 Quartz 博客5.域名绑定6.CDN加速7.Google 搜索收录排查:为什么关键词搜得到别人,却搜不到我的网站?Obsidian、双向链接与知识图谱
架构设计
分布式分布式事务RPC原理
旅行
行程攻略青岛三日游行程攻略
前端
NginxNginx配置与反向代理入门
Nodenpm与npx的区别
数据库
mysql01.MySQL基础语法与约束02.MySQL数据库类型03.MySQL多表查询04.MySQL 索引05.MySQL 事务06.MySQL 隔离级别07.MySQL MVCC08.MySQL 锁09.EXPLAIN10.慢查询与 SQL 优化
mysql面试题01.索引问题02.事务与隔离级别03.锁机制04.存储引擎与性能优化05.用户权限、字符集与备份恢复
mysql实战01.EXPLAIN实战02.测试环境菜单误删排查与恢复03.MySQL IN 详解
网络
网络安全1.无线监听网卡苹果M系列虚拟机安装Kali Linux教程3.kali怎么使用RTL8812AU4.kali基础命令
网络基础进程之间怎样通过网络传递数据
网址常用网址
中间键
esElasticsearch 入门
mq
RabbitMQRabbitMQ高级RabbitMQ基础
1. MQ 基础2. MQ 核心角色与代码对应3. MQ Consumer Group 与消费模式4. SOFAMQ 账户变更代码实践5. MQ 可靠性问题6. 消息幂等
redis缓存击穿、穿透与雪崩Redis 分布式锁Redis 缓存问题Redis 数据结构
AI
使用说明
第三方插件&技能简介Archify使用与安装指南geekgeekRun使用OfferFlow使用指南Ponytail使用指南Skills Manager使用指南
CodexCodex CLI与IDE区别及使用指南Codex Hook单独配置与提交通知Codex MCP安装与使用指南Codex第三方插件安装与使用指南
Agent开发0-学习路线1.LLM、Token、上下文窗口与模型参数2.大模型 API、请求参数与响应结构3.Spring AI ChatClient4.Prompt、System Prompt、Prompt 模板6.结构化输出、JSON Schema7.SSE 流式响应8.会话 ID、聊天记录、Redis9.超时、重试、限流、降级10.*完成可运行聊天接口11.Function Calling、Tool Calling 原理12.用 Java 方法定义 Tool13.工具参数设计和校验14.实现订单查询工具15.实现物流查询工具16.实现库存查询工具17.多个工具自动选择18.工具异常、超时、重试
diy主机
主机零件学习1.CPU2.主板3.内存4.显卡5.硬盘6.散热7.风扇8.机箱热门型号价格表
捡垃圾diy-13600kf+5060diy-14600kf+5070
English高频词汇英文基础语法简单句子音标
GitGit常用命令Sublime Merge 使用指南
Java
工具库Caffeine本地缓存Reactor教程
工作实践如何使用@Async线程池使用与排查线上服务 CPU 使用率过高排查压测下RPC接口大量失败排查记录
基础1.HashMap2.ConcurrentHashMap3.HashSet4.final5.static6.volatile7.AtomicInteger8.String动态代理反射锁与线程同步线程注解
面试题1.HashMap 存储在方法内是线程安全的吗2.ArrayList参数传递与引用重新赋值3.== 和 equals 的区别、hashcode4.重载和重写的区别5.String、StringBuffer、StringBuilder区别及使用场景6.接口和抽象类7.ArrayList 与 LinkedList 的区别和扩容机制8.HashMap 和 Hashtable 的区别及底层实现9.Object 和 Objects 的区别10.ThreadLocal的原理和使用场景11.字节码是什么及作用12.Java类加载器有哪些13.双亲委派模型14.Java异常体系15.GC是什么16.GC如何判断对象可以被回收
面试总结0.HR面试模板1.面试一2.面试二3.面试三笔试
网络与 IO(黑马程序员-纯理论)01-网络编程基础02-IO模型与零拷贝03-BIO04-NIO基础与缓冲区05-NIO通道与选择器06-AIO
juc00-目录01-进程与线程02-线程同步与通信03-并发设计模式04-Java内存模型05-CAS与原子类06-ThreadLocal07-阻塞队列08-线程池使用与原理09-任务调度10-ForkJoin与享元模式11-AQS12-ReentrantLock13-读写锁14-并发协作工具15-ConcurrentHashMap16-CopyOnWrite与同步容器17-SkipListMap18-非阻塞队列
juc(工作常见)Java并发编程三个重点拆解
jvm1. JVM运行时数据区
Spring1.Spring IoC、DI与构造方法注入CGLIB、Byte Buddy 与 Spring AOPSpring 常用注解详解Spring Bean 生命周期
Spring MVC异常处理机制
linux常用命令
mac快捷键终端常用操作
python
基础01.Python是什么,为什么要安装Python02.Python项目中.venv是什么03.Python基础语法
基础实战sql替换
HTTPXHRMS员工详情接口调用(Python HTTPX)
windowswindows激活

返回 JUC 导航

一、概念速览

先记住一幅画

程序是开店说明书,进程是已经营业的店,线程是店里干活的员工。
员工多了,可以分头推进工作;但能不能同一时刻一起计算,还要看有多少 CPU 执行能力。

早上七点,你的早餐店开门了。小李煎蛋,小王准备豆浆,你负责接单。

小李等食材送来时,你可以继续接单,不必全店一起停下来。但如果两个人一起修改剩余鸡蛋数量,就得约好谁先改,避免记错账。

这就是本篇一直使用的场景:怎样让几个人合作,又不把事情做乱。

早餐店里的东西对应计算机里的什么先这样理解
还没执行的开店说明书程序写好的代码和指令
一家已经营业的店进程(Process)程序运行起来的一份实例,有自己的运行资源
店里干活的员工线程(Thread)进程里的一条执行路线
商场统一分配的灶台CPU 核心的执行能力员工轮流或同时使用它来“计算”
店里的公共备料区、库存账本进程内的共享资源,如 Java 堆中的对象多个员工可能操作同一份数据
每个人自己的工作便签线程自己的栈、执行位置各自记住干到了哪里

这里的“灶台”只用来说明 CPU 执行机会。现实中员工离开灶台还会干活;计算机中线程执行指令需要 CPU。现代 CPU 还有硬件线程等细节,先不展开,也别把一个核心理解成只能运行一个进程。

阅读路线:先看店和员工是什么 → 再看怎样合作 → 最后运行几段代码。 本文基础顺序参考 Oracle 并发教程,示例使用 Java 8 语法;涉及虚拟线程时单独注明 Java 21 的区别。

二、进程与线程

2.1 程序与进程

电脑上保存着一个 Java 程序,好比抽屉里放着一份开店说明书。没启动时,它不会自己接单。

启动程序后,系统给它准备内存、文件等运行资源,它开始工作,就有了进程。用同一份程序启动两次,通常会得到两个不同的进程,就像同一套经营方案开了两家店。

原文说“进程是资源分配的基本单位”,可以先翻译成:

系统要为正在运行的程序安排一套资源,通常按进程来组织。

不同进程通常拥有相互隔离的地址空间。一家店的员工,不能随便伸手修改隔壁店的库存账本。要交换信息,得走约定好的渠道。

2.2 线程

一家店可以只有你一个人,也可以请小李、小王一起工作。

对应到 Java:一个进程里可以有多个线程。每个线程按自己的代码顺序向前执行;系统可以在这些执行路线之间安排运行机会。

原文说“线程是调度的基本单位”,可以先翻译成:

安排 CPU 执行机会时,要安排到具体干活的线程。

进程回答“这是哪家店,资源归谁”;线程回答“这次轮到谁干活”。Oracle 的进程与线程教程也先从运行资源和执行路线区分两者。

2.3 进程与线程的区别

小李、小王共用店里的库存账本,不必每次都把账本快递给对方。这就是同一进程内多个线程共享内存带来的方便。

但方便不等于安全。假设账本写着“还剩 10 个鸡蛋”:

  1. 小李看到 10,准备减去 1。
  2. 小王也看到 10,也准备减去 1。
  3. 两个人都写回 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 两阶段终止

合理的打烊方式是:

  1. 你发出“准备下班”的通知。
  2. 员工收到后退出工作循环,归还物品、做好清理,再结束。

这就是两阶段终止的思路。关键在于员工配合,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

无需输入。观察下面三个确定的现象:

  1. 第一段虽然调用了 EggCook 的 run(),执行者名字却是 main。直接调用没有启动该线程,因此 direct 仍是 NEW。
  2. 第二段出现“煎蛋员工”“豆浆员工”,说明启动了新线程。它们与 main 的部分输出顺序可能变化。
  3. “早餐齐了”一定在两个员工的任务结束后输出;正常结束后的 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() 等可中断等待会抛出异常,任务也要正确处理。它是一种协作机制。

九、重点回顾

  1. 哪家店在营业? —— 进程,管理一套运行资源。
  2. 具体谁在干活? —— 线程,按自己的执行路线推进。
  3. 轮流做,还是这一刻一起做? —— 区分并发与并行。
  4. 交代完能继续做别的,还是后续必须等? —— 区分这次任务调用的异步与同步。
  5. 卡在哪儿:等 CPU、等钥匙、等消息,还是休息? —— 根据原因理解线程状态。

常用操作就回想这几句话:

start 叫人来干,run 是干什么。
sleep 自己暂停,join 等别人结束,get 等结果。
interrupt 发通知,线程配合才收工。
让出 CPU 和归还锁,是两件不同的事。

接下来读 02-线程同步与通信 时,继续沿用这家店:重点看“公共账本怎样轮流改,等消息时钥匙该怎么交”。

十、参考资料

本文保留原笔记的入门主题,按 Oracle 并发教程的“进程与线程 → 启动线程 → 暂停 → 中断 → 等待结束”主线展开,并把状态、运行原理作为补充。生活故事用于记忆;Java 行为以对应官方说明为依据。

原笔记的课程来源:黑马程序员 Java 并发课程。本次改写未逐段复核视频,以文中已查阅的官方资料校正具体结论。资料核对日期:2026-11-05。