Java八股文标准答案手册
Java 八股文 · 大厂级标准答案手册
适用人群:8 年 Java 开发经验,目标阿里 / 美团 / 携程等大厂 AI 应用开发岗 编写原则:每个知识点包含「核心定义 → 深度原理 → 高频追问陷阱 → 延伸知识点」四层结构
一、面试复盘与复习策略
1.1 面试问题诊断
从这场面试的信息看,这家公司大概率是在认真招人,但技术面采用的是非常传统的 Java 筛选方式。这次主要不是输在项目,而是输在基础八股。第三题 MySQL B+Tree 不会,对于一个标注"高级 Java 开发"的岗位,确实可能成为明显扣分项。
| 问题 | 表现 | 面试官判断 | | --- | --- | --- | | 多线程几种实现方式 | 较好 | Java 基础尚可 | | synchronized 是什么 | 勉强 | 并发基础不扎实 | | MySQL B+Tree 索引 | 不知道 | 数据库基础存在明显短板 |
这三个问题是有意串起来的:Java 并发 → synchronized → MySQL 索引,都是传统高级 Java 开发的基础知识。如果只是走过场,通常没必要专门考这些基础题。
1.2 核心问题:知识结构失衡
最近的准备方向明显偏了,花了大量时间准备 RAG、Agent、LangGraph、DAG、Tool、Token 治理、Multi-Agent,但面对的却是 synchronized 是什么、B+Tree 是什么。
对于 8 年 Java 开发经历 + 想转 AI 应用开发 的人,很多公司会把你定义成"Java 高级开发转 AI",而不是"纯 AI 算法工程师",所以完全有可能先用 Java 基础把你筛掉。
1.3 复习优先级调整
| 优先级 | 模块 | 核心知识点 | | --- | --- | --- | | 第一 | Java 核心 | 多线程、synchronized、volatile、CAS、AQS、ReentrantLock、ThreadLocal、线程池、ConcurrentHashMap、HashMap、JVM、GC、类加载、内存模型 | | 第二 | MySQL | B+Tree、聚簇/非聚簇索引、回表、覆盖索引、最左匹配、索引失效、联合索引、索引下推、MVCC、Redo/Undo/Binlog、事务隔离、行锁、间隙锁、死锁 | | 第三 | Spring | IOC、Bean 生命周期、AOP、事务、三级缓存、循环依赖、Spring Boot 自动配置、Spring Cloud、Feign、Gateway、Sentinel、Nacos | | 第四 | Redis | 数据结构、缓存穿透/击穿/雪崩、分布式锁、Lua、Redisson、持久化、主从、哨兵、Cluster | | 第五 | MQ | Kafka 为什么快、分区、Consumer Group、Offset、重复消费、消息丢失、顺序消息、RocketMQ vs Kafka、RabbitMQ ACK、死信队列、重试机制 |
1.4 复习时间分配建议
Java 基础 / 并发 / JVM:25%
MySQL / Redis / MQ:20%
Spring / Spring Cloud:15%
项目深挖:20%
AI / RAG / Agent:20%
核心原则:AI 知识即使准备得很多,如果传统 Java 基础题过不了,面试官可能根本不给你展示 AI 能力的机会。
二、Java 核心 · 并发编程
2.1 多线程的几种实现方式
核心定义
Java 中创建线程主要有四种方式:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口配合 FutureTask、使用线程池。
深度答案
方式一:继承 Thread 类
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("线程执行");
}
}
// 使用:new MyThread().start();
方式二:实现 Runnable 接口
public class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("线程执行");
}
}
// 使用:new Thread(new MyRunnable()).start();
方式三:实现 Callable 接口 + FutureTask(可获取返回值)
public class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
return "执行结果";
}
}
// 使用:
FutureTask<String> task = new FutureTask<>(new MyCallable());
new Thread(task).start();
String result = task.get(); // 阻塞获取结果
方式四:线程池(生产环境推荐)
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(() -> System.out.println("线程执行"));
高频追问陷阱
追问 1:Runnable 和 Callable 有什么区别?
Runnable 的 run() 方法没有返回值,不能抛出受检异常;Callable 的 call() 方法有返回值,可以抛出异常。
Runnable 配合 Thread 或 ExecutorService 使用;Callable 必须配合 FutureTask 或线程池 submit 使用。
Callable 执行后可以通过 Future.get() 获取结果,Runnable 不行。
追问 2:start() 和 run() 有什么区别?
start() 方法会启动一个新线程,并自动调用 run() 方法,是真正的多线程。
直接调用 run() 只是在当前线程中执行一个普通方法,不会创建新线程。
start() 方法只能调用一次,第二次调用会抛出 IllegalThreadStateException。
追问 3:为什么推荐用线程池而不是 new Thread?
线程池可以复用线程,减少线程创建和销毁的开销。
可以控制并发线程数,避免资源耗尽。
提供定时执行、定期执行等高级功能。
便于管理和监控线程状态。
延伸知识点
线程的六种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED
线程池的核心参数:corePoolSize、maximumPoolSize、keepAliveTime、workQueue、threadFactory、handler
Future 和 FutureTask 的关系,CompletableFuture 的使用
2.2 synchronized 关键字
核心定义
synchronized 是 Java 提供的内置锁,用于实现线程同步,保证同一时刻只有一个线程执行被修饰的代码块,从而保证线程安全。
深度答案
三种使用方式:
修饰实例方法:锁的是当前对象实例(this)
public synchronized void method() { }
修饰静态方法:锁的是当前类的 Class 对象
public static synchronized void method() { }
修饰代码块:锁的是指定对象
synchronized (lock) { }
底层实现原理:
synchronized 基于对象头的 Mark Word 实现。每个对象都有一个对象头,Mark Word 中存储了锁信息:
无锁状态:01
偏向锁:01(带线程 ID)
轻量级锁:00
重量级锁:10
锁升级过程(JDK 1.6 之后):
偏向锁:第一个线程进入时,将 Mark Word 中的线程 ID 设置为当前线程,以后该线程进入无需 CAS。
轻量级锁:当有第二个线程竞争时,偏向锁撤销,升级为轻量级锁。线程通过 CAS 自旋尝试获取锁,不阻塞。
重量级锁:自旋超过一定次数(默认 10 次,JDK 1.6 后自适应自旋)或有线程等待,升级为重量级锁,依赖操作系统的 Mutex Lock,线程会阻塞。
Monitor 机制:
每个对象都关联一个 Monitor(管程),重量级锁通过 Monitor 实现。Monitor 包含:
Owner:当前持有锁的线程
EntryList:等待锁的线程队列(BLOCKED 状态)
WaitSet:调用 wait() 后等待的线程队列(WAITING 状态)
高频追问陷阱
追问 1:synchronized 是可重入锁吗?原理是什么?
是可重入锁。Mark Word 中记录了持有锁的线程 ID 和重入次数。
同一个线程再次获取锁时,发现线程 ID 匹配,直接将重入次数 +1。
退出时重入次数 -1,减到 0 时释放锁。
追问 2:synchronized 和 ReentrantLock 的区别?
synchronized 是 JVM 层面的关键字,ReentrantLock 是 JDK 层面的 API。
synchronized 是非公平锁,ReentrantLock 支持公平和非公平。
ReentrantLock 支持可中断获取锁(lockInterruptibly)、超时获取锁(tryLock)。
ReentrantLock 支持绑定多个 Condition,synchronized 只有一个 wait/notify 机制。
synchronized 会自动释放锁,ReentrantLock 必须手动 unlock(finally 中)。
性能上,低竞争时 synchronized 经过优化后性能不差,高竞争时 ReentrantLock 更可控。
追问 3:wait() 和 sleep() 的区别?
wait() 是 Object 的方法,sleep() 是 Thread 的方法。
wait() 会释放锁,sleep() 不会释放锁。
wait() 必须在 synchronized 块中调用,sleep() 不需要。
wait() 可以被 notify/notifyAll 唤醒,sleep() 只能等时间到或被 interrupt。
追问 4:notify() 和 notifyAll() 的区别?
notify() 随机唤醒一个等待线程,notifyAll() 唤醒所有等待线程。
notify() 可能导致信号丢失(某些线程永远不会被唤醒),notifyAll() 更安全但性能稍差。
生产环境推荐使用 notifyAll()。
延伸知识点
对象头结构:Mark Word + Klass Pointer(数组还有 Array Length)
锁消除:JIT 编译器在不可能存在共享数据竞争时自动消除锁
锁粗化:将多个连续的加锁解锁操作合并为一个大锁
偏向锁在 JDK 15 中被默认禁用并标记为废弃
2.3 volatile 关键字
核心定义
volatile 是 Java 提供的轻量级同步机制,保证可见性和有序性,但不保证原子性。
深度答案
两大特性:
可见性:一个线程修改了 volatile 变量的值,其他线程能立即看到最新值。
底层通过内存屏障实现:写操作后插入 Store-Load 屏障,强制将写缓冲区数据刷回主内存。
读操作前插入 Load-Load 屏障,强制从主内存读取最新值。
有序性(禁止指令重排):volatile 变量的读写操作不会被重排序。
写 volatile 变量之前的操作不会被重排到写之后。
读 volatile 变量之后的操作不会被重排到读之前。
内存屏障类型:
LoadLoad:禁止前面的读和后面的读重排
StoreStore:禁止前面的写和后面的写重排
LoadStore:禁止前面的读和后面的写重排
StoreLoad:禁止前面的写和后面的读重排(最耗性能)
高频追问陷阱
追问 1:volatile 为什么不能保证原子性?
比如 i++ 操作,实际包含读-改-写三个步骤。volatile 保证每次读的是最新值,但在改和写的过程中,其他线程可能已经修改了值,导致写回时覆盖。
要保证原子性需要用 synchronized 或 AtomicInteger(CAS)。
追问 2:volatile 和 synchronized 的区别?
volatile 只能修饰变量,synchronized 可以修饰方法和代码块。
volatile 保证可见性和有序性,不保证原子性;synchronized 三者都保证。
volatile 不会造成线程阻塞,synchronized 会。
volatile 是轻量级同步,synchronized 是重量级。
追问 3:双重检查锁(DCL)为什么需要 volatile?
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 这行不是原子操作
}
}
}
return instance;
}
new Singleton()分为三步:分配内存、初始化对象、引用指向内存。由于指令重排,可能变成:分配内存 → 引用指向内存 → 初始化对象。
此时另一个线程判断 instance != null,直接返回未初始化的对象,导致 NPE。
volatile 禁止指令重排,保证对象初始化完成后引用才赋值。
追问 4:volatile 的写可见性和 happens-before 关系?
对 volatile 变量的写操作 happens-before 于后续对该变量的读操作。
这意味着写 volatile 之前的所有普通变量的修改,对读 volatile 的线程都是可见的。
延伸知识点
Java 内存模型(JMM):主内存 + 工作内存的抽象
happens-before 原则的 8 条规则
内存屏障在 x86 架构下的实现:lock 前缀指令
volatile 在单例模式、状态标记、双重检查锁中的应用
2.4 CAS(Compare And Swap)
核心定义
CAS 是一种无锁原子操作,比较内存中的值是否等于预期值,如果相等则更新为新值,否则不更新。整个操作是原子的。
深度答案
CAS 操作包含三个操作数:
内存位置 V
预期原值 A
新值 B
只有当 V == A 时,才将 V 更新为 B,否则什么都不做。通常配合自旋使用:
// AtomicInteger 的 incrementAndGet 底层实现
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
// Unsafe 中的 CAS 操作
public final int getAndAddInt(Object obj, long offset, int delta) {
int v;
do {
v = getIntVolatile(obj, offset);
} while (!compareAndSwapInt(obj, offset, v, v + delta));
return v;
}
底层实现:
CAS 依赖 Unsafe 类的 native 方法。
在 x86 架构下,最终通过
cmpxchg指令实现。多核心环境下需要
lock前缀保证原子性(锁缓存行或锁总线)。
高频追问陷阱
追问 1:CAS 的三大问题是什么?
ABA 问题:值从 A 变成 B 又变回 A,CAS 会认为没变。解决方案:使用版本号(AtomicStampedReference)或时间戳。
自旋开销大:高竞争下 CAS 一直失败,CPU 空转。解决方案:自适应自旋、限制自旋次数、升级为重量级锁。
只能保证单个变量的原子操作:多个变量需要用 synchronized 或 AtomicReference 封装成对象。
追问 2:什么是 ABA 问题?怎么解决?
线程 1 读取值为 A,线程 2 将值改为 B 又改回 A,线程 1 执行 CAS 时发现还是 A,认为没有变化。
解决:AtomicStampedReference 给值加上版本号,每次修改版本号 +1,CAS 时同时比较值和版本号。
实际场景:链表头节点删除后又插回原位置,可能导致链表结构异常。
追问 3:AtomicInteger 怎么保证原子性?
内部使用 volatile 修饰 value,保证可见性。
使用 Unsafe 的 CAS 操作保证更新的原子性。
自旋 + CAS 实现无锁更新。
追问 4:synchronized 和 CAS 各自的适用场景?
低竞争、锁持有时间短:CAS 更优,无阻塞。
高竞争、锁持有时间长:synchronized 更优,CAS 自旋浪费 CPU。
单个变量原子更新:CAS / Atomic 类。
多变量或代码块同步:synchronized / ReentrantLock。
延伸知识点
Unsafe 类的功能和限制(JDK 9 后被 VarHandle 替代)
AtomicInteger、AtomicLong、AtomicReference、AtomicStampedReference
LongAdder 解决高竞争下 AtomicLong 的性能问题(分段累加)
CAS 与 CPU 缓存一致性协议(MESI)的关系
2.5 AQS(AbstractQueuedSynchronizer)
核心定义
AQS 是 Java 并发包的核心框架,是一个用于构建锁和同步器的抽象队列同步器。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock 等都基于 AQS 实现。
深度答案
核心数据结构:
state 状态变量(volatile int):表示同步状态
对于 ReentrantLock:0 表示无锁,>0 表示被锁的重入次数
对于 CountDownLatch:表示剩余计数
对于 Semaphore:表示剩余许可数
CLH 双向队列:存放等待线程
head:头节点(虚节点,不存放线程)
tail:尾节点
每个 Node 包含:thread、waitStatus、prev、next
Condition 队列(可选):每个 Condition 对应一个单向等待队列
核心方法:
tryAcquire(int)/tryRelease(int):独占式获取/释放(子类实现)tryAcquireShared(int)/tryReleaseShared(int):共享式获取/释放acquire(int):独占式获取,不响应中断acquireInterruptibly(int):独占式获取,响应中断release(int):独占式释放
获取锁流程(以独占模式为例):
调用 tryAcquire 尝试获取锁,成功则直接返回。
失败则创建 Node 节点,通过 CAS 加入 CLH 队列尾部。
判断前驱节点是否为 head,如果是则再次尝试获取锁(自旋)。
如果不是或获取失败,则将当前线程挂起(LockSupport.park)。
被唤醒后重新尝试获取锁。
释放锁流程:
调用 tryRelease 释放锁。
唤醒后继节点(LockSupport.unpark)。
高频追问陷阱
追问 1:AQS 为什么用双向队列而不是单向队列?
取消等待的节点需要从队列中移除,双向队列可以 O(1) 找到前驱节点。
节点入队时需要 CAS 设置 tail,双向队列便于维护前驱关系。
唤醒后继节点时需要检查节点状态,双向队列更灵活。
追问 2:AQS 中的 waitStatus 有哪些状态?
CANCELLED(1):线程已取消(超时或中断)
SIGNAL(-1):后继线程需要被唤醒
CONDITION(-2):线程在 Condition 队列中等待
PROPAGATE(-3):共享模式下需要向后传播唤醒
0:初始状态
追问 3:AQS 中的 head 节点为什么是虚节点?
第一个获取锁成功的线程不会入队,当有线程等待时才创建队列。
第一个等待线程入队时,会创建一个空的 head 节点,然后把自己接到后面。
这样设计是为了统一处理逻辑,避免判断队列为空的边界情况。
追问 4:公平锁和非公平锁在 AQS 层面的区别?
非公平锁:tryAcquire 时直接尝试 CAS 修改 state,不管队列中是否有等待线程。
公平锁:tryAcquire 时先检查 hasQueuedPredecessors(),如果队列中有其他线程在等,就不抢锁。
非公平锁性能更好但可能导致线程饥饿。
延伸知识点
ReentrantLock 的公平锁与非公平锁实现
ReentrantReadWriteLock:读锁共享、写锁独占,state 高 16 位存读锁数量,低 16 位存写锁重入次数
Semaphore:信号量,控制并发线程数
CountDownLatch:倒计时门闩,不能重置
CyclicBarrier:循环栅栏,可以重置使用
LockSupport.park/unpark 与 wait/notify 的区别
2.6 ReentrantLock
核心定义
ReentrantLock 是基于 AQS 实现的可重入独占锁,支持公平和非公平两种模式,提供了比 synchronized 更丰富的功能。
深度答案
基本使用:
ReentrantLock lock = new ReentrantLock(); // 默认非公平锁
ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
lock.lock();
try {
// 临界区代码
} finally {
lock.unlock(); // 必须在 finally 中释放
}
核心功能:
可重入:同一个线程可以多次获取锁,state 记录重入次数。
公平/非公平:构造函数传入 true 为公平锁,false 为非公平锁(默认)。
可中断获取锁:lockInterruptibly(),等待过程中可被中断。
超时获取锁:tryLock(timeout, unit),超时返回 false。
多 Condition 支持:可以创建多个 Condition,实现精确唤醒。
Condition 使用示例:
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();
// 生产者
lock.lock();
try {
while (queue.size() == capacity) {
notFull.await(); // 等待队列不满
}
queue.put(item);
notEmpty.signal(); // 唤醒消费者
} finally {
lock.unlock();
}
高频追问陷阱
追问 1:ReentrantLock 默认是公平锁还是非公平锁?为什么?
默认非公平锁。因为非公平锁性能更好,线程可以直接尝试获取锁,不需要排队。
非公平锁的问题是可能导致线程饥饿,某些线程一直抢不到锁。
公平锁保证 FIFO,但吞吐量较低。
追问 2:ReentrantLock 的 tryLock 和 lock 有什么区别?
lock():阻塞等待,直到获取锁。
tryLock():立即返回,获取成功返回 true,失败返回 false。
tryLock(timeout, unit):等待指定时间,超时返回 false,可响应中断。
生产环境推荐 tryLock 避免死锁。
追问 3:ReentrantLock 怎么实现可重入?
AQS 的 state 变量记录重入次数。
同一个线程再次获取锁时,判断当前线程 == 持有线程,state + 1。
释放锁时 state - 1,减到 0 时真正释放锁。
追问 4:Condition 的 await/signal 和 wait/notify 有什么区别?
Condition 可以有多个,实现分组唤醒;wait/notify 只有一个等待队列。
Condition.await() 可以指定时间、可以响应中断;wait() 也可以但功能较少。
Condition 必须配合 ReentrantLock 使用,wait/notify 必须配合 synchronized。
signalAll() 对应 notifyAll(),signal() 对应 notify()。
延伸知识点
ReentrantLock 与 synchronized 的性能对比(JDK 1.6 后 synchronized 优化后差距不大)
公平锁的实现:hasQueuedPredecessors() 判断队列中是否有前驱
死锁的四个必要条件:互斥、占有且等待、不可抢占、循环等待
如何避免死锁:固定加锁顺序、tryLock 超时、减少锁粒度
2.7 ThreadLocal
核心定义
ThreadLocal 是线程本地变量,每个线程都有自己的独立副本,线程之间互不影响,用于实现线程隔离。
深度答案
基本使用:
ThreadLocal<String> threadLocal = new ThreadLocal<>();
threadLocal.set("value"); // 当前线程设置值
String value = threadLocal.get(); // 当前线程获取值
threadLocal.remove(); // 移除当前线程的值
底层原理:
每个 Thread 对象内部都有一个 threadLocals 变量,类型是 ThreadLocalMap:
// Thread 类中
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocalMap 是一个自定义的哈希表,key 是 ThreadLocal 对象(弱引用),value 是线程私有值。
set() 流程:
获取当前线程的 ThreadLocalMap。
以 ThreadLocal 对象为 key,查找是否已存在。
存在则覆盖 value,不存在则创建 Entry。
get() 流程:
获取当前线程的 ThreadLocalMap。
以 ThreadLocal 对象为 key 查找 Entry。
返回 Entry 的 value,找不到则返回初始值(initialValue)。
高频追问陷阱
追问 1:ThreadLocal 为什么会内存泄漏?
ThreadLocalMap 的 Entry 继承 WeakReference,key 是弱引用。
当 ThreadLocal 没有强引用时,GC 会回收 key(变成 null),但 value 是强引用不会被回收。
如果线程一直存活(如线程池中的线程),value 永远无法被回收,导致内存泄漏。
解决:使用完后调用 remove() 方法手动清理。
追问 2:ThreadLocalMap 的 key 为什么用弱引用?
如果用强引用,ThreadLocal 对象即使外部没有引用也不会被回收,导致 ThreadLocal 本身泄漏。
用弱引用后,ThreadLocal 可以被正常回收,key 变成 null。
但 value 还是强引用,所以需要手动 remove() 或在 set/get/remove 时清理脏 Entry。
追问 3:ThreadLocal 适用于什么场景?
数据库连接管理:每个线程一个 Connection,避免多线程共享连接。
用户上下文传递:如登录用户信息、请求 Trace ID。
SimpleDateFormat 线程安全问题:每个线程一个实例。
Spring 的事务管理:TransactionSynchronizationManager 使用 ThreadLocal。
MDC 日志追踪:每个请求的 traceId 存在 ThreadLocal 中。
追问 4:父子线程怎么共享 ThreadLocal?
普通 ThreadLocal 不能在父子线程间传递。
使用 InheritableThreadLocal:子线程创建时会从父线程复制一份。
线程池场景下 InheritableThreadLocal 也有问题,因为线程是复用的,需要用阿里的 TransmittableThreadLocal(TTL)。
延伸知识点
ThreadLocalMap 的哈希冲突解决:线性探测法(不是链表法)
ThreadLocalMap 的扩容机制:容量 2 的幂,负载因子 2/3
InheritableThreadLocal 的实现原理:Thread 类中的 inheritableThreadLocals
TransmittableThreadLocal(TTL):解决线程池场景下的 ThreadLocal 传递
ThreadLocal 与 synchronized 的区别:ThreadLocal 是空间换时间,synchronized 是时间换空间
2.8 线程池
核心定义
线程池是一种池化技术,预先创建一定数量的线程,复用这些线程执行任务,避免频繁创建和销毁线程的开销。
深度答案
ThreadPoolExecutor 七大核心参数:
public ThreadPoolExecutor(
int corePoolSize, // 核心线程数
int maximumPoolSize, // 最大线程数
long keepAliveTime, // 非核心线程空闲存活时间
TimeUnit unit, // 时间单位
BlockingQueue<Runnable> workQueue, // 任务队列
ThreadFactory threadFactory, // 线程工厂
RejectedExecutionHandler handler // 拒绝策略
)
任务提交流程:
当前线程数 < corePoolSize:创建核心线程执行任务。
当前线程数 >= corePoolSize:任务加入 workQueue。
workQueue 满了:创建非核心线程执行任务(直到 maximumPoolSize)。
线程数达到 maximumPoolSize 且队列满:执行拒绝策略。
四种拒绝策略:
AbortPolicy(默认):抛出 RejectedExecutionException
CallerRunsPolicy:由提交任务的线程自己执行
DiscardPolicy:直接丢弃任务
DiscardOldestPolicy:丢弃队列中最老的任务,重新提交当前任务
常见的工作队列:
ArrayBlockingQueue:有界队列,数组实现
LinkedBlockingQueue:无界队列(默认 Integer.MAX_VALUE),链表实现
SynchronousQueue:不存储任务,直接交给线程
DelayedWorkQueue:延迟队列,用于 ScheduledThreadPoolExecutor
高频追问陷阱
追问 1:为什么不推荐用 Executors 创建线程池?
FixedThreadPool 和 SingleThreadPool:队列是 LinkedBlockingQueue,容量 Integer.MAX_VALUE,可能堆积大量任务导致 OOM。
CachedThreadPool:maximumPoolSize 是 Integer.MAX_VALUE,可能创建大量线程导致 OOM。
ScheduledThreadPool:maximumPoolSize 也是 Integer.MAX_VALUE。
推荐直接用 ThreadPoolExecutor 构造,明确每个参数。
追问 2:核心线程会被回收吗?
默认不会,即使空闲也会一直存活。
设置 allowCoreThreadTimeOut(true) 后,核心线程空闲超过 keepAliveTime 也会被回收。
追问 3:线程池的核心线程数怎么设置?
CPU 密集型任务:corePoolSize = CPU 核心数 + 1
IO 密集型任务:corePoolSize = CPU 核心数 * 2 或 CPU 核心数 / (1 - 阻塞系数)
实际需要根据压测调整,没有万能公式。
可以参考:核心数 = CPU 核数 * 期望 CPU 利用率 * (1 + 等待时间/计算时间)
追问 4:线程池怎么优雅关闭?
shutdown():不再接受新任务,等待已提交任务执行完。
shutdownNow():不再接受新任务,尝试中断正在执行的任务,返回队列中等待的任务。
生产环境推荐 shutdown() + awaitTermination(timeout) 组合。
追问 5:submit 和 execute 的区别?
execute():提交 Runnable,无返回值。
submit():提交 Runnable 或 Callable,返回 Future 对象,可以获取执行结果和异常。
submit 内部也是调用 execute,只是包装成 FutureTask。
延伸知识点
线程池的五种状态:RUNNING、SHUTDOWN、STOP、TIDYING、TERMINATED
线程池监控:getActiveCount、getCompletedTaskCount、getQueue().size()
动态调整线程池参数:setCorePoolSize、setMaximumPoolSize
ForkJoinPool:工作窃取算法,适合分治任务
CompletableFuture:异步编程,配合线程池使用
定时任务线程池:ScheduledThreadPoolExecutor
2.9 HashMap
核心定义
HashMap 是基于哈希表实现的键值对存储结构,允许 null 键和 null 值,非线程安全,平均查找时间复杂度 O(1)。
深度答案
底层数据结构(JDK 1.8):
数组 + 链表 + 红黑树
数组是主体,称为哈希桶(bucket)
哈希冲突时用链表存储(拉链法)
链表长度超过 8 且数组长度 >= 64 时,链表转为红黑树
红黑树节点数少于 6 时退化为链表
核心参数:
initialCapacity:初始容量,默认 16,必须是 2 的幂
loadFactor:负载因子,默认 0.75
threshold:扩容阈值 = capacity * loadFactor
size:实际存储的键值对数量
put 流程:
计算 key 的 hash 值:
(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16)计算数组下标:
(n - 1) & hash如果数组位置为空,直接创建节点放入。
如果不为空,判断 key 是否相同(hash 相同且 equals 为 true),相同则覆盖 value。
如果是红黑树节点,调用红黑树插入方法。
如果是链表,遍历链表,找到相同 key 则覆盖,否则尾插法追加。
链表长度超过 8 且数组长度 >= 64,转红黑树。
put 完成后 size++,如果 size > threshold,触发扩容。
扩容机制(resize):
新数组容量 = 旧容量 * 2。
重新计算每个元素的位置:由于容量是 2 的幂,扩容后元素要么在原位置,要么在原位置 + 旧容量。
遍历旧数组,将元素迁移到新数组。
链表迁移时用高低位链表,保持元素顺序(JDK 1.8 修复了 1.7 的头插法死循环问题)。
高频追问陷阱
追问 1:HashMap 为什么容量必须是 2 的幂?
计算下标用
(n - 1) & hash代替取模运算,位运算效率更高。只有 n 是 2 的幂时,n-1 的二进制才全是 1,与运算结果才均匀分布。
扩容时元素位置只有两种可能,便于高效迁移。
追问 2:HashMap 的 hash 函数为什么要高低位异或?
hash = (h = key.hashCode()) ^ (h >>> 16)因为数组长度较小时,hash 的高位参与不到与运算中,容易导致哈希冲突。
右移 16 位后异或,让高位也参与到下标计算中,使分布更均匀。
追问 3:JDK 1.7 和 1.8 的 HashMap 有什么区别?
1.7:数组 + 链表,头插法,扩容时可能死循环。
1.8:数组 + 链表 + 红黑树,尾插法,扩容时不会死循环。
1.8 扩容时不需要重新计算 hash,通过 hash & oldCap 判断位置。
1.8 的 hash 函数更简单。
追问 4:HashMap 是线程安全的吗?多线程下会有什么问题?
不是线程安全的。
JDK 1.7 多线程扩容可能导致死循环(链表成环)。
JDK 1.8 不会死循环,但可能数据丢失。
多线程环境用 ConcurrentHashMap。
追问 5:链表转红黑树的条件为什么是 8?
泊松分布:在负载因子 0.75 下,链表长度达到 8 的概率约为 0.00000006,几乎不可能。
红黑树的插入删除比链表慢,只有链表足够长时转红黑树才有收益。
退化为链表的阈值是 6,留一个缓冲区间避免频繁转换。
延伸知识点
HashMap 的遍历方式:keySet、entrySet、values、Iterator
modCount 与 fail-fast 机制:遍历时结构被修改抛出 ConcurrentModificationException
LinkedHashMap:保持插入顺序或访问顺序,基于 HashMap + 双向链表
TreeMap:基于红黑树,按 key 排序
HashTable:线程安全,方法加 synchronized,性能差,已废弃
2.10 ConcurrentHashMap
核心定义
ConcurrentHashMap 是线程安全的 HashMap,通过分段锁(JDK 1.7)或 CAS + synchronized(JDK 1.8)实现高并发。
深度答案
JDK 1.7 实现:分段锁(Segment)
内部由 Segment 数组组成,每个 Segment 是一个小的 HashMap。
默认 16 个 Segment,最多支持 16 个线程并发写。
每个 Segment 独立加锁(ReentrantLock),不同 Segment 之间互不影响。
缺点:并发度固定为 Segment 数量,不够灵活。
JDK 1.8 实现:CAS + synchronized
取消 Segment,改用 Node 数组 + 链表 + 红黑树。
数组位置为空时,用 CAS 插入节点。
数组位置不为空时,用 synchronized 锁当前桶的头节点。
锁粒度更细,支持更高并发。
扩容时多线程协助迁移。
put 流程(JDK 1.8):
计算 key 的 hash 值。
如果数组未初始化,调用 initTable 初始化(CAS 控制只有一个线程初始化)。
定位到桶,如果桶为空,CAS 插入节点。
如果桶的 hash 为 MOVED(-1),说明正在扩容,当前线程协助扩容。
否则 synchronized 锁桶头节点,遍历链表或红黑树插入。
插入完成后,如果链表长度 >= 8,尝试转红黑树。
addCount 统计元素数量,检查是否需要扩容。
size 计算:
使用 baseCount + CounterCell[] 数组,避免多线程竞争一个计数器。
CounterCell 使用 @sun.misc.Contended 注解避免伪共享。
size() 不是精确值,因为统计过程中可能有并发修改。
高频追问陷阱
追问 1:ConcurrentHashMap 为什么不允许 null 键和 null 值?
HashMap 允许 null,但 ConcurrentHashMap 不允许。
原因:在并发环境下,get(key) 返回 null 无法判断是 key 不存在还是 value 就是 null。
HashMap 是单线程使用,可以通过 containsKey 判断,但并发下 get 和 containsKey 之间可能被修改。
追问 2:ConcurrentHashMap 的 size 是精确的吗?
不是精确值,是一个估计值。
因为统计 size 的过程中,可能有其他线程在增删元素。
如果需要精确计数,需要额外加锁。
追问 3:ConcurrentHashMap 扩容时多线程怎么协助?
扩容时会创建一个 ForwardingNode(hash = MOVED)放在旧数组的桶上。
其他线程 put 时发现桶是 ForwardingNode,就会调用 helpTransfer 协助迁移。
每个线程领取一段桶区间进行迁移,通过 CAS 分配任务。
迁移完成后,旧数组被新数组替换。
追问 4:ConcurrentHashMap 和 Hashtable 的区别?
Hashtable 方法全部加 synchronized,锁粒度是整个表,性能差。
ConcurrentHashMap 锁粒度是单个桶,并发性能好。
Hashtable 不允许 null,ConcurrentHashMap 也不允许。
Hashtable 是遗留类,不推荐使用。
追问 5:JDK 1.8 的 ConcurrentHashMap 为什么用 synchronized 而不是 ReentrantLock?
synchronized 经过优化后性能不差,且 JVM 可以做更深入的优化。
synchronized 是 JVM 内置的,内存占用更小(不需要 AQS 队列等额外结构)。
锁的是桶头节点,粒度很细,synchronized 的偏向锁、轻量级锁优化很适用。
延伸知识点
CounterCell 的伪共享问题与 @Contended 注解
ConcurrentHashMap 的迭代器是弱一致性的(不会 fail-fast)
ConcurrentSkipListMap:跳表实现,支持排序,并发安全
并发容器总结:ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue
LongAdder 的分段累加思想与 ConcurrentHashMap size 计算类似
三、JVM 与 GC
3.1 JVM 内存模型
核心定义
JVM 运行时数据区分为:程序计数器、虚拟机栈、本地方法栈、堆、方法区(元空间)。其中线程私有的是程序计数器、虚拟机栈、本地方法栈;线程共享的是堆和方法区。
深度答案
1. 程序计数器(PC Register)
当前线程执行的字节码行号指示器。
线程私有,每个线程独立的 PC。
唯一不会 OutOfMemoryError 的区域。
执行 Java 方法时记录字节码地址,执行 Native 方法时为空。
2. 虚拟机栈(VM Stack)
线程私有,生命周期与线程相同。
每个方法执行时创建一个栈帧(Stack Frame)。
栈帧包含:局部变量表、操作数栈、动态链接、方法返回地址。
栈深度超过限制抛出 StackOverflowError。
栈扩展时无法申请足够内存抛出 OutOfMemoryError。
3. 本地方法栈(Native Method Stack)
为 Native 方法服务,其他与虚拟机栈类似。
HotSpot 中虚拟机栈和本地方法栈合二为一。
4. 堆(Heap)
线程共享,JVM 启动时创建,存放对象实例。
是 GC 管理的主要区域,也叫 GC 堆。
分代划分:新生代(Eden + Survivor0 + Survivor1)、老年代。
可以是物理上不连续的空间,逻辑上连续。
通过 -Xms 和 -Xmx 控制大小。
5. 方法区(Method Area)
线程共享,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码。
JDK 1.7 及之前叫永久代(PermGen),JDK 1.8 之后叫元空间(Metaspace)。
元空间使用本地内存,不受 -Xmx 限制,通过 -XX:MaxMetaspaceSize 控制。
运行时常量池是方法区的一部分,存放编译期生成的字面量和符号引用。
高频追问陷阱
追问 1:JDK 1.8 为什么用元空间替代永久代?
永久代大小固定,容易 OOM(类加载过多时)。
永久代的 GC 复杂,需要特殊处理。
元空间使用本地内存,只受系统内存限制。
字符串常量池在 JDK 1.7 已经移到堆中,1.8 移除永久代更彻底。
便于与其他 JVM(如 JRockit)统一。
追问 2:对象创建的过程是什么?
类加载检查:检查 new 指令的参数能否在常量池中定位到类符号引用,检查类是否已加载。
分配内存:指针碰撞(内存规整)或空闲列表(内存不规整)。
初始化零值:将分配的内存空间初始化为零值(保证对象字段不赋初值也能使用)。
设置对象头:Mark Word、Klass Pointer 等。
执行 方法:调用构造函数。
追问 3:对象的内存布局是什么?
对象头(Header):Mark Word(哈希码、GC 分代年龄、锁状态等)+ 类型指针(指向类元数据)+ 数组长度(数组对象才有)。
实例数据(Instance Data):对象的字段值。
对齐填充(Padding):保证对象大小是 8 字节的整数倍。
追问 4:对象怎么访问定位?
句柄访问:堆中划分句柄池,reference 指向句柄,句柄包含对象实例数据和类型数据指针。优点:对象移动时只需改句柄。
直接指针访问(HotSpot):reference 直接指向对象,对象中包含类型指针。优点:速度快,少一次指针定位。
延伸知识点
直接内存(Direct Memory):NIO 使用,不受堆大小限制,但受系统内存限制
逃逸分析:对象是否在方法外被引用,决定是否可以栈上分配
标量替换:将对象拆解为基本类型直接在栈上分配
TLAB(Thread Local Allocation Buffer):线程私有分配缓冲区,避免多线程分配内存竞争
3.2 垃圾回收(GC)
核心定义
垃圾回收是 JVM 自动回收不再使用的对象内存的机制,主要针对堆内存。需要解决三个问题:哪些对象是垃圾、怎么回收、什么时候回收。
深度答案
如何判断对象是垃圾:
引用计数法:对象被引用时计数 +1,引用失效时 -1,计数为 0 时回收。缺点:无法解决循环引用。
可达性分析算法(HotSpot 使用):
从 GC Roots 开始遍历,能到达的对象是存活的,不可达的是垃圾。
GC Roots 包括:虚拟机栈中引用的对象、方法区静态变量引用的对象、方法区常量引用的对象、本地方法栈 JNI 引用的对象。
四种引用类型:
强引用(Strong):new 出来的,OOM 也不回收。
软引用(SoftReference):内存不足时回收,适合缓存。
弱引用(WeakReference):下次 GC 时一定回收,ThreadLocal 的 key 就是弱引用。
虚引用(PhantomReference):随时可能被回收,用于管理堆外内存,必须配合 ReferenceQueue。
垃圾回收算法:
标记-清除(Mark-Sweep):标记存活对象,清除垃圾。缺点:产生内存碎片。
复制(Copying):将内存分为两块,每次只用一块,回收时将存活对象复制到另一块。优点:无碎片,缺点:内存利用率低。用于新生代。
标记-整理(Mark-Compact):标记存活对象,将存活对象向一端移动,清除边界外的内存。优点:无碎片,缺点:移动对象开销大。用于老年代。
分代收集:新生代用复制算法,老年代用标记-清除或标记-整理。
Minor GC / Major GC / Full GC:
Minor GC:新生代 GC,频率高,速度快。
Major GC:老年代 GC,通常伴随一次 Minor GC。
Full GC:整个堆 + 方法区的 GC,速度慢,应尽量避免。
高频追问陷阱
追问 1:什么情况下对象会进入老年代?
年龄达到阈值(默认 15,通过 -XX:MaxTenuringThreshold 设置)。
Survivor 空间中相同年龄所有对象大小总和 > Survivor 空间一半,年龄 >= 该年龄的对象直接进入老年代。
大对象直接进入老年代(-XX:PretenureSizeThreshold 设置)。
Minor GC 后 Survivor 放不下,通过分配担保进入老年代。
追问 2:什么是空间分配担保?
Minor GC 前,JVM 检查老年代最大可用连续空间是否大于新生代所有对象总空间。
如果大于,Minor GC 安全。
如果小于,检查 HandlePromotionFailure 是否允许担保失败。
允许则继续检查老年代最大可用空间是否大于历次晋升到老年代对象的平均大小。
大于则尝试 Minor GC,小于则改为 Full GC。
追问 3:System.gc() 会立刻触发 GC 吗?
不会,只是建议 JVM 进行 GC,JVM 可以选择忽略。
默认情况下 System.gc() 会触发 Full GC,可以通过 -XX:+DisableExplicitGC 禁用。
追问 4:怎么判断一个类可以被回收?
该类的所有实例都已被回收。
加载该类的 ClassLoader 已被回收。
该类的 java.lang.Class 对象没有任何地方被引用。
满足以上三个条件才可以被回收(方法区的 GC)。
延伸知识点
GC Roots 枚举时需要 Stop The World(STW)
安全点(Safepoint)和安全区域(Safe Region)
记忆集(Remembered Set)和卡表(Card Table):解决跨代引用
写屏障(Write Barrier):维护卡表
GC 日志分析工具:GCViewer、GCEasy
常用 GC 参数:-XX:+PrintGCDetails、-XX:+PrintGCDateStamps、-Xloggc
3.3 垃圾收集器
核心定义
垃圾收集器是 GC 算法的具体实现。主流收集器包括:Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1、ZGC。
深度答案
新生代收集器:
Serial:单线程收集,STW,复制算法。简单高效,适合客户端模式。
ParNew:Serial 的多线程版本,STW,复制算法。常用于配合 CMS。
Parallel Scavenge:多线程,复制算法,吞吐量优先。可设置最大停顿时间和吞吐量目标。
老年代收集器:
Serial Old:单线程,标记-整理算法。
Parallel Old:多线程,标记-整理算法,配合 Parallel Scavenge。
CMS(Concurrent Mark Sweep):并发标记清除,低延迟。
CMS 收集过程:
初始标记(STW):标记 GC Roots 直接关联的对象,速度快。
并发标记:从 GC Roots 遍历,与用户线程并发。
重新标记(STW):修正并发标记期间变动的对象,用增量更新。
并发清除:与用户线程并发清除垃圾。
CMS 缺点:
对 CPU 资源敏感(并发阶段占用 CPU)。
无法处理浮动垃圾(并发清除阶段产生的垃圾)。
标记-清除产生内存碎片,可能提前触发 Full GC。
JDK 14 中被移除。
G1(Garbage First)收集器:
将堆分为多个大小相等的 Region(1-32MB)。
每个 Region 可以是 Eden、Survivor、Old、Humongous(大对象)。
可预测的停顿时间模型(-XX:MaxGCPauseMillis,默认 200ms)。
整体是标记-整理,局部是复制算法,无内存碎片。
G1 收集过程:
初始标记(STW)
并发标记
最终标记(STW)
筛选回收(STW):根据停顿时间选择回收价值最高的 Region
ZGC(JDK 11 引入,JDK 15 正式):
基于着色指针和读屏障,几乎全并发。
停顿时间不超过 10ms,与堆大小无关。
支持 TB 级堆。
高频追问陷阱
追问 1:CMS 和 G1 的区别?
CMS 是传统分代收集,G1 是 Region 化分代。
CMS 用标记-清除,有内存碎片;G1 用标记-整理,无碎片。
G1 可预测停顿时间,CMS 不能精确控制。
G1 适合大堆(>4GB),CMS 适合中小堆。
G1 可以并发整理,CMS 只能在 Full GC 时用 Serial Old 整理。
追问 2:G1 为什么可以预测停顿时间?
G1 跟踪每个 Region 的回收价值(存活对象多少、回收时间)。
回收时根据设置的最大停顿时间,选择回收价值最高的 Region 集合(CSet)。
是概率模型,基于历史数据预测,不是精确保证。
追问 3:什么是记忆集?G1 怎么解决跨 Region 引用?
每个 Region 都有一个 Remembered Set(RSet),记录其他 Region 对本 Region 的引用。
写屏障在引用变更时更新 RSet。
GC 时扫描 RSet 即可知道哪些外部对象引用了当前 Region,不需要全堆扫描。
G1 的 RSet 比传统卡表更精确,是 points-into 结构。
追问 4:Parallel Scavenge 和 ParNew 的区别?
Parallel Scavenge 是吞吐量优先,ParNew 是为了配合 CMS。
Parallel Scavenge 不能与 CMS 配合使用(JDK 1.8 中)。
Parallel Scavenge 有自适应调节策略(-XX:+UseAdaptiveSizePolicy)。
ParNew 可以用 -XX:ParallelGCThreads 控制线程数。
延伸知识点
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + GC 时间)
低延迟 vs 高吞吐量的权衡
JDK 8 默认收集器:Parallel Scavenge + Parallel Old
JDK 9 默认收集器:G1
JDK 11 引入 ZGC 和 Shenandoah
三色标记法:白色(未访问)、灰色(正在访问)、黑色(已访问)
增量更新 vs 原始快照(SATB):CMS 用增量更新,G1 用 SATB
四、MySQL 核心
4.1 B+Tree 索引
核心定义
B+Tree 是 MySQL InnoDB 存储引擎的核心索引结构,是一种多路平衡查找树,所有数据都在叶子节点,非叶子节点只存索引键和指针,叶子节点通过双向链表连接。
深度答案
B+Tree 结构特点:
非叶子节点只存储索引键和子节点指针,不存储数据。
所有数据都存储在叶子节点。
叶子节点之间通过双向链表连接,支持范围查询。
每个节点对应一个磁盘页(默认 16KB),减少磁盘 IO。
为什么用 B+Tree 而不是 B Tree?
磁盘 IO 更少:B+Tree 非叶子节点不存数据,同样大小的页可以存更多索引键,树更矮(通常 3-4 层),查询 IO 次数更少。
范围查询高效:叶子节点链表连接,范围查询只需找到起点后沿链表遍历,不需要中序遍历。
查询性能稳定:所有查询都走到叶子节点,IO 次数相同,性能稳定。B Tree 可能在非叶子节点找到数据,性能不稳定。
适合磁盘存储:B+Tree 的设计充分利用磁盘预读特性,每次 IO 读取一个页。
为什么不用红黑树?
红黑树是二叉树,树高较高(百万数据约 20 层),磁盘 IO 次数多。
B+Tree 是多路树,扇出大(约 1170 个子节点),树高低。
计算:16KB 页,假设索引键 8B,指针 6B,每个节点约存 1170 个键,3 层可存约 2000 万行数据。
高频追问陷阱
追问 1:聚簇索引和非聚簇索引的区别?
聚簇索引:叶子节点存储完整行数据,一张表只能有一个。InnoDB 主键索引就是聚簇索引。
非聚簇索引(二级索引):叶子节点存储主键值,需要回表查询完整数据。
聚簇索引查询更快(不需要回表),但插入速度受主键顺序影响。
没有主键时 InnoDB 会选择唯一非空索引,都没有则生成隐藏的 row_id。
追问 2:什么是回表?怎么避免回表?
回表:通过二级索引找到主键后,再用主键到聚簇索引查询完整数据。
避免回表:使用覆盖索引,查询的列都在索引中,不需要回表。
例如:SELECT name FROM user WHERE name = '张三',如果 name 有索引且只查 name,就是覆盖索引。
追问 3:联合索引的最左匹配原则是什么?
联合索引 (a, b, c) 相当于建立了 (a)、(a, b)、(a, b, c) 三个索引。
查询条件必须从最左列开始,不能跳过。
WHERE a = 1 AND b = 2 AND c = 3:用到全部三列。
WHERE b = 2 AND c = 3:用不到索引(没有 a)。
WHERE a = 1 AND c = 3:只用到 a 列。
范围查询后的列用不到索引:WHERE a = 1 AND b > 2 AND c = 3,c 用不到。
追问 4:索引失效的场景有哪些?
对索引列使用函数、运算:WHERE YEAR(create_time) = 2024
隐式类型转换:WHERE id = '123'(id 是 int)
模糊查询以 % 开头:WHERE name LIKE '%张'
OR 连接非索引列:WHERE a = 1 OR b = 2(b 无索引)
违反最左匹配原则
使用 !=、NOT IN、<> 可能失效(优化器判断)
IS NULL / IS NOT NULL 可能失效(数据分布影响)
延伸知识点
索引下推(ICP):在存储引擎层根据索引条件过滤,减少回表次数(MySQL 5.6 引入)
自适应哈希索引(AHI):InnoDB 自动为热点页建立哈希索引
索引选择性:不重复值数量 / 总行数,选择性越高索引越好
前缀索引:对字符串前 N 个字符建索引,节省空间
全文索引:基于倒排索引,用于文本搜索
空间索引:R-Tree,用于地理数据
4.2 MVCC(多版本并发控制)
核心定义
MVCC 是通过保存数据的多个版本,实现读写不阻塞的并发控制机制。InnoDB 在 READ COMMITTED 和 REPEATABLE READ 隔离级别下使用 MVCC。
深度答案
实现原理:
MVCC 依赖三个核心要素:
隐藏字段:每行数据包含
DB_TRX_ID:最近修改该行的事务 ID
DB_ROLL_PTR:回滚指针,指向 undo log 中的旧版本
DB_ROW_ID:隐藏主键(没有主键时)
undo log(版本链):每次修改数据时,旧版本被写入 undo log,通过 DB_ROLL_PTR 形成版本链。
ReadView(一致性视图):事务执行快照读时生成,包含
m_ids:当前活跃的事务 ID 列表
min_trx_id:最小活跃事务 ID
max_trx_id:下一个要分配的事务 ID
creator_trx_id:创建 ReadView 的事务 ID
可见性判断规则:
数据版本的 trx_id < min_trx_id:可见(事务已提交)
数据版本的 trx_id >= max_trx_id:不可见(事务在 ReadView 之后启动)
min_trx_id <= trx_id < max_trx_id:如果 trx_id 在 m_ids 中则不可见,否则可见
trx_id == creator_trx_id:可见(自己修改的)
RC 和 RR 的区别:
READ COMMITTED:每次 SELECT 都生成新的 ReadView,可以读到其他事务已提交的修改。
REPEATABLE READ:第一次 SELECT 时生成 ReadView,后续 SELECT 复用同一个,保证可重复读。
高频追问陷阱
追问 1:快照读和当前读的区别?
快照读:普通 SELECT,基于 MVCC,读的是历史版本,不加锁。
当前读:SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、INSERT、UPDATE、DELETE,读最新版本,加锁。
当前读需要加行锁(可能还有间隙锁)。
追问 2:MVCC 能解决幻读吗?
在 RR 级别下,MVCC 的快照读可以避免幻读(因为 ReadView 固定)。
但当前读仍然可能出现幻读,需要间隙锁(Next-Key Lock)解决。
InnoDB 的 RR 级别通过 MVCC + 间隙锁解决了幻读问题。
追问 3:undo log 和 redo log 的区别?
undo log:逻辑日志,记录数据修改前的状态,用于回滚和 MVCC。
redo log:物理日志,记录页的修改,用于崩溃恢复(WAL)。
undo log 是回滚用的,redo log 是前滚用的。
undo log 在 undo 段中,redo log 在 redo log buffer 和 redo log 文件中。
追问 4:ReadView 什么时候创建?
RR 级别:事务中第一次执行快照读时创建,之后复用。
RC 级别:每次快照读都创建新的 ReadView。
注意:BEGIN 时不会创建 ReadView,第一次 SELECT 时才创建。
如果想在 BEGIN 时就创建 ReadView,可以用 START TRANSACTION WITH CONSISTENT SNAPSHOT。
延伸知识点
purge 线程:清理不再需要的 undo log 版本
insert undo log:插入操作的 undo log,提交后可直接删除
update undo log:更新/删除操作的 undo log,需要用于 MVCC,不能立即删除
事务 ID 分配:只读事务不分配 trx_id,只有写操作才分配
两阶段提交(2PC):redo log prepare → binlog write → redo log commit
4.3 事务隔离级别
核心定义
SQL 标准定义了四种事务隔离级别,从低到高:读未提交、读已提交、可重复读、串行化。隔离级别越高,并发性能越低,数据一致性越好。
深度答案
四种隔离级别:
读未提交(READ UNCOMMITTED)
可以读到其他事务未提交的数据。
问题:脏读、不可重复读、幻读都可能发生。
实际几乎不用。
读已提交(READ COMMITTED)
只能读到其他事务已提交的数据。
解决了脏读,仍有不可重复读和幻读。
Oracle、SQL Server 默认级别。
可重复读(REPEATABLE READ)
同一事务内多次读取同一数据结果一致。
解决了脏读和不可重复读,InnoDB 通过间隙锁解决了幻读。
MySQL InnoDB 默认级别。
串行化(SERIALIZABLE)
所有事务串行执行,完全隔离。
解决所有问题,但并发性能最差。
实际很少用。
三个并发问题:
脏读:事务 A 读到事务 B 未提交的数据,B 回滚后 A 读到的是脏数据。
不可重复读:事务 A 内两次读取同一数据,期间事务 B 修改并提交,A 两次结果不同。
幻读:事务 A 内两次范围查询,期间事务 B 插入新行,A 第二次读到新增行(幻影行)。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | | --- | --- | --- | --- | | 读未提交 | 可能 | 可能 | 可能 | | 读已提交 | 不会 | 可能 | 可能 | | 可重复读 | 不会 | 不会 | 可能(InnoDB 解决) | | 串行化 | 不会 | 不会 | 不会 |
高频追问陷阱
追问 1:不可重复读和幻读的区别?
不可重复读:针对同一行数据的修改(UPDATE/DELETE),两次读到的值不同。
幻读:针对范围查询的新增行(INSERT),两次查询行数不同。
不可重复读通过行锁解决,幻读需要间隙锁解决。
追问 2:InnoDB 的 RR 级别怎么解决幻读?
快照读:通过 MVCC,ReadView 固定,看不到其他事务的插入。
当前读:通过 Next-Key Lock(行锁 + 间隙锁),锁住查询范围,防止其他事务插入。
例如:SELECT * FROM user WHERE id > 10 FOR UPDATE,会锁住 id > 10 的所有行和间隙。
追问 3:为什么 MySQL 默认用 RR 而不是 RC?
历史原因:InnoDB 设计时 RR 已经通过间隙锁解决了幻读,和 RC 性能差距不大。
RR 下主从复制更安全(statement 格式 binlog 在 RC 下可能不一致)。
MySQL 5.1 之后 row 格式 binlog 普及,RC 也可以安全使用,但默认还是 RR。
追问 4:RC 和 RR 在锁方面有什么区别?
RC 下间隙锁关闭,只有记录锁,并发更好。
RR 下使用 Next-Key Lock,间隙锁可能导致更多锁冲突。
RC 下半一致性读(semi-consistent read):UPDATE 时如果行被锁,先读最新提交版本判断是否需要加锁。
RC 下唯一索引的等值查询,找到记录后不会继续扫描(RR 会扫描到不满足条件的第一个值)。
延伸知识点
ACID:原子性(undo log)、一致性(最终目的)、隔离性(锁 + MVCC)、持久性(redo log)
事务的四种状态:活动、部分提交、失败、中止、提交
长事务的危害:占用 undo log、锁持有时间长、主从延迟
如何避免长事务:分批处理、设置事务超时、监控长事务
分布式事务:2PC、3PC、TCC、Saga、本地消息表、最大努力通知
4.4 锁机制
核心定义
InnoDB 实现了多种锁:共享锁(S)、排他锁(X)、意向锁(IS/IX)、记录锁、间隙锁、Next-Key Lock、自增锁。
深度答案
按粒度分类:
全局锁:FTWRL(Flush Tables With Read Lock),整个库只读,用于全量备份。
表级锁:
表锁:LOCK TABLES,很少用。
元数据锁(MDL):访问表时自动加,DDL 会加 MDL 写锁,可能导致阻塞。
意向锁:表级,表明事务打算对表中的行加 S 或 X 锁。
行级锁:
记录锁(Record Lock):锁单行。
间隙锁(Gap Lock):锁索引间隙,防止插入。
Next-Key Lock:记录锁 + 间隙锁,左开右闭区间。
意向锁:
意向共享锁(IS):事务打算给行加 S 锁。
意向排他锁(IX):事务打算给行加 X 锁。
意向锁之间互不冲突,意向锁和行锁也不冲突。
作用:加表锁时快速判断是否有行锁,不需要逐行检查。
锁兼容矩阵: | | S | X | IS | IX | | --- | --- | --- | --- | --- | | S | 兼容 | 冲突 | 兼容 | 冲突 | | X | 冲突 | 冲突 | 冲突 | 冲突 | | IS | 兼容 | 冲突 | 兼容 | 兼容 | | IX | 冲突 | 冲突 | 兼容 | 兼容 |
加锁规则(RR 级别):
唯一索引等值查询,命中记录:退化为记录锁。
唯一索引等值查询,未命中:退化为间隙锁。
唯一索引范围查询:Next-Key Lock + 可能的记录锁。
普通索引等值查询:Next-Key Lock,命中后还会加下一个间隙锁。
普通索引范围查询:Next-Key Lock。
无索引:全表扫描,所有行加记录锁,所有间隙加间隙锁(相当于表锁)。
高频追问陷阱
追问 1:什么是死锁?怎么解决?
死锁:两个事务互相持有对方需要的锁,都无法继续执行。
四个必要条件:互斥、占有且等待、不可抢占、循环等待。
InnoDB 自动检测死锁,回滚代价最小的事务(undo log 少的)。
避免:固定加锁顺序、减少事务大小、合理使用索引、降低隔离级别。
查看死锁:SHOW ENGINE INNODB STATUS。
追问 2:间隙锁有什么问题?
间隙锁可能锁住不需要的范围,导致并发降低。
可能导致更多死锁(两个事务都锁同一个间隙)。
RC 级别下间隙锁关闭,只有外键和唯一索引检查时使用。
间隙锁只在 RR 级别默认开启。
追问 3:什么是自增锁?
插入数据时自增列需要获取自增锁(AUTO-INC Lock)。
传统模式:语句级锁,插入完成后释放,保证自增值连续。
交错模式(innodb_autoinc_lock_mode=2,MySQL 8.0 默认):批量插入才加锁,普通插入用轻量级互斥量,性能更好但自增值可能不连续。
自增值不连续的原因:事务回滚、批量插入预分配、INSERT ... ON DUPLICATE KEY UPDATE。
追问 4:MDL 锁是什么?有什么坑?
MDL(Metadata Lock):访问表时自动加 MDL 读锁,DDL 加 MDL 写锁。
坑:长事务持有 MDL 读锁,DDL 加写锁会被阻塞,后续所有查询都会被 DDL 阻塞,导致连接打满。
解决:DDL 前检查长事务、设置 lock_wait_timeout、使用 pt-online-schema-change 或 gh-ost。
延伸知识点
两阶段锁协议(2PL):加锁阶段和解锁阶段,事务中所有加锁在解锁之前
锁等待超时:innodb_lock_wait_timeout,默认 50 秒
死锁检测:innodb_deadlock_detect,默认开启,高并发下可能有性能开销
乐观锁:版本号或 CAS,不加锁,适合读多写少
悲观锁:SELECT ... FOR UPDATE,适合写多读少
行锁是加在索引上的,没有索引会升级为表锁(全表记录锁)
五、Spring 核心
5.1 IOC 与 Bean 生命周期
核心定义
IOC(控制反转)是 Spring 的核心思想,将对象的创建和依赖管理交给 Spring 容器,而不是手动 new。Bean 生命周期指 Bean 从创建到销毁的完整过程。
深度答案
IOC 原理:
BeanFactory:Spring 最核心的接口,负责 Bean 的创建和管理。
ApplicationContext:BeanFactory 的子接口,增加了国际化、事件发布、资源加载等功能。
BeanDefinition:描述 Bean 的元数据(类名、作用域、依赖关系、初始化方法等)。
容器启动时读取配置(XML、注解、Java Config),解析成 BeanDefinition,注册到 BeanDefinitionRegistry。
getBean 时根据 BeanDefinition 创建 Bean 实例,注入依赖。
Bean 生命周期(完整流程):
实例化:调用构造方法创建 Bean 实例。
属性注入:调用 set 方法注入依赖(依赖注入)。
Aware 接口回调:
BeanNameAware:setBeanName()
BeanClassLoaderAware:setBeanClassLoader()
BeanFactoryAware:setBeanFactory()
ApplicationContextAware:setApplicationContext()
BeanPostProcessor 前置处理:postProcessBeforeInitialization()
初始化:
@PostConstruct 注解方法
InitializingBean 接口的 afterPropertiesSet()
init-method 指定的方法
BeanPostProcessor 后置处理:postProcessAfterInitialization()(AOP 代理在此生成)
使用中:Bean 可以被使用。
销毁:
@PreDestroy 注解方法
DisposableBean 接口的 destroy()
destroy-method 指定的方法
依赖注入方式:
构造器注入:推荐,强制依赖,不可变,便于测试。
Setter 注入:可选依赖,可以重新注入。
字段注入(@Autowired):最简单,但不便于测试,Spring 团队不推荐。
高频追问陷阱
追问 1:BeanFactory 和 ApplicationContext 的区别?
BeanFactory 是最底层接口,只提供基本的 Bean 管理功能。
ApplicationContext 继承 BeanFactory,提供更多企业级功能:国际化、事件发布、资源加载、AOP 集成等。
BeanFactory 延迟初始化,getBean 时才创建;ApplicationContext 默认启动时预初始化所有单例 Bean。
开发中一般用 ApplicationContext。
追问 2:Bean 的作用域有哪些?
singleton(默认):全局唯一实例。
prototype:每次获取创建新实例。
request:每个 HTTP 请求一个实例(Web 环境)。
session:每个 Session 一个实例。
application:每个 ServletContext 一个实例。
websocket:每个 WebSocket 连接一个实例。
追问 3:@Autowired 和 @Resource 的区别?
@Autowired:Spring 注解,默认按类型注入,找不到或找到多个时报错。可以配合 @Qualifier 指定名称。
@Resource:JSR-250 注解,默认按名称注入,找不到名称时按类型。可以指定 name 和 type。
@Autowired required=false 可以允许注入失败(为 null)。
追问 4:循环依赖怎么解决?
Spring 通过三级缓存解决单例 Bean 的循环依赖(构造器注入无法解决)。
三级缓存:singletonObjects(成品)、earlySingletonObjects(半成品)、singletonFactories(工厂)。
流程:A 实例化后放入三级缓存 → A 注入 B 发现 B 未创建 → B 创建 → B 注入 A 时从三级缓存获取 A 的早期引用 → B 初始化完成 → A 继续初始化。
为什么需要三级缓存而不是两级?因为如果 Bean 需要 AOP 代理,三级缓存的 ObjectFactory 可以返回代理对象而不是原始对象。
延伸知识点
@Component、@Service、@Repository、@Controller 的区别(本质都是 Component,语义不同)
@Configuration 和 @Component 的区别:@Configuration 中 @Bean 方法会被 CGLIB 代理,保证单例
BeanPostProcessor 和 BeanFactoryPostProcessor 的区别
ImportBeanDefinitionRegistrar:动态注册 Bean
FactoryBean:自定义 Bean 创建逻辑(MyBatis 的 MapperFactoryBean)
5.2 AOP
核心定义
AOP(面向切面编程)通过预编译方式或运行期动态代理实现程序功能的统一维护,用于处理日志、事务、权限等横切关注点。
深度答案
核心概念:
切面(Aspect):横切关注点的模块化,如日志切面。
连接点(JoinPoint):程序执行的某个点,如方法调用。
切入点(Pointcut):匹配连接点的表达式。
通知(Advice):在切入点执行的动作,包括前置、后置、异常、返回、环绕。
织入(Weaving):将切面应用到目标对象的过程。
引入(Introduction):给类添加新方法或字段。
五种通知类型:
@Before:前置通知,方法执行前。
@After:后置通知,方法执行后(无论是否异常)。
@AfterReturning:返回通知,方法正常返回后。
@AfterThrowing:异常通知,方法抛出异常后。
@Around:环绕通知,方法执行前后,最强大,可以决定是否执行原方法。
动态代理实现:
JDK 动态代理:
基于接口,目标类必须实现接口。
运行时生成实现了目标接口的代理类。
使用 java.lang.reflect.Proxy 和 InvocationHandler。
CGLIB 动态代理:
基于继承,目标类不能是 final。
运行时生成目标类的子类。
使用 ASM 字节码框架。
Spring Boot 2.x 默认使用 CGLIB(spring.aop.proxy-target-class=true)。
织入时机:
编译期织入:AspectJ 的 ajc 编译器。
类加载期织入:AspectJ 的 load-time weaving。
运行期织入:Spring AOP 默认方式,生成代理对象。
高频追问陷阱
追问 1:JDK 动态代理和 CGLIB 的区别?
JDK 代理基于接口,CGLIB 基于继承。
JDK 代理只能代理实现了接口的类,CGLIB 可以代理任意类(final 除外)。
JDK 代理生成代理类速度快,执行速度慢(反射调用)。
CGLIB 生成代理类速度慢,执行速度快(直接调用)。
CGLIB 不能代理 final 方法和 final 类。
Spring 5.x / Spring Boot 2.x 默认 CGLIB。
追问 2:Spring AOP 和 AspectJ 的区别?
Spring AOP 是运行期动态代理,只能拦截方法调用,功能有限。
AspectJ 是编译期或类加载期织入,可以拦截字段访问、构造器、静态方法等,功能更强大。
Spring AOP 集成了 AspectJ 的注解(@Aspect、@Pointcut 等),但织入方式还是动态代理。
复杂场景用 AspectJ,一般场景用 Spring AOP 足够。
追问 3:同一个类中方法调用,AOP 为什么不生效?
因为 AOP 是通过代理对象实现的,this 调用是原始对象,不走代理。
解决方法:
注入自己(@Autowired 自身,循环依赖)。
使用 AopContext.currentProxy() 获取代理对象(需要 exposeProxy=true)。
重构代码,将方法放到不同类中。
追问 4:@Transactional 为什么有时候不生效?
方法不是 public(Spring AOP 只能拦截 public 方法)。
同一个类中方法调用(不走代理)。
异常被 catch 了没有抛出。
抛出的异常不是 RuntimeException 或 Error(默认只回滚这些)。
数据库引擎不支持事务(如 MyISAM)。
没有被 Spring 管理(没有 @Component 等)。
多线程环境下事务不生效(事务绑定线程)。
延伸知识点
切点表达式:execution、within、this、target、args、@annotation、@within
通知执行顺序:@Around → @Before → 方法执行 → @AfterReturning/@AfterThrowing → @After → @Around 后续
多个切面的执行顺序:@Order 或 Ordered 接口,值小的先执行
声明式事务 vs 编程式事务
事务传播行为:REQUIRED、REQUIRES_NEW、NESTED 等 7 种
事务失效场景汇总
5.3 Spring Boot 自动配置
核心定义
Spring Boot 自动配置通过 @EnableAutoConfiguration 注解,根据 classpath 中的依赖自动配置 Spring 应用,减少手动配置。
深度答案
自动配置原理:
@SpringBootApplication 是组合注解,包含:
@SpringBootConfiguration:标记为配置类
@EnableAutoConfiguration:开启自动配置
@ComponentScan:组件扫描
@EnableAutoConfiguration 导入 AutoConfigurationImportSelector。
AutoConfigurationImportSelector 读取 META-INF/spring.factories(Spring Boot 2.7 前)或 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(Spring Boot 2.7+)文件中的自动配置类列表。
条件注解过滤:每个自动配置类都有条件注解,满足条件才生效:
@ConditionalOnClass:classpath 中有指定类
@ConditionalOnMissingBean:容器中没有指定 Bean
@ConditionalOnProperty:配置文件中有指定属性
@ConditionalOnWebApplication:Web 应用
@ConditionalOnBean:容器中有指定 Bean
自动配置类通过 @Bean 方法注册组件到容器。
starter 机制:
starter 是一组依赖的集合,引入 starter 就引入了相关依赖。
starter 本身不写代码,只负责依赖管理和自动配置类的引入。
自定义 starter:写自动配置类 + 配置 META-INF/spring.factories。
高频追问陷阱
追问 1:spring.factories 和 AutoConfiguration.imports 的区别?
spring.factories 是 Spring Boot 2.7 之前的方式,key-value 格式。
AutoConfiguration.imports 是 2.7 引入的新方式,纯列表格式,更简洁。
Spring Boot 3.0 移除了 spring.factories 中的自动配置注册,只支持新方式。
两种方式在 2.7 可以共存。
追问 2:自动配置的优先级怎么控制?
@AutoConfigureBefore:在指定配置类之前加载。
@AutoConfigureAfter:在指定配置类之后加载。
@AutoConfigureOrder:指定顺序。
@ConditionalOnMissingBean 保证用户配置优先(用户定义的 Bean 会覆盖自动配置的)。
追问 3:怎么排查自动配置不生效?
启动时加 --debug 参数,查看自动配置报告。
查看 Positive matches 和 Negative matches。
检查条件注解是否满足(类是否在 classpath、Bean 是否已存在、属性是否配置)。
检查包扫描路径(默认扫描启动类所在包及子包)。
追问 4:@SpringBootApplication 的 scanBasePackages 有什么用?
默认扫描启动类所在包及其子包。
如果 Bean 在其他包,需要指定 scanBasePackages 或手动 @Import。
常见坑:启动类放错位置导致 Bean 没被扫描到。
延伸知识点
外部化配置:application.yml、bootstrap.yml、环境变量、命令行参数
配置加载优先级:命令行 > 环境变量 > application-{profile}.yml > application.yml
@ConfigurationProperties:类型安全的配置绑定
@Conditional 系列注解大全
自定义 starter 的完整步骤
Spring Boot 3.0 新特性:Jakarta EE、GraalVM 原生镜像、Observability
六、Redis 核心
6.1 数据结构
核心定义
Redis 有 5 种基础数据类型:String、List、Hash、Set、Sorted Set,以及多种高级数据结构:Bitmap、HyperLogLog、Geo、Stream。
深度答案
String(字符串):
最基础类型,value 最大 512MB。
底层实现:int(整数)、embstr(短字符串,<=44字节)、raw(长字符串)。
常用命令:SET、GET、INCR、DECR、SETEX、SETNX、MSET、MGET。
应用场景:缓存、计数器、分布式锁、session。
List(列表):
双向链表,两端操作快,中间操作慢。
底层实现:quicklist(3.2+),之前是 ziplist + linkedlist。
常用命令:LPUSH、RPUSH、LPOP、RPOP、LRANGE、BLPOP。
应用场景:消息队列、最新列表、排行榜。
Hash(哈希):
键值对集合,适合存储对象。
底层实现:ziplist(元素少且值小时)、hashtable。
转换条件:hash-max-ziplist-entries=512,hash-max-ziplist-value=64。
常用命令:HSET、HGET、HGETALL、HINCRBY、HDEL、HEXISTS。
应用场景:对象缓存、购物车。
Set(集合):
无序不重复集合。
底层实现:intset(全是整数且数量少时)、hashtable。
常用命令:SADD、SREM、SMEMBERS、SISMEMBER、SINTER、SUNION、SDIFF。
应用场景:标签、共同好友、去重。
Sorted Set(有序集合):
每个元素有一个 score,按 score 排序。
底层实现:ziplist(元素少时)、skiplist + hashtable。
skiplist 保证范围查询高效,hashtable 保证单点查询 O(1)。
常用命令:ZADD、ZRANGE、ZREVRANGE、ZRANK、ZSCORE、ZINCRBY。
应用场景:排行榜、延迟队列、范围查找。
高级数据结构:
Bitmap:位操作,基于 String,用于签到、用户在线状态。
HyperLogLog:基数统计,基于概率算法,标准误差 0.81%,每个 key 只占 12KB。
Geo:地理空间,基于 Sorted Set,用于附近的人。
Stream:消息队列,支持消费者组,5.0 引入。
高频追问陷阱
追问 1:Redis 的跳表(skiplist)是什么?为什么不用红黑树?
跳表是多层链表结构,每层都是下一层的索引,查找时从高层开始,类似二分查找。
时间复杂度:查找 O(log n),插入删除 O(log n),范围查询 O(log n + k)。
相比红黑树:实现更简单、范围查询更方便(链表顺序遍历)、并发修改更容易(局部修改)。
Redis 的跳表每个节点有随机层数(1-32),平均 O(log n)。
追问 2:ziplist 是什么?有什么优缺点?
ziplist 是压缩列表,连续内存存储,节省空间。
缺点:插入删除需要移动内存,O(n),元素多时性能差。
所以元素超过阈值或值过大时会转换为 hashtable/skiplist。
Redis 7.0 引入 listpack 替代 ziplist,解决连锁更新问题。
追问 3:String 的三种编码什么时候转换?
int:value 是整数且在 long 范围内。
embstr:value 是字符串且长度 <= 44 字节(Redis 3.2+,之前是 39)。
raw:长度 > 44 字节。
embstr 是只读的,修改时会转为 raw。
int 类型修改后如果不再是整数,也会转为 raw。
追问 4:Hash 和 String 存对象有什么区别?
String 存 JSON:整个对象序列化,读取需要反序列化,修改需要读改写。
Hash 存对象:每个字段独立,可以单独读写某个字段,更灵活。
Hash 更省空间(ziplist 编码时),但不支持嵌套对象。
选择:经常修改单个字段用 Hash,整体读写用 String。
延伸知识点
RedisObject:所有对象的头部,包含 type、encoding、lru、refcount、ptr
内存优化:使用短键、合理选择数据结构、使用 ziplist
渐进式 rehash:hashtable 扩容时不一次性迁移,而是分多次迁移
Redis 7.0 的 listpack 和函数(Functions)
Redis 模块系统:RedisBloom、RedisJSON、RediSearch、RedisGraph
6.2 缓存问题
核心定义
Redis 缓存常见三大问题:缓存穿透、缓存击穿、缓存雪崩,以及缓存与数据库一致性问题。
深度答案
1. 缓存穿透:
问题:查询不存在的数据,缓存和数据库都没有,每次请求都打到数据库。
危害:大量恶意请求可能压垮数据库。
解决方案:
缓存空值:查询不到也缓存,设置较短过期时间。
布隆过滤器:在缓存前加一层布隆过滤器,不存在的直接拦截。
参数校验:接口层做合法性校验。
2. 缓存击穿:
问题:某个热点 key 过期的瞬间,大量并发请求同时打到数据库。
解决方案:
互斥锁:缓存失效时,只有一个线程去查数据库并重建缓存,其他线程等待。
逻辑过期:不设置物理过期时间,value 中存逻辑过期时间,过期后异步更新缓存。
热点 key 永不过期 + 异步更新。
3. 缓存雪崩:
问题:大量 key 同时过期,或 Redis 宕机,请求全部打到数据库。
解决方案:
过期时间加随机值,避免同时过期。
多级缓存:本地缓存 + Redis 缓存。
Redis 高可用:主从 + 哨兵 + 集群。
限流降级:数据库侧限流,返回默认值。
缓存预热:启动时提前加载热点数据。
4. 缓存与数据库一致性:
Cache Aside Pattern(旁路缓存):
读:先读缓存,命中返回;未命中读数据库,写入缓存。
写:先更新数据库,再删除缓存。
为什么是删除缓存而不是更新缓存?
更新缓存可能导致并发写时数据不一致。
删除缓存更简单,下次读时懒加载。
为什么先更新数据库再删除缓存?
如果先删缓存再更新数据库,并发读可能把旧值写回缓存。
先更新数据库再删缓存,理论上也有不一致窗口,但概率极低(需要缓存刚好过期 + 读在更新前 + 写在读后)。
延迟双删:更新数据库后删缓存,延迟一段时间再删一次,解决并发导致的脏缓存。
高频追问陷阱
追问 1:布隆过滤器的原理和优缺点?
原理:多个哈希函数 + 位数组,元素加入时多个位置置 1,查询时所有位置都为 1 才可能存在。
优点:空间效率高,查询快。
缺点:有假阳性(可能误判存在),不能删除元素。
解决删除:布谷鸟过滤器(Cuckoo Filter)。
Redis 中使用 RedisBloom 模块。
追问 2:互斥锁方案中,获取不到锁的线程怎么处理?
方案一:自旋重试,等待缓存重建完成。
方案二:直接返回旧值(如果缓存中有旧值)。
方案三:返回降级数据或空值。
实际中常用自旋 + 超时,避免无限等待。
追问 3:先更新数据库再删缓存,删除失败怎么办?
删除失败会导致缓存中是旧数据,数据库是新数据。
解决方案:
重试机制:删除失败后重试几次。
消息队列:删除操作发到 MQ,消费者保证删除成功。
订阅 binlog:监听数据库 binlog,异步删除缓存(Canal)。
设置缓存过期时间,最终一致。
追问 4:什么是缓存预热?怎么做?
缓存预热是系统启动时提前将热点数据加载到缓存,避免启动后大量请求打到数据库。
实现方式:
启动时定时任务加载。
手动触发接口预热。
基于访问统计自动预热热点数据。
注意:不要一次性加载全部数据,分批加载。
延伸知识点
读写穿透(Read/Write Through):缓存层负责读写数据库,业务层只操作缓存
写回(Write Behind):写操作只更新缓存,异步批量写数据库,性能高但有数据丢失风险
本地缓存:Caffeine、Guava Cache、Ehcache
多级缓存架构:浏览器缓存 → CDN → Nginx 缓存 → 本地缓存 → Redis → 数据库
缓存监控:命中率、内存使用、key 数量、慢查询
6.3 分布式锁
核心定义
分布式锁是在分布式环境下控制多个进程对共享资源互斥访问的机制。Redis 是实现分布式锁的常用方式。
深度答案
Redis 分布式锁的演进:
1. 基础版:SETNX + EXPIRE
SETNX lock:key value
EXPIRE lock:key 30
问题:SETNX 和 EXPIRE 不是原子操作,如果 SETNX 后进程崩溃,锁永远不会过期。
2. 原子版:SET key value NX EX 30
SET 命令支持 NX(不存在才设置)和 EX(过期时间),原子操作。
问题:释放锁时可能误删别人的锁(锁过期后被别人获取,原持有者释放了别人的锁)。
3. 安全版:SET + UUID + Lua 脚本释放
value 设置为唯一 UUID,释放时先判断是否是自己的锁,再删除。
判断和删除用 Lua 脚本保证原子性。
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
4. 可重入版:Redisson
基于 HashMap + 线程标识实现可重入。
看门狗机制:锁未释放时自动续期(默认每 10 秒续期到 30 秒)。
支持公平锁、读写锁、信号量等。
Redisson 加锁原理:
使用 Hash 结构存储锁:key 是锁名,field 是 UUID:threadId,value 是重入次数。
加锁 Lua 脚本:判断锁是否存在,不存在则创建并设置过期时间;存在且是自己的则重入次数 +1;否则返回剩余过期时间。
看门狗:后台线程定时续期,保证业务执行期间锁不过期。
高频追问陷阱
追问 1:Redis 分布式锁有什么问题?
主从切换问题:锁在主节点,主节点宕机,从节点提升为主,锁丢失。
锁过期时间不好设置:太短业务没执行完,太长影响可用性。
不可重入(基础版)。
无法阻塞等待(基础版只能轮询)。
追问 2:Redlock 算法是什么?可靠吗?
Redlock:向多个独立的 Redis 实例(通常 5 个)请求加锁,超过半数成功才算加锁成功。
目的:解决主从切换导致的锁丢失问题。
争议:Martin Kleppmann 认为 Redlock 不可靠,依赖系统时钟,时钟漂移可能导致问题。
antirez(Redis 作者)反驳:时钟漂移在可控范围内,Redlock 是合理的。
实际:对一致性要求极高的场景用 ZooKeeper 或 etcd,一般场景 Redisson 足够。
追问 3:Redis 分布式锁和 ZooKeeper 分布式锁的区别?
Redis:性能好,实现简单,但可靠性稍差(主从切换)。
ZooKeeper:强一致性(ZAB 协议),可靠性高,但性能稍差。
Redis 适合高并发、对锁可靠性要求不是极端的场景。
ZooKeeper 适合对一致性要求高的场景(如金融)。
etcd 也是强一致性的选择,基于 Raft 协议。
追问 4:锁的过期时间怎么设置?
根据业务执行时间设置,通常是业务最大执行时间的 2-3 倍。
使用 Redisson 的看门狗自动续期,不需要手动设置。
如果不用看门狗,需要评估业务最长执行时间,留足余量。
不能设置太长(锁释放慢影响并发),也不能太短(业务没执行完锁就过期)。
延伸知识点
Redisson 的公平锁实现:基于队列 + 线程标识
Redisson 的读写锁:读锁共享,写锁独占
Redisson 的信号量(Semaphore)和闭锁(CountDownLatch)
分布式锁的选型:Redis(性能)、ZooKeeper(一致性)、数据库(简单)
锁的粒度:越细越好,但要注意死锁
分布式事务中的锁:TCC、Saga 等模式
6.4 持久化与高可用
核心定义
Redis 提供 RDB 和 AOF 两种持久化方式,高可用通过主从复制、哨兵模式、集群模式实现。
深度答案
RDB(Redis Database):
快照方式,将某一时刻的数据保存到磁盘。
触发方式:SAVE(阻塞)、BGSAVE(fork 子进程,非阻塞)、自动触发(配置 save 规则)。
优点:文件紧凑,恢复速度快,适合备份。
缺点:可能丢失最后一次快照后的数据,fork 子进程有内存开销(COW)。
AOF(Append Only File):
日志方式,记录所有写命令。
三种刷盘策略:
always:每次写都刷盘,最安全,性能最差。
everysec(默认):每秒刷盘,最多丢 1 秒数据。
no:由操作系统决定,性能最好,丢数据最多。
AOF 重写:压缩 AOF 文件,只保留最终状态的命令。BGREWRITEAOF 触发,fork 子进程。
优点:数据安全性高,丢失少。
缺点:文件大,恢复速度慢。
Redis 4.0+ 混合持久化:
AOF 重写时,前半部分是 RDB 格式的快照,后半部分是增量写命令。
兼顾恢复速度和数据安全。
配置:aof-use-rdb-preamble yes。
主从复制:
一主多从,主节点写,从节点读,读写分离。
全量复制:从节点首次连接,主节点生成 RDB 发送给从节点。
增量复制:断线重连后,通过复制积压缓冲区(repl_backlog)同步增量数据。
复制偏移量 + 复制积压缓冲区实现增量同步。
哨兵模式(Sentinel):
监控主从节点,主节点故障时自动故障转移。
三个定时任务:INFO 命令获取主从信息、发布订阅发现其他哨兵、PING 检测心跳。
主观下线(sdown):单个哨兵认为节点下线。
客观下线(odown):超过 quorum 数量的哨兵认为节点下线。
选举领头哨兵:Raft 协议,选举出一个哨兵执行故障转移。
选新主节点规则:优先级 > 复制偏移量 > runid。
集群模式(Cluster):
数据分片,16384 个槽位(slot)分配给不同节点。
每个节点负责一部分槽位,客户端通过 CRC16(key) % 16384 计算槽位。
每个节点有主从,保证高可用。
支持重新分片(reshard),在线迁移槽位。
Gossip 协议传播节点状态。
高频追问陷阱
追问 1:RDB 和 AOF 怎么选?
只用 RDB:可能丢较多数据,不推荐。
只用 AOF:恢复慢,数据安全。
都开启:Redis 默认策略,AOF 数据更全,恢复时优先用 AOF。
混合持久化:推荐,兼顾两者优点。
纯缓存场景可以关闭持久化,追求最高性能。
追问 2:fork 子进程为什么会卡顿?
fork 时需要复制父进程的内存页表,内存越大耗时越长。
写时复制(COW):父子进程共享物理内存,只有修改时才复制页。
如果写入量大,COW 会复制大量内存页,导致内存翻倍和性能下降。
优化:大内存实例关闭自动 RDB,用 AOF;合理设置 rewrite 时机;使用小内存实例集群。
追问 3:主从复制有哪些问题?
数据延迟:从节点同步有延迟,读写分离可能读到旧数据。
主节点写压力大:全量复制时主节点需要生成 RDB。
复制风暴:多个从节点同时全量复制,主节点压力大。
从节点重启后如果 runid 变化,会触发全量复制(Redis 4.0 后支持 psync2,重启后可以增量复制)。
追问 4:Cluster 模式下怎么支持批量操作?
多个 key 必须在同一个槽位才能执行批量操作(MGET、MSET 等)。
使用 hash tag:{tag}key,只对 {} 中的内容计算 CRC16,保证相同 tag 的 key 在同一槽位。
例如:{user:1}:name 和 {user:1}:age 会在同一个槽位。
不使用 hash tag 的多 key 操作会报错 CROSSSLOT。
延伸知识点
Redis 持久化文件损坏怎么恢复(redis-check-rdb / redis-check-aof)
哨兵的脑裂问题:min-replicas-to-write 配置
集群的数据迁移:CLUSTER SETSLOT、MIGRATE
Redis 6.0 多线程 IO:IO 线程处理网络读写,命令执行还是单线程
Redis 7.0 多 AOF 文件:AOFRW 避免重写期间 AOF 文件过大
内存淘汰策略:noeviction、allkeys-lru、volatile-lru、allkeys-lfu、volatile-lfu 等 8 种
七、消息队列
7.1 Kafka
核心定义
Kafka 是分布式流处理平台,基于发布订阅模式,高吞吐、可持久化、可水平扩展,常用于日志收集、数据管道、流处理。
深度答案
核心概念:
Producer:消息生产者。
Consumer:消息消费者。
Broker:Kafka 服务器节点。
Topic:消息主题,逻辑分类。
Partition:分区,Topic 的物理分组,每个 Partition 是有序队列。
Consumer Group:消费者组,组内消费者分摊消费,组间独立消费。
Offset:消息在 Partition 中的偏移量。
Replica:副本,每个 Partition 有多个副本,分为 Leader 和 Follower。
ISR(In-Sync Replicas):与 Leader 保持同步的副本集合。
为什么 Kafka 快:
顺序读写:消息追加到 Partition 末尾,磁盘顺序写比随机写快很多。
页缓存(Page Cache):利用操作系统页缓存,读写都在内存,避免 JVM 堆内存开销。
零拷贝:sendfile 系统调用,数据从磁盘直接到网卡,不经过用户态。
批量处理:生产者批量发送,消费者批量拉取。
压缩:消息批量压缩,减少网络传输。
分区并行:多个 Partition 并行读写。
消息可靠性:
生产者确认机制(acks):
acks=0:不等待确认,可能丢消息。
acks=1:Leader 写入成功即确认,可能丢(Leader 宕机 Follower 未同步)。
acks=all/-1:所有 ISR 副本都写入才确认,最安全。
消费者手动提交 offset,处理完再提交。
副本机制,Leader 故障时 Follower 提升为新 Leader。
消费者组 Rebalance:
消费者组成员变化或订阅 Topic 变化时触发 Rebalance。
Rebalance 期间所有消费者停止消费,影响性能。
分配策略:Range、RoundRobin、Sticky(粘性)。
静态成员(Static Membership):减少 Rebalance 次数。
高频追问陷阱
追问 1:Kafka 怎么保证消息不丢失?
生产者:acks=all,重试机制,同步发送或异步回调检查。
Broker:副本数 >= 3,min.insync.replicas >= 2,unclean.leader.election.enable=false。
消费者:手动提交 offset,处理完再提交,幂等消费。
注意:acks=all + min.insync.replicas=2 时,如果 ISR 只剩 1 个,生产者会报错。
追问 2:Kafka 怎么保证消息顺序?
同一个 Partition 内消息是有序的。
需要全局有序:Topic 只设 1 个 Partition(牺牲并发)。
需要局部有序:相同 key 的消息发到同一个 Partition(默认按 key 哈希)。
消费者单线程消费同一个 Partition。
注意:重试可能导致乱序,需要 max.in.flight.requests.per.connection=1 或启用幂等生产者。
追问 3:重复消费怎么处理?
Kafka 至少一次(at-least-once)语义,可能重复消费。
消费者端实现幂等:唯一 ID + 数据库去重、Redis 去重、唯一索引。
幂等生产者:enable.idempotence=true,保证单分区单会话不重复。
事务:跨分区原子性, exactly-once 语义(仅在 Kafka 内部)。
追问 4:Kafka 的消息积压怎么处理?
临时增加消费者数量(不超过 Partition 数)。
增加 Partition 数量(需要重新分配)。
消费者批量处理,提高消费速度。
临时将消息转存到其他 Topic,后续慢慢处理。
排查消费慢的原因(业务逻辑、数据库慢查询等)。
延伸知识点
Kafka 的 HW(High Watermark)和 LEO(Log End Offset)
控制器(Controller)选举和职责
Kafka 事务:生产者事务 + 消费者事务,实现 exactly-once
Kafka Streams:流处理库
Kafka vs RocketMQ vs RabbitMQ 对比
分区数设置:根据吞吐量和并行度,通常不超过 1000 个/集群
7.2 RabbitMQ
核心定义
RabbitMQ 是基于 AMQP 协议的消息队列,Erlang 编写,可靠性高,支持丰富的路由模式,适合企业级应用。
深度答案
核心概念:
Producer:生产者。
Consumer:消费者。
Broker:消息服务器。
Virtual Host:虚拟主机,隔离不同环境。
Exchange:交换机,接收生产者消息并路由到队列。
Queue:队列,存储消息。
Binding:绑定,Exchange 和 Queue 之间的绑定关系 + RoutingKey。
Channel:信道,多路复用连接。
四种交换机类型:
Direct:精确匹配 RoutingKey。
Fanout:广播,忽略 RoutingKey,发给所有绑定队列。
Topic:通配符匹配,* 匹配一个词,# 匹配零个或多个词。
Headers:根据消息头匹配,不常用。
消息确认机制:
生产者确认(Publisher Confirm):消息到达 Broker 后回调确认。
消费者确认(Consumer ACK):
自动确认:消息发送给消费者后立即确认,可能丢失。
手动确认:处理完后调用 basicAck,失败调用 basicNack/basicReject。
持久化:Exchange、Queue、Message 都要持久化。
死信队列(DLX):
消息变成死信的情况:
消费者拒绝(basicReject/basicNack)且 requeue=false。
消息 TTL 过期。
队列达到最大长度。
死信消息会被转发到 DLX(Dead Letter Exchange),再路由到死信队列。
应用:异常消息处理、延迟队列(TTL + DLX)。
高频追问陷阱
追问 1:RabbitMQ 怎么保证消息不丢失?
生产者:Publisher Confirm + Return 机制,确保消息到达 Broker 和队列。
Broker:持久化(Exchange、Queue、Message 都 durable),镜像队列(高可用)。
消费者:手动 ACK,处理完再确认。
补偿机制:定时任务扫描未确认消息。
追问 2:RabbitMQ 怎么实现延迟队列?
方式一:TTL + DLX。消息设置过期时间,过期后进入死信队列,消费者消费死信队列。
方式二:使用 rabbitmq_delayed_message_exchange 插件,直接支持延迟消息。
TTL 方式的坑:队列头部消息阻塞,如果第一个消息 TTL 长,后面短 TTL 的消息也不会先过期。
插件方式更灵活,每个消息独立延迟。
追问 3:RabbitMQ 的消息积压怎么处理?
增加消费者数量。
消费者批量处理。
临时新建队列,将消息转移过去多消费者消费。
排查消费慢的原因。
限流:生产者侧限流或消费者 prefetch 控制。
追问 4:RabbitMQ 和 Kafka 的区别? | 维度 | RabbitMQ | Kafka | | --- | --- | --- | | 协议 | AMQP | 自定义 TCP 协议 | | 模型 | Exchange + Queue | Topic + Partition | | 吞吐量 | 万级 | 十万级 | | 延迟 | 微秒级 | 毫秒级 | | 可靠性 | 高 | 高 | | 顺序性 | 单队列有序 | 单分区有序 | | 适用场景 | 业务消息、RPC | 日志、大数据、流处理 |
延伸知识点
RabbitMQ 镜像队列(Mirror Queue)和 Quorum Queue(Raft 协议,3.8+)
消息追踪(Firehose)和管理插件
消费者优先级(Consumer Priority)
惰性队列(Lazy Queue):消息存磁盘,避免内存溢出
RPC 模式:ReplyTo + CorrelationId
消息幂等性:唯一消息 ID + 去重
八、高频追问陷阱总结
8.1 并发编程陷阱
synchronized 锁升级:偏向锁 → 轻量级锁 → 重量级锁,JDK 15 偏向锁默认禁用。
volatile 不保证原子性:i++ 不是原子操作,需要 AtomicInteger 或 synchronized。
CAS 的 ABA 问题:版本号解决,AtomicStampedReference。
ThreadLocal 内存泄漏:弱引用 key + 强引用 value,用完必须 remove()。
线程池参数设置:核心线程数根据 CPU 密集/IO 密集调整,队列不要用无界队列。
AQS 的 CLH 队列:虚节点头,后继节点挂起,前驱节点唤醒。
ReentrantLock 默认非公平:非公平性能好但可能饥饿。
8.2 MySQL 陷阱
索引失效:函数、隐式转换、%前缀、OR、违反最左匹配。
MVCC 可见性:RC 每次读生成 ReadView,RR 第一次读生成。
间隙锁:RR 级别下 Next-Key Lock 解决幻读,可能导致死锁。
聚簇索引 vs 二级索引:二级索引叶子节点存主键,回表查询。
事务隔离级别:MySQL 默认 RR,通过间隙锁解决幻读。
死锁检测:InnoDB 自动回滚代价最小的事务。
MDL 锁:DDL 加写锁,可能阻塞所有查询,长事务是元凶。
8.3 Spring 陷阱
@Transactional 失效:非 public、同类调用、异常被 catch、非 RuntimeException。
循环依赖:三级缓存解决,构造器注入无法解决。
AOP 不生效:同类方法调用不走代理,用 AopContext 或注入自身。
自动配置不生效:条件不满足、包扫描路径不对、用户 Bean 覆盖。
Bean 生命周期:实例化 → 属性注入 → Aware → BeanPostProcessor → 初始化 → 销毁。
@Autowired 和 @Resource:按类型 vs 按名称。
事务传播行为:REQUIRED、REQUIRES_NEW、NESTED 的区别。
8.4 Redis 陷阱
缓存穿透/击穿/雪崩:布隆过滤器、互斥锁、随机过期时间。
缓存一致性:先更新数据库再删缓存,延迟双删,binlog 订阅。
分布式锁:SET NX EX + UUID + Lua 释放,Redisson 看门狗续期。
大 key 问题:避免 value 过大,拆分或压缩,影响阻塞和迁移。
热 key 问题:本地缓存、多副本、热点发现。
持久化 fork 卡顿:大内存实例 fork 耗时长,COW 内存翻倍。
Cluster 跨槽操作:用 hash tag 保证多 key 同槽。
8.5 MQ 陷阱
消息丢失:生产者确认 + Broker 持久化 + 消费者手动 ACK。
重复消费:消费者幂等,唯一 ID 去重。
消息顺序:同 Partition/Queue 有序,全局有序牺牲并发。
消息积压:增加消费者、批量处理、临时转储。
Kafka Rebalance:停止消费,静态成员减少触发。
RabbitMQ 死信队列:TTL + DLX 实现延迟队列。
这里特别讲一下消息堆积的问题,1.业务队列设置了x-max-length,然后业务消费能力确实是跟不上,主队列消息越积越多,超过了上限,队列满了,老消息被挤出,就会变成死信进入死信队列;
2.消费者代码逻辑写的有问题,有bug,有脏数据,大量消息消费失败,requeue=false变成死信,主队列看起来消费正常,但是死信队列瞬间积累大量的异常消息;
3.TTL过期产生死信,消息堆积在业务队列,长时间不被消费,消息TTL过期,也会变成死信。
Kafka 高吞吐原因:顺序写、页缓存、零拷贝、批量、压缩。
本文档覆盖 Java 后端面试八股文核心知识点,建议结合项目经验深入理解,不要死记硬背。大厂面试更看重原理理解和实际应用能力。