锁框架-面试
Java 的并发锁机制主要分为两大体系:一是基于 synchronized
关键字的内置锁(或监视器锁),二是 java.util.concurrent.locks
包下的显式锁框架。
内置锁
定义
内置锁的核心是:synchronized 关键字,这是 Java 最原始、最基本的锁机制,由 JVM 底层实现和管理。
用法
同步代码块:
synchronized(object) { ... }
,需指定一个锁对象。同步实例方法:
public synchronized void method() { ... }
,锁对象是当前实例this
。同步静态方法:
public static synchronized void method() { ... }
,锁对象是当前类的Class
对象。
特性
互斥性:同一时间只有一个线程可以进入被锁保护的代码区域。
可重入性:一个线程可以多次获取自己已经持有的锁(锁计数)。
非公平锁:内置锁不保证线程获取锁的顺序,默认是非公平的。
不可中断:等待获取锁的线程无法被中断,会一直阻塞直到获取到锁。
自动释放:当同步代码块执行完毕或发生异常时,锁会自动释放,无需手动操作。
优点
使用简单,JVM 会自动优化(如锁升级),不易出错。
缺点
功能相对单一,缺乏灵活性(如无法尝试非阻塞地获取锁、无法设置超时、无法实现公平锁等)。
显式锁框架
定义
java.util.concurrent.locks包,其核心接口是 Lock
。
场景
锁类型 | 核心特性与描述 | 适用场景 | 优点 | 缺点与注意事项 |
---|---|---|---|---|
ReentrantLock (首选替代方案) | 可重入的独占锁。提供了比 synchronized 更丰富的功能:可中断、超时获取、公平性选择。 | 1. 需要 synchronized 所缺乏的高级功能(如可中断、超时)的场景。2. 需要实现公平性策略的场景。 3. 锁的获取和释放不在同一个代码块(方法)中。 | 1. 功能强大且灵活。 2. 性能与 synchronized 相差无几(甚至在某些情况下更优)。3. 提供了丰富的 API。 | 1. 必须手动释放锁(必须在 finally 块中 unlock() ),否则会导致死锁。2. 代码编写稍显复杂。 |
ReentrantReadWriteLock (读写分离场景) | 读写锁分离。遵循读读共享、读写互斥、写写互斥的原则。 | 读多写少的并发场景。 例如: - 缓存系统 - 资源注册表 - 频繁查询但很少修改的配置数据 | 1. 极大提升读操作的并发性能,允许多个线程同时读。 2. 保证了写操作时的独占性和数据一致性。 | 1. 写锁饥饿:如果读线程源源不断,写线程可能长时间等待。 2. 在读少写多或竞争激烈的场景下,性能可能不如普通独占锁(因为内部实现更复杂)。 3. 同样需手动释放锁。 |
StampedLock (极致读性能优化) | 提供了三种模式的锁:写锁、悲观读锁和乐观读。乐观读是一种无锁的读取,性能极高。 | 读操作远远远远大于写操作,并且对读性能有极致要求的场景。 例如: - 高性能的统计、监控数据访问 - 金融行情报价系统 | 1. 乐观读模式性能远超读写锁,因为它完全不加锁。 2. 所有方法都返回一个印戳(Stamp),用于解锁或验证。 | 1. API 非常复杂,容易用错。 2. 不是可重入锁,同一个线程重复获取锁会导致死锁。 3. 不支持条件变量 ( Condition )。4. 悲观读锁和写锁不区分读写线程,可能导致饥饿。 |
实现核心
显示锁将其所有的同步机制(如获取锁、释放锁)都委托给了一个内部的 AQS 子类(Sync)来实现。
AQS锁框架定义
AQS(AbstractQueuedSynchronizer)是Java并发包中的一个抽象类,用于构建锁或其他同步组件的基础框架。它通过一个FIFO队列(同步队列)管理线程的阻塞与唤醒,并提供模板方法供子类实现自定义同步逻辑(如独占锁或共享锁)。
通俗理解
我们把 AQS 想象成一套标准的、自动化的医院挂号排队管理系统。这套系统的核心任务是:管理一大堆想要挂专家号(竞争共享资源)的人(线程),保证秩序不乱。
状态变量 (State) - “还剩几个号?”
通俗理解:挂号大厅里有一个巨大的电子显示屏,上面显示着
当前剩余号数
。比如
Semaphore
(信号量)允许多个线程同时访问,那么这个状态就表示还剩多少个许可。ReentrantLock
是独占锁,这个状态通常表示:0(没锁)、1(已锁定)、>1(同个线程重入了多次)。
底层实现:就是一个用
volatile
修饰的int
变量,保证所有线程都能立刻看到它的最新值。所有抢号的行为最终都是通过 CAS 操作(一种乐观锁)来修改这个数字,保证不会多人同时改成功。
等待队列 (CLH Queue) - “排队区”
通俗理解:当电子屏显示“0”,即没号了,后来的人怎么办?不能堵在诊室门口吧?保安会让他们去排队区坐着等。这个排队区就是一个 FIFO(先进先出)的队列。每个来排队的人会拿到一个排队号(Node)。
底层实现:AQS 内部维护了一个双向链表结构的队列。每个请求资源的线程都会被包装成一个
Node
节点,然后加入到这个队列的尾部。
核心工作流程:抢号与排队 (acquire)
现在,一个人(线程)想来挂专家号(获取资源),这套系统(AQS)的工作流程是这样的:
第一步:尝试直接抢号 (
tryAcquire
)他首先会看一眼大屏幕(读 State),如果还有号(
state > 0
),他就立刻尝试通过机器自助挂号(CAS 操作)。如果成功:他挂上号了,屏幕上的数字减一。他就可以直接进去看医生了(线程继续运行)。这个过程非常快,完全没有阻塞。
如果失败:说明同时有另一个人和他一起抢,并且对方手更快。或者干脆就没号了(
state = 0
)。那么流程进入第二步。
第二步:入队等待 (
addWaiter
)抢号失败的人,会被系统(AQS)自动生成一张排队小票(Node节点),然后让他去排队区(等待队列) 的末尾坐着等。
第三步:排队并等待唤醒 (
acquireQueued
)坐在排队区的人并不会傻傻地一直问“到我了吗?到我了吗?”,这样会累死(消耗CPU)。
他会睡觉(线程被挂起/LockSupport.park)。
那么谁叫他起床呢?当前面一个人看完病出来(释放资源)时,系统会叫排队区第一个位置的人起床(唤醒线程/LockSupport.unpark)。
第一个位置的人被唤醒后,他会再次尝试去第一步抢号(因为可能同时有新的号放出来,或者他是公平锁条件下理所应当该他)。
如果这次抢成功了,他就离开队列进去看医生。如果又失败了(比如突然来了个VIP?非公平锁下可能被插队),他会继续坐下睡觉,等待下一次被唤醒。
释放与唤醒 (release)
看完医生的人(线程)出来时,需要做一件事:
尝试释放号 (
tryRelease
):他会告诉系统:“我用完了”。系统会把大屏幕上的号数加一(修改 State)。唤醒下一个 (
unparkSuccessor
):如果释放成功(比如锁完全解开了),系统(AQS)就会去排队区,找到第一个正在睡觉的人,拍拍他叫他起床(唤醒下一个线程),让他去尝试挂号。
总结:AQS形容的是一个流程,需要一个状态变量保证当前专家号是否可用,需要一个队列保证阻塞需要排队处理、第三个是工作流程在尝试CAS抢号操作、第四抢到看完病后就可以释放资源好和重新唤醒共享资源的状态。
使用场景
显式锁实现:如
ReentrantLock
、ReentrantReadWriteLock
。同步工具:如
CountDownLatch
、Semaphore
、CyclicBarrier
。自定义同步组件:如需要控制线程协作的场景。
使用方法
继承AQS:子类需实现
tryAcquire
(独占锁)或tryAcquireShared
(共享锁)等方法。调用模板方法:如
acquire()
、release()
等,AQS会自动处理队列管理。
示例代码(独占锁):
class Mutex extends AbstractQueuedSynchronizer {@Overrideprotected boolean tryAcquire(int arg) {return compareAndSetState(0, 1); // CAS设置状态}@Overrideprotected boolean tryRelease(int arg) {setState(0); // 释放锁return true;}
}
// 使用
Mutex mutex = new Mutex();
mutex.acquire(1); // 加锁
mutex.release(1); // 解锁
用到的设计模式
模板方法模式:AQS定义骨架(如
acquire()
),子类实现具体逻辑(如tryAcquire()
)。观察者模式:线程通过队列等待状态变化,类似观察者模式中的通知机制。
AQS中同步队列的数据结构
双向链表(CLH队列):每个节点(
Node
)保存线程引用、等待状态(WAITING
、CANCELLED
等)、前驱和后继指针。状态变量(volatile int state):表示锁的占用情况(如
ReentrantLock
中state=0表示未占用)。
AQS原理
一个状态 (
state
):用来表示资源是否可用、可用多少。这是所有操作的核心。一个队列:用来管理所有抢资源失败的线程,让它们排队,避免无序竞争。
CAS + 自旋:在尝试获取资源时,使用高效的CAS操作,快速成功。
Park/Unpark:对于抢失败的线程,不是忙等,而是让它休眠,由释放资源的线程来唤醒,极大节省了CPU资源。
核心伪代码逻辑:
// acquire流程
if (!tryAcquire(arg) && addWaiter(Node.EXCLUSIVE).acquireQueued())selfInterrupt();// release流程
if (tryRelease(arg)) {Node h = head;if (h != null && h.waitStatus != 0)unparkSuccessor(h);
}