您的当前位置:首页正文

多线程\并发编程——ReentrantLock 详解

2024-11-30 来源:个人技术集锦

多线程\并发编程——ReentrantLock 详解


在上一篇文章中,我们学习了基于互斥同步实现线程安全的的一种典型的 块结构同步手段——Synchronized。但是我们也认识到了 Synchronized 保证线程安全是使用操作系统级别的互斥量来完成的,线程的阻塞和唤醒都需要操作系统来帮忙完成,需要频繁的进行用户态和内核态的切换,消耗大量的系统资源,且无法由我们程序员在代码层面来进行显式的切换,所以说 Synchronized 是 Java 语言的一个重量级操作

由于 Synchronized 本身的局限性,除 Synchronized 关键字之外,自 JDK 1.5 起,Java 的类库中提供了 java.util.concurrent 包(后面简称 J.U.C),其中的 java.util.concurrent 接口便成了 Java 的另一种全新的互斥同步手段。基于 Lock 接口,我们能以 非块结构 来实现互斥同步,摆脱语言特性的束缚,改为在类库层面实现同步,为以后拓展出各种同步锁提供了广阔的空间

一、ReentrantLock 的基本使用

重入锁(ReentrantLock)是 Lock 接口最常见的一种实现,顾名思义,它也是可重入锁。在基本用法上,ReentrantLock 也与 Synchronized 很相似,只是代码写法上稍有区别而已

ReentrantLock 并不是用来替代 Synchronized 的,而是内置加锁不适用时,作为一种可选择的高级功能

1、锁的可重入性语义

以下代码的解释:

  • 方法 m1() 和 m2() 都是锁定实例对象,想要执行这两个方法,需要获得类 ReentrantLock1 的实例对象的锁
  • 启动一个线程,通过实例对象 rl,执行方法 m1(),则线程获得了实例对象 rl 的锁
  • 在方法 m1() 执行期间要调用方法 m2(),由于线程已经具备了 实例对象 rl 的锁,所以可以直接执行加锁方法 m2(),不需要在重复获得锁,只需要把锁的计数器加一即可
  • 这就是可重入性语义
public class ReentrantLock1 {
   //执行方法 m1 和 m2 需要的都是实例对象锁
   synchronized void m1() {
      for(int i=0; i<10; i++) {
         try {
            TimeUnit.SECONDS.sleep(1);
         } catch (InterruptedException e) {
            e.printStackTrace();
         }
         System.out.println(i);
         //由于是同一个线程调用两个方法,执行第一个方法的时候,线程就拿到了这个对象的锁,在执行第二个方法的时候
         //第二个方法同样要尝试拿到对象的锁,一检查发现就是这个线程拿到的锁,直接拿来用即可,无需再次尝试加锁
         //这就是可重入锁的概念,synchronized必然是可重入锁。
         if(i == 2) m2();
      }

   }
   
   synchronized void m2() {
      System.out.println("m2 方法运行...");
   }
   
   public static void main(String[] args) {
      ReentrantLock1 rl = new ReentrantLock1();
      new Thread(rl::m1).start();
      try {
         TimeUnit.SECONDS.sleep(1);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      //另起一个线程,也是尝试对这个对象加锁,则它必须要等到第一个线程执行完释放锁之后,才可以获得锁并运行;所以期间第一个线程 sleep,第二个线程也不能获得锁并执行
      new Thread(rl::m2).start();
   }
}

2、lock() 和 unLock()

API:

lock
public void lock()
获取锁。
如果该锁没有被另一个线程保持,则获取该锁并立即返回,将锁的保持计数设置为 1。
如果当前线程已经保持该锁,则将保持计数加 1,并且该方法立即返回。
如果该锁被另一个线程保持,则出于线程调度的目的,禁用当前线程,并且在获得锁之前,该线程将一
直处于休眠状态,此时锁保持计数被设置为 1

可以认为 ReentrantLock 是可以替代 Synchronized 的,只需要在原来使用 Synchronized 的地方替换成 lock.lock();**但是要注意隐式锁 Synchronized 的加锁和解锁是由操作系统来调度的,使用显式锁 ReentrantLock 的解锁操作需要手动完成,所以必须要在 finally 语句块中执行 unlock 方法,保证无论发生任何异常的情况下锁都能够被释放 **

具体代码参考如下:

/**
 * 使用reentrantlock可以完成同样的功能
 * 需要注意的是,必须要必须要必须要手动释放锁(重要的事情说三遍)
 * 使用syn锁定的话如果遇到异常,jvm会自动释放锁,但是lock必须手动释放锁,因此经常在finally中进行锁的释放
 */
public class ReentrantLock2 {
   ReentrantLock lock = new ReentrantLock();

