当前位置: 代码迷 >> 综合 >> Java 并发 (7) -- ReentrantLock 类
  详细解决方案

Java 并发 (7) -- ReentrantLock 类

热度:81   发布时间:2023-12-16 13:16:23.0

文章目录

  • 1. 简介
  • 2. 精讲
    • 1. 重入锁概念
    • 2. ReentrantLock 与 synchronized 的对比
      • 1. synchronized 回顾
      • 2. ReentrantLock 和 synchronized 的比较
      • 3. 重入锁的一个简单案例
    • 3. 源码分析
      • 1. 继承体系
      • 2. 获取锁
      • 3. 释放锁
      • 4. 公平锁与非公平锁

1. 简介

在 Java 5.0 之前,协调对共享对象的访问可以使用的机制只有 synchronized 和 volatile。我们知道 synchronized 关键字实现了内置锁,而 volatile 关键字保证了多线程的内存可见性。在大多数情况下,这些机制都能很好地完成工作,但却无法实现一些更高级的功能,例如,无法中断一个正在等待获取锁的线程,无法实现限定时间的获取锁机制,无法实现非阻塞结构的加锁规则等。而这些更灵活的加锁机制通常都能够提供更好的活跃性或性能。因此,在 Java 5.0 中增加了一种新的机制:ReentrantLock。

ReentrantLock 类实现了 Lock 接口,并提供了与 synchronized 相同的互斥性和内存可见性,它的底层是通过 AQS 来实现多线程同步的。与内置锁相比 ReentrantLock 不仅提供了更丰富的加锁机制,而且在性能上也不逊色于内置锁

ReentrantLock 与 synchronized 的区别有:(5)

  1. synchronized 关键字是 Java 提供的内置锁机制,其同步操作由底层 JVM 实现,而 ReentrantLock 是 JUC 包提供的显式锁,其同步操作由 AQS 同步器提供支持。
  2. ReentrantLock 在加锁和内存上提供的语义与内置锁相同,此外它还提供了一些其他功能,包括定时的锁等待,可中断的锁等待,公平锁等等
  3. 内置锁的使用方式也更加的简洁紧凑,另外,显式锁必须手动在 finally 块中调用 unlock(),所以使用内置锁相对来说会更加安全些
  4. synchronized 是 JVM 的内置属性,它能执行一些优化,例如通过增加锁的粒度来消除内置锁的同步
  5. (在 JDK 1.6 之后,虚拟机对于 synchronized 关键字进行整体优化后,在性能上 synchronized 与 ReentrantLock 已没有明显差距,因此)在使用选择上,大部分情况下 synchronized 关键字仍然是首选,原因就是:一是使用方便 、语义清晰,二是性能上虚拟机已为我们自动优化;而 ReentrantLock 提供了多样化的同步特性,如超时获取锁 、等待可中断 、可实现公平锁、可实现选择性通知等,因此当我们确实需要使用到这些功能时,可以选择 ReentrantLock

2. 精讲

1. 重入锁概念

重入锁 ReentrantLock,顾名思义,就是支持重进入的锁,它表示该线程能够支持一个线程对资源的重复加锁。除此之外,重入锁还支持获取锁时的公平和非公平性选择。

所谓的公平与非公平指的是在请求先后顺序上,先对锁进行请求的就一定先获取到锁,那么这就是公平锁,反之,如果对于锁的获取并没有时间上的先后顺序,如后请求的线程可能先获取到锁,这就是非公平锁,一般而言非,非公平锁机制的效率往往会胜过公平锁的机制,但在某些场景下,可能更注重时间先后顺序,那么公平锁自然是很好的选择。需要注意的是 ReetrantLock 支持对同一线程重加锁,但是加锁多少次,就必须解锁多少次,这样才可以成功释放锁。

ReetrantLock 是基于 AQS 并发框架实现的。这里简单回顾下 AQS 的工作原理:

AbstractQueuedSynchronizer 又称为队列同步器(后面简称AQS),它是用来构建锁或其他同步组件的基础框架,内部通过一个 int 类型的成员变量 state 来控制同步状态,当 state=0 时,则说明没有任何线程占有共享资源的锁,当 state=1 时,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待。AQS 内部通过内部类 Node 构成 FIFO 的同步队列来完成线程获取锁的排队工作,同时利用内部类 ConditionObject 构建等待队列,当 Condition 调用 wait() 方法后,线程将会加入等待队列中,而当 Condition 调用 signal() 方法后,线程将从等待队列转移动同步队列中进行锁竞争。注意这里涉及到两种队列,一种的同步队列,当线程请求锁而等待的将加入同步队列等待,而另一种则是等待队列(可有多个),通过 Condition 调用 await() 方法释放锁后,将加入等待队列

