17. java项目中,如何判断一段代码是否有线程安全问题
第一步:寻找“共享可变状态”(核心判断标准)
线程安全问题产生的三个必要条件(缺一不可):
判断结论:
• 如果代码是单线程运行的,线程安全。
• 如果代码没有共享变量(全部是局部变量、方法参数),绝对线程安全。
• 如果有共享变量,但是它是不可变的(共享变量是引用类型的,需要满足
引用不可变也就是final修饰和对象不可变;是基本类型的,那么只要final修饰即可),绝对线程安全。• 如果有共享且可变的变量,存在线程安全隐患,进入第二步。
第二步:基于 JMM(Java内存模型)三大特性分析
如果存在“共享可变状态”,就需要检查是否破坏了以下三个特性:
1. 原子性
•含义:一个操作或多个操作,要么全部执行且执行过程不被中断,要么都不执行。
•典型反例:
i++、i--、a = a + 1。这些操作在字节码层面是“读-改-写”三步,非原子操作。•复合操作:如
if (map.contains(key)) { map.put(key, value); },即使contains和put自身是线程安全的(如ConcurrentHashMap),组合在一起也不是原子的。•判断点:对共享变量的修改,是否是一个非原子的复合操作?
2. 可见性
•含义:一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
•典型反例:线程A将变量
flag=false修改为true,线程B一直在while(!flag)循环中,可能永远读不到true,因为B读取的是自己工作内存中的旧值。•判断点:共享变量是否被
volatile修饰?对变量的读写是否在synchronized块或Lock中?
3. 有序性
•含义:程序执行的顺序按照代码的先后顺序。
•典型反例:指令重排。最经典的是单例模式的“双重检查锁(DCL)”
•判断点:是否存在没有适当同步保障的“先检查后执行”逻辑?共享变量是否加
volatile禁止重排?
总结:排查清单
当你拿到一段代码,请按以下清单过一遍:
- 有没有多线程访问?(Web接口、线程池、定时任务、消息消费者等默认都是多线程)
- 有没有共享变量?(全局变量、静态变量、单例对象中的成员变量)
- 共享变量会被修改吗?(只读是安全的)
- 修改操作是原子的吗?(
i++不是,AtomicInteger是) - 修改后的值对其他线程立即可见吗?(没加
volatile或没在锁内可能不可见) - 代码逻辑是否依赖前一个状态?(检查再执行逻辑,必须整体加锁,防止指令重排)
- 使用的JDK类是线程安全的吗?(
HashMap换ConcurrentHashMap,SimpleDateFormat换DateTimeFormatter或用ThreadLocal)
18. 为什么现代Java项目,不推荐使用Timer实现定时任务?
Timer的核心缺陷如下:
单线程阻塞问题
由于Timer只有一个TimerThread,如果其中一个TimerTask执行时间过长,或者因为某些原因卡住了,其他所有任务都会被阻塞,导致后续任务的执行时间延后,丧失定时准确性。单点故障问题
如果任何一个TimerTask抛出了未捕获的异常(如RuntimeException),TimerThread会因为异常而退出并死亡,整个Timer就会崩溃。队列中所有尚未执行的任务都会被静默丢弃,且不会再接受新任务,这在生产环境中是灾难性的。对系统时钟敏感
Timer依赖的是System.currentTimeMillis()(绝对时间)。如果在运行期间操作系统的时钟被回拨,Timer会出现混乱,可能导致任务被疯狂执行或长时间挂起。
如果老项目中,一个Timer对应一个TimerTask(没有单线程阻塞问题),每个TimerTask间隔一天执行一次,每个TimerTask都做了兜底的异常处理(没有单点故障问题),虽然仍会造成线程资源浪费,如果运行稳定,也可以维持现状。
19. Go 语言的协程(轻量级线程),java中是否有类似实现,为什么要设计它?
Java 语言里,原有的Thread是和操作系统线程一一对应的,从JDK 21开始支持的虚拟线程(Virtual Threads,Project Loom),则是一种轻量级线程,从此java也有了对协程的原生支持。
为什么要设计这种“轻量级线程”(虚拟线程)?
OS 线程的三大痛点
- 资源开销大
• 栈默认 1–2MB,几千个线程就能把内存吃光。
• 创建/销毁要走系统调用,涉及内核对象、页表等,开销在毫秒级。
- 上下文切换代价高
• 每次切换都要:用户态 → 内核态 → 保存/恢复寄存器、栈、TLB 等 → 用户态。
• 线程多了,CPU 时间大量浪费在切换上。
- I/O 阻塞 = 线程浪费
• 典型 Web/微服务场景:一个请求一个线程,线程大部分时间在等 DB/HTTP。
• 等的时候线程啥也不干,但 OS 线程是宝贵资源,结果就是:
• 并发上不去(几千线程就顶天),
• CPU 利用率低(线程都在等 I/O)。
为什么不用“异步非阻塞 + 回调”?
为了解决 I/O 阻塞问题,Java 世界早有异步方案(NIO、Netty、CompletableFuture、Reactor):
• 优点:
• 少量线程就能处理大量连接(事件循环 / epoll)。
• 缺点:
• 代码变成回调链或响应式流,可读性/可维护性差,调试难。
• 业务逻辑被拆得稀碎,错误处理、上下文传递都很痛苦。
Go 的思路是:我不想写回调,也不想管线程池,我就想“一个请求一个 goroutine,同步写到底”。虚拟线程的思路也一样:让“每请求一线程”的模型在高并发下也能用。
虚拟线程如何解决这些问题?
- 用户态调度,避免内核切换
• JVM 自己维护调度器,在用户态切换 虚拟线程。
• 切换只是保存/恢复少量寄存器、栈指针,不陷入内核,开销极小。
- 极轻量栈,海量并发
• 虚拟线程:初始几百字节~几 KB,按需增长。
• 同样 8GB 内存,只能装几千 OS 线程,但可以装几十万/百万虚拟线程。
- 阻塞不浪费载体线程
• 虚拟线程阻塞时,JVM 把它从载体线程卸载,载体线程继续跑其他虚拟线程。
• 结果:少量 OS 线程就能支撑海量并发 I/O。
- 回归同步写法,简化编程模型
• 你可以继续写“顺序代码”:
val = call(); process(val);,• 底层自动在 I/O 处挂起/恢复,不再需要回调地狱或响应式流。
- 与语言/平台生态整合
- • Java:虚拟线程仍然是
Thread,兼容synchronized、ExecutorService等,尽量少改既有代码。
传统Java线程和虚拟线程的对比
| 特性 | 平台线程(传统 Java 线程) | 虚拟线程(Virtual Thread) |
|---|---|---|
| 映射关系 | 1:1(线程 = OS 线程) | M:N(虚拟线程:平台线程) |
| 栈大小 | 默认 ~1MB | 初始几百字节~几 KB,按需增长 |
| 创建/上下文切换 | 毫秒级,内核态 | 微秒/纳秒级,用户态 |
| 可轻松创建数量级 | 数千 | 百万级 |
| 阻塞行为 | 阻塞 OS 线程 | 仅挂起自己,载体线程可复用 |
20. jdk21的虚拟线程适合什么场景,不适合什么场景?
**虚拟线程最适合:大量“等 I/O、短任务、高并发”的场景,尤其是用“一个请求一个线程”写的服务端程序。**对 CPU 密集型任务几乎没有帮助,甚至更差。
20.1. 虚拟线程表现最好的典型场景
20.1.1 高并发 Web / API 服务(thread-per-request)
官方明确说,虚拟线程的目标就是让简单的 “thread-per-request 服务器应用能接近硬件极限扩展。
特点:
• 每个请求一个虚拟线程,代码结构几乎不变
• 大量请求在等 I/O:等 DB、等缓存、等下游服务
• 传统线程池几千线程就到瓶颈,虚拟线程可以轻松上十万、百万级
适合:
• REST API / BFF 网关
• 电商/支付等高并发短请求服务
• 任何用 “一个请求一个线程” 写的服务端程序
20.1.2 微服务之间大量调用(HTTP / RPC / Feign / Dubbo)
一个请求经常要串行或并行调用多个下游服务,大部分时间在等网络返回。
传统做法:
• 用异步 Callback / Reactor / WebFlux,避免线程池被打满
• 代码复杂,调试困难
用虚拟线程:
• 继续写同步阻塞调用:
http.get(),rpc.call()• 阻塞时虚拟线程自动让出载体线程,少量载体线程就能支撑海量并发请
适合:
• 服务间 A 调 B、B 调 C 的同步链路
• 重构现有异步代码为同步风格,同时保持吞吐
20.1.3 高并发 JDBC / NoSQL 访问
典型情况:每个请求都要查几次数据库或 Redis,线程大部分时间在等 I/O。
优势:
• 虚拟线程阻塞在
connection.executeQuery()时,载体线程可以服务其他请求• 不再需要“大连接池 + 大线程池”的复杂配置,线程数几乎不再成为瓶颈
适合:
• 高并发短查询的业务系统
• 批量查询 / 批量更新(每次 I/O 很短,但量很大)
20.1.4 消息队列消费者(Kafka / RocketMQ / RabbitMQ)
消费逻辑通常是:收消息 → 调用远程服务 / 写库 → 确认。大部分时间在等 I/O。
传统问题:
• 线程池大小配置难:太少吞吐低,太多 OOM
• 异步消费者回调模型复杂
虚拟线程方式:
• 每条消息一个虚拟线程,
send()/call()阻塞也不怕• 可以非常方便地提高并发消费能力,不用调线程池
适合:
• I/O 密集型消费者(调下游服务、写 DB)
• 需要线性提高消费并发度的场景
20.1.5 大量文件 / 网络 I/O 的批处理
例如:成千上万小文件读取、外部 API 批量调用、日志/埋点处理等。
特点:
• 每个任务 I/O 等待时间长,CPU 闲置
• 虚拟线程可以在等 I/O 时把载体线程让给别人,整体吞吐提升明显
适合:
• 批量导入/导出、数据同步
• 大量短 I/O 任务(每任务几十毫秒~几百毫秒)
20.2. 虚拟线程“不太合适”的场景
20.2.1 CPU 密集型任务
官方和大量实践都强调:虚拟线程不是更快的线程,而是更“可扩展”的线程。
• 如果任务主要是 CPU 计算(加密、压缩、图像/视频处理、复杂算法),虚拟线程几乎没有优势,甚至因调度开销略慢
• 虚拟线程默认的载体线程数量 ≈ CPU 核心数,长计算会占死载体线程,其他虚拟线程排不到
建议:
- • CPU 密集任务仍用 **平台线程池(ThreadPoolExecutor)或并行流(parallel stream。
20.2.2 需要精细控制线程数 / 亲和性的场景
例如:
• 需要固定线程数绑定 CPU 缓存、NUMA 优化
• 需要严格限制并发访问某个资源(如外部服务限流)
虚拟线程非常“轻”,可以轻易创建百万个,反而容易把下游打挂。此时更适合:
• 显式使用固定大小的平台线程池 + 信号量/限流
• 虚拟线程只在I/O 等待阶段使用,CPU 部分仍用平台线程
21. jdk21中,虚拟线程什么情况下阻塞会被钉住(pinned)?
•会被“钉住”(pinned):虚拟线程在
synchronized块/方法里阻塞,或者调本地方法(native / foreign function)时阻塞——载体线程不会释放,其他虚拟线程用不了这个载体线程。•不会被钉住:**不是在
synchronized/ native 里的阻塞,一般都可以卸载。普通 JDK 的阻塞 I/O(socket/文件/Channel)、Thread.sleep、Object.wait、LockSupport.park、BlockingQueue.take等——虚拟线程会正常从载体线程卸载,载体线程可以被别的虚拟线程复用。
什么情况下阻塞会钉住?
synchronized块/方法里的阻塞
典型代码(JDK 21–23)
synchronized(lock){// 这里做阻塞 I/O 或sleepsocket.read();// 或 Thread.sleep(1000)}• 虚拟线程进入
synchronized后,监视器锁的 owner 记录的是载体线程(平台线程),而不是虚拟线程。• 如果虚拟线程在这里卸载,载体线程换人,锁的 owner 就对不上了,会破坏互斥语义,所以 JVM不允许卸载,直接把虚拟线程“钉”在当前载体线程上。
• 结果:
socket.read()/Thread.sleep()虽然是“可卸载”操作,但因为外面包着synchronized,虚拟线程无法卸载,载体线程一起阻塞,直到离开synchronized块。
关键点:
• 不是
Thread.sleep/ I/O 本身钉住,而是在synchronized里面做这些操作才钉住。• 只要还在
synchronized块内,哪怕只是Thread.sleep(1),也会钉住载体线程。
native / foreign function 里的阻塞
例如:
• 调用 JNI 函数,内部做了阻塞读网络 / 文件;
• 使用 Panama / foreign function API 调外部 C 函数,该函数里阻塞。
JEP 444 把这些归为第二类钉住点:
• native / foreign 函数对 JVM 来说是“
黑盒”,JVM 无法安全地保存/恢复 Java 栈帧,因此不能卸载,只能阻塞载体线程。• 这类代码如果频繁长时间阻塞,也会严重拖垮并发。