Java SDK并发包的核心是对管程的实现。理论上利用管程,可以实现并发包里所有的工具类。
在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。Java SDK并发包通过Lock和Condition两个接口来实现管程,其中Lock用于解决互斥问题,Condition用于解决同步问题。
Java语言本身提供的synchronized也是管程的一种实现。SDK包重复造轮子,管程的另一种实现。
再造管程的理由
死锁问题的解决方案之一-破坏不可抢占条件,这个方案synchronized没有办法解决。原因是synchronized申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。
对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。
互斥锁有三种方案可以破坏不可抢占条件:
- 能够响应中断。synchronized的问题是,持有锁A后,如果尝试获取锁B失败,那么线程就进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程能够响应中断信号,也就是说给阻塞的线程发送中断信号的时候,能够唤醒它,那么它就有机会释放曾经持有的锁A。这样就破坏了不可抢占条件了。
- 支持超时。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
- 非阻塞的获取锁。如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁。这样也能破话不可抢占条件。
这三种方案可以全面弥补synchronized的问题。这三个方案就是重复造轮子的主要原因,体现在API上,就是Lock接口的三个方法。
1 | //支持中断的API |
保证可见性
Java SDK里面Lock的使用范例:try{} finally{},需要重点关注的是在finally里面释放锁。
Java里多线程的可见性是通过Happens-Before规则保障的,而synchronized之所以能够保证可见性,也是因为有一条synchronized相关的规则:synchronized的解锁Happens-Before于后续对这个锁的加锁。
1 | class X { |
Java SDK里面的锁实现非常复杂,原理:利用了volatile相关的Happens-Before规则。Java SDK里面的ReentrantLock,内部持有一个volatile的成员变量state,获取锁的时候,会读写state的值;解锁的时候,也会读写state的值。(在执行value+=1之前,程序先读写了一次volatile变量state,在执行value+=1之后,又读写了一次volatile变量state)根据Happens-Before规则:
- 顺序性规则:对于线程T1,value+=1 Happens-Before释放锁的操作unlock();
- volatile变量规则:由于state=1会先读取state,所以线程T1的unlock()操作Happens-Before线程T2的lock()操作;
- 传递性规则:线程T1的value+=1 Happens-Before线程T2的lock()操作。
1 | class SampleLock { |
可重入锁
ReentrantLock,可重入锁,指的是线程可以重复获取同一把锁。
1 | //当线程T1执行到1处时,已经获取到了锁rtl,当在1处调用get()方法时,会在2再次对锁rtl执行解锁操作。 |
可重入函数,指的是多个线程可以同时调用该函数,每个线程都能得到正确结果;同时在一个线程内支持线程切换,无论被切换多少次,结果都是正确的。多线程可以同时执行,还支持线程切换,意味着线程安全。可重入函数是线程安全的。
公平锁与非公平锁
ReentrantLock这个类有两个构造函数,一个是无参构造函数,一个是传入fair参数的构造函数。fair参数代表的是锁的公平策略,如果传入true就表示需要构造一个公平锁,反之则表示要构造一个非公平锁。
1 | //无参构造函数:默认非公平锁 |
管程中介绍的入口等待队列,锁都对应着一个等待队列,如果一个线程没有获得锁,就会进入等待队列,当有线程释放锁的时候,就需要从等待队列中唤醒一个等待的线程。如果是公平锁,唤醒的策略就是谁等待的时间长,就唤醒谁,很公平;如果是非公平锁,则不提供这个公平保证,有可能等待时间短的线程反而先被唤醒。
用锁的最佳实践
用锁能解决很多并发问题,但是可能导致死锁,也可能影响性能。用锁的三个最佳实践:
永远只在更新对象的成员变量时加锁
永远只在访问可变的成员变量时加锁
永远不在调用其他对象的方法时加锁
其他方法里面有线程sleep()的调用,也可能有奇慢无比的I/O操作,这些都会严重影响性能。其他类的方法可能也会加锁,然后双重加锁就可能导致死锁。
减少锁的持有时间
减小锁的粒度
总结
Java SDK并发包里的Lock接口里面的方法,除了支持类似synchronized隐式加锁的Lock()方法外,还支持超时、非阻塞、可中断的方式获取锁,这三种方式为编写更加安全、健壮的并发程序提供了很大的遍历。