AQS 作为基础组件,对于锁的实现存在两种不同的模式,即共享模式(如 Semaphore)和独占模式(如 ReetrantLock),无论是共享模式还是独占模式的实现类,其内部都是基于 AQS 实现的,也都维持着一个虚拟的同步队列,当请求锁的线程超过现有模式的限制时,会将线程包装成 Node 结点并将线程当前必要的信息存储到 node 结点中,然后加入同步队列等待获取锁,而这一系列操作都是 AQS 协助我们完成的。这也是 AQS 作为基础组件的原因,无论是 Semaphore 还是 ReetrantLock,其内部绝大多数方法都是间接调用 AQS 完成的。

下面就看下 ReentrantLock 和 AQS 之间的关系:
在这里插入图片描述

  • AbstractOwnableSynchronizer:抽象类,定义了存储独占当前锁的线程和获取的方法。
  • AbstractQueuedSynchronizer:抽象类,AQS框架核心类,其内部以虚拟队列的方式管理线程的锁获取与锁释放,其中获取锁(tryAcquire方法)和释放锁(tryRelease方法)并没有提供默认实现,需要子类重写这两个方法实现具体逻辑,目的是使开发人员可以自由定义获取锁以及释放锁的方式。
  • Node:AbstractQueuedSynchronizer 的内部类,用于构建虚拟队列(双向链表),管理需要获取锁的线程。
  • Sync:抽象类,是 ReentrantLock 的内部类,继承自 AbstractQueuedSynchronizer,实现了释放锁的操作(tryRelease()方法),并提供了lock抽象方法,由其子类实现。
  • NonfairSync:是ReentrantLock的内部类,继承自Sync,非公平锁的实现类。
  • FairSync:是ReentrantLock的内部类,继承自Sync,公平锁的实现类。
  • ReentrantLock:实现了Lock接口的,其内部类有Sync、NonfairSync、FairSync,在创建时可以根据fair参数决定创建NonfairSync(默认非公平锁)还是FairSync。

ReentrantLock 内部存在3个实现类,分别是 Sync、NonfairSync、FairSync。其中 Sync 继承自 AQS 实现了解锁 tryRelease() 方法,而 NonfairSync(非公平锁)、 FairSync(公平锁) 则继承自 Sync,实现了获取锁的 tryAcquire() 方法。ReentrantLock 的所有方法调用都通过间接调用 AQS 和 Sync 类及其子类来完成的。

从上述类图可以看出 AQS 是一个抽象类,但请注意其源码中并没有抽象的方法(一个都没有),这是因为 AQS 只是作为一个基础组件,并不希望直接作为直接操作类对外输出,而更倾向于作为基础组件,为真正的实现类提供基础设施。如构建同步队列,控制同步状态等,事实上,从设计模式角度来看,AQS 采用的模板模式的方式构建的,其内部除了提供并发操作核心方法以及同步队列操作外,还提供了一些模板方法让子类自己实现,如加锁操作以及解锁操作,

为什么这么做?这是因为 AQS 作为基础组件,封装的是核心并发操作,但是实现上分为两种模式,即共享模式与独占模式,而这两种模式的加锁与解锁实现方式是不一样的,但 AQS 只关注内部公共方法实现并不关心外部不同模式的实现,所以提供了模板方法给子类使用,也就是说实现独占锁,如 ReentrantLock 需要自己实现tryAcquire() 方法和 tryRelease() 方法,而实现共享模式的 Semaphore,则需要实现 tryAcquireShared() 方法和 tryReleaseShared() 方法,这样做的好处是显而易见的,无论是共享模式还是独占模式,其基础的实现都是同一套组件(AQS),只不过是加锁解锁的逻辑不同罢了,更重要的是如果我们需要自定义锁的话,也变得非常简单,只需要选择不同的模式实现不同的加锁和解锁的模板方法即可

2. ReentrantLock 与 synchronized 的对比

1. synchronized 回顾

Java 提供了内置锁来支持多线程的同步,JVM 根据 synchronized 关键字来标识同步代码块,当线程进入同步代码块时会自动获取锁,退出同步代码块时会自动释放锁,一个线程获得锁后其他线程将会被阻塞。

每个 Java 对象都可以作为一个实现同步的锁,synchronized 关键字可以用来修饰对象方法,静态方法和代码块,当修饰对象方法和静态方法时锁分别是方法所在的对象和 Class 对象,当修饰代码块时需提供额外的对象作为锁。每个 Java 对象之所以可以作为锁,是因为在对象头中关联了一个 monitor 对象(管程),线程进入同步代码块时会自动持有 monitor 对象,退出时会自动释放 monitor 对象,当 monitor 对象被持有时其他线程将会被阻塞。当然这些同步操作都由 JVM 底层帮你实现了,但以 synchronized 关键字修饰的方法和代码块在底层实现上还是有些区别的。

