前言
在Java并发编程中,理解内存模型和可见性问题是掌握多线程开发的基础。本文将重点围绕Java内存模型(JMM)以及synchronized关键字的内存语义展开,帮助读者建立扎实的并发编程基础。
一、并发与并行的区别
1.1 概念辨析
| 概念 | 定义 | 特点 |
|---|---|---|
| 并发 | 在一个时间段内多个任务交替执行 | 宏观同时,微观串行 |
| 并行 | 在同一个时刻多个任务同时执行 | 需要多核CPU支持 |
💡 重点理解:并发任务强调在一个时间段内同时执行,但单位时间内不一定同时在执行。在单CPU时代,所有任务都是并发执行的。
1.2 实际场景
并发:

现代多线程编程中,线程数量往往多于CPU核心数,因此我们通常称之为多线程并发编程而非并行编程。当系统配置双CPU时,线程A和线程B可以各自在不同CPU上真正并行运行。
并行:

二、Java内存模型(JMM)
2.1 抽象结构
Java内存模型规定:
-
主内存:所有的变量都存放在主内存中
-
工作内存:每个线程拥有自己的工作内存,线程读写变量时操作的是工作内存中的副本
2.2 硬件层面的映射
在实际硬件架构中(以双核CPU为例):

对应关系:
-
Java工作内存 → L1/L2 Cache 或 CPU寄存器
-
Java主内存 → 物理主内存
三、内存可见性问题
3.1 问题复现
假设线程A和线程B在不同CPU上执行,共同操作共享变量X,初始值为0,两级Cache初始均为空:
| 步骤 | 操作 | CPU-A Cache | CPU-B Cache | 主内存 |
|---|---|---|---|---|
| 初始 | - | 空 | 空 | X=0 |
| 1 | A读取X(两级Cache未命中,从主内存加载) | X=0 | 空 | X=0 |
| 2 | A修改X=1,写入两级Cache并刷新到主内存 | X=1 | 空 | X=1 |
| 3 | B读取X(L1未命中,L2命中,返回1) | X=1 | X=1 | X=1 |
| 4 | B修改X=2,写入L1、L2,更新主内存 | X=1 ❌ | X=2 | X=2 |
| 5 | A再次读取X(L1命中,返回旧值1) | X=1 ❌ | X=2 | X=2 |
⚠️ 问题出现:步骤5中,线程A获取到的X仍然是1,无法感知线程B已经将其修改为2。
3.2 可见性问题的本质
内存可见性问题:一个线程对共享变量的修改,未能及时被其他线程看到。
产生原因:
-
CPU缓存的存在:每个CPU核心拥有独立的缓存
-
编译器优化:指令重排序可能导致执行顺序变化
-
处理器优化:写缓冲区和无效化队列的延迟
四、synchronized的内存语义
4.1 核心语义
synchronized关键字在Java内存模型中具有明确的内存语义:
| 操作 | 内存行为 |
|---|---|
| 进入synchronized块 | 清除工作内存中该块内使用到的变量,直接从主内存读取 |
| 退出synchronized块 | 将synchronized块内对共享变量的修改刷新到主内存 |
4.2 语义图解
text
进入synchronized块:
┌─────────────────────────────────────────────────────┐
│ 线程工作内存 主内存 │
│ ┌──────────┐ ┌──────────┐ │
│ │ 变量副本 │ ──(清除)──► │ 共享变量 │ │
│ │ (可能过期)│ │ (最新值) │ │
│ └──────────┘ └────┬─────┘ │
│ │ │
│ (从主内存重新读取) │
│ ▼ │
│ ┌──────────┐ │
│ │ 最新副本 │ │
│ └──────────┘ │
└─────────────────────────────────────────────────────┘
退出synchronized块:
┌─────────────────────────────────────────────────────┐
│ 线程工作内存 主内存 │
│ ┌──────────┐ ┌──────────┐ │
│ │ 修改后的 │ ──(刷新)──► │ 共享变量 │ │
│ │ 变量值 │ │ (更新后) │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────┘
4.3 解决可见性问题的原理
回到3.1的问题场景,使用synchronized后:
java
public class SynchronizedSolution {
private int x = 0;
public synchronized void updateX(int newValue) {
// 进入同步块:清除x的工作内存副本
// 直接从主内存读取最新值
this.x = newValue;
// 退出同步块:将x的新值刷新到主内存
}
public synchronized int getX() {
// 进入同步块:清除工作内存,从主内存读取
return this.x;
// 退出同步块:刷新到主内存
}
}
执行流程:
-
线程A调用
updateX(1)→ 从主内存读取最新x值 → 修改为1 → 退出时刷新到主内存 -
线程B调用
updateX(2)→ 进入时清除缓存 → 从主内存读取到1 → 修改为2 → 退出时刷新到主内存 -
线程A再次调用
getX()→ 进入时清除缓存 → 从主内存读取到2 → ✅ 可见性问题解决
4.4 双重作用
synchronized关键字提供两个重要保证:
| 作用 | 说明 |
|---|---|
| 解决可见性 | 通过进入时清除缓存、退出时刷新缓存的内存语义 |
| 保证原子性 | 同一时刻只有一个线程能执行同步块/方法 |
4.5 使用示例
java
public class Counter {
private int count = 0;
// 同步方法 - 保证原子性和可见性
public synchronized void increment() {
count++; // 读取-修改-写入,三步操作整体原子执行
}
public synchronized int getCount() {
return count; // 保证读取到最新值
}
}
public class SharedResource {
private boolean initialized = false;
private Object data = null;
// 同步块 - 更细粒度的控制
public void init(Object newData) {
synchronized (this) {
if (!initialized) {
data = newData;
initialized = true;
// 修改自动刷新到主内存
}
}
}
public Object getData() {
synchronized (this) {
// 自动从主内存读取最新值
return initialized ? data : null;
}
}
}
五、synchronized的性能考虑
5.1 性能开销
📌 重要提醒 :
synchronized关键字会引起线程上下文切换并带来线程调度开销。
主要开销来源:
-
获取/释放锁的系统调用
-
线程阻塞与唤醒:未获取到锁的线程会进入阻塞状态
-
上下文切换:保存和恢复线程状态
-
内存屏障:保证可见性引入的缓存刷新操作
5.2 优化建议
java
// ❌ 不推荐:同步范围过大
public synchronized void badExample() {
// 大量非共享变量的计算操作
longRunningTask();
sharedValue++;
}
// ✅ 推荐:仅同步必要部分
public void goodExample() {
// 非共享操作在同步块外执行
longRunningTask();
synchronized (this) {
sharedValue++;
}
}
六、常见问题与最佳实践
6.1 常见误区
| 误区 | 正确理解 |
|---|---|
| synchronized只保证互斥 | ✅ 同时保证互斥和可见性 |
| 进入同步块后所有变量都从主内存读取 | ✅ 仅同步块内使用到的变量 |
| 退出同步块后所有变量都刷新到主内存 | ✅ 仅同步块内修改过的变量 |
6.2 最佳实践
java
public class BestPractice {
// 1. 同步范围最小化
private int counter = 0;
public void increment() {
// 只同步必要的操作
synchronized (this) {
counter++;
}
}
// 2. 读操作也需要同步(保证可见性)
public synchronized int getCounter() {
return counter;
}
// 3. 使用私有锁对象(避免外部干扰)
private final Object lock = new Object();
public void safeMethod() {
synchronized (lock) {
// 安全操作
}
}
// 4. 避免在同步块内调用外部方法
public void dangerMethod(OtherService service) {
synchronized (this) {
// ❌ 可能造成死锁或性能问题
service.callback(this);
}
}
}
6.3 适用场景总结
| 场景 | 推荐方案 |
|---|---|
| 需要保证复合操作的原子性 | synchronized |
| 多个操作需要整体执行 | synchronized |
| 读写共享变量需要互斥 | synchronized |
| 简单状态标记(单次读写) | 后续会介绍的volatile |
七、总结
核心要点回顾
-
Java内存模型:主内存 + 线程工作内存的抽象结构
-
可见性问题:缓存的存在导致一个线程的修改对另一个线程不可见
-
synchronized内存语义:
-
进入:清除工作内存中的变量副本,从主内存读取
-
退出:将对共享变量的修改刷新到主内存
-
-
synchronized的双重作用:既保证原子性,又保证可见性
关键结论
🎯
synchronized通过进入时清除缓存、退出时刷新缓存的内存语义,完美解决了共享变量的内存可见性问题。同时,它也是Java中最基础的原子性保证机制