Synchronized和ReentrantLock面试详解
前言
接下来为大家带来的是 Java 中的两个典型锁代表:Synchronized 和 ReentrantLock 的详解
面试题:谈一谈AQS
在说 ReentrantLock 时,有必要先了解一下 AQS,因为 ReentrantLock 就是基于 AQS 实现的
分析:
共享状态 volatile 修饰的 state + FIFO 队列,来管理线程对共享资源的访问
回答:
切记不要长篇大论,容易绕进去
简单来说 AQS 就是起到了一个抽象,封装的作用,将一些排队,入队,加锁,等方法提供出来,便于其他相关 JUC 锁的使用,具体加锁时机,入队时机等都需要实现类自己控制
它主要通过维护一个共享状态 state 和一个先进先出的 FIFO 等待队列,来管理线程对共享资源的访问
state 用 volatile 修饰,表示当前资源的状态。例如,在独占锁中,0 表示未被占用,1 表示已被占用
当线程尝试获取资源失败时,会被加入到 AQS 的等待队列中。队列采用双向链表结构,节点包含线程的引用,等待状态以及前驱和后继节点的指针
AQS 的常见实现类有:ReentrantLock,Semaphore 等等
然后可能会被追问 ReentrankLock 实现原理
面试题:ReentrankLock 实现原理
分析:
基于 AQS 的可重入锁,支持公平和非公平两种 依靠 state 变量和两种队列:同步队列和等待队列
回答:
ReentrantLock 其实就是基于 AQS 实现的一个可重入锁,支持公平和非公平两种方式。
内部实现依靠一个 state 变量和两种队列:同步队列和等待队列,等待队列可以有多个,具体看 condition 条件的数量
利用 CAS 修改 state 来争抢锁
争抢不到则入同步队列等待,同步队列是一个双向链表
条件 condition 不满足时则入等待队列(当条件满足时,从等待队列中出来的线程会尝试直接获取锁,而不是先进入同步队列,失败再进入同步队列),是个单向链表
是否是非公平锁的区别在于:线程获取锁时是加入到同步队列尾部还是直接利用 CAS 争抢锁
一、等待队列中线程的唤醒顺序
默认情况:在
ReentrantLock
的Condition
实现中,如果没有特别指定,当条件满足时,唤醒等待队列中的线程顺序是不确定的。它通常是基于底层实现的一些规则,可能与线程进入等待队列的顺序没有直接的固定关联。例如,可能是根据底层数据结构(如链表)的遍历顺序或者其他内部机制来决定先唤醒哪个线程。
signal()
和signalAll()
的影响:
当调用
condition.signal()
方法时,通常只会唤醒等待队列中的一个线程,但具体是哪个线程被唤醒是不确定的,可能是任意一个等待线程。而调用
condition.signalAll()
方法时,会唤醒等待队列中的所有线程,这些线程会竞争获取锁,获取到锁的线程可以继续执行,其他线程则会进入同步队列等待获取锁的机会。二、
Condition
与等待队列的关系
一个
Condition
一个等待队列:每个Condition
对象都有自己独立的等待队列。这意味着不同的Condition
可以用于不同的等待条件和线程协作场景,并且它们各自管理自己的等待线程队列。
例如,在一个复杂的多线程应用中,可能有多个不同的条件需要线程等待,如数据准备好、资源可用、某个标志位设置等。可以为每个这样的条件创建一个
Condition
对象,每个Condition
的等待队列中存放着等待相应条件的线程。当某个条件满足时,通过对应的Condition
对象的signal()
或signalAll()
方法来唤醒该Condition
等待队列中的线程,而不会影响其他Condition
的等待队列和其中的线程。这样的设计使得线程间的协作更加灵活和精细,可以根据具体的业务逻辑和需求,为不同的条件和场景创建专门的
Condition
对象及其等待队列,从而更好地控制线程的等待和唤醒,提高并发程序的正确性和性能。例如,在一个线程池的实现中,可能有一个
Condition
用于等待任务队列中有任务可执行,另一个Condition
用于等待线程池中的空闲线程数量达到一定阈值等,它们各自管理着不同的等待线程,互不干扰。
公平锁情况
同步队列:
当同步队列中的线程被唤醒时(例如前面的线程释放了锁),线程会按照先进先出(FIFO)的原则依次尝试获取锁。被唤醒的线程会检查自己是否是同步队列中的第一个节点(头节点的下一个节点),如果是,它会尝试通过CAS操作来修改
state
变量以获取锁。这是因为公平锁要保证线程获取锁的顺序是按照请求锁的顺序来的,所以会严格按照队列顺序来处理。如果CAS操作成功,线程就获取到了锁,然后从同步队列中移除该节点;如果CAS操作失败,说明可能有新的线程插队(这种情况在公平锁中一般是由于一些特殊的实现细节或者并发干扰导致的),被唤醒的线程会再次等待,直到轮到自己成为第一个节点并且成功通过CAS获取锁。
等待队列:
当等待队列中的线程被唤醒(通过
Condition
的signal
或signalAll
操作)后,线程会先重新获取ReentrantLock
锁。由于公平锁的特性,它会加入到同步队列的尾部,然后按照同步队列的规则,等待自己成为头节点的下一个节点后,再尝试通过CAS操作获取锁。这样就保证了公平性,即等待条件满足的线程也需要按照请求锁的顺序来获取锁。非公平锁情况
同步队列:
当同步队列中的线程被唤醒时,和公平锁类似,它会尝试通过CAS操作修改
state
变量来获取锁。但是与公平锁不同的是,非公平锁允许新到达的线程直接尝试获取锁,而不考虑同步队列中的顺序。所以在同步队列中的线程获取锁时,可能会被新到达的线程抢先获取锁。如果被抢先,被唤醒的线程会继续等待,直到再次有机会通过CAS获取锁。等待队列:
当等待队列中的线程被唤醒后,它会直接尝试通过CAS操作获取锁,而不是先加入到同步队列的尾部。如果CAS操作成功,线程就获取到了锁并继续执行;如果CAS操作失败,说明锁已经被其他线程获取(可能是新到达的线程或者同步队列中抢先的线程),那么该线程会加入到同步队列的尾部,等待下一次获取锁的机会。这体现了非公平锁的“非公平”特性,即等待条件满足的线程也可能会被其他新到达的线程抢先获取锁。
面试题:Java中的 Synchronized 是怎么实现的
分析:
原理,修饰不同地方的解释,依赖于什么(Monitor,Monitor中的属性),使用 synchronized 的早期流程,后期的锁升级
回答:
原理:
synchronzied 实现原理基于 JVM 的 Monitor(监视器锁)机制,每个对象的对象头中都有一个 MarkWord 部分,锁状态不同,MarkWord 存的数据也不同,重量级锁状态存的是指向 monitor 的指针
修饰不同地方的解释:
synchronized 可以修饰方法和修饰代码块,修饰普通方法锁住的是当前实例对象,修饰静态方法锁住的是当前对象,修饰代码块可以锁主任意对象(看括号中是什么),不管是修饰代码还是代码块,在字节码层面,JVM 会插入两条指令,monitorenter 用于获取 monitor 锁,monitorexit 用于释放锁
依赖于什么:
Monitor 中有几个重要属性:owner,entryList,waitSet
Owner 表示当前持有锁的线程,当一个线程获取锁失败时会进入 entryList,当一个线程调用 wait() 时 该线程会释放锁并进入waitSet
Synchronized 的 monitor 锁主要使用的是 owner 和 entryList,waitSet 主要是 synchronized 配合 wait/notify/notifyAll 使用
使用 synchronized 的早期流程:
早期使用 synchronized 锁时,线程获取锁的过程:
检查锁状态 -> 获取不到锁时的处理 -> 锁的释放
-
检查锁状态:检查当前对象的 MatkWord 来判断锁的状态,如果锁是无锁状态,那么线程会获取锁,将 monitor 中的 owner 设置为当前线程
-
如果没有获取到锁:那么线程会进入 Monitor 的 EntryList 等待
-
线程执行完临界区代码后,会释放锁,并将 monitor 中的 owner 设置为 null。当锁被释放时,EntryList 中的线程会竞争锁。
后期的锁升级:
JDK6 开始引入了偏向锁和轻量级锁,避免每次都要加 monitor 这样的重量级锁
具体的锁升级流程是:无锁 -> 偏向锁 -> 轻量锁 -> 重量锁(monitor)
-
无锁:一开始没有线程持有锁
-
偏向锁:当只有一个线程访问时,JVM 会将锁设置为偏向锁
-
轻量级锁:当有其他不同线程来竞争锁时(相同线程不会升级,会进行重入),会使用 CAS 来获取锁,如果成功,就获取到了轻量级锁,CAS 不成功,就会升级重量级锁。
-
重量级锁:轻量级锁阶段的 CAS 不成功,JVM 会将锁升级为重量级锁,也就是 monitor 锁
当 Java 的 synchronized 升级到重量级锁后,所有线程都释放锁了,此时它还是重量级锁吗?
回答:重量级锁的线程释放后,锁重新从无锁开始,此时如果再有一个线程争抢锁,则会从轻量级锁开始
Synchronized 配合 wait() notify() notifyAll() 的使用:
此时 WaitSet 中有多个线程,我现在调用了一个 notifyAll(),那么所有在 WaitSet 中的线程会被唤醒,并从 WaitSet 中移除。被唤醒的线程会尝试获取锁:
如果成功获取锁,线程会继续执行
如果未能获取锁,线程会进入 EntryList 等待(不回 waitSet 了,因为等待条件已经满足,只是没拿到锁,所以要进 entryList)
为什么 wait() notify() notifyAll() 得在同步代码块里?
分析:因为 wait() notify() notifyAll() 得获取锁之后才能使用,所以得放在同步代码块里
回答:
当一个线程需要调用对象的wait()方法的时候,这个线程必须拥有该对象的锁,接着它就会释放这个对象锁并进入等待状态直到其他线程调用这个对象上的notify()方法。同样的,当一个线程需要调用对象的notify()方法时,也会先获取到对象的锁,然后执行notify,最后再释放这个对象的锁,以便其他在等待的线程就可以得到这个对象锁。由于所有的这些方法都需要线程持有对象的锁,这样就只能通过同步来实现,所以它们只能在同步方法或者同步块中被调用。
面试题:Synchronized 和 ReentrantLock 的区别
分析:
5 个不同:用法,获取释放锁时机不同,锁类型不同,响应中断不同,底层实现不同
Synchronized 和 ReentrantLock 都是可重入锁
回答:
Synchronized 和 ReentrantLock 都是 Java 中提供的可重入锁,主要区别如下:
-
用法不同:synchronized 可以用来修饰普通方法,静态方法和代码块,而 ReentrantLock 只能用于代码块
-
获取锁和释放锁的时机不同:synchronized 自动加锁和释放锁,而 ReentrantLock 需要手动加锁和释放锁
-
锁类型不同:synchronized 是非公平锁,而 ReentrantLock 默认为非公平锁,也可以手动指定为公平锁
-
响应中断不同:ReentrantLock 可以响应中断,解决死锁问题 ,而 synchronized 不能响应中断
-
底层实现不同:synchronized 是 JVM 层面通过监视器实现的,而 ReentrantLock 是经过 AQS 实现的