synchronized 关键字修饰的方法是隐式同步的,即无需通过字节码指令来控制的,JVM 可以根据方法表中的ACC_SYNCHRONIZED 访问标志来区分一个方法是否是同步方法;

而 synchronized 关键字修饰的代码块是显式同步的,它是通过 monitorenter 和 monitorexit 字节码指令来控制线程对管程的持有和释放。

monitor 对象内部持有 _ count 字段,_ count 等于0表示管程未被持有,_ count 大于0表示管程已被持有,每次持有线程重入时 _ count 都会加1,每次持有线程退出时 _ count 都会减1,这就是内置锁重入性的实现原理。另外,monitor 对象内部还有两条队列 _ EntryList 和 _ WaitSet,对应着 AQS 的同步队列和条件队列,当线程获取锁失败时会到 _ EntryList 中阻塞,当调用锁对象的 wait 方法时线程将会进入 _WaitSet 中等待,这是内置锁的线程同步和条件等待的实现原理

2. ReentrantLock 和 synchronized 的比较

在 Java 5.0 之前,协调对共享对象的访问可以使用的机制只有 synchronized 和 volatile。我们知道 synchronized 关键字实现了内置锁,而 volatile 关键字保证了多线程的内存可见性。在大多数情况下,这些机制都能很好地完成工作,但却无法实现一些更高级的功能,例如,无法中断一个正在等待获取锁的线程,无法实现限定时间的获取锁机制,无法实现非阻塞结构的加锁规则等。而这些更灵活的加锁机制通常都能够提供更好的活跃性或性能。因此,在 Java 5.0 中增加了一种新的机制:ReentrantLock。

ReentrantLock 类实现了 Lock 接口,并提供了与 synchronized 相同的互斥性和内存可见性,它的底层是通过 AQS 来实现多线程同步的。与内置锁相比 ReentrantLock 不仅提供了更丰富的加锁机制,而且在性能上也不逊色于内置锁(在以前的版本中甚至优于内置锁)。

ReentrantLock和synchronized的主要区别如下:

  1. synchronized 关键字是 Java 提供的内置锁机制,其同步操作由底层 JVM 实现,而 ReentrantLock 是java.util.concurrent 包提供的显式锁,其同步操作由 AQS 同步器提供支持。

  2. ReentrantLock 在加锁和内存上提供的语义与内置锁相同,此外它还提供了一些其他功能,包括定时的锁等待,可中断的锁等待,公平锁,以及实现非块结构的加锁。

  3. 事实上确实有许多人使用 ReentrantLock 来替代 synchronized 关键字的加锁操作。但是内置锁仍然有它特有的优势,内置锁为许多开发人员所熟悉,使用方式也更加的简洁紧凑,因为显式锁必须手动在 finally 块中调用 unlock,所以使用内置锁相对来说会更加安全些。

  4. 同时未来更加可能会去提升 synchronized 而不是 ReentrantLock 的性能。因为 synchronized 是 JVM 的内置属性,它能执行一些优化,例如对线程封闭的锁对象的锁消除优化,通过增加锁的粒度来消除内置锁的同步,而如果通过基于类库的锁来实现这些功能,则可能性不大

在 JDK 1.6 之后,虚拟机对于 synchronized 关键字进行整体优化后,在性能上 synchronized 与 ReentrantLock 已没有明显差距,因此在使用选择上,需要根据场景而定,大部分情况下我们依然建议是 synchronized 关键字,原因之一是使用方便 、语义清晰,二是性能上虚拟机已为我们自动优化。而 ReentrantLock 提供了多样化的同步特性,如超时获取锁 、等待可中断 、可实现公平锁、可实现选择性通知等,因此当我们确实需要使用到这些功能时,可以选择 ReentrantLock

3. 重入锁的一个简单案例

