并发编程-可重入锁ReentrantLock

1,152 阅读5分钟

今天我们来看看并发编程的可重入锁ReentrantLock,它和关键字synchronized非常的相像。ReentrantLock可以完全替代关键字synchronized,JDK6 以后synchronized进行了很多的优化,所以两者在性能上差距不大。建议能使用synchronized情况下优先使用,因为后续 JVM 还会对其实现进行持续优化。

那么它比synchronized有哪些优点呢?

1)支持公平锁和非公平锁:

在大多数情况下,锁的申请都是非公平的。当线程 1 请求了锁 A,线程 2 之后也请求了锁 A,当锁可用时,是谁获得这个锁呢?这是不一定的,系统会从锁的等待队列中随机挑选一个,因此不能保证公平性。开启公平锁模式必须要维护一个有序队列,所以会带来性能的损失。但是公平模式会保证有序性,并且不会产生饥饿现象。

2)支持响应中断以及限时获取锁:

对关键字synchronized而言,如果一个线程正在等待一把锁,那么结果只有两种:要么等待,要么获得锁。而ReentrantLock为我们提供了第三种可能,那就是响应中断(等着等着不等了),这种方式对处理死锁也是有帮助的。

限时获取锁指的是在一定时间内尝试获取锁,超时在不在获取返回 false。

3)更灵活的 api

这个也能算是它的一个优点,我们可以方便的控制临界区代码。

用法

lock

还是直接来看例子:

static ReentrantLock lock = new ReentrantLock();

static int j = 0;

public static void main(String[] args) throws InterruptedException {
 Task first = new Task();
 Task second = new Task();
 first.start();
 second.start();
 first.join();
 second.join();
 System.out.println(j);
}

public static class Task extends Thread {
 @Override
 public void run() {
  try {
   lock.lock();
   for (int i = 0; i < 1000; i++) {
    j++;
   }
  } finally {
   lock.unlock();
  }
 }
}

可以看到,和synchronized相比,ReentrantLock加锁和释放锁的操作都是由开发人员手动控制的,所以它有更高的灵活性。需要注意的是,锁在使用完必须释放,否则其他线程没有机会访问到临界区了。一般我们会在finally中进行释放。

为什么叫可重入锁呢?看下面的代码就明白了:

try {
 lock.lock();
 lock.lock();
 for (int i = 0; i < 1000; i++) {
  j++;
 }
} finally {
 lock.unlock();
 lock.unlock();
}

当获取到锁时再次进行获取是可以得到锁的,并不会因为自己已经持有了锁导致死锁。一定要注意,获取了几次便要释放几次锁。

lockInterruptibly 中断响应

对关键字synchronized而言,如果一个线程正在等待一把锁,那么结果只有两种:要么等待,要么获得锁。而ReentrantLock为我们提供了第三种可能,那就是响应中断(等着等着不等了),这种方式对处理死锁也是有帮助的。

先演示一个死锁的发生:

static ReentrantLock lock1 = new ReentrantLock();
static ReentrantLock lock2 = new ReentrantLock();

public static void main(String[] args) throws InterruptedException {
 Task t1 = new Task(1);
 Task t2 = new Task(2);
 t1.start();
 t2.start();
 t1.join();
}

public static class Task extends Thread {
 int lock; // 控制传入的锁

 public Task(int lock) {
  this.lock = lock;
 }

 @Override
 public void run() {

  if (lock == 1) {
   try {
    // 先获取lock1 再获取lock2
    lock1.lock();
    TimeUnit.SECONDS.sleep(1);
    lock2.lock();
   } catch (InterruptedException e) {
    e.printStackTrace();
   } finally {
    lock1.unlock();
    lock2.unlock();
   }
  } else {
   try {
    // 先获取lock2 再获取lock1
    lock2.lock();
    TimeUnit.SECONDS.sleep(1);
    lock1.lock();
   } catch (InterruptedException e) {
    e.printStackTrace();
   } finally {
    lock2.unlock();
    lock1.unlock();
   }
  }

 }
}

t1 获得 lock1 的时候,t2 同时获得了 lock2;它们想获得的第二个锁被对方持有,因而死锁。我们将程序稍事修改,即可解决这个死锁问题。

static ReentrantLock lock1 = new ReentrantLock();
static ReentrantLock lock2 = new ReentrantLock();

