Java高并发编程
欢迎你来读这篇博客,这篇博客主要是关于Java高并发编程。
其中包括了关于我的见解和收集的知识分享。
序言
Java 高并发编程不是一上来就背 synchronized、volatile、ReentrantLock、ThreadPoolExecutor 参数。真正要理解并发,先要搞清楚几个底层概念:
程序是什么?进程是什么?线程是什么?虚拟线程又是什么?
串行、并行、并发、同步、异步到底有什么区别?
为什么多线程会出现线程安全问题?为什么会有原子性、可见性、有序性问题?
JMM 到底解决了什么问题?
线程有几种状态?为什么有人说 5 种,有人说 6 种,有人说 7 种?
本文按 Java 高并发学习路径,把基础概念、执行流程、线程状态、线程创建方式和 JUC 入门体系串起来讲一遍。
一、程序、进程、线程、虚拟线程
1. 程序
程序是静态的代码文件。
比如:
1 | |
这段代码保存在磁盘上,它只是一个 .java 或 .class 文件,还没有真正运行。
可以把程序理解成“菜谱”。菜谱本身不会做饭,它只是描述了怎么做。
2. 进程
进程是程序运行起来之后的实例。
当我们执行:
1 | |
操作系统会启动一个 Java 进程。
进程拥有独立的系统资源,比如:
| 资源 | 说明 |
|---|---|
| 内存空间 | 每个进程有自己的内存地址空间 |
| 文件句柄 | 打开的文件、网络连接 |
| 代码段 | 程序指令 |
| 数据段 | 全局变量、静态数据 |
| 堆空间 | Java 对象主要存放的位置 |
| 线程资源 | 一个进程至少有一个线程 |
进程之间默认是隔离的。一个进程崩溃,通常不会直接影响另一个进程。
3. 线程
线程是进程中的执行单元。
一个 Java 程序启动后,JVM 会创建主线程执行 main 方法。
1 | |
输出通常是:
1 | |
说明 main 方法是由 main 线程执行的。
线程共享同一个进程的堆内存、方法区等资源,但每个线程有自己的:
| 私有区域 | 说明 |
|---|---|
| 程序计数器 | 当前线程执行到哪条字节码指令 |
| 虚拟机栈 | 方法调用栈、局部变量表 |
| 本地方法栈 | native 方法调用栈 |
线程之间共享堆内存,所以多个线程同时访问同一个对象时,就可能产生线程安全问题。
4. 平台线程
Java 传统线程通常也叫平台线程。
平台线程本质上是 Java 对操作系统线程的封装:
1 | |
每创建一个平台线程,底层通常会对应一个操作系统线程。
平台线程的特点:
| 特点 | 说明 |
|---|---|
| 成本较高 | 创建、销毁、上下文切换都比较重 |
| 数量有限 | 不能无限创建,创建太多会耗尽内存或调度资源 |
| 适合 CPU 密集型和一般并发场景 | 比如计算任务、业务线程池 |
| 依赖操作系统调度 | 线程切换由 OS 调度器控制 |
5. 虚拟线程
虚拟线程是 JDK 管理的轻量级线程。
从 Java 21 开始,虚拟线程成为正式特性。
创建虚拟线程:
1 | |
或者:
1 | |
虚拟线程也是 java.lang.Thread 的实例,但它不是和操作系统线程一一绑定的。
简单理解:
| 类型 | 类比 |
|---|---|
| 平台线程 | 一个任务长期占一个工人 |
| 虚拟线程 | 很多任务被 JVM 调度到少量工人身上执行 |
虚拟线程适合:
| 场景 | 是否适合 |
|---|---|
| 大量 I/O 阻塞任务 | 非常适合 |
| 大量 HTTP 请求 | 适合 |
| 大量数据库查询 | 适合,但要注意连接池限制 |
| CPU 密集型计算 | 不适合提升计算速度 |
| 替代所有线程池 | 不建议无脑替代 |
虚拟线程最大的价值不是让单个任务跑得更快,而是让阻塞式代码拥有更高吞吐。
一句话总结:
平台线程是操作系统级别的重资源线程;虚拟线程是 JVM 管理的轻量级线程,适合高并发 I/O 阻塞场景。
6. 程序、进程、线程、虚拟线程对比
| 概念 | 本质 | 是否运行 | 资源归属 | 说明 |
|---|---|---|---|---|
| 程序 | 静态代码 | 否 | 磁盘文件 | .java、.class、jar |
| 进程 | 程序运行实例 | 是 | 操作系统分配 | 一个 JVM 通常是一个进程 |
| 线程 | 进程内执行单元 | 是 | 共享进程资源 | Java 代码真正执行的单位 |
| 平台线程 | OS 线程封装 | 是 | 操作系统调度 | 成本高,数量有限 |
| 虚拟线程 | JVM 轻量线程 | 是 | JVM 调度 | 成本低,适合大量阻塞任务 |
二、串行、并行、并发、同步、异步
1. 串行
串行是一个任务执行完,再执行下一个任务。
1 | |
执行顺序:
1 | |
特点:
| 优点 | 缺点 |
|---|---|
| 简单、稳定、容易理解 | 效率低,不能充分利用多核 CPU |
2. 并行
并行是多个任务在同一时刻真正同时执行。
前提通常是多核 CPU。
例如:
1 | |
并行强调的是同一时刻同时执行。
3. 并发
并发是多个任务在同一时间段内交替推进。
并发不一定是真正同时执行。
单核 CPU 也可以并发,因为 CPU 可以快速切换线程:
1 | |
由于切换速度很快,人感觉像是同时执行。
4. 并发和并行的区别
| 对比项 | 并发 | 并行 |
|---|---|---|
| 关注点 | 任务调度能力 | 同时执行能力 |
| 是否必须多核 | 不必须 | 通常需要多核 |
| 是否同一时刻执行 | 不一定 | 是 |
| 类比 | 一个人轮流处理多个窗口 | 多个人同时处理多个窗口 |
一句话:
并发是“看起来一起做”,并行是“真的一起做”。
5. 同步
同步是调用方发起任务后,要等待结果返回,才能继续往下执行。
1 | |
queryUser() 不返回,下面代码就不执行。
同步强调的是调用方等待结果。
6. 异步
异步是调用方发起任务后,不需要一直等待结果,可以先去做别的事情。
1 | |
异步强调的是调用方不阻塞。
7. 同步、异步和阻塞、非阻塞的区别
很多人会把同步/异步和阻塞/非阻塞混在一起,其实它们关注点不同。
| 概念 | 关注点 |
|---|---|
| 同步/异步 | 关注结果通知方式 |
| 阻塞/非阻塞 | 关注当前线程是否被挂起等待 |
例如:
同步阻塞:
1 | |
异步非阻塞:
1 | |
同步非阻塞也存在,例如不断轮询结果:
1 | |
这就像你点外卖:
| 模式 | 类比 |
|---|---|
| 同步阻塞 | 站在店门口等饭做好 |
| 同步非阻塞 | 每隔一分钟问一次饭好了没 |
| 异步阻塞 | 留电话,但你坐着啥也不干 |
| 异步非阻塞 | 留电话,然后去干别的,饭好了通知你 |
三、临界区、锁、原子操作
1. 临界区
临界区是访问共享资源的代码区域。
例如:
1 | |
如果 count 是多个线程共享的变量,那么这行代码就是临界区的一部分。
因为 count++ 不是一步完成的,它大概包含:
1 | |
多个线程同时执行,就可能发生数据覆盖。
2. 锁
锁是保护临界区的一种机制。
1 | |
锁的作用是:
同一时刻只允许一个线程进入临界区。
常见锁:
| 锁 | 说明 |
|---|---|
synchronized |
Java 内置锁,基于对象监视器 |
ReentrantLock |
JUC 可重入锁,支持公平锁、尝试加锁、可中断 |
ReadWriteLock |
读写锁,读读共享,读写互斥 |
StampedLock |
支持乐观读的锁 |
| 分布式锁 | 跨进程、跨服务控制并发 |
3. 原子操作
原子操作是不可再分割的操作。
要么全部执行成功,要么完全不执行,中间不会被其他线程打断看到半成品状态。
例如:
1 | |
AtomicInteger 底层通常基于 CAS 实现。
4. 临界区、锁、原子操作的区别
| 概念 | 解决什么问题 | 举例 |
|---|---|---|
| 临界区 | 哪段代码有并发风险 | count++ |
| 锁 | 如何保护临界区 | synchronized、ReentrantLock |
| 原子操作 | 单个操作不可分割 | AtomicInteger.incrementAndGet() |
锁是悲观思路:
我认为会有冲突,所以先加锁。
CAS 是乐观思路:
我先尝试修改,如果没人改过就成功,否则重试。
四、死锁、饥饿、活锁
1. 死锁
死锁是多个线程互相等待对方释放资源,导致所有线程都无法继续执行。
典型例子:
1 | |
t1 拿着 lockA 等 lockB,t2 拿着 lockB 等 lockA,两边都不让,程序就僵住了。
这就像两个人过窄桥,都说“你先退”,但谁也不退。
2. 死锁四个必要条件
死锁产生通常需要同时满足四个条件:
| 条件 | 含义 |
|---|---|
| 互斥条件 | 资源一次只能被一个线程占用 |
| 请求与保持条件 | 线程已经持有资源,又继续请求其他资源 |
| 不可剥夺条件 | 线程持有的资源不能被强制抢走 |
| 循环等待条件 | 多个线程形成头尾相接的资源等待环 |
只要破坏其中一个条件,就可以预防死锁。
3. 饥饿
饥饿是某个线程长期得不到执行机会或资源。
例如:
1 | |
非公平锁下,新来的线程可能插队成功,老线程可能长期拿不到锁。
饥饿不一定是程序完全卡死,而是某些线程“老是轮不到”。
4. 活锁
活锁是线程没有阻塞,也一直在运行,但因为互相让步,导致任务始终无法完成。
比如两个人在走廊相遇:
1 | |
两个人都在动,但就是过不去。
死锁是“都不动了”,活锁是“都在动但没进展”。
5. 死锁、饥饿、活锁对比
| 问题 | 表现 | 是否还在运行 | 根本原因 |
|---|---|---|---|
| 死锁 | 互相等待 | 不运行了 | 资源等待环 |
| 饥饿 | 某线程长期拿不到资源 | 其他线程运行 | 调度不公平或优先级问题 |
| 活锁 | 一直重试但没进展 | 还在运行 | 过度让步或冲突处理策略错误 |
五、死锁预防、避免、检测、解除
1. 死锁预防
死锁预防是在设计时破坏死锁四个必要条件之一。
常见方式:
破坏请求与保持条件
一次性申请所有资源。
1 | |
但要确保所有线程申请锁的顺序一致。
破坏不可剥夺条件
如果获取不到后续资源,就释放已经持有的资源。
1 | |
破坏循环等待条件
规定锁的获取顺序。
比如所有线程都必须先拿 lockA,再拿 lockB。
这是业务开发中最常用的方式。
2. 死锁避免
死锁避免是在运行过程中判断这次资源分配是否安全。
经典算法是银行家算法。
它的思想是:
分配资源之前先判断,分配之后系统是否仍然处于安全状态。
如果不安全,就暂时不分配。
在 Java 业务开发中,我们一般不会直接实现银行家算法,但这个思想可以用在资源池设计中。
例如:
| 场景 | 避免策略 |
|---|---|
| 数据库连接池 | 限制最大连接数 |
| 线程池 | 控制队列和线程数量 |
| 分布式锁 | 设置超时时间 |
| RPC 调用 | 设置超时、熔断、限流 |
3. 死锁检测
死锁检测是系统运行过程中发现已经出现死锁。
Java 中可以用工具检测:
1 | |
如果存在 Java 级别死锁,线程 dump 里可能会出现:
1 | |
也可以用:
| 工具 | 说明 |
|---|---|
jstack |
查看线程堆栈 |
jconsole |
图形化监控 |
jvisualvm |
查看线程状态 |
| Arthas | 在线诊断 Java 应用 |
| JFR | Java Flight Recorder |
4. 死锁解除
死锁解除是在死锁发生后恢复系统。
常见方式:
| 方式 | 说明 |
|---|---|
| 中断线程 | 对可中断锁有效,例如 lockInterruptibly() |
| 超时退出 | 获取锁设置超时时间 |
| 回滚事务 | 数据库场景常见 |
| 重启服务 | 最粗暴但有时有效 |
| 人工释放资源 | 运维处理资源占用 |
示例:
1 | |
5. 四者区别
| 名称 | 发生阶段 | 核心思想 |
|---|---|---|
| 死锁预防 | 设计阶段 | 破坏死锁条件 |
| 死锁避免 | 运行前/运行中 | 判断是否安全再分配 |
| 死锁检测 | 运行中 | 发现是否已经死锁 |
| 死锁解除 | 死锁后 | 恢复系统执行 |
六、线程安全、原子性、可见性、有序性
1. 线程安全
线程安全是指多个线程同时访问同一段代码或同一个对象时,程序结果仍然正确。
例如:
1 | |
这个类不是线程安全的。
多个线程同时执行 increment(),最终结果可能小于预期。
2. 原子性
原子性是指一个操作不可被打断。
例如:
1 | |
不是原子操作。
因为它至少包含三步:
1 | |
解决方式:
1 | |
或者:
1 | |
3. 可见性
可见性是指一个线程修改了共享变量,其他线程能够及时看到。
问题示例:
1 | |
一个线程调用 stop() 后,另一个线程未必马上看到 running = false。
可以使用 volatile:
1 | |
volatile 可以保证可见性。
4. 有序性
有序性是指程序执行顺序在多线程环境下看起来符合预期。
为了优化性能,编译器和 CPU 可能会进行指令重排序。
单线程下,重排序不会改变最终结果。
多线程下,重排序可能导致意想不到的问题。
经典问题是双重检查锁:
1 | |
这里 volatile 不仅保证可见性,也禁止特定的指令重排序。
5. 三大问题总结
| 问题 | 含义 | 典型解决方式 |
|---|---|---|
| 原子性 | 操作不能被打断 | synchronized、Lock、Atomic 类 |
| 可见性 | 修改能被其他线程看到 | volatile、synchronized、Lock |
| 有序性 | 执行顺序符合预期 | volatile、synchronized、JMM happens-before |
七、JMM:Java 内存模型
1. JMM 是什么
JMM,全称 Java Memory Model,Java 内存模型。
它不是 JVM 内存结构,也不是堆、栈、方法区那套东西。
JMM 主要定义:
多线程环境下,线程如何与主内存交互,以及一个线程对共享变量的修改,什么时候能被另一个线程看到。
JMM 解决的是并发中的三类问题:
| 问题 | 说明 |
|---|---|
| 原子性 | 操作是否不可分割 |
| 可见性 | 修改是否能被其他线程看到 |
| 有序性 | 指令是否会被重排序影响 |
2. 主内存和工作内存
JMM 可以抽象理解为:
| 区域 | 说明 |
|---|---|
| 主内存 | 共享变量存放的位置,可以近似理解为堆中的共享数据 |
| 工作内存 | 每个线程自己的变量副本,可以近似理解为 CPU 缓存、寄存器等 |
线程不能直接操作主内存中的变量,而是先把变量拷贝到自己的工作内存中,修改后再同步回主内存。
示意:
1 | |
如果两个线程都对 count++,可能发生:
1 | |
最终结果是 1,而不是 2。
3. happens-before
JMM 中非常重要的概念是 happens-before。
它表示:
如果操作 A happens-before 操作 B,那么 A 的执行结果对 B 可见,并且 A 的执行顺序排在 B 之前。
常见 happens-before 规则:
| 规则 | 说明 |
|---|---|
| 程序顺序规则 | 同一线程内,前面的操作 happens-before 后面的操作 |
| 监视器锁规则 | unlock happens-before 后续对同一把锁的 lock |
| volatile 规则 | 对 volatile 变量的写 happens-before 后续对该变量的读 |
| 线程启动规则 | Thread.start() happens-before 线程内的动作 |
| 线程终止规则 | 线程内动作 happens-before 其他线程检测到它终止 |
| 线程中断规则 | interrupt() happens-before 被中断线程检测到中断 |
| 传递性 | A happens-before B,B happens-before C,则 A happens-before C |
4. volatile 的作用
volatile 主要有两个作用:
| 作用 | 说明 |
|---|---|
| 保证可见性 | 一个线程修改后,其他线程能看到 |
| 禁止特定重排序 | 防止读写 volatile 前后的指令被乱序优化 |
但 volatile 不保证复合操作的原子性。
错误示例:
1 | |
虽然 count 是 volatile,但 count++ 仍然不是原子操作。
5. synchronized 的作用
synchronized 同时可以保证:
| 能力 | 说明 |
|---|---|
| 原子性 | 同一时刻只有一个线程进入临界区 |
| 可见性 | 解锁前修改会刷新,后续加锁线程可见 |
| 有序性 | 基于 happens-before 规则 |
所以 synchronized 是一个比较完整的并发控制工具。
6. JMM 和 JVM 内存结构的区别
| 对比项 | JMM | JVM 内存结构 |
|---|---|---|
| 关注点 | 多线程可见性、有序性、原子性 | JVM 运行时内存区域 |
| 内容 | 主内存、工作内存、happens-before | 堆、栈、方法区、程序计数器 |
| 解决问题 | 并发安全 | 程序运行时数据存放 |
| 是否真实物理划分 | 抽象规范 | JVM 运行时区域 |
一句话:
JVM 内存结构讲对象和方法放在哪里;JMM 讲多线程读写共享变量时怎么保证正确性。
八、Java 程序执行流程示例
假设代码如下:
1 | |
1. 加载类:ClassLoader
程序启动后,JVM 会通过类加载器加载相关类:
1 | |
类加载大致分为:
| 阶段 | 说明 |
|---|---|
| 加载 | 读取 class 文件,生成 Class 对象 |
| 验证 | 验证字节码是否合法 |
| 准备 | 为静态变量分配内存并设置默认值 |
| 解析 | 将符号引用转为直接引用 |
| 初始化 | 执行静态变量赋值和 static 代码块 |
例如:
1 | |
准备阶段 count 是默认值 0,初始化阶段才变成 10。
2. 启动 main 方法
JVM 创建 main 线程,调用:
1 | |
此时主线程有自己的虚拟机栈。
main 方法会创建一个栈帧,里面存放局部变量表、操作数栈等信息。
3. 执行 emp = new Employee()
这一步大致包括:
1 | |
如果 Employee 类是:
1 | |
对象刚分配时默认值为:
1 | |
4. 执行 emp.setName("老齐")
1 | |
含义是:
1 | |
字符串 "老齐" 是一个字符串对象,通常在字符串常量池中有对应引用。
5. 执行 emp.setAge(13)
1 | |
把堆中 Employee 对象的 age 字段改成 13。
此时 Employee 对象状态:
1 | |
6. 执行 dept = new Department()
JVM 在堆中创建 Department 对象:
1 | |
如果 Department 是:
1 | |
初始状态:
1 | |
7. 执行 dept.setDname("小卖部")
把 Department 对象的 dname 字段设置为 "小卖部"。
1 | |
8. 执行 emp.setDepartment(dept)
1 | |
这一步不是把 Department 对象复制一份给 Employee,而是把 dept 引用赋值给 emp.department。
最终对象关系:
1 | |
9. 执行 emp.sayJoke("一言不合...")
调用对象方法:
1 | |
JVM 会创建 sayJoke 方法对应的新栈帧。
方法执行时,可以访问:
| 数据 | 来源 |
|---|---|
this |
当前 Employee 对象 |
| 参数 joke | "一言不合..." |
| 成员变量 name、age、department | 堆中 Employee 对象字段 |
10. 方法执行完成
sayJoke 方法执行完后,它的栈帧出栈。
main 方法继续执行,最终 main 方法结束,main 栈帧出栈。
如果没有其他非守护线程,JVM 进程退出。
11. 这段代码和并发有什么关系?
单线程执行时,这段代码基本没有线程安全问题。
但如果多个线程共享同一个 Employee 对象:
1 | |
这时就涉及:
| 问题 | 说明 |
|---|---|
| 可见性 | 一个线程修改字段,另一个线程是否能看到 |
| 原子性 | 多个字段更新是否作为整体完成 |
| 有序性 | 对象发布时是否可能被看到半初始化状态 |
| 线程安全 | 对象是否允许被多个线程安全访问 |
所以对象创建本身不复杂,复杂的是对象被多个线程共享之后。
九、线程的五种状态、六种状态、七种状态
1. 为什么有多种说法?
线程状态有不同的划分方式。
常见说法:
| 说法 | 来源 |
|---|---|
| 五种状态 | 操作系统或教学模型 |
| 六种状态 | Java 官方 Thread.State |
| 七种状态 | 把 Runnable 拆成 Ready 和 Running 的细化模型 |
写 Java 博客时,建议重点以 Java 官方 6 种状态为准。
2. Java 官方六种状态
Java Thread.State 有 6 种:
| 状态 | 说明 |
|---|---|
| NEW | 新建,还没有调用 start() |
| RUNNABLE | 可运行,可能正在运行,也可能等待 CPU 调度 |
| BLOCKED | 阻塞,等待进入 synchronized 临界区 |
| WAITING | 无限期等待,需要其他线程唤醒 |
| TIMED_WAITING | 限时等待,时间到了会自动返回 |
| TERMINATED | 线程执行结束 |
3. NEW
线程对象被创建,但还没有启动。
1 | |
此时状态是 NEW。
4. RUNNABLE
调用 start() 后,线程进入 RUNNABLE。
1 | |
注意:
Java 的 RUNNABLE 包含两个含义:
1 | |
所以 Java 里没有单独的 READY 和 RUNNING 状态。
5. BLOCKED
线程等待进入 synchronized 代码块时,会进入 BLOCKED。
1 | |
另一个线程也想进入同一个锁保护的代码块,就会 BLOCKED。
注意:
BLOCKED 主要针对 synchronized 的 monitor lock。
6. WAITING
线程无限期等待其他线程唤醒。
常见方式:
1 | |
例如:
1 | |
此时线程等待其他线程调用:
1 | |
或者:
1 | |
7. TIMED_WAITING
线程限时等待。
常见方式:
1 | |
时间到了之后,线程会自动恢复到可竞争 CPU 的状态。
8. TERMINATED
线程执行完成,进入终止状态。
1 | |
任务执行完后,线程状态就是 TERMINATED。
线程终止后不能再次调用 start()。
否则会抛出:
1 | |
9. 五种状态模型
五种状态通常是:
1 | |
| 状态 | 说明 |
|---|---|
| 新建 | 线程创建 |
| 就绪 | 等待 CPU |
| 运行 | 正在执行 |
| 阻塞 | 等待锁、I/O、sleep、wait 等 |
| 终止 | 执行结束 |
这是操作系统或教学模型,比较宏观。
10. 七种状态模型
七种状态通常是:
1 | |
它比五种状态更细,把阻塞类状态拆成:
| 状态 | 说明 |
|---|---|
| 阻塞 | 等锁 |
| 等待 | 无限等待 |
| 超时等待 | 限时等待 |
11. 五种、六种、七种状态映射
| 教学七状态 | Java 官方状态 |
|---|---|
| 新建 | NEW |
| 就绪 | RUNNABLE |
| 运行 | RUNNABLE |
| 阻塞 | BLOCKED |
| 等待 | WAITING |
| 超时等待 | TIMED_WAITING |
| 终止 | TERMINATED |
结论:
Java 官方是 6 种状态;所谓 7 种状态,是把 RUNNABLE 拆成“就绪”和“运行”之后的教学模型。
十、创建线程的几种方式
Java 中常见创建线程方式有四类:
1 | |
实际开发中,最推荐的是线程池。
1. 继承 Thread 类
示例
1 | |
注意:
调用 start() 才是启动新线程。
如果直接调用 run():
1 | |
那只是普通方法调用,不会创建新线程。
优点
| 优点 | 说明 |
|---|---|
| 简单直观 | 适合初学者理解线程 |
| 可以直接使用 Thread 方法 | 比如 getName()、interrupt() |
缺点
| 缺点 | 说明 |
|---|---|
| Java 单继承限制 | 继承 Thread 后不能再继承其他类 |
| 任务和线程绑定太死 | 不利于复用 |
| 不适合实际业务开发 | 难以统一管理线程资源 |
使用场景
适合学习线程基本概念,不推荐在生产业务中大量使用。
2. 实现 Runnable 接口
示例
1 | |
Lambda 写法:
1 | |
优点
| 优点 | 说明 |
|---|---|
| 避免单继承限制 | 类还可以继承其他父类 |
| 任务和线程分离 | Runnable 表示任务,Thread 表示执行者 |
| 更适合资源共享 | 多个线程可以执行同一个 Runnable |
缺点
| 缺点 | 说明 |
|---|---|
| 没有返回值 | run() 返回 void |
| 不能直接抛出受检异常 | 需要自己捕获处理 |
使用场景
适合不需要返回结果的异步任务。
3. Callable + Future
Runnable 没有返回值,也不能方便地抛出异常。
如果任务需要返回结果,可以使用 Callable。
示例
1 | |
优点
| 优点 | 说明 |
|---|---|
| 有返回值 | call() 可以返回结果 |
| 可以抛出异常 | 便于任务失败处理 |
| 可以配合 Future 获取结果 | 支持取消、判断完成状态 |
缺点
| 缺点 | 说明 |
|---|---|
get() 可能阻塞 |
如果任务没完成,调用线程会等待 |
| 单独使用不够优雅 | 实际开发通常配合线程池 |
使用场景
适合需要异步计算结果的任务。
4. 通过线程池创建线程
线程池是实际开发中最常用的方式。
示例
1 | |
不过,生产环境不建议直接使用 Executors.newFixedThreadPool() 这类快捷工厂方法,因为很多默认参数不够明确,可能导致队列过大或线程过多。
更推荐手动创建 ThreadPoolExecutor。
ThreadPoolExecutor 示例
1 | |
核心参数
| 参数 | 说明 |
|---|---|
| corePoolSize | 核心线程数 |
| maximumPoolSize | 最大线程数 |
| keepAliveTime | 非核心线程空闲存活时间 |
| unit | 时间单位 |
| workQueue | 任务队列 |
| threadFactory | 线程工厂 |
| handler | 拒绝策略 |
执行流程
线程池提交任务后,大致流程:
1 | |
常见拒绝策略
| 策略 | 说明 |
|---|---|
| AbortPolicy | 直接抛异常,默认策略 |
| CallerRunsPolicy | 由提交任务的线程自己执行 |
| DiscardPolicy | 直接丢弃任务,不抛异常 |
| DiscardOldestPolicy | 丢弃队列中最老的任务 |
优点
| 优点 | 说明 |
|---|---|
| 复用线程 | 避免频繁创建销毁线程 |
| 控制并发量 | 防止线程无限增长 |
| 统一管理 | 方便监控、关闭、命名 |
| 支持任务队列 | 高峰期可缓冲任务 |
缺点
| 缺点 | 说明 |
|---|---|
| 参数配置复杂 | 配不好容易 OOM 或任务堆积 |
| 需要监控 | 需要关注队列长度、活跃线程数 |
| 任务隔离要求高 | 不同业务最好使用不同线程池 |
使用场景
实际业务开发中,线程池适合:
| 场景 | 示例 |
|---|---|
| 异步处理 | 发短信、发 MQ、写日志 |
| 批量任务 | 批量计算、批量导入 |
| I/O 并发 | 调用多个远程接口 |
| 定时任务 | 周期性数据同步 |
| 限流隔离 | 防止某类任务拖垮主线程 |
十一、虚拟线程创建方式
虽然传统线程创建方式有四种,但 Java 21 之后还需要了解虚拟线程。
1. 直接创建虚拟线程
1 | |
2. 使用 Builder 创建
1 | |
3. 使用虚拟线程执行器
1 | |
4. 虚拟线程使用建议
适合:
1 | |
不适合:
1 | |
虚拟线程不是银弹。
如果数据库连接池只有 20 个连接,你开 10000 个虚拟线程同时查库,最终瓶颈还是数据库连接池。
十二、JUC 入门
JUC 是 java.util.concurrent 的简称,是 Java 并发编程的核心工具包。
它大致可以分为几类:
1 | |
1. 线程池相关
核心类:
| 类 | 说明 |
|---|---|
| Executor | 最基础任务执行接口 |
| ExecutorService | 支持提交任务、关闭线程池 |
| ThreadPoolExecutor | 普通线程池核心实现 |
| ScheduledThreadPoolExecutor | 定时任务线程池 |
| ForkJoinPool | 分治任务线程池 |
| CompletableFuture | 异步任务编排 |
示例:
1 | |
2. 锁相关
核心类:
| 类 | 说明 |
|---|---|
| Lock | 锁接口 |
| ReentrantLock | 可重入锁 |
| ReadWriteLock | 读写锁接口 |
| ReentrantReadWriteLock | 可重入读写锁 |
| StampedLock | 支持乐观读 |
| Condition | 条件队列 |
| LockSupport | 线程挂起和唤醒工具 |
ReentrantLock 示例
1 | |
必须在 finally 中释放锁。
否则异常发生后锁不释放,其他线程可能永远等不到。
3. 原子类
核心类:
| 类 | 说明 |
|---|---|
| AtomicInteger | 原子 int |
| AtomicLong | 原子 long |
| AtomicBoolean | 原子 boolean |
| AtomicReference | 原子对象引用 |
| LongAdder | 高并发计数优化 |
| LongAccumulator | 自定义累加规则 |
示例:
1 | |
高并发计数推荐:
1 | |
AtomicLong 适合一般并发计数,LongAdder 适合高并发热点计数。
4. 并发容器
核心类:
| 类 | 说明 |
|---|---|
| ConcurrentHashMap | 并发 Map |
| CopyOnWriteArrayList | 写时复制 List |
| ConcurrentLinkedQueue | 非阻塞并发队列 |
| BlockingQueue | 阻塞队列接口 |
ConcurrentHashMap 示例
1 | |
不要在并发场景下直接使用普通 HashMap 做共享写入。
5. 阻塞队列
核心类:
| 类 | 说明 |
|---|---|
| ArrayBlockingQueue | 有界数组阻塞队列 |
| LinkedBlockingQueue | 链表阻塞队列 |
| PriorityBlockingQueue | 优先级阻塞队列 |
| DelayQueue | 延迟队列 |
| SynchronousQueue | 不存储元素的移交队列 |
阻塞队列常用于生产者消费者模型。
示例
1 | |
6. 并发协作工具
核心类:
| 类 | 说明 |
|---|---|
| CountDownLatch | 一个线程等待多个线程完成 |
| CyclicBarrier | 多个线程互相等待,到齐后继续 |
| Semaphore | 控制同时访问资源的线程数量 |
| Phaser | 更灵活的阶段协作工具 |
| Exchanger | 两个线程交换数据 |
CountDownLatch 示例
1 | |
适合主线程等待多个子任务完成。
Semaphore 示例
1 | |
适合限流,比如限制同时导出文件、同时调用外部接口的数量。
7. CompletableFuture
CompletableFuture 适合异步编排。
示例
1 | |
常见方法:
| 方法 | 说明 |
|---|---|
| supplyAsync | 异步执行,有返回值 |
| runAsync | 异步执行,无返回值 |
| thenApply | 转换结果 |
| thenAccept | 消费结果 |
| thenCompose | 串联异步任务 |
| thenCombine | 合并两个异步任务 |
| allOf | 等待多个任务全部完成 |
| anyOf | 任意一个任务完成即可 |
| exceptionally | 异常处理 |
十三、线程池实战建议
1. 不同业务使用不同线程池
不要所有异步任务都丢到一个线程池。
例如:
1 | |
这样可以避免一个慢任务拖垮所有业务。
2. 线程必须命名
线程名很重要。
错误示例:
1 | |
线上排查问题时很痛苦。
推荐:
1 | |
3. 队列不要无限大
不要轻易使用无界队列。
无界队列可能导致任务无限堆积,最终 OOM。
推荐使用有界队列:
1 | |
4. 设置合理拒绝策略
业务重要任务不要静默丢弃。
一般建议:
| 业务类型 | 策略 |
|---|---|
| 核心业务 | AbortPolicy,快速失败 |
| 可降级任务 | CallerRunsPolicy |
| 日志、埋点 | 可考虑丢弃,但要有监控 |
| 批处理任务 | 失败重试或落库补偿 |
5. 监控线程池
至少监控:
1 | |
线程池没有监控,就像开车没有仪表盘。 不是不能开,是迟早撞树。
参考资料
启示录
富贵岂由人,时会高志须酬。
能成功于千载者,必以近察远。