1. 先搞清楚基本定义:线程安全到底在说什么
1.1 从一个 BUG 开始:两个线程抢同一个变量
先别急着抠术语,咱们从一段最普通的代码聊起。假设你写了一个计数器,用来统计某段时间内的请求数量:
public class Counter { private int count = 0; public void increment() { count++; } public int getCount() { return count; } }单线程下这段代码没什么可说的,跑一万次结果就是一万。但如果一个 Web 服务同时有一千个请求进来,每个请求各自线程里调用increment(),你会惊讶地发现最终getCount()的结果不是一个固定值,有时候是 997,有时候是 998,有时候又是 1000。这中间少的那些数字,就是被并发搞丢的。
很多人第一反应是“count++ 这么简单的操作还能出错?” 实际上恰恰相反,count++在 Java 里并不是一个原子操作。它背后至少包含了三步:读取当前 count 的值,把这个值加一,再把新值写回内存。既然它拆成了多步,两个线程就可能同时读到同一个旧值,然后各自加一、各自写回,结果只增加了 1 而不是 2。这就像两个人在同一张纸上签字,都以为自己是第一个签的,等纸传回老板手里,上面只有一个签名。
这个例子就是线程不安全的典型形态:多个线程共享同一个可变变量,又没有任何约束,最终结果依赖线程执行的先后顺序。运气好结果对,运气不好数据就错了。
1.2 线程安全的本质,是给并发执行“立规矩”
那么到底什么是线程安全?用一句话概括:当多个线程同时访问某个类、对象或者方法时,无论这些线程实际是怎么被调度的,最终都能得到正确的结果,调用方不需要额外加任何同步措施,这个类就可以被认为是线程安全的。
注意这里面有两个关键词。
第一个是“共享可变状态”。如果每个线程都只用自己内部的局部变量,那天然是线程安全的,因为变量之间井水不犯河水。但如果一份数据被多个线程同时读、同时写,而且这份数据的状态还在不断变化,那么竞争就出现了。我们说一个类的线程安全问题,本质上都是针对这种共享且可变的内部状态而言的。
第二个是“正确性”。什么叫正确?最朴素的理解就是:不管你把线程怎样交错执行,最后得到的状态都不会违背程序的逻辑,不会有脏读、丢失更新、死锁这类乱七八糟的结果。换句话说,线程安全不是为了避免并发,而是为了保证哪怕并发发生,程序依然表现得像是有秩序地在执行。
不少初学者会把线程安全和加锁划等号,觉得只要看到 synchronized 就是线程安全,没加锁就不安全。实际上线程安全是一个更宽泛的设计目标,加锁只是实现这个目标的手段之一。你完全可以用不可变对象、原子类、并发容器、ThreadLocal 这些方式来达到同样的效果,核心思路都是想办法消除“多线程同时操作共享可变数据”带来的不确定性。
1.3 线程不安全会带来哪些具体后果
聊完了定义,再盘点一下线程不安全在实际项目里的表现,这样你对问题严重性能有更直观的感知。
首先最常见的是丢失更新。前面那个计数器例子就是最好说明,多个线程基于同一个旧值做计算,最后写回时覆盖了彼此的成果。这个现象在做库存扣减、余额变更、订单号生成时尤其致命。库存明明只剩 10 件,十个线程同时判断“剩余库存大于 0”,然后一起扣减成功,结果订单下了 10 个,系统里库存变成了 0,但实际上拍下 20 件商品。
其次是脏读。当一个线程正在修改某个对象状态,修改过程还没完成,另一个线程就插进来读取了状态。典型的例子就是转账场景,账户 A 向账户 B 转账,需要先把 A 的余额扣掉,再给 B 加钱。如果第一步做完、第二步还没做的时候,别的线程来查 B 的余额,就会看到 B 的钱还没到账;或者反过来,看到 A 的钱已经扣了但 B 没加。这些都是中间状态被泄露了出去。
再一个是死锁和活锁。线程 A 持有了锁 1 等待锁 2,线程 B 持有了锁 2 等待锁 1,两边谁也不让谁,程序卡死。这种问题比数据错乱更隐蔽,因为不是每次都会出现,往往要在特定调度时机才会触发。一旦上了生产环境才暴露,排查起来相当折腾。
最后还有一类常常被忽略的问题:竞态条件下抛出的“偶发异常”。比如迭代一个 ArrayList,另一个线程同时在往里 add 元素,就会触发ConcurrentModificationException。这种异常最大特点就是“时有时无”,测试环境不出现,线上偶尔出现,定位时总让人怀疑人生。
2. 线程安全背后的原理:三根支柱必须搞懂
2.1 原子性:操作要么全部完成,要么完全不执行
想真正理解线程安全,光会写锁是不够的,你得知道问题到底出在哪里。线程安全问题可以从三个维度去拆解:原子性、可见性、顺序性。这三个词记熟了,后面分析很多并发问题都会顺很多。
先讲原子性。原子性的意思很简单:一组操作,要么一次性全部执行完,要么一个都不执行,中间不能被任何线程打断。就像你去餐厅点一杯奶茶,从下单到拿到奶茶,服务员不会中途跑过去接待另一个客人,然后又回来继续给你做,否则你永远不知道自己的奶茶做到哪一步了。
在 Java 里,基本类型变量之间的赋值操作在很多情况下是原子的,比如说int x = 1这种写入操作。但业务逻辑往往是复合的,前面count++那种“读-改-写”本身就是三步,任何一步被其他线程打断,结果就可能不对。再比如“先检查后执行”的模式,典型的就是if (map.containsKey(key)) { map.put(...); },同样是一个窗口期很大的复合操作。
要保证原子性,常用的办法就是加锁或者使用原子类。加锁的本质是让临界区变成“单行道”,同一时间只允许一个线程进去,这样里面的操作自然不会被穿插。原子类则是利用 CPU 层面的 CAS 指令,把“比较再交换”做成了一个硬件级别的基本操作,效率更高,后面会专门说。
2.2 可见性:一个线程改了,另一个线程不一定看得见
第二个维度是可见性。这个就更反直觉了——线程 A 明明已经把变量的值改了,线程 B 读的时候却还是旧值。为什么?这就得说到 Java 内存模型。
现代 CPU 处理数据时不会每次都直接操作主内存,而是先把数据复制到自己的高速缓存里,算完了再同步回去。Java 虚拟机为了跨平台支持这种能力,也定义了一套抽象的内存模型:每个线程都有自己的工作内存,里面保存了主内存变量的副本。线程对变量的操作,实际上都是先在自己的工作内存里做,然后在某个时间点再同步回主内存。
这就导致了一个问题:线程 A 在自己的工作内存里把running改成了false,但如果这个改动还没来得及同步到主内存,线程 B 在自己工作内存里看到的running仍然是true,它就会继续顺着旧逻辑往下跑。用下面这段代码演示最直观:
public class VisibilityDemo { private static boolean running = true; public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { while (running) { // 空转,依赖 running 状态退出 } System.out.println("worker 线程退出了"); }); worker.start(); Thread.sleep(1000); running = false; System.out.println("main 线程已把 running 置为 false"); } }没有加 volatile 的时候,这个程序有可能一直运行下去,worker 线程永远看不到 main 线程对running的修改。这就是可见性问题。解决可见性的核心思想,就是让线程之间能够及时看到彼此的改动。synchronized和volatile都能做到这一点,区别在于 volatile 更轻量,它强迫线程每次使用变量时都从主内存读取最新值。
2.3 顺序性:指令重排带来的隐藏炸弹
第三个维度是顺序性,也是很多老手容易栽跟头的地方。现代编译器和 CPU 为了提高执行效率,会在不改变单线程语义的前提下,对指令的执行顺序做调整。这在单线程下没任何问题,因为结果不受影响。但在多线程下,指令重排可能让原本写在后面的操作先执行,从而产生出乎意料的交叉行为。
最经典的例子就是单例模式里的双重检查锁。早期很多人这样写:
public class Singleton { private static Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }看起来逻辑没问题:先判断实例是否为空,为空再进入同步块,进入之后又判断一次,防止多个线程同时创建。但这里有个很隐蔽的坑:instance = new Singleton()这行代码,在字节码层面其实包含了好几步。先要分配一块内存,然后调用构造器完成对象初始化,最后把这块内存地址赋值给instance引用。如果编译器和 CPU 把“赋值”这一步提前到了“构造器初始化”之前,那么另一个线程就可能拿到一个还没有完成初始化的半成品对象,然后直接用它,结果可想而知。
解决办法很简单,给 instance 加上volatile关键字,禁止指令重排。这也解释了为什么业界标准写法要求这个字段必须是 volatile。
3. 让代码变得线程安全的实战招数
3.1 synchronized:直接、易用,但要用对粒度
理论说了这么多,实际操作才是我们真正关心的。Java 里最简单、最古老的同步方式就是synchronized,它可以修饰方法,也可以修饰代码块。
修饰实例方法的时候,锁的是当前对象:
public synchronized void increment() { count++; }修饰静态方法的时候,锁的是当前类的 Class 对象:
public static synchronized void doSomething() { // 锁的是类对象 }修饰代码块的时候,你可以自己指定锁住哪个对象:
public void increment() { synchronized (this) { count++; } }我这里要说几个比较实际的经验。
第一,锁的粒度要尽可能小。如果你把一整个方法都加上 synchronized,相当于把方法里所有逻辑都放进了单行通道,性能会明显下降。更好的做法是只把真正操作共享变量的代码块圈起来,其他不涉及共享状态的计算放在外面。第二,要确保所有访问同一个共享变量的代码路径,都使用同一把锁。比如一个类里有多个方法都会修改某个 list,如果其中一个方法锁了 this,另一个方法锁了其他对象,那么这两个线程之间还是没有互斥效果,问题照样存在。第三,不要用字符串常量作为锁对象,字符串常量池会让不同业务逻辑之间产生意外的锁共享,调试起来非常痛苦。
3.2 volatile:解决可见性和顺序性,但不解决原子性
volatile是个很容易被误解的关键字。很多人以为加了 volatile 就等于线程安全了,其实不是。它只保证了可见性和禁止指令重排,并不保证复合操作的原子性。
回到前面那个count++的问题,如果只把 count 声明成private volatile int count = 0;,然后多个线程同时执行count++,最终结果依然可能是错的,因为“读-改-写”的整个过程依然可以被其他线程插入,volatile 管不到这一步。它只能保证:你每次读 count 都读的是主内存里的最新值,你每次写 count 也会立刻同步回主内存。
所以 volatile 最适合的场景是什么?是一个线程修改状态,其他线程只负责读取这个状态,不参与修改。比如我用一个volatile boolean running作为线程停止标记,main 线程设置 false,worker 线程读取并退出,这就是非常经典的用法。另外,如果你的状态变量依赖它之前的值,或者这个变量会连带触发其他状态变化,那就不适合用 volatile。
3.3 原子类和显式锁:处理复合操作的更优解
对于像“计数、累加、增减”这类高频的复合操作,用 synchronized 虽然能保证安全,但线程争抢锁的时候会有上下文切换成本。Java 在java.util.concurrent.atomic包下提供了一系列原子类,底层基于 CAS 实现,性能一般比锁更好。
比如计数器可以改成:
public class Counter { private AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } }AtomicInteger 的incrementAndGet底层是一个 CAS 循环:先读取当前值,计算新值,然后通过 CAS 把新值写回去。如果写的时候发现值已经被别的线程改了,就重新读取、重新计算,再来一次。所以它既能保证原子性,又避免了锁的粗粒度开销。
除了原子类,显示锁ReentrantLock也是需要掌握的。相比于synchronized,它有几个明显的优势:可以支持公平锁,可以尝试获取锁而不死等,还可以使用lock.lockInterruptibly()支持中断响应。当然功能更强也意味着代码更复杂,你用的时候要记得在 finally 里释放锁。
private final ReentrantLock lock = new ReentrantLock(); public void update() { lock.lock(); try { // 操作共享资源 } finally { lock.unlock(); } }我个人的习惯是:简单互斥场景用 synchronized,追求超时等待、可中断或更精细控制时再用 ReentrantLock。
3.4 不可变对象和 ThreadLocal:从源头消灭并发问题
有一种非常妙的思路:既然并发问题来自多个线程修改共享状态,那我干脆让这个状态不可修改,或者让每个线程都有一份自己的状态。
不可变对象就是这样的思路。如果一个对象创建之后,它的任何字段都不能再被修改,那么所有线程看到的一定是同一个稳定状态,天然线程安全。Java 里最典型的就是 String,还有各种包装类。实际开发中,你可以用 final 修饰字段,不让外部修改;要更新就创建一个新对象,而不是在原有对象上改。这种模式在函数式编程风格里特别常见。
ThreadLocal 则是另一个思路:每个线程都能拿到一个属于自己的变量副本。它的实现原理类似于每个线程内部维护了一个 map,key 就是当前 ThreadLocal 对象,value 就是你塞进变量的值。所以不同的线程访问同一个 ThreadLocal,拿到的其实是各自独立的值,互不干扰。典型的使用场景有 SimpleDateFormat,这个类不是线程安全的,每次使用都 new 一个又太浪费,用 ThreadLocal 让每个线程拥有一个自己的 SimpleDateFormat 实例,既安全又高效。
private static final ThreadLocal<SimpleDateFormat> dateFormat = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));这里有个很重要的坑:在线程池场景下,线程是复用的,ThreadLocal 里的值不会自动清理。如果你在业务处理过程中往 ThreadLocal 塞了用户信息,处理完之后不主动 remove,那么下一次这个线程处理其他请求时,还能读到上一次遗留的旧数据。这个问题在线上很容易造成越权或者数据串号,我自己就踩过,处理完业务之后一定记得在 finally 里调用remove()。
3.5 并发容器:比手动加锁更省心
说实话,绝大多数业务场景里,你不需要自己去造并发数据结构,JDK 已经提供了一整套并发容器,直接用它们比自己加锁更安全、更高效。
最典型的是ConcurrentHashMap。它是线程安全的 Map,而且读操作大多不加锁,写操作采用分段锁或者 CAS 等机制,性能远好于给整个 HashMap 加上 synchronized。如果你要在多线程环境里维护一个键值映射,优先考虑它。CopyOnWriteArrayList适合读多写少的场景,每次写操作都会把整个数组复制一份,然后修改副本,再把引用切换过去,读操作永远能拿到一致性的快照,不会抛出并发修改异常。还有BlockingQueue家族的 ArrayBlockingQueue、LinkedBlockingQueue,它们是生产者-消费者模式的最佳搭档,队列为空时取元素会阻塞等待,队列满时添加元素也会阻塞等待,省去了自己用锁和条件变量实现通信的麻烦。
有个小小的提醒:ConcurrentHashMap的 key 和 value 都不能为 null,这是它跟普通 HashMap 的一个差异。你在从老代码迁移到并发容器时,如果原本允许存入 null,就要注意这个地方,否则会莫名其妙地抛空指针。
4. 排查线程安全问题的套路与工具心法
4.1 先看“症状”:哪些现象在提醒你可能线程不安全
线程安全问题的排查,难的不是修,而是判断“到底是不是线程安全问题”。根据我多年的经验,下面这些症状出现时,你要优先往线程安全方向想。
第一类是数据错乱。某个数值时对时错,而且没有固定规律,换台机器、换个环境结果都不一样。计数器、统计值、库存、金额这些场景特别容易中招。
第二类是偶发异常。同一个接口单独测试没问题,并发一来就抛 ConcurrentModificationException,或者偶发空指针。优先检查那些全局单例的对象,比如 Spring 默认创建的 Bean 就是单例的,里面如果放了有状态成员变量,并发访问时很容易出问题。
第三类是程序卡死,但 CPU 没有明显上涨。这种多半是死锁。如果线程状态一直处于 BLOCKED 或者 WAITING,迟迟不结束,那就要考虑是不是多个线程拿着锁互相等待了。
第四类是性能突然塌方。加了锁之后吞吐量急剧下降,或者响应时间变得极不稳定。这通常是锁粒度太大,或者锁竞争太激烈导致的。
4.2 用工具揪出问题:jstack、压测和代码审查
定位线程安全问题,我常用的手段有几个。
第一个是 jstack 生成线程快照。在 Linux 环境下可以执行jstack 进程ID,也可以配合jps先找到 Java 进程号。jstack 输出里有很详细的线程状态和调用栈,如果发现线程处于java.lang.Thread.State: BLOCKED,并且旁边能看到等待获取锁的信息,基本就能锁定死锁位置。线上的死锁问题用这招特别管用。
第二个是并发压测。很多人写代码只做过功能测试,没做过并发测试,问题自然发现不了。你自己写一个简单的多线程调用脚本,十个、五十个、一百个线程同时去跑目标方法,跑完检查结果是否符合预期。多跑几轮,因为并发问题往往要特定时机才触发。我有一个经验:不要只检查最终结果的正确性,还要在运行过程中打印一些中间值,不然你只能看到“结果错了”,还不知道错在第几步。
第三个是代码审查。线程安全问题和普通逻辑 bug 不太一样,它经常是“几个人分别写的代码合在一起时”才产生问题。比如 A 同事负责维护一个全局配置对象,B 同事觉得读多写少直接用了 HashMap,C 同事在某个定时任务里修改配置。三个模块单看都问题不大,组合起来就出事了。所以做并发相关设计时,一定要提前画出哪块数据是共享的、会被哪些线程修改、读写频率如何,这个信息比任何工具都重要。
4.3 避免死锁的设计原则和实操技巧
死锁是个很经典的问题,形成条件说起来也不复杂:互斥、持有并等待、不可剥夺、循环等待。四个条件同时满足就会死锁。所以避免死锁,本质上就是打破这四条件中的一个。
最常用的方法是有序加锁。如果多个线程需要同时获取多把锁,那就规定所有线程都必须按同样的顺序来获取。比如有两个资源对象 A 和 B,所有线程都先锁 A 再锁 B,就不会出现一个线程持 A 等 B、另一个线程持 B 等 A 的局面。这种做法我建议在项目设计阶段就定好规矩,等代码写完了再加就很被动。
另一种做法是用tryLock+ 超时。ReentrantLock 提供了尝试获取锁的能力,拿不到锁就等待指定时间,超时之后主动放弃,并且释放已经持有的其他锁,这样就能避免无限期等下去。
if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lock.unlock(); } } else { // 获取锁失败,做兜底处理 }最后还要警惕“锁内嵌套调用外部方法”的风险。你持有一把锁的时候,调了一个远程服务或者一个很复杂的业务方法,这个方法内部可能又去获取其他锁,死锁就很容易在这种隐蔽的调用链里出现。拿锁的时间越短越好,拿锁时执行的逻辑越简单越好,这是保命原则。
4.4 我亲自踩过的几个坑
第一个坑是锁错了对象。我早期写过一个缓存工具类,内部有一个 HashMap,我在写入方法上加 synchronized,想着锁 this 肯定没问题。结果另一个方法操作同一个 HashMap 时,我图省事锁了一个 String 常量对象。两个方法压根不是同一把锁,并发一上去,数据全乱了。排查了很久才发现是锁对象不一致。
第二个坑是 synchronized 的锁粒度太大,把耗时操作放进去了。有个接口逻辑很简单,就是读配置、组装数据、写入缓冲,但里面有一次 Redis 调用花了 200 毫秒。我把整个方法都加了 synchronized,结果一瞬间几十个请求全部排队等这一把锁,吞吐量直接打成了个位数。后来把 Redis 调用挪到锁外面,性能立刻恢复正常。这个教训让我明白:锁只是保护共享变量的修改,不是保护整个业务流程。
第三个坑是迭代时修改并发集合。有一次用 ConcurrentHashMap,我天真地以为它线程安全,就在迭代的时候直接调用removeIf或者put。其实 ConcurrentHashMap 在单线程迭代的语义也有约束,如果其他线程在迭代过程中修改了映射结构,还是会出现不一致的迭代结果。正确做法是使用它提供的compute系列原子方法,或者把需要修改的 key 收集起来,迭代结束之后统一处理。
第四个坑是 ThreadLocal 没有清理。上面提过,在线程池里复用线程时,ThreadLocal 会残留上一次请求的数据。有一次两个用户之间互相看到了对方的登录信息,吓得我手都在抖。后面养成习惯,所有 ThreadLocal 用完都在 finally 里 remove,再也没有出过这种级别的事故。
5. 个人经验:一个最实用的判断模型
分享了这么多,最后说一个我自己总结的判断模型,希望对你有帮助。每次分析一个类或者方法是否线程安全时,我习惯按下面的顺序问自己。
第一,这份数据会被多个线程同时访问吗?如果不会,那你不需要考虑线程安全。第二,这份数据在运行期间会变化吗?如果是初始化之后永远不会变的,即使是全局变量也没关系,加 final 就行。第三,多个线程对它的操作是纯读还是有读有写?纯读场景基本安全,有写就得小心。第四,这些操作是单个原子操作还是复合操作?单个赋值可能是原子的,但“检查后再修改”、“读取后累加”、“先删后加”基本都是复合的,需要加锁或者使用并发工具。
这套问题问下来,80% 的线程安全问题都能在设计和代码审查阶段被发现。剩下的 20%,就得靠压测、jstack 和线上监控来暴露了。
另外我再真心建议一点:不要一上来就追求非常花哨的并发技巧。项目里大多数地方用 synchronized 和 ConcurrentHashMap 就够了,代码也容易读。只有在你明确测量出性能瓶颈、确认锁竞争是主要原因之后,再去尝试 Lock、CAS、无锁队列这些高级手段。线程安全的主要目标是“正确性”,性能优化应该排在后面。为了性能把代码写成只有自己看得懂,后面的同事接手时会在心里问候你很久。
线程安全这个话题,看起来是语法层面的问题,背后其实是对并发本质的理解深度。把原子性、可见性、顺序性这三个点琢磨透了,再配合并发容器和锁的工具箱,你在大多数项目里都不会再为并发问题发愁。