并发编程三大特性:原子性、可见性、有序性底层原理与实战

发布时间:2026/10/9 12:01:43
并发编程三大特性:原子性、可见性、有序性底层原理与实战 1. 并发编程的三大特性到底在讲什么刚入行那会儿我对并发编程的理解基本停留在“开个线程跑任务”的层面。直到有一次线上服务在压测时出现了一个诡异现象同一个账户的余额在并发扣减场景下居然出现了负数。排查了大半天最后定位到问题根源——共享变量的可见性和原子性同时出了问题。那次事故之后我才真正意识到并发编程不是“会开线程”就行它背后有一套必须吃透的基础理论。并发编程的三大特性指的是原子性Atomicity、可见性Visibility和有序性Ordering。这三个词看起来简单但它们几乎解释了所有并发Bug的根源。你遇到的大部分线程安全问题——数据竞争、脏读、死循环、指令重排导致的诡异结果——追到底都是这三个特性中的某一个被破坏了。这篇文章适合谁看如果你写过多线程代码但对volatile、synchronized、CAS、内存屏障这些概念只是“知道个大概”那这篇内容就是为你准备的。我不会只告诉你“用synchronized就能解决”而是会把每个特性背后的硬件原理、JMMJava内存模型的设计意图、以及实际编码中怎么取舍讲清楚。不管你是做Java、Go还是C并发这三大特性的底层逻辑是相通的。接下来我会从“为什么需要这三大特性”入手逐个拆解每个特性的本质、破坏场景、修复手段再配合可直接运行的代码示例和排查技巧把这块硬骨头啃透。2. 三大特性的底层逻辑与硬件根源2.1 为什么单核时代没有这些问题要理解三大特性为什么存在得先回到计算机的硬件架构。在单核CPU时代所有线程实际上是在同一个物理核心上分时复用同一时刻只有一个线程在执行。这意味着共享变量的读写天然是串行的不存在“两个核心同时改同一个变量”的情况。那时候的并发问题主要是线程调度层面的不涉及内存模型。但多核时代彻底改变了局面。每个CPU核心都有自己的寄存器、L1/L2缓存甚至L3缓存也可能是分片的。当一个核心修改了某个变量这个修改可能只写入了自己的缓存还没有同步到主内存其他核心读到的仍然是旧值。这就是可见性问题的硬件根源。2.2 CPU缓存架构带来的可见性挑战现代CPU的缓存层次结构大致是这样的每个核心有私有的L1和L2缓存多个核心共享L3缓存最底层是主内存。数据从主内存加载到缓存时会按照缓存行Cache Line通常是64字节为单位进行搬运。问题就出在这里。假设核心A和核心B都缓存了变量x0。核心A把x改成1这个修改先写入核心A的L1缓存。如果没有任何同步机制核心B读x时仍然从自己的L1缓存里拿到0。两个核心看到的值不一致程序行为就乱了。硬件层面其实提供了缓存一致性协议如MESI协议来缓解这个问题但MESI只能保证缓存行级别的一致性它不保证操作的原子性也不保证指令的执行顺序。所以光靠硬件是不够的还需要软件层面的内存模型来约束。2.3 编译器与CPU的指令重排除了缓存还有一个更隐蔽的问题指令重排。编译器和CPU为了提升执行效率会在不改变单线程语义的前提下对指令进行重新排序。比如下面这段代码int a 1; // 语句1 int b 2; // 语句2 int c a b; // 语句3编译器可能会把语句2提到语句1前面执行因为这两句没有数据依赖。在单线程下这没有任何问题。但在多线程下如果另一个线程依赖语句1先于语句2完成就会出问题。CPU层面也有类似的重排——乱序执行Out-of-Order Execution。CPU会根据指令的依赖关系和执行单元的可用性动态调整指令的执行顺序。这种重排在单核视角下是透明的但在多核并发场景下其他核心观察到的执行顺序可能和代码写的顺序不一致。这就是有序性问题的根源你写的代码顺序不等于CPU实际执行的顺序也不等于其他线程观察到的顺序。2.4 JMM内存模型的设计意图Java内存模型JMM就是为了屏蔽不同硬件和操作系统的差异定义一套统一的并发语义。JMM规定了线程和主内存之间的抽象关系每个线程有自己的工作内存对应CPU缓存和寄存器线程对共享变量的操作必须通过主内存来协调。JMM的核心规则包括所有共享变量存储在主内存中每个线程有自己的工作内存保存了该线程使用到的变量的副本线程对变量的所有操作都在工作内存中进行不能直接读写主内存。这套抽象模型配合happens-before规则定义了哪些操作对其他线程可见、哪些操作必须有序执行。理解了这层硬件和模型的背景再看三大特性就不会觉得它们是“凭空规定”而是对真实硬件行为的抽象和约束。3. 原子性操作不可分割的保证3.1 原子性的本质定义原子性指的是一个操作或者多个操作要么全部执行且不被中断要么全部不执行。最经典的例子就是银行转账从A账户扣100块、给B账户加100块这两个操作必须作为一个整体完成不能只扣不加。在单线程环境下你写i看起来是一个操作但实际上它至少包含三步读取i的当前值、计算i1、写回i。这三步之间随时可能被线程调度打断。如果两个线程同时执行i就可能出现两个线程都读到i5各自加1后都写回6最终结果应该是7却变成了6。3.2 哪些操作天生就是原子的不是所有操作都需要额外同步。在Java中以下操作天然具有原子性基本类型除long和double外的读写操作引用类型的读写操作java.concurrent.atomic包下所有类的操作为什么long和double例外因为在32位JVM上64位的long/double读写会被拆分成两个32位操作中间可能被打断。不过在64位JVM上这个问题通常不存在但JMM并不保证这一点所以多线程环境下操作long/double时仍然建议加volatile。3.3 用synchronized保证原子性synchronized是最常用的原子性保证手段。它可以修饰方法或代码块确保同一时刻只有一个线程能进入临界区。public class Counter { private int count 0; public synchronized void increment() { count; // 现在这个操作是原子的 } public synchronized int getCount() { return count; } }synchronized的底层实现依赖于对象头中的Mark Word和Monitor管程。当线程进入同步块时会尝试获取对象的Monitor锁获取失败则进入阻塞队列等待。这个机制同时保证了原子性、可见性和有序性是最“重”但最省心的方案。3.4 CAS操作与乐观锁思路synchronized虽然好用但它是悲观锁——不管有没有竞争都先加锁再说。在高并发场景下线程频繁阻塞和唤醒会带来不小的性能开销。于是有了CASCompare-And-Swap这种乐观锁思路。CAS包含三个操作数内存位置V、预期值A、新值B。当且仅当V的当前值等于A时才将V更新为B否则不做任何操作。整个比较和交换过程是一个原子操作由CPU的cmpxchg指令保证。import java.util.concurrent.atomic.AtomicInteger; public class CasCounter { private AtomicInteger count new AtomicInteger(0); public void increment() { int oldValue; int newValue; do { oldValue count.get(); newValue oldValue 1; } while (!count.compareAndSet(oldValue, newValue)); } }这段代码就是自旋CAS的典型写法不断尝试直到成功为止。AtomicInteger内部就是通过Unsafe类调用底层CAS指令实现的。3.5 原子性的常见破坏场景与修复实际开发中原子性被破坏的场景往往很隐蔽。我整理了几种高频情况破坏场景典型代码修复方案复合操作未同步countsynchronized或AtomicInteger先检查后执行if (map.containsKey(k)) map.put(k,v)用putIfAbsent或加锁多变量联合更新同时更新x和y封装为对象锁或使用不可变对象非原子类的组合Collections.synchronizedList遍历时遍历时手动加锁注意ConcurrentHashMap的单个操作是原子的但“先get再put”这种组合操作不是原子的。很多人在这里踩坑。3.6 实操心得原子性方案怎么选选型时我一般遵循这个原则能用原子类就不用锁能用锁就不用分布式锁。原子类适合简单的计数、状态标记场景synchronized适合临界区逻辑较复杂的场景ReentrantLock适合需要超时、可中断、公平锁等高级功能的场景。还有一个容易忽略的点CAS存在ABA问题。线程1读到值A线程2把值改成B又改回A线程1的CAS仍然成功但它不知道中间发生过变化。解决办法是加版本号AtomicStampedReference就是干这个的。4. 可见性一个线程改了另一个线程能看见吗4.1 可见性问题的经典复现先看一段代码这段代码在我早期排查问题时反复出现过public class VisibilityDemo { private static boolean flag false; public static void main(String[] args) throws InterruptedException { new Thread(() - { while (!flag) { // 空循环等待flag变为true } System.out.println(线程退出); }).start(); Thread.sleep(1000); flag true; System.out.println(主线程已设置flagtrue); } }直觉上主线程把flag设为true后子线程的循环应该退出。但实际运行时子线程很可能永远卡在循环里。原因是子线程从自己的缓存中读取flag主线程的修改对它不可见。4.2 volatile关键字的两层语义volatile是Java中保证可见性最轻量的手段。它有两层语义第一层是可见性当一个线程修改了volatile变量新值会立即被刷新到主内存并且其他线程读取该变量时会从主内存重新加载而不是从自己的缓存中读。第二层是禁止指令重排volatile变量的读写会插入内存屏障阻止编译器和CPU对volatile操作前后的指令进行重排。public class VisibilityFix { private static volatile boolean flag false; public static void main(String[] args) throws InterruptedException { new Thread(() - { while (!flag) { // 现在能正确退出了 } System.out.println(线程退出); }).start(); Thread.sleep(1000); flag true; } }加上volatile后子线程每次读flag都会从主内存加载能及时看到主线程的修改。4.3 volatile不保证原子性这是新手最容易犯的错误。很多人以为volatile能解决所有并发问题但volatile只保证可见性和有序性不保证原子性。public class VolatileNotAtomic { private static volatile int count 0; public static void main(String[] args) throws InterruptedException { for (int i 0; i 10; i) { new Thread(() - { for (int j 0; j 1000; j) { count; // 仍然不是原子操作 } }).start(); } Thread.sleep(3000); System.out.println(count count); // 大概率小于10000 } }count包含读-改-写三步volatile只能保证每一步读到的值是最新的但三步之间仍然可能被打断。要解决这个问题还是得用synchronized或AtomicInteger。4.4 synchronized和Lock的可见性保证synchronized除了保证原子性也保证可见性。JMM规定线程解锁前必须把共享变量的最新值刷新到主内存线程加锁时必须清空工作内存中共享变量的副本从主内存重新加载。ReentrantLock的可见性保证和synchronized类似底层通过AQS的state变量volatile修饰和LockSupport的park/unpark来实现。4.5 可见性排查技巧可见性问题最难的地方在于它不一定每次都复现。有时候加个日志、改个断点问题就“消失”了因为日志和断点会触发内存同步。排查时我一般用这几招用volatile或Atomic类替换普通变量看问题是否消失用jstack查看线程状态如果线程一直RUNNABLE但逻辑没进展很可能是可见性问题在循环中加Thread.sleep(1)或Thread.yield()如果问题消失基本可以确认是可见性实操心得可见性Bug在生产环境往往表现为“偶发”“重启后恢复”“压测时出现”。遇到这类特征优先怀疑共享变量没有正确同步。5. 有序性代码写的顺序不等于执行顺序5.1 指令重排的三种来源有序性问题来自三个层面的重排编译器重排编译器在不改变单线程语义的前提下调整指令顺序以减少寄存器压力或优化流水线。CPU重排CPU的乱序执行引擎根据指令依赖关系动态调整执行顺序充分利用执行单元。内存系统重排由于缓存和写缓冲区的存在一个核心的写操作对其他核心可见的顺序可能与程序顺序不一致。5.2 经典案例双重检查锁定的坑有序性最著名的案例就是双重检查锁定DCL单例模式。早期版本长这样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()这行代码实际上分三步分配内存、初始化对象、把引用指向内存地址。编译器或CPU可能把第二步和第三步重排导致引用先指向了一块未初始化的内存。此时另一个线程在第一个if判断时看到instance ! null直接返回了一个半成品对象。修复方法就是给instance加上volatileprivate static volatile Singleton instance;volatile禁止了初始化对象和引用赋值之间的重排保证其他线程要么看到null要么看到一个完整初始化的对象。5.3 happens-before规则详解JMM用happens-before规则来定义多线程之间的内存可见性。如果操作A happens-before 操作B那么A的结果对B可见。核心规则包括程序顺序规则同一线程中前面的操作happens-before后面的操作监视器锁规则解锁操作happens-before后续对同一锁的加锁操作volatile变量规则对volatile变量的写happens-before后续对该变量的读传递性A happens-before BB happens-before C则A happens-before C线程启动规则Thread.start() happens-before 新线程中的所有操作线程终止规则线程中的所有操作happens-before其他线程检测到该线程终止理解happens-before是判断“这段并发代码是否安全”的核心工具。你不需要记住所有规则但要知道如果两个操作之间没有happens-before关系它们的执行顺序和可见性就没有保证。5.4 内存屏障的四种类型volatile和synchronized的底层都依赖内存屏障来实现有序性。JMM定义了四种屏障屏障类型作用LoadLoad禁止读操作与后面的读操作重排StoreStore禁止写操作与后面的写操作重排LoadStore禁止读操作与后面的写操作重排StoreLoad禁止写操作与后面的读操作重排开销最大volatile写操作前面插入StoreStore屏障后面插入StoreLoad屏障volatile读操作前面插入LoadLoad屏障后面插入LoadStore屏障。这些屏障确保了volatile变量读写前后的指令不会被重排到屏障另一侧。5.5 有序性的实际影响与规避有序性问题在实际开发中比原子性和可见性更隐蔽因为它往往需要特定的时序才能触发。我总结了几条规避原则单例模式必须用volatile修饰实例变量多线程共享的配置对象初始化完成后才发布用final字段或volatile避免在没有同步的情况下依赖两个变量的写入顺序使用final字段可以保证构造完成后的可见性这是JMM的特殊保证实操心得如果你发现代码在单线程测试下完全正常但多线程压测时偶尔出现“对象状态不一致”“字段值错乱”优先检查是否有共享可变对象没有正确发布。6. 三大特性在实战中的综合应用6.1 单例模式的完整演进从最简单的懒汉式到最终的安全版本单例模式的演进完美体现了三大特性的综合运用public class SafeSingleton { // volatile保证可见性和有序性 private static volatile SafeSingleton instance; private SafeSingleton() {} public static SafeSingleton getInstance() { if (instance null) { // 第一次检查避免不必要的锁 synchronized (SafeSingleton.class) { // 锁保证原子性 if (instance null) { // 第二次检查防止重复创建 instance new SafeSingleton(); } } } return instance; } }这个版本同时用到了volatile保证可见性和禁止重排synchronized保证原子性双重检查减少锁竞争。三者缺一不可。6.2 生产者-消费者模型的特性分析用阻塞队列实现生产者-消费者时三大特性都在起作用import java.util.concurrent.BlockingQueue; import java.util.concurrent.LinkedBlockingQueue; public class ProducerConsumer { private final BlockingQueueInteger queue new LinkedBlockingQueue(10); public void produce() throws InterruptedException { for (int i 0; i 100; i) { queue.put(i); // put操作内部保证原子性和可见性 } } public void consume() throws InterruptedException { while (true) { Integer item queue.take(); // take操作同理 if (item -1) break; } } }BlockingQueue内部用ReentrantLock和Condition实现put和take操作都是原子的队列状态的修改对其他线程可见等待/通知机制保证了有序性。你不需要自己处理这些细节但要知道底层发生了什么。6.3 并发计数器的性能对比我做过一个简单的压测对比不同方案在10线程各100万次自增下的表现方案耗时ms是否线程安全普通int约50否synchronized方法约1200是AtomicInteger约300是LongAdder约80是LongAdder在高并发下性能最好因为它把计数分散到多个Cell中减少了CAS竞争。但它的sum()方法不是原子的适合最终一致性场景。6.4 用三大特性视角排查线上问题线上遇到并发问题时我一般按这个顺序排查先看现象是数据不一致原子性、还是线程卡死/不退出可见性、还是对象状态错乱有序性再看共享变量找出所有被多线程访问的可变变量检查同步措施每个共享变量是否有对应的同步机制验证happens-before写操作和读操作之间是否存在happens-before关系压测复现用高并发压测尝试复现观察是否稳定实操心得很多并发Bug在开发环境永远复现不了因为开发环境并发度低、CPU核心少。上线前一定要在接近生产环境的配置下做压测。7. 常见问题与排查技巧实录7.1 三大特性速查表特性核心问题保证手段破坏场景原子性操作被中断synchronized、Lock、Atomic类i、先检查后执行可见性修改不可见volatile、synchronized、Lock共享变量无同步有序性指令重排volatile、synchronized、finalDCL单例、对象发布7.2 高频踩坑记录坑一以为volatile能替代锁。volatile只解决可见性和有序性不解决原子性。count加volatile仍然不安全。坑二在synchronized块中修改共享变量但读取时没加锁。写加锁读不加锁可见性没有保证。读操作也需要在同一个锁的保护下。坑三用Thread.stop()终止线程。这个方法会强制释放锁导致对象状态不一致。应该用中断标志位配合volatile。坑四String的不可变性带来的假安全。String本身不可变但StringBuilder不是。多线程共享StringBuilder拼接字符串结果会错乱。坑五HashMap在多线程下扩容导致死循环。JDK 7的HashMap在并发扩容时可能形成环形链表导致CPU 100%。JDK 8修复了这个问题但HashMap仍然不是线程安全的应该用ConcurrentHashMap。7.3 排查工具与命令jstack查看线程堆栈定位死锁和线程阻塞jconsole/jvisualvm监控线程状态和CPU占用javap -c反编译字节码查看synchronized的实现指令monitorenter/monitorexit-XX:UnlockDiagnosticVMOptions -XX:PrintAssembly查看汇编指令验证内存屏障7.4 设计阶段的避坑原则与其事后排查不如在设计阶段就规避。我遵循这几条原则能不共享就不共享用ThreadLocal或局部变量替代共享变量共享必同步所有被多线程访问的可变状态必须有明确的同步策略优先用不可变对象final字段、不可变类天然线程安全优先用现成工具java.util.concurrent包已经覆盖了大部分并发场景不要重复造轮子文档化同步策略在类注释中写明这个类是否线程安全、用什么方式保证最后分享一个我自己的习惯每次写多线程代码时我都会在纸上画出共享变量和同步措施的关系图标出每个变量的读写路径。这个习惯帮我避免了很多“以为加了锁其实没加对”的问题。并发编程的三大特性不是背概念而是要在每一行涉及共享状态的代码里主动验证原子性够不够、可见性有没有、有序性能不能保证。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询