作为后端开发,你大概率在面试中被问过“线程安全是什么”,也大概率在线上遇到过诡异的数据错乱问题。搞懂线程安全,不只是为了应付面试,而是每个写并发代码的人必须迈过的一道坎。
这篇内容我会从线程安全问题的底层原因讲起,再把解决方案按不同场景拆开揉碎,最后附带我自己实际排查问题时的经验。无论你是刚接触并发编程的新手,还是写过几年业务代码但没系统梳理过这块的老手,这篇文章都能给你一个相对完整的视角。内容比较长,建议收藏后慢慢看。
1. 线程安全的本质:为什么并发下代码会“乱套”
先说结论:线程安全问题,本质上是多个线程同时访问共享可变资源时,对资源状态的修改产生不可预期的结果。要理解这句话,需要拆开看“同时”“共享”“可变”这三个关键词。
1.1 硬件层面的根源:CPU缓存与内存不一致
很多人以为多线程同时执行是“真正同时操作同一个变量”,但现代CPU架构早就不是这个样子了。每个CPU核心都有自己的高速缓存(L1、L2),多核CPU之间共享主内存。一个线程在核心A上修改变量,先把值写入核心A的缓存,再通过缓存一致性协议同步到主内存,最后才可能被核心B看到。这个同步过程存在时间差,所以核心B读到的可能是旧值。
这就引出了并发编程里第一个重要概念:可见性(Visibility)。一个线程对共享变量的修改,另一个线程不一定能立即看到。比如下面这段经典代码:
java复制public class VisibilityTest {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
new Thread(() -> {
while (flag) {
// 空转
}
System.out.println("thread stopped");
}).start();
Thread.sleep(1000);
flag = false; // 主线程修改flag
System.out.println("main set flag=false");
}
}
我实际跑过这段代码,在不加任何同步措施的情况下,子线程很可能永远循环下去。原因很简单:子线程在CPU缓存里读到的flag一直是true,主线程对flag的修改没有及时刷新到子线程的缓存中。
1.2 执行层面的根源:指令重排序
除了可见性,还有一个更隐蔽的问题叫有序性(Ordering)。CPU和编译器为了提升性能,会对指令进行重排序。在单线程环境下,重排序不影响最终结果;但在多线程环境下,重排序可能导致另一个线程观察到与代码顺序不一致的执行顺序。
经典的例子是双重检查锁(Double-Checked Locking,DCL)实现单例。早期JDK里,如果instance字段没有用volatile修饰,就可能因为指令重排序导致线程拿到未初始化完成的对象。new对象的操作可以拆成三步:分配内存、初始化对象、把引用赋值给变量。如果第2步和第3步被重排了,另一个线程在判断instance != null时,拿到的可能是尚未初始化完毕的“半成品”对象。
1.3 并发编程的“三性”与线程安全的关系
业内通常把并发编程需要解决的三个核心问题归纳为:可见性、有序性和原子性。原子性(Atomicity)指一个操作或一组操作要么全部执行成功,要么全部不执行,不可被中断。比如count++这行代码,在字节码层面是“读取-计算-写入”三步,不是原子操作,在多线程下就会丢失更新。
这三个特性解决好了,线程安全问题才算是真正解决。后面讲到的所有解决方案,本质上都是在围绕这三个特性做文章。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从具体场景看线程安全:哪些代码会踩雷
理解了原理后,我们看几个实际的代码场景。很多同学背了“HashMap不安全、StringBuilder不安全”这类结论,但不知道为什么不安全,也不清楚具体在什么条件下不安全。这里还原几个我实际遇到过或复现过的场景。
2.1 计数器类场景:i++ 不是原子操作
这是最经典、也最容易复现的例子。假如有10个线程,每个线程对共享变量执行10000次自增,最后结果应该是什么?如果线程安全,应该是100000。但实际运行很多次,结果都小于100000。
java复制public class AtomicityTest {
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
int threadCount = 10;
int loopCount = 10000;
Thread[] threads = new Thread[threadCount];
for (int i = 0; i < threadCount; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < loopCount; j++) {
count++;
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("final count = " + count);
}
}
原因在于count++包含三步:读count的值、把值加1、写回count。线程A读到了100,还没写回101之前,线程B也读到了100,接着两个线程都写回101。一次更新就被覆盖掉了。类似的情况还有count += n、count--等复合操作。
2.2 集合类场景:HashMap 的致命扩容链
HashMap线程不安全是共识,但很多人只知道“不安全”,却说不清具体现象。HashMap在并发put时,如果触发扩容,多个线程同时rehash,可能导致链表形成环。一旦有环,下次get这个key时就会陷入死循环,CPU飙到100%。
我在JDK 7的环境下复现过这个问题,当时用的代码逻辑大致是:多个线程同时往一个HashMap里put数据,触发扩容后,有一个线程的get操作永远卡住了。JDK 8改进了扩容算法,用高低位链表优化了rehash,死循环问题基本解决,但数据丢失、size不准确等问题在JDK 8及以后版本依然存在。
ArrayList也有类似问题。多个线程同时add,可能触发数组越界异常,也可能出现元素被覆盖的情况。因为ArrayList的add操作包含“检查容量-扩容-赋值”多个步骤,不是原子的。
2.3 时间格式化类场景:SimpleDateFormat 的隐性问题
很多人习惯把SimpleDateFormat定义成静态成员变量复用,这是非常危险的做法。SimpleDateFormat是线程不安全的,它的内部使用了Calendar对象,而Calendar对象在parse和format过程中会被修改。
我见过一个线上事故,多个线程调用同一个SimpleDateFormat解析不同格式的日期字符串,结果偶尔解析出完全错误的时间,甚至抛出NumberFormatException。排查了很久才发现,问题不在业务逻辑,而是这个日期工具类一直是单例复用的。后来的解决方案很简单,每个线程都用局部变量创建SimpleDateFormat,或者改用DateTimeFormatter(它是线程安全的)。
3. 解决方案全解析:从 synchronized 到锁优化再到无锁方案
知道问题在哪里之后,解决方案才是重头戏。很多初学者一说线程安全就想到synchronized,但实际工程里,方案选择要根据场景来。这里我把常见方案按“从重到轻、从有锁到无锁”的层次梳理一遍。
3.1 synchronized:最基础也最可靠的互斥手段
synchronized是JVM层面的内置锁,可以修饰方法,也可以修饰代码块。它的核心作用就是保证同一时刻只有一个线程能执行被锁定的代码段,同时利用JMM的Happens-Before规则保证释放锁之前的修改对后续获取锁的线程可见。
java复制public class SynchronizedTest {
private static int count = 0;
private static final Object LOCK = new Object();
public static void increment() {
synchronized (LOCK) {
count++;
}
}
}
Java 6之后,synchronized经过了大量优化,引入了偏向锁、轻量级锁、重量级锁的升级路径。也就是说,在竞争不激烈的情况下,synchronized的开销并不是想象中那么大。很多场景下,它反而比手动使用Lock更简单、更不易出错,毕竟锁的释放是JVM自动完成的,不用担心忘记unlock导致死锁。
一个容易忽略的点是:synchronized锁的是对象,不是代码。所以多个线程必须锁同一个对象,才能实现互斥。如果A线程锁的是this,B线程锁的是另一个对象的this,那两边根本锁不到一块去。
3.2 volatile:轻量级可见性保障,但不解决原子性
volatile解决的是可见性和有序性问题。它有两个作用:一是对被修饰变量,每次读写都直接操作主内存,绕开CPU缓存;二是禁止指令重排序,通过插入内存屏障实现。
但volatile不保证原子性。也就是说,volatile int count; count++;在并发下依然会丢数据。volatile适合什么场景呢?适合一个线程写、多个线程读的“发布”场景,比如上面提到的flag标志位,写一个状态开关让其他线程感知。也适合构造不可变对象的安全发布,比如在DCL单例中用volatile修饰instance字段。
我见过很多初学者把volatile当成万能锁,用在计数、累加等需要原子性的场景上,结果问题依旧。这就是对volatile能力的边界认识不清。
3.3 Lock体系:更灵活的显式锁
从JDK 5开始,java.util.concurrent.locks包提供了更丰富的锁实现。Lock接口的代表是ReentrantLock,它和synchronized相比有几个优势:
- 支持公平锁与非公平锁
- 支持尝试获取锁(tryLock),可以带超时时间
- 支持多个条件队列(Condition),更精细地控制线程唤醒
- 支持中断响应
java复制ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// 业务逻辑
} finally {
lock.unlock();
}
使用Lock最核心的注意事项就是:必须在finally里unlock。否则一旦业务代码抛出异常,锁永远不会释放,其他线程全部卡死。这是显式锁和内置锁最大的使用差异,也是新手最容易犯的错误。
3.4 读写锁与StampedLock:读多写少场景的优化
很多业务场景是读多写少,比如缓存配置、商品信息。如果直接用ReentrantLock,读和读之间也要互斥,这对性能是一种浪费。ReadWriteLock(读写锁)把锁拆成读锁和写锁:读锁是共享的,多个线程可以同时持有;写锁是独占的。
JDK 8还引入了一个更激进的StampedLock,它支持乐观读(optimistic read)。乐观读不真正加锁,而是先读,读完后检查是否有写操作发生过,如果没有则读有效,如果有则升级为悲观读。这在读多写极少的情况下能显著降低锁竞争开销。
不过StampedLock有个明显的坑:它不支持重入,也不支持条件变量。如果代码里需要重入或者等待条件,就不要用它。
3.5 CAS与原子类:无锁并发的高性能方案
锁的代价在于线程阻塞、上下文切换。而无锁方案的核心是CAS(Compare And Swap),即“比较并交换”。CAS有三个操作数:内存位置V、预期原值A、新值B。只有当V的值等于A时,才把V的值更新为B,否则什么都不做。
java.util.concurrent.atomic包下的AtomicInteger、AtomicLong、AtomicReference等类就是基于CAS实现的。以AtomicInteger为例,它的incrementAndGet方法内部就是死循环CAS,直到成功为止。这种方案避免了线程切换的开销,在高并发、竞争时间短的场景下性能优于锁。
java复制AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet();
不过CAS也有经典问题——ABA问题。简单说就是变量从A变成B又变回A,CAS检查时发现值还是A,就认为“没变过”,然后执行更新。这在某些场景会造成错误。解决方式是使用AtomicStampedReference或AtomicMarkableReference,为变量加上版本号。
3.6 ThreadLocal:干脆每个线程一份数据
前面的方案都是控制线程之间的访问,而ThreadLocal的思路更彻底:让每个线程都有自己的变量副本,线程之间天然隔离,也就不存在竞争了。
ThreadLocal的典型使用场景是传递上下文信息,比如把用户信息、TraceId放在ThreadLocal里,方便链路追踪。另一个场景是让非线程安全的工具类变成“线程隔离”的,比如SimpleDateFormat。
但ThreadLocal有几个必须注意的问题。第一是内存泄漏:ThreadLocalMap中key是弱引用,value是强引用。如果ThreadLocal对象被回收,而value还挂着,就形成了key为null但value不为null的Entry,无法被访问也无法被清理。如果在高并发场景下反复创建线程或者使用线程池,很容易造成内存飙升。解决方案是使用完及时调用remove()。第二个问题是线程池结合ThreadLocal时,要特别注意线程复用导致的数据串线,也就是线程从池中取出时,上一次执行的ThreadLocal数据还在。
3.7 不可变对象:终极方案是根本不用改
最后一个方案在思想上最简单,却也最容易被忽略:如果对象创建后状态不可变,那就不存在并发修改的问题。String、Integer、Long、BigDecimal这些类都是不可变类,它们天然线程安全。
实际开发中,如果某些字段不需要变更,就尽量用final修饰。对于集合类,可以用Collections.unmodifiableList()等工具方法包装,或者使用Java 9引入的List.of()、Map.of()创建不可变集合。这样不仅线程安全,还能让代码意图更清晰。
我在代码评审中经常看到有人为不需要修改的List加锁,这完全是没必要的。改成不可变集合或者干脆每次创建新对象,逻辑简洁还省心。
4. 实战案例:从零实现一个线程安全的“库存扣减”
理论聊了不少,但真正有价值的还是操作。这里我以一个电商常见的库存扣减场景为例,拆解如何一步步把线程安全问题解决到位。
4.1 场景定义与需求分析
假设有一个商品库存表,库存字段stock,初始100件。用户下单时执行扣减,规则是:库存必须大于0才能扣,扣完即止。这个场景看起来极其简单,但并发下单时如果处理不当,超卖几乎必然发生。
先看一个反面写法。如果直接操作数据库:
sql复制UPDATE product SET stock = stock - 1 WHERE id = 123;
在MySQL默认的RR隔离级别下,这条SQL自身是原子的,两个并发事务同时执行时,行锁会保证只有一个事务先执行。所以单条UPDATE在数据库层面是安全的。问题往往出在业务代码上。
常见错误写法是:先SELECT查库存,在Java代码里判断库存是否大于0,再执行UPDATE。这两个步骤不是原子的,并发情况下两个线程都可能SELECT到同一个旧库存值,都判断“库存足够”,都执行UPDATE,结果超卖。
再常见错误写法是在应用内用同步锁,但忘记了分布式场景下锁只对单进程内线程有效。多实例部署时,每个实例的锁是独立的,依然会超卖。
4.2 方案一:基于数据库行锁与条件更新的可靠方案
最简单的可靠方案是让扣减在一条SQL里完成,用条件语句保证“库存必须充足才扣减”:
sql复制UPDATE product
SET stock = stock - 1
WHERE id = 123 AND stock > 0;
执行结果返回影响行数,如果影响行数为0,说明库存不足,下单失败。这个方案在单库场景下完全可行,代码也很简单。它的核心在于:把“判断库存和扣减库存”合并成了一个原子的数据库操作,由数据库的行锁保证并发安全。
需要注意的是,如果业务对库存扣减有更复杂的规则,比如阶梯库存、多仓库库存,单纯一条UPDATE就不够用了,需要事务配合锁。此时可以用SELECT ... FOR UPDATE锁定行,但这会带来锁等待问题,需要控制事务时间,避免长事务。
4.3 方案二:基于Redis做库存扣减
电商高并发场景下,数据库扛不住那么大的瞬时流量,常见的做法是先用Redis预扣库存。Redis是单线程模型,命令是原子的,天然适合做计数扣减。
java复制// 扣减库存,返回剩余库存
Long remaining = redisTemplate.opsForValue().decrement("product:stock:123");
if (remaining < 0) {
// 恢复库存,标记失败
redisTemplate.opsForValue().increment("product:stock:123");
return "库存不足";
}
这里用decrement命令做扣减,利用其原子性避免超卖。但要注意,Redis的库存扣减和数据库的库存最终扣减需要保持一致,否则可能出现Redis显示有货、数据库下单失败等情况。通常的架构是:Redis预扣 -> 异步写订单 -> 回调更新数据库库存,并定期对账。
Redis方案的坑也不少。比如decrement返回成负数后,需要立刻increment回去,但万一increment时Redis宕机,库存就真变负了。更稳妥的办法是用Lua脚本,把扣减、判断、回滚写在一个脚本里,保证原子性。这属于分布式系统里面的一致性设计范畴,展开讲又是一篇长文,这里先点到为止。
4.4 方案三:本地锁 + 版本号/乐观锁
如果不希望引入Redis,又想在高并发下控制超卖,可以考虑在数据库层面使用乐观锁。做法是给表增加version字段,更新时带上版本号条件:
sql复制UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 123 AND stock > 0 AND version = #{oldVersion};
如果影响行数为0,说明版本不匹配或库存不足,此时重试或失败。这个方案的优点是没加悲观锁,并发压力下更轻量;缺点是重试逻辑需要自己控制,而且在高竞争下,重试率会很高,反而拖垮数据库。
适合什么场景?用户冲突概率较低的场景,比如个人博客点赞、文章收藏数更新,这类操作冲突频率低,用乐观锁足够了。
5. 线上问题排查:从定位到修复的完整思路
讲了这么多原理和方案,最后分享一些实际排查线程安全问题的经验。理论学再多,遇到线上诡异问题还是容易摸不着头脑。这里整理一套我一直在用的排查路径,以及几个高频问题的速查表。
5.1 第一步:判断是不是线程问题
线上出现了数据错乱、值不符合预期等问题,先不要急着看业务逻辑,先判断是不是并发问题。关键看三要素:是否有多个线程访问同一份数据?这份数据是不是可变的?有没有加同步措施?
常用的排查手段有以下几种。观察异常特征:如果问题偶尔出现、并发量大时频发、每次出错的数据值不一样,大概率是竞态条件。看线程栈:jstack命令抓取线程快照,如果发现大量WAITING状态线程,多半和锁有关系。复现规律:尝试用小规模并发脚本复现,看能否稳定复现出同样的问题。
5.2 第二步:用日志与监控定位关键代码
确认并线程嫌疑后,要在关键代码处打日志,重点记录线程名称、变量值、时间戳。等并发问题再次出现时,比对日志时间戳上有没有间隔极短的并发访问。比如两个线程都打印了“读取库存=50”且时间戳相差不到1毫秒,那基本可以认定是读取和写入的竞态问题。
此外,可以用监控工具观察系统指标。如果CPU飙高,大概率是死循环或频繁GC;如果线程数飙升,可能是锁竞争导致大量线程阻塞。
5.3 第三步:修复并验证
定位到问题代码后,按场景选择方案。我之前遇到一个实际案例,某模块用静态变量存储临时数据,每逢流量高峰就会出现脏数据。当时查日志发现两个线程同时写同一个key,数据互相覆盖。修复方案很简单:改用ThreadLocal存储线程私有数据,或者把静态变量换成按key加锁。实测修改后问题消失,性能没有明显下降。
修复完成后,一定要做并发回归测试。可以用CountDownLatch同时放行多个线程,模拟并发场景,反复跑多轮,确认无异常后再上线。
5.4 常见问题速查表
| 问题表现 | 常见原因 | 排查工具/方法 | 推荐方案 |
|---|---|---|---|
| 计数结果偏小 | i++非原子操作 | 多线程压测复现 | AtomicInteger或synchronized |
| 数据被覆盖 | 复合操作非原子 | 日志比对时间戳 | 锁或CAS |
| 死循环、CPU飙高 | HashMap并发扩容 | jstack查看线程栈 | ConcurrentHashMap |
| 线程阻塞不释放 | 忘记unlock | jstack看锁等待 | try-finally中unlock |
| 解析日期偶尔错乱 | SimpleDateFormat非线程安全 | 并发复现 | DateTimeFormatter或ThreadLocal |
| 脏数据、串数据 | ThreadLocal未清理 | 日志追踪上下文 | 使用后remove() |
| 库存超卖 | 检查与更新分离 | 并发下单测试 | 条件UPDATE或Redis原子命令 |
5.5 避开这些操作陷阱
先避开性能陷阱。锁粒度要尽量小,锁住的是临界区代码,不是整个方法。我曾经见过一个把synchronized加在整个大方法上的代码,方法内部有耗时很长的RPC调用,所有请求排队等锁,吞吐量直接腰斩。正确的做法是只锁住需要保护的那几行代码。
再避开锁对象陷阱。字符串常量作为锁对象要特别小心,因为内容相同的字符串指向同一个常量池对象,可能发生不可控的锁竞争。用private static final Object LOCK = new Object()这种方式更安全。
最后避开锁顺序陷阱。多个线程需要持有多把锁时,如果加锁顺序不一致,就可能形成死锁。死锁一旦发生,jstack会明确提示Found one Java-level deadlock。解决方法是所有线程按全局统一顺序加锁,或者使用tryLock设置超时时间。
6. 从线程安全到更广阔的并发设计
写到这里,线程安全的原因和解决方案已经梳理完一遍。就我个人经验而言,线程安全不是一个可以单纯靠API堆砌解决的问题,它更考验对并发场景的理解。同一个问题,在单机场景和分布式场景下解法完全不同;同一个方案,在读多写少和写多读少场景下效果天差地别。
我在实际项目中,最常用到的组合是这样的:状态开关和标记位用volatile;计数统计用AtomicLong;需要互斥更新的复合操作优先考虑synchronized,毕竟它简单可靠不易出错;需要公平锁、超时控制或者多个条件变量,才考虑ReentrantLock;读多写少的缓存场景用读写锁或者直接上ConcurrentHashMap;线程私有上下文一律用ThreadLocal但必须记得清理;数据库层面的并发更新,优先用条件UPDATE或乐观锁。
还有一个容易被忽视的认知是:线程安全的终点不是“加锁”,而是“减少共享”。能把共享可变数据拆掉,让每个线程操作自己的副本,或者设计成不可变对象,往往比任何锁都高效。很多高性能框架的并发设计,核心思想就是尽量消除共享,这比在共享上叠加各种同步机制要高明得多。
最后分享一个我踩过多次坑的体会:写并发代码时,不要基于“直觉上应该没问题”来推理,而是要用“最坏情况”来推导。假设两个线程同时走到你最不在意的判断分支,会发生什么?假设指令被重排序了,结果会怎样?多问自己几个这样的问题,很多隐患在代码评审阶段就能暴露出来,而不是等到线上数据错乱再去救火。
线程安全这个命题很大,但它的边界很清晰。只要你把可见性、原子性、有序性拿捏住,再按场景匹配方案,大部分并发问题都能迎刃而解。如果这篇文章对你有帮助,不妨在实际项目里找一段并发代码练练手,把理论落到工程里,才算真正掌握。
