并发编程有3个源头性问题:缓存导致的可见性问题,编译优化导致的有序性问题,以及线程切换导致的原子性问题。解决可见性问题和有序性问题的方法是按需禁用缓存和编译优化,Java的内存模型就是一种按需禁用缓存和编译优化的规则,它规定了 JVM 如何提供相关的方法,这些已经在Java内存模型与Hppens-Before规则进行了描述。
我们把一个或者多个操作在 CPU 执行过程中不被中断的特性称为原子性。由于操作系统的时间片轮转机制,以及高级语言可能包含多个指令,导致一句高级语言在执行过程中可能出现线程切换。在并发编程中就会因为线程切换导致原子性问题。
锁模型是解决原子性问题的通用方案。线程在进入临界区之前必须持有锁,退出临界区时释放锁,此时其他线程就能再次获取锁。
锁与资源之间是 1:N 的关系,即一把锁可以保护多个资源。同时要注意不能用自己的锁保护别人的资源;要让代码实现互斥,必须使用同一把锁。
synchronized
关键字是 Java 语言对锁模型的实现,它可以修饰方法或者代码块,被修饰的方法和代码块会隐式地添加lock()
和unlock()
方法。
什么是原子性问题
现代操作系统都是基于线程的分时调度系统,CPU会为线程分配时间片,线程分配都时间片就获取到CPU的使用权。比如说线程 A 读取文件,它可以将自己标记为「休眠状态」,让出 CPU 的使用权。文件读取完成之后,操作系统再将其唤醒,线程 A 就有机会重新获得 CPU 的使用权。
线程切换为什么导致并发问题呢?Java 是一门高级语言,高级语言的一条语句往往包含多个 CPU 指定,比如说 count += 1
这条语句,至少包含 3 条 CPU 指令:
- 把变量 count 从内存加载到 CPU 的寄存器;
- 在寄存器中执行 +1 操作;
- 将结果写入内存(缓存机制导致可能写入的是 CPU 缓存而不是内存)。
操作系统以指令为单位执行,期间伴随着线程切换。这就导致 count += 1
执行到一半,就有可能碰到线程切换,导致并发问题的产生,如下图所示:
我们把一个或者多个操作在 CPU 执行过程中不被中断的特性称为原子性,即我们期望 count += 1
在执行过程中是原子一样的,不可分割的整体,线程切换不会在执行这条语句相关的CPU指令时发生,但允许线程切换在count += 1
执行之前或者之后发生。
锁模型
锁模型是一种解决原子性问题的通用技术方案。在锁模型中,临界区是一段要互斥执行的代码,在进入临界区之前我们要执行 lock()
操作持有锁,只有获取到锁的线程才能执行临界区的代码;执行完临界区代码执行 unlock()
操作释放锁,此时其他线程就可以尝试获取锁。
在现实生活中,我们用锁来保护我们的东西,但不能用自己的锁来锁别人的东西。在锁模型中,锁与临界区中被保护的资源也有着关联关系,图中用箭头来表示它们之间的关联。
我们不能用一把锁来保护范围之外的资源,代码要实现互斥则要使用同一把锁。
Java 语言提供的锁技术:synchronized
锁是一种通用的技术方案,Java 语言提供的 synchronized
关键字,就是锁的一种实现。synchronized
关键字可以用来修饰方法,也可以用来修饰代码块,它的使用示例基本上都是下面这个样子:
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}
前面说过,锁模型中有锁以及它保护的资源,synchronized 修饰代码块的时候锁显然是 obj 对象,那么 synchronized 修饰非静态方法和静态方法的时候,它创建的锁是什么呢?
Java 中有一条隐式规则:
当修饰静态方法的时候,锁定的是当前类的 Class 对象;
当修饰非静态方法的时候,锁定的是当前实例对象 this。
相当于
class X {
// 修饰静态方法
synchronized(X.class) static void bar() {
// 临界区
}
}
class X {
// 修饰非静态方法
synchronized(this) void foo() {
// 临界区
}
}
锁和受保护资源的关系
锁可以保护一个或者多个资源。我们可以用一个范围较大的锁,比如说 X.class
保护多个相关的资源;也可以用不同的锁对被保护资源进行精细化管理,这就叫细粒度锁。
一把锁保护一个资源
class SafeCalc {
long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
这是一段想解决 count += 1
问题的代码,我们对 addOne()
使用 synchronized 加上互斥锁,可以保证其原子性。根据 Happens-before 管程中锁的规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁,也可以保证其可见性。即使是 1000 个线程同时执行 addOne()
也可以保证 value 增加 1000。
但我们无法保证 get()
的可见性,管程中锁的规则,是只保证后续对这个锁的加锁的可见性,而 get() 方法并没有加锁操作,所以可见性没法保证。所以我们给 get()
也加上锁:
class SafeCalc {
long value = 0L;
synchronized long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}
此时 get()
和 addOne()
都持有 this 这把锁,此时 get()
和 addOne()
是互斥的,并且保证了可见性,缩模型如下图所示:
两把锁保护不同资源的问题
如果将 value 改为 static 的,addOne()
变为静态方法:
class SafeCalc {
static long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
此时 get()
和 addOne()
分别持有不同的锁,get()
和 addOne()
不互斥,也就不能保证可见性,就会导致并发问题。
一把锁保护多个资源
现在要写一个银行转账的方法,用户 A 给用户 B 转账,将其转换成代码:
class Account {
private int balance;
// 转账
synchronized void transfer(
Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
用户 A 给用户 B 转账 100,要保证 A 的余额减少 100,B 的余额增加 100。由于转账操作可以是并发的,所以要保证转账操作没有并发问题。比如说 A 的余额只有 100,两个线程分别执行 A 给 B 转账 100,A 给 C 转账 100,这两个线程有可能同时从内存中读取到 A 的余额是 100,这就产生了并发问题。
解决这个问题的第一反应,就是给 transfer(Account target, int amt)
加上 synchronized。这样做真的对么?transfer()
此时有两个需要被保护的资源 target.balance
和 this.balance
即别人钱和自己的钱,但我们使用的锁是 this
锁,如下图所示:
自己的锁 this
能保护自己的 this.balance
但是无法保护别人的 target.balance
,就像我的锁不能即保护我家的东西,又保护你家的东西一样。
所以我们需要一把锁的范围更大一点,让它能够覆盖到所有的被保护资源,比如说传入同一个对象作为锁:
class Account {
private Object lock;
private int balance;
private Account();
// 创建Account时传入同一个lock对象
public Account(Object lock) {
this.lock = lock;
}
// 转账
void transfer(Account target, int amt){
// 此处检查所有对象共享的锁
synchronized(lock) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
或者使用类锁 Accout.class
,由于 Accoutn.class
是在 Java 虚拟机加载 Account 类时创建的,所以 Account.class
是所有 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;
}
}
}
}
Accout.class
就可以同时保护两个不同对象的临界区资源:
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。