当前位置:   article > 正文

【JAVA程序员面试必备】详解 ReentrantLock:深入剖析 AQS 支撑下的可重入锁机制与实现原理_java reentrantlock 面试

java reentrantlock 面试

img
你好我的朋友,请先容许我作一个简单介绍:我是赵士杰,Java 攻城狮,CSDN 专家博主、阿里云社区“乘风者计划”专家博主,欢迎关注我的微信公众号【技术人阿杰】。
本篇文章是一篇以 ReentrantLock 可重入锁为主题的技术文章,希望能够激发并且鼓舞您继续前行。

img

引入

并发编程中,多个线程可能会同时访问某个共享资源,如共享变量、共享数据结构或共享文件等。如果没有适当的同步机制,就可能出现数据不一致或数据损坏的情况。

Java 最开始使用 Synchronized 关键字提供了一种保证线程安全访问共享资源的方式。Synchronized 可以确保在同一时刻,只有一个线程可以执行被它修饰的代码块或方法。当一个线程进入一个 Synchronized 代码块或方法时,它会获取一个锁,其他需要访问该共享资源的线程需要等待该锁被释放后才能进入。这样可以避免多个线程同时修改共享资源,从而保证了数据的完整性和一致性。

在JDK 1.6以前(不包括1.6,从JDK 1.6开始,JVM 对 Synchronized 进行了优化),Synchronized 很重,也被称为重量级锁(Heavyweight Locking),注意这里的“重”是指在实现线程同步时可能带来的性能开销和复杂度。这是由于 Synchronized 的实现依赖于内核中的互斥量(mutex),即使用操作系统的资源,当线程需要获取锁时,需要进入内核态,这样会导致用户态和内核态的切换,这个切换的开销相对较大。因为涉及到了从用户态到内核态的上下文切换,需要保存和恢复线程的上下文信息,这样的操作是比较昂贵的。

从Jdk 1.5开始,Java引入了 Java.util.concurrent 包,即 JUC,它提供了 ReentrantLock 类用于替代 Synchronized 加锁,相比 SynchronizedReentrantLock不仅提供了相同的互斥锁功能,还具有更多的灵活性和扩展性。

ReentrantLock 是什么?

首先,我们先简单了解一下所谓的 ReentrantLock 是什么?

ReentrantLock 是 Java 中的一个可重入互斥锁,也被称为“独占锁”,属于java.util.concurrent.locks包。它提供了一种比传统Synchronized关键字更灵活、更强大的线程同步方法。ReentrantLock是可重入锁,这意味着同一个线程可以多次获得同一把锁,而不会导致死锁。

ReentrantLock 特性如下:

  1. 可重入性:允许同一个线程多次获得同一把锁。

  2. 公平性选择ReentrantLock提供了创建公平锁和非公平锁的选择。公平锁意味着等待时间最长的线程会优先获得锁,而非公平锁则允许插队,可能会导致某些线程饥饿,但在某些场景下会有更好的性能。

  3. 条件变量ReentrantLock提供了与Condition对象配合使用的能力,允许线程在特定条件下等待,或者在条件满足时被唤醒。这类似于Object类中的wait()notify()方法,但提供了更丰富的功能,如支持多个等待集。

  4. 尝试非阻塞获取锁ReentrantLock提供了tryLock()方法,允许线程尝试获取锁而不会无限期地阻塞。

  5. 可中断的锁获取操作ReentrantLock还提供了一个可以响应中断的锁获取操作,即lockInterruptibly()方法。这允许在等待锁的过程中,线程可以响应中断。

