☰
Java并发编程核心:从synchronized到volatile与CAS的线程安全实战
2026/10/24 4:26:33 网站建设 项目流程

1. 一次线上事故复盘:加了锁为什么数据还是不对

有次在梳理一个订单汇总模块时,A同学找我排查问题:他们用synchronized锁住了一个统计方法,压测环境一切正常,一到线上五台实例一跑,统计数据就出现偏差。

我让他把synchronized加在哪了告诉我。他锁的是一个匿名内部类的Runnable,每次提交任务前都new一个新的Runnable对象。锁在this上,但每一次this都不是同一个对象,五台机器上各锁各的,实际上等于完全没有加锁。这就是线程安全领域最典型的认知误区:写了synchronized,不代表锁住了正确的对象。

1.1 线程安全的本质:三大特性缺一不可

线程安全不是一个“加锁就完事”的问题。从JMM(Java内存模型)的角度看,并发环境下要保证正确性,必须同时满足三个条件:

  • 原子性:一个操作或者多个操作要么全部执行完成,且执行过程中不被任何因素打断,要么都不执行。i++之所以不安全,就是因为“读-改-写”三步在并发下可能被穿插。
  • 可见性:一个线程修改了共享变量,其他线程能立刻看到这个修改。CPU缓存和寄存器导致的“不可见”问题,比大部分人想象的更隐蔽。
  • 有序性:代码的执行顺序在编译器和CPU优化后可能发生变化,也就是指令重排。单线程下有as-if-serial语义兜底,多线程下必须依赖同步机制来禁止重排。

很多人把synchronized当成“原子性”的代名词,这没问题。但在某些场景下,代码明明没有被并发修改,数据却依然出错,问题往往出在可见性和有序性上。

1.2 数据出错的三个表面症状

根据我的经验,线程安全问题在线上表现出三种非常典型的症状:

  1. 计数结果偏小或偏大:比如库存扣减出现负数、计数器最终值不等于实际请求数,绝大多数是原子性被破坏。
  2. 某个线程读到奇怪的值:比如已经volatile修饰的变量还是读到旧值,或者读到了一个“既不是旧值也不是新值”的中间状态,这往往和缓存一致性、指令重排有关。
  3. 偶发性的死锁或线程卡死:锁使用不当,比如多个锁交叉获取、锁内做耗时操作,这类问题通常不是“数据不对”,而是“根本跑不完”。

所以,解决问题之前,必须先定位清楚你面对的是哪一类问题。用错了方案,MySQL事务、Redis分布式锁都救不了你。

2. synchronized的底层演进:从Monitor到锁升级

synchronized在JDK 1.6之前被很多人嫌弃,因为底层是重量级锁,依赖操作系统互斥量(Mutex),线程一旦阻塞就会陷入内核态切换,开销非常大。但JDK 1.6之后,虚拟机团队做了大量优化,引入了偏向锁、轻量级锁、重量级锁三档升级路径。现在写synchronized,性能完全不输ReentrantLock。

2.1 对象头与Monitor:锁信息的“存放地”

Java对象在内存里的布局分三块:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。锁相关信息就存在对象头里。

对象头的Mark Word(标记字)在不同锁状态下存的内容不同:

锁状态Mark Word内容说明
无锁对象的hashCode、分代年龄正常对象状态
偏向锁线程ID、epoch、偏向标志位记录持有锁的线程ID
轻量级锁指向栈中Lock Record的指针线程在栈帧里记录锁记录
重量级锁指向Monitor的指针所有竞争线程挂到Monitor上

Monitor(监视器锁)是synchronized的核心,每个对象都可以关联一个Monitor。它内部有Owner(持有者)、EntryList(等待队列)、WaitSet(条件等待队列)。当多个线程竞争锁时,没抢到的线程进入EntryList阻塞等待。

2.2 锁升级的完整过程

锁升级是按“一个线程持锁、少量线程竞争、大量线程竞争”的不同程度递进的:

  1. 偏向锁:只有一个线程反复进入同步块时,锁会被“偏向”给这个线程。Mark Word记录线程ID,后续这个线程再进来,不需要任何CAS操作,直接拿到锁。这就是偏心的含义——偏向于第一个拿到锁的线程。
  2. 轻量级锁:一旦出现第二个线程竞争(哪怕只是偶尔来一次),偏向锁立即撤销,升级为轻量级锁。竞争线程在自己的栈帧中创建Lock Record,用CAS把Mark Word替换成指向Lock Record的指针。CAS成功则持有锁,失败则自旋等待。
  3. 重量级锁:自旋超过阈值(默认10次,也可以用自适应自旋)后仍然没抢到锁,锁升级为重量级锁。后续竞争线程直接进入操作系统内核态的阻塞队列,等待被唤醒。

2.3 锁消除和锁粗化:编译器的“额外赠品”

除了运行时锁升级,JIT编译器还会做两件好事:

  • 锁消除:如果JIT通过逃逸分析发现某个锁对象不会被其他线程访问,就会把锁直接删除。所以很多人写“局部变量加锁”的代码,实际运行时锁根本不存在。
  • 锁粗化:如果连续对同一个对象反复加锁、解锁(比如在循环体里加锁),JIT会把锁的范围扩大,合并成一次加锁解锁,减少锁切换的开销。

这里我有一条很实在的经验:不要因为考虑性能就过度设计“细粒度锁”。比如在for循环里给每个元素单独加锁,编译器可能反手给你合并成一个粗粒度锁,而且你写的代码可读性极差。先用最自然的synchronized写法,压测时再根据热点分析结果优化,这才是工程化的做法。

3. volatile与原子性:一个常见误区的完整拆解

很多初学多线程的人,把volatile理解成“并发神器”,好像变量加了volatile就安全了。这个误解非常危险。volatile只保证可见性和有序性,不保证原子性。

3.1 volatile到底干了什么

volatile变量的语义包含两条:

  1. 可见性:对一个volatile变量的写操作,会立即刷新到主内存;读操作会直接从主内存读取(实际上是通过内存屏障保证不读CPU缓存中的旧值)。
  2. 有序性:编译器不会把volatile变量前后的指令重排到越过这个变量读写的位置。JMM通过在volatile前后插入内存屏障(Memory Barrier)来实现这个禁止重排的语义。

但volatile不保证复合操作的原子性。最经典的例子:

public class Counter { private volatile int count = 0; public void increment() { count++; // 不是原子操作 } }

count++在字节码层面是三步:GETSTATIC读取、IADD加一、PUTSTATIC写回。三个线程同时执行,都读到count=0,各自加一后写回,最终结果可能只是1而不是3。

3.2 从底层看volatile的内存屏障

volatile之所以能禁用重排,是因为它在读写前后插入了特定的内存屏障指令:

  • 写屏障(StoreStore):volatile写之前插入,保证之前的普通写操作已经刷新到主内存。
  • 读屏障(LoadLoad):volatile读之后插入,保证后续的普通读操作不会读到旧值。

理解内存屏障是理解JMM的一把钥匙。很多并发工具(比如后面要说的Lock、ConcurrentHashMap)本质上都是在不同位置插入内存屏障来达到目的。你不需要手写这些,但当你排查一个“明明用了volatile还是出问题”的bug时,你会感激自己懂这些。

3.3 什么时候真的需要volatile

volatile适合以下两类场景:

  1. 状态标志:比如线程的停止标记。
public class Worker implements Runnable { private volatile boolean running = true; @Override public void run() { while (running) { // 执行任务 } } public void stop() { running = false; } }

这个场景里,running变量的写操作只有stop()方法调用一次,读操作在run()的循环里,不存在“读-改-写”复合操作,volatile能够完美解决可见性问题。如果你把running声明为普通变量,JIT可能会把while (running)优化成死循环(因为单线程视角下running永远不会被修改)。