   void m1() {
      try {
         lock.lock(); //等同于synchronized(this)
         for (int i = 0; i < 10; i++) {
            TimeUnit.SECONDS.sleep(1);
            System.out.println(i);
            if ( i == 2) m2();
         }
      } catch (InterruptedException e) {
         e.printStackTrace();
      } finally {
          //必须要在 finally 语句块中执行 unlock 方法,保证任何情况下锁都能够被释放
         lock.unlock();
      }
   }

   void m2() {
      try {
         lock.lock();
         System.out.println("m2 ...");
      } finally {
         lock.unlock();
      }
   }

   public static void main(String[] args) {
      ReentrantLock2 rl = new ReentrantLock2();
      new Thread(rl::m1).start();
      try {
         TimeUnit.SECONDS.sleep(1);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      new Thread(rl::m2).start();
   }
}

注意:解锁 unlock 操作一定要写在 finall 语句块里面,保证执行期间不管发生任何意外情况都一定会解锁

以下三个特点是 ReentrantLock 相较于 Synchronized 中比较有优势的特性

3、tryLock() – 尝试加锁

API:

tryLock    
public boolean tryLock()

仅在调用时锁未被另一个线程保持的情况下,才获取该锁。 

1)如果该锁没有被另一个线程保持,并且立即返回 true 值,则将锁的保持计数设置为 1。
即使已将此锁设置为使用公平排序策略,但是调用 tryLock() 仍将 立即获取锁(如果有可用的),
而不管其他线程当前是否正在等待该锁。在某些情况下,此“闯入”行为可能很有用,即使它会打破公
平性也如此。如果希望遵守此锁的公平设置,则使用 tryLock(0, TimeUnit.SECONDS) 
,它几乎是等效的(也检测中断)。 

2)如果当前线程已经保持此锁,则将保持计数加 1,该方法将返回 true3)如果锁被另一个线程保持,则此方法将立即返回 false 值。 

线程执行 Synchronized 同步代码,如果无法获得锁,就会阻塞在那里,处在 Blocked 状态;但是如果使用 ReentrantLock ,我们可以通过 tryLock() 方法来进行尝试加锁,此方法返回值是 boolean 类型,可以根据方法的返回值来确定当前线程是否获得锁

使用 tryLock() 尝试获得锁,不管锁定与否,方法都能将继续执行;不过可以根据是否能够获得锁,来决定方法的不同的执行逻辑

public class TestTryLock {
   ReentrantLock lock = new ReentrantLock();

   void m1() {
      try {
         lock.lock();
         //线程 1 在此处循环调整释放锁的时机,循环是否超过 5 次决定了线程 2 是否能够获得锁
         for (int i = 0; i < 3; i++) {
            TimeUnit.SECONDS.sleep(1);
            System.out.println(i);
         }
      } catch (InterruptedException e) {
         e.printStackTrace();
      } finally {
         lock.unlock();
      }
   }

   /**
    * 使用tryLock进行尝试锁定,不管锁定与否,方法都将继续执行
    * 可以根据tryLock的返回值来判定是否锁定
    * 也可以指定tryLock的时间,由于tryLock(time)抛出异常,所以要注意unclock的处理,必须放到finally中
    */
   void m2() {

      boolean locked = false;
      
      try {
         // tryLock 方法的返回值是 boolean 类型:如果 5 秒种之内能够获得锁,就可以返回 true,否则返回 false
         locked = lock.tryLock(5, TimeUnit.SECONDS);
         System.out.println("m2 是否获得了锁?-- " + locked);

         //使用 tryLock 可以根据方法返回值,来决定方法的允许逻辑,哪怕未获得锁,方法仍然可以向下运行
         if (locked == true){
            //....省略若干逻辑代码
            System.out.println("m2 获得锁的执行逻辑");
         }else if (locked == false){
            //未获得锁,方法仍然得以运行
            System.out.println("m2 未获得锁的执行逻辑");
         }

      } catch (InterruptedException e) {
         e.printStackTrace();
      } finally {
          //如果获得锁,则必须手动释放锁
         if(locked) lock.unlock();
      }
      
   }

   public static void main(String[] args) {
      TestTryLock rl = new TestTryLock();
      new Thread(rl::m1).start();
      try {
         TimeUnit.SECONDS.sleep(1);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      new Thread(rl::m2).start();
   }
}

4、lockInterruptibly() – 等待可中断

等待可中断:是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。可中断特性对处理执行时间非常长的同步块很有帮助

API:

lockInterruptibly
public void lockInterruptibly() throws InterruptedException
1)如果当前线程未被中断,则获取锁。 
 
