1. ReentrantLock 介绍

ReentrantLock 是一个可重入的互斥(/独占)锁,又称为「独占锁」,是实现 Lock 接口的一个类。支持重入性,表示能够对共享资源能够重复加锁,即当前线程获取该锁再次获取不会被阻塞。在 java 关键字 synchronized 隐式支持重入性(关于synchronized可以看这篇文章),synchronized 通过获取自增,释放自减的方式实现重入。与此同时,ReentrantLock 还支持公平锁和非公平锁两种方式。 :::info synchronizedReentrantLock 都具有重入性,ReentrantLock 还支持公平锁和非公平锁两种方式。 ::: ReentrantLock 通过自定义队列同步器(AQS-AbstractQueuedSychronized,是实现锁的关键)来实现锁的获取与释放。

2. 重入性实现原理

支持重入性,需解决两个问题:

  1. 在线程获取锁的时候,如果已经获取锁的线程是当前线程的话则直接再次获取成功;
  2. 由于锁会被获取n次,那么只有锁在被释放同样的n次之后,该锁才算是完全释放成功。

针对第一个问题,我们来看看 ReentrantLock 是怎样实现的,以非公平锁为例,判断当前线程能否获得锁为例,核心方法为nonfairTryAcquire

  1. final boolean nonfairTryAcquire(int acquires) {
  2. final Thread current = Thread.currentThread();
  3. int c = getState();
  4. //1. 如果该锁未被任何线程占有,该锁能被当前线程获取
  5. if (c == 0) {
  6. if (compareAndSetState(0, acquires)) {
  7. setExclusiveOwnerThread(current);
  8. return true;
  9. }
  10. }
  11. //2.若被占有,检查占有线程是否是当前线程
  12. else if (current == getExclusiveOwnerThread()) {
  13. // 3. 再次获取,计数加一
  14. int nextc = c + acquires;
  15. if (nextc < 0) // overflow
  16. throw new Error("Maximum lock count exceeded");
  17. setState(nextc);
  18. return true;
  19. }
  20. return false;
  21. }

为了支持重入性,在第二步增加了处理逻辑,如果该锁已经被线程所占有了,会继续检查占有线程是否为当前线程,如果是的话,同步状态加1返回 true,表示可以再次获取成功。每次重新获取都会对同步状态进行加1的操作。

那么释放的时候处理思路是怎样的了?(依然还是以非公平锁为例)核心方法为tryRelease

  1. protected final boolean tryRelease(int releases) {
  2. //1. 同步状态减1
  3. int c = getState() - releases;
  4. if (Thread.currentThread() != getExclusiveOwnerThread())
  5. throw new IllegalMonitorStateException();
  6. boolean free = false;
  7. if (c == 0) {
  8. //2. 只有当同步状态为0时,锁成功被释放,返回true
  9. free = true;
  10. setExclusiveOwnerThread(null);
  11. }
  12. // 3. 锁未被完全释放,返回false
  13. setState(c);
  14. return free;
  15. }

重入锁的释放必须得等到同步状态为0时锁才算成功释放,否则锁仍未释放。

3. 公平锁与非公平锁

ReentrantLock 支持两种锁

  • 公平锁
  • 非公平锁

何谓公平性,是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合请求上的绝对时间顺序,满足FIFO

ReentrantLock 的构造方法无参时是构造非公平锁,源码为:

  1. public ReentrantLock() {
  2. sync = new NonfairSync();
  3. }

另外还提供了另外一种方式,可传入一个 boolean 值,true 时为公平锁,false 时为非公平锁,源码为:

  1. public ReentrantLock(boolean fair) {
  2. sync = fair ? new FairSync() : new NonfairSync();
  3. }

公平锁每次都是从同步队列中的第一个节点获取到锁,而非公平性锁则不一定,有可能刚释放锁的线程能再次获取到锁

公平锁 VS 非公平锁

  • 公平锁每次获取到锁为同步队列中的第一个节点,保证请求资源时间上的绝对顺序,而非公平锁有可能刚释放锁的线程下次继续获取该锁,则有可能导致其他线程永远无法获取到锁,造成「饥饿」现象
  • 公平锁为了保证时间上的绝对顺序,需要频繁的上下文切换而非公平锁会降低一定的上下文切换,降低性能开销。因此,ReentrantLock 默认选择的是非公平锁,则是为了减少一部分上下文切换,保证了系统更大的吞吐量