  1. 双重检查锁(DCL)中的单例引用:
public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); // 必须volatile } } } return instance; } }

这里volatile的核心价值是禁止指令重排。new Singleton()在底层是三步:分配内存、调用构造方法、把引用赋值给instance。如果不加volatile,第三步可能被重排到第二步之前,另一个线程就能读到“半初始化”的对象。

4. CAS与原子类:无锁并发的取舍哲学

synchronized是悲观锁的思路:假设一定会发生冲突,所以先锁住再说。而CAS(Compare And Swap,比较并交换)是典型的乐观锁思路:假设大概率没冲突,先尝试更新,失败再重试。

4.1 从AtomicInteger看CAS实现

以AtomicInteger为例,incrementAndGet()底层是这样的逻辑:

public final int incrementAndGet() { int current = get(); int next = current + 1; if (compareAndSet(current, next)) { return next; } // CAS失败,循环重试 }

JDK里的实际实现是Unsafe.compareAndSwapInt,它是一条CPU级别的原子指令。CPU直接比较内存中的值是否等于期望值current,如果相等就把新值写进去,并且整个操作是原子的,不会被其他线程打断。这避免了操作系统层面的线程阻塞,性能比synchronized在竞争激烈时好得多。

4.2 ABA问题:CAS最经典的坑

ABA问题是什么?想象一个场景:线程A读到变量值为A,在它准备CAS更新前,线程B把值改成B,再改回A。此时线程A的CAS判断“值仍然是A”,于是更新成功。但在这个时间段里,数据其实被其他线程动过。

这个问题的危害性取决于业务语义。比如一个栈,线程B弹出了节点A后又推入了新节点A(内存地址复用),线程A的CAS就可能把已经不在栈里的元素又推回去。

解决方案是带版本号的CAS。AtomicStampedReference就是干这个的——比较的不只是值,还有版本戳。

AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(1, 0); int[] stampHolder = new int[1]; Integer value = ref.get(stampHolder); boolean success = ref.compareAndSet(value, 2, stampHolder[0], stampHolder[0] + 1);

stampHolder[0]是当前版本号,CAS时要同时匹配值和版本号,版本号变了就说明有其他线程改过。

4.3 从CAS到LongAdder:高并发下的性能优化

高并发场景下,AtomicLong也有瓶颈。因为所有线程都去CAS同一个内存地址,竞争激烈时CAS失败率飙升,大量线程在自旋空转。

LongAdder的设计思路是分散竞争:内部维护一个base变量和一组Cell[]数组。每个线程根据自己的hash映射到不同的Cell上加值,最后求和时把base和所有Cell里的值加起来。这样多个线程修改不同的Cell,CAS冲突概率大幅下降。

特性AtomicLongLongAdder
竞争激烈时的性能低(大量CAS失败)高(分散到多个Cell)
内存占用小较大(需要Cell数组)
适合场景低频计数、需要实时精确值高并发统计、最终一致即可

我第一次用LongAdder是在一个日志采集统计模块里,单机QPS三万左右,AtomicLong的CAS失败率高到CPU飙升。换成LongAdder后,吞吐量翻了将近一倍。但它有个特点需要注意:sum()方法返回的并不是一个快照值,并发写入时可能不是绝对准确的,适合“累加统计”但不适合“需要精确到某一瞬间值”的场景。

4.4 自旋带来的CPU消耗

CAS配合循环重试就是自旋锁。自旋不会导致线程阻塞,但会占用CPU时间。如果锁竞争非常激烈,自旋线程会持续消耗CPU,反而不如synchronized阻塞唤醒那样省CPU。

所以选择无锁方案时,我通常会评估一下临界区的竞争强度:低竞争用CAS,高竞争用synchronized或ReentrantLock更稳妥。这个判断标准,比“无锁一定快”的流言靠谱得多。

5. Lock接口与AQS:显式锁的完整拼图

synchronized虽然好用,但不够灵活:无法中断一个正在等待锁的线程、无法设置超时时间、无法实现多路条件队列。Lock接口就是来补这些短板的。

5.1 ReentrantLock与synchronized的正面交锋

ReentrantLock是最常用的Lock实现,和synchronized的对比如下:

特性synchronizedReentrantLock
锁的获取/释放JVM自动管理手动lock/unlock,必须finally释放
可中断性不支持支持lockInterruptibly()
超时获取锁不支持支持tryLock(timeout, TimeUnit)
公平锁非公平可通过构造参数选公平/非公平
条件队列每个锁只有一个wait/notify支持多个Condition实例
性能JDK 1.6后已优化竞争激烈时灵活度更高

ReentrantLock的公平锁和非公平锁是我特别想强调的。公平锁意味着等待时间最长的线程先获得锁,避免“饿死”,但代价是线程切换更频繁,整体吞吐量下降。非公平锁允许新来的线程和等待队列里的老线程一起竞争,多数情况下吞吐量更高,但极端场景下可能有个别线程一直抢不到锁。

Lock lock = new ReentrantLock(true); // 公平锁 Lock lock = new ReentrantLock(false); // 非公平锁,默认 // 超时获取锁的标准写法 if (lock.tryLock(2, TimeUnit.SECONDS)) { try { // 临界区 } finally { lock.unlock(); } } else { // 没拿到锁的降级处理 }

5.2 AQS:所有显式锁的基石

AbstractQueuedSynchronizer(AQS)是JUC包的灵魂。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock,底层全部建立在AQS之上。

AQS的核心是一个volatile int state变量和一个CLH变体双向队列。state的语义由子类自行定义:

  • ReentrantLock里,state表示锁的持有次数(重入一次加一)。
  • Semaphore里,state表示剩余许可数。
  • CountDownLatch里,state表示还需要多少计数才放行。

获取锁的过程就是CAS修改state的过程。如果CAS成功,就持有锁;失败就封装成Node节点进入队列,通过LockSupport.park()阻塞自己。释放锁的时候唤醒队列头部的后继节点。

这道工序理解一次,以后看任何基于AQS的工具源码都会顺畅很多。

5.3 读写锁与StampedLock:读多写少场景的升级之路

ReentrantReadWriteLock把锁拆分成读锁和写锁:多个线程可以同时持有读锁,但写锁是独占的。这很适合“读多写少”的缓存场景。

不过ReentrantReadWriteLock的读锁和写锁互斥,写线程会被读线程“饿死”,而且它的锁升级很麻烦:读锁不能直接升级为写锁,否则可能死锁。

StampedLock是JDK 8加入的“进阶版读写锁”,特色是支持乐观读(Optimistic Read)。乐观读不真正加锁,只返回一个stamp版本号。读取完数据后,判断stamp是否变化,若没变说明读取期间没有写操作发生,可以直接使用数据;若变了说明有冲突,这时再升级为悲观读锁或重新读取。

long stamp = stampedLock.tryOptimisticRead(); int x = sharedValue; if (!stampedLock.validate(stamp)) { // 乐观读失败,说明有写操作发生 stamp = stampedLock.readLock(); try { x = sharedValue; } finally { stampedLock.unlockRead(stamp); } }

StampedLock不是无脑替代品。它不可重入,且只支持Condition的写锁版本,使用时要仔细评估场景。

6. 并发容器与线程池:工具类背后的安全边界

线程安全不只是锁的事。JDK提供了一整套线程安全的容器和工具,但如果选错或误用,依然会掉进坑里。

6.1 CopyOnWriteArrayList:适合读多写极少

CopyOnWriteArrayList的机制是“写时复制”:每次修改,都会复制整个底层数组,然后在副本上修改,最后把引用指向新数组。读操作不加锁,直接读旧数组。

这套机制保证了读线程永远看到的是一个一致的、完整的数组,就算有线程正在写入,读到的也是修改前的旧版本。

但代价非常明显:写操作的开销是O(n),频繁写入的场景效率很低。我见过有人把CopyOnWriteArrayList用在每秒写多次、几十万条数据缓存的场景,结果内存和GC双双告急。它只适合读多、写极少、集合大小不大的场景,比如监听器列表、配置文件项缓存。

6.2 ConcurrentHashMap:为什么它敢说自己安全

ConcurrentHashMap在JDK 8之后放弃了分段锁设计,改成CAS + synchronized锁住桶的头节点。只在某个桶发生哈希冲突时,才对那个桶的头节点加锁,锁粒度比分段锁更细。

ConcurrentHashMap也不允许null键和null值,这和HashMap不同。原因在于并发场景下如果get返回null,你无法区分是“键不存在”还是“键对应的值就是null”。这会导致后续的原子操作(比如computeIfAbsent)出现歧义。所以设计上直接禁止null。

使用ConcurrentHashMap时,有一个很容易被忽视的细节:单个方法线程安全,不代表复合操作线程安全。比如:

if (map.containsKey(key)) { map.put(key, value); }

这两行之间,其他线程可能已经修改了key。正确做法是用putIfAbsent、compute这类原子方法,而不是自己组合调用。

6.3 线程池中的ThreadLocal陷阱

线程池和ThreadLocal的组合,是我在代码评审里见过最多的问题之一。ThreadLocal本身是线程隔离的,每个线程有自己的变量副本。但线程池里的线程是复用的——一个线程执行完任务A后不会销毁,还会继续执行任务B。

这就导致一个严重问题:任务A写入的ThreadLocal值,可能会被任务B读到。特别是如果线程池的线程数很少,这个“串数据”的问题非常隐蔽,而且只在特定任务序列下偶发。

解决办法有两个:

  1. 任务结束前手动清除:threadLocal.remove()。
  2. 用ThreadLocal的子类,重写initialValue()并在任务入口处重新设置。

我个人强烈推荐第一种:每个任务跑完必须remove(),这是线程池场景下使用ThreadLocal的最低要求。

6.4 异步任务的异常处理与安全边界

ExecutorService.submit()返回的Future,如果任务内部抛异常,异常不会直接打印出来,而是被封装在Future里。如果你没有调用Future.get(),异常会被默默吞掉——数据算到一半,线程池里的线程池接着跑下一个任务,没人知道刚才出错了。

这其实也是一种“线程安全”问题:异常丢失导致数据不一致。所以我的习惯是,异步任务里统一用try-catch包裹,或者至少提交后立刻在回调里get()处理异常,绝不能放任不管。

7. 实战中反复踩到的五个线程安全陷阱

最后这部分,我把我这几年在代码评审和排查事故时遇到频率最高的五个坑列出来。每一个都是真实发生过的,不是教科书上的案例。

7.1 双重检查锁忘记volatile

这个前面聊过原理,但我要强调一下工程后果。不加volatile的DCL单例,在高并发初始化的时候有小概率返回一个还没构造完的对象。这个对象可能某些字段是默认值,而且不会报错,只会在真正调用某个方法时出现诡异的空指针或逻辑错乱。定位这种问题非常痛苦,因为它在压测环境可能上千次都复现不了,一上生产高并发就偶发。

7.2 SimpleDateFormat的并发格式化

SimpleDateFormat是个非常典型的“非线程安全类”,内部维护了Calendar和NumberFormat等共享状态。多个线程同时调用format()或parse(),轻则解析字段错乱,重则抛NumberFormatException甚至ArrayIndexOutOfBoundsException。

解决办法有几种:每次调用都new一个(最简单但浪费);用ThreadLocal包一层(性能好但要记得清理);或者干脆用JDK 8的DateTimeFormatter——它是线程安全的,本质上是不可变类。我现在的项目里已经全面迁移到DateTimeFormatter,这是最省心的方案。

7.3 静态变量与单例Bean的隐性共享

很多人写的“工具类”,里面放一个static的Map或List当缓存。如果你的应用是单实例部署,可能永远不出问题。一旦做了多实例部署,或者某个测试环境并行跑多个实例,这个静态变量就会被所有实例共享。

Spring默认的单例Bean同理:无状态的Bean天然线程安全,一旦你在单例Bean里加了可变的成员变量,这个Bean就不再安全了。我的建议是:尽量把可变状态收敛到局部变量里,非要共享,就显式加锁或者用并发容器,绝不要“觉得不会并发访问”。

7.4 包装集合的线程安全误解

Collections.synchronizedList()、Collections.synchronizedMap()这些包装类只是把每个方法用synchronized包了一层,它保证的是单个方法调用的原子性,而不保证迭代安全。

比如:

List<String> list = Collections.synchronizedList(new ArrayList<>()); synchronized (list) { // 迭代需要额外加锁 for (String item : list) { // 处理 } }

包装类的迭代器并没有做同步处理(SynchronizedCollection的迭代器就是普通迭代器),迭代过程中其他线程修改集合时,会抛出ConcurrentModificationException。要么在迭代时手动锁住整个list,要么改用CopyOnWriteArrayList。

7.5 自动拆装箱引发的原子性失效

基本类型的int在32位机器上读写是原子的,但long和double因为64位,在Java内存模型里规定“非volatile的long/double的写入可能被拆成两次32位写入”,存在读到“半个值”的可能性。虽然这主要影响32位JVM和某些老平台,但依然值得注意。

更常见的拆装箱问题发生在集合里:List<Integer>存放的每个元素,读写时都有自动装箱/拆箱的隐式转换。如果你在多线程下对同一个Integer变量做自增运算,它依然是“读-改-写”复合操作,没有任何同步保护。很多人以为“包装类自带线程安全”,这是彻头彻尾的误解。

写在最后

我自己的体会是,线程安全的本质不是背一堆API,而是对“内存模型、锁机制、并发工具边界”这三个层面同时有清晰的认知。写完的并发代码,每次都要问自己三个问题:共享变量在哪里?哪些操作是复合的?同步机制是否覆盖了所有读写路径?

上面这些内容,涵盖了从synchronized锁升级、volatile、CAS、显式锁到并发容器和实战陷阱的主线。下一篇可以接着聊线程池的拒绝策略、阻塞队列选型和异步编排下的线程安全边界,如果你遇到过类似的问题,也欢迎在评论区把场景打出来,一起复盘。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询