2)如果该锁没有被另一个线程保持,则获取该锁并立即返回,将锁的保持计数设置为 13)如果当前线程已经保持此锁,则将保持计数加 1,并且该方法立即返回。 
 
4)如果锁被另一个线程保持,则出于线程调度目的,禁用当前线程,并且在发生以下两种情况之一以
前,该线程将一直处于休眠状态: 
     1)锁由当前线程获得;或者 
 
     2)其他某个线程中断当前线程。 
 
5)如果当前线程获得该锁,则将锁保持计数设置为 1。 
   如果当前线程: 
       1)在进入此方法时已经设置了该线程的中断状态;或者 
 
       2)在等待获取锁的同时被中断。 
 
   则抛出 InterruptedException,并且清除当前线程的已中断状态。 
 
 
6)在此实现中,因为此方法是一个显式中断点,所以要优先考虑响应中断,而不是响应锁的普通获取或
重入获取 

lock()与lockInterruptibly()的区别:

  • Synchronized 及 ReentrantLock.lock方法不允许Thread.interrupt中断,即使检测到Thread.isInterrupted,一样会继续尝试获取锁,失败则继续休眠;只是在最后获取锁成功后再把当前线程置为interrupted状态,然后再中断线程
  • ReentrantLock.lockInterruptibly允许在等待时由其它线程调用等待线程的Thread.interrupt方法来中断等待线程的等待而直接返回,这时不用获取锁,而会抛出一个InterruptedException,我们可以在处理 Exception 中执行不需要加锁的逻辑

简单总结就是:

Synchronized 和 lock() 方法优先考虑获得锁,待获取锁成功后,才响应中断

lockInterruptibly() 方法优先考虑响应中断,而不是响应锁的普通获或重入获取

示例代码如下:

public class TestInterrupt {
      
   public static void main(String[] args) {
      ReentrantLock reentrantLock = new ReentrantLock();

      Thread t1 = new Thread(()->{
         System.out.println("t1 start");
         try {
            reentrantLock.lock();
            System.out.println("t1 获得锁并执行");
            //线程 t1 获得锁,但是调用 sleep 方法休眠一天,进入 Blocked 状态,而且并不会释放锁
            TimeUnit.SECONDS.sleep(60 * 60 * 24);

         } catch (InterruptedException e) {
            System.out.println("线程 t1 被中断运行,锁资源被释放");
         } finally {
            reentrantLock.unlock();
         }
      });
      t1.start();
      
      Thread t2 = new Thread(()->{
         System.out.println("t2 start");
         try {
            //通过 lock 方法加锁,无法对interrupt方法作出响应
//            reentrantLock.lock();

            //通过 lockInterruptibly() 方法进行加锁,可以对 interrupt 方法作出响应
            reentrantLock.lockInterruptibly();
             
            System.out.println("t2 获得锁并正常执行需要加锁的工作");
         } catch (InterruptedException e) {
            System.out.println("t2 未获得锁,但是可以对interrupt方法作出响应,阻塞状态被打断");
            System.out.println("t2 去执行一些不需要加锁的工作");
         } finally {
            try {
               reentrantLock.unlock();
            } catch (Exception e) {
            }
         }
      });
      t2.start();

      try {
         TimeUnit.SECONDS.sleep(1);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      //t2 未获得锁的情况下打断其阻塞状态,并通过lockInterruptibly() 方法做出响应
      //执行不需要加锁的执行逻辑
      t2.interrupt();
   }
}

运行结果:

t1 start
t1 获得锁并执行
t2 start
t2 未获得锁,但是可以对interrupt方法作出响应,阻塞状态被打断
t2 去执行一些不需要加锁的工作

5、公平锁

公平锁:是指当多个线程在等待同一个锁的时候,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都会抢夺锁,都有机会获得锁,甚至刚释放锁的线程仍然可以再次抢夺锁

Synchronized 中的锁是非公平的,ReentrantLock 默认情况下和 Synchronized 一样是非公平锁,但是 ReentrantLock 可以通过带 boolean 值的构造函数要求使用公平锁

ReentrantLock 设置公平锁示例如下:

public class TestFireLock extends Thread {
    //参数为 true 表示为公平锁,请对比输出结果
    private static ReentrantLock lock=new ReentrantLock(true);
    public void run() {
        for(int i=0; i<100; i++) {
            lock.lock();
            try{
                Thread.sleep(1);
                System.out.println(Thread.currentThread().getName()+"获得锁");
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally{
                lock.unlock();
            }
        }
    }
    public static void main(String[] args) {
        TestFireLock fireLock = new TestFireLock();
        Thread th1 = new Thread(fireLock);
        Thread th2 = new Thread(fireLock);
        th1.start();
        th2.start();
    }
}

当设置为公平锁时,运行结果必然是如下这种公平交替运行的情况:

Thread-1获得锁
Thread-2获得锁
Thread-1获得锁
Thread-2获得锁
Thread-1获得锁
Thread-2获得锁
...

如果不进行公平锁的设置,两条线程将会抢夺锁资源,可能会出现任何情况,比如一个线程连续若干次抢夺到锁,另一个线程很少抢到锁:

Thread-2获得锁
Thread-2获得锁
Thread-2获得锁
Thread-1获得锁
Thread-1获得锁
...

不过一旦使用公平锁,会导致 ReentrantLock 的性能急剧下降,明显影响吞吐量,而且大多数情况下我们并不需要维护公平性;只有在一些特殊的、需要保证公平性的情况下,才会使用公平锁

6、Condition–锁绑定多个条件

锁绑定多个条件:是指一个 ReentantLock 对象可以同时绑定多个 Condition 对象。

在 Synchronized 中,锁对象的 wait() 和它的 notify() 方法配合可以实现一个隐含的条件,如果要和多于 1 个的条件关联的时候,就不得不额外添加一个锁;而 ReentantLock 则无需这样做,多次调用 newCondition() 方法即可

经典的生产者消费者实现是通过 Synchronized 来完成——当生产者线程把容器生产满了的时候,会调用 notifyAll() 方法,唤醒所有生产者、消费者线程抢夺容器的锁资源,此时如果还是生产者抢到了容器的锁,它是没有办法继续生产的,只会再次调用 notifyAll() 方法,直到有消费者线程把容器消费出空闲容量;消费者线程也是同理

当生产者线程 wait 的时候,是没有必然唤醒其他的生产者线程的(消费者线程同理),有没有一种方法能够唤醒指定的线程呢?此时可以使用 ReentantLock 中的 Condition ,使线程分为不同的条件队列

Condition的本质就是不同的等待队列