公平锁和非公平锁的实例

  1. public class FairLock implements Runnable{
  2. public static ReentrantLock fairLock = new ReentrantLock(true);
  3. public void run() {
  4. while (true) {
  5. try {
  6. fairLock.lock();
  7. System.out.println(Thread.currentThread().getName()+",获得锁!");
  8. }finally {
  9. fairLock.unlock();
  10. }
  11. }
  12. }
  13. public static void main(String[] args) {
  14. FairLock fairLock = new FairLock();
  15. Thread t1 = new Thread(fairLock, "线程1");
  16. Thread t2 = new Thread(fairLock, "线程2");
  17. t1.start();t2.start();
  18. }
  19. }

测试结果:

  1. 当参数设置为 true 时:线程1 和 线程2 交替进行 公平竞争 交替打印

    线程1,获得锁! 线程2,获得锁! 线程1,获得锁! 线程2,获得锁! 线程1,获得锁! 线程2,获得锁!

  2. 当参数设置为 false 时: 此时线程1 可以持续拿到锁 等线程1 执行完后,线程 2 才可以拿到线程,然后多次执行, 这就是使用可重入锁后是非公平机制,线程可以优先多次拿到执行权。

    线程1,获得锁! 线程1,获得锁! 线程1,获得锁! 线程1,获得锁! 线程1,获得锁! 线程2,获得锁! 线程2,获得锁! 线程2,获得锁! 线程2,获得锁!

