0%

Lock和Condition(上):隐藏在并发包中的管程

Java SDK并发包的核心是对管程的实现。理论上利用管程,可以实现并发包里所有的工具类。
在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。Java SDK并发包通过Lock和Condition两个接口来实现管程,其中Lock用于解决互斥问题,Condition用于解决同步问题。

Java语言本身提供的synchronized也是管程的一种实现。SDK包重复造轮子,管程的另一种实现。

再造管程的理由

死锁问题的解决方案之一-破坏不可抢占条件,这个方案synchronized没有办法解决。原因是synchronized申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。

对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。

互斥锁有三种方案可以破坏不可抢占条件:

  1. 能够响应中断。synchronized的问题是,持有锁A后,如果尝试获取锁B失败,那么线程就进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程能够响应中断信号,也就是说给阻塞的线程发送中断信号的时候,能够唤醒它,那么它就有机会释放曾经持有的锁A。这样就破坏了不可抢占条件了。
  2. 支持超时。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
  3. 非阻塞的获取锁。如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁。这样也能破话不可抢占条件。

这三种方案可以全面弥补synchronized的问题。这三个方案就是重复造轮子的主要原因,体现在API上,就是Lock接口的三个方法。

1
2
3
4
5
6
//支持中断的API
void lockInterruptibly() throws InterruptedException;
//支持超时的API
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
//支持非阻塞获取锁的API
boolean tryLock();

保证可见性

Java SDK里面Lock的使用范例:try{} finally{},需要重点关注的是在finally里面释放锁。

Java里多线程的可见性是通过Happens-Before规则保障的,而synchronized之所以能够保证可见性,也是因为有一条synchronized相关的规则:synchronized的解锁Happens-Before于后续对这个锁的加锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class X {
private final Lock rtl = new ReentrantLock();
int value;
public void addOne() {
//获取锁
rtl.lock();
try {
value += 1;
} finally {
//保证锁能释放
rtl.unlock();
}
}
}

Java SDK里面的锁实现非常复杂,原理:利用了volatile相关的Happens-Before规则。Java SDK里面的ReentrantLock,内部持有一个volatile的成员变量state,获取锁的时候,会读写state的值;解锁的时候,也会读写state的值。(在执行value+=1之前,程序先读写了一次volatile变量state,在执行value+=1之后,又读写了一次volatile变量state)根据Happens-Before规则:

  1. 顺序性规则:对于线程T1,value+=1 Happens-Before释放锁的操作unlock();
  2. volatile变量规则:由于state=1会先读取state,所以线程T1的unlock()操作Happens-Before线程T2的lock()操作;
  3. 传递性规则:线程T1的value+=1 Happens-Before线程T2的lock()操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
class SampleLock {
volatile int state;
//加锁
lock() {
//省略代码
state = 1;
}
//解锁
unlock() {
//省略代码
state = 0;
}
}

可重入锁

ReentrantLock,可重入锁,指的是线程可以重复获取同一把锁。

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
//当线程T1执行到1处时,已经获取到了锁rtl,当在1处调用get()方法时,会在2再次对锁rtl执行解锁操作。  
//此时,如果锁rtl是可重入的,那么线程T1可以再次加锁成功;
//如果锁rtl是不可重入的,那么线程T1此时会被阻塞。
class X {
private final Lock rtl = new ReentrantLock();
int value;
public int get() {
//获取锁
rtl.lock(); ②
try {
return value;
} finally {
//保证锁能释放
rtl.unlock();
}
}
public void addOne() {
//获取锁
rtl.lock();
try {
value = 1 + get(); ①
} finally {
//保证锁能释放
rtl.unlock();
}
}
}

可重入函数,指的是多个线程可以同时调用该函数,每个线程都能得到正确结果;同时在一个线程内支持线程切换,无论被切换多少次,结果都是正确的。多线程可以同时执行,还支持线程切换,意味着线程安全。可重入函数是线程安全的。

公平锁与非公平锁

ReentrantLock这个类有两个构造函数,一个是无参构造函数,一个是传入fair参数的构造函数。fair参数代表的是锁的公平策略,如果传入true就表示需要构造一个公平锁,反之则表示要构造一个非公平锁。

1
2
3
4
5
6
7
8
//无参构造函数:默认非公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
//根据公平策略参数创建锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}

管程中介绍的入口等待队列,锁都对应着一个等待队列,如果一个线程没有获得锁,就会进入等待队列,当有线程释放锁的时候,就需要从等待队列中唤醒一个等待的线程。如果是公平锁,唤醒的策略就是谁等待的时间长,就唤醒谁,很公平;如果是非公平锁,则不提供这个公平保证,有可能等待时间短的线程反而先被唤醒。

用锁的最佳实践

用锁能解决很多并发问题,但是可能导致死锁,也可能影响性能。用锁的三个最佳实践:

  1. 永远只在更新对象的成员变量时加锁

  2. 永远只在访问可变的成员变量时加锁

  3. 永远不在调用其他对象的方法时加锁

    其他方法里面有线程sleep()的调用,也可能有奇慢无比的I/O操作,这些都会严重影响性能。其他类的方法可能也会加锁,然后双重加锁就可能导致死锁。

  4. 减少锁的持有时间

  5. 减小锁的粒度

总结

Java SDK并发包里的Lock接口里面的方法,除了支持类似synchronized隐式加锁的Lock()方法外,还支持超时、非阻塞、可中断的方式获取锁,这三种方式为编写更加安全、健壮的并发程序提供了很大的遍历。