【Java面试经典】谈谈ReentrantLock和Synchrnized区别

144 阅读15分钟

面试官:请你讲一下 ReentrantLock 和 synchronized 的区别。

:ReentrantLock 和 synchronized 都是用于在多线程环境下实现同步的机制,但它们有不少区别。

首先,从使用方式上看,synchronized 是 Java 语言的关键字,使用起来比较简洁。例如,在一个方法上加上 synchronized 关键字,就可以实现对这个方法的同步访问。像这样:

public synchronized void synchronizedMethod() {
    // 方法体
}

而 ReentrantLock 是一个类,需要通过实例化来使用。比如:

ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // 同步代码块
} finally {
    lock.unlock();
}

其次,在功能特性方面,ReentrantLock 比 synchronized 更加灵活。ReentrantLock 提供了诸如可中断锁的功能。当一个线程在等待获取锁时,可以被中断。例如,在一个长时间等待获取锁的线程中,如果外部希望终止这个等待过程,就可以调用线程的 interrupt 方法来中断等待。而 synchronized 不具备这个功能,线程一旦进入等待获取锁的状态,就只能一直等待下去,直到获取到锁或者抛出异常。

另外,ReentrantLock 可以设置公平锁和非公平锁。公平锁是指多个线程按照申请锁的先后顺序来获取锁。在一些对线程获取资源顺序有严格要求的场景下很有用。而 synchronized 是非公平锁,无法保证线程获取锁的顺序。

从性能角度考虑,在低竞争环境下,synchronized 的性能和 ReentrantLock 差不多。但是在高竞争环境下,ReentrantLock 的性能可能会更好。因为 synchronized 是基于对象头的标记和内置的管程模型来实现同步的,在高竞争场景下,线程的阻塞和唤醒等操作可能会比较频繁,导致性能下降。而 ReentrantLock 可以通过一些优化策略,比如更灵活的锁获取和释放机制,来减少这种性能损耗。

最后,在锁的可扩展性方面,ReentrantLock 提供了更多的扩展功能。例如,它可以和条件(Condition)配合使用,实现更复杂的线程间通信和同步控制。比如可以通过 Condition 来实现类似 “等待 - 通知” 的模式,一个线程在满足某个条件后等待,另一个线程在满足另一个条件后通知等待的线程继续执行,这比 synchronized 单纯的等待和唤醒机制要更加灵活。

面试官:你刚才提到 ReentrantLock 可以设置公平锁,那在实际应用中,公平锁和非公平锁的性能差异具体体现在哪些方面呢?

:在实际应用中,公平锁因为要保证线程按照申请顺序来获取锁,所以在每次有线程请求锁时,都需要检查等待队列,看是否有比它更早申请的线程还在等待。这就导致了一定的性能开销,尤其是在高并发场景下,频繁地维护等待队列会使性能下降。例如,在一个频繁获取锁的多线程任务处理系统中,如果使用公平锁,每次获取锁都需要遍历等待队列,这会消耗额外的时间。

而非公平锁则没有这种限制,它允许新到来的线程先尝试获取锁,而不管等待队列中的情况。这样在某些情况下,新线程可能会直接获取到锁,而不需要像公平锁那样排队等待。在高并发场景下,非公平锁的这种特性可能会使得一些线程能够更快地获取到锁,从而提高整体的性能。不过,非公平锁可能会导致某些线程长时间等待,因为有可能新线程不断抢占锁,使得等待队列中的线程一直无法获取到锁。

面试官:在你说的 ReentrantLock 的可中断锁功能中,如果一个线程被中断了,那它所占用的锁会怎样呢?

:当一个线程被中断时,它所占用的锁并不会自动释放。这是为了保证数据的安全性和一致性。因为如果在中断时自动释放锁,可能会导致其他线程在数据状态不稳定的情况下获取锁并进行操作。

不过,被中断的线程可以在合适的代码位置(比如在捕获到中断异常后的 finally 块中)手动释放锁。这样可以确保锁资源的正确管理,同时也能让线程根据中断信号来决定如何处理后续的操作,比如是直接退出任务,还是进行一些清理工作后再退出。

