美团终面:你确定CAS不加锁吗?
1. 面试现场一句回答引发的追问先还原一个真实的面试场景。面试官翻着你的简历看到你写了「熟悉 Java 并发编程、JUC 包、乐观锁」于是抛出一个看起来很基础的问题「AtomicInteger 底层是怎么保证线程安全的它加锁了吗」这一句多数候选人能答上来AtomicInteger 底层用的是 CAS也就是 Compare And Swap它是一条 CPU 原子指令不加锁所以比 synchronized 性能好。听到这里面试官往往不会点头放行而是突然追问「你确定 CAS 不加锁吗它是怎么做到原子性的如果它真的不加锁两个 CPU 核心同时修改同一个内存位置会不会出现问题」这一下就能问倒一大片。很多人背下了「CAS 是无锁的」却从没想过「无锁」这两个字的边界在哪里Java 层面的无锁和 CPU 硬件层面的有锁根本是两回事。这篇文章就沿着这个问题一路挖到底从 Java 源码、JVM 实现、汇编指令一直追到 CPU 的缓存一致性协议把「CAS 到底加不加锁」这条链路彻底讲透。2. 从「加锁」到「无锁」为什么需要 CAS要理解 CAS 的意义先要理解它要解决什么问题。在多线程环境下多个线程同时修改同一个共享变量会发生经典的「读改写」三步竞态public class Counter { private int count 0; public void increment() { count; // 读 count、加 1、写回 count这是三个独立的步骤 } }count在字节码层面会被拆成多条指令大致是从内存读取 count 到操作数栈、把值加 1、再写回内存。如果有两个线程同时执行就可能出现「两个线程都读到 5都加 1 得到 6都写回 6」的情况原本应该变成 7结果只加了 1。最直接的解决办法就是加锁用 synchronized 或者 Lock 把整段临界区保护起来public class LockedCounter { private int count 0; public synchronized void increment() { count; // 同一时刻只有一个线程能进这个方法 } }加锁确实能解决问题但它有代价线程阻塞与唤醒竞争不到锁的线程会被挂起进入阻塞态操作系统要进行用户态到内核态的切换保存恢复线程上下文这是一笔不小的开销。上下文切换线程切换本身就是耗时的而且切换时机不可控。死锁风险多把锁、嵌套加锁、锁顺序不当都可能引发死锁这类问题很难排查。持锁阻塞持有锁的线程如果因为缺页、GC、调度等原因被暂停其他线程只能干等。于是人们开始想能不能不阻塞线程用「不断尝试、冲突就重来」的思路保证原子性这就是乐观锁和无锁编程的出发点。乐观锁默认冲突很少发生先不加锁直接操作操作完成前检查一下有没有人动过数据如果有人动过就重试。CAS 就是实现这种「检查并交换」语义的原子操作源头。一句话总结加锁解决的是「互斥」而 CAS 走的是「冲突检测 自旋重试」的另一条路。它不是为了替代所有锁而是为了在短临界区、低冲突的场景下用更轻量的方式换取更高的吞吐。3. CAS 是什么一条指令的原子操作CAS 的全称是 Compare And Swap字面意思是「比较并交换」。它接收三个操作数内存位置 VVariable也就是要修改的变量地址期望的旧值 AExpected要写入的新值 BNew。它的语义可以用这样一段伪代码描述// 伪代码这一段在 CPU 层面是一条不可分割的原子指令 boolean compareAndSwap(内存位置 V, 期望值 A, 新值 B) { if (V 当前的值 A) { V B; // 当前值等于期望值说明没人改过放心写入 return true; // 交换成功 } else { return false; // 当前值已经被别人改了交换失败不做写入 } }关键就在这里「比较 V 是否等于 A」和「把 B 写入 V」这两个动作合并成了一个原子操作。要么一起成功要么一起失败中间不会被其他线程插入。判断的依据不是「谁抢到了锁」而是「我说好的旧值还是不是现在这个值」。这也是 CAS 名字的精妙之处它不是在跟别的线程抢资源而是在跟这个内存位置的历史状态做确认。用它来实现递增操作就变成了「不断自旋直到成功」的模式// 用 CAS 表达「读改写」的伪代码 int oldValue, newValue; do { oldValue V; // 1. 读出当前值 newValue oldValue 1; // 2. 在本地算出新值 } while (!compareAndSwap(V, oldValue, newValue)); // 3. 如果期间 V 被其他线程改了CAS 返回 false回到第 1 步重试注意这种「自旋 CAS」在 Java 的并发库里极其常见Unsafe 里的getAndAddInt就是照这个套路写的后面的源码分析会专门讲到。4. CAS 在 Java 中的实现从 Unsafe 到原子类Java 并没有直接暴露 CPU 的 CAS 指令而是通过一层层的封装把它藏了起来。完整链路是AtomicInteger 等原子类 → sun.misc.Unsafe 的 native 方法 → JVM 的原子函数 → CPU 的 lock cmpxchg 指令。下面顺着这条链路一层层往下看。4.1 第一层AtomicInteger 对外接口先看最常用的AtomicInteger。以 JDK 8 为例它的incrementAndGet()是这样实现的// JDK 8 中 AtomicInteger 的核心字段与本方法简化展示 public class AtomicInteger extends Number implements java.io.Serializable { private static final Unsafe unsafe Unsafe.getUnsafe(); // valueOffset 记录了字段 value 在该类对象中的内存偏移量 private static final long valueOffset; private volatile int value; static { try { valueOffset unsafe.objectFieldOffset( AtomicInteger.class.getDeclaredField(value)); } catch (Exception ex) { throw new Error(ex); } } public final int incrementAndGet() { return unsafe.getAndAddInt(this, valueOffset, 1) 1; } }注意两个点真正保存数值的字段value是用volatile修饰的保证内存可见性任何线程都能读到最新值valueOffset是 value 字段的内存偏移量通过objectFieldOffset计算得到。Unsafe 的 CAS 方法操作的是「对象 偏移量」而不是直接操作字段本身。4.2 第二层Unsafe 的自旋 CAS继续追unsafe.getAndAddInt。JDK 8 中它的实现是这样的// sun.misc.UnsafeJDK 8 源码简化 public final int getAndAddInt(Object o, long offset, int delta) { int v; do { // 先以 volatile 语义读出当前值 v getIntVolatile(o, offset); // 然后尝试 CAS从 v 改成 v delta失败说明中间值变了继续循环 } while (!compareAndSwapInt(o, offset, v, v delta)); return v; }这就是前面那个自旋伪代码的真身。它没有加任何 synchronized 或者 ReentrantLock全程只是「读旧值 → 尝试 CAS → 失败就重试」。从这段 Java 代码看确实没有任何锁。这是很多人说「CAS 不加锁」的直接依据。4.3 第三层native 方法进入 JVMcompareAndSwapInt是一个 native 方法声明如下public final native boolean compareAndSwapInt(Object o, long offset, int expected, int x);这里的语义和前面完全一致o是目标对象offset是字段在对象内的偏移量两者合起来定位内存位置 Vexpected是期望的旧值 Ax是要写入的新值 B。方法名里带的Int表示它只处理 4 字节的 int。Unsafe 还提供了一整套类似方法compareAndSwapObject、compareAndSwapLong以及带Volatile后缀、用来保证读写可见性的版本。它们的 native 实现最终都会汇聚到 JVM 的原子操作函数。4.4 第四层JVM 的原子函数HotSpot 虚拟机内部compareAndSwapInt会调用到Atomic::cmpxchg这个原子操作函数。以 x86 架构为例这个函数最终由一条汇编指令实现大致是lock cmpxchg [目标内存地址], 新值寄存器或者用更接近 C 内联汇编的写法// x86 平台上 Atomic::cmpxchg 的核心逻辑示意 __asm__ volatile (lock cmpxchgl %1,(%3) : a(old_value) : r(new_value), a(expected_value), r(address) : memory);到这里事情开始变得有意思了——因为出现了一个非常关键的前缀lock。这个前缀正是「CAS 到底加不加锁」这个问题的答案所在。下一节就重点拆解它。5. CAS 在 CPU 层面到底做了什么这条指令其实「有锁」很多文章讲到这里会直接说「cmpxchg 是一条原子指令硬件保证原子性」然后戛然而止。但面试官追问的恰恰就是这一层硬件凭什么保证原子性答案就是那个不起眼的lock前缀。lock不是 Java 里的锁也不是操作系统级的互斥量它是 CPU 提供给指令的一个总线锁 / 缓存锁标志。带上它之后这条「读内存、比较、写内存」的复合操作才真正变成了原子操作。5.1 为什么 cmpxchg 单独一条指令还不够原子直觉上「cmpxchg 是一条指令」一条指令应该天然原子才对。但这个直觉在现代 CPU 上并不成立。现代 CPU 是乱序执行、流水线、多发射的一条复杂指令在微架构内部会被拆成多个微操作micro-ops其中包含读内存和写内存两个阶段。如果不做特殊处理在多核环境下同样可能出现核心 0 读的时候核心 1 也在读两边都读到同一个旧值然后各自写回——竞态又回来了。所以单纯的cmpxchg并不保证跨核心的原子性真正起作用的配套机制是lock前缀。5.2 lock 前缀做了三件事给cmpxchg加上lock前缀后CPU 会执行以下动作共同保证原子性锁定缓存行或总线在较老的 CPU 上lock会直接锁住内存总线保证该指令执行期间自己是唯一访问内存的主体现代 CPU 则优先锁缓存行。如果目标数据正好在缓存中且没有跨越缓存行边界就只锁这一条缓存行否则退化为锁总线。保证读改写不可分割在锁住缓存行的窗口内读内存、比较、写内存这三个动作对外表现为一个整体其他核心无法插入任何访问。与缓存一致性协议联动执行lock指令时会把该缓存行标记为独占/修改状态并让其他核心中同样的缓存行失效从而阻止它们拿到过期数据。5.3 所以CAS 到底加没加锁现在可以正面回答这个问题了。这个问题的标准答案其实是一个分层的答案在 Java 语言和操作系统线程的层面上CAS 不加固有锁。它没有 synchronized 的监视器monitor没有 ReentrantLock 的同步队列线程不会被挂起阻塞也不存在「谁持有锁」的概念。但在 CPU 硬件层面CAS 确实「锁」了东西。它通过lock cmpxchg指令锁定缓存行个别情况下锁总线从而用极短的一段硬件独占时间换取原子性。这两个层面并不矛盾Java 里说的「无锁」指的是无操作系统级线程锁、无阻塞、无临界区所有权而 CPU 层面的「锁缓存行」是硬件为了原子性必须付出的代价。它锁的对象不是线程而是一段极短的内存访问窗口粒度小到一条缓存行时间短到一条指令远不是「加锁阻塞线程」那个概念。换个更形象的说法synchronized 是「一整条马路封路一晚上」而 CAS 的 lock 前缀是「红灯亮了几纳秒只拦了你脚下这一条车道」。两者都叫「不让别人过」但成本完全不在一个数量级。6. 缓存一致性协议MESI 如何配合 CAS上一节提到 lock 前缀会「让其他核心的缓存行失效」这背后离不开 CPU 的缓存一致性协议。理解 MESI 是回答「CAS 不不加锁」这道题躲不开的一环也是面试官经常会顺着往下问的点。6.1 为什么需要缓存一致性协议现代 CPU 每个核心都有自己的 L1、L2 缓存多个核心共享 L3 和主存。同一个变量可能在核心 0 的 L1 缓存里有一份在核心 1 的 L1 缓存里也有一份。如果核心 0 修改了这个变量核心 1 手里的副本就过期了。要让所有核心看到一致的数据就需要一套协议来协调缓存行状态。x86 上主流的就是基于 MESI 的变体实际实现更复杂常称为 MOESI / MESIF但 MESI 是理解的基础。6.2 MESI 的四种状态MESI 是四种缓存行状态的缩写每个核心缓存中的每条缓存行都处于以下状态之一MModified已修改缓存行只存在于当前核心且已经被修改过与主存不一致数据以当前缓存为准。EExclusive独占缓存行只存在于当前核心且与主存一致。当前核心可以放心修改它修改后进入 M 状态。SShared共享缓存行可能同时存在于多个核心且都与主存一致。任何核心不能直接修改必须先让其他缓存行失效。IInvalid无效缓存行无效不能被使用下次访问需要重新从主存或其他核心读取。6.3 一次 lock cmpxchg 在缓存层面发生了什么假设核心 0 要对某个变量执行lock cmpxchg整个过程在缓存层面大致分为以下几步发出独占请求核心 0 先要把目标缓存行拿到自己手里并请求该缓存行的独占权。让其他副本失效缓存一致性协议会向其他核心广播失效消息把它们缓存中同一条缓存行从 SShared共享状态改为 IInvalid无效状态。取得 M 状态并锁定核心 0 拿到独占权后把缓存行置为 EExclusive独占或直接进入 MModified已修改状态然后由lock前缀锁定这条缓存行保证后续读改写期间其他核心无法插入。完成读改写在锁定窗口内核心 0 读出当前值、与期望值比较如果相等就写入新值。写入后缓存行从 E 进入 M 状态表示该数据以本核心的缓存为准。释放锁定指令执行完成后释放缓存行锁。其他核心如果接下来要访问这个变量会因为本地缓存行处于 I 状态而重新从核心 0 或主存加载最新值。可以看到lock cmpxchg的原子性不是凭空来的而是三套机制协同的结果lock前缀锁定缓存行、缓存一致性协议让其他副本失效、核心内部的读写窗口被合并为一个整体。理解到这一层才能完整回答「CAS 到底加没加锁」。7. 总结把「CAS 加不加锁」的答案重新说一遍现在把整条链路串起来给出一个能直接搬进面试的回答框架。Java 源码层AtomicInteger 通过volatile加 Unsafe 的自旋 CAS 实现没有 synchronized线程不会被挂起阻塞因此叫「无锁」。JVM 层Unsafe 的compareAndSwapInt是 native 方法最终下沉到 JVM 的Atomic::cmpxchg。CPU 指令层Atomic::cmpxchg编译为lock cmpxchg靠lock前缀保证读改写的原子性。缓存一致性层lock前缀配合 MESI 协议让其他核心的缓存行失效只用极短的硬件独占时间完成更新。一句话收束CAS 在应用层、语言层不加锁在 CPU 硬件层则借用了lock前缀锁缓存行的机制。日常说的「无锁」是相对 synchronized 那种会挂起线程的互斥锁而言并不是说硬件什么都没做。8. 延伸面试官还可能追问 CAS 的三大问题如果前面的问题你都能答上来面试官通常会继续考察 CAS 的局限。这里简单列出三个高频追问建议一并准备。8.1 ABA 问题CAS 只比较「值是否还是 A」但如果一个变量从 A 被改成 B又从 B 改回 ACAS 依然会判断成功。对于引用类型对象其内容可能已经变了比较结果却失真。通常的解法是引入版本号或时间戳例如使用AtomicStampedReference来区分「值相同但版本不同」的情况。8.2 自旋开销高冲突场景下CAS 失败的线程会一直空转重试白白消耗 CPU。因此 CAS 更适合临界区短、冲突概率低的场景冲突高时反而要回归锁或者引入退避策略来降低空转成本。8.3 只能保证一个共享变量的原子性单个 CAS 只能操作一个内存变量。涉及多个变量的复合更新时需要借助AtomicReference包装聚合对象或者用锁来维护整体原子性。把这些问题准备清楚并配合前面的分层解释就能从容应对「AtomicInteger 加锁了吗」系列追问。

相关新闻

Python aihems-pkg 包实战案例与常见错误

Python aihems-pkg 包实战案例与常见错误

1. 引言aihems-pkg 是一个面向 Python 开发者的实用工具包,旨在简化 AI 辅助的工程管理、数据清洗与自动化脚本编写流程。它封装了常见的文件处理、配置解析、日志记录和轻量级 AI 接口调用能力,让开发者可以用更少的样板代码完成更多工作。本文将从功能…

2026/10/9 2:50:46 阅读更多 →
【AI大模型】量化加速:不同量化方案的速度与精度对比

【AI大模型】量化加速:不同量化方案的速度与精度对比

【AI大模型】量化加速:不同量化方案的速度与精度对比 核心结论:量化是降低显存、提升推理速度的关键手段,但不同量化方案在速度与精度上取舍不同。主流方案包括:INT8权重量化、INT4权重量化(含GPTQ、AWQ)、动态量化、混合精度(FP8/FP16)等。总体上,量化位数越低,显存…

2026/10/9 2:50:46 阅读更多 →
QCC5229(ADK)耳机提示音(Tones / Prompts)配置与使用深度解析:ringtone_note 编码、双通路播放链与 TWS 双耳同步

QCC5229(ADK)耳机提示音(Tones / Prompts)配置与使用深度解析:ringtone_note 编码、双通路播放链与 TWS 双耳同步

QCC5229(ADK)耳机提示音(Tones / Prompts)配置与使用深度解析:ringtone_note 编码、双通路播放链与 TWS 双耳同步 适用读者:基于高通 ADK(QCC5229 等 earbud 应用工程)进行 TWS 蓝牙耳机开发的嵌入式软件工程师。 分析基线:earbud/src/earbud_tones.c、earbud/src/ear…

2026/10/9 2:50:46 阅读更多 →

最新新闻

寄件小程序前端实战:便捷寄件与省心体验的关键设计

寄件小程序前端实战:便捷寄件与省心体验的关键设计

最近在几个快递点来回跑,还是忍不住感叹一句:寄件这件事,看起来就是“填单—等人—拿走”三步,实际上最让人上火的从来不是快递员,而是填地址那一长串表单、不知道选哪家便宜、约了上门时间结果一整天不敢出门。这也是…

2026/10/9 3:26:09 阅读更多 →
桌面运维必备:30条Win+R运行命令速查,系统信息、网络排查、硬件驱动一篇搞定

桌面运维必备:30条Win+R运行命令速查,系统信息、网络排查、硬件驱动一篇搞定

做了这么多年桌面运维,我发现自己平时用得最多的不是那些花里胡哨的第三方工具,而是 WinR 这个组合键。接到“电脑卡了”“上不了网”“软件打不开”的工单,我第一步基本都是按 WinR,敲一条命令,先把系统状态摸清楚。今…

2026/10/9 3:26:09 阅读更多 →
Vue3 + Electron + Vite 从0到1搭建桌面客户端全攻略

Vue3 + Electron + Vite 从0到1搭建桌面客户端全攻略

最近在帮一个朋友搭桌面客户端,技术栈选了Vue3 Electron Vite,整个过程踩了不少坑,也梳理出了一套相对顺手的搭建流程。所以这期就准备把“从0到1搭建项目”的第一期完整记录下来:怎么初始化工程、怎么把Vite的dev server和Elec…

2026/10/9 3:26:09 阅读更多 →
RISC-V电源管理实战:WFI、SBI CPPC与Linux cpufreq协同调频指南

RISC-V电源管理实战:WFI、SBI CPPC与Linux cpufreq协同调频指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/9 3:26:09 阅读更多 →
Unity UGUI实战避坑指南:Canvas、适配与性能优化核心解析

Unity UGUI实战避坑指南:Canvas、适配与性能优化核心解析

做Unity开发这几年,UGUI算是我用得最多、也踩坑踩得最狠的系统。刚接触时以为它就是摆摆UI组件、写写点击回调,直到后来负责某项目的UI框架和性能优化才发现,UGUI这两个看似简单的字背后,藏着一整套Canvas组织、RectTransform适配…

2026/10/9 3:26:09 阅读更多 →
MCP协议实战:从原理到搭建,让AI工具调用讲同一种普通话

MCP协议实战:从原理到搭建,让AI工具调用讲同一种普通话

做AI工具集成这一年多,我最大的感受就是“工具多到用不过来,但全都各说各话”。每个模型有自己的一套工具调用方式,每家平台有一套插件协议,连个数据库都要单独写适配代码。直到MCP(Model Context Protocol&#xff0c…

2026/10/9 3:25:09 阅读更多 →

日新闻

Java时间API实战:LocalDate、Date与ZonedDateTime的转换与避坑指南

Java时间API实战:LocalDate、Date与ZonedDateTime的转换与避坑指南

Java时间API这个话题,隔三差五就会在群里被翻出来讨论一次。上周还有个同事线上处理一个订单超时问题,排查到最后发现是ZonedDateTime序列化后时区丢了,用户在下单当天晚上看到的时间整整差了8个小时。这类问题几乎每个做Java开发的人都遇到过…

2026/10/9 0:00:49 阅读更多 →
EasyTier实践:从NAT穿透到子网代理的异地组网部署与排错

EasyTier实践:从NAT穿透到子网代理的异地组网部署与排错

前几个月我手头有好几台机器需要互相访问:办公室台式机、家里 NAS、还有一台云主机。如果只是偶尔传个文件倒还好,问题是工作场景经常要在几处环境之间来回切换,每次都先登录跳板机再层层代理,实在折腾。我先后试过端口映射、自建…

2026/10/9 0:00:49 阅读更多 →
AI Agent工程实战:从七要素到七个决策点的系统设计指南

AI Agent工程实战:从七要素到七个决策点的系统设计指南

AI Agent 这个词在过去一年里被反复提及,但真正动手搭过一套能跑起来的 Agent 系统的人都知道,从"知道它是什么"到"让它稳定干活"之间隔着一整套工程决策。我前后参与过几个 Agent 项目的落地,从最初用现成框架拼装&…

2026/10/9 0:01:50 阅读更多 →

周新闻

KT148A语音芯片外挂8002D功放的工程实践指南

KT148A语音芯片外挂8002D功放的工程实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/8 15:26:32 阅读更多 →
LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/8 15:26:40 阅读更多 →
ARM架构深度解析:从RISC设计理念到交叉编译实战

ARM架构深度解析:从RISC设计理念到交叉编译实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/8 10:10:36 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/8 21:13:17 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/8 15:26:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/7 13:34:55 阅读更多 →