  • 使用Sync的时候,只有一个等待队列,所有的生产者消费者都在一个等待队列,所有线程一拥而上抢锁
  • ReentrantLock 的 Condition 可以创建多个等待队列,这样在唤醒线程的时候,就可以只唤醒指定等待队列的线程

ReentantLock 实现生产者–消费者模型代码示例:

public class TestCondition<T> {
   final private LinkedList<T> lists = new LinkedList<>();
   final private int MAX = 10; //最多10个元素
   private int count = 0;
   
   private Lock lock = new ReentrantLock();
   //生产者线程队列
   private Condition producer = lock.newCondition();
   //消费者线程队列
   private Condition consumer = lock.newCondition();
   
   public void put(T t) {
      try {
         lock.lock();
         while(lists.size() == MAX) {
            producer.await();
         }
         
         lists.add(t);
         ++count;
         //通知消费者线程进行消费,这唤醒消费者
         consumer.signalAll();
      } catch (InterruptedException e) {
         e.printStackTrace();
      } finally {
         lock.unlock();
      }
   }
   
   public T get() {
      T t = null;
      try {
         lock.lock();
         while(lists.size() == 0) {
            consumer.await();
         }
         t = lists.removeFirst();
         count --;
         //通知生产者进行生产,只唤醒生产者
         producer.signalAll();
      } catch (InterruptedException e) {
         e.printStackTrace();
      } finally {
         lock.unlock();
      }
      return t;
   }
   
   public static void main(String[] args) {
      TestCondition<String> c = new TestCondition<>();
      //启动消费者线程
      for(int i=0; i<10; i++) {
         new Thread(()->{
            for(int j=0; j<5; j++) System.out.println(c.get());
         }, "c" + i).start();
      }
      
      try {
         TimeUnit.SECONDS.sleep(2);
      } catch (InterruptedException e) {
         e.printStackTrace();
      }
      
      //启动生产者线程
      for(int i=0; i<2; i++) {
         new Thread(()->{
            for(int j=0; j<25; j++) c.put(Thread.currentThread().getName() + " " + j);
         }, "p" + i).start();
      }
   }
}

用到的方法的补充说明:

​ await() :阻塞当前队列的所有线程

​ signal() :唤醒指定的等待队列

​ signalAll() :唤醒所有的等待队列

二、ReentantLock 的底层实现—AQS

AQS 即 AbstractQueuedSynchronized,底层数据结构是 FIFO 双向队列,AQS是很多同步工具锁的底层实现,比如ReentrantLock、CountDownLatch、CyclicBarrier等,所以理解 AQS 是非常有必要的

AQS 的核心是 State——标志锁的状态

