MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java可重入锁的使用场景

2024-12-106.4k 阅读

一、Java 可重入锁概述

在 Java 多线程编程领域,锁机制是控制多线程访问共享资源的重要手段。可重入锁是一种特殊类型的锁,它允许同一个线程对同一把锁进行多次获取。这一特性避免了死锁的发生,在某些复杂的业务场景下显得尤为重要。

从本质上来说,可重入锁通过记录持有锁的线程以及该线程获取锁的次数来实现其可重入特性。当一个线程首次获取锁时,锁的持有线程被设置为当前线程,获取次数设为 1。如果该线程再次获取同一把锁,获取次数就会递增。只有当获取次数递减为 0 时,锁才会真正被释放,其他线程才有机会获取。

二、使用场景分析

(一)同步方法调用场景

  1. 场景描述 在一个类中,可能存在多个同步方法,并且这些方法之间存在相互调用的情况。比如,一个业务逻辑方法 methodA 调用了另一个同步方法 methodB。如果使用的是非可重入锁,当 methodA 获取锁后调用 methodB,由于 methodB 也需要获取锁,而锁已经被 methodA 持有,此时就会导致死锁。可重入锁则可以避免这种情况,因为同一个线程可以多次获取同一把锁。
  2. 代码示例
public class ReentrantLockExample1 {
    private final Object lock = new Object();

    public void methodA() {
        synchronized (lock) {
            System.out.println("Method A is running, thread: " + Thread.currentThread().getName());
            methodB();
        }
    }

    public void methodB() {
        synchronized (lock) {
            System.out.println("Method B is running, thread: " + Thread.currentThread().getName());
        }
    }

    public static void main(String[] args) {
        ReentrantLockExample1 example = new ReentrantLockExample1();
        Thread thread = new Thread(() -> example.methodA());
        thread.start();
    }
}

在上述代码中,methodAmethodB 都使用 synchronized 关键字对同一个对象 lock 进行同步。methodA 在获取锁后调用 methodB,由于 synchronized 关键字使用的是可重入锁,所以不会发生死锁。程序能够正常执行,输出如下:

Method A is running, thread: Thread-0
Method B is running, thread: Thread-0

(二)递归调用场景

  1. 场景描述 递归方法在执行过程中,可能需要多次获取锁。例如,一个递归方法用于遍历树形结构,在每次递归调用时都需要对树节点进行操作,为了保证数据一致性,需要对节点进行加锁。如果使用非可重入锁,递归调用会导致死锁,因为每次递归都试图获取已经被当前线程持有的锁。而可重入锁允许同一线程多次获取锁,从而保证递归操作的顺利进行。
  2. 代码示例
public class ReentrantLockExample2 {
    private final Object lock = new Object();

    public void recursiveMethod(int level) {
        synchronized (lock) {
            System.out.println("Entering recursiveMethod at level " + level + ", thread: " + Thread.currentThread().getName());
            if (level > 0) {
                recursiveMethod(level - 1);
            }
            System.out.println("Exiting recursiveMethod at level " + level + ", thread: " + Thread.currentThread().getName());
        }
    }

    public static void main(String[] args) {
        ReentrantLockExample2 example = new ReentrantLockExample2();
        Thread thread = new Thread(() -> example.recursiveMethod(3));
        thread.start();
    }
}

在这个示例中,recursiveMethod 是一个递归方法,每次递归调用都会获取 lock 对象的锁。由于 synchronized 是可重入锁,程序能够顺利执行,输出如下:

Entering recursiveMethod at level 3, thread: Thread-0
Entering recursiveMethod at level 2, thread: Thread-0
Entering recursiveMethod at level 1, thread: Thread-0
Entering recursiveMethod at level 0, thread: Thread-0
Exiting recursiveMethod at level 0, thread: Thread-0
Exiting recursiveMethod at level 1, thread: Thread-0
Exiting recursiveMethod at level 2, thread: Thread-0
Exiting recursiveMethod at level 3, thread: Thread-0

(三)父子类同步方法调用场景

  1. 场景描述 在继承体系中,子类可能会重写父类的同步方法,并且在子类的方法中调用父类的同步方法。这种情况下,如果锁不具备可重入性,就会出现问题。例如,子类的 subMethod 重写了父类的 superMethod,并且 subMethod 中调用了 super.superMethod()。如果使用非可重入锁,当 subMethod 获取锁后调用 superMethod 时,会因为锁已被占用而导致死锁。可重入锁则能确保这种调用的正确性。
  2. 代码示例
class Parent {
    protected final Object lock = new Object();

    public void superMethod() {
        synchronized (lock) {
            System.out.println("Parent's superMethod is running, thread: " + Thread.currentThread().getName());
        }
    }
}

public class ReentrantLockExample3 extends Parent {
    @Override
    public void superMethod() {
        synchronized (lock) {
            System.out.println("Child's superMethod is running, thread: " + Thread.currentThread().getName());
            super.superMethod();
        }
    }

    public static void main(String[] args) {
        ReentrantLockExample3 example = new ReentrantLockExample3();
        Thread thread = new Thread(() -> example.superMethod());
        thread.start();
    }
}

在上述代码中,子类 ReentrantLockExample3 重写了父类 ParentsuperMethod,并在其中调用了 super.superMethod()。由于 synchronized 锁的可重入性,程序能够正常运行,输出如下:

Child's superMethod is running, thread: Thread-0
Parent's superMethod is running, thread: Thread-0

