线程间共享数据全解析:从竞态条件到无锁并发

线程间共享数据全解析:从竞态条件到无锁并发 写在前面这章我读了不下五遍才真正理解共享二字的份量说来惭愧我最早接触线程间共享数据是在一个压力测试的深夜。当时用C写了一个多线程日志系统多个工作线程往同一个日志缓冲区写数据跑单线程一切正常一旦开8个线程日志内容就开始出现乱码、丢行甚至偶发崩溃。我第一反应是缓冲区开小了调大之后问题依旧。后来翻遍资料才意识到问题的根源根本不是容量而是多个线程同时访问同一块数据这件事本身在底层有太多看不见的规则。那句线程间共享数据是并发编程的核心难题我是用一晚上的崩溃日志换来的深刻理解。这篇归纳总结算是我重新梳理这一章的心得笔记。内容会覆盖共享数据为什么会出问题、互斥与同步机制的设计逻辑、死锁的根因和排查、线程安全的容器与生产者消费者模型以及C、Java、FreeRTOS等不同环境下的实践差异。不管你是刚学操作系统原理的学生还是在工作中被多线程bug折磨的开发者这篇文章的每一段都是先讲清楚为什么再给出怎么做的思路希望能帮你少走一些弯路。1. 为什么共享数据是并发编程一切问题的根源这一节我们先不急着上锁和原子操作而是把共享数据为什么会出问题这件事的底层逻辑掰开揉碎。很多初学者问不就是多个线程读写同一个变量吗加个锁不就完了问题远没有这么简单——因为共享这个词在计算机体系结构中的含义和我们在代码层面理解的不完全一样。1.1 从进程到线程共享内存带来的双刃剑先退一步看进程与线程的区别。进程是资源分配的最小单位每个进程拥有独立的地址空间彼此之间的数据天然隔离线程是CPU调度的最小单位同一个进程内的线程共享该进程的地址空间、全局变量、堆内存、文件描述符等资源。这种共享带来的好处很明显线程间通信不需要像进程那样走管道、消息队列、共享内存等复杂的IPC机制直接读写同一个变量就行效率高出好几个数量级。坏处也同样明显正因为所有线程都能直接访问同一块数据一旦访问时机不受控制就会产生竞争。打个比方多个进程的关系就像不同房间的住户各自家里放各自的东西互相不干扰而多个线程的关系就像一家人住在同一套房子里客厅只有一台电视谁都想看自己想看的频道如果没有协商机制遥控器就会在你按一下我按一下之间被抢来抢去最后谁都没看成。这里要注意一个关键点线程共享的是地址空间但每个线程依然有自己私有的东西最典型的就是栈空间和寄存器。线程私有的栈决定了局部变量天然是线程安全的而全局变量、静态变量、堆上动态分配的对象则是共享数据的高发区。我在实际项目里排查多线程问题时第一步永远是先问一句这个变量是线程私有的还是线程共享的如果它被多个线程同时读写了那就进入下一步——分析读写模式。1.2 竞态条件看似正确实则随机的结果竞态条件Race Condition这个词教科书定义是程序的执行结果取决于多个线程的相对执行顺序。听起来很学术我用一个极简的例子说明。假设两个线程同时对同一个整数变量执行count操作你直觉认为结果是count增加2但实际上结果可能是1。原因在于count在底层绝不是一条指令而是至少三步操作读取count的当前值到寄存器、在寄存器中加1、把新值写回count对应的内存地址。当线程A执行完第一步读取到count5后操作系统把CPU切换给了线程B线程B也读取到count5、加1、写回6然后线程A恢复执行它手上持有的还是旧的5加1后写回6。于是两个线程各自加了一次最终结果却是6而不是7中间丢失了一次更新。这就是竞态条件的典型症状单线程下逻辑绝对正确多线程下结果变得不可预测而且往往是小概率偶发——这恰恰是它最可怕的地方。之前有个朋友跟我抱怨说他的服务在测试环境怎么压测都正常上线后高峰期偶发数据错乱定位了几个星期最后才发现是一个全局计数器被多个线程无保护地自增。这种问题不是代码写错了而是代码没有按并发规则写属于并发编程最隐蔽的一类缺陷。2. 三个并发bug的典型症状原子性、可见性、有序性理解了竞态条件的来源之后我们要把问题拆成三个具体的维度。业内通常用三个性质来衡量一段并发代码是否正确原子性Atomicity、可见性Visibility、有序性Ordering。这三个词是所有共享数据问题的病根分类定位bug时先判断属于哪一类能大幅缩小排查范围。2.1 原子性操作被拆散后的混乱原子性的含义是一个操作要么全部执行成功要么全部不执行中间不可能被其他线程插入。count之所以会出问题就是因为它不是一个原子操作。反观32位整数在大多数平台上的单个读取或单个写入底层往往是一条指令就能完成的这类操作天然具备原子性。但不是所有操作都这么幸运。实际开发中最容易踩坑的是先检查再执行check-then-act模式典型场景是懒加载单例线程A判断某个指针为空准备创建对象但在它创建完成之前线程B也判断指针为空也去创建了一个对象结果两个线程各创建一份最终只有一个能被赋值另一个就泄漏了。再比如读取-修改-写入read-modify-write模式除了计数器自增还有往链表头部插入节点、从队列中取出并删除元素等这些复合操作如果不加保护在并发下就是一颗定时炸弹。判断一个操作是否具备原子性不能看代码写了几行而要看它编译成汇编后由几条指令完成以及这些指令之间有没有可能被线程调度打断。i这种一行代码都可能是非原子的这是新手最容易误解的地方。2.2 可见性编译器与CPU优化带来的幻觉原子性解决的是操作会不会被打断的问题可见性解决的则是一个线程的修改另一个线程什么时候能看到的问题。听起来有点反直觉同一个变量线程A改了线程B居然可能看不到原因在于现代计算机的多层缓存架构。每个CPU核心都有自己的L1、L2缓存变量在内存中的值会被拷贝到缓存中使用。线程A在核心1上修改了变量写入了核心1的缓存但如果没有同步机制这个修改可能暂时不会刷新到主内存线程B在核心2上读取时从自己的缓存里读到的是旧值。这就是可见性问题的来源。在C和Java这样的语言里编译器还会做指令重排序和优化。比如编译器发现某个变量在当前线程内没有被修改可能直接把读取结果缓存到寄存器里导致其他线程已经改了内存这个线程还是读寄存器里的旧值。经典案例就是Java多线程中的死循环不退出问题一个线程修改了标记变量另一个线程在循环里始终看不到修改程序永远停不下来。解决可见性的核心手段是使用volatile关键字或者同步原语锁它们在底层会插入内存屏障指令强制缓存与主内存同步。2.3 有序性指令重排序与内存模型有序性问题相对最难理解它说的是代码写的顺序不一定是CPU实际执行的顺序。为了提升流水线效率编译器和CPU都可能对没有数据依赖的指令进行重排。在单线程环境下重排序不影响最终结果但在多线程共享数据时重排序可能导致另一个线程观察到不可能出现的中间状态。经典的例子是双重检查锁定Double-Checked Locking模式中的对象发布问题。代码逻辑是先检查指针是否为空为空则加锁加锁后再检查一次然后创建对象。看起来万无一失但在早期Java和某些C编译环境下由于创建对象的步骤分配内存、调用构造函数、赋值给指针可能被重排序为先赋值指针、再调用构造函数另一个线程就会在加锁前看到非空的指针并直接使用一个还没构造完成的对象程序随即崩溃。有序性问题催生了内存模型这一概念。Java内存模型JMM和C11内存模型都定义了各种同步操作之间的先行发生happens-before关系只有通过这些规则程序员才能确定代码的可见性和有序性边界。这一块我建议不要死记硬背核心记住一条经验只要多个线程共享了数据就不要依赖代码书写顺序来保证正确性一切以语言规范中的同步语义为准。3. 互斥与同步给共享数据装上通行证前面把问题讲透了接下来进入解决方案。线程间共享数据的安全手段可以大致分成两类互斥Mutual Exclusion和同步Synchronization。互斥保证同一时间只有一个线程能访问数据同步保证线程之间的执行顺序符合预期。这两者常常配合使用但本质目标不同。3.1 互斥锁临界区的正确打开方式互斥锁Mutex是最基础也最常用的手段。它的使用模式非常简单在访问共享数据的代码段即临界区之前加锁访问结束后解锁。任何一个时刻只能有一个线程持有锁其他尝试加锁的线程会被阻塞直到锁被释放。这里要强调几个关键实践经验。第一锁的保护范围要正确覆盖所有访问共享数据的路径漏掉任何一条路径锁就形同虚设。第二临界区尽量短只把真正操作共享数据的代码包在锁里其他耗时操作网络请求、磁盘IO、复杂计算应该放在锁外否则会严重拖累并发性能。第三务必保证解锁操作在异常情况下也能执行C中用std::lock_guard或std::unique_lock的RAII机制Java中用synchronized或try-finally包裹lock()和unlock()避免死锁或锁泄漏。选锁还有一层性能考量。读多写少的场景下普通互斥锁会让所有读线程互相排斥但读操作之间本可以并发。这时可以用读写锁std::shared_mutex、Java的ReentrantReadWriteLock允许多个读线程同时持有锁只有写线程需要独占。但读写锁也不是银弹它的实现更复杂在写操作频繁的场景下甚至可能比普通互斥锁更慢选型时要结合实际的读写比例来评估。3.2 条件变量等待与通知的协作场景互斥锁解决的是不能同时访问的问题但很多场景还需要等到某个条件满足再继续。最典型的就是生产者-消费者模型消费者线程想要从队列里取数据但队列是空的它不应该反复加锁查询这会浪费CPU而应该让出CPU等待生产者往队列里放入数据后通知它。条件变量Condition Variable就是为这个场景设计的。它必须和互斥锁配合使用核心操作有三个wait、notify_one、notify_all。wait的操作有点绕但极其重要它会原子性地执行释放锁 进入等待状态两步被唤醒后再重新获取锁。为什么必须原子性因为如果先释放锁、再去等待中间就会产生一个时间窗口生产者在这个窗口内发送通知会丢失消费者就永远等不到通知了。这里有个非常经典的坑条件变量的wait必须放在一个while循环里而不是用if判断。原因是可能存在伪唤醒spurious wakeup线程可能在没有收到通知的情况下被唤醒另外即使收到了通知也可能是多个消费者同时被唤醒其中一个已经把数据取走了另一个醒来后发现条件又不满足了。所以标准写法是std::unique_lockstd::mutex lock(mtx); while (queue.empty()) { cond.wait(lock); // 释放锁并等待被唤醒后重新获得锁 } auto data queue.front(); queue.pop();3.3 原子操作与CAS无锁世界的轻量方案锁虽然强大但有两个天生的缺点一是线程在锁上阻塞和唤醒的代价较高二是高并发下锁竞争会导致线程排队反而拉低吞吐量。对于诸如计数器、标志位、单一变量更新这类简单场景完全可以用原子操作替代锁性能可以提升好几倍。原子操作的本质是硬件层面提供的指令支持比如x86架构下的LOCK前缀指令、std::atomic在C中的封装、Java中的AtomicInteger等。它们能保证一个操作在硬件层面不可分割同时附带内存屏障效果解决可见性问题。最核心的原子原语是CASCompare-And-Swap比较当前值是否等于预期值相等则交换为新值不相等则失败重试。大量的无锁数据结构包括各类无锁队列、无锁栈都是基于CAS构建的。我从实际项目中得到的经验是原子操作适合保护单个变量级别的数据比如计数器、序列号生成器、状态切换标志但一旦共享数据的操作逻辑超过一个变量比如需要同时修改两个互相约束的字段原子操作就不够用了还是要回到锁的怀抱。无锁编程是性能极致场景下的高级话题对绝大多数业务系统来说把锁用对、用细比强行无锁更稳妥。4. 死锁共享数据最常见的事故现场如果说竞态条件是并发问题的隐形杀手死锁就是显形事故。死锁一出现程序通常直接卡死CPU占用率可能跌到0所有相关线程都处于阻塞状态问题非常容易定位但处理起来往往很棘手。4.1 死锁的四个必要条件教科书上管死锁的成因叫四个必要条件互斥条件、持有并等待条件、不可剥夺条件、循环等待条件。翻译成人话就是多个线程各自持有一把锁同时在等待对方释放另一把锁谁也不让谁于是大家永远卡住。我见过最多的死锁场景是嵌套锁线程A持有锁1想要获取锁2线程B持有锁2想要获取锁1。两个线程在锁的获取顺序上正好相反就形成了循环等待。听起来有点傻但在真实代码中非常容易发生尤其是两个不同模块各自维护自己的锁又需要调用对方接口时锁的顺序往往难以全局把控。Java中还有一种特殊的可重入机制需要注意。synchronized和ReentrantLock默认都是可重入的也就是同一个线程可以重复获取自己已经持有的锁。可重入避免了自己锁死自己的问题但也可能掩盖代码结构问题——如果一个锁被同一个线程递归持有过多层容易让人忽视临界区过大的隐患。4.2 一个真实的死锁复现与排查链路说一个我印象深刻的真实案例。一个金融交易系统有两个核心服务订单服务和账户服务。订单服务在处理交易时先加订单锁再调用账户服务扣减余额账户服务在更新余额时先加账户锁再回调订单服务查询订单状态。单机部署时一切正常后来为了扩展性做了多线程并发改造高峰时开始频繁出现挂起。排查过程我是这样做的第一步获取线程转储thread dump。Java环境用jstackC环境用gdb附加到进程观察所有线程的栈信息和锁等待关系。转储显示一批线程阻塞在账户服务.更新余额的锁获取处另一批阻塞在订单服务.回调查询的锁获取处典型的ABBA模式。第二步梳理调用链画出每个线程持有什么锁、等待什么锁。这一步至关重要不要靠猜要把转储中的锁ID对应到代码里的具体锁对象。第三步确认锁获取顺序。订单服务持有的锁是订单锁 - 账户锁的顺序账户服务持有的锁是账户锁 - 订单锁的顺序正好相反。第四步修复方案有两个方向。要么调整代码让所有线程都按同样的顺序获取锁这里需要重构账户服务的回调逻辑改为异步要么使用tryLock加超时机制获取不到锁就释放已有锁重试或降级。最终我们选择了调整锁顺序 引入超时重试的双保险方案。这整个排查链路从现象到转储到根因到修复其实并不复杂难的是你要有系统性的方法而不是东改一行西试一处。4.3 预防死锁的实用策略根据我的经验预防死锁有几个务实策略按优先级排序避免嵌套锁能不用嵌套锁就不用实在需要嵌套确保所有线程以全局一致的顺序获取锁。使用锁排序给锁定义全局编号规定必须先获取编号小的锁、再获取编号大的锁从根上消除循环等待。加锁超时机制Java的tryLock(timeout)、C的try_lock_for获取不到锁就回退避免无限期等待。缩小锁粒度锁持有的时间越短死锁窗口越小并发性能也越高。用更高层的并发组件很多场景下直接使用线程安全的队列如ConcurrentLinkedQueue、并发Map如ConcurrentHashMap来代替手工加锁的集合操作能自然规避大部分锁顺序问题。死锁这个话题永远不是遇到才学而是提前预防。每次我review别人的并发代码第一眼一定看锁的获取顺序是否一致这一眼就能排除一半以上的死锁风险。5. 线程安全的数据结构与生产者消费者模型处理共享数据除了自己加锁同步还有一个更省心的思路使用现成的线程安全数据结构。这也是现在主流语言和库的方向——与其让业务代码处处加锁不如把并发安全封装进容器内部。5.1 从裸数据结构到线程安全包装先明确一个概念标准库里的std::vector、std::mapJava里的ArrayList、HashMap在单线程下好用在多线程下并不安全。它们内部没有同步机制多个线程同时修改轻则数据损坏重则内存崩溃。解决思路一般有三条路。第一外部加锁由业务代码保证访问时加同一个锁。这是最朴素的方式缺点是容易漏加锁而且把同步责任推给了调用方。第二使用语言提供的同步包装器比如Java的Collections.synchronizedList它内部把所有方法都加了synchronized但锁的粒度较粗并发性能一般。第三使用专门设计的并发容器比如Java的ConcurrentHashMap、CopyOnWriteArrayList、C社区常见的tbb::concurrent_queue、Intel TBB库中的各种并发容器这些容器在内部通过分段锁、CAS、无锁算法等手段把锁粒度降到最低并发性能显著提高。选择哪种方案取决于读多写少还是写多读少、容器规模、并发量级。我个人的准则是性能敏感且并发量高的核心路径优先使用专业并发容器并发量不高的模块简单加锁就够了过度设计反而增加维护成本。5.2 生产者-消费者最经典的共享数据协作范式生产者-消费者模型是线程间共享数据最经典的应用场景几乎所有并发编程教程都会讲但真正在工程中做好并不容易。这个模型的本质是生产者线程产生数据放入缓冲区消费者线程从缓冲区取出数据进行处理。缓冲区的存在一方面解耦了生产者和消费者的处理速度另一方面也承担了共享数据的安全责任。实现上有两个关键点。第一缓冲区本身必须线程安全这可以用互斥锁条件变量实现也可以直接用无锁并发队列。第二要处理缓冲区满和缓冲区空的边界条件。缓冲区满时生产者要阻塞等待消费者消费缓冲区空时消费者要阻塞等待生产者生产。如果没有这两个等待机制就会变成忙等待busy-waitingCPU被无谓消耗。我之前在一个爬虫系统里用C实现过一个有界阻塞队列模板类的核心代码大约六七十行用std::mutex、std::condition_variable和std::queue组合而成带容量上限满时push阻塞直到有空间空时pop阻塞直到有数据。这个组件后来被多个服务复用稳定运行了很久。如果你不想重复造轮子Java自带的ArrayBlockingQueue、LinkedBlockingQueue是现成的有界/无界阻塞队列C则可以考虑boost::lockfree::queue或者自己封装。线程池这个热搜词也跟这里强相关。线程池本质上就是一个任务队列 一组工作线程的组合任务队列里的任务就是共享数据工作线程从中取出任务并执行。理解生产者-消费者模型之后再看线程池的实现就一目了然了提交任务的线程是生产者池中的工作线程是消费者而线程池对任务队列的并发安全处理正是共享数据问题的标准答案。6. 多语言与多平台下的共享数据实践对比线程间共享数据的理论是通用的但落到不同语言、不同平台上具体工具和写法差别很大。这一节说说我在C、Java和嵌入式RTOS以FreeRTOS为例中的实践体会。6.1 C从mutex到std::atomic的现代实践C11之前线程支持靠的是操作系统原生的API比如POSIX的pthread库用起来很繁琐而且跨平台性差。C11引入了标准线程库把线程、互斥锁、条件变量、原子类型都纳入了标准这是C并发编程的分水岭。现代C中共享数据的核心工具就是mutex头文件中的std::mutex、std::shared_mutex、std::lock_guard、std::unique_lock等以及atomic头文件中的std::atomicT。这里我特别想强调的是RAII锁守卫的使用习惯永远用std::lock_guard或std::unique_lock管理加锁和解锁绝不裸调用lock()和unlock()。因为裸调用意味着如果中间抛出异常解锁操作可能被跳过锁永远不会释放其他线程永久阻塞。对于两个线程分别读写一个大数组这类热搜词我的建议是如果不是在同一个位置做写操作可以分区处理比如一个线程写前半段、另一个线程写后半段互不重叠的情况下不需要锁如果确实有重叠区域就加锁保护重叠区间或者使用std::atomic加 CAS 循环来更新。关键还是先想清楚数据访问模式再决定用什么同步手段。C内存模型C11及以后定义了memory_order的语义std::atomic默认使用memory_order_seq_cst顺序一致这是最安全但性能略低的选择。在性能极端敏感时可以尝试memory_order_acquire/release/relaxed等更宽松的内存序但这属于高阶优化新手不要轻易碰。6.2 Javasynchronized、Lock与并发容器Java作为一个自带线程模型的语言在并发方面提供了非常丰富的工具。最基础的synchronized关键字既可以修饰方法也可以修饰代码块它的优点是JVM自动管理锁的释放即使方法中抛出异常锁也会在退出时被JVM释放相比裸lock()要安全得多。java.util.concurrent.locks.Lock接口及其实现ReentrantLock则提供了更灵活的功能可中断地获取锁、可超时地获取锁、支持公平/非公平策略、可以绑定多个条件变量。比如前面提到的tryLock(timeout)在synchronized里是无法实现的因为synchronized获取锁只能无限期等待。在高并发、需要优雅降级的场景下ReentrantLock几乎是最佳选择。Java并发包JUC里的并发容器是我的最爱。ConcurrentHashMap是典型的分段锁/无锁实现读操作几乎不阻塞写操作也只在特定桶上锁并发性能远超简单加锁的Hashtable。ConcurrentLinkedQueue是基于CAS的无界线程安全队列。CopyOnWriteArrayList则适合读多写极少的场景写操作时复制整个底层数组读操作不加锁适合缓存类数据。还有两个热搜词值得单独说。一个是java线程等待都完成这个对应的是CountDownLatch、CyclicBarrier、CompletableFuture这些并发辅助类。CountDownLatch适合等待N个线程都完成后再继续的场景CompletableFuture则支持更灵活的异步编排。另一个是java编写守护线程守护线程Daemon Thread是JVM中一种特殊的线程当所有非守护线程结束时JVM会立即退出不管守护线程是否运行中。用thread.setDaemon(true)可以把线程设为守护线程适合做一些后台清理、监控类的任务。但要注意守护线程里不要写不能中断的IO操作否则JVM退出时可能造成数据未落盘。6.3 嵌入式RTOSFreeRTOS中的线程间数据共享嵌入式场景常被主流程编程的开发者忽略但FreeRTOS中线程间共享数据同样是核心话题。FreeRTOS中线程叫任务Task任务间共享数据主要有三种手段队列、信号量、互斥锁。队列Queue是FreeRTOS任务间通信的主要方式。它内部实现了生产者-消费者模型发送任务向队列写数据接收任务从队列读数据队列本身是线程安全的无需额外加锁。这在设计上是极其优雅的——共享数据不走共享内存 锁的老路而是通过消息传递的方式解耦。我自己写嵌入式代码时特别推荐优先使用队列传递数据而不是让多个任务直接访问同一个全局变量。信号量和互斥锁在FreeRTOS中的区别比较微妙。互斥锁Mutex有优先级继承机制能防止优先级反转问题适合保护共享资源的临界区但要注意不能在中断服务函数中获取互斥锁信号量Semaphore是更轻量的同步工具常用于任务与中断之间的通知比如中断中发送信号量任务中等待信号量。关于热搜词freertos中检查线程中内存使用大小的接口FreeRTOS确实提供了任务栈高水位检查uxTaskGetStackHighWaterMark这虽然是内存管理话题但和共享数据有关系——因为任务栈是线程私有的如果某个任务栈空间不足导致溢出可能覆盖其他任务的数据造成极其隐蔽的共享数据破坏。排查这类问题最好的办法是用FreeRTOS提供的栈溢出检测钩子在任务切换时检查栈指针是否越界。这里我特别想强调一个嵌入式开发的通用经验能靠消息传递绝不用裸共享变量能用互斥锁不用关中断除非临界区只有几条指令。在资源受限的MCU上并发问题的排查手段比PC端少得多从设计上避免共享数据冲突才是最高效的路线。7. 锁粒度、伪共享与性能共享数据背后的隐形代价最后这一节讲点更高阶的内容。很多人以为共享数据只要能保证正确就够了但实际上并发编程还有一层残酷的现实保护手段本身会引入性能开销甚至可能制造新的性能瓶颈。7.1 锁粒度粗锁与细锁的权衡锁粒度指的是锁保护的数据范围大小。粗粒度锁保护的数据多、临界区大实现简单但并发度低所有线程都在排队等一把锁细粒度锁保护的数据少、临界区小并发度高但实现复杂还容易引入死锁风险。一个经典例子是Java集合框架演进史。早期的Hashtable是粗粒度锁的典型——所有方法都加了synchronized但本质上是对整个表加锁并发写性能很差。后来的ConcurrentHashMap改进了锁粒度用分段锁甚至JDK8后的CAS 桶级锁把锁的粒度从整张表缩小到单个桶并发性能大幅提升。但在做锁细化的过程中我踩过不少坑。有一次我把一个大锁拆成两个小锁分别保护两个独立的数据结构结果因为拆分后需要同时更新两个数据结构的一致性反而需要引入新的锁去保证原子性最后锁的数量变多死锁风险反而上升。我的经验是锁的粒度要从数据访问模式的实际情况出发而不是一味追求细。如果一个数据结构在业务上总是被成对访问那就应该用同一把锁保护而不是强行拆开。7.2 伪共享一个你未必听过的性能杀手伪共享False Sharing是并发编程中非常隐蔽的性能问题。它的原理和CPU缓存有关CPU缓存以缓存行Cache Line为单位加载数据一个缓存行通常64字节。如果两个线程分别访问两个不同的变量但这两个变量恰好落在同一个缓存行中那么当线程A修改变量X时会使缓存行的状态变为已修改线程B在自己的核心上要访问变量Y发现该缓存行已经无效就必须重新从内存加载哪怕Y本身没被修改。这样一来两个不共享任何逻辑数据的线程因为物理上被安排在同一个缓存行产生了无效的缓存同步性能可能急剧下降。这在多核高并发场景下尤其明显。解决伪共享的办法也很简单粗暴给变量填充padding让两个热点变量分散到不同缓存行。在C17中可以用alignas(64)对齐Java中可以填充无用的long字段Java 8之后还提供了Contended注解需要JVM参数开启。我曾经在一个高并发的计费系统里通过给几个频繁读写的计数器加padding把系统吞吐量提升了将近一倍效果之明显让我自己都惊讶。判断是否为伪共享需要借助性能分析工具比如Linux下的perf、Intel的VTune。在我的经验里如果程序的开销大比例消耗在缓存同步相关的指令上就很有可能是伪共享在捣鬼。如果你正在做多线程优化值得把这节课补上。7.3 从单例模式看线程安全的完整思辨单例模式是考察线程安全最经典的一块试金石。热搜词里有c 单例模式 线程安全和java相关词条说明这是高频考点但也是高频错误区。最朴素的单例写法是懒加载第一次使用时才创建实例。单线程下没问题多线程下就需要加锁。很多人在函数上直接加一把大锁这种方式虽然是线程安全的但每次调用都要抢锁即使实例已经创建也要付锁的代价。性能优化方向是双重检查锁定DCL前面提过它和指令重排序相关的问题——在C11之前DCL在纯C层面不是完全安全的需要依赖特定平台的内存屏障C11之后用std::call_once或者函数局部static变量的初始化C11保证它是线程安全的可以干净地解决。在Java中推荐的方式是静态内部类持有单例利用JVM的类初始化锁class initialization lock保证线程安全既懒加载又无锁是我个人最喜欢的写法。框架层面Spring管理的Bean默认就是单例的容器的初始化机制已经处理好了线程安全问题这又是一种自己不加锁、让框架加锁的思路。单例模式的小小代码折射出线程安全的全貌加锁的方式、锁的粒度、指令重排序的影响、语言规范提供的保证。把这个例子吃透基本就掌握了共享数据同步的八成功力。写在最后共享数据的本质是全局状态的管理分享几条我长期实践下来最有价值的体会。第一写并发代码之前先花三分钟画出数据流图标出哪些数据是线程共享的、谁在写、谁在读。这一张纸能节省你三天的排查时间。第二能不共享就不共享。很多场景可以让每个线程持有自己的数据副本比如线程局部存储thread_local最后再合并结果这是最干净的方案。代码单例、缓存、全局配置这些确实需要共享的才考虑上锁和原子操作。第三永远把正确性放在性能之前。先用简单可靠的锁把功能做对性能不够再考虑细粒度锁、原子操作甚至无锁方案。我在项目里见过太多人一上手就搞无锁队列最后bug满天飞得不偿失。第四多线程bug的特点是测不出来但线上会发生所以code review时对共享数据的审查一定要格外严格。哪怕只是给变量加个volatile也要说明清楚为什么加、解决的是什么问题。线程间共享数据这一章表面上是技术知识点本质上是并发思维的建立。希望这篇归纳总结能帮你把知识点串成一张网。下次再遇到多线程问题你会知道先分析原子性再看可见性最后检查有序性该加锁加锁该上原子操作上原子操作死锁先看锁顺序性能瓶颈先查锁粒度和伪共享。这条路我替你趟过一遍照着走能少踩不少坑。