并发编程的本质问题是:
- 在多线程环境下,一个线程对共享变量的修改,什么时候能够被其他线程看到?
- 编译器和 CPU 是否允许对指令进行重排序?
- 哪些操作具备原子性保证?
这些问题由内存模型(Memory Model)来定义。
一、什么是内存模型?
内存模型是一套语言级规范,用于定义在多线程环境下:
- 线程之间如何通过内存进行交互
- 变量读写在不同线程之间何时可见
- 编译器和 CPU 在什么范围内可以进行重排序
- 哪些同步操作能够建立 happens-before 关系
需要注意的是,内存模型并不是物理内存结构,而是抽象层面的规则系统。
在现代计算机体系结构中:
- CPU 存在多级缓存
- 指令允许乱序执行
- 写操作可能被延迟刷新
如果语言层面不定义清晰规则,多线程程序将无法推理其行为。
内存模型通常围绕三个核心性质展开:
- 可见性(Visibility)—— 一个线程的修改何时对其他线程可见
- 有序性(Ordering)—— 指令是否可能被重排序
- 原子性(Atomicity)—— 操作是否不可分割
理解内存模型,本质是在理解并发程序的行为边界。
二、什么是 happens-before?
happens-before 是内存模型中的一个偏序关系(partial order),用于定义:
- 如果操作 A happens-before 操作 B,那么 A 的结果对 B 可见,并且 A 在逻辑上先于 B 执行。
它并不表示“时间上的绝对先后”,而是表示一种可见性与顺序保证。
可以理解为:
- 如果没有 happens-before 关系 → 结果不可预测
- 如果存在 happens-before 关系 → 可见性得到保证
一个简单示例
1 | int x = 0; |
在没有同步的情况下:
- Thread 2 可能打印 0
- 也可能打印 42
因为两个线程之间没有 happens-before 关系。
加上锁之后
1 | int x = 0; |
这里存在:
- Thread1 的 unlock happens-before Thread2 的 lock
因此:
- 写入
x = 42对 Thread2 可见 - 不会打印 0
happens-before 的本质
它本质上定义了:
- 哪些操作会形成内存屏障
- 哪些写入必须对后续读取可见
- 哪些重排序是被禁止的
常见建立 happens-before 的方式包括:
- 释放锁 → 获取同一把锁
- volatile 写 → volatile 读
- 线程启动
- 线程 join
- 原子操作
无论是 .NET 还是 Java,内存模型的核心都是围绕 happens-before 构建。
三、.NET 是否有内存模型?
有。.NET 的内存模型定义在 CLI(Common Language Infrastructure)规范中(ECMA 规范的一部分)。C# 作为运行在 CLI 之上的语言,遵循这一内存模型。
.NET 内存模型定义了:
- 在没有同步的情况下,允许重排序
- 同步原语如何建立 happens-before 关系
- lock、volatile、Interlocked 等具备的内存屏障语义
四、.NET 内存模型核心机制
1. 重排序
.NET 允许:
- 编译器重排序(JIT)
- CPU 重排序
只要不改变单线程语义,重排序是合法的。
问题在于:多线程下如果没有同步,行为是不确定的。
2. 可见性问题示例
1 | bool ready = false; |
理论上可能输出:
1 | 0 |
原因可能是:
- 写入 number 与 ready 被重排序
- ready 被先刷新到缓存
- number 仍然停留在线程缓存中
3. volatile
1 | volatile bool ready; |
作用:
- 禁止某些重排序
- 保证对该变量的读写直接与主内存交互
特点:
- 保证可见性
- 不保证复合操作的原子性
- 不能替代锁
.NET 中的 volatile 语义相对克制,更多用于简单状态标记。
4. lock(Monitor)
1 | lock (obj) |
本质是:
1 | Monitor.Enter(obj); |
语义包括:
- 互斥
- 完整内存屏障
- 进入临界区时读取最新值
- 退出临界区时刷新写入
lock 不仅解决可见性问题,还解决竞态条件。
5. Interlocked
1 | Interlocked.Increment(ref count); |
特点:
- 原子操作
- 无锁实现
- 自带内存屏障语义
常用于高性能并发场景,类似于 Java 中的原子类。
6. MemoryBarrier
1 | Thread.MemoryBarrier(); |
强制插入内存屏障:
- 禁止前后指令重排序
- 强制刷新缓存
实际开发中很少直接使用,通常由 lock 或 Interlocked 隐式提供。
五、Java 内存模型(JMM)
Java 内存模型定义在 Java Language Specification 中。
它采用一个更抽象的结构:
- 主内存(Main Memory)
- 每个线程的工作内存(Working Memory)
线程对变量的读写必须:
- 从主内存加载到工作内存
- 修改后再写回主内存
Java 明确定义:
- happens-before 规则
- volatile 语义
- synchronized 语义
- final 字段的安全发布语义
六、.NET 与 Java 内存模型对比
| 维度 | .NET | Java |
|---|---|---|
| 是否有正式规范 | CLI 规范 | JLS 规范 |
| 是否定义 happens-before | 有 | 有 |
| 内存结构抽象 | 不强调主/工作内存 | 明确主内存 + 工作内存 |
| volatile 语义 | 保证可见性,限制重排序 | 语义更强,明确屏障类型 |
| 锁机制 | lock / Monitor | synchronized |
| 原子操作 | Interlocked | AtomicXXX |
| final 语义 | 无直接等价机制 | final 保证安全发布 |
七、volatile 对比
Java volatile:
- 保证可见性
- 禁止重排序
- 明确包含 StoreLoad 屏障
- 经常用于双重检查锁
.NET volatile:
- 保证读取最新值
- 限制重排序
- 语义略弱于 Java
- 更多建议使用 lock 或 Interlocked
八、双重检查锁(DCL)差异
Java 中:
1 | private volatile static Singleton instance; |
必须 volatile,否则可能读取到半初始化对象。
.NET 中:
1 | private static Singleton instance; |
.NET 2.0 之后的内存模型已经保证 DCL 安全。
九、两者设计哲学差异
Java:
- 模型高度形式化
- 明确抽象主内存与工作内存
- happens-before 规则非常完整
.NET:
- 更贴近 CPU 层面
- 更依赖内存屏障语义
- 抽象层级更低
本质目标一致,但抽象方式不同。
十、与 GC 的关系
GC 负责:
- 对象生命周期
- 内存回收
内存模型负责:
- 并发可见性
- 指令重排序
- happens-before
二者处于完全不同层面。
十一、总结
- .NET 存在正式内存模型,定义在 CLI 规范中
- 支持 happens-before 语义
- lock、Interlocked、volatile 都具有内存屏障语义
- Java 内存模型更加形式化,抽象更清晰
- 两者核心目标一致,但实现哲学不同
理解内存模型,本质是在理解并发程序的行为边界,以及同步机制在底层真正做了什么。