Java并发编程学习——互斥锁

如何解决原子性问题

原子性问题的源头就是线程切换,而我们禁用线程切换就能解决,而操作系统进行线程切换是依赖于CPU中断的,所以我们禁用CPU中断就能禁用线程切换。

在早期单核CPU的情况下,统一时刻只有一个线程执行(多线程是利用时间片切换),所以禁用了线程切换就导致了获得CPU使用权的线程可以不间断的执行,就比如说在32位机器上写入一个long变量分为写入高32位和低32位,那么如果禁用CPU中断,该线程就不会被打断,所以这时候写入高32和低32这两个操作就具有原子性,即要么不被执行要么都被执行。

但是对于现代多核CPU,这时候就有多个线程同一时刻在不同cpu上执行,这个时候如果禁用CPU中断是不能禁止同一时刻只有一个线程执行的。它只能保证这时的执行线程不会被切换,如果这时候同时有两个变量要写入long型变量,由于多线程竞争的问题,在线程A写入高32位的时候线程B也写入高32位,然后线程B写入低32位之后线程A写入低32位,这时候就会出现写入数值出现异常值的bug。

同一时刻只有一个线程执行这个概念非常重要,我们称之为互斥。如果我们对于某一共享变量的操作都是互斥的,无论是单核还是多核CPU都能实现原子性。

简易锁模型

简易锁模型

而解决原子性的问题最重要的解决办法就是。比如线程A要执行临界区的一段代码,必须先进行加锁,枷锁完成后进入临界区并执行,当线程B要进入的时候由于获得不到锁,那么线程B得不到执行。当线程A执行完成后便释放锁,之后线程B就可以获得锁并且进入临界区执行了。

但是我们常常忽略的两个问题——我们的锁是什么?我们保护的资源又是什么?

改进后的锁模型

简易锁模型

改进后的锁模型则对资源和锁进行了定义,就像现实生活中,自家门对应着自家门的锁,别人家的对应别人的锁。

Java语言提供的锁技术——synchronized

Java语言提供的锁技术就是synchronized关键字,它可以应用于方法中也可以是代码块中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}

其中锁的实现是Java编译器帮我们自动实现的,而synchronized并没有指定锁到底是什么,其实这里java是有默认规则的。

具体的默认规则如下:

  1. 当synchronized修饰的是静态方法的时候,默认锁住的是当前类的class
1
2
3
4
5
6
class X {
// 修饰静态方法
synchronized(X.class) static void bar() {
// 临界区
}
}
  1. 当修饰的是非静态方法的时候默认锁着的是当前对象即this
1
2
3
4
5
6
class X {
// 修饰非静态方法
synchronized(this) void foo() {
// 临界区
}
}

用synchronized来解决count+=1的问题

SafeCalc 这个类有两个方法:一个是 get() 方法,用来获得 value 的值;另一个是 addOne() 方法,用来给 value 加 1,并且 addOne() 方法我们用 synchronized 修饰。那么我们使用的这两个方法有没有并发问题呢?

1
2
3
4
5
6
7
8
9
class SafeCalc {
long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}

由于synchronized的存在,假使有1000个线程去执行addOne方法,因为同一时刻只能有一个线程去执行,所以最后的value肯定是1000。

但是对于get方法就不是了,如果有线程去执行get方法,因为上述代码有了管程中锁机制的happens-before规则(前一个线程加锁后进入临界区对共享资源的修改对于后一个线程解锁后进入是可见的),这个时候get方法就不是可见的了。所以这时候将get方法也synchronized一下就可以了,因为前面对addOne的操作对于get就可见了。

简易锁模型

锁和受保护资源的关系

受保护资源和锁之间的关联关系是 N:1 的关系,如果我们将上述代码改一下。把 value 改成静态变量,把 addOne() 方法改成静态方法,此时 get() 方法和 addOne() 方法是否存在并发问题呢?

1
2
3
4
5
6
7
8
9
class SafeCalc {
static long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}

简易锁模型

这个时候是通过不同的锁来保护不同的资源,那么get方法就会像刚刚那样出现并发问题了。

锁不能是可变对象

1
2
3
4
5
6
7
8
9
10
11
12
13
class SafeCalc {
long value = 0L;
long get() {
synchronized (new Object()) {
return value;
}
}
void addOne() {
synchronized (new Object()) {
value += 1;
}
}
}

如果将锁变为这样,是不允许的,因为锁应该是不可变的。

保护没有关联关系的多个资源

被保护资源和锁应该是N:1的关系,那么保护没有关联关系的多个资源呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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;
}
}
}

很简单,使用不同的锁去保护没有关联关系的不同资源就行了,这里使用两个锁pwlock和ballock来保护密码和余额。

当然,我们也可以用一把互斥锁来保护多个资源,例如我们可以用 this 这一把锁来管理账户类里所有的资源:账户余额和用户密码。具体实现很简单,示例程序中所有的方法都增加同步关键字 synchronized 就可以了。

但是用一把锁有个问题,就是性能太差,会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的。而我们用两把锁,取款和修改密码是可以并行的。用不同的锁对受保护资源进行精细化管理,能够提升性能。这种锁还有个名字,叫细粒度锁

保护有关联关系的多个资源

如果多个资源是有关联的,比如转账,将A账户的钱转到B账户中,这两个账户的余额就是相关联的。

1
2
3
4
5
6
7
8
9
10
11
class Account {
private int balance;
// 转账
void transfer(
Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}

如果通过synchronized关键字,你会发现你锁住的仅仅是当前账户的余额,而target的余额就不能保证原子性。比如你同时进行target账户的收款和打钱的操作,那么target就会出现并发问题。

假设有 A、B、C 三个账户,余额都是 200 元,我们用两个线程分别执行两个转账操作:账户 A 转给账户 B 100 元,账户 B 转给账户 C 100 元,最后我们期望的结果应该是账户 A 的余额是 100 元,账户 B 的余额是 200 元, 账户 C 的余额是 300 元。

我们假设线程 1 执行账户 A 转账户 B 的操作,线程 2 执行账户 B 转账户 C 的操作。这两个线程分别在两颗 CPU 上同时执行,那它们是互斥的吗?我们期望是,但实际上并不是。因为线程 1 锁定的是账户 A 的实例(A.this),而线程 2 锁定的是账户 B 的实例(B.this),所以这两个线程可以同时进入临界区 transfer()。同时进入临界区的结果是什么呢?线程 1 和线程 2 都会读到账户 B 的余额为 200,导致最终账户 B 的余额可能是 300(线程 1 后于线程 2 写 B.balance,线程 2 写的 B.balance 值被线程 1 覆盖),可能是 100(线程 1 先于线程 2 写 B.balance,线程 1 写的 B.balance 值被线程 2 覆盖),就是不可能是 200。

简易锁模型

这时候我们就可以通过增大锁的细粒度来保护资源,我们可以将Account.class作为锁对象,这时候就可以解决并发问题。

问题是解决了,但是使用class作为锁的意思就是当一个账户执行操作的时候,其他账户都不能使用,这样可是会严重影响性能的。

简易锁模型

原子性

“原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。例如,在 32 位的机器上写 long 型变量有中间状态(只写了 64 位中的 32 位),在银行转账的操作中也有中间状态(账户 A 减少了 100,账户 B 还没来得及发生变化)。所以解决原子性问题,是要保证中间状态对外不可见

-------------本文结束感谢阅读-------------