(四)锁升级场景

  1. 场景描述 在一些复杂的业务场景中,可能需要对资源进行逐步升级的锁控制。例如,开始时可能只需要对资源进行读操作,使用读锁(共享锁),但在后续操作中,可能需要将读锁升级为写锁(排他锁)。如果锁不支持可重入,在升级过程中,由于写锁与读锁的互斥性,并且当前线程已经持有读锁,就会导致死锁。可重入锁允许线程在持有读锁的情况下获取写锁,实现锁的升级。
  2. 代码示例
import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReentrantLockExample4 {
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
    private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();

    public void readAndWrite() {
        readLock.lock();
        try {
            System.out.println("Reading data, thread: " + Thread.currentThread().getName());
            // 模拟读操作
            Thread.sleep(1000);
            writeLock.lock();
            try {
                System.out.println("Upgrading to write, thread: " + Thread.currentThread().getName());
                // 模拟写操作
                Thread.sleep(1000);
            } finally {
                writeLock.unlock();
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            readLock.unlock();
        }
    }

    public static void main(String[] args) {
        ReentrantLockExample4 example = new ReentrantLockExample4();
        Thread thread = new Thread(() -> example.readAndWrite());
        thread.start();
    }
}

在这个示例中,readAndWrite 方法首先获取读锁,进行读操作,然后尝试获取写锁进行升级。ReentrantReadWriteLock 是可重入的,所以能够顺利实现锁的升级,程序输出如下:

Reading data, thread: Thread-0
Upgrading to write, thread: Thread-0

三、可重入锁的原理深入分析

  1. 基于 synchronized 关键字的可重入锁原理 在 Java 中,synchronized 关键字是基于对象头来实现锁机制的。对象头中包含了一些与锁相关的信息,如是否偏向锁、锁的状态等。当一个线程获取锁时,会检查对象头的锁状态。如果是偏向锁,并且偏向的线程是当前线程,那么获取锁成功,获取次数加 1。如果不是偏向锁或者偏向的线程不是当前线程,则尝试获取轻量级锁。轻量级锁通过 CAS(Compare and Swap)操作来尝试获取锁,如果成功则获取锁并将获取次数设为 1。如果 CAS 操作失败,会尝试升级为重量级锁。在整个过程中,同一个线程获取锁时,会递增获取次数,释放锁时递减获取次数,当获取次数为 0 时,锁真正被释放。
  2. 基于 ReentrantLock 的可重入锁原理 ReentrantLock 是 Java 提供的另一种可重入锁实现。它内部使用 AQS(AbstractQueuedSynchronizer)框架来实现同步。AQS 维护了一个 FIFO 队列,用于存储等待获取锁的线程。当一个线程获取锁时,首先检查当前锁的状态,如果锁未被占用,则通过 CAS 操作尝试获取锁。如果获取成功,将当前线程设置为锁的持有者,并将获取次数设为 1。如果锁已被占用,并且持有者是当前线程,则获取次数递增。当线程释放锁时,获取次数递减,当获取次数为 0 时,从 AQS 队列中唤醒等待的线程。

四、可重入锁与非可重入锁的对比

  1. 死锁风险 非可重入锁在上述提到的同步方法调用、递归调用等场景下容易导致死锁,因为同一个线程再次获取已经持有的锁时会被阻塞。而可重入锁通过记录获取次数,允许同一线程多次获取锁,大大降低了死锁的风险。
  2. 性能开销 可重入锁由于需要记录获取次数等额外信息,相对非可重入锁可能会有一些额外的性能开销。但在大多数实际应用场景中,这种开销相对较小,并且可重入锁带来的安全性提升更为重要。在高并发场景下,合理使用可重入锁能够保证程序的正确性和稳定性,而不会因为死锁等问题导致系统崩溃。

五、总结可重入锁使用注意事项

  1. 锁的释放 在使用可重入锁时,一定要确保在合适的地方释放锁。尤其是在递归调用或多层同步方法调用的场景下,每一次获取锁都要有对应的释放操作,否则可能会导致其他线程永远无法获取锁。例如,在上述递归调用的示例中,如果在递归方法中没有正确释放锁,就会造成资源的永久占用。
  2. 锁的粒度 合理控制锁的粒度对于性能至关重要。如果锁的粒度太大,会导致并发性能下降,因为过多的线程会竞争同一把锁。在使用可重入锁时,要尽量将锁的范围缩小到必要的操作上。比如在同步方法调用场景中,如果 methodB 的操作不需要与 methodA 同步,可以将 methodB 的同步块独立出来,避免不必要的锁竞争。
  3. 与其他同步机制的配合 在实际项目中,可重入锁通常会与其他同步机制一起使用,如信号量、条件变量等。要确保这些同步机制之间的协同工作不会产生死锁或其他同步问题。例如,在使用 ReentrantLock 结合 Condition 进行线程间通信时,要注意在正确的时机获取和释放锁,以及使用 Conditionawaitsignal 方法。

通过深入理解可重入锁的使用场景、原理以及与非可重入锁的对比,开发人员能够在多线程编程中更加灵活、准确地使用锁机制,编写高效、稳定的多线程程序。无论是在同步方法调用、递归调用,还是在父子类同步方法调用、锁升级等场景下,可重入锁都为多线程编程提供了可靠的保障。同时,注意可重入锁的使用注意事项,能够进一步优化程序的性能和稳定性。在实际项目中,根据具体的业务需求和场景,合理选择和使用可重入锁,是实现高效多线程应用的关键之一。