public static void main(String[] args) throws InterruptedException {
 Task t1 = new Task(1);
 Task t2 = new Task(2);
 t1.start();
 t2.start();
 TimeUnit.SECONDS.sleep(2);
 t1.interrupt();
 System.out.println("t1.interrupt()");
}

public static class Task extends Thread {
 int lock; // 控制传入的锁

 public Task(int lock) {
  this.lock = lock;
  setName("lock-" + lock);
 }

 @Override
 public void run() {

  try {

   if (lock == 1) {
    // 先获取lock1 再获取lock2
    lock1.lockInterruptibly();
    System.out.println(Thread.currentThread().getName() + "已获得获取lock1");
    TimeUnit.SECONDS.sleep(1);
    System.out.println(Thread.currentThread().getName() + "尝试获取lock2");
    lock2.lockInterruptibly();
    System.out.println(Thread.currentThread().getName() + "获取lock2成功,执行完毕");
   } else {
    // 先获取lock2 再获取lock1
    lock2.lockInterruptibly();
    System.out.println(Thread.currentThread().getName() + "已获得获取lock2");
    TimeUnit.SECONDS.sleep(1);
    System.out.println(Thread.currentThread().getName() + "尝试获取lock1");
    lock1.lockInterruptibly();
    System.out.println(Thread.currentThread().getName() + "获取lock1成功,执行完毕");
   }

  } catch (InterruptedException e) {
   e.printStackTrace();
  } finally {
   if (lock1.isHeldByCurrentThread()) {
    lock1.unlock();
   }
   if (lock2.isHeldByCurrentThread()) {
    lock2.unlock();
   }
  }
 }
}

线程可以响应中断,中断的线程会让出持有的锁,从而解决死锁的问题。这里还有个细节释放锁的时候通过lock.isHeldByCurrentThread()来判断,字如其意,如果这个锁被当前线程持有则释放。为什么需要这样判断,直接释放不可以吗?对不起不可以,因为线程中断的时候,锁已经被释放了,这里不判断的话会多次释放触发IllegalMonitorStateException

tryLock 锁申请等待限时

boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

这两个 api 看着都很直观,第一个尝试获取锁失败返回 false,第二个则是在一定时间内尝试获取,超时后返回 false。

同样,利用这种机制也可以减少死锁的发生。

重入锁的好搭档 Condition

类似于翻版的wait-notify,我们知道等待通知机制是配合synchronized先获取锁,然后才能wait-notify。在重入锁里也提供了这样类似实现。使用Condition来控制等待通知。

使用

void await() throws InterruptedException;
void awaitUninterruptibly();
long awaitNanos(long nanosTimeout) throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;
boolean awaitUntil(Date deadline) throws InterruptedException;
void signal();
void signalAll();

以上方法的含义如下:

  • await()方法会使当前线程等待,并释放当前锁。当其他线程正确使用了signal()或者signalAll()会返回。当前线程被中断,也能跳出等待。这和Object.wait()类似。
  • awaitUninterruptibly()不会在等待过程中响应中断
  • signal()唤醒一个正在等待的线程,signalAll()唤醒所有。这和Object.notify()/notifyAll()类似。

来看一个示例:

static ReentrantLock lock = new ReentrantLock();
static Condition condition = lock.newCondition();

public static void main(String[] args) throws InterruptedException {

 Task task1 = new Task();
 Task task2 = new Task();

 task2.start();
 task1.start();

 TimeUnit.SECONDS.sleep(3);
 try {
  lock.lock();
 // condition.signal();
  condition.signalAll(); //通知所有
  System.out.println("已通知");
 } finally {
  TimeUnit.SECONDS.sleep(2);
  lock.unlock();
 }
}

public static class Task extends Thread {
 @Override
 public void run() {
  try {
   lock.lock();
   condition.await();
   System.out.println(getName() + " over..");
  } catch (InterruptedException e) {
   e.printStackTrace();
  } finally {
   lock.unlock();
  }
 }
}

console output:

已通知
Thread-1 over..
Thread-0 over..

程序运行的效果是 3 秒后控制台输出已通知,再过 2 秒,输出Thread-0 over..和Thread-0 over..。可以看出这点和我们使用等待通知是类似的(notify 方法放在方法的最后一行)。通知时只有释放了锁,正在等待的线程才能恢复运行。

总结

今天我们学习了 J.U.C 中重要的可重入锁,它提供了更高的灵活性,可一定程度避免死锁,同时还有它的好搭档Condition。可以在很多软件中看见它的使用,下次我们将从源码层面分析其实现。