下面是一个使用ReentrantLock的简单示例,演示了如何在代码中使用这个锁:

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockDemo {
    private final ReentrantLock lock = new ReentrantLock();

    public void print() {
        lock.lock();  // 在进入临界区之前获取锁
        try {
            // 临界区代码,只有获取了锁的线程才能执行
            System.out.println(Thread.currentThread().getName() + "获得了锁");
        } finally {
            lock.unlock();  // 确保在退出临界区时释放锁
        }
    }

    public static void main(String[] args) {
        ReentrantLockDemo demo = new ReentrantLockDemo();
        Thread t1 = new Thread(demo::print);
        Thread t2 = new Thread(demo::print);

        t1.start();
        t2.start();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

ReentrantLock 内部的核心组件

要深入理解ReentrantLock,我们需要探讨其内部的一些关键元素,这些元素共同构成了ReentrantLock强大的同步机制。

Lock 接口

ReentrantLock 通过实现 Lock 接口,提供了与 Lock 接口定义的锁操作一致的方法,不仅可以更灵活地控制对共享资源的访问,可以与其他 Lock 实现进行互操作,还提供了额外的功能,如可重入性、公平性等。Lock 接口定义的锁操作方法如下:

public interface Lock {
    
    /**
     * 获取锁。如果锁不可用,则当前线程将被禁用,直到获取到锁为止。
     */
    void lock();

    /**
     * 获取锁,可响应中断。
     */
    void lockInterruptibly() throws InterruptedException;

    /**
     * 尝试获取锁
     */
    boolean tryLock();

    /**
     * 在给定的等待时间内尝试获取锁
     */
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

    /**
     * 释放锁
     */
    void unlock();

    /**
     * 返回与该锁实例关联的条件实例
     */
    Condition newCondition();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32

Sync 抽象内部类

ReentrantLock 类中,声明了一个私有的(private)、不可变(final)的 Sync 类型的变量 sync

/** 实现同步机制的同步器 */
private final Sync sync;
  • 1
  • 2

Sync 是一个抽象内部类,是 ReentrantLock 内部用于实现锁机制的同步器对象。

它继承了AbstractQueuedSynchronizer类(简称AQS)。Sync 类通过继承和扩展 AQS 来提供同步控制功能,使 ReentrantLock 支持实现公平和非公平锁的机制。AQS 是 Java 并发包中提供的一个用于构建锁和其他同步组件的框架,

Sync 类作为 ReentrantLock 的同步控制的基础,提供了一系列关键的同步操作方法:

  1. lock():这是一个抽象方法,由子类实现具体的加锁逻辑。

    abstract void lock();
    
    • 1
  2. nonfairTryAcquire(int acquires):这个方法尝试以非公平的方式获取锁,如果成功则返回 true,否则返回 false

    final boolean nonfairTryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            // 如果锁当前没有被任何线程持有,则尝试直接获取锁。
            if (compareAndSetState(0, acquires)) {
                // 如果成功,则将当前线程设置为独占线程,并返回 true。
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        else if (current == getExclusiveOwnerThread()) {
            // 如果当前线程已经持有了该锁,则更新锁的状态并返回 true。
            int nextc = c + acquires;
            if (nextc < 0) // 溢出
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    在方法中会通过 getState() 获取当前锁的状态(0表示锁可用,非0表示锁已经被占用),再根据锁的状态执行获取锁的逻辑:

    • 锁可用(c == 0):通过 compareAndSetState(0, acquires) 原子操作尝试更新锁的状态为 acquires。如果更新成功,意味着当前线程成功获取了锁,随后通过 setExclusiveOwnerThread(current) 设置当前线程为锁的独占者,并返回 true
    • 重入:如果锁已经被当前线程占用(即 current == getExclusiveOwnerThread()),则尝试将锁的状态增加 acquires 值(nextc = c + acquires)。如果 nextc 小于0,表示整型溢出,抛出 Error 异常,因为这表示锁的计数达到了最大值。否则,通过 setState(nextc) 更新锁的状态,并返回 true

    注:Sync内部类默认实现了非公平尝试获取锁方法,而公平尝试获取锁的方法则需要子类中提供实现。

  3. tryRelease(int releases):该方法用于在独占模式下释放锁操作:

    protected final boolean tryRelease(int releases) {
        int c = getState() - releases;
        if (Thread.currentThread() != getExclusiveOwnerThread())
            throw new IllegalMonitorStateException();
        boolean free = false;
        if (c == 0) {
            free = true;
            setExclusiveOwnerThread(null);
        }
        setState(c);
        return free;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    首先检查当前线程是否有权释放锁,然后更新锁的状态,最后根据同步状态是否归零来决定是否完全释放锁。如果锁的状态为0,说明锁已经完全释放,同时通过 setExclusiveOwnerThread(null) 清除对当前线程的独占。

  4. isHeldExclusively():判断当前线程是否独占地持有锁。

    protected final boolean isHeldExclusively() {
        return getExclusiveOwnerThread() == Thread.currentThread();
    }
    
    • 1
    • 2
    • 3

    getExclusiveOwnerThread() 方法会返回当前持有独占锁的线程:

    • 如果两者相等,说明当前线程就是独占锁的持有者,方法返回 true
    • 如果不相等,说明当前线程不是独占锁的持有者,方法返回 false

NonfairSync 非公平锁

NonfairSyncSync 的一个子类,实现了非公平锁的同步机制。其特点是在尝试获取锁时,会先尝试“插队”获取锁,如果失败则按正常流程排队等待。这种方式可能导致等待时间较长的线程饥饿,但在某些场景下能提高吞吐量。

NonfairSync 类中提供了加锁和获取锁的具体实现:

  • lock() 加锁操作:

    final void lock() {
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    首先尝试通过CAS操作立即获取锁,如果成功(即同步状态从0变为1),则通过 setExclusiveOwnerThread(Thread.currentThread()) 设置当前线程为锁的独占者。如果直接尝试失败(表示锁已被其他线程持有),则调用 acquire(1) 方法进入正常的获取锁流程。

  • tryAcquire(int acquires) 获取锁操作:

    protected final boolean tryAcquire(int acquires) {
        return nonfairTryAcquire(acquires);
    }
    
    • 1
    • 2
    • 3

    这个方法重写了 AQS 的 tryAcquire 方法,通过调用 Sync 提供的 nonfairTryAcquire(acquires) 来实现非公平的获取锁逻辑。

FairSync 公平锁

NonfairSync 类似,FairSyncSync 的另一个子类,实现了公平锁的同步机制。其特点是在尝试获取锁时,会检查是否有其他线程已经在等待队列中,确保按照请求锁的顺序来获取锁,从而避免饥饿情况。

  • lock() 加锁操作:

    final void lock() {
        acquire(1);
    }
    
    • 1
    • 2
    • 3
  • tryAcquire(int acquires)获取锁操作:

    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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    公平锁版本的 tryAcquire 方法获取锁逻辑,在当前线程获取锁时,如果当前锁的状态为0(未锁定),则检查是否没有排队的前驱节点(即等待队列中没有其他线程或当前线程位于队列的首部),同时设置当前线程为独占锁的所有者,并返回 true,表示获取锁成功。

    如果锁已被当前线程持有(即 current 线程是锁的独占所有者),则支持该线程再次获取锁。

    • 如果队列中有比当前线程等待时间更长的线程,意味着当前线程不应该立即获取锁(在公平锁的情况下)。
    • 如果当前线程位于队列的最前面或队列为空,那么当前线程可以尝试获取锁。

    因此在公平锁的获取锁逻辑中,hasQueuedPredecessors() 方法的作用至关重要。它保证了对锁的访问是按照请求锁的顺序(即等待队列中的顺序)进行的。

构造方法

ReentrantLock 提供了两个构造方法用于创建锁的实例:

/**
 * 创建非公平锁实例
 */
public ReentrantLock() {
    sync = new NonfairSync();
}

/**
 * true-公平锁;false-非公平锁
 */
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

ReentrantLock 的无参构造方法会默认创建一个非公平锁的实例;有参构造方法允许用户指定锁的公平性策略。如果 fairtrue,则创建一个公平锁实例,否则创建一个非公平的锁实例。

ReentrantLock 非公平锁的获取与释放

获取锁

首先我们通过以下方式创建一个非公平锁的 ReentrantLock 实例:

Lock nonfair = new ReentrantLock();
Lock nonfair = new ReentrantLock(false);
  • 1
  • 2

然后我们调用 lock() 方法获取锁:

nonfair.lock();
  • 1

由于我们的实例实际是一个非公平锁,实际上调用的是 NonfairSync.lock() 方法:

final void lock() {
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

当 CAS 操作直接获取锁失败时,会调用 AQSacquire 方法:

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}
  • 1
  • 2
  • 3
  • 4
  • 5

acquire 方法中执行逻辑如下:

  1. 首先通过 tryAcquire(int arg) 方法尝试获取锁 方法尝试获取锁,如果成功返回 true,否则返回 false

    注:这里实际调用的是非公平锁的获取锁方法:Sync.nonfairTryAcquire(int acquires)

  2. 当线程获取锁失败时,调用 addWaiter(Node.EXCLUSIVE) 方法将当前线程包装成一个独占模式的节点(Node),并通过 acquireQueued(final Node node, int arg) 将当前线程(已经被包装成节点)放入队列中进行等待获取资源。具体的等待逻辑由 AQS 类提供。

加入等待队列后,线程会进行少量的自旋,尝试再次获取锁,如果仍然失败,它最终会被阻塞挂起,直到锁被释放并且该线程被选择为下一个获取锁的线程。

释放锁

在释放锁时我们需要调用 unlock() 方法来释放锁:

nonfair.unlock();
  • 1

unlock() 方法中,会调用 AQS 提供的 release 方法进行释放锁:

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

release 方法是 AQS 框架中处理资源释放和线程唤醒逻辑的关键方法之一。该方法用于在独占模式下释放资源,具体执行逻辑如下:

  1. 首先调用 tryRelease 方法(需要由继承AQS的同步器类来实现)释放锁。如果锁成功被释放,则返回 true;否则返回 false

    注:这里的锁释放逻辑实际调用的是 Sync.tryRelease(int acquires) 方法。

  2. 如果 tryRelease 返回 true,表示资源已经成功被释放,此时会检查同步队列的头节点(head)。如果头节点存在并且其等待状态不为0(表示可能有线程在等待唤醒),则调用 unparkSuccessor(h) 方法来唤醒它的后继节点尝试获取资源。

ReentrantLock 公平锁的获取与释放

获取锁

创建一个公平锁实例:

Lock fair = new ReentrantLock(true);
  • 1

然后我们调用 lock() 方法获取锁:

fair.lock();
  • 1

由于我们的实例是一个公平锁,实际上调用的是 FairSync.lock() 方法:

final void lock() {
    acquire(1);
}
  • 1
  • 2
  • 3

lock() 方法中,不同于非公平锁在获取锁时会先尝试直接获取锁进行“插队”,获取失败再调用 AQSacquire 方法;

公平锁会直接调用 AQSacquire 方法:

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}
  • 1
  • 2
  • 3
  • 4
  • 5

acquire 方法中的执行逻辑和非公平锁一样,但是在调用 tryAcquire(int arg) 方法尝试获取锁的逻辑由 FairSync 内部类具体实现,即调用 FairSync.tryAcquire(int arg) 方法:

image-20240207144118250

为了实现公平机制,防止新来的线程"插队",确保等待时间最长的线程优先获得锁,在锁可用时会先通过 hasQueuedPredecessors() 方法检查等待队列中是否有比当前线程等待时间更长的线程,如果队列中有比当前线程等待时间更长的线程,意味着当前线程不应该立即获取锁;否则当前线程就会尝试获取锁。

之后的获取锁失败处理和非公平锁相同,即将当前线程进行包装并加入等待队列,直到锁被释放并且该线程被选择为下一个获取锁的线程。

释放锁

参考非公平锁,非公平锁和公平锁在释放锁的逻辑是相同的。

面试题

  1. 什么是可重入锁?

    可重入锁是一种支持一个线程对资源的重复加锁的同步机制。当一个线程已经持有某个锁时,它可能需要再次请求该锁。即一个线程可以多次获得同一把锁,而不会导致死锁。

  2. 可重入是如何实现的?

    image-20240207145913804

    以非公平锁为例,当一个线程请求获取锁时,如果锁当前已经被占用,则会通过 current == getExclusiveOwnerThread() 判断锁是否被当前线程独占,如果锁已经被它自己持有,则允许该线程再次获取锁,并更新锁的状态,实现锁的可重入性。

  3. 非公平锁和公平锁的区别?

    非公平锁和公平锁的实现差异主要体现在获取锁的策略上。

    非公平锁允许“插队”,即如果锁当前可用(state==0),那么当前线程可以直接获取锁,而不必等待之前已经在等待队列中的线程。

    相比之下,公平锁则严格遵循先来先服务的原则,即只有当等待队列中没有线程,或者当前线程位于队列的头部时,才能尝试获取锁。

  4. 为什么 Sync 默认实现了非公平获取锁的方法,而将公平获取锁的方法留给子类实现?

    非公平锁通常比公平锁具有更好的性能。因为非公平锁减少了线程在等待队列中的时间,从而减少了上下文切换的次数。对于大多数场景,非公平锁已经足够高效且公平性的需求并不是很强。

    因此Sync提供了非公平锁的默认实现,以满足大多数情况下对性能的需求;公平锁则作为一种可选的、可能会影响性能的特性,留给了需要它的子类实现。

  5. ReentrantLock 的 state 与 AQS 的 state 有什么含义?

    AbstractQueuedSynchronizer(AQS)为构建锁和其他同步类提供了一个基础框架,而 state 字段则是这个框架中用于跟踪同步状态的核心变量。

    ReentrantLock 使用 AQS 的 state 来追踪锁被重入的次数。当一个线程首次获取锁时,state 从0变为1。如果同一个线程再次获取这个锁(即重入),state 会增加(变为2、3等),每次释放锁时 state 减少,直到 state 再次为0,锁才被完全释放。

  6. 非公平锁的插队是如何实现的?

    image-20240207152637025

    当调用 lock() 方法时,会先执行一次 CAS 操作尝试直接获取锁,如果失败,则调用非公平获取锁策略,如果锁可用则再次执行一次 CAS 操作:

    image-20240207152105785

    即在非公平锁中,当一个线程请求锁时,即使有其他线程正在等待,它也会直接尝试获取锁。如果在此时锁可用(即没有其他线程持有锁),请求锁的线程就有可能直接获取锁,而不用进入等待队列,从而实现了插队。这种插队行为提高了锁的获取效率,但在高竞争环境下可能导致等待时间较长的线程出现饥饿现象。

  7. 公平锁的公平是如何保证的?

    image-20240207161556723

    与非公平锁相比,公平锁在当前线程获取锁时考虑了等待队列中的其他线程,会先通过 hasQueuedPredecessors() 方法检查等待队列中是否有其他线程已经在等待获取锁,确保按照请求锁的顺序(即等待队列中的顺序)来授予锁。

    这样可以防止“饥饿”现象,即某些线程可能会长时间等待而始终无法获取到锁。通过仅在当前线程是队列中的第一个(或没有其他线程在等待)时才尝试获取锁,确保了长时间等待的线程最终能够获得锁。

  8. 公平锁和非公平锁的适用场景?

    公平锁适用于那些需要确保线程获取锁的顺序性的场景,可以避免线程饥饿,但性能可能较低,因为维护一个有序队列需要额外的开销。

    非公平锁在大多数场景下提供更好的性能,因为它减少了线程调度的开销,但可能导致线程饥饿。

    在实际应用中,推荐使用默认的非公平锁,以获得更好的性能。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/正经夜光杯/article/detail/882081
推荐阅读
相关标签
  

闽ICP备14008679号