Java 项目里最常见的线程安全问题有哪些
从共享变量、集合并发、单例状态到锁粒度与可见性,系统梳理 Java 项目里最常见的线程安全问题、典型场景和排查思路。
线程安全这个词,很多 Java 开发都不陌生。面试里会问,代码评审里也会提,线上问题排查时更容易绕不开。但它又特别容易被说成一种很抽象的东西:共享变量、可见性、原子性、锁、并发容器……概念很多,真到了项目里,反而不一定能第一时间意识到问题就在这里。
更麻烦的是,线程安全问题并不总是以“程序直接崩掉”的方式出现。很多时候它表现得更像偶发 bug:
- 某个计数偶尔不准
- 某个状态偶尔覆盖
- 某条数据偶尔重复处理
- 某个缓存值偶尔不一致
这些现象往往让人第一反应去怀疑业务逻辑,但真正根因可能是:多线程下共享状态没有被正确保护。
这篇文章想做的,是把 Java 项目里最常见的几类线程安全问题放到真实工程场景里讲清楚。重点不是背理论,而是知道:哪些地方最容易出事,为什么会出事,以及排查时该优先回看哪类共享状态。
这篇更适合什么场景
先用这张表判断,你现在更像是在补并发基础,还是已经进入更具体的运行态问题。
| 你现在看到的现象 | 更像什么 | 下一步 |
|---|---|---|
| 你想系统梳理项目里最常见的线程安全坑和排查思路 | 并发基础补充内容 | 继续看本文 |
你已经在选择本地锁实现,想判断 synchronized 还是 ReentrantLock | 更像锁选型页 | 接 synchronized 和 ReentrantLock 有什么区别?项目里该怎么选 |
| 你怀疑是 ThreadLocal 串用、清理边界或线程复用问题 | 更像 ThreadLocal 补充内容 | 接 ThreadLocal 常见坑有哪些?项目里最容易踩的几个问题 |
| 现象已经外溢成线程池拥塞、任务慢或执行链问题 | 更像故障主线 | 接 线程池打满以后,应该先查队列、拒绝策略还是慢任务? |
| 你当前更关心接口变慢、CPU 抖动和运行态退化 | 更像性能排障主线 | 接 接口响应慢怎么排查?后端性能问题定位步骤 或 Java CPU 飙高怎么排查:一套线上定位顺序 |
这篇主要解决哪一层问题
这篇文章更适合这样的时机:你已经意识到代码里可能有共享状态、复合操作或可见性问题,想先把最常见的坑和排查顺序梳理清楚。
它不替代下面这些已经进入运行态的现场:
- 线程池已经开始拥塞
- CPU、GC、OOM 已经明显异常
- 问题已经收窄到 ThreadLocal 或锁选型
所以这篇最适合拿来做两件事:代码评审时提前扫雷,或者线上怀疑并发 bug 时,先把共享可变状态这层重新看一遍。
一、先有一个基本认识:线程安全问题的本质,是多个线程同时操作同一份状态
线程安全之所以麻烦,不是因为 Java 线程多神秘,而是因为:
- 多个线程同时运行
- 它们可能共享同一份数据
- 如果没有清晰约束,就会出现竞态条件
所以大多数线程安全问题,本质上都能收敛到一句话:
多个线程在没有正确同步的情况下,同时读写同一份可变状态。
这里有三个关键词特别重要:
- 多个线程
- 共享
- 可变
如果对象根本不共享,或者共享但不可变,很多问题就不会出现;真正危险的是共享可变状态。
二、最常见的问题 1:共享计数、标志位、状态字段没有同步保护
这是最典型也最常见的一类。
例如:
private int count = 0;
public void incr() {
count++;
}
很多人看到这段代码会觉得没什么,但在多线程下,count++ 不是一个原子操作。它通常包含:
- 读取旧值
- 加一
- 写回新值
如果两个线程同时执行,就可能发生覆盖,最终结果比预期小。
典型场景
- 请求计数
- 重试次数
- 状态切换标记
- 限流窗口内的计数
常见误区
“这个字段就改一下,应该没事吧。”
并发问题最容易藏在这种“看起来很小的状态”里。
三、最常见的问题 2:把普通集合当成线程安全集合使用
例如:
private static final Map<Long, String> cache = new HashMap<>();
如果多个线程同时对这个 HashMap 做读写,就可能出现问题。
为什么
普通集合类例如:
HashMapArrayListHashSet
默认都不是线程安全的。
在并发场景下,常见风险包括:
- 数据覆盖
- 读取到中间状态
- 并发修改异常
- 结构状态异常
项目里最容易出现场景的地方
- 本地缓存
- 规则配置表
- 临时结果汇总
- 批处理中的共享容器
更稳妥的做法
- 明确加锁保护
- 使用并发容器,例如
ConcurrentHashMap - 尽量减少跨线程共享集合
重点不是“有没有集合”,而是:有没有多个线程同时改它。
四、最常见的问题 3:单例 Bean 里放了可变状态
这是 Spring 项目里特别容易踩坑的一类。
很多服务类默认都是单例的,例如:
@Service
public class OrderService {
private String currentUser;
}
如果你在单例 Bean 里保存请求相关状态、处理中间结果,多个请求并发进来时就会互相覆盖。
为什么这个问题隐蔽
因为从代码表面看,它只是一个普通成员变量;但从运行时看,这个 Bean 是全局共享的一份实例。
典型场景
- 把用户上下文存在成员变量里
- 把中间结果缓存到服务对象字段里
- 想靠成员变量复用一些请求级数据
更好的习惯
- 单例 Bean 尽量保持无状态
- 请求级数据放在方法参数、局部变量或明确的上下文对象里
- 不要把“方便访问”变成“共享可变状态”
五、最常见的问题 4:先检查再执行,中间没有原子性保证
看一个很常见的逻辑:
if (!set.contains(id)) {
set.add(id);
}
单线程下没问题,多线程下却可能出事。
为什么
因为:
- 线程 A 检查
contains(id),结果是 false - 在线程 A
add之前,线程 B 也检查到 false - 两个线程都执行
add
于是你本来以为“不会重复”的逻辑,最后还是重复了。
这种问题特别容易出现在
- 去重逻辑
- 状态判断后更新
- 库存扣减前校验
- 幂等控制
核心问题
不是单个步骤错,而是“检查”和“执行”之间不是原子操作。
这类场景如果只靠 if 判断,没有锁、CAS、数据库约束或其他原子保障,问题就很容易出现。
六、最常见的问题 5:可见性问题——一个线程改了,另一个线程没及时看到
并发问题不一定都是“同时改坏了”,还有一类是“明明改了,但别人看不见”。
例如:
private boolean running = true;
public void stop() {
running = false;
}
public void work() {
while (running) {
// do something
}
}
如果没有合适的可见性保证,工作线程可能一直看不到 running = false 的变化。
这类问题常见在哪
- 自定义线程控制
- 停止标志位
- 配置热更新
- 后台任务开关
为什么会这样
因为在多线程环境下,线程对共享变量的读取不一定总是立刻看到别的线程刚写入的新值。
这时候就要靠:
volatile- 锁
- 原子类
等机制建立可见性保证。
七、最常见的问题 6:锁加了,但锁的范围和粒度不对
有些代码意识到了并发风险,也加了锁,但问题还是会出现。
例如:
- 锁加在了错误对象上
- 只锁了局部步骤,没有锁住完整临界区
- 锁太细,保护不住共享状态
- 锁太粗,虽然安全了,但性能又被拖垮
一个典型误区
synchronized (new Object()) {
// do something
}
这段代码看起来像加了锁,实际上每次锁的都是一个新对象,线程之间根本没有同步效果。
另一个常见问题
只锁“写”,不锁“读”,而读逻辑又依赖写入过程的完整性。
所以锁的问题不只是“有没有”
还要看:
- 锁的是不是同一把锁
- 锁的范围是否覆盖完整临界区
- 性能和正确性有没有平衡好
八、最常见的问题 7:ThreadLocal 用错,导致数据串用或泄漏
很多人会把 ThreadLocal 当成解决线程安全问题的银弹,因为它能给每个线程一份独立变量副本。
它确实有用,但也很容易被用坏。
常见问题包括
- 用完不清理,线程池线程复用后数据串到下一个请求
- 把本不该放进 ThreadLocal 的大对象放进去
- 上下文传递边界不清晰
典型场景
- 用户上下文
- TraceId
- 数据源切换标记
- 事务或请求级上下文
为什么在线程池里特别要小心
因为线程不是一次性销毁的,而是会复用。如果你不 remove(),后续请求就可能看到前一个请求残留的数据。
所以 ThreadLocal 解决的是“每线程隔离”,不是“自动生命周期管理”。
九、最常见的问题 8:并发下的懒加载、单例初始化写得不严谨
例如:
if (instance == null) {
instance = new Config();
}
如果多个线程同时进入,就可能重复初始化,甚至在某些场景下读到不完整对象。
这类问题常出现在哪
- 单例初始化
- 缓存懒加载
- 配置对象延迟创建
- 某些工具类静态资源初始化
为什么它容易被忽视
因为代码看起来“平时都能跑”,并发一低时也不容易出事;但一旦启动高峰或高并发访问撞到这个窗口,问题就出来了。
十、怎么排查:线程安全问题最怕靠猜,要先找共享可变状态
线程安全问题之所以难查,是因为它经常不是每次都复现。真正有效的排查方式,不是直接在一堆业务分支里瞎找,而是先缩小到“谁在共享、谁在修改、谁没保护好”。
一个比较实用的顺序是:
第一步:先找共享状态
问自己:
- 哪些对象会被多个线程同时访问
- 哪些字段不是局部变量
- 哪些集合、缓存、单例 Bean 持有可变数据
第二步:再看修改路径
- 是否只有一个线程修改
- 是否读写混合出现
- 是否存在先判断后执行
- 是否存在跨线程传递状态
第三步:再看同步手段
- 是否有锁
- 是否用了原子类
- 是否用了并发容器
- 可见性有没有保证
只要这三步跑一遍,大多数线程安全问题都会开始露出轮廓。
十一、一个典型例子:为什么库存偶尔会被扣成负数
假设有这样一段逻辑:
if (stock > 0) {
stock--;
}
单线程看没问题,但并发下就可能出现:
- 两个线程同时看到
stock > 0 - 然后都执行
stock-- - 最终库存被多扣了一次
这类问题特别能说明:
- 并发 bug 不一定复杂
- 问题经常出在“检查 + 更新”之间没有原子性
它也说明一个很重要的事实:
线程安全问题很多时候不是某一行代码写错,而是多个本来各自正确的步骤,在并发下组合出了错误结果。
十二、几个非常常见的误区
1. 觉得“这个字段改动很少,应该没事”
并发 bug 不看次数多少,看的是有没有窗口撞上。
2. 觉得“加了 synchronized 就万事大吉”
还要看锁对不对、范围够不够、性能能不能接受。
3. 觉得“用了 ConcurrentHashMap,整个逻辑就线程安全了”
容器本身安全,不代表基于它写出的复合操作天然安全。
4. 觉得“Spring 帮我管理 Bean,所以天然线程安全”
Spring 管生命周期,不替你消灭共享可变状态。
5. 觉得“偶发复现不了,就不是并发问题”
恰恰相反,偶发、难稳定复现,往往正是并发问题的典型特征。
十三、最后留一份够用的检查 checklist
以后再怀疑线程安全问题,可以沿着这条线查:
第 1 步:找共享可变状态
- 成员变量
- 单例 Bean 字段
- 静态变量
- 共享集合
- 本地缓存
第 2 步:看并发读写路径
- 多线程是否同时访问
- 是否有复合操作
- 是否存在 check-then-act
- 是否存在跨线程可见性问题
第 3 步:看保护手段
- 锁
- 原子类
- 并发容器
volatile- ThreadLocal 生命周期管理
第 4 步:最后再评估设计是否该重构
- 能不能去掉共享状态
- 能不能改成不可变对象
- 能不能把状态下沉到数据库、缓存或消息模型里
十四、最后总结:线程安全问题的高频根因,不是“线程多”,而是共享可变状态没有被清楚管理
很多人一提线程安全,就会下意识想到锁、CAS、JMM 这些概念。它们当然重要,但在真实项目里,更值得优先记住的其实是更朴素的一句话:
只要多个线程同时碰同一份可变数据,而你又没有清楚定义谁来改、怎么改、什么时候别人能看到,那问题迟早会出现。
所以比起死记各种并发术语,更有用的思路是:先找共享可变状态,再看复合操作和可见性,最后才决定是加锁、改容器,还是干脆改设计。
只要这条主线清楚了,大多数线程安全问题就不会再显得那么“玄学”。
FAQ:线程安全基础问题里最常被追问的几个问题
1. 为什么线程安全问题总是又偶发、又难复现?
因为它依赖并发时序、窗口竞争和共享状态读写交错,很多问题只有在特定线程切换顺序下才会暴露,所以它们天然就带有偶发性。
2. 用了并发容器,是不是整个逻辑就线程安全了?
不是。并发容器只能保证容器本身的并发语义,不能自动保证你围绕它写出的复合操作、检查后再执行逻辑和跨步骤状态转换也天然安全。
3. Spring 单例 Bean 为什么这么容易引出线程安全问题?
因为它天然就是全局共享实例。一旦把请求级、会话级或处理中间状态塞进成员变量,多个线程就会共享并竞争这份可变状态。
4. 什么时候不该继续停在本文,而要切到更具体专题?
当你已经确定问题落在锁实现取舍、ThreadLocal 清理边界,或者现场已经开始表现成线程池拥塞、接口变慢这类运行态症状时,就别再只停在线程安全总览里了,直接去看对应那条线会更省时间。
读完后怎么接着看
把共享可变状态这层看清后,后面通常会分成几种更具体的现场:
- 已经在锁实现里犹豫:去看
synchronized 和 ReentrantLock 有什么区别?项目里该怎么选。 - 更像 ThreadLocal 串用、线程复用和清理边界问题:去看
ThreadLocal 常见坑有哪些?项目里最容易踩的几个问题。 - 已经拖成线程池拥塞或 worker 变慢:去看
线程池打满以后,应该先查队列、拒绝策略还是慢任务?。 - 线上先暴露成接口慢或 CPU 抖动:去看
接口响应慢怎么排查?后端性能问题定位步骤和Java CPU 飙高怎么排查:一套线上定位顺序。
如果你想顺着线程安全这条主线继续补,我通常会这样接:
- 用这篇把 共享可变状态 -> 复合操作 -> 可见性 -> 保护手段 这条线过一遍。
- 已经开始落到锁实现,就看 synchronized 和 ReentrantLock 有什么区别?项目里该怎么选。
- 如果现场更像 ThreadLocal 串用、线程复用带来的上下文污染,就看 ThreadLocal 常见坑有哪些?项目里最容易踩的几个问题。
- 如果已经拖成线程池拥塞,再看 线程池打满以后,应该先查队列、拒绝策略还是慢任务?。
- 如果问题最后是以接口慢、CPU 抖动或事务边界异常的样子冒出来,再看 接口响应慢怎么排查?后端性能问题定位步骤、Java CPU 飙高怎么排查:一套线上定位顺序 和 Spring 事务为什么会失效?常见场景汇总。