面试难度:★★★★★
考察概率:★★★★★
#莫等闲,白了少年头#
本人从毕业开始一直在一线互联网大厂工作,现任技术TL,出版过《深入理解Java并发》一书,折腾过技术开源项目,并长期作为面试官参与面试,深谙双方的诉求与技术沟通。如今归零心态,再出发。#莫等闲,白了少年头#
技术交流+v:xxxyxsyy1234(一起努力,每日打卡) 2000+以面试官视角总结的考点,可与我共同打卡学习
面试官视角
如果说并发j.u.c包的实现基石是什么?那一定是整个lock体系,为开发者提供了各种各样的锁机制,让开发者来高效的实现线程安全的业务代码。从lock体系的考察来看,一方面是指令集支撑的并发关键字synchronized的理论知识,另一方面是自定义的ReentrantLock体系,这里如果能真正理解源码设计的话,可以参考大佬的设计思想,在实际工作中能实现业务自定义的线程安全组件,同时也能学习到大佬在实现组件时所应用到的设计模式。作为面试官来看,这块是完全可以拉开各个候选人不同档次的差异点的。
面试题
- Lock与synchronized的比较?
- AQS的设计思路?
- 如何创建一个同步组件?
- AQS如何维护同步状态以及完成同步队列的入队出队的过程?
- ReentrantLock如何实现重入性的?
- ReentrantReadWriteLock的读写锁实现读写状态分别标记的过程?
- 公平锁与非公平锁的比较?
- 简述Condition的await和signal的等待通知实现原理?
- LockSupport的使用场景?
回答要点
1. Lock与synchronized的比较?
- Lock提供了基于API的可操作性,提供能可响应中断式获取锁,超时获取锁以及非阻塞式获取锁的特性
- synchronized执行完同步块以及遇到异常会自动释放锁,而Lock要显示的调用unlock方法释放锁
2. AQS的设计思路?
- AQS同步队列的数据结构:带头结点的双向链表实现的队列
- 独占式锁
- 锁获取原理:当前线程对同步状态获取成功则退出;如果获取失败则通过addWaiter方法将当前线程封装成节点加入同步队列,acquireQueued方法使得当前线程等待获取同步状态。如果获取同步状态并且是当前节点的前驱节点是同步队列中的头结点,则表明获取锁成功,并唤醒后继结点.
public final void acquire(int arg) {
//先看同步状态是否获取成功,如果成功则方法结束返回
//若失败则先调用addWaiter()方法再调用acquireQueued()方法
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
- 锁释放原理:如果同步状态释放成功(tryRelease返回true)则会执行if块中的代码,当head指向的头结点不为null,并且该节点的状态值不为0的话才会执行unparkSuccessor()方法。队列中存在部分节点已经获取到同步状态了,那么就需要从后往前找到距离头结点最近的正在等待获取同步状态的节点,通过调用LockSupport.unpark()方法,该方法会唤醒该节点的后继节点所引用的线程。
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
通过对源码的分析了解了AQS同步队列入队以及出队的操作,这也是AQS实现中最为复杂的部分。在深入理解这些底层原理后在以后的并发编程中才能够更加的融会贯通。现在对整体流程做一下总结:
(1) 线程获取锁失败,线程被封装成Node进行入队操作,核心方法在于addWaiter()和enq(),同时enq()完成对同步队列的头结点初始化工作、节点首次入队操作以及CAS操作失败的重试;
(2) 线程获取锁是一个自旋的过程,当且仅当当前节点的前驱节点是头结点并且成功获得同步状态时,节点出队即该节点引用的线程获得锁。否则,当不满足条件时就会调用LockSupport.park()方法使得线程阻塞;
(3) 释放锁的时候会通过LockSupport.unpark唤醒后继节点;
整体来说,在获取同步状态时,AQS维护一个同步队列,当获取同步状态失败的线程会加入到队列中进行自旋。移除队列(或停止自旋)的条件是前驱节点是头结点并且成功获得了同步状态。在释放同步状态时,同步器会调用unparkSuccessor()方法唤醒后继节点。
AQS其他的考点:这部分可以参考本人的书籍《深入理解Java并发》,或者本人博客 juejin.cn/spost/68449…
- 可响应中断式获取锁;
- 超时获取锁特性的实现原理
- 共享式锁
-
- 锁获取原理
- 锁释放原理
- 可响应中断式获取锁
- 超时获取锁特性的实现原理
可加分亮点
面试官心理:AQS是在java并发体系中很难理解的一个技术点,同时在设计时也遵循了设计模式,特别是在基于AQS来实现上层的并发组件时,更需要掌握这种设计精髓。这里反应了候选人对设计模式的掌握。
AQS的设计模式?
AQS的设计是使用模板方法设计模式,通过模板方法封装了对可变状态的维护操作,而针对不同场景的同步语义的实现,则由实现者通过继承重写特定的方法来进行实现。以ReentrantLock为例来看一下具体的实现流程。
AQS中需要重写的方法tryAcquire:
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
ReentrantLock中NonfairSync(继承AQS)会重写该方法为:
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
而最终AQS执行入队时会调用模板方法acquire():
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
在执行acquire方法时最终会调用tryAcquire方法,而此时tryAcquire已经被NonfairSync重写,并且最终通过重写来实现的是ReentrantLock的同步语义。整体上,这就是使用AQS的方式,在弄懂这点后会Lock的实现理解有很大的提升。可以归纳总结为这么几点:
-
同步组件(比如ReentrantLock等满足线程安全性的工具都称之为同步组件)的实现依赖于同步器AQS。在同步组件实现中,使用AQS的方式被推荐定义继承AQS的静态内部类;
-
AQS采用模板方法进行设计,AQS的protected修饰的方法需要由继承AQS的子类进行重写实现,通过重写逻辑来实现同步组件的不同业务场景下的同步语义。
AQS负责同步状态的管理、线程的排队以及等待和唤醒这些底层操作,而Lock等同步组件主要专注于实现同步语义来满足不同的并发场景。同时,在重写AQS的方法时,可能会使用AQS提供的getState()、setState()以及compareAndSetState()等方法进行修改同步状态。
3. 如何创建一个同步组件?
AQS提供给同步组件实现者,为其屏蔽了同步状态的管理,线程排队等底层操作实现者只需要通过AQS提供的模板方法实现同步组件的语义即可,lock(同步组件)是面向使用者的,定义了接口,隐藏了实现细节。
要创建一个同步组件,需要继承AQS并实现一些关键方法。以下是创建同步组件的一般步骤:
- 定义同步状态(state):同步组件的状态是AQS的核心。可以使用一个整数或一个原子变量来表示同步组件的状态。
- 实现获取同步状态的方法:定义
tryAcquire
方法,在该方法中通过CAS(Compare and Swap)操作获取同步状态。该方法用于尝试获取同步状态,如果成功获取则返回true,否则返回false。 - 实现释放同步状态的方法:定义
tryRelease
方法,在该方法中通过CAS操作释放同步状态。该方法用于释放同步状态,并通知后继线程。 - 实现判断同步状态的方法:定义
isHeldExclusively
方法,用于判断当前线程是否是同步状态的持有者。 - 定义同步方法:在自定义的同步组件中,可以定义一些公开的同步方法,使用AQS提供的模板方法来实现同步逻辑。
- 可选:实现共享模式方法:如果需要支持共享模式,可以实现
tryAcquireShared
和tryReleaseShared
方法。
通过继承AQS并实现上述方法,可以创建一个基于AQS的同步组件。AQS提供了一套底层的同步机制,可以方便地构建各种复杂的同步组件,如锁、信号量等组件。
需要注意的是,AQS是一个高度复杂和底层的框架,需要深入理解Java并发编程以及多线程同步的概念才能正确使用和实现。在实际使用中,也可以使用Java并发包中已经提供的同步组件,如ReentrantLock、Semaphore等,它们都是基于AQS实现的。
4. AQS 如何维护同步状态以及完成同步队列的入队出队的过程?
AQS通过一个整数类型的同步状态(state)来维护同步状态,并通过一个双向链表(等待队列)来完成同步队列的入队和出队操作。
- 维护同步状态:
AQS使用一个整数类型的变量来表示同步状态,通常将其定义为volatile修饰的成员变量。同步状态表示同步组件的当前状态,比如锁的状态可以表示为0表示未锁定,1表示已锁定。通过获取和释放同步状态,线程可以进行临界区的保护和共享资源的访问。 - 同步队列的入队操作:
当一个线程请求获取同步资源但无法立即获得时,它会被封装成一个Node对象,并加入到等待队列中。入队操作通过以下步骤完成:
-
- 创建一个Node对象,封装当前线程。
- 通过CAS(Compare and Swap)操作将Node对象添加到等待队列的尾部,即将Node对象的prev指针指向当前队列的尾节点,并将尾节点的next指针指向新的Node对象。
- 如果CAS操作失败,表示在操作过程中有其他线程并发修改了等待队列,需要重试入队操作。
- 同步队列的出队操作:
当一个线程释放同步资源时,它需要唤醒等待队列中的其他线程来竞争同步资源。唤醒操作通常发生在释放同步状态的tryRelease
方法中。出队操作通过以下步骤完成:
-
- 通过CAS操作将等待队列的头节点出队,即将头节点指向下一个节点,并将下一个节点的prev指针置为null。
- 如果CAS操作失败,表示在操作过程中有其他线程并发修改了等待队列,需要重试出队操作。
- 成功出队后,被出队的节点将被线程唤醒,并允许它们重新尝试获取同步状态。
通过维护同步状态和同步队列,AQS可以实现线程的排队和唤醒机制,保证同步资源的正确竞争和访问。这样,AQS就成为了构建各种同步组件(如锁、信号量等)的基础框架。
5. ReentrantLock 是如何实现重入性的?
ReentrantLock是Java并发包中的一个同步组件,它支持重入性(Reentrancy),也就是同一个线程可以多次获取同一个锁。要想实现重入性,主要需要解决两个问题:1. 在线程获取锁的时候,如果持有锁资源的线程是当前线程的话,则直接再次获取成功;2. 由于锁会被获取n次,那么只有锁在被释放同样的n次之后,该锁才算是完全释放成功。
以非公平锁为例,判断当前线程能否获得锁为例,核心方法为nonfairTryAcquire:
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
//1. 如果该锁未被任何线程占有,该锁能被当前线程获取
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
//2.若被占有,检查占有线程是否是当前线程
else if (current == getExclusiveOwnerThread()) {
// 3. 再次获取,计数加一
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
具体的代码逻辑可以查看注释,为了支持重入性,在第二步增加了处理逻辑,如果该锁已经被线程所占有了,会继续检查占有线程是否为当前线程。如果是的话,同步状态加1返回true,表示可以再次获取成功。每次重新获取都会对同步状态进行加一的操作,那么释放的时候处理思路是怎样的呢?(依然还是以非公平锁为例)核心方法为tryRelease:
protected final boolean tryRelease(int releases) {
//1. 同步状态减1
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
//2. 只有当同步状态为0时,锁成功被释放,返回true
free = true;
setExclusiveOwnerThread(null);
}
// 3. 锁未被完全释放,返回false
setState(c);
return free;
}
代码的逻辑请看注释,需要注意的是,重入锁的释放必须得等到同步状态为0时锁才算成功释放,否则锁仍未释放。如果锁被获取n次,释放了n-1次,该锁未完全释放返回false,只有被释放n次才算成功释放,返回true。至此可以理解ReentrantLock重入性的实现原理,也就是理解了同步语义的第一条。
6. ReentrantReadWriteLock读写状态的管理?
在并发场景中用于解决线程安全的问题,实际业务开发中几乎会高频率的使用到独占式锁来解决并发场景问题,通常使用java提供的关键字synchronized或者concurrents包中实现了Lock接口的ReentrantLock。它们都是独占式获取锁,也就是在同一时刻只有一个线程能够获取锁。而在一些业务场景中,大部分只是读数据,写数据的场景很少。如果仅仅是读数据的话并不会影响数据正确性,而如果在这种业务场景下,依然使用独占锁的话,很显然这将是出现性能瓶颈的地方,由于并发性不够好会严重的降低系统的吞吐量。针对这种读多写少的情况,java还提供了另外一个实现Lock接口的ReentrantReadWriteLock(读写锁)。那么,读写锁是怎样实现分别记录读写状态的?
读写锁是怎样实现分别记录读锁和写锁的状态的,如下图所示,通过对state变量的高低位来维护读写两个状态。
因此,写锁的tryAcquire方法的主要逻辑为:当读锁已经被读线程获取或者写锁已经被其他写线程获取,则写锁获取失败;否则,获取成功并支持重入,增加写状态。
可加分亮点
面试官心理:这种int或者long类型状态值,通过高低位切分出不同的值区间来管理多个业务状态,然后通过位运算对值区间进行操作,是个很巧妙的设计,在业务架构设计上也会采用这种方式,如果能理解到这一层,是个加分项。
另外的考点:这部分可以参考本人的书籍《深入理解Java并发》,或者本人博客 juejin.cn/post/684490…
1.WriteLock的获取和释放;
2.ReadLock的获取和释放;
3.锁降级策略:按照WriteLock.lock() --》ReadLock.lock() --》WriteLock.unlock()的顺序,WriteLock会降级为ReadLock
7. 公平锁与非公平锁的比较?
公平锁每次获取到锁为同步队列中的第一个节点,保证请求资源时间上的绝对顺序,而非公平锁有可能刚释放锁的线程下次继续获取该锁,则有可能导致其他线程永远无法获取到锁,造成“饥饿”现象。
公平锁为了保证时间上的绝对顺序,需要频繁的上下文切换,而非公平锁会降低一定的上下文切换,降低性能开销。因此,ReentrantLock默认选择的是非公平锁,则是为了减少一部分上下文切换,保证了系统更大的吞吐量。
8. 简述condition的await()和signal()方法的等待通知实现原理?
await源码如下:
public final void await() throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
// 1. 将当前线程包装成Node,尾插入到等待队列中
Node node = addConditionWaiter();
// 2. 释放当前线程所占用的lock,在释放的过程中会唤醒同步队列中的下一个节点
int savedState = fullyRelease(node);
int interruptMode = 0;
while (!isOnSyncQueue(node)) {
// 3. 当前线程进入到等待状态
LockSupport.park(this);
if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
break;
}
// 4. 自旋等待获取到同步状态(即获取到lock)
if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
interruptMode = REINTERRUPT;
if (node.nextWaiter != null) // clean up if cancelled
unlinkCancelledWaiters();
// 5. 处理被中断的情况
if (interruptMode != 0)
reportInterruptAfterWait(interruptMode);
}
当一个线程调用Condition的await()方法时,它会发生以下操作:
- 线程通过获取与Condition关联的锁(通常是ReentrantLock)来确保当前线程获取到锁资源,进入到同步队列中;
- 线程释放该锁,并进入Condition的等待队列中等待通知。
- 在释放锁之后,线程进入等待状态,让出CPU资源。等待被signal后并再次获取到锁资源时,从等待队列中进行退出,继续执行。
signal源码如下:
public final void signal() {
//1. 先检测当前线程是否已经获取lock
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
//2. 获取等待队列中第一个节点,之后的操作都是针对这个节点
Node first = firstWaiter;
if (first != null)
doSignal(first);
}
signal方法首先会检测当前线程是否已经获取lock,如果没有获取lock会直接抛出异常,如果获取到锁的话再得到等待队列的头指针引用的节点,之后的操作的doSignal方法也是基于该节点。
当另一个线程调用Condition的signal()方法时,它会发生以下操作:
- 线程通过获取与Condition关联的锁来确保当前线程获取到锁资源;
- 线程发送一个通知给Condition等待队列中的一个等待线程,使其从等待状态返回到可运行状态。
- 通常,被唤醒的线程将再次尝试获取与Condition关联的锁。
具体来说,Condition的等待和通知实现依赖于底层的AQS(AbstractQueuedSynchronizer)和等待队列的机制。在等待队列中,每个等待线程会被封装成一个Node对象,其内部保存了线程的相关信息。
当线程调用await()方法时,它会创建一个Node对象,并将其加入到Condition的等待队列中。同时,线程会释放与Condition关联的锁,允许其他线程进入临界区。然后,线程进入等待状态,挂起自己。
当另一个线程调用signal()方法时,它会尝试获取与Condition关联的锁。一旦获取到锁,它会从Condition的等待队列中选择一个等待线程(通常是先入队的线程),将其从等待状态唤醒,并让其重新尝试获取锁。
值得注意的是,await()和signal()方法的调用必须在持有相同的Condition对象时才能有效。一个Condition对象通常与一个特定的锁关联在一起,以确保在正确的上下文中进行等待和通知。通过Condition的await()和signal()方法,线程可以在满足特定条件之前等待,并在条件满足时得到通知,从而更加灵活地控制并发执行的顺序和流程。
9. 简述LockSupport的使用场景?
当涉及到更多细节时,以下是LockSupport使用场景的详细描述:
- 线程间的同步:
-
- LockSupport可以用于实现线程间的同步,典型的例子是一个线程需要等待另一个线程完成某个任务后才能继续执行。
- 通过调用park()方法阻塞当前线程,可以使线程等待某个特定条件的发生。其他线程可以在满足条件后调用unpark()方法唤醒等待的线程。
- 线程的阻塞和唤醒:
-
- LockSupport可以用于线程的阻塞和唤醒操作,使线程在需要的时候进行阻塞,并在某个时机被唤醒。
- 调用park()方法将会使当前线程进入阻塞状态,直到其他线程调用unpark()方法唤醒它。与传统的wait()和notify()方法相比,LockSupport提供了更直接的线程阻塞和唤醒操作。
- 中断支持:
-
- LockSupport支持线程的中断操作。当一个线程被阻塞时,如果其他线程调用了它的interrupt()方法进行中断,被阻塞的线程会立即返回而不会继续等待。
- 这种中断支持使得LockSupport可以在中断场景下提供更细粒度的线程控制。
- 高级同步工具的实现:
-
- LockSupport是许多高级同步工具的实现基础,如ReentrantLock、Semaphore等。
- 它可以用于构建更复杂的同步机制,通过结合其他并发工具实现更灵活和高效的同步策略。
总体而言,LockSupport提供了一种轻量级的线程控制机制,适用于各种并发编程场景。它具有灵活性、可中断性和高性能的特点,能够满足对线程同步和控制的多种需求。
代码考核
可能会考察手撕代码写一个锁的实现,可以参考第3个问题,实际上在AQS的源码中也给了一个互斥锁的示例demo的。
知识点详情
这部分可以参考本人的书籍《深入理解Java并发》,或者本人博客