  • int 类型数据,且被volatile修饰,保证线程间可见
  • State这个值是根据子类的不同实现,取不同的意义

如果对于ReentrantLock,state是0就表示没有加锁,从0变成1就表示有一个线程加了锁,此后如果还是这个线程重入,值就会递增,什么时候这个值不断递减至0,就说明这个锁被释放了;如果对于CountDownLatch,state就可能代表门闩latch的默认限制数量,对于CyclicBarrier中可能代表栅栏的限制等等

State后面跟着一个队列,这个队列是双向链表,由AQS维护,里面包含一堆的线程节点node,每个node里面封装了一个线程,这么多的线程节点去争用state,谁先抢到state,谁就拿到了锁。示意图如下:

理解 AQS 的核心,就是理解 State 的意义以及双向队列如何通过 CAS 操作来完成双向队列中节点正确的入队列、出队列实现

由于 AQS 很重要,是很多并发工具类的底层实现核心,而且其实现很复杂,在这个 ReentrantLock 的专题来讲有点喧宾夺主,所以我们后面单独用一篇文章来讲 AQS,这里简单了解 ReentrantLock 的底层实现核心是 AQS 即可

三、ReentantLock 与 Synchronized 的异同(面试常问)

1、相同点

Synchronized与 ReentantLock 的相同点:

  • 都是可重入锁
  • 实现线程安全的方式相同:都是通过互斥实现同步,都是阻塞式的同步

2、不同点

1、加锁、解锁方式
  • Synchronized 是 JVM 自动加锁,加锁解锁操作都映射到操作系统来自动完成;ReentantLock 是手动加锁,加锁方式和时机可以自己选择

  • Synchronized 遇到异常情况,会自动释放锁;ReentantLock 遇到异常不会释放锁,所以必须要在 finall 块中手动释放锁

2、是否支持等待可中断

详细区别:

  • Synchronized 不允许Thread.interrupt中断,即使检测到Thread.isInterrupted,一样会继续尝试获取锁,失败则继续休眠;只是在最后获取锁成功后再把当前线程置为interrupted状态,然后再中断线程
  • ReentantLock 使用 lockInterruptibly() 方法加锁允许在等待时由其它线程调用等待线程的Thread.interrupt方法来中断等待线程的等待而直接返回,这时不用获取锁,而会抛出一个InterruptedException,我们可以在处理 Exception 中执行不需要加锁的逻辑

简单总结区别就是:

  • Synchronized 优先考虑获得锁,待获取锁成功后,才响应中断
  • ReentantLock 使用 lockInterruptibly() 方法加锁,优先考虑响应中断,而不是响应锁的普通获或重入获取
3、是否公平锁
  • Synchronized 中的锁是非公平的
  • ReentrantLock 默认情况下和 Synchronized 一样是非公平锁,但是 ReentrantLock 可以通过带 boolean 值的构造函数要求使用公平锁
4、条件 Condition
  • 在 Synchronized 中,锁对象的 wait() 和它的 notify() 方法配合可以实现一个隐含的条件,如果要和多于 1 个的条件关联的时候,就不得不额外添加一个锁
  • ReentantLock 对象可以同时绑定多个 Condition 对象,不需要多次添加锁

四、ReentantLock 与 Synchronized 的场景选择

根据上面的讨论,ReentantLock 在功能上是 Synchronized 的超集,在性能上也不会弱于 Synchronized ,那 Synchronized 是否就应该被抛弃,不被使用了呢?当然不是,实际上基于以下情况我们大多数情况下,还是会有限使用 Synchronized,而不是 ReentantLock

  • Synchronized 是在 Java 语法方面的同步,足够清晰,也足够简单。每个程序员都熟悉 Synchronized,但是却并不一定熟悉 J.U.C.Lock 接口的使用。因此在只需要基础的同步功能时。仍然优先选择 Synchronized
  • Lock 应该确保在 finally 块中释放锁,否则一旦同步代码中抛出异常,则很有可能永远也不会释放持有的锁。这一点必须由程序员编写的代码来保证,如果有新手忘记编写,则很有可能导致重大生产事故;而Synchronized 由 JVM 保证即使出现异常,锁也能被自动释放
  • 从长远来看, JVM 更易对 Synchronized 进行优化。因为 JVM 可以在线程和对象的元数据中记录 Synchronized 锁相关的信息,而使用 JUC.Lock 的话,JVM 是很难得知具体那些锁对象是由特定线程所持有的

参考:《深入理解JAVA虚拟机》

关联文章:

显示全文