Java内存模型与互斥锁
1. Java内存模型:解决可见性和有序性问题
1.1. 内存模型
内存模型的概念:
内存模型是一个抽象的概念,它描述了计算机内存的组织和访问方式。在编程中,内存模型是编译器和硬件设计者用来优化代码性能和正确性的一种工具。
内存模型主要包括以下几个部分:
1. 内存布局:描述了内存的组织方式,包括代码段、数据段、堆和栈等。
2. 内存访问:描述了如何在内存中读取和写入数据。例如,是按字节访问还是按字访问。
3. 内存同步:描述了在多线程环境中如何同步访问内存。例如,如何保证多个线程看到的内存一致性。
4. 内存管理:描述了对象内存的分配和回收
我们目前重点关注内存同步,是多线程的重点
内存布局:学到各个语言的时候再深入学
内存访问:计组和操作系统的知识,之前学过
内存管理:如何分配对象,回收对象。学到各个语言的时候再深入学。比如:Java、Go、Python有各自的垃圾回收机制
1.2. Java内存模型
为何需要 JMM?
并发 Bug 源头 | 真实动机 | 失效表现 |
CPU 缓存 | 提升访问速度 | 可见性 —— 线程 A 的写,线程 B 看不见 |
编译 / CPU 重排 | 提升指令吞吐 | 有序性 —— 语句顺序被打乱 |
线程切换 | 平衡 IO 与 CPU | 原子性 —— 单条语句被拆分 |
- 目标:让程序员“在必要处”按需禁用这些优化,而其余地方依旧高速运行。
java内存模型的概念:
Java内存模型(JMM) = 关键字 (volatile / synchronized / final) + Happens‑Before 规则集
它让我们“按需关闭”缓存与重排,确保 可见性 与 有序性,同时保留大部分优化带来的性能。牢记 “钥匙三件套 + HB 六军规”,并发调 Bug 时就能对症下药。
1. JMM 给程序员的 3 把钥匙
关键字 | 解决 | 常见用途 |
| 可见性 & 有序性(单变量) | 状态标志、轻量级读–写锁 |
| 可见性 + 排他原子性(临界区) | 原子复合操作、条件队列 |
| 构造期后不可变,可见性保证 | 不可变对象、枚举、单例字段 |
2. Happens‑Before(HB)六规则
它表达的意思是:前面一个操作的结果对后续操作是可见的。
# | 规则 & 口诀 | 含义(A HB B ⇒ A 的写对 B 可见) |
1 | 程序顺序(顺) | 同一线程,代码先后顺序天然 HB |
2 | volatile 写→读(volatile) | 对同一 |
3 | 传递性(传) | A HB B 且 B HB C ⇒ A HB C |
4 | 锁解→锁加(锁) | 同一锁,解锁 HB 之后的加锁 |
5 | 线程 start()(启) | 线程 A 调 |
6 | 线程 join()/结束(等) | t 线程体结束 HB A 中 |
- 管程是一种通用的同步原语,在 Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。
2. 互斥锁
2.1. 原子性问题的解决办法
原子性问题的源头是线程切换:
概念 | 1 句话释义 | 关键陷阱 |
原子性 (Atomicity) | 若一组 CPU 指令 要么全部完成,要么全不做,中途不会被别的线程观察到中间态 | 线程切换拆散指令序列 → 写一半/读一半 |
源头 | OS 调度靠 CPU 中断,多核下可真“并行执行” | 禁中断在多核无用 |
“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。
2.2. 锁模型
- 一把锁 ⇐⇒ 一组受保护资源(1:N)
- 错误示例:用多把锁守同一资源 → 同步失效
Java 语言提供的锁技术:synchronized
锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。synchronized 关键字可以用来修饰方法,也可以用来修饰代码块,它的使用示例基本上都是下面这个样子:
class X {// 修饰非静态方法synchronized void foo() {// 临界区}// 修饰静态方法synchronized static void bar() {// 临界区}// 修饰代码块Object obj = new Object();void baz() {synchronized(obj) {// 临界区}}
}
- 当修饰代码块的时候,锁定了一个 obj 对象;
- 当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;
- 当修饰非静态方法的时候,锁定的是当前实例对象 this。
用 synchronized 解决 count+=1 问题
管程中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
class SafeCalc {long value = 0L;synchronized long get() {return value;}synchronized void addOne() {value += 1;}
}
get() 方法和 addOne() 方法都需要访问 value 这个受保护的资源,这个资源用 this 这把锁来保护。线程要进入临界区 get() 和 addOne(),必须先获得 this 这把锁,这样 get() 和 addOne() 也是互斥的。
2.3. 锁和受保护资源的关系
受保护资源和锁之间的关联关系是 N:1 的关系。
存在并发问题的例子:
class SafeCalc {static long value = 0L;synchronized long get() {return value;}synchronized static void addOne() {value += 1;}
}
改动后的代码是用两个锁保护一个资源。这个受保护的资源就是静态变量 value,两个锁分别是 this 和 SafeCalc.class。
由于临界区 get() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了。
互斥锁原理:锁,一定有一个要锁定的对象,至于这个锁定的对象要保护的资源以及在哪里加锁 / 解锁,就属于设计层面的事情了。
2.4. 保护没有关联关系的多个资源
银行业务中有针对账户余额(余额是一种资源)的取款操作,也有针对账户密码(密码也是一种资源)的更改操作,我们可以为账户余额和账户密码分配不同的锁来解决并发问题,这个还是很简单的。
相关的示例代码如下,账户类 Account 有两个成员变量,分别是账户余额 balance 和账户密码 password。取款 withdraw() 和查看余额 getBalance() 操作会访问账户余额 balance,我们创建一个 final 对象 balLock 作为锁(类比球赛门票);而更改密码 updatePassword() 和查看密码 getPassword() 操作会修改账户密码 password,我们创建一个 final 对象 pwLock 作为锁(类比电影票)。不同的资源用不同的锁保护,各自管各自的。
class Account {// 锁:保护账户余额private final Object balLock= new Object();// 账户余额 private Integer balance;// 锁:保护账户密码private final Object pwLock= new Object();// 账户密码private String password;// 取款void withdraw(Integer amt) {synchronized(balLock) {if (this.balance > amt){this.balance -= amt;}}} // 查看余额Integer getBalance() {synchronized(balLock) {return balance;}}// 更改密码void updatePassword(String pw){synchronized(pwLock) {this.password = pw;}} // 查看密码String getPassword() {synchronized(pwLock) {return password;}}
}
当然,我们也可以用一把互斥锁来保护多个资源,例如我们可以用 this 这一把锁来管理账户类里所有的资源:账户余额和用户密码。具体实现很简单,示例程序中所有的方法都增加同步关键字 synchronized 就可以了。
但是用一把锁有个问题,就是性能太差,会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。而我们用两把锁,取款和修改密码是可以并行的。用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁。
2.5. 保护有关联关系的多个资源
错误方法:
例如银行业务里面的转账操作,账户 A 减少 100 元,账户 B 增加 100 元。这两个账户就是有关联关系的。
用户 synchronized 关键字修饰一下 transfer() 方法:
class Account {private int balance;// 转账synchronized void transfer(Account target, int amt){if (this.balance > amt) {this.balance -= amt;target.balance += amt;}}
}
问题就出在 this 这把锁上,this 这把锁可以保护自己的余额 this.balance,却保护不了别人的余额 target.balance,就像你不能用自家的锁来保护别人家的资产,也不能用自己的票来保护别人的座位一样。
正确使用锁:
用 Account.class 作为共享的锁。Account.class 是所有 Account 对象共享的,而且这个对象是 Java 虚拟机在加载 Account 类的时候创建的,所以我们不用担心它的唯一性。
class Account {private int balance;// 转账void transfer(Account target, int amt){synchronized(Account.class) {if (this.balance > amt) {this.balance -= amt;target.balance += amt;}}}
}
- 锁能覆盖所有受保护资源
总结:
我们再引申一下上面提到的关联关系,关联关系如果用更具体、更专业的语言来描述的话,其实是一种“原子性”特征,在前面的文章中,我们提到的原子性,主要是面向 CPU 指令的,转账操作的原子性则是属于是面向高级语言的,不过它们本质上是一样的。
“原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。例如,在 32 位的机器上写 long 型变量有中间状态(只写了 64 位中的 32 位),在银行转账的操作中也有中间状态(账户 A 减少了 100,账户 B 还没来得及发生变化)。所以解决原子性问题,是要保证中间状态对外不可见。