例如,在一个多线程文件读取系统中,如果一个线程在读取文件过程中被中断,它应该在释放锁之后,根据业务逻辑判断是否需要重新尝试读取,或者通知其他相关线程文件读取任务被中断。

面试官:你提到 ReentrantLock 可以和条件(Condition)配合使用,能详细说一下这种配合是如何实现更复杂的线程间通信的吗?

:ReentrantLock 和 Condition 配合使用,可以实现多线程之间的精准通知和等待。首先,通过 ReentrantLock 的 newCondition 方法可以创建一个 Condition 对象。

假设我们有一个生产者 - 消费者模型的场景。生产者线程和消费者线程共享一个资源池。当生产者生产了一个资源后,它需要等待消费者消费这个资源后才能继续生产。而消费者在资源池为空时需要等待生产者生产资源。

通过 ReentrantLock 和 Condition 可以这样实现:

ReentrantLock lock = new ReentrantLock();
Condition notEmpty = lock.newCondition();
// 资源池
List<Object> resourcePool = new ArrayList<>();

// 生产者线程
new Thread(() -> {
    lock.lock();
    try {
        while (resourcePool.size() == 0) {
            // 资源池为空,生产者等待
            notEmpty.await();
        }
        // 生产资源
        Object resource = resourcePool.remove(0);
        // 处理资源
        System.out.println("生产者处理资源: " + resource);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        lock.unlock();
    }
}).start();

// 消费者线程
new Thread(() -> {
    lock.lock();
    try {
        // 生产资源并放入资源池
        Object resource = new Object();
        resourcePool.add(resource);
        System.out.println("消费者添加资源: " + resource);
        // 通知生产者资源池不为空了
        notEmpty.signal();
    } finally {
        lock.unlock();
    }
}).start();

在这个例子中,生产者线程在资源池为空时通过 await 方法等待在 Condition 对象上,而消费者线程在生产并添加资源到资源池后,通过 signal 方法通知等待的生产者线程。这种方式比单纯使用 synchronized 关键字实现的等待 - 通知机制更加灵活,可以根据不同的业务条件来精确地控制线程之间的通信和同步。

面试官:在高并发环境下,使用 ReentrantLock 和 synchronized 对系统的可维护性有什么不同的影响吗?

:在高并发环境下,从可维护性角度来看,ReentrantLock 相对来说可能更复杂,但也更灵活。

因为 ReentrantLock 是通过代码块来显式地控制锁的获取和释放,这使得代码的逻辑结构相对复杂一些。如果代码中有多个地方使用了 ReentrantLock,就需要确保在所有可能的路径上都正确地释放锁,否则可能会导致死锁或者资源泄漏的问题。不过,正是由于这种显式的控制,开发人员可以在代码中更灵活地处理各种异常情况和复杂的业务逻辑。

而 synchronized 关键字在方法或者代码块上的使用相对简单直接,代码的阅读和理解相对容易。但是在复杂的高并发场景下,由于其功能相对固定,当需要一些特殊的同步功能(如可中断锁、公平锁等)时,就很难满足需求。如果要实现这些功能,可能需要对代码进行大量的重构,引入额外的复杂机制,这会降低代码的可维护性。

例如,在一个大型的分布式系统的高并发模块中,如果需要根据不同的业务场景灵活地切换公平锁和非公平锁,使用 ReentrantLock 可以在不改变整体架构的情况下,通过修改配置或者少量代码调整来实现。而如果使用 synchronized,可能需要重新设计整个同步机制,这会增加维护的难度和成本。

面试官:你提到在低竞争环境下,synchronized 和 ReentrantLock 性能差不多,那从底层原理角度讲讲为什么会这样呢?

:从底层原理来说,synchronized 在 JVM 中有优化机制。在低竞争环境下,它主要是通过偏向锁来实现高效同步。当一个线程访问同步块时,JVM 会把这个锁标记为偏向该线程,这个线程后续再访问这个同步块就几乎没有额外开销,就像访问普通的代码一样。