import java.util.concurrent.locks.ReentrantLock;public class ReentrantLockDemo implements Runnable {
    public static ReentrantLock lock = new ReentrantLock();public static int count = 0;@Overridepublic void run() {
    for(int j = 0; j < 10000000; j++){
    lock.lock();// 支持重入锁lock.lock();try {
    count++;   // 临界区:共享变量} finally {
    // 执行两次解锁lock.unlock();lock.unlock();				}}}public static void main(String[] args) throws InterruptedException {
    ReentrantLockDemo tld = new ReentrantLockDemo();Thread t1 = new Thread(tld);Thread t2 = new Thread(tld);t1.start();t2.start();t1.join();t2.join();// 产看count的结果System.out.println(count);  // 20000000}
}

案例代码非常简单,我们使用两个线程同时操作临界资源 i,执行自增操作,使用 ReenterLock 进行加锁,解决线程安全问题,这里进行了两次重复加锁。由于 ReenterLock 支持重入,因此这样是没有问题的,需要注意的是 在 finally 代码块中,需执行两次解锁操作才能真正成功地让当前执行线程释放锁。

3. 源码分析

1. 继承体系

在这里插入图片描述

2. 获取锁

我们一般都是这么使用ReentrantLock获取锁的:

//非公平锁
ReentrantLock lock = new ReentrantLock();
lock.lock();//lock 方法源码
public void lock() {
    sync.lock();
}

Sync为ReentrantLock里面的一个内部类,它继承AQS(AbstractQueuedSynchronizer),它有两个子类:公平锁FairSync和非公平锁NonfairSync。 ReentrantLock里面大部分的功能都是委托给Sync来实现的,同时Sync内部定义了lock()抽象方法由其子类去实现,默认实现了nonfairTryAcquire(int acquires)方法,可以看出它是非公平锁的默认实现方式。下面我们看非公平锁的lock()方法:

final void lock() {
    //尝试获取锁if (compareAndSetState(0, 1))setExclusiveOwnerThread(Thread.currentThread());else//获取失败,调用AQS的acquire(int arg)方法acquire(1);
}

首先会第一次尝试快速获取锁,如果获取失败,则调用acquire(int arg)方法,该方法定义在AQS中,如下:

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&acquireQueued(addWaiter(Node.EXCLUSIVE), arg))selfInterrupt();
}

这个方法首先调用tryAcquire(int arg)方法,在AQS中,tryAcquire(int arg)需要自定义同步组件提供实现,非公平锁实现如下:

protected final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);
}final boolean nonfairTryAcquire(int acquires) {
    //当前线程final Thread current = Thread.currentThread();//获取同步状态int c = getState();//state == 0,表示没有该锁处于空闲状态if (c == 0) {
    //获取锁成功,设置为当前线程所有if (compareAndSetState(0, acquires)) {
    setExclusiveOwnerThread(current);return true;}}//线程重入//判断锁持有的线程是否为当前线程else if (current == getExclusiveOwnerThread()) {
    int nextc = c + acquires;if (nextc < 0) // overflowthrow new Error("Maximum lock count exceeded");setState(nextc);return true;}return false;
}

该方法主要逻辑:首先判断同步状态state == 0 ?,如果是表示该锁还没有被线程持有,直接通过CAS获取同步状态,如果成功返回true。如果state != 0,则判断当前线程是否为获取锁的线程,如果是则获取锁,成功返回true。成功获取锁的线程再次获取锁,这是增加了同步状态state。

3. 释放锁

获取同步锁后,使用完毕则需要释放锁,ReentrantLock提供了unlock释放锁:

public void unlock() {
    sync.release(1);
}

unlock内部使用Sync的release(int arg)释放锁,release(int arg)是在AQS中定义的:

public final boolean release(int arg) {
    if (tryRelease(arg)) {
    Node h = head;if (h != null && h.waitStatus != 0)unparkSuccessor(h);return true;}return false;
}

与获取同步状态的acquire(int arg)方法相似,释放同步状态的tryRelease(int arg)同样是需要自定义同步组件自己实现:

protected final boolean tryRelease(int releases) {
    //减掉releasesint c = getState() - releases;//如果释放的不是持有锁的线程,抛出异常if (Thread.currentThread() != getExclusiveOwnerThread())throw new IllegalMonitorStateException();boolean free = false;//state == 0 表示已经释放完全了,其他线程可以获取同步状态了if (c == 0) {
    free = true;setExclusiveOwnerThread(null);}setState(c);return free;
}

只有当同步状态彻底释放后该方法才会返回true。当state == 0 时,则将锁持有线程设置为null,free= true,表示释放成功。

4. 公平锁与非公平锁

公平锁与非公平锁的区别在于获取锁的时候是否按照FIFO的顺序来。释放锁不存在公平性和非公平性,上面以非公平锁为例,下面我们来看看公平锁的tryAcquire(int arg):

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();int c = getState();if (c == 0) {
    if (!hasQueuedPredecessors() &&compareAndSetState(0, acquires)) {
    setExclusiveOwnerThread(current);return true;}}else if (current == getExclusiveOwnerThread()) {
    int nextc = c + acquires;if (nextc < 0)throw new Error("Maximum lock count exceeded");setState(nextc);return true;}return false;
}

比较非公平锁和公平锁获取同步状态的过程,会发现两者唯一的区别就在于公平锁在获取同步状态时多了一个限制条件:hasQueuedPredecessors(),定义如下:

public final boolean hasQueuedPredecessors() {
    Node t = tail;  //尾节点Node h = head;  //头节点Node s;//头节点 != 尾节点//同步队列第一个节点不为null//当前线程是同步队列第一个节点return h != t &&((s = h.next) == null || s.thread != Thread.currentThread());
}

该方法主要做一件事情:主要是判断当前线程是否位于CLH同步队列中的第一个。如果是则返回true,否则返回false。

  相关解决方案