4. 中断响应(lockInterruptibly

对于 synchronized 来说,如果一个线程在等待锁,那么结果只有两种情况,获得这把锁继续执行,或者线程就保持等待。而使用重入锁,提供了另一种可能,这就是线程可以被中断。也就是在等待锁的过程中,程序可以根据需要取消对锁的需求。

下面的例子中,产生了死锁,但得益于锁中断,最终解决了这个死锁:

  1. public class IntLock implements Runnable{
  2. public static ReentrantLock lock1 = new ReentrantLock();
  3. public static ReentrantLock lock2 = new ReentrantLock();
  4. int lock;
  5. /**
  6. * 控制加锁顺序,产生死锁
  7. */
  8. public IntLock(int lock) {
  9. this.lock = lock;
  10. }
  11. public void run() {
  12. try {
  13. if (lock == 1) {
  14. lock1.lockInterruptibly(); // 如果当前线程未被中断,则获取锁。
  15. try {
  16. Thread.sleep(500);
  17. } catch (InterruptedException e) {
  18. e.printStackTrace();
  19. }
  20. lock2.lockInterruptibly();
  21. System.out.println(Thread.currentThread().getName()+",执行完毕!");
  22. } else {
  23. lock2.lockInterruptibly();
  24. try {
  25. Thread.sleep(500);
  26. } catch (InterruptedException e) {
  27. e.printStackTrace();
  28. }
  29. lock1.lockInterruptibly();
  30. System.out.println(Thread.currentThread().getName()+",执行完毕!");
  31. }
  32. } catch (InterruptedException e) {
  33. e.printStackTrace();
  34. } finally {
  35. // 查询当前线程是否保持此锁。
  36. if (lock1.isHeldByCurrentThread()) {
  37. lock1.unlock();
  38. }
  39. if (lock2.isHeldByCurrentThread()) {
  40. lock2.unlock();
  41. }
  42. System.out.println(Thread.currentThread().getName() + ",退出。");
  43. }
  44. }
  45. public static void main(String[] args) throws InterruptedException {
  46. IntLock intLock1 = new IntLock(1);
  47. IntLock intLock2 = new IntLock(2);
  48. Thread thread1 = new Thread(intLock1, "线程1");
  49. Thread thread2 = new Thread(intLock2, "线程2");
  50. thread1.start();
  51. thread2.start();
  52. Thread.sleep(1000);
  53. thread2.interrupt(); // 中断线程2
  54. }
  55. }

线程 thread1 和 thread2 启动后,thread1 先占用 lock1,再占用 lock2;thread2 反之,先占 lock2,后占 lock1。这便形成 thread1 和 thread2 之间的相互等待。

代码 53 行(thread2.interrupt();),main 线程处于休眠(sleep)状态,两线程此时处于死锁的状态,代码 53 行 thread2 被中断(interrupt),故 thread2 会放弃对 lock1 的申请,同时释放已获得的 lock2。这个操作导致 thread1 顺利获得 lock2,从而继续执行下去。输出如下:
image.png

5. 锁申请 等待限时(tryLock)

除了等待外部通知(中断操作 interrupt )之外,限时等待也可以做到避免死锁。

通常,无法判断为什么一个线程迟迟拿不到锁。也许是因为产生了死锁,也许是产生了饥饿。但如果给定一个等待时间,让线程自动放弃,那么对系统来说是有意义的。可以使用 **tryLock()** 方法进行一次限时的等待。

  1. public class TimeLock implements Runnable{
  2. public static ReentrantLock lock = new ReentrantLock();
  3. public void run() {
  4. try {
  5. if (lock.tryLock(5, TimeUnit.SECONDS)) {
  6. Thread.sleep(6 * 1000);
  7. }else {
  8. System.out.println(Thread.currentThread().getName()+" get Lock Failed");
  9. }
  10. } catch (InterruptedException e) {
  11. e.printStackTrace();
  12. }finally {
  13. // 查询当前线程是否保持此锁。
  14. if (lock.isHeldByCurrentThread()) {
  15. System.out.println(Thread.currentThread().getName()+" release lock");
  16. lock.unlock();
  17. }
  18. }
  19. }
  20. /**
  21. * 在本例中,由于占用锁的线程会持有锁长达6秒,故另一个线程无法再5秒的等待时间内获得锁,因此请求锁会失败。
  22. */
  23. public static void main(String[] args) {
  24. TimeLock timeLock = new TimeLock();
  25. Thread t1 = new Thread(timeLock, "线程1");
  26. Thread t2 = new Thread(timeLock, "线程2");
  27. t1.start();
  28. t2.start();
  29. }
  30. }

上述例子中,由于占用锁的线程会持有锁长达 6 秒,故另一个线程无法在 5 秒的等待时间内获得锁,因此,请求锁失败。

**ReentrantLock.tryLock()** 方法也可以不带参数直接运行。这种情况下,当前线程会尝试获得锁,如果锁并未被其他线程占用,则申请锁成功,立即返回 true。否则,申请失败,立即返回 false,当前线程不会进行等待。这种模式不会引起线程等待,因此也不会产生死锁。

总结

对上面 ReentrantLock 的 几个重要方法整理如下:

  • **lock()**获得锁,如果锁被占用,进入等待。
    • 如果该锁没有被另一个线程保持,则获取该锁并立即返回,将锁的保持计数设置为 1。
    • 如果当前线程已经保持该锁,则将保持计数加 1,并且该方法立即返回。
    • 如果该锁被另一个线程保持,会进入 block 状态
  • **tryLock()**尝试获得锁,如果成功,立即放回 true,反之失败返回 false。该方法不会进行等待,立即返回。
  • **tryLock(long time, TimeUnit unit)**在给定的时间内尝试获得锁。
  • **lockInterruptibly()**获得锁(和 lock 一致),但优先响应中断(如果当前线程未被中断,则获取锁)。
    • 允许在等待时由其它线程调用等待线程的 Thread.interrupt() 方法来中断等待线程的等待而直接返回,这时不用获取锁,而会抛出一个 InterruptedException,并且清除当前线程的已中断状态。
    • 每个线程都有一个 打扰 标志。这里分两种情况,
      • 线程在 sleepwaitjoin, 此时如果别的进程调用此进程的 interrupt() 方法,此线程会被唤醒并被要求处理 InterruptedException
      • 此线程在运行中, 则不会收到提醒。但是 此线程的 「打扰标志」会被设置, 可以通过 isInterrupted() 查看并作出处理。
  • **unLock()**:释放锁。