而 ReentrantLock 在低竞争环境下,由于没有太多线程竞争锁,它的锁获取和释放操作相对简单直接,没有因为频繁的竞争而导致复杂的阻塞和唤醒操作。所以在这种情况下,两者的性能表现比较接近。

例如,在一个简单的单线程为主,偶尔有其他少量线程访问的缓存系统中,使用 synchronized 关键字来保证缓存数据更新的同步,和使用 ReentrantLock 的性能损耗都比较小,都能够有效地防止数据不一致的情况。

面试官:如果在一个嵌套使用锁的场景中,ReentrantLock 和 synchronized 分别是如何处理的?

:对于 ReentrantLock,它是可重入锁。这意味着如果一个线程已经获取了一个 ReentrantLock 锁,在没有释放这个锁的情况下,它可以再次获取这个锁,并且内部会有一个计数器来记录重入的次数。每次获取锁,计数器加 1,每次释放锁,计数器减 1,当计数器为 0 时,锁才真正被释放。

ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // 第一次获取锁后的代码
    lock.lock();
    try {
        // 第二次获取锁后的代码
    } finally {
        lock.unlock(); // 第二次释放锁
    }
} finally {
    lock.unlock(); // 第一次释放锁
}

对于 synchronized,它也是可重入的。如果一个线程进入了一个同步方法或者同步代码块,在这个方法或者代码块内部又调用了其他同步方法或者进入同步代码块,只要是同一个对象的锁,它就可以直接进入,不会出现自己把自己锁住的情况。

public class ReentrantExample {
    public synchronized void outerMethod() {
        // 外部同步方法
        innerMethod();
    }
    public synchronized void innerMethod() {
        // 内部同步方法
    }
}

在这个例子中,当一个线程调用 outerMethod 方法时,获取了对象的锁,在 outerMethod 内部调用 innerMethod 时,因为是同一个对象的锁,所以线程可以直接进入 innerMethod 方法,不会被阻塞。

面试官:在分布式系统中,ReentrantLock 和 synchronized 都有哪些局限性?

:在分布式系统中,synchronized 的局限性比较明显。因为 synchronized 是基于 JVM 内部的机制来实现锁,它只能控制同一个 JVM 内的多线程同步。在分布式系统中,多个不同 JVM 中的线程无法通过 synchronized 来进行同步。

例如,在一个分布式的微服务架构中,不同微服务运行在不同的 JVM 上,synchronized 就无法保证这些微服务之间的资源访问同步。

ReentrantLock 虽然是一个功能更强大的锁,但它本质上也是在单个 JVM 范围内起作用。如果要在分布式系统中使用,需要额外的分布式锁机制来配合。比如结合 Zookeeper 或者 Redis 来实现分布式锁,将 ReentrantLock 的功能扩展到分布式环境中。但这样会增加系统的复杂性,并且在性能和可靠性方面也需要更多的考虑,如网络延迟、节点故障等因素可能会影响分布式锁的正常使用。

面试官:ReentrantLock是如何做到可重入的?

:ReentrantLock 实现可重入主要是通过内部维护的一个计数器来实现的。

当一个线程第一次获取 ReentrantLock 锁时,它会将一个计数器的值初始化为 1。这个计数器记录了当前线程获取锁的次数。然后,这个线程就可以进入被该锁保护的代码块或者方法。

如果这个线程在持有锁的情况下,再次进入被同一个 ReentrantLock 保护的代码块或者方法,ReentrantLock 会检查当前线程是否是锁的持有者。因为是同一个线程,所以会允许它再次获取锁,并且此时计数器会加 1。例如,在下面的代码中:

ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // 第一次获取锁后的代码
    lock.lock();
    try {
        // 第二次获取锁后的代码
    } finally {
        lock.unlock(); // 第二次释放锁,计数器减1
    }
} finally {
    lock.unlock(); // 第一次释放锁,计数器减1,当计数器为0时,锁才真正被释放
}

在这个代码中,第一次调用lock.lock()时,计数器变为 1,线程进入第一个try块。当第二次调用lock.lock()时,因为是同一个线程,所以计数器变为 2,线程可以进入第二个try块。

