并发编程有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 += 1 执行到一半,就有可能碰到线程切换,导致并发问题的产生,如下图所示:
我们把一个或者多个操作在 CPU 执行过程中不被中断的特性称为原子性,即我们期望 count += 1 在执行过程中是原子一样的,不可分割的整体,线程切换不会在执行这条语句相关的CPU指令时发生,但允许线程切换在 count += 1 执行之前或者之后发生。
锁模型是一种解决原子性问题的通用技术方案。在锁模型中, 临界区 是一段要互斥执行的代码,在进入临界区之前我们要执行 lock() 操作 持有锁 ,只有获取到锁的线程才能执行临界区的代码;执行完临界区代码执行 unlock() 操作 释放锁 ,此时其他线程就可以尝试获取锁。
在现实生活中,我们用锁来保护我们的东西,但不能用自己的锁来锁别人的东西。在锁模型中,锁与临界区中被保护的资源也有着关联关系,图中用箭头来表示它们之间的关联。
我们不能用一把锁来保护范围之外的资源,代码要实现互斥则要使用同一把锁。
锁是一种通用的技术方案,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 就可以同时保护两个不同对象的临界区资源: