
1. 并发编程的三大特性到底是什么刚入行那会儿我对并发编程的理解基本停留在“开个线程跑任务”的层面。直到有一次线上服务在压测时频繁出现数据错乱排查了整整两天才发现是共享变量的可见性出了问题。那次之后我才真正意识到并发编程远不是调个API那么简单它背后有一套完整的理论体系在支撑。这套体系的核心就是三大特性原子性、可见性、有序性。这三个词听起来像是教科书里的概念但实际工作中遇到的绝大多数并发Bug追根溯源都逃不出这三个特性的范畴。你写的代码在单线程环境下跑一万遍都没问题一到多线程就各种诡异现象——计数不准、状态错乱、死循环——本质上都是这三个特性中的某一个被破坏了。这篇文章主要面向有一定编程基础、正在或者即将接触并发场景的开发者。不管你是写业务代码时偶尔需要处理多线程还是正在准备面试需要系统梳理这块知识我都会从实际场景出发把这三个特性讲透。我不会只告诉你“是什么”更会告诉你“为什么”以及“怎么用”。每个特性我都会配上可运行的代码示例、底层原理分析以及我在实际项目中踩过的坑和总结的排查技巧。先给一个整体认知框架原子性解决的是“操作会不会被打断”的问题可见性解决的是“一个线程的修改另一个线程能不能看到”的问题有序性解决的是“代码执行顺序会不会被重排”的问题。这三个问题在单线程环境下都不存在因为单线程天然串行执行没有竞争也没有通信。但一旦引入多线程这三个问题就会同时冒出来而且往往交织在一起让Bug表现得非常隐蔽。注意很多人学并发编程时喜欢一上来就啃JMMJava内存模型的定义结果被各种抽象概念绕晕。我的建议是先理解这三个特性分别对应什么实际问题再去深入底层原理这样学习路径会顺畅很多。2. 原子性操作不可分割的底层逻辑2.1 从一个计数器Bug说起先看一段代码这段代码看起来毫无问题但实际运行结果几乎不可能等于预期值public class Counter { private int count 0; public void increment() { count; } public int getCount() { return count; } }假设用10个线程每个线程调用increment()方法1000次最终count的结果大概率小于10000。为什么因为count这个操作看起来是一行代码实际上在底层至少包含三个步骤读取count的当前值、将值加1、将新值写回count。这三个步骤在多线程环境下可能被交错执行。比如线程A和线程B同时读到count5各自加1后都写回6两次自增实际上只增加了1。这就是原子性被破坏的典型场景。2.2 原子性的本质与实现手段原子性的定义很直白一个操作或者多个操作要么全部执行且执行过程不被中断要么全部不执行。听起来简单但在硬件层面即便是最简单的赋值操作也可能不是原子的。32位系统上对long类型的写操作会被拆成两次32位写这中间就可能被中断。那怎么保证原子性常见的手段有这么几种第一种是使用锁。synchronized关键字或者ReentrantLock都可以保证同一时刻只有一个线程能执行临界区代码。锁的本质是把并发操作串行化用性能换正确性。我在实际项目中对计数场景更倾向于用AtomicInteger这类原子类因为它在低竞争场景下性能明显优于锁。第二种是CAS操作。Compare-And-Swap是硬件层面提供的一条原子指令Java中的原子类就是基于CAS实现的。它的逻辑是比较当前值和预期值如果相等就更新为新值否则不做任何操作。这个“比较更新”的过程是硬件保证原子的。第三种是利用不可变对象。如果一个对象创建后状态就不能改变那它天然就是线程安全的也就不存在原子性问题。这也是为什么String被设计成不可变类的原因之一。2.3 原子性实操中的关键细节在实际编码中有几个关于原子性的细节特别容易踩坑。第一个坑是复合操作的原子性。即便每个单独的操作都是原子的组合在一起也未必是。比如“先检查再执行”check-then-act这种模式即使检查操作和执行操作各自原子中间也可能被插入其他线程的操作。典型的例子是单例模式的双重检查锁定如果不用volatile修饰实例变量就可能拿到一个半初始化状态的对象。第二个坑是原子类的ABA问题。CAS操作在判断“当前值是否等于预期值”时如果值从A变成B又变回ACAS是无法感知的。在大多数计数场景下这不是问题但如果涉及链表节点的操作ABA问题就可能导致数据结构损坏。解决办法是加版本号Java提供了AtomicStampedReference来应对这种情况。第三个坑是锁的粒度选择。锁的粒度太粗会影响并发性能太细又容易导致死锁或者漏锁。我的经验是优先考虑用原子类替代简单的计数和状态标记场景只有在需要保护多个变量之间的一致性时才用锁。实操心得判断一个操作是否需要原子性保护最简单的方法是问自己——如果这个操作执行到一半被暂停另一个线程看到中间状态会不会出问题如果会那就需要保护。3. 可见性一个线程的修改另一个线程能看见吗3.1 可见性问题的经典表现先看一段代码public class VisibilityDemo { private static boolean flag false; public static void main(String[] args) throws InterruptedException { new Thread(() - { while (!flag) { // 空循环等待 } System.out.println(循环结束); }).start(); Thread.sleep(1000); flag true; System.out.println(flag已设置为true); } }这段代码在主线程中将flag设为true之后子线程理论上应该退出循环。但实际运行中子线程很可能永远不会退出一直卡在while循环里。原因就是主线程对flag的修改子线程“看不到”。为什么看不到这要从现代计算机的存储结构说起。CPU和内存之间有多级缓存每个CPU核心有自己的L1、L2缓存。当一个线程修改了共享变量这个修改可能只写入了当前核心的缓存还没有同步到主内存。另一个线程从自己的缓存中读取这个变量时读到的还是旧值。3.2 从硬件架构理解可见性要真正理解可见性得从CPU缓存架构说起。现代多核CPU的缓存结构大致是这样的每个核心有私有的L1和L2缓存多个核心共享L3缓存最下面是主内存。数据在缓存之间以缓存行为单位传输典型大小是64字节。当一个核心修改了某个缓存行中的数据其他核心对应的缓存行就变成了失效状态。但“失效”这个动作不是瞬间完成的需要处理器之间通过总线进行通信。在这个通信完成之前其他核心读到的仍然是旧数据。这种设计是为了性能——如果每次读写都直接操作主内存CPU的大部分时间都会浪费在等待内存上。但性能优化的代价就是可见性问题。3.3 保证可见性的技术手段Java中保证可见性的核心机制是volatile关键字和锁。volatile的语义有两条第一写volatile变量时会将当前处理器缓存行的数据立即写回主内存第二读volatile变量时会将当前处理器缓存行置为无效从主内存重新读取。这两条合在一起就保证了volatile变量的写操作对后续的读操作立即可见。但volatile只保证可见性不保证原子性。volatile int count; count;这样的代码仍然是不安全的因为count是复合操作。synchronized和Lock也能保证可见性因为它们在释放锁之前会将修改刷新到主内存在获取锁时会清空本地缓存从主内存重新读取。所以“锁不仅保证互斥还保证可见性”这句话是有底层依据的。还有一个容易被忽略的点是final字段的可见性。正确构造的对象构造过程中没有this引用逸出其final字段的值对其他线程是可见的不需要额外同步。这是Java语言规范专门做的保证。3.4 可见性问题的排查技巧可见性问题最麻烦的地方在于它不一定每次都出现。可能跑一万次才出现一次而且和硬件、JVM版本、运行环境都有关系。我总结了几条排查经验第一如果多线程程序出现“某个线程似乎永远看不到另一个线程的修改”这种现象优先怀疑可见性问题。特别是循环等待某个标志位的场景。第二用jstack查看线程栈如果发现某个线程长时间停留在循环中且状态是RUNNABLE结合代码检查是否有共享变量缺少volatile或同步。第三压测时如果问题复现频率和CPU核心数相关比如单核不复现多核复现那基本可以确定是可见性或有序性问题。注意不要用Thread.sleep()来“解决”可见性问题。有些开发者发现加了sleep之后问题消失了就以为搞定了。实际上sleep只是碰巧让缓存同步发生了并没有从根本上解决问题换个环境或者换个时序问题又会冒出来。4. 有序性指令重排带来的隐蔽陷阱4.1 指令重排是怎么回事先看一段经典的重排示例int a 0; boolean flag false; // 线程A a 1; // 操作1 flag true; // 操作2 // 线程B if (flag) { // 操作3 int i a; // 操作4 }在线程A中操作1和操作2没有数据依赖关系编译器和处理器都可能将它们重排。如果操作2被重排到操作1之前执行那么线程B可能在flag为true时读到a仍然等于0。这就是有序性问题。指令重排不是Bug而是性能优化手段。CPU执行指令时访存操作往往需要等待为了不让流水线停顿处理器会在等待期间执行其他不相关的指令。编译器也会做类似的优化。单线程环境下这些重排不会改变程序语义但多线程环境下就可能导致问题。4.2 重排的三种类型指令重排大致分三类编译器重排编译器在不改变单线程语义的前提下调整指令顺序。比如把没有依赖关系的变量赋值调换位置。处理器重排CPU的乱序执行。现代处理器都有多个执行单元可以在等待某个操作结果的同时执行其他指令。内存系统重排由于缓存和写缓冲区的存在内存操作的完成顺序可能与程序顺序不一致。写操作先进入写缓冲区之后才真正写入缓存或主内存。这三种重排叠加在一起使得多线程程序的实际执行顺序可能和代码顺序相差甚远。4.3 内存屏障与happens-before原则Java通过内存屏障来禁止特定类型的重排。内存屏障是一组CPU指令分为四种屏障类型作用LoadLoad禁止读操作与后续读操作重排StoreStore禁止写操作与后续写操作重排LoadStore禁止读操作与后续写操作重排StoreLoad禁止写操作与后续读操作重排volatile变量的读写会在前后插入相应的内存屏障。写volatile变量时前面插入StoreStore屏障后面插入StoreLoad屏障读volatile变量时前面插入LoadLoad屏障后面插入LoadStore屏障。比内存屏障更上层的一个概念是happens-before原则它是JMM定义的一套规则用来判断一个操作的结果是否对另一个操作可见。核心规则包括程序顺序规则同一个线程中前面的操作happens-before后面的操作volatile规则对volatile变量的写happens-before后续对这个变量的读锁规则解锁happens-before后续对同一个锁的加锁传递性如果A happens-before BB happens-before C那么A happens-before C理解happens-before是写出正确并发代码的关键。它不要求你记住所有底层细节只需要在分析代码时问自己这两个操作之间有happens-before关系吗如果没有那它们之间就没有顺序保证。4.4 有序性问题的实际案例我遇到过一个真实的有序性Bug场景是一个状态机的实现。代码大致是这样的public class StateMachine { private int state 0; private Object data null; public void init() { data loadData(); // 操作1加载数据 state 1; // 操作2标记状态为已初始化 } public void use() { if (state 1) { process(data); // 操作3使用数据 } } }在压测时偶尔出现process方法收到null的data。原因就是操作1和操作2可能被重排state先被设为1data还没赋值另一个线程就进来使用了。解决办法很简单给state加上volatile修饰利用volatile的happens-before语义禁止重排。这个案例告诉我们有序性问题往往和可见性问题一起出现因为volatile同时解决了这两个问题。在实际开发中如果一个变量被多个线程读写而且它的值决定了其他变量的可见性那这个变量就应该用volatile修饰。5. 三大特性在实战中的综合应用5.1 双重检查锁定的正确写法双重检查锁定Double-Checked Locking是面试高频题也是三大特性综合应用的经典案例。先看错误的写法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不为null直接返回了一个未初始化完成的对象。正确写法是给instance加上volatileprivate static volatile Singleton instance;volatile在这里的作用是禁止instance new Singleton()内部的重排保证对象完全初始化之后才被其他线程看到。这一个关键字同时涉及了有序性和可见性两个特性。5.2 生产者-消费者模型中的特性权衡生产者-消费者是并发编程中最常见的模式之一。用阻塞队列实现时队列内部已经处理好了三大特性开发者不需要额外操心。但如果自己用wait/notify实现就需要格外注意。public class Buffer { private final QueueInteger queue new LinkedList(); private final int capacity 10; public synchronized void produce(int item) throws InterruptedException { while (queue.size() capacity) { wait(); } queue.offer(item); notifyAll(); } public synchronized int consume() throws InterruptedException { while (queue.isEmpty()) { wait(); } int item queue.poll(); notifyAll(); return item; } }这段代码中synchronized保证了原子性和可见性wait/notify的机制隐含了有序性保证。几个关键点用while而不是if来判断条件是为了防止虚假唤醒用notifyAll而不是notify是为了避免信号丢失。5.3 无锁编程的边界与取舍CAS-based的无锁编程在高并发场景下性能优势明显但它不是银弹。我在实际项目中总结了几条适用边界竞争程度低时CAS性能优于锁竞争激烈时CAS的自旋重试反而会浪费CPU无锁算法实现复杂容易出错除非性能瓶颈确实在这里否则优先用锁ABA问题在涉及引用类型和链表操作时必须考虑无锁编程只能保证单个变量的原子性多变量一致性仍然需要锁实操心得我通常的建议是先用最简单的锁方案把功能做对压测发现锁竞争成为瓶颈后再考虑针对性优化。过早追求无锁编程往往得不偿失。6. 常见问题与排查技巧实录6.1 三大特性问题速查表现象可能原因排查方向解决方案计数结果偏小原子性被破坏检查复合操作是否加锁用原子类或synchronized循环无法退出可见性问题检查循环条件变量是否volatile加volatile或改用同步对象状态不一致有序性问题检查是否有重排可能用volatile禁止重排偶发NullPointerException可见性有序性检查对象发布是否安全用final或volatile压测时数据错乱三者都可能有用jstackjmap分析逐项排查6.2 排查工具与方法jstack查看线程状态和调用栈判断线程是否卡在某个循环或锁上。如果发现线程长时间RUNNABLE但业务没有进展怀疑可见性问题。jcmd可以查看JVM运行时的各种信息包括线程、内存、锁等。jcmd pid Thread.print等价于jstack。压力测试并发Bug往往需要特定时序才能触发单次运行很难复现。用JMeter或自己写多线程测试类反复运行提高复现概率。代码审查重点检查共享变量的访问。每个共享变量问三个问题访问是否原子修改是否可见顺序是否有保证6.3 几个容易忽略的坑第一个坑String的不可变性不等于线程安全。虽然String本身不可变但如果你用一个String变量做状态标记多个线程同时读写这个变量引用仍然需要volatile或同步。第二个坑ConcurrentHashMap的复合操作。虽然单个put/get是线程安全的但“先检查再放入”这种复合操作仍然需要额外同步。Java 8之后提供了putIfAbsent等原子方法来解决这个问题。第三个坑ThreadLocal的内存泄漏。ThreadLocal本身是线程封闭的不存在三大特性问题但如果在线程池中使用且不调用remove()会导致内存泄漏。这不是并发特性问题但经常和并发编程一起出现。第四个坑伪共享。多个线程修改同一个缓存行中的不同变量时会导致缓存行频繁失效性能急剧下降。解决办法是用填充字段把变量隔开到不同的缓存行。这个问题在高性能场景下才需要关注普通业务代码不用过度优化。6.4 面试中的回答思路如果面试被问到三大特性我建议的回答结构是先分别解释三个特性是什么然后各举一个代码示例说明破坏后的后果最后讲一下Java中分别用什么机制来保证。这样既展示了理论功底又体现了实战经验。如果能结合自己项目中遇到的真实案例加分效果非常明显。7. 我个人在并发编程上的一些体会写了这么多年代码我对并发编程最大的体会是不要凭直觉写并发代码。单线程时代的编程直觉在多线程环境下大部分是失效的。你觉得一行代码是原子的它可能不是你觉得改了值别人就能看到它可能看不到你觉得代码按顺序执行它可能被重排。我的习惯是每次写涉及共享状态的代码时都会在脑子里过一遍三个问题这个操作原子吗这个修改可见吗这个顺序有保证吗如果任何一个答案不确定就加同步或者用原子类。宁可性能差一点也不要留一个可能在生产环境爆炸的隐患。另外一个体会是并发Bug的调试成本远高于预防成本。写代码时多花五分钟想清楚同步策略可能省下上线后两天的排查时间。而且并发Bug往往在压测甚至生产环境才暴露那时候修复的代价就更大了。最后分享一个实用建议如果你正在学习并发编程不要只看书上的理论。自己动手写代码故意制造原子性、可见性、有序性问题观察现象然后用各种手段去修复。这种“先破坏再修复”的学习方式比单纯看理论效果好得多。我在带新人的时候一直用这个方法基本上两三天就能建立起对三大特性的直观认知。