一、重入锁
重入锁可以完全替代关键字synchronized
。在JDK 5.0的早期版本中,重入锁的性能远远优于关键字synchronized
,但从JDK 6.0开始,JDK在关键字synchronized
上做了大量的优化,使得两者的性能差距并不大。重入锁使用java.util.concurrent.locks.ReentrantLock
类来实现。下面是一段最简单的重入锁使用案例。
import java.util.concurrent.locks.ReentrantLock;
public class ReenterLock implements Runnable {
public static ReentrantLock lock = new ReentrantLock();
public static int i=0;
public void run() {
for(int j=0;j<10000000;j++){
lock.lock();
try {
i++;
}finally {
lock.unlock();
}
}
}
public static void main(String[] args) throws InterruptedException {
ReenterLock r1 = new ReenterLock();
Thread t1 = new Thread(r1);
Thread t2 = new Thread(r1);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}
上述代码第8~13行使用重入锁保护临界区资源i
,确保多线程对i
操作的安全性。从这段代码可以看到,与关键字synchronized
相比,重入锁有着显示的操作过程。开发人员必须手动指定何时加锁,何时释放锁。也正因为这样,重入锁对逻辑控制的灵活性要远远优于关键字synchronized
。但值得注意的是,在退出临界区时,必须记得释放锁(代码第13行),否则,其他线程就没有机会再访问临界区了。
你可能会对重入锁的名字感到奇怪。锁为什么要加上“重入”两个字呢?从类的命名上看,Re- Entrant-Lock翻译成重入锁非常贴切。之所以这么叫,是因为这种锁是可以反复进入的。当然,这里的反复仅仅局限于一个线程。上述代码的第8~13行,可以写成下面的形式:
在这种情况下,一个线程连续两次获得同一把锁是允许的。如果不允许这么操作,那么同一个线程在第2次获得锁时,将会和自己产生死锁。程序就会“卡死”在第2次申请锁的过程中。但需要注意的是,如果同一个线程多次获得锁,那么在释放锁的时候,也必须释放相同次数。如果释放锁的次数多了,那么会得到一个java.lang.IllegalMonitorStateException
异常,反之,如果释放锁的次数少了,那么相当于线程还持有这个锁,因此,其他线程也无法进入临界区。
除使用上的灵活性以外,重入锁还提供了一些高级功能。比如,重入锁可以提供中断处理的能力。
二、中断响应
对于关键字synchronized
来说,如果一个线程在等待锁,那么结果只有两种情况,要么它获得这把锁继续执行,要么它就保持等待。而使用重入锁,则提供另外一种可能,那就是线程可以被中断。
也就是在等待锁的过程中,程序可以根据需要取消对锁的请求。有些时候,这么做是非常有必要的。
比如,你和朋友约好一起去打球,如果你等了半个小时朋友还没有到,你突然接到一个电话,说由于突发情况,朋友不能如约前来了,那么你一定扫兴地打道回府了。中断正是提供了一套类似的机制。如果一个线程正在等待锁,那么它依然可以收到一个通知,被告知无须等待,可以停止工作了。这种情况对于处理死锁是有一定帮助的
下面的代码产生了一个死锁,但得益于锁中断,我们可以很轻易地解决这个死锁。
import java.util.concurrent.locks.ReentrantLock;
public class IntLock implements Runnable {
public static ReentrantLock lock1 = new ReentrantLock();
public static ReentrantLock lock2 = new ReentrantLock();
int lock;
public IntLock(int lock) {
this.lock = lock;
}
@Override
public void run() {
try {
if (lock == 1) {
lock1.lockInterruptibly();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
lock2.lockInterruptibly();
} else {
lock2.lockInterruptibly();
try {
Thread.sleep(500);
} catch (InterruptedException e) {
}
lock1.lockInterruptibly();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (lock1.isHeldByCurrentThread()) {
lock1.unlock();
}
if (lock2.isHeldByCurrentThread()) {
lock2.unlock();
}
System.out.println(Thread.currentThread().getName() + " ---> 线程退出");
System.out.println(Thread.currentThread().getId() + " ---> 线程退出");
}
}
public static void main(String[] args) throws InterruptedException {
IntLock r1 = new IntLock(1);
IntLock r2 = new IntLock(2);
Thread t1 = new Thread(r1,"线程 r1");
Thread t2 = new Thread(r2,"线程 r2");
t1.start();
t2.start();
Thread.sleep(1000);
t2.interrupt();
}
}
线程t1和t2启动后,t1先占用lock1,再占用lock2;t2先占用lock2,再请求lock1。因此,很容易形成t1和t2之间的相互等待。在这里,对锁的请求,统一使用lockInterruptibly()
方法。这是一个可以对中断进行响应的锁申请动作,即在等待锁的过程中,可以响应中断。
在代码第51行,主线程main处于休眠状态,此时,这两个线程处于死锁的状态。在代码第49行,由于t2线程被中断,故t2会放弃对lock1的申请,同时释放已获得的lock2。这个操作导致t1线程可以顺利得到lock2而继续执行下去。
可以看到,中断后两个线程双双退出,但真正完成工作的只有t1,而t2线程则放弃其任务直接退出,释放资源。
三、锁申请等待限时
除了等待外部通知之外,要避免死锁还有另外一种方法,那就是限时等待。依然以约朋友打球为例,如果朋友迟迟不来,又无法联系到他,那么在等待1到2个小时后,我想大部分人都会扫兴离去。对线程来说也是这样。通常,我们无法判断为什么一个线程迟迟拿不到锁。
也许是因为死锁了,也许是因为产生了饥饿。如果给定一个等待时间,让线程自动放弃,那么对系统来说是有意义的。我们可以使用tryLock()
方法进行一次限时的等待。
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class TimeLock implements Runnable {
public static ReentrantLock lock = new ReentrantLock();
@Override
public void run() {
try {
if (lock.tryLock(5, TimeUnit.SECONDS)) {
Thread.sleep(6000);
} else {
System.out.println("Get lock failed");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
public static void main(String[] args) {
TimeLock lock1 = new TimeLock();
Thread t1 = new Thread(lock1);
Thread t2 = new Thread(lock1);
t1.start();
t2.start();
}
}
在这里,tryLock()
方法接收两个参数,一个表示等待时长,另外一个表示计时单位。这里的单位设置为秒,时长为5,表示线程在这个锁请求中最多等待5秒。如果超过5秒还没有得到锁,就会返回false。如果成功获得锁,则返回true。在本例中,由于占用锁的线程会持有锁长达6秒,故另一个线程无法在5秒的等待时间内获得锁,因此请求锁会失败。
ReentrantLock.tryLock()
方法也可以不带参数直接运行。在这种情况下,当前线程会尝试获得锁,如果锁并未被其他线程占用,则申请锁会成功,并立即返回true。如果锁被其他线程占用,则当前线程不会进行等待,而是立即返回false。这种模式不会引起线程等待,因此也不会产生死锁。下面演示了这种使用方式:
import java.util.concurrent.locks.ReentrantLock;
public class TryLock implements Runnable {
// 这里书中没有提到,这儿使用static,所以创建两个TryLock对象才会共用lock1和lock2
// 如果没有static会很快结束程序,因为使用的是自己的lock1和lock2不会互相锁住
public static ReentrantLock lock1 = new ReentrantLock();
public static ReentrantLock lock2 = new ReentrantLock();
int lock;
public TryLock(int lock) {
this.lock = lock;
}
@Override
public void run() {
if (lock == 1) {
while (true) {
if (lock1.tryLock()) {
try {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (lock2.tryLock()) {
try {
System.out.println(Thread.currentThread().getId() + " my job done");
return;
} finally {
lock2.unlock();
}
}
} finally {
lock1.unlock();
}
}
}
} else {
while (true) {
if (lock2.tryLock()) {
try {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (lock1.tryLock()) {
try {
System.out.println(Thread.currentThread().getId() + " my job done");
return;
} finally {
lock1.unlock();
}
}
} finally {
lock2.unlock();
}
}
}
}
}
public static void main(String[] args) {
TryLock lock1 = new TryLock(1);
TryLock lock2 = new TryLock(2);
Thread t1 = new Thread(lock1);
Thread t2 = new Thread(lock2);
t1.start();
t2.start();
}
}
上述代码采用了非常容易死锁的加锁顺序。也就是先让t1获得lock1,再让t2获得lock2,接着做反向请求,让t1申请lock2,t2申请lock1。在一般情况下,这会导致t1和t2相互等待,从而引起死锁。
但是使用tryLock()
方法后,这种情况就大大改善了。由于线程不会傻傻地等待,而是不停地尝试,因此,只要执行足够长的时间,线程总是会得到所有需要的资源,从而正常执行(这里以线程同时获得lock1和lock2两把锁,作为其可以正常执行的条件)。在同时获得lock1和lock2后,线程就打印出标志着任务完成的信息“MyJob done”。
执行上述代码,等待一会儿(由于线程中包含休眠500毫秒的代码)。最终你还是可以欣喜地看到程序执行完毕,并产生如下输出,表示两个线程双双正常执行。
四、公平锁
在大多数情况下,锁的申请都是非公平的。也就是说,线程1首先请求了锁A,接着线程2也请求了锁A。那么当锁A可用时,是线程1可以获得锁还是线程2可以获得锁呢?
这是不一定的,系统只是会从这个锁的等待队列中随机挑选一个。因此不能保证其公平性。这就好比买票不排队,大家都围在售票窗口前,售票员忙得焦头烂额,也顾不及谁先谁后,随便找个人出票就完事了。而公平的锁,则不是这样,它会按照时间的先后顺序,保证先到者先得,后到者后得。
公平锁的一大特点是:它不会产生饥饿现象。只要你排队,最终还是可以等到资源的。如果我们使用synchronized
关键字进行锁控制,那么产生的锁就是非公平的。而重入锁允许我们对其公平性进行设置。它的构造函数如下:
当参数fair为true时,表示锁是公平的。公平锁看起来很优美,但是要实现公平锁必然要求系统维护一个有序队列,因此公平锁的实现成本比较高,性能却非常低下,因此,在默认情况下,锁是非公平的。如果没有特别的需求,则不需要使用公平锁。公平锁和非公平锁在线程调度表现上也是非常不一样的。下面的代码可以很好地突出公平锁的特点:
public class FairLock implements Runnable {
//指定锁是公平的
public static ReentrantLock fairLock = new ReentrantLock(true);
@Override
public void run() {
while (true) {
try {
fairLock.lock();
System.out.println(Thread.currentThread().getName());
} finally {
fairLock.unlock();
}
}
}
public static void main(String[] args) {
FairLock r1 = new FairLock();
Thread t1 = new Thread(r1, "Thread_t1");
Thread t2 = new Thread(r1, "Thread_t2");
t1.start();
t2.start();
}
}
在这个输出中,很明显可以看到,两个线程基本上是交替获得锁的,几乎不会发生同一个线程连续多次获得锁的可能,从而保证了公平性。如果不使用公平锁,那么情况会完全不一样,下面是使用非公平锁时的部分输出:
可以看到,根据系统的调度,一个线程会倾向于再次获取已经持有的锁,这种分配方式是高效的,但是无公平性可言。
五、总结
对上面ReentrantLock
的几个重要方法整理如下:
lock()
:获得锁,如果锁已经被占用,则等待。lockInterruptibly()
:获得锁,但优先响应中断。tryLock()
:尝试获得锁,如果成功,则返回true,失败返回false。该方法不等待,立即返回。tryLock(long time, TimeUnit unit)
:在给定时间内尝试获得锁。unlock()
:释放锁。
就重入锁的实现来看,它主要集中在Java层面。在重入锁的实现中,主要包含三个要素。
第一,原子状态。原子状态使用CAS操作来存储当前锁的状态,判断锁是否已经被别的线程持有了。
第二,等待队列。所有没有请求到锁的线程,会进入等待队列进行等待。待有线程释放锁后,系统就能从等待队列中唤醒一个线程,继续工作。
第三,阻塞原语park()和unpark(),用来挂起和恢复线程。没有得到锁的线程将会被挂起。