前面讲锁时核心问题一直是多个线程同时访问共享状态时如何保证结果正确。synchronized、ReentrantLock、读写锁、StampedLock都是在这个问题上给出的不同答案。但是锁不是唯一方案。如果线程获取不到锁通常要进入阻塞、唤醒、重新竞争的过程无锁设计则换了一个方向不让线程因为竞争失败而进入阻塞队列而是通过 CAS、快照替换、版本校验、分散竞争等方式在用户态完成冲突处理。因此无锁并不是没有并发控制而是竞争失败后的处理方式不同。加锁的失败结果通常是等待无锁的失败结果通常是重试、校验或换一个位置继续尝试。这也是本章后面所有内容的共同主线。1. CAS失败重试型无锁CAS 是 Compare-And-Set 的缩写意思是“比较并设置”。它的语义可以简化成一句话修改共享变量前先确认它还是不是我之前看到的旧值如果是就替换成新值如果不是就说明期间已经有人改过本次更新失败。普通的count不是一个原子动作它至少包含读取、计算、写回三个步骤如果两个线程都读到count 0再分别写回1就会丢失一次更新。CAS 的关键区别在于“比较旧值”和“替换新值”在底层是一个不可分割的原子操作。两个线程同时执行compareAndSet(0, 1)时只可能有一个线程成功另一个线程会发现当前值已经不是0从而失败。因此真正的无锁更新通常不是单独执行一次 CAS而是配合循环while(true){intoldValueatomic.get();intnewValueoldValue1;if(atomic.compareAndSet(oldValue,newValue)){break;}}这段代码的含义是线程先基于当前值计算新值提交时检查当前值是否仍然等于旧值如果检查失败说明期间有其他线程完成了更新当前线程重新读取最新值再计算、再提交。CAS 适合的是可以重试的短操作例如计数器累加、状态位切换、引用替换、链表节点指针更新。它提供的是无锁设计中最基础的原子能力不用阻塞线程也能安全完成一次条件更新。2. CAS 的代价不阻塞不等于没有成本CAS 竞争失败后线程不会进入阻塞队列但这不代表它没有成本。失败线程仍然在运行会继续读取、计算、尝试 CAS。如果竞争很激烈CPU 时间会被大量消耗在失败重试上。更重要的是多个核心同时修改同一个变量时这个变量所在的 cache line 会在不同核心之间反复失效和转移。比如多个线程同时更新一个AtomicLong某个核心 CAS 成功后其他核心缓存中的同一条 cache line 可能失效其他核心下次再尝试更新时又要重新获取最新值。高竞争下CAS 的问题不是不安全而是所有线程都集中争抢同一个热点。所以 CAS 是无锁设计的基础但不是所有场景下的最优解。它解决的是“如何安全完成一次条件更新”并不自动解决“所有线程都抢同一个位置”的问题。后面的 Copy-On-Write、ABA 版本号和 LongAdder都是在 CAS 这个基础能力之上继续处理更具体的问题。3. Copy-On-Write替换引用型无锁CAS 更适合更新一个简单变量或一个引用。如果共享对象本身比较复杂例如 Map、List、配置表、路由表直接原地修改就会带来另一个问题读线程可能读到一个正在被修改的中间状态。Copy-On-Write 的思路是旧对象不动复制一份新对象在新对象上完成修改最后一次性替换共享引用。以本地配置缓存为例系统中维护两类配置MapString,Stringsettings;MapString,ListStringfeatureRules;其中settings保存普通配置项例如超时时间、开关状态featureRules保存某个功能对应的一组规则。读线程会频繁读取这些配置但配置更新并不频繁因此它适合作为 Copy-On-Write 的例子。刷新缓存时不应该在旧 Map 上逐个增删settings.put(timeout,3000);featureRules.get(search).add(rule-1);这样会破坏旧快照的稳定性。更合适的做法是先在局部变量里构造下一版缓存MapString,StringnextSettingsnewHashMap();nextSettings.put(timeout,3000);nextSettings.put(retry,3);nextSettings.put(gray.enabled,true);MapString,ListStringnextFeatureRulesnewHashMap();nextFeatureRules.put(search,List.of(rule-a,rule-b));nextFeatureRules.put(recommend,List.of(rule-c,rule-d));构造完成后再把外层 Map 和内层 List 都转成不可修改结构MapString,StringimmutableSettingsMap.copyOf(nextSettings);MapString,ListStringimmutableFeatureRulesnextFeatureRules.entrySet().stream().collect(Collectors.toUnmodifiableMap(Map.Entry::getKey,entry-List.copyOf(entry.getValue())));这里不能只冻结外层 Map。Map.copyOf()或Collectors.toUnmodifiableMap()只能防止调用者对 Map 做put()、remove()等操作如果 value 仍然是普通ArrayList外部仍然可以通过featureRules.get(feature).add(rule)修改内部列表。List.copyOf()的作用就是让每个功能对应的规则列表也变成只读结构。Copy-On-Write 的关键不是复制和修改速度很快而是复制和修改发生在私有副本上。副本没有发布出去之前读线程看不到它读线程要么读到旧快照要么读到新快照不会读到一个构造到一半的对象。4. 用一个 Snapshot 发布一个逻辑版本如果一次刷新要发布多个相关字段最好不要分别赋值。例如localSettingsimmutableSettings;localFeatureRulesimmutableFeatureRules;这两行在源码中虽然连续但对其他线程来说不是一个原子整体。读线程可能短暂看到如果这两个字段必须来自同一次刷新就应该把它们封装成一个快照对象publicfinalclassConfigSnapshot{privatefinalMapString,Stringsettings;privatefinalMapString,ListStringfeatureRules;publicConfigSnapshot(MapString,Stringsettings,MapString,ListStringfeatureRules){this.settingssettings;this.featureRulesfeatureRules;}publicMapString,StringgetSettings(){returnsettings;}publicMapString,ListStringgetFeatureRules(){returnfeatureRules;}}然后只保留一个共享引用privatevolatileConfigSnapshotconfigSnapshot;刷新时先构造完整的新快照最后一次性发布ConfigSnapshotnextSnapshotnewConfigSnapshot(immutableSettings,immutableFeatureRules);configSnapshotnextSnapshot;这里有三层含义设计作用volatile configSnapshot保证新快照引用对读线程可见final settings / featureRules保证快照对象构造完成后字段初始化结果稳定可见不可修改 Map / List防止快照发布后被原地修改final不是用来替换共享引用的。它的作用是固定快照对象内部字段并提供构造完成后的安全初始化语义。真正负责发布新版本的是volatile引用。读线程访问缓存时应该先读取一次快照引用再基于这个局部变量访问内部数据ConfigSnapshotsnapshotconfigSnapshot;Stringtimeoutsnapshot.getSettings().get(timeout);ListStringrulessnapshot.getFeatureRules().get(search);这样读线程要么读到旧快照要么读到新快照但快照内部的settings和featureRules来自同一个逻辑版本。5. 全量刷新和增量更新的区别Copy-On-Write 是否需要 CAS要看更新方式。本地配置刷新通常是全量重建配置中心或数据库是数据源本地缓存只是快照。多个线程同时刷新时即使后完成的刷新覆盖先完成的刷新只要它们都来自同一份外部数据源的完整配置一般可以接受。这种场景重点是volatile Snapshot的安全发布而不是 CAS。但是如果更新是基于旧快照做增量修改就不同了。假设当前配置是{timeout1000}线程 A 基于它生成{timeout1000,retry3}线程 B 也基于它生成{timeout1000,gray.enabledtrue}如果只是普通赋值最后可能只剩{timeout1000,gray.enabledtrue}A 增加的retry3被覆盖了。这种场景需要AtomicReference配合 CASprivatefinalAtomicReferenceConfigSnapshotrefnewAtomicReference(initialSnapshot);publicvoidupdateSetting(Stringkey,Stringvalue){while(true){ConfigSnapshotoldSnapshotref.get();MapString,StringnextSettingsnewHashMap(oldSnapshot.getSettings());nextSettings.put(key,value);ConfigSnapshotnewSnapshotnewConfigSnapshot(Map.copyOf(nextSettings),oldSnapshot.getFeatureRules());if(ref.compareAndSet(oldSnapshot,newSnapshot)){return;}}}AtomicReferenceT可以理解成“引用版本的 AtomicInteger”AtomicInteger原子更新数字AtomicReference原子更新对象引用。它的compareAndSet(oldRef, newRef)比较的是当前引用是否仍然指向oldRef如果是才切换到newRef。所以这两类场景要分开更新方式核心问题典型做法全量刷新发布一个完整新版本volatile Snapshot增量更新多写线程可能基于旧版本修改导致丢更新AtomicReference CAS前者更关注快照发布后者更关注旧版本是否已经过期。6. 版本号与乐观校验CAS 在写入前检查旧值是否仍然有效版本号则常用于读取后检查数据是否在读取期间发生变化。乐观读就是这种思路。线程先假设读取期间没有写入不加锁读取数据读完之后再检查版本是否变化如果版本号仍然是10说明这次读取可以接受如果版本号变化说明读取期间可能发生过写入需要重新读取或退化成加锁读。这和前文的 CAS 是同一类乐观思想只是检查时机不同思路检查时机检查内容CAS写入时检查当前值是否仍然等于旧值乐观读读取后检查读取期间版本是否变化这里不再展开StampedLock的具体 API。它在本章中的作用是为 ABA 做铺垫只比较值本身有时不够还需要额外的版本信息。7. ABA值相同不代表中间没有变化CAS 的判断逻辑是我之前看到的是 A现在仍然是 A所以可以更新。问题是“现在还是 A”不代表“中间没有变过”。可能发生过这就是 ABA 问题。它不是 CAS 更新失败而是 CAS 成功了但成功建立在一个不完整的判断之上CAS 只确认当前值和旧值相同却不知道中间是否经历过变化。经典场景出现在无锁栈中。初始结构如下线程 1 想弹出 A于是先读到oldHead A next B它准备执行CAS(head, A, B)但在线程 1 执行 CAS 之前线程 2 插入执行此时结构变成head又回到了 A但 A 后面的节点已经不是 B。线程 1 继续执行之前准备好的CAS(head, A, B)CAS 会成功因为当前head确实还是 A。成功之后结构变成问题是B 已经被线程 2 弹出却被线程 1 根据旧的next B重新接回了栈中。这里危险的不是 A 回来了本身而是线程 1 基于旧结构做了决策而 CAS 只检查了 head 是否还是 A没有检查 A 后面的结构是否仍然有效。解决 ABA 的常见思路是增加版本号把比较对象从A升级成(A, version)例如虽然值又回到了 A但版本号已经变化。线程基于(A, 1)发起 CAS 时会发现当前状态是(A, 3)从而失败。Java 中对应的工具是AtomicStampedReferenceTAtomicStampedReferenceNodeheadnewAtomicStampedReference(nodeA,1);head.compareAndSet(oldNode,newNode,oldStamp,oldStamp1);它比较的不只是引用还包括 stamp。这样就能识别“值虽然相同但中间已经变化过”的情况。8. LongAdder把单点竞争拆成多点竞争前面的 CAS 解决了单次条件更新的安全问题但高并发计数时所有线程都 CAS 同一个变量会形成单点热点。AtomicLong的模型是LongAdder的思路是把一个热点拆成多个槽最后求总数时再把base和所有Cell加起来因此LongAdder不是让 CAS 消失而是让 CAS 不再集中发生在同一个变量上。它的核心结构可以简化为低竞争时线程优先 CAS 更新base。如果base更新失败说明竞争开始变强LongAdder会初始化cells[]让线程根据自己的 probe 选择某个 Cell 更新。不同线程大概率落到不同 Cell 上CAS 失败概率就会下降。当多个线程仍然撞到同一个 Cell 时LongAdder会尝试调整 probe让线程换一个位置如果竞争持续存在还可能扩容cells[]。初始化和扩容属于结构性操作不能让多个线程同时修改数组结构因此内部会用cellsBusy这样的 CAS 标记位协调。可以把一次add(1)简化为它的设计重点是分层处理竞争操作频率处理方式更新base高频CAS更新Cell.value高频分散 CAS初始化cells[]低频cellsBusy协调扩容cells[]低频cellsBusy协调高频更新被分散到多个 Cell低频结构调整才使用全局协调点。这就是LongAdder在高并发计数场景下优于AtomicLong的主要原因。9. LongAdder 的读取不是强一致快照LongAdder.sum()不是一个原子快照。它不会在某一个瞬间冻结base和所有 Cell而是逐个读取后相加如果求和过程中仍然有线程在执行add()这次求和可能看到某些更新也可能看不到某些更新。例如sum()已经读完cell[0]后另一个线程又更新了cell[0]这次更新就可能不会包含在本次返回值中。因此LongAdder.sum()返回的是遍历过程中读到的一组值拼出来的结果而不是严格对应某一个全局时刻的精确值。它适合请求次数、QPS、监控指标、日志埋点、吞吐量统计等场景因为这些场景更关心高并发写入性能可以接受短暂不精确。但它不适合账户余额、库存扣减、订单号生成、严格限流这类强一致场景。这些场景需要每一次读取和更新都对应明确的全局顺序不能用弱一致统计值做决策。所以LongAdder的本质不是比AtomicLong更原子而是在更新路径上更会分散竞争。它用读取时的弱一致换取高并发写入时的低冲突。10. Cell 也要避免伪共享LongAdder把一个 value 拆成多个 Cell 后还要考虑缓存行问题。如果多个Cell.value在内存里挨得太近可能落在同一条 cache line 上。表面上线程更新的是不同变量但缓存层面仍然在争抢同一条 cache line。这种情况叫伪共享多个线程修改的是不同变量但这些变量位于同一条 cache line 上导致它们互相造成缓存失效。因此LongAdder的优化有两层层次解决的问题拆分 Cell避免所有线程 CAS 同一个变量缓存行隔离避免不同 Cell 在缓存层面重新互相干扰这也解释了为什么高并发计数不能只从 Java 变量层面理解还要考虑 CPU 缓存层面的竞争。LongAdder不只是把一个数拆成多个数它还要尽量让这些热点值在缓存层面互不影响。11. 小结无锁设计解决的不是竞争而是竞争失败后的处理方式本章的因果链条可以从 CAS 开始理解普通读改写会丢更新所以需要 CAS 把“比较旧值”和“替换新值”合成一个原子动作但 CAS 只提供一次条件更新的原子能力竞争激烈时仍然会退化成大量失败重试。当共享状态从简单变量变成复杂对象时原地修改会破坏读线程看到的结构稳定性于是 Copy-On-Write 选择不修改旧对象而是在局部变量中构造新快照再通过一个引用整体发布。这个思路解决的是读多写少场景下的快照稳定性而不是多写线程下的增量合并后者仍然需要 CAS 检查旧版本是否过期。CAS 的另一个边界是它只能判断当前值是否等于旧值不能判断中间是否发生过变化。ABA 正是从这个边界中出现的值回到 A并不意味着结构还停留在原来的 A。版本号把比较对象从单个值升级成“值 版本”让中间变化可以被识别。最后LongAdder 处理的是 CAS 在高并发计数下的热点问题。它没有消灭 CAS而是把单点 CAS 拆成多个 Cell 上的分散 CAS代价是读取总和时不再提供强一致快照。因此无锁设计并不是一套固定写法而是一组围绕竞争成本做出的取舍能重试就用 CAS读多写少就用快照替换需要识别中间变化就加版本号单点热点太强就拆散竞争。