Java锁机制浅析:到底什么情况下该用ReentrantLock?

上一篇 / 下一篇  2023-05-19 10:03:47

  在多线程编程中,锁(Lock)是一种重要的同步机制,它可以保证同一时间只有一个线程可以访问共享资源。Java 中提供了两种类型的锁:隐式锁和显式锁。
  隐式锁通过 synchronized 关键字实现,在使用时比较方便,但其粒度较大,无法满足复杂的同步需求。而显式锁则通过 Lock 接口实现,可以更灵活地控制锁的粒度和行为。本文将介绍 Java 显式锁中的显示锁(ReentrantLock)和显示条件队列(Condition),并讨论它们的使用方法、进阶用法以及可能遇到的问题和解决方案。
  一、显示锁
  1、简介
  显示锁(ReentrantLock)是 Java 显式锁中最常用的一种,它实现了 Lock 接口的所有特性,并提供了可重入和公平性等额外功能。其中,可重入指同一线程可以多次获取该锁而不会造成死锁,公平性指多个线程按照申请锁的顺序获得锁。
  与隐式锁不同的是,显示锁需要手动加锁和释放锁,通常使用 try-finally 语句块保证锁的正确释放,避免异常导致锁未能被及时释放而造成死锁。
  2、基本使用
  显示锁(ReentrantLock)的基本用法如下:
  import java.util.concurrent.locks.Lock;
  import java.util.concurrent.locks.ReentrantLock;
  public class MyRunnable implements Runnable {
      private final Lock lock = new ReentrantLock();
      private int count = 0;
      public void run() {
          lock.lock(); // 加锁
          try {
              count++; // 访问共享资源
          } finally {
              lock.unlock(); // 解锁
          }
      }
  }
  在上述示例中,我们首先创建了一个 ReentrantLock 对象,并将其作为同步对象(Monitor)来访问共享资源。然后,在访问共享资源时使用 lock.lock() 方法加锁,使用 lock.unlock() 方法解锁。由于 lock 和 unlock 方法都可能抛出异常,因此通常需要使用 try-finally 语句块来确保锁的正确释放。
  3、可重入性
  在 Java 中,可重入性指同一线程可以多次获得该锁而不会产生死锁或排斥自己的情况。这是由于每个线程在加锁时会记录加锁的次数,只有在解锁和加锁次数相等时才真正释放锁。例如:
  import java.util.concurrent.locks.Lock;
  import java.util.concurrent.locks.ReentrantLock;
  public class MyRunnable implements Runnable {
      private final Lock lock = new ReentrantLock();
      private int count = 0;
      public void run() {
          lock.lock(); // 第一次加锁
          try {
              count++; // 访问共享资源
              lock.lock(); // 第二次加锁
              try {
                  count++; // 访问共享资源
              } finally {
                  lock.unlock(); // 第二次解锁
              }
          } finally {
              lock.unlock(); // 第一次解锁
          }
      }
  }
  在上述示例中,我们先后两次获取了同一个锁,并在其中访问了共享资源。由于锁是可重入的,因此即使在第二次加锁时仍然持有锁,也不会产生死锁或排斥自己的情况。
  4、公平性
  在 Java 中,公平性指多个线程按照申请锁的顺序获得锁的特性。公平性可以避免某些线程长期持有锁,导致其他线程无法获得锁而等待过长时间的情况。
  在显示锁中,默认情况下是非公平的,即当前线程可以随时获得锁,而不考虑其他线程的申请顺序。这样可能会导致某些线程一直无法获得锁,从而产生线程饥饿(Thread Starvation)的问题。
  为了解决这个问题,Java 中提供了公平锁(FairLock),它会按照线程申请锁的顺序进行排队,并且保证先来先得的原则。示例代码如下:
  import java.util.concurrent.locks.Lock;
  import java.util.concurrent.locks.ReentrantLock;
  public class MyRunnable implements Runnable {
      private final Lock lock = new ReentrantLock(true); // 公平锁
      private int count = 0;
      public void run() {
          lock.lock(); // 加锁
          try {
              count++; // 访问共享资源
          } finally {
              lock.unlock(); // 解锁
          }
      }
  }
  在上述示例中,我们创建了一个公平锁(FairLock),并将其传递给 ReentrantLock 的构造函数中。然后,在访问共享资源时使用 lock.lock() 方法加锁,使用 lock.unlock() 方法解锁。由于公平锁会按照线程申请锁的顺序进行排队,因此可以避免线程饥饿的问题。
  二、显示条件队列
  1、简介
  条件队列(Condition)是 Java 显式锁中实现线程等待/通知机制的一种方式。它允许多个线程在某些条件不满足时暂停执行,并在特定条件满足时恢复执行。与 synchronized 关键字相比,条件队列提供了更灵活和细粒度的同步控制,可以更好地支持复杂的同步需求。
  条件队列通常与显示锁一起使用,通过
  ReentrantLock.newCondition() 方法创建一个 Condition 对象,并使用 await()、signal() 和 signalAll() 等方法来进行线程等待和唤醒操作。其中,await() 方法用于使当前线程等待某个条件发生变化,signal() 方法用于唤醒一个等待该条件的线程,signalAll() 方法用于唤醒所有等待该条件的线程。
  2、基本使用
  import java.util.concurrent.locks.Condition;
  import java.util.concurrent.locks.Lock;
  import java.util.concurrent.locks.ReentrantLock;
  public class MyRunnable implements Runnable {
      private final Lock lock = new ReentrantLock();
      private final Condition condition = lock.newCondition();
      private boolean flag = false;
      public void run() {
          lock.lock(); // 加锁
          try {
              while (!flag) {
                  condition.await(); // 等待条件变化
              }
              // 访问共享资源
          } catch (InterruptedException e) {
              Thread.currentThread().interrupt();
          } finally {
              lock.unlock(); // 解锁
          }
      }
      public void changeFlag() {
          lock.lock(); // 加锁
          try {
              flag = true; // 修改条件
              condition.signalAll(); // 唤醒等待的线程
          } finally {
              lock.unlock(); // 解锁
          }
      }
  }
  在上述示例中,我们首先创建了一个 Condition 对象,并将其关联到一个显示锁(ReentrantLock)上。然后,在访问共享资源时使用 while 循环判断条件是否满足,如果不满足则调用 condition.await() 方法使当前线程进入等待状态。在修改条件时调用 changeFlag() 方法,并使用 condition.signalAll() 唤醒所有等待该条件的线程。需要注意的是,await() 方法和 signal()/signalAll() 方法都必须在锁保护下进行调用,否则会抛出
  IllegalMonitorStateException 异常。
  3、进阶使用
  条件队列(Condition)还提供了许多高级操作,用于支持更复杂的同步需求。以下是一些常用的进阶使用方式:
  (1)等待超时
  有时候我们希望线程在等待一段时间后自动唤醒,而不是一直等待到被唤醒为止。这时候可以使用 condition.await(long time, TimeUnit unit) 方法,它允许我们指定等待的最长时间,如果超过指定时间仍未被唤醒,则自动退出等待状态。示例代码如下:
  import java.util.concurrent.locks.Condition;
  import java.util.concurrent.locks.Lock;
  import java.util.concurrent.locks.ReentrantLock;
  import java.util.concurrent.TimeUnit;
  public class MyRunnable implements Runnable {
      private final Lock lock = new ReentrantLock();
      private final Condition condition = lock.newCondition();
      private boolean flag = false;
      public void run() {
          lock.lock(); // 加锁
          try {
              long timeout = 10L; // 等待 10 秒
              while (!flag) {
                  if (!condition.await(timeout, TimeUnit.SECONDS)) {
                      // 在等待一定时间后还未被唤醒,做相应处理
                      break;
                  }
              }
              // 访问共享资源
          } catch (InterruptedException e) {
              Thread.currentThread().interrupt();
          } finally {
              lock.unlock(); // 解锁
          }
      }
      public void changeFlag() {
          lock.lock(); // 加锁
          try {
              flag = true; // 修改条件
              condition.signalAll(); // 唤醒等待的线程
          } finally {
              lock.unlock(); // 解锁
          }
      }
  }
  在上述示例中,我们使用 condition.await(timeout, TimeUnit.SECONDS) 方法等待了 10 秒,如果超过该时间还未被唤醒,则退出等待状态并做相应处理。
  (2)等待多个条件
  有时候我们需要等待多个条件同时满足后才能继续执行,这时候可以使用多个条件队列(Condition)来实现。示例代码如下:
  import java.util.concurrent.locks.Condition;
  import java.util.concurrent.locks.Lock;
  import java.util.concurrent.locks.ReentrantLock;
  public class MyRunnable implements Runnable {
      private final Lock lock = new ReentrantLock();
      private final Condition condition1 = lock.newCondition();
      private final Condition condition2 = lock.newCondition();
      private boolean flag1 = false;
      private boolean flag2 = false;
      public void run() {
          lock.lock(); // 加锁
          try {
              while (!flag1 || !flag2) {
                  if (!flag1) {
                      condition1.await(); // 等待条件 1
                  }
                  if (!flag2) {
                      condition2.await(); // 等待条件 2
                  }
              }
              // 访问共享资源
          } catch (InterruptedException e) {
              Thread.currentThread().interrupt();
          } finally {
              lock.unlock(); // 解锁
          }
      }
      public void changeFlag1() {
          lock.lock(); // 加锁
          try {
              flag1 = true; // 修改条件 1
              condition1.signalAll(); // 唤醒等待条件 1 的线程
          } finally {
              lock.unlock(); // 解锁
          }
      }
      public void changeFlag2() {
          lock.lock(); // 加锁
          try {
              flag2 = true; // 修改条件 2
              condition2.signalAll(); // 唤醒等待条件 2 的线程
          } finally {
              lock.unlock(); // 解锁
          }
      }
  }
  在上述示例中,我们创建了两个条件队列(Condition),分别用于等待两个不同的条件。然后,在访问共享资源时使用 while 循环判断两个条件是否都满足,如果不满足则分别调用 condition1.await() 和 condition2.await() 方法使当前线程进入等待状态。在修改条件时分别调用 changeFlag1() 和 changeFlag2() 方法,并使用 condition1.signalAll() 和 condition2.signalAll() 唤醒等待相应条件的线程。
  (3)实现生产者消费者模型
  条件队列(Condition)还可以用于实现生产者消费者模型,其中生产者和消费者共享一个缓冲区,当缓冲区为空时,消费者需要等待生产者生产数据;当缓冲区满时,生产者需要等待消费者消费数据。示例代码如下:
  import java.util.LinkedList;
  import java.util.Queue;
  import java.util.concurrent.locks.Condition;
  import java.util.concurrent.locks.Lock;
  import java.util.concurrent.locks.ReentrantLock;
  public class MyRunnable implements Runnable {
      private final Lock lock = new ReentrantLock();
      private final Condition notFull = lock.newCondition();
      private final Condition notEmpty = lock.newCondition();
      private final Queue<Integer> queue = new LinkedList<>();
      private final int maxSize = 10;
      public void run() {
          while (true) {
              lock.lock(); // 加锁
              try {
                  while (queue.isEmpty()) {
                      notEmpty.await(); // 等待不为空
                  }
                  int data = queue.poll(); // 取出数据
                  notFull.signalAll(); // 唤醒生产者
                  // 处理数据
              } catch (InterruptedException e) {
                  Thread.currentThread().interrupt();
              } finally {
                  lock.unlock(); // 解锁
              }
          }
      }
      public void produce(int data) {
          lock.lock(); // 加锁
          try {
              while (queue.size() == maxSize) {
                  notFull.await(); // 等待不满
              }
              queue.offer(data); // 添加数据
              notEmpty.signalAll(); // 唤醒消费者
          } catch (InterruptedException e) {
              Thread.currentThread().interrupt();
          } finally {
              lock.unlock(); // 解锁
          }
      }
  }
  在上述示例中,我们创建了一个缓冲区(Queue),并使用两个条件队列(Condition)分别表示缓冲区不为空和不满。在消费者线程中,使用 while 循环判断缓冲区是否为空,如果为空则调用 notEmpty.await() 方法使当前线程进入等待状态。当从缓冲区取出数据后,调用 notFull.signalAll() 方法唤醒所有等待不满的生产者线程。在生产者线程中,使用 while 循环判断缓冲区是否已满,如果已满则调用 notFull.await() 方法使当前线程进入等待状态。当往缓冲区添加数据后,调用 notEmpty.signalAll() 方法唤醒所有等待不为空的消费者线程。
  三、读写锁
  1、简介
  读写锁是一种特殊的锁,它允许多个线程同时读取共享资源,但只允许一个线程对共享资源进行写操作。读写锁可以有效地提高并发性能,特别是在读取操作远多于写操作的场景下。
  Java 中提供了 ReentrantReadWriteLock 类来实现读写锁。它包含一个读锁和一个写锁,读锁可同时被多个线程持有,但写锁一次只能被一个线程持有。示例代码如下:
  import java.util.concurrent.locks.ReadWriteLock;
  import java.util.concurrent.locks.ReentrantReadWriteLock;
  public class MyRunnable implements Runnable {
      private final ReadWriteLock lock = new ReentrantReadWriteLock();
      private int count = 0;
      public void run() {
          lock.readLock().lock(); // 获取读锁
          try {
              // 访问共享资源(读取)
          } finally {
              lock.readLock().unlock(); // 释放读锁
          }
      }
      public void write() {
          lock.writeLock().lock(); // 获取写锁
          try {
          // 访问共享资源(写入)
          } finally {
          lock.writeLock().unlock(); // 释放写锁
          }
      }
  }
  在上述示例中,我们创建了一个读写锁(ReentrantReadWriteLock),并使用 readLock() 方法获取读锁,writeLock() 方法获取写锁。在访问共享资源时,读取操作可以同时被多个线程持有读锁,而写入操作必须先获取写锁,然后其他所有操作都被阻塞,直到写入完成并释放写锁。
  2、使用场景
  读写锁适用于以下场景:
  ·读取操作远多于写入操作。
  · 共享资源的状态不会发生太大变化,即读取操作和写入操作之间的时间间隔较长。
  · 写入操作对资源的一致性要求高,需要独占式访问。
  使用读写锁可以有效地提高程序的并发性能,特别是在读取操作远多于写入操作的情况下。但需要注意的是,读写锁的实现需要消耗更多的系统资源,因此只有在读取操作远多于写入操作、且读写操作之间的时间间隔较长时才应该使用读写锁。
  四、StampedLock
  1、简介
  StampedLock 是 Java 8 新增的一种锁机制,它是对读写锁的一种改进,具有更高的并发性能。StampedLock 支持三种模式:读(共享)、写(独占)和乐观读(非独占)。与 ReadWriteLock 不同的是,StampedLock 的读取操作不会被阻塞,但可能会失败,如果读取的数据在读取过程中发生了改变,则读取操作会失败并返回一个标记(stamp),此时可以根据需要重试读取操作或者转换为独占写入操作。
  StampedLock 使用一个长整型的 stamp 来表示锁的版本号,每次修改数据后都会更新版本号。读取操作需要传入当前版本号以确保读取的数据没有被修改,写入操作则需要传入上一次读取操作返回的版本号以确保数据的一致性。示例代码如下:
  import java.util.concurrent.locks.StampedLock;
  public class MyRunnable implements Runnable {
      private final StampedLock lock = new StampedLock();
      private int x = 0;
      private int y = 0;
      public void run() {
          long stamp = lock.tryOptimisticRead(); // 尝试乐观读取
          int currentX = x;
          int currentY = y;
          if (!lock.validate(stamp)) { // 校验版本号
              stamp = lock.readLock(); // 获取读锁
              try {
                  currentX = x; // 重新读取数据
                  currentY = y;
              } finally {
                  lock.unlockRead(stamp); // 释放读锁
              }
          }
          // 访问共享资源(读取)
      }
      public void write(int newX, int newY) {
          long stamp = lock.writeLock(); // 获取写锁
          try {
              x = newX; // 修改数据
              y = newY;
          } finally {
              lock.unlockWrite(stamp); // 释放写锁
          }
      }
  }
  在上述示例中,我们创建了一个 StampedLock,并使用 tryOptimisticRead() 方法尝试进行乐观读取操作。如果校验版本号失败,则说明数据被修改过,此时需要再次获取读锁并重新读取数据。在修改数据时,使用 writeLock() 方法获取写锁,修改完成后释放写锁。
  2、使用场景
  StampedLock 适用于以下场景:
  ·读取操作频繁,而写入操作较少。
  · 数据的一致性要求不高,即数据会发生周期
  性的变化,但读取操作与写入操作之间的时间间隔较短,不需要使用分布式锁或者数据库事务来保证数据一致性。
  使用 StampedLock 可以提高程序的并发性能,特别是在读取操作频繁、写入操作较少的情况下。但需要注意的是,StampedLock 的实现依赖于硬件的 CAS(Compare and Swap)指令,因此在某些 CPU 架构上可能会存在性能问题。此外,在使用乐观读取模式时需要进行版本号校验,如果校验失败则需要重新获取读锁并重新读取数据,这可能会带来额外的开销和复杂度。
  五、总结
  Java 提供了多种锁机制来协调多个线程对共享资源的访问。ReentrantLock 是最基本的一种锁,它采用独占式访问方式,可以精确控制多个线程对共享资源的访问顺序。Condition 可以用于在锁的基础上实现更灵活的同步操作,例如线程的等待和唤醒。ReadWriteLock 是一种特殊的锁,它允许多个线程同时读取共享资源,但只允许一个线程对共享资源进行写操作。StampedLock 是对读写锁的一种改进,具有更高的并发性能,但需要注意的是它的实现依赖于硬件的 CAS 指令。
  在使用锁时需要注意避免死锁、避免过度竞争和防止资源饥饿等问题。应该根据具体的场景选择不同的锁机制,并合理地设置锁的粒度和范围。同时也可以考虑使用一些高级的并发工具来简化锁的管理,例如 Executor 框架、原子变量、信号量、倒计时门闩等。

TAG: 软件开发 Java java

 

评分:0

我来说两句

Open Toolbar