当线程执行完内部的代码块,准备释放锁时,会调用unlock方法。每调用一次unlock,计数器就减 1。只有当计数器的值最终减到 0 时,锁才会被真正释放,这样其他线程才能获取这个锁。这种机制就保证了 ReentrantLock 的可重入性,避免了同一个线程在多次获取同一个锁时出现死锁的情况。

面试官:这个计数器在 ReentrantLock 的内部实现中是如何存储和管理的呢?

:在 ReentrantLock 的内部实现中,计数器实际上是和锁的状态存储在一起的。具体来说,在 AQS(AbstractQueuedSynchronizer)框架的基础上实现,AQS 中有一个state变量来保存锁的状态信息。

对于 ReentrantLock 来说,这个state变量就充当了计数器的角色。当state为 0 时,表示锁是空闲的,没有线程持有。当一个线程获取锁时,state会被更新为 1 或者在重入的情况下进行相应的增加。在 AQS 的实现中,有一系列的方法来对这个state变量进行原子操作,比如compareAndSetState方法,它通过 CAS(Compare - and - Swap)操作来确保state变量的更新是原子性的。

例如,在非公平锁的lock方法实现中,它会首先尝试通过 CAS 操作将state从 0 设置为 1 来获取锁。如果成功,就表示获取锁成功,并且会设置当前线程为锁的持有者。如果 CAS 操作失败,就会进入到一个等待队列的处理逻辑中,看看是否能够通过排队等方式获取锁。在重入的情况下,每次获取锁,都会通过安全的方式(如 CAS 操作)来更新state变量,确保计数器的正确性。

面试官:在多线程并发获取 ReentrantLock 的场景下,如果一个线程在重入获取锁的过程中被中断,会发生什么情况?

:如果一个线程在重入获取 ReentrantLock 过程中被中断,这取决于 ReentrantLock 的设置。

如果是可中断模式(通过lockInterruptibly方法获取锁),当线程被中断时,会抛出InterruptedException异常。例如:

ReentrantLock lock = new ReentrantLock();
try {
    lock.lockInterruptibly();
    try {
        // 第一次获取锁后的代码
        lock.lockInterruptibly();
        try {
            // 第二次获取锁后的代码
        } finally {
            lock.unlock(); // 第二次释放锁
        }
    } finally {
        lock.unlock(); // 第一次释放锁
    }
} catch (InterruptedException e) {
    // 处理中断异常
    System.out.println("线程被中断");
}

在这个代码中,如果线程在重入获取锁的过程中被中断,就会捕获到InterruptedException,然后可以在catch块中进行相应的处理,比如清理已经完成的部分工作、释放已经获取的资源等。

如果是通过普通的lock方法获取锁,即使线程被中断,它也不会抛出InterruptedException,而是会继续尝试获取锁,就好像没有被中断一样。这是因为普通的lock方法不响应中断,它的目的是确保能够获取到锁,除非出现其他异常情况,如内存不足等。

面试官:在 ReentrantLock 的重入机制下,如何确保公平性呢?如果是公平锁,重入的线程是否需要重新排队?

:在公平锁模式下,ReentrantLock 会维护一个等待队列,线程按照它们请求锁的先后顺序排队。

当一个线程重入获取锁时,它不需要重新排队。因为公平锁主要是确保不同线程获取锁的公平性,对于已经获取到锁的线程再次获取锁(重入),它仍然是锁的持有者,所以可以直接获取锁。

例如,假设有三个线程 A、B、C 按照顺序请求锁。A 先获取到锁,在 A 持有锁的过程中,如果 A 再次请求锁(重入),它可以直接获取,因为它已经是锁的持有者。B 和 C 则会在等待队列中等待 A 释放锁。只有当 A 最终释放锁,并且 B 是等待队列中的第一个线程时,B 才有机会获取锁。

在公平锁的实现中,内部会通过检查等待队列和当前线程是否是锁的持有者等条件,来正确地处理锁的获取和重入,以保证整个过程是公平的。这是通过 AQS(AbstractQueuedSynchronizer)框架中的一些复杂的逻辑来实现的,包括对等待队列的操作和对锁状态的判断等。