第四天:Java锁机制全面详解(面试必备)
第四天:Java锁机制全面详解(面试必备)
一、锁机制核心基础(面试入门必背)
1.1 锁的核心作用(底层本质)
在多线程并发场景中,多个线程同时访问共享资源(如全局变量、数据库数据、集合)时,会出现并发冲突(如数据错乱、重复修改、超卖等问题),锁的核心作用就是控制线程访问顺序,保证共享资源的原子性、可见性和有序性,从根源上解决并发冲突,保障数据安全。
底层本质:通过阻塞或非阻塞的方式,让多个线程有序访问共享资源,避免“同时修改”的情况发生,是多线程并发编程的“安全保障工具”。
1.2 锁的核心核心特性(面试高频)
无论哪种锁,都围绕以下3个核心特性设计,这是判断锁机制是否安全、高效的关键,也是面试必问考点:
-
原子性:最核心特性,保证共享资源的操作是“不可分割”的,要么全部执行成功,要么全部执行失败,不会出现中间状态(如线程A修改数据到一半,线程B读取到未修改完的脏数据)。
-
可见性:当一个线程修改了共享资源的值后,其他线程能立即看到修改后的最新值,避免线程读取到“过期数据”(如线程A修改了变量a,线程B依然读取到修改前的a值)。
-
有序性:避免CPU指令重排序导致的并发问题,保证线程执行顺序与代码逻辑顺序一致,防止“指令乱序”引发的数据错乱(如先执行赋值操作,再执行判断操作,避免判断时使用未赋值的变量)。
1.3 锁的分类(按核心维度划分,面试必记)
Java中的锁有多种分类方式,核心分类(面试高频)如下,后续会逐一详细拆解:
-
按“并发策略”分:悲观锁、乐观锁(最核心分类,面试必考)
-
按“可重入性”分:可重入锁、不可重入锁(基础特性,避坑关键)
-
按“共享方式”分:排他锁(独占锁)、共享锁(如读写锁)
-
按“实现粒度”分:全局锁、分段锁(如ConcurrentHashMap底层)
-
按“公平性”分:公平锁、非公平锁(ReentrantLock重点)
补充:所有锁的设计,本质都是“在性能和安全性之间找平衡”——安全性越高,往往性能越低(如悲观锁);性能越高,往往需要承担一定的冲突风险(如乐观锁)。
二、悲观锁 全面详解(并发安全首选,面试重点)
2.1 悲观锁核心概念(底层+大白话)
大白话定义:基于“悲观估计”,预判并发冲突一定会发生,因此在访问共享资源前,先加锁,强制让所有线程排队访问,未获取锁的线程会被阻塞,直到持有锁的线程释放锁,从根源上避免冲突。
底层核心思想:防御性编程,默认“所有线程都会修改共享资源”,通过加锁阻塞线程,控制线程访问顺序,保证共享资源的原子性和安全性,牺牲部分并发性能,换取数据安全。
核心特点:线程阻塞(未获取锁的线程进入阻塞队列)、安全性极高、存在加锁/解锁的性能开销(线程上下文切换)、适合并发冲突量大、数据一致性要求高的场景。
通俗比喻:出门随身带锁,出门前先把家门锁死,杜绝别人进入,属于保守防御型策略;就像银行办理业务,客户必须排队,一个客户办理完毕(释放锁),下一个客户才能办理(获取锁)。
2.2 悲观锁主流实现(3种,含底层细节)
2.2.1 关键字synchronized(JVM级别隐式悲观锁,重点)
Java内置关键字,无需手动导入包,JVM自动管理加锁、解锁,无需手动控制,是最常用的悲观锁实现,JDK1.6之后经过大幅优化(锁升级),性能大幅提升,不再是“重量级锁”的代名词。
底层实现:JDK1.6之前,synchronized直接是重量级锁,依赖操作系统的互斥量(Mutex)实现,线程阻塞/唤醒会产生大量上下文切换,性能较低;JDK1.6之后,引入偏向锁、轻量级锁,实现锁升级机制,根据并发场景动态切换锁状态,平衡性能与安全。
锁的作用范围(实战必记):
-
修饰普通方法:锁对象是当前实例对象(this),多个线程调用同一实例的该方法,会竞争同一把锁。
-
修饰静态方法:锁对象是当前类的Class对象(全局唯一),多个线程调用该静态方法,无论实例是否相同,都会竞争同一把锁(全局锁)。
-
修饰代码块:锁对象是括号内指定的对象(如this、类.class、自定义对象),粒度最细,推荐使用(减少锁的范围,提升并发性能)。
2.2.2 ReentrantLock(JUC包显式悲观锁,重点)
JDK1.5引入,位于java.util.concurrent.locks包下,是Lock接口的核心实现类,显式控制加锁、解锁,功能比synchronized更灵活,适合复杂并发场景。
底层实现:基于AQS(AbstractQueuedSynchronizer,队列同步器)实现,AQS底层维护一个双向链表(等待队列),未获取锁的线程会进入队列排队,获取锁的线程执行完毕后,唤醒队列头部线程,实现线程有序竞争。
2.2.3 老旧集合(底层默认悲观锁,了解即可)
Vector、HashTable(注意:不是HashMap),底层方法默认使用synchronized修饰,实现悲观锁,保证线程安全,但由于锁粒度太粗(全表加锁),并发性能极低,现在已被ConcurrentHashMap、CopyOnWriteArrayList等高效线程安全集合替代,面试可能会问“Vector和ArrayList的区别”,需记住“Vector线程安全,底层synchronized加锁,效率低”。
2.3 synchronized 底层锁升级机制(面试重中之重,含源码细节)
JDK1.6之后,官方为了优化synchronized的性能,引入了“锁升级”机制,锁状态单向不可逆升级,从无锁逐步升级为重量级锁,根据并发竞争的激烈程度,动态切换锁状态,平衡性能与安全,这是面试高频考点(大厂必问)。
四种锁状态详解(含底层实现、适用场景,面试必背)
-
无锁:
-
状态描述:无任何线程竞争共享资源,无锁标记,线程可以直接访问资源,无任何性能开销。
-
底层实现:无锁机制,依赖CPU的CAS指令(后续乐观锁会详细讲),无需加锁。
-
适用场景:单线程访问共享资源,无任何并发竞争。
-
-
偏向锁(JDK1.6新增,性能最优):
-
状态描述:单线程反复获取同一把锁,JVM会将当前线程ID存入锁对象的Mark Word(对象头的一部分),后续该线程再次获取锁时,无需重复加锁、解锁,直接放行,减少锁的开销。
-
底层实现:通过修改对象头的Mark Word标记线程ID,无需调用操作系统的互斥量,属于用户态锁,性能极高。
-
适用场景:单线程频繁访问共享资源(如单线程循环执行加锁方法)。
-
补充:如果偏向锁开启(默认开启),JVM会优先为单线程分配偏向锁;如果有其他线程竞争,偏向锁会立即撤销,升级为轻量级锁。
-
-
轻量级锁(JDK1.6新增,适配少量竞争):
-
状态描述:少量线程交替竞争锁,未获取锁的线程不会立即阻塞,而是采用自旋等待(循环尝试获取锁),避免线程上下文切换,减少性能开销。
-
底层实现:线程获取锁时,会将锁对象的Mark Word复制到当前线程的栈帧中,通过CAS修改Mark Word的锁标记,自旋一定次数(默认10次,可通过JVM参数调整),自旋成功则获取锁,失败则升级为重量级锁。
-
适用场景:少量线程交替竞争锁,并发冲突不激烈(如2-3个线程交替执行加锁逻辑)。
-
补充:自旋锁的优势是避免线程阻塞,劣势是自旋过程中会消耗CPU资源,因此自旋次数不能过多。
-
-
重量级锁(最安全,性能最低):
-
状态描述:大量线程激烈竞争锁,自旋失败,未获取锁的线程进入操作系统的阻塞队列(等待队列),由操作系统调度,线程阻塞/唤醒会产生上下文切换,性能开销较大。
-
底层实现:依赖操作系统的互斥量(Mutex)实现,属于内核态锁,JVM会调用操作系统的锁机制,控制线程的阻塞与唤醒。
-
适用场景:大量线程激烈竞争锁,并发冲突严重(如10个以上线程同时执行加锁逻辑)。
-
补充:锁升级的核心触发条件(面试延伸题)
-
偏向锁→轻量级锁:出现第二个线程竞争同一把锁,偏向锁撤销,升级为轻量级锁。
-
轻量级锁→重量级锁:自旋次数耗尽(默认10次),或自旋过程中出现更多线程竞争锁,轻量级锁升级为重量级锁。
-
锁升级不可逆:一旦升级为重量级锁,不会再降级为轻量级锁或偏向锁,因为降级的性能开销大于收益。
2.4 synchronized 实战使用代码(完整版,含3种用法+并发测试)
import java.util.concurrent.CountDownLatch;
public class SyncLockDemo {
// 共享变量(多线程并发操作的资源)
private int count = 0;
// 倒计时器,用于等待所有线程执行完毕,查看最终结果
private final CountDownLatch latch = new CountDownLatch(10);
// 1. 修饰普通方法:锁对象是当前实例(this)
public synchronized void addCount() {
count++;
// 模拟业务执行时间
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
latch.countDown(); // 线程执行完毕,倒计时减1
}
// 2. 修饰代码块(推荐,锁粒度更细,提升并发性能):锁对象是this
public void subCount() {
// 只对核心业务加锁,减少锁的范围
synchronized (this) {
count--;
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
latch.countDown();
}
// 3. 修饰静态方法:锁对象是当前类的Class对象(全局锁)
public static synchronized void staticLock() {
System.out.println("静态全局锁:" + Thread.currentThread().getName());
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws InterruptedException {
SyncLockDemo demo = new SyncLockDemo();
// 测试1:10个线程并发执行addCount,验证线程安全
for (int i = 0; i < 10; i++) {
new Thread(demo::addCount, "线程" + (i + 1)).start();
}
latch.await(); // 等待所有线程执行完毕
System.out.println("并发执行addCount后,count值:" + demo.count); // 预期输出10
// 测试2:10个线程并发执行subCount,验证线程安全
CountDownLatch latch2 = new CountDownLatch(10);
demo.latch = latch2;
for (int i = 0; i < 10; i++) {
new Thread(demo::subCount, "线程" + (i + 1)).start();
}
latch2.await();
System.out.println("并发执行subCount后,count值:" + demo.count); // 预期输出0
// 测试3:静态方法锁(全局锁)
for (int i = 0; i < 3; i++) {
new Thread(SyncLockDemo::staticLock, "静态线程" + (i + 1)).start();
}
}
}
2.5 ReentrantLock 显式悲观锁详解(核心考点,对比synchronized)
核心特性(区别synchronized,面试必背)
-
手动加锁手动解锁:必须通过lock()方法加锁,unlock()方法解锁,且unlock()必须写在finally代码块中,防止代码异常导致锁未释放,引发死锁(这是最容易踩的坑)。
-
支持公平锁/非公平锁:
-
非公平锁(默认):线程获取锁时,不排队,直接抢占锁,效率高,但可能出现线程饥饿(某些线程长期获取不到锁)。
-
公平锁:通过new ReentrantLock(true)创建,线程必须排队,按顺序获取锁,无线程饥饿,但并发效率低(因为需要维护排队顺序)。
-
-
支持超时获取锁:通过tryLock(long timeout, TimeUnit unit)方法,设置超时时间,超过时间未获取到锁则返回false,避免线程无限阻塞,有效防止死锁。
-
支持线程中断:通过lockInterruptibly()方法,可中断正在等待锁的线程(比如线程等待时间过长,主动中断,避免资源浪费)。
-
支持条件变量:通过newCondition()方法获取Condition对象,实现线程的精准唤醒(比如生产者-消费者模式,可唤醒指定类型的线程),而synchronized只能通过wait()/notify()/notifyAll()唤醒,无法精准唤醒。
-
可查询锁状态:提供isLocked()(判断锁是否被占用)、isHeldByCurrentThread()(判断当前线程是否持有锁)等方法,可灵活判断锁的状态,synchronized无法查询。
ReentrantLock 使用规范代码(完整版,含公平锁、超时锁、中断锁、条件变量)
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockDemo {
// 1. 创建非公平锁(默认)
private final ReentrantLock nonFairLock = new ReentrantLock();
// 2. 创建公平锁
private final ReentrantLock fairLock = new ReentrantLock(true);
// 3. 条件变量(用于精准唤醒)
private final Condition condition = nonFairLock.newCondition();
private int num = 0;
// 基本用法:手动加锁+解锁(必须finally解锁)
public void increase() {
nonFairLock.lock(); // 加锁
try {
// 执行业务逻辑(共享资源操作)
num++;
System.out.println(Thread.currentThread().getName() + ":num = " + num);
} finally {
nonFairLock.unlock(); // 必须在finally中解锁,异常也能释放锁
}
}
// 超时锁:设置1秒超时,超过1秒未获取锁则放弃
public void tryLockWithTimeout() {
try {
// 尝试获取锁,超时时间1秒
boolean isLocked = nonFairLock.tryLock(1, TimeUnit.SECONDS);
if (isLocked) {
try {
num++;
System.out.println(Thread.currentThread().getName() + ":超时锁获取成功,num = " + num);
} finally {
nonFairLock.unlock();
}
} else {
System.out.println(Thread.currentThread().getName() + ":超时锁获取失败");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 中断锁:可中断等待锁的线程
public void lockWithInterrupt() {
try {
nonFairLock.lockInterruptibly(); // 可中断锁
try {
num++;
System.out.println(Thread.currentThread().getName() + ":中断锁获取成功,num = " + num);
} finally {
nonFairLock.unlock();
}
} catch (InterruptedException e) {
System.out.println(Thread.currentThread().getName() + ":等待锁时被中断");
}
}
// 条件变量:精准唤醒(生产者-消费者模式简化版)
public void produce() throws InterruptedException {
nonFairLock.lock();
try {
// 模拟生产条件:num >= 5时,停止生产,等待消费
while (num >= 5) {
condition.await(); // 线程等待,释放锁
}
num++;
System.out.println(Thread.currentThread().getName() + ":生产后,num = " + num);
condition.signal(); // 唤醒等待的消费线程
} finally {
nonFairLock.unlock();
}
}
public void consume() throws InterruptedException {
nonFairLock.lock();
try {
// 模拟消费条件:num == 0时,停止消费,等待生产
while (num == 0) {
condition.await(); // 线程等待,释放锁
}
num--;
System.out.println(Thread.currentThread().getName() + ":消费后,num = " + num);
condition.signal(); // 唤醒等待的生产线程
} finally {
nonFairLock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
ReentrantLockDemo demo = new ReentrantLockDemo();
// 测试基本用法
for (int i = 0; i < 3; i++) {
new Thread(demo::increase, "基本线程" + (i + 1)).start();
}
// 测试超时锁
Thread timeoutThread = new Thread(demo::tryLockWithTimeout, "超时线程");
timeoutThread.start();
// 测试中断锁
Thread interruptThread = new Thread(demo::lockWithInterrupt, "中断线程");
interruptThread.start();
Thread.sleep(500); // 让线程先进入等待锁状态
interruptThread.interrupt(); // 中断线程
// 测试条件变量(生产者-消费者)
Thread producer = new Thread(() -> {
try {
demo.produce();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "生产者");
Thread consumer = new Thread(() -> {
try {
demo.consume();
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "消费者");
producer.start();
consumer.start();
}
}
synchronized 与 ReentrantLock 对比表(面试直接背诵,完整版)
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 锁类型 | 隐式锁,JVM自动加解锁,无需手动控制 | 显式锁,手动调用lock()/unlock()控制,必须finally解锁 |
| 公平锁支持 | 仅支持非公平锁,无法设置为公平锁 | 支持公平锁(new ReentrantLock(true))和非公平锁(默认) |
| 超时等待 | 不支持,线程会无限阻塞,易死锁 | 支持tryLock()超时,避免死锁 |
| 线程中断 | 不支持,无法中断等待锁的线程 | 支持lockInterruptibly(),可中断等待锁的线程 |
| 条件变量 | 支持wait()/notify()/notifyAll(),无法精准唤醒 | 支持Condition,可精准唤醒指定线程(如生产者-消费者) |
| 锁状态查询 | 无法查询锁的状态(无相关方法) | 提供isLocked()、isHeldByCurrentThread()等方法查询 |
| 底层实现 | JVM底层实现,依赖对象头Mark Word,锁升级机制 | JDK实现,基于AQS队列同步器,维护等待队列 |
| 性能 | JDK1.6优化后,与ReentrantLock差距不大,简单场景更高效 | 复杂场景(公平锁、超时锁等)更灵活,性能略优 |
| 使用难度 | 简单易用,无需手动管理锁,降低死锁风险 | 复杂,需手动控制加解锁,易遗漏unlock()导致死锁 |
| 适用场景 | 简单并发场景、单线程多任务、少量线程交替执行 | 复杂并发场景、需要公平锁/超时锁/中断锁/精准唤醒 |
三、乐观锁 全面详解(高并发高性能首选,面试高频)
3.1 乐观锁核心概念(底层+大白话)
大白话定义:默认认为线程之间几乎不会产生资源竞争,访问共享资源时,全程不加锁,直接操作资源,在提交更新数据时,校验数据是否被其他线程修改(即“版本校验”),无冲突则直接提交更新,有冲突则自动重试(或返回失败)。
底层核心思想:基于“乐观估计”,预判并发冲突不会发生,省去加锁/解锁的性能开销,通过“版本校验”解决偶尔的冲突,提升高并发场景下的性能。
核心特点:无阻塞(线程无需排队,直接操作资源)、性能极强(无上下文切换开销)、冲突过多时重试消耗CPU资源、适合高并发低冲突场景(如全局计数器、接口访问量统计、点赞收藏等)。
通俗比喻:平时出门不锁门,默认没人进家门,回家后先检查家里的东西有没有被动过(版本校验),如果没被动过(无冲突),就正常整理;如果被动过(有冲突),就重新整理(重试),属于激进高效策略。
3.2 乐观锁核心原理:CAS比较并交换(面试重中之重,含底层细节)
乐观锁的核心实现是CAS(Compare And Swap,比较并交换),是CPU提供的一条原子指令(保证原子性),无需加锁就能实现共享资源的原子操作,底层由硬件支持,性能极高。
CAS核心逻辑:包含三个参数——内存地址V(存储共享资源的地址)、预期原值A(线程读取到的共享资源当前值)、新值B(线程修改后的共享资源值);执行时,CPU会比较内存地址V中的实际值与预期原值A,如果相等,则将V中的值更新为B,返回成功;如果不相等,则不做任何操作,返回失败,线程重新读取A,再次尝试(重试)。
底层细节:CAS是CPU的原子指令,不会被其他线程打断,因此能保证原子性,无需加锁;JDK中的原子类(AtomicInteger等),就是基于CAS实现的乐观锁。
CAS的优势与不足(面试必答)
-
优势:
-
无阻塞:线程无需排队,直接操作资源,减少线程上下文切换开销。
-
高性能:基于CPU原子指令,无需JVM/操作系统介入,性能远高于悲观锁。
-
简单易用:JDK提供原子类,无需手动实现CAS逻辑,直接调用方法即可。
-
-
不足(面试高频延伸题):
-
ABA问题(核心问题):数值从A变成B,又从B改回A,CAS无法识别数据被修改过,会误认为数据未被修改,导致修改异常(比如线程1读取到A,线程2将A改为B,再改为A,线程1的CAS会认为A未变,执行修改)。
-
自旋空转:并发冲突量大时,线程会反复重试,自旋过程中消耗大量CPU资源,导致CPU使用率飙升。
-
只能保证单个变量原子性:CAS只能对单个共享变量实现原子操作,无法实现多变量联合原子操作(比如同时修改两个变量,无法保证两者同时成功或同时失败)。
-
ABA问题的解决方案(面试必背)
核心思路:给共享资源增加“版本号”(或时间戳),校验时不仅比较资源值,还比较版本号,只要版本号发生变化,就认为数据被修改过,避免ABA问题。
具体实现:
-
JDK中的AtomicStampedReference类:通过“值+版本号”的方式,实现CAS操作,解决ABA问题(比如存储数据时,同时存储值和版本号,修改时版本号自增,校验时同时比较值和版本号)。
-
数据库乐观锁:通过version字段(版本号),更新时校验version是否与当前版本一致,一致则更新并自增version,不一致则重试(后续实战会讲)。
3.3 乐观锁主流实现(3种,含实战代码)
3.3.1 JUC原子类(日常开发最常用,重点)
JDK1.5引入,位于java.util.concurrent.atomic包下,基于CAS实现,无需手动加锁,直接调用方法即可实现共享资源的原子操作,常用原子类如下:
-
AtomicInteger:int类型原子计数器,支持自增、自减、赋值等原子操作。
-
AtomicLong:long类型原子计数器,适合大数据量统计(如接口访问量)。
-
AtomicBoolean:boolean类型原子变量,适合开关控制(如并发场景下的开关状态)。
-
AtomicReference:引用类型原子变量,支持对象的原子赋值。
-
AtomicStampedReference:带版本号的原子引用,解决ABA问题。
原子类乐观锁实战代码(完整版,含ABA问题解决)
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicStampedReference;
public class CasLockDemo {
// 1. 普通原子类(AtomicInteger),适合计数器场景
private static final AtomicInteger COUNT = new AtomicInteger(0);
// 2. AtomicStampedReference(带版本号),解决ABA问题
// 参数1:初始值,参数2:初始版本号
private static final AtomicStampedReference<Integer> STAMPED_REFERENCE = new AtomicStampedReference<>(0, 1);
public static void main(String[] args) {
// 测试1:AtomicInteger原子自增(100个线程并发,验证线程安全)
for (int i = 0; i < 100; i++) {
new Thread(() -> {
// 原子自增(CAS实现,无需加锁)
int current = COUNT.incrementAndGet();
System.out.println(Thread.currentThread().getName() + ":count = " + current);
}, "计数器线程" + (i + 1)).start();
}
// 测试2:AtomicStampedReference解决ABA问题
// 线程1:模拟ABA操作(0→1→0)
new Thread(() -> {
int stamp = STAMPED_REFERENCE.getStamp(); // 获取当前版本号
System.out.println("线程1:当前值=" + STAMPED_REFERENCE.getReference() + ",版本号=" + stamp);
// 第一次修改:0→1,版本号自增为2
boolean firstChange = STAMPED_REFERENCE.compareAndSet(0, 1, stamp, stamp + 1);
System.out.println("线程1:第一次修改(0→1)" + (firstChange ? "成功" : "失败") + ",新版本号=" + STAMPED_REFERENCE.getStamp());
// 第二次修改:1→0,版本号自增为3
int newStamp = STAMPED_REFERENCE.getStamp();
boolean secondChange = STAMPED_REFERENCE.compareAndSet(1, 0, newStamp, newStamp + 1);
System.out.println("线程1:第二次修改(1→0)" + (secondChange ? "成功" : "失败") + ",新版本号=" + STAMPED_REFERENCE.getStamp());
}, "ABA线程").start();
// 线程2:尝试修改值为2,校验版本号
new Thread(() -> {
try {
Thread.sleep(100); // 等待线程1完成ABA操作
} catch (InterruptedException e) {
e.printStackTrace();
}
int stamp = STAMPED_REFERENCE.getStamp(); // 获取当前版本号(此时版本号为3)
System.out.println("线程2:当前值=" + STAMPED_REFERENCE.getReference() + ",版本号=" + stamp);
// 尝试修改:值为0,版本号为1(线程2最初读取的版本号),实际版本号为3,修改失败
boolean change = STAMPED_REFERENCE.compareAndSet(0, 2, 1, stamp + 1);
System.out.println("线程2:修改(0→2)" + (change ? "成功" : "失败") + ",最终值=" + STAMPED_REFERENCE.getReference());
}, "校验线程").start();
}
}
3.3.2 数据库版本号机制(实战常用,重点)
在数据库表中新增一个version字段(版本号,int类型,初始值为1),更新数据时,校验当前version是否与数据库中的version一致,一致则更新数据并将version自增,不一致则说明数据被其他线程修改,重试或返回失败,这是数据库层面的乐观锁实现,适合分布式场景、库存扣减等业务。
实战场景:电商库存扣减(避免超卖)
数据库表结构(简化):
CREATE TABLE product_stock (
id INT PRIMARY KEY AUTO_INCREMENT,
product_id VARCHAR(50) NOT NULL COMMENT '商品ID',
stock INT NOT NULL COMMENT '库存数量',
version INT NOT NULL DEFAULT 1 COMMENT '版本号,用于乐观锁'
) COMMENT '商品库存表';
乐观锁更新SQL(核心):
-- 更新库存,校验版本号,一致则更新,不一致则返回0(修改失败)
UPDATE product_stock
SET stock = stock - 1, version = version + 1
WHERE product_id = '1001' AND version = 1;
-- 说明:如果返回影响行数为1,说明修改成功;如果为0,说明数据被修改,需要重试
Java实战代码(简化版):
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
public class DbOptimisticLockDemo {
// 库存扣减(乐观锁实现)
public boolean deductStock(String productId) {
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = getConnection(); // 获取数据库连接(实际项目中用连接池)
while (true) {
// 1. 查询当前商品库存和版本号
String querySql = "SELECT stock, version FROM product_stock WHERE product_id = ?";
pstmt = conn.prepareStatement(querySql);
pstmt.setString(1, productId);
rs = pstmt.executeQuery();
if (!rs.next()) {
System.out.println("商品不存在");
return false;
}
int stock = rs.getInt("stock");
int version = rs.getInt("version");
// 2. 校验库存是否充足
if (stock <= 0) {
System.out.println("库存不足");
return false;
}
// 3. 乐观锁更新库存,校验版本号
String updateSql = "UPDATE product_stock SET stock = stock - 1, version = version + 1 WHERE product_id = ? AND version = ?";
pstmt = conn.prepareStatement(updateSql);
pstmt.setString(1, productId);
pstmt.setInt(2, version);
int affectedRows = pstmt.executeUpdate();
// 4. 判断修改是否成功
if (affectedRows > 0) {
System.out.println("库存扣减成功,剩余库存:" + (stock - 1));
return true;
} else {
// 版本号不匹配,数据被修改,重试
System.out.println("库存扣减失败,重试...");
Thread.sleep(50); // 重试间隔,避免频繁重试消耗CPU
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
// 关闭资源(实际项目中用try-with-resources)
close(conn, pstmt, rs);
}
return false;
}
// 模拟获取数据库连接(实际项目中用Druid等连接池)
private Connection getConnection() {
// 省略连接获取逻辑
return null;
}
// 关闭资源
private void close(Connection conn, PreparedStatement pstmt, ResultSet rs) {
// 省略关闭逻辑
}
public static void main(String[] args) {
DbOptimisticLockDemo demo = new DbOptimisticLockDemo();
demo.deductStock("1001");
}
}
3.3.3 分布式乐观锁(拓展知识点,面试加分)
分布式场景下(多服务、多节点并发),本地乐观锁(原子类)无法跨服务、跨节点生效,此时需要使用分布式乐观锁,核心实现方式:
-
Redis版本号机制:通过Redis的SETNX命令(原子指令),存储共享资源的版本号,更新时校验版本号,一致则更新并自增版本号,不一致则重试。
-
Redis CAS指令:利用Redis的GETSET命令(原子指令),实现CAS逻辑,获取当前值的同时设置新值,校验是否与预期值一致。
-
Zookeeper乐观锁:通过ZNode的版本号(dataVersion),更新ZNode数据时校验版本号,实现乐观锁。
补充:分布式乐观锁适合分布式高并发低冲突场景(如分布式计数器、分布式点赞),高冲突场景建议使用分布式悲观锁(Redis分布式锁、ZK分布式锁)。
四、可重入锁 核心详解(必备基础特性,面试基础题)
4.1 可重入锁定义(大白话+底层原理)
大白话定义:同一个线程,在已经获取某把锁的情况下,再次请求获取同一把锁时,无需阻塞等待,直接可以再次获取锁(即“锁可重入”),避免线程自己阻塞自己,引发死锁。
底层核心原理:锁内部维护一个“线程持有计数器”,当线程第一次获取锁时,计数器值设为1;线程再次获取同一把锁时,计数器值加1;线程释放锁时,计数器值减1;当计数器值为0时,锁才真正被释放,其他线程才能获取。
核心特点:避免自死锁、简化代码逻辑(无需手动释放锁后再重新获取)、底层依赖计数器实现,是Java锁的基础特性(大部分Java锁都是可重入锁)。
通俗比喻:你拿着家里的钥匙(获取锁)进入家门后,再去开卧室门(再次获取同一把锁,此处比喻同一把锁的不同使用场景),无需再找钥匙、无需等待,直接就能打开,因为你已经持有了家门的钥匙(锁)。
面试关键点:可重入锁的核心是“线程持有计数器”,解决的是“线程自死锁”问题——如果锁不可重入,同一个线程两次获取同一把锁,会导致线程阻塞自己(第一次获取锁未释放,第二次请求锁时被阻塞),最终引发死锁。
4.2 可重入锁的核心实现(2种重点,面试必背)
Java中大部分锁都是可重入锁,核心实现有两种,也是面试高频考点,需掌握其底层实现差异:
4.2.1 synchronized(隐式可重入锁,自动实现)
synchronized是隐式可重入锁,JVM自动帮我们实现了可重入逻辑,无需手动处理,底层通过“对象头Mark Word + 线程持有计数器”实现。
底层细节:当线程第一次获取synchronized锁时,JVM会将锁对象的Mark Word标记为当前线程持有,同时将计数器设为1;当该线程再次进入同步代码块(再次获取同一把锁)时,计数器加1;当线程退出同步代码块时,计数器减1;当计数器为0时,释放锁,Mark Word重置,其他线程可竞争锁。
实战代码演示(synchronized可重入性):
public class SynchronizedReentrantDemo {
// 1. 普通方法加锁(锁对象为this)
public synchronized void method1() {
System.out.println(Thread.currentThread().getName() + ":进入method1,获取锁");
// 同一线程,再次获取同一把锁(this),无需阻塞
method2();
System.out.println(Thread.currentThread().getName() + ":退出method1,释放锁");
}
// 2. 普通方法加锁(锁对象仍为this,与method1是同一把锁)
public synchronized void method2() {
System.out.println(Thread.currentThread().getName() + ":进入method2,再次获取同一把锁");
// 可继续调用其他同步方法,实现多重重入
method3();
System.out.println(Thread.currentThread().getName() + ":退出method2,释放锁");
}
public synchronized void method3() {
System.out.println(Thread.currentThread().getName() + ":进入method3,第三次获取同一把锁");
System.out.println(Thread.currentThread().getName() + ":退出method3,释放锁");
}
public static void main(String[] args) {
SynchronizedReentrantDemo demo = new SynchronizedReentrantDemo();
// 单个线程调用method1,验证可重入性
new Thread(demo::method1, "可重入测试线程").start();
}
}
运行结果(重点):线程会依次进入method1、method2、method3,无需阻塞,证明synchronized是可重入锁;退出时,依次释放method3、method2、method1的锁(计数器依次减1,直至为0)。
4.2.2 ReentrantLock(显式可重入锁,手动实现)
ReentrantLock是显式可重入锁,名字中“Reentrant”即“可重入”的意思,底层基于AQS的“线程持有计数器”实现,需手动加锁、解锁,但可重入逻辑由JDK自动维护,无需手动处理计数器。
底层细节:ReentrantLock的可重入性,底层依赖AQS的“状态变量state”实现(AQS的核心就是一个volatile修饰的int类型state变量),这个state变量本质就是“线程持有计数器”,具体逻辑如下:
1. 当线程第一次调用lock()方法获取锁时,AQS会通过CAS将state从0修改为1,同时记录当前持有锁的线程(exclusiveOwnerThread)为当前线程,此时计数器为1,线程成功获取锁。
2. 当该线程再次调用lock()方法(再次获取同一把锁)时,AQS会先判断当前线程是否为持有锁的线程(exclusiveOwnerThread == 当前线程),如果是,则直接将state的值加1(计数器自增),无需再次竞争锁,实现可重入。
3. 当线程调用unlock()方法释放锁时,会将state的值减1(计数器自减);当state的值减至0时,才会清空持有锁的线程(exclusiveOwnerThread置为null),真正释放锁,其他线程才能竞争获取。
补充:ReentrantLock的可重入性是默认开启的,无论创建的是公平锁还是非公平锁,都支持可重入,这也是其命名“ReentrantLock”的核心原因,与synchronized的可重入性本质一致,只是底层实现载体不同(synchronized依赖对象头Mark Word,ReentrantLock依赖AQS的state变量)。
实战代码演示(ReentrantLock可重入性,完整版):
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockDemo2 {
// 创建ReentrantLock(公平锁、非公平锁均支持可重入)
private final ReentrantLock lock = new ReentrantLock(true); // 公平锁示例
// 方法1:加锁
public void methodA() {
lock.lock(); // 第一次获取锁,state从0→1
try {
System.out.println(Thread.currentThread().getName() + ":进入methodA,获取锁,state=" + lock.getHoldCount());
// 同一线程,再次获取同一把锁(可重入)
methodB();
} finally {
lock.unlock(); // 释放锁,state从2→1
System.out.println(Thread.currentThread().getName() + ":退出methodA,释放锁,state=" + lock.getHoldCount());
}
}
// 方法2:再次加锁(同一把锁)
public void methodB() {
lock.lock(); // 第二次获取锁,state从1→2
try {
System.out.println(Thread.currentThread().getName() + ":进入methodB,再次获取锁,state=" + lock.getHoldCount());
// 可继续重入,调用methodC
methodC();
} finally {
lock.unlock(); // 释放锁,state从2→1
System.out.println(Thread.currentThread().getName() + ":退出methodB,释放锁,state=" + lock.getHoldCount());
}
}
// 方法3:第三次加锁(同一把锁)
public void methodC() {
lock.lock(); // 第三次获取锁,state从1→2(methodB释放后state为1,此处再次加锁变为2)
try {
System.out.println(Thread.currentThread().getName() + ":进入methodC,第三次获取锁,state=" + lock.getHoldCount());
} finally {
lock.unlock(); // 释放锁,state从2→1
System.out.println(Thread.currentThread().getName() + ":退出methodC,释放锁,state=" + lock.getHoldCount());
}
}
public static void main(String[] args) {
ReentrantLockDemo2 demo = new ReentrantLockDemo2();
// 单个线程调用methodA,验证ReentrantLock可重入性
new Thread(demo::methodA, "可重入测试线程").start();
}
}
运行结果与重点解析(面试必记):
-
运行结果会依次打印“进入methodA(state=1)→ 进入methodB(state=2)→ 进入methodC(state=2)→ 退出methodC(state=1)→ 退出methodB(state=1)→ 退出methodA(state=0)”,证明同一线程可多次获取同一把锁,实现可重入。
-
核心API:ReentrantLock提供**getHoldCount()**方法,用于获取当前线程持有该锁的次数(即计数器值),这是synchronized没有的功能,可直观查看可重入次数,面试中可作为ReentrantLock可重入性的证明。
-
注意事项:ReentrantLock是显式锁,必须保证“加锁次数 = 解锁次数”,否则会导致锁未释放(state未减至0),引发死锁。比如methodA中加锁1次、解锁1次,methodB中加锁1次、解锁1次,总共加锁2次、解锁2次,state最终为0,锁正常释放;若遗漏一次unlock(),state会大于0,锁无法释放,其他线程会一直阻塞。
面试延伸:synchronized与ReentrantLock可重入性对比(必背):
| 对比维度 | synchronized(隐式可重入) | ReentrantLock(显式可重入) |
|---|---|---|
| 底层实现 | 依赖对象头Mark Word + 线程持有计数器 | 依赖AQS的state变量(计数器)+ exclusiveOwnerThread(持有线程) |
| 可重入控制 | JVM自动控制,无需手动干预,加解锁由JVM自动完成 | JDK自动维护可重入逻辑,但需手动控制加锁(lock())和解锁(unlock()),需保证加解锁次数一致 |
| 可重入次数查看 | 无相关API,无法直接查看当前线程持有锁的次数 | 提供getHoldCount()方法,可直接获取当前线程持有锁的次数 |
| 异常影响 | 异常时JVM自动释放锁,不会导致锁泄漏 | 异常时需手动在finally中释放锁,否则会导致锁泄漏(state不为0,锁无法释放) |
补充总结:可重入性是Java锁的基础特性,synchronized和ReentrantLock作为最常用的两种锁,均支持可重入,核心目的是避免线程自死锁,简化并发代码编写。面试中,不仅要能说出可重入锁的定义,还要能区分两种锁可重入性的底层实现差异,以及实战中的注意事项(尤其是ReentrantLock的手动加解锁次数一致问题)。
面试易错点补充(避坑必记):很多面试者会混淆“可重入锁”与“公平锁/非公平锁”,需明确两者是不同维度的锁分类——可重入性描述的是“同一线程能否重复获取同一把锁”,而公平性描述的是“多个线程竞争锁时是否按排队顺序获取”,两者无直接关联。例如:ReentrantLock既可以是公平锁,也可以是非公平锁,但无论哪种,都具备可重入性;synchronized是不可设置公平性的非公平锁,但同样具备可重入性。
实战避坑技巧(开发必用):
-
使用ReentrantLock实现可重入时,建议在finally代码块中统一编写unlock()方法,并且严格对应lock()的调用次数,可通过getHoldCount()方法在关键节点打印持有锁次数,排查是否存在“加解锁次数不匹配”的问题,避免死锁。
-
synchronized的可重入性虽由JVM自动维护,但需注意锁对象的一致性——如果两个同步方法的锁对象不同(如一个锁this,一个锁类.class),则不属于“同一把锁”,无法实现可重入,会出现线程阻塞,这是开发中常见的踩坑点。
-
避免过度依赖可重入性:虽然可重入性简化了代码,但过多的重入层级(如嵌套调用多个同步方法)会增加代码可读性和维护成本,同时可能导致锁持有时间过长,降低并发性能,建议合理控制重入层级,尽量减少嵌套同步调用。
面试延伸考点(加分项):
-
不可重入锁示例:Java中默认不存在内置的不可重入锁,但可手动实现简单的不可重入锁(如自定义锁时,不维护线程持有计数器,只要锁被持有,无论是否是当前线程,均需阻塞等待),面试中若被要求手写不可重入锁,可基于此逻辑实现。
-
可重入锁的性能影响:可重入锁的计数器操作是轻量级的(synchronized依赖JVM本地操作,ReentrantLock依赖AQS的state变量CAS操作),对性能影响极小,几乎可以忽略不计,因此在实际开发中,无需担心可重入性带来的性能损耗。
-
分布式场景下的可重入锁:分布式锁(如Redis分布式锁)默认大多不支持可重入,若业务需要分布式可重入锁,需手动实现——核心是在Redis中存储“线程标识+持有次数”,获取锁时校验线程标识,一致则自增持有次数,释放时自减,直至次数为0时删除锁标识。
小结:可重入性看似是基础特性,但却是面试中“区分基础扎实度”的关键考点,不仅要掌握定义和实现,更要吃透底层差异、实战避坑点和延伸考点,才能在面试中从容应对。后续我们将讲解排他锁与共享锁,进一步完善锁机制的知识体系,为面试做好全面准备。
五、排他锁与共享锁(锁的共享方式分类,面试高频)
5.1 核心定义(大白话+本质区别,面试必背)
排他锁和共享锁,是按“锁的共享方式”划分的核心分类,核心区别在于“同一时刻,多线程能否同时持有锁”,两者适用场景截然不同,也是面试中区分锁机制理解深度的重要考点。
排他锁(Exclusive Lock):又称独占锁,指同一时刻,只有一个线程能持有锁,其他线程无论读写操作,都必须阻塞等待,直到持有锁的线程释放锁。简单说,“一人持有,众人等待”,核心是“排斥所有其他线程”。
核心特点:安全性极高,能完全避免并发冲突,但并发性能极低;适合“写操作密集”的场景(如数据新增、修改、删除),确保写操作的原子性和一致性。
共享锁(Shared Lock):又称读锁,指同一时刻,多个线程可同时持有锁,但仅支持读操作;若有线程获取排他锁(写锁),则所有共享锁(读锁)需阻塞,反之亦然。简单说,“多人大读,一人独写”,核心是“读可共享,写需独占”。
核心特点:并发性能高,读操作可并行执行,不阻塞;适合“读操作密集”的场景(如数据查询、统计),兼顾读性能和写安全性。
通俗比喻:排他锁就像家里的卫生间,同一时刻只能一个人使用(独占),其他人必须排队;共享锁就像家里的客厅,多个人可以同时在客厅活动(共享),但如果有人要占用客厅做私密操作(写锁),其他人就必须离开。
面试关键点:排他锁与共享锁的核心冲突规则——读-读不冲突、读-写冲突、写-写冲突,这是理解两者使用场景的核心,必须牢记。
5.2 排他锁的主流实现(3种,重点掌握)
前面讲解的synchronized和ReentrantLock,本质都是排他锁,除此之外,还有数据库层面的排他锁实现,具体如下:
5.2.1 synchronized(隐式排他锁)
synchronized是典型的排他锁,无论修饰方法还是代码块,同一时刻只能有一个线程获取锁,执行同步逻辑,其他线程全部阻塞,直至锁释放。无论是读操作还是写操作,只要进入同步代码,都需竞争同一把排他锁,因此读操作无法并行,适合写操作密集场景。
补充:正因为synchronized是排他锁,读操作也会阻塞其他读线程,所以在高并发读场景下,性能较差,此时需用共享锁(如读写锁)优化。
5.2.2 ReentrantLock(显式排他锁)
ReentrantLock默认是排他锁,与synchronized一致,同一时刻只能有一个线程持有锁,支持公平/非公平锁,具备手动加解锁、超时锁、中断锁等特性,比synchronized更灵活,适合复杂写操作场景(如分布式事务、精准唤醒)。
注意:ReentrantLock本身是排他锁,但可结合Condition实现更精细的排他控制,无法直接实现共享锁,共享锁需通过ReentrantReadWriteLock实现(后续讲解)。
5.2.3 数据库排他锁(实战常用)
数据库中的排他锁(行锁、表锁),是基于SQL语句实现的悲观排他锁,用于保证数据库操作的原子性,避免并发修改冲突,常见于电商下单、库存扣减等核心业务。
实战示例(MySQL行级排他锁):
-- 开启事务,给id=1的商品行加排他锁,其他线程无法修改或读取该数据(需等待锁释放)
BEGIN;
SELECT * FROM product_stock WHERE id = 1 FOR UPDATE; -- FOR UPDATE 表示加排他锁
-- 执行业务操作(如库存扣减)
UPDATE product_stock SET stock = stock - 1 WHERE id = 1;
-- 提交事务,释放锁
COMMIT;
特点:行级排他锁只锁定指定行,不影响其他行的操作,锁粒度更细,并发性能优于表级排他锁;表级排他锁(如LOCK TABLES)会锁定整个表,并发性能极差,尽量避免使用。
5.3 共享锁的主流实现(2种,重点掌握)
共享锁的核心是“读可共享、写需独占”,Java中最常用的实现是ReentrantReadWriteLock(读写锁),数据库中也有对应的共享锁实现,具体如下:
5.3.1 ReentrantReadWriteLock(JUC读写锁,重点)
JDK1.5引入,位于java.util.concurrent.locks包下,是Lock接口的实现类,核心是“一把锁分为读锁(共享锁)和写锁(排他锁)”,读锁可被多个线程同时持有,写锁只能被一个线程持有,完美解决“读多写少”场景的性能问题。
底层实现:基于AQS实现,通过AQS的state变量拆分“读锁计数器”和“写锁计数器”——state变量高16位表示读锁持有次数,低16位表示写锁持有次数,实现读锁共享、写锁独占的逻辑。
核心特性(面试必记):
-
读锁共享:多个线程可同时获取读锁,执行读操作,互不阻塞,提升读并发性能。
-
写锁独占:同一时刻只能有一个线程获取写锁,执行写操作,其他线程(无论读还是写)均需阻塞。
-
锁升级/降级:支持“读锁升级为写锁”(需先释放读锁,再获取写锁,不能直接升级),支持“写锁降级为读锁”(无需释放写锁,可直接获取读锁),这是面试高频延伸点。
-
可重入性:读锁和写锁均支持可重入,读锁可被同一线程多次获取(读锁计数器累加),写锁也可被同一线程多次获取(写锁计数器累加)。
ReentrantReadWriteLock实战代码(完整版,含读写锁使用、锁降级):
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadWriteLockDemo {
// 创建读写锁(默认非公平锁,可通过构造参数设置为公平锁)
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
// 读锁(共享锁)
private final ReentrantReadWriteLock.ReadLock readLock = rwLock.readLock();
// 写锁(排他锁)
private final ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();
// 共享资源(模拟缓存数据)
private String cacheData = "初始缓存数据";
// 读操作:获取读锁,支持多线程并行读取
public void readData() {
readLock.lock(); // 获取读锁
try {
System.out.println(Thread.currentThread().getName() + ":获取读锁,读取数据:" + cacheData);
// 模拟读操作耗时
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
readLock.unlock(); // 释放读锁
System.out.println(Thread.currentThread().getName() + ":释放读锁");
}
}
// 写操作:获取写锁,独占式写入
public void writeData(String newData) {
writeLock.lock(); // 获取写锁
try {
System.out.println(Thread.currentThread().getName() + ":获取写锁,开始更新数据");
// 模拟写操作耗时
Thread.sleep(200);
cacheData = newData;
System.out.println(Thread.currentThread().getName() + ":数据更新完成,新数据:" + cacheData);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
writeLock.unlock(); // 释放写锁
System.out.println(Thread.currentThread().getName() + ":释放写锁");
}
}
// 锁降级:写锁降级为读锁(实战常用,避免写锁释放后数据被修改)
public void writeToRead() {
writeLock.lock(); // 先获取写锁
try {
System.out.println(Thread.currentThread().getName() + ":获取写锁,更新数据");
cacheData = "锁降级测试数据";
// 写锁降级为读锁:无需释放写锁,直接获取读锁
readLock.lock();
System.out.println(Thread.currentThread().getName() + ":写锁降级为读锁,读取更新后的数据:" + cacheData);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
readLock.unlock(); // 先释放读锁
writeLock.unlock(); // 再释放写锁
}
}
public static void main(String[] args) {
ReadWriteLockDemo demo = new ReadWriteLockDemo();
// 测试读锁共享:3个线程同时读取
for (int i = 0; i < 3; i++) {
new Thread(demo::readData, "读线程" + (i + 1)).start();
}
// 测试写锁独占:1个写线程,2个读线程(写线程执行时,读线程阻塞)
new Thread(() -> demo.writeData("新的缓存数据"), "写线程1").start();
new Thread(demo::readData, "读线程4").start();
// 测试锁降级
new Thread(demo::writeToRead, "锁降级线程").start();
}
}
5.3.2 数据库共享锁(实战常用)
数据库中的共享锁(读锁),通过SQL语句实现,同一时刻多个线程可获取共享锁,执行读操作,但无法执行写操作;若有线程获取排他锁,共享锁会被阻塞,适合“读多写少”的数据库场景(如商品详情查询、数据统计)。
实战示例(MySQL行级共享锁):
-- 开启事务,给id=1的商品行加共享锁,其他线程可加共享锁(读),但无法加排他锁(写)
BEGIN;
SELECT * FROM product_stock WHERE id = 1 LOCK IN SHARE MODE; -- LOCK IN SHARE MODE 表示加共享锁
-- 执行读操作(如查询库存)
SELECT stock FROM product_stock WHERE id = 1;
-- 提交事务,释放共享锁
COMMIT;
注意:共享锁仅限制写操作,不限制读操作,多个线程可同时加共享锁读取数据;若有线程持有共享锁,其他线程的写操作会阻塞,直至所有共享锁释放。
5.4 排他锁与共享锁对比(面试直接背诵,完整版)
| 对比维度 | 排他锁(独占锁) | 共享锁(读锁) |
|---|---|---|
| 核心特性 | 同一时刻,仅一个线程持有锁,排斥所有其他线程 | 同一时刻,多个线程可持有锁,仅排斥写线程 |
| 并发冲突规则 | 读-读冲突、读-写冲突、写-写冲突 | 读-读不冲突、读-写冲突、写-写冲突 |
| 适用场景 | 写操作密集(如新增、修改、删除),数据一致性要求高 | 读操作密集(如查询、统计),兼顾读性能和写安全性 |
| 主流实现 | synchronized、ReentrantLock、数据库行级/表级排他锁 | ReentrantReadWriteLock读锁、数据库行级/表级共享锁 |
| 并发性能 | 极低,线程需排队,无并行执行可能 | 极高,读线程可并行执行,仅写操作阻塞 |
| 可重入性 | 支持(如synchronized、ReentrantLock均支持可重入) | 支持(如ReentrantReadWriteLock的读锁支持可重入) |
面试延伸考点(加分项):
-
锁饥饿问题:共享锁场景下,若读线程一直持有读锁,写线程会长期阻塞,出现“写饥饿”;解决方案:使用公平锁(ReentrantReadWriteLock可设置为公平锁),让线程按排队顺序获取锁,避免写线程长期等待。
-
锁降级的意义:写锁降级为读锁,可避免写锁释放后,其他线程修改数据,导致当前线程读取到脏数据;例如,写线程更新数据后,需立即读取更新后的数据,降级为读锁可确保数据一致性,同时不阻塞其他读线程。
-
读写锁的不足:读锁和写锁的切换会产生一定的性能开销,因此在“读极少、写极多”的场景下,性能不如纯排他锁(如ReentrantLock),需根据业务场景选择合适的锁。
小结:排他锁和共享锁是锁机制的重要分类,核心是解决“读-写并发”的性能与安全平衡问题。面试中,不仅要掌握两者的定义、特性和实现,还要能结合业务场景,分析哪种锁更合适(如读多写少用共享锁,写多读写用排他锁),同时掌握锁升级、降级、锁饥饿等延伸考点,才能从容应对面试提问。后续我们将讲解锁的其他分类及实战选型技巧,进一步完善锁机制的知识体系。
六、锁的其他核心分类(面试拓展考点)
除了前面讲解的核心分类(悲观/乐观、可重入/不可重入、排他/共享),Java中还有几种常用的锁分类,虽不是面试必考重点,但掌握后能提升答题深度,应对拓展提问,具体如下:
6.1 按“实现粒度”分:全局锁、分段锁
6.1.1 全局锁(粗粒度锁)
全局锁是粒度最粗的锁,锁定整个共享资源或整个对象/类,无论操作的是资源的哪个部分,都需要竞争同一把锁,并发性能极低,适合简单场景或资源不可拆分的场景。
实战示例:synchronized修饰静态方法(锁Class对象)、ReentrantLock全局实例锁,均属于全局锁;老旧集合Vector的add、remove方法,底层用synchronized修饰,本质也是全局锁,锁定整个集合。
6.1.2 分段锁(细粒度锁,重点)
分段锁是为了优化全局锁的性能而生,核心是“将共享资源拆分为多个片段,每个片段单独加锁”,多个线程操作不同片段时,无需竞争同一把锁,可并行执行,大幅提升并发性能,适合大体积共享资源(如大集合、缓存)。
核心实现:Java中的ConcurrentHashMap(JDK1.8之前)是分段锁的典型代表,底层将哈希表分为16个Segment(片段),每个Segment对应一把锁,线程操作不同Segment时互不阻塞,操作同一Segment时才需竞争锁;JDK1.8之后,ConcurrentHashMap改用CAS+ synchronized优化,但分段锁的思想仍被广泛应用于分布式缓存、分库分表等场景。
面试关键点:分段锁的核心优势是“细粒度控制,提升并发性能”,不足是“锁管理成本增加,片段拆分不合理会导致性能下降”,需结合资源大小合理拆分。
6.2 按“公平性”分:公平锁、非公平锁(补充完善)
前面讲解ReentrantLock时已提及公平锁与非公平锁,此处补充完整,形成体系,方便面试串联答题:
6.2.1 公平锁
定义:线程竞争锁时,必须按“排队顺序”获取锁,先请求锁的线程先获取锁,不存在抢占行为,能避免线程饥饿(所有线程都有机会获取锁)。
底层实现:基于AQS的等待队列实现,线程获取锁时,先检查等待队列是否有排队线程,若有则加入队列尾部,等待前序线程释放锁后依次唤醒。
适用场景:对线程公平性要求高、避免线程饥饿的场景(如金融交易、核心业务订单处理),但并发性能较低(需维护队列顺序,增加锁切换开销)。
6.2.2 非公平锁
定义:线程竞争锁时,不排队,直接尝试抢占锁,抢占失败后再加入等待队列,效率高,但可能导致某些线程长期抢占不到锁,出现线程饥饿。
底层实现:同样基于AQS,但线程获取锁时,会先尝试CAS抢占锁(无需排队),抢占失败后才进入等待队列,减少队列操作的开销。
适用场景:对并发性能要求高、线程饥饿影响较小的场景(如普通业务查询、非核心数据修改),是Java中锁的默认选择(synchronized、ReentrantLock默认均为非公平锁)。
公平锁与非公平锁对比(面试补充)
| 对比维度 | 公平锁 | 非公平锁 |
|---|---|---|
| 获取方式 | 按排队顺序获取,不抢占 | 优先抢占,失败后排队 |
| 线程饥饿 | 无,所有线程均有机会获取 | 可能出现,部分线程长期抢占不到 |
| 并发性能 | 较低,需维护队列顺序 | 较高,减少队列操作开销 |
| 实现复杂度 | 较高,需处理队列排序 | 较低,优先抢占,逻辑简单 |
6.3 按“锁的状态”分:无锁、偏向锁、轻量级锁、重量级锁(补充串联)
这四种锁状态,本质是synchronized的锁升级过程(前面2.3节已详细讲解),此处补充分类维度,串联知识点,方便面试时形成完整的锁分类体系:
-
无锁:无竞争场景,线程直接操作资源,依赖CAS实现,无任何锁开销。
-
偏向锁:单线程场景,标记线程ID,避免重复加解锁,性能最优。
-
轻量级锁:少量线程交替竞争,自旋等待,避免线程阻塞。
-
重量级锁:大量线程激烈竞争,线程阻塞,安全性最高,性能最低。
面试延伸:这四种锁状态是“单向不可逆升级”,核心是JVM为了平衡性能与安全,根据并发竞争激烈程度动态切换,这是synchronized优化的核心,也是大厂面试高频考点。
七、锁机制实战选型技巧(面试核心,落地必备)
掌握锁的分类和实现后,核心是能结合业务场景选择合适的锁,这是面试中“综合能力考察”的重点(避免只会背概念,不会落地),以下是实战选型的核心原则和场景对应,直接背诵即可应对面试:
7.1 核心选型原则(必记)
-
优先考虑性能与安全性的平衡:高并发低冲突→乐观锁;高并发高冲突→悲观锁。
-
锁粒度越细,并发性能越高:大资源拆分→分段锁;小资源→全局锁。
-
简单场景优先用synchronized:无需手动管理锁,降低死锁风险;复杂场景用ReentrantLock:需公平锁、超时锁、精准唤醒等特性。
-
读多写少→共享锁(ReentrantReadWriteLock);写多读写→排他锁(synchronized、ReentrantLock)。
-
分布式场景→分布式锁(Redis、ZK);本地并发→本地锁(synchronized、ReentrantLock)。
7.2 常见业务场景选型(面试直接套用)
| 业务场景 | 推荐锁类型 | 选型原因 |
|---|---|---|
| 电商库存扣减(高冲突、写密集) | 悲观锁(ReentrantLock)+ 数据库排他锁 | 数据一致性要求高,高冲突场景,避免超卖,支持超时锁防死锁 |
| 商品详情查询(读密集、低冲突) | 共享锁(ReentrantReadWriteLock) | 读操作并行执行,提升并发性能,兼顾写操作安全性 |
| 全局计数器、点赞收藏(高并发、低冲突) | 乐观锁(AtomicInteger、数据库版本号) | 无阻塞,高性能,少量冲突可通过重试解决 |
| 分布式订单处理(分布式场景) | Redis分布式锁(可重入) | 跨服务、跨节点,保证分布式场景下的数据一致性 |
| 普通业务同步(简单场景) | synchronized | 简单易用,JVM自动管理加解锁,降低开发成本和死锁风险 |
| 大集合并发操作(如大缓存) | 分段锁(ConcurrentHashMap) | 细粒度锁,多个线程操作不同片段,提升并发性能 |
7.3 实战避坑技巧(开发+面试双重点)
-
避免过度加锁:仅对核心共享资源加锁,避免给无关代码加锁(如将锁范围缩小到代码块,而非整个方法),减少锁开销。
-
ReentrantLock必写finally解锁:手动加锁后,必须在finally中调用unlock(),防止代码异常导致锁泄漏,引发死锁。
-
避免锁粒度过粗/过细:过粗(全局锁)导致并发差,过细(拆分过多片段)导致锁管理成本增加,根据资源大小合理拆分。
-
乐观锁重试次数控制:并发冲突量大时,避免无限重试,设置合理重试次数(如3次),失败后返回友好提示,减少CPU消耗。
-
分布式锁注意释放机制:Redis分布式锁需设置过期时间,避免锁未释放导致死锁;可结合Redisson框架,自动实现锁的释放和重入。
八、锁机制面试高频真题汇总(直接背诵,应对面试)
结合前面所有知识点,汇总大厂面试高频真题及标准答案,省去自行整理时间,面试直接套用:
8.1 基础必问真题
-
问:锁的核心作用是什么?底层本质是什么?
答:核心作用是控制线程访问顺序,保证共享资源的原子性、可见性和有序性,解决并发冲突;底层本质是通过阻塞或非阻塞方式,让多个线程有序访问共享资源,避免同时修改。 -
问:悲观锁和乐观锁的区别?各自适用场景是什么?
答:悲观锁预判冲突会发生,先加锁再操作,阻塞线程,安全性高、性能低,适合高冲突、写密集场景;乐观锁预判冲突不会发生,无锁操作,提交时校验版本,性能高、冲突多时耗CPU,适合低冲突、读密集场景。 -
问:synchronized和ReentrantLock的区别?实战中如何选择?
答:区别核心在:synchronized是隐式锁,JVM自动加解锁,不支持公平锁、超时锁,简单易用;ReentrantLock是显式锁,手动加解锁,支持公平/非公平锁、超时锁、精准唤醒,复杂场景更灵活。选型:简单场景用synchronized,复杂场景(需公平锁、超时锁等)用ReentrantLock。 -
问:什么是可重入锁?synchronized和ReentrantLock的可重入性底层实现有何区别?
答:可重入锁是同一线程可重复获取同一把锁,避免自死锁;synchronized依赖对象头Mark Word+线程持有计数器,JVM自动维护;ReentrantLock依赖AQS的state变量+持有线程标识,手动加解锁但可重入逻辑自动维护。 -
问:排他锁和共享锁的核心区别?冲突规则是什么?
答:核心区别是同一时刻多线程能否同时持有锁;排他锁仅一个线程持有,共享锁多个线程可同时持有(仅读)。冲突规则:读-读不冲突、读-写冲突、写-写冲突。
8.2 进阶拓展真题
-
问:synchronized的锁升级机制是什么?为什么要设计锁升级?
答:锁升级是无锁→偏向锁→轻量级锁→重量级锁,单向不可逆;设计目的是根据并发竞争激烈程度动态切换锁状态,平衡性能与安全,避免低并发场景下使用重量级锁导致的性能浪费。 -
问:CAS的核心原理是什么?有什么不足?如何解决ABA问题?
答:CAS是CPU原子指令,通过比较内存地址V的实际值与预期值A,相等则更新为B;不足是ABA问题、自旋空转、仅支持单个变量原子性;解决ABA问题可通过增加版本号(AtomicStampedReference、数据库version字段)。 -
问:ReentrantReadWriteLock的底层实现?锁升级和降级的注意事项?
答:底层基于AQS,state高16位为读锁计数器,低16位为写锁计数器;锁升级需先释放读锁再获取写锁,不能直接升级;锁降级可直接从写锁获取读锁,无需释放写锁,用于保证数据一致性。 -
问:什么是锁饥饿?如何解决?
答:锁饥饿是某些线程长期无法获取锁(如共享锁场景下读线程一直持有锁,写线程长期阻塞);解决方案是使用公平锁,让线程按排队顺序获取锁,避免抢占导致的饥饿。 -
问:分布式场景下,如何实现分布式锁?需要注意什么?
答:主流实现有Redis分布式锁(SETNX+过期时间)、ZK分布式锁(ZNode版本号);注意事项:设置过期时间防止锁泄漏,支持可重入,避免死锁,保证分布式环境下的原子性。
九、总结(面试串联必备)
Java锁机制的核心是“平衡并发性能与数据安全”,所有知识点都围绕这一核心展开:从锁的基础特性(原子性、可见性、有序性),到核心分类(悲观/乐观、可重入/不可重入、排他/共享),再到具体实现(synchronized、ReentrantLock、CAS等),最终落地到实战选型。
面试中,切忌只背概念,需做到“知识点串联+场景落地”——不仅要掌握每种锁的定义、实现,还要能结合业务场景分析选型原因,同时应对拓展考点(如锁升级、ABA问题、锁饥饿)。掌握本章节所有内容,可从容应对Java锁机制相关的所有面试提问,为并发编程面试打下坚实基础。
如果觉得本文对你有帮助,欢迎点赞+收藏+关注,后续会持续更新Java核心知识点和面试干货!
链接: https://pan.baidu.com/s/1j4JM1bLhvObe0P2OUcofeg 提取码: pgme
更多推荐


所有评论(0)