尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Java内存模型对并发编程的实践影响

Java内存模型对并发编程的实践影响 从你写下i的那一刻起JVM 就给你挖好了三个坑读取、加一、写回。这三步在单线程里天经地义但在多线程下它们可以被任意交错、乱序、甚至延迟可见。你以为自己在写程序其实你在跟一套极其精密的“记忆模型”谈判。Java 内存模型JMM不是教科书上的概念它直接决定了你的并发代码是稳定运行还是随机翻车。代码的顺序不是执行的顺序JMM 允许编译器和 CPU 对指令进行重排序只要单线程语义不变。这意味着你写的第 10 行代码可能在运行时会跑到第 12 行之后。这种重排序在多线程环境下会带来极其隐蔽的 bug。比如经典的双重检查锁DCL问题如果不把单例字段声明为volatile一个线程可能看到一个“半初始化”的对象——因为对象的引用赋值操作可能被重排到构造函数执行之前。你侥幸运行了无数次都没出事只是在给自己积累事故概率。重排序的根源是性能。现代 CPU 有写缓冲区和乱序执行引擎完全按程序顺序执行会浪费大量时钟周期。JMM 没有禁止重排序而是定义了一套规则哪些重排是允许的哪些是禁止的以及什么时候必须通过内存屏障来“刹车”。理解 JMM就是理解这套刹车系统的位置和代价。volatile最被低估的关键字很多人把volatile理解成“让变量线程安全”这是彻底的误解。volatile保证的是可见性和有序性它并不保证原子性。对volatile变量的读取和写入会插入内存屏障迫使 CPU 失效其他核心缓存中的数据同时禁止相关指令重排序。但这只是让你看到最新值而不是让你对它的操作不被打断。正因为如此volatile适合做状态标志、发布不可变对象、或者配合原子类使用。它不适合做计数器。你声明一个volatile int count两个线程同时count结果照样会丢更新。为什么因为不是单条指令volatile 只保证读和写各自是原子的但“读-改-写”的复合操作依然需要同步。千万别把 volatile 当免费的锁它的成本比锁低但能力也比锁小得多。synchronized内存屏障的隐形大师synchronized不仅仅是互斥。在 JMM 中它建立了一套完整的 happens-before 关系一个线程解锁前对共享变量的修改对于随后获取该锁的线程是可见的。这听起来简单但实际作用极其深远。它意味着锁的释放会刷写所有修改到主内存锁的获取会重新加载主内存中的最新数据锁区域内的读写不会被重排序到临界区之外这些效果是由底层的内存屏障自动实现的你不需要手写。但是代价是巨大的性能损耗。synchronized 的代价不是互斥而是缓存同步。当一个线程进入同步块它可能被迫使本地缓存失效退出时又必须把脏数据写回。在低竞争下这种开销可以接受但在高竞争下锁的“缓存一致性流量”会成为性能杀手。所以现代 JVM 对 synchronized 做了大量优化偏向锁、轻量级锁、适应性自旋。但无论怎么优化JMM 的可见性协议基于缓存一致性协议如 MESI才是底层根基。你调的每个 synchronized 方法底层都是和缓存一致性协议在打交道。happens-beforeJMM 的宪法JMM 的核心不是“同步”和“可见”这些模糊概念而是一套happens-before规则。它规定了哪些操作之间具有“先于”关系如果一个操作 happens-before 另一个操作那么前者的结果对后者可见。这些规则包括程序顺序规则单线程内监视器锁规则解锁让位于后续加锁volatile 变量规则写让位于后续读传递性规则A在前BB在前C则A在前C这条规则意味着如果两个操作之间没有 happens-before 关系那么 JMM 允许它们看起来像任意顺序执行。这给了编译器巨大的优化空间但也让并发代码的推理变得极其困难。很多开发者犯的错误是用直觉判断“应该可见”。比如线程 A 设置了一个标志线程 B 在循环里检查这个标志。你期望 B 能看到 A 的修改但如果没有volatile或锁JVM 完全可以让 B 永远读缓存中的旧值。这不是 bug这是 JMM 的“合法行为”。所以写出正确并发代码的唯一方法就是严格依赖 happens-before 规则而不是依赖运气。final 字段与安全发布final字段在 JMM 中有特殊的语义一旦对象构造完成final 字段的值对所有线程可见且不会被重排序到构造函数之外。这听起来很美好但有一个关键前提对象不能“逸出”构造函数。如果你在构造函数里把this引用发布给了别的线程那么 final 的保证就被打破了。比如在构造函数中启动了一个线程这个线程去读对象的 final 字段——此时 final 字段可能还是默认值。JMM 对 final 的承诺是只要对象安全发布final 字段就能安全访问。安全发布的方式包括通过volatile引用、final引用、synchronized保护、Thread.start()之前赋值、ConcurrentHashMap等并发容器。“安全发布”不是“可见性”的同义词它是比可见性更强的保证。如果你用普通字段发布对象即使你看到了非 null 的引用你可能也看不到对象的完整状态。伪共享JMM 的隐形性能炸弹JMM 的最小操作单位是变量但物理内存的最小同步单位是缓存行通常是 64 字节。当两个不同线程分别读写两个不同的变量但它们位于同一个缓存行时就会发生伪共享。每个线程的写操作都会导致对方缓存行失效即使它们之间没有任何逻辑关系。这会造成惨烈的性能暴跌——你明明用无锁算法性能却比有锁还差。JMM 本身并不讨论缓存行但它通过“主内存和私有工作内存”的抽象间接引入了这个问题。解决伪共享的方法通常是字节填充或者使用sun.misc.Contended注解JVM 参数-XX:-RestrictContended开启。在 JDK 内部比如LongAdder和ConcurrentHashMap的节点都用了类似技术。如果你的并发程序性能与预期不符先查伪共享而不是盲目调大堆内存。内存屏障JMM 与 CPU 的桥梁JMM 的规则要落地必须转换成具体的 CPU 指令。内存屏障就是那层桥梁。常见的有四种LoadLoadLoadStoreStoreLoadStoreStore不同的 CPU 对这些屏障的支持不同。X86 架构只有 StoreLoad 是真正需要的而 ARM 和 PowerPC 需要更全面的屏障。JMM 为了跨平台一致性必须统一要求最严格的屏障级别这导致在 X86 上本来能跑的代码可能在 ARM 上出问题反之亦然。举例来说volatile写操作在 X86 上编译后会插入一个 StoreStore 前置屏障和一个 StoreLoad 后置屏障。但在 ARM 上你可能需要更多的dmb指令。JVM 在屏蔽这些差异方面做得很好但代价是性能上的“取最大值”。如果你在做高性能并发框架就应该理解 CPU 模型避免写出在非 X86 平台上慢性崩溃的代码。实践影响从 Debug 到 Design理解 JMM 到底改变了什么首先是改变你对“Bug”的认知。一个并发 Bug 没有固定的复现路径它只是 JMM 允许的一种合法执行轨迹。你调试半天可能只是因为运气好没触发。其次是改变你的设计思路不要试图用“语义联想”来推断并发正确性而要用 happens-before 规则逐条验证。比如你写一个无锁的LazySingletonpublic class Singleton { private static volatile Singleton instance; public static Singleton get() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }这里的关键不是synchronized而是volatile。volatile阻止了instance new Singleton()中的引用赋值被重排序到构造过程之前。你失去了 volatile就等于失去了安全发布的担保。很多面试者能背出这个公式但不知道背后的 JMM 原理。当你真的遇到“单例返回了但字段是 null”的问题你才会深刻理解 JMM 的意义。关于非原子的 long 与 doubleJMM 允许对于.long和.double类型如果没有声明为volatile那么单个写入操作可能被拆成两个 32 位写操作。这在 32 位 JVM 上或者某些硬件上会导致一个线程写高位另一个线程写低位最终看到一个“拼接”出来的脏值。虽然 x86-64 上的 JVM 通常不会这样拆分但 JMM 规范并没有禁止。所以如果你要跨平台别忘了对long和double加volatile或者用AtomicLong。这个细节极容易被忽略但它属于 JMM 的“基础规则”。线程启动与终止的可见性Thread.start()和Thread.join()也定义了 happens-before 关系。Thread.start()保证调用 start 之前的操作对启动的线程可见。Thread.join()保证被 join 的线程的所有操作对 join 成功返回后的线程可见。这听起来简单但实践意义重大你可以在启动一个线程之前随意初始化数据而不用担心同步问题。但是如果你在run()内部启用了另一个线程并传递了共享数据那么你需要自己建立同步。很多人会忽略这种“传递性”导致数据在第二层线程中不可见。JMM 的灰色地带允许但不能依赖JMM 允许某些行为是“非确定性的”比如在没有同步的情况下不同线程对同一变量的读写顺序。这意味着你的程序可能是 bug 的但你无法在 JMM 层面证明它一定是 bug因为它可能在某一组特定 JVM、CPU、操作系统的组合下正常工作。这种灰色地带是最危险的它让你把“幸运”当作“必然”然后在上线后崩溃。所以实践中最稳妥的策略是禁止一切“未同步访问共享可变变量”的代码。不要让编译器或硬件替你决定行为要把 happens-before 关系显式地写在代码结构里。从 JMM 走到工具链理解了 JMM你就知道为什么ConcurrentHashMap的size()方法用LongAdder为什么AtomicInteger用 CAS 循环为什么ThreadLocal在某些场景下会有对象逃逸问题。JMM 是并发工具库的地基工具库是它的包装。你不必亲手写内存屏障但你必须理解这些工具在做什么才不会误用。比如AtomicReference的compareAndSet底层调用了Unsafe的compareAndSwapInt这个操作直接依赖 CPU 的 CAS 指令并且有全内存屏障在 x86 上带lock前缀。它的成本比synchronized低但并非零成本。最后的思考性能与安全的平衡JMM 的复杂性源于它要同时满足两个对立的诉求让程序正确让程序高效。如果彻底禁止重排序所有线程安全但性能像单线程如果完全允许重排序性能拉满但无法编程。JMM 采取了一个精妙的折中定义一组最小约束让遵守约束的程序获得正确性同时给编译器和 CPU 留出充分的优化空间。你写并发代码就是在这条边界上走钢丝。多学一条 JMM 规则你就少踩一个坑少学一条你迟早会踩。别再问“为什么我加了 volatile 还是不对”这样的问题先检查你的复合操作、检查你的对象逸出、检查你的锁粒度。JMM 不会对你仁慈但它会对那些理解并尊重它的人给予稳定的正确性和可观的性能回报。
返回列表