在Java并发编程中,为了保证线程安全,我们需要使用锁机制进行控制。但是使用锁也会带来一些负面影响,比如死锁和性能损耗。这篇文章将介绍我们如何正确、合理地使用锁,以避免发生死锁,减少性能损耗。
一、避免死锁
有需要的可以先去看看这篇操作系统教材上关于死锁的知识:操作系统 关于死锁的面试题
死锁的必要条件有四个:
- 互斥条件:在一段时间内,某资源只能被一个进程占用
- 请求和保持条件:进程已保持了资源,又提出了新的资源请求,而该资源已被其他进程占有,此时该进程阻塞,但不释放已持有的资源
- 不可抢占条件:进程已经获得的资源,在未使用完之前不能被抢占,只能等该进程使用完后自己释放
- 循环等待条件:发生死锁时,存在一个进程-资源循环链,1等2,2等3,......
这四个必要条件,只要破坏其中一个,就可避免死锁。“互斥条件”是临界资源所必需的,无法破坏,因此主要是破坏其他的三个条件以避免死锁。那么具体到Java并发编程中,我们应该如何做呢?
1. 超时放弃
当使用synchronized关键字提供的内置锁时,只要线程没有获得锁,那么就会永远等待下去,而且无法放弃已经持有的锁。然而Lock接口的实现类ReentrantLock中,提供了 tryLock()和tryLock(long time, TimeUnit unit) 方法,该方法可以按照固定时长等待锁,如果获得了锁就万事大吉并返回true,如果在限制时间内没有获得锁,就会返回false。那么我们就可以根据其返回值来执行不同的操作,比如线程T获得了lock1,有用获取lock2,并用tryLock()获取lock2,这时我们可以在tryLock()返回false的时候手动释放lock1。也就是说,当线程获取锁失败时候,要释放已经持有的锁。
2. 以一致的顺序加锁
我们在并发编程时,尽量一次只获取一个锁。如果必须获取多个锁,即获取了一个锁,还没有释放,又去获取另一个锁,这种情况下,要让每个线程都按照一致的顺序获取锁,那么死锁就可以避免。否则就可能会产生死锁。如图:
此方法保证“循环等待”的条件不会成立。
二、提高锁的性能
锁的竞争必然会导致程序的整体性能下降。为了将这种副作用降到最低,这里提出一些关于使用锁的建议,遵循这些建议可以写出性能更高的程序。
1. 减少锁持有的时间
能用无锁的数据结构,就不要用锁;能锁代码块,就不要锁整个方法体;能用对象锁,就不要用类锁。也就是要让加锁的代码块工作量尽可能少,不要扩大加锁的范围,把无需加锁的代码也放进同步块。这样可以减少线程持有锁的时间,减少线程间的锁冲突。
2. 减小锁的粒度
减小锁的粒度是指缩小锁定对象的范围,从而降低锁冲突的可能性,进而提高系统的并发能力。比较典型的使用场景是ConcurrentHashMap,在JDK1.7中,采用的方式是将HashMap分段,并为每个段独立加锁,不至于将整个HashMap锁住。而在JDK1.8中就更进了一步,干脆直接锁住桶中的链表头结点或红黑树根节点。个人觉得和MySql中的表锁,页锁和行锁的机制有点像。关于ConcurrentHashMap更详细的介绍,可以看这篇文章:ConcurrentHashMap!你居然不知道1.7和1.8可不一样?!
3、锁粗化
通常情况下,为了减少线程见的冲突,应该尽可能缩短锁的持有时间,使用锁之后立即释放。但是呢,凡事需要辩证地看待。因为加锁和释放锁是要陷入内核且会发生上下文切换,是有成本的,如果一个线程连续地对同一个锁不断地请求和释放,或者说释放锁和再次加锁之间有一些无锁操作,但是这些操作很快就完成了,那么应该将对锁的多次请求和释放整合成一次请求和释放。这个操作叫作锁的粗化,代码实例如下
public void demoMethod{
synchronized(lock){
do sth 1.
}
do sth 2. (不需要同步,但是很快完成)
synchronized(lock){
do sth 3.
}
}
public void demoMethod{
// 整合成一次锁请求
synchronized(lock){
do sth 1.
do sth 2. (不需要同步,但是很快完成)
do sth 3.
}
}
另一个可能用到锁粗化进行锁优化的场景是,在循环体内请求锁时。不过要看具体情况,如果循环体内还有大量不需同步的代码,在循环体外面加锁就会使得锁的持有时间很长。具体看代码说明
for (int i = 0; i < MAX; i++){
synchronized(lock){
do sth...
}
}
// 在循环的外层只请求一次锁
synchronized(lock){
for (int i = 0; i < MAX; i++){
do sth...
}
}
4. 读写分离锁代替独占锁
如果说减小锁粒度通过分割数据结构实现的,那么读写分离锁则是对系统功能点的分割。所谓独占锁,就是无论对变量进行读操作还是写操作,都要加排他锁,使得本来不冲突的读和读之间也需要同步。Java的JUC包提供了一个读写分离锁,ReadWriteLock的ReentrantReadWriteLock实现类,其中的两个方法readLock()和writeLock()分别返回读锁和写锁。读写分离锁允许多线程同时读,尤其在读多写少的场合效果明显。
5. 锁分离
刚才说到的读写分离锁就是使用了锁分离的思想,但是这种思想可以延伸开来,即不同的功能尽量不要用同一个锁,要专锁专用。最典型的例子就是LinkedBlockingQueue(链表式的阻塞队列)。在这个队列中,take()方法从队头取数据,put()方法在队尾添加数据。当队列有大于1个元素时,两者是不冲突的,它们的功能不同,作用的空间也不同,大可不必在一端操作时,将整个队列锁住,以至于另一端也无法操作。而JDK中就是让take()方法和put()方法使用两把不同的锁,互不干扰。