Java

Java 项目里最常见的线程安全问题有哪些

从共享变量、集合并发、单例状态到锁粒度与可见性,系统梳理 Java 项目里最常见的线程安全问题、典型场景和排查思路。

  • Java
  • 并发
  • 线程安全
  • 后端开发
13 分钟阅读

线程安全这个词,很多 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 做读写,就可能出现问题。

为什么

普通集合类例如:

  • HashMap
  • ArrayList
  • HashSet

默认都不是线程安全的。

在并发场景下,常见风险包括:

  • 数据覆盖
  • 读取到中间状态
  • 并发修改异常
  • 结构状态异常

项目里最容易出现场景的地方

  • 本地缓存
  • 规则配置表
  • 临时结果汇总
  • 批处理中的共享容器

更稳妥的做法

  • 明确加锁保护
  • 使用并发容器,例如 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 清理边界,或者现场已经开始表现成线程池拥塞、接口变慢这类运行态症状时,就别再只停在线程安全总览里了,直接去看对应那条线会更省时间。

读完后怎么接着看

把共享可变状态这层看清后,后面通常会分成几种更具体的现场:

如果你想顺着线程安全这条主线继续补,我通常会这样接:

  1. 用这篇把 共享可变状态 -> 复合操作 -> 可见性 -> 保护手段 这条线过一遍。
  2. 已经开始落到锁实现,就看 synchronized 和 ReentrantLock 有什么区别?项目里该怎么选
  3. 如果现场更像 ThreadLocal 串用、线程复用带来的上下文污染,就看 ThreadLocal 常见坑有哪些?项目里最容易踩的几个问题
  4. 如果已经拖成线程池拥塞,再看 线程池打满以后,应该先查队列、拒绝策略还是慢任务?
  5. 如果问题最后是以接口慢、CPU 抖动或事务边界异常的样子冒出来,再看 接口响应慢怎么排查?后端性能问题定位步骤Java CPU 飙高怎么排查:一套线上定位顺序Spring 事务为什么会失效?常见场景汇总