阿里云-Java后端技术基础面试题深度解析
覆盖并发编程、JVM、Redis、消息队列、MySQL、Spring 与算法共 20 道高频题,每题从原理到工程实践逐层展开。
一、并发编程基础
1. 对 Java 中 volatile 关键字的理解
volatile 是 Java 提供的一种轻量级同步机制,它解决的是多线程环境下共享变量的可见性和有序性两个问题,但不保证原子性。
三大特性逐一拆解:
可见性:在 JMM(Java Memory Model)中,每个线程有自己的工作内存(CPU 缓存 + 寄存器),共享变量存在主内存。线程对变量的读写都在工作内存中进行,何时刷回主内存由 JVM 决定。这会导致一个线程修改了变量,另一个线程可能读到的还是旧值。volatile 强制变量的读写直接与主内存交互:写操作会立即刷新到主内存,读操作会强制从主内存重新加载,并通过缓存一致性协议(MESI)让其他 CPU 缓存行失效。
有序性:编译器和 CPU 为了优化性能会进行指令重排序,单线程下不会改变语义,但多线程下可能出错。volatile 通过插入内存屏障(Memory Barrier)禁止特定类型的重排序,建立 happens-before 关系:对 volatile 变量的写操作 happens-before 后续对它的读操作。
不保证原子性:i++ 是「读-改-写」三步操作,volatile 只保证每次读到的是最新值,但两个线程同时读到相同的值再各自加 1,结果只加了 1 次。
真实场景一:DCL 单例模式
public class Singleton {
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() 实际分三步:分配内存、初始化对象、引用指向内存地址。不加 volatile 时,JVM 可能重排为「分配→引用赋值→初始化」,此时另一个线程在第一次检查时看到 instance 非 null,直接返回一个尚未初始化完成的对象,导致 NPE。volatile 禁止这种重排。
真实场景二:状态标志位
private volatile boolean running = true;
public void run() {
while (running) {
// 业务逻辑
}
}
public void shutdown() {
running = false;
}
如果一个线程跑循环、另一个线程发停止信号,不加 volatile,跑循环的线程可能永远读不到 running 变成 false,导致线程无法退出。
底层实现:HotSpot 中 volatile 写会在其后插入 StoreStore + StoreLoad 屏障,volatile 读会在其前插入 LoadLoad + LoadStore 屏障。StoreLoad 屏障开销最大,也是 volatile 性能略低于普通变量的原因。
2. 实际中怎么保证线程安全的复合操作
「复合操作」是指由多个步骤组成、必须作为整体执行的操作,典型如 check-then-act(先判断再执行)和 read-modify-write(读改写)。volatile 解决不了这类问题,工程上有四类方案:
方案一:加锁(悲观策略)
用 synchronized 或 ReentrantLock 把复合操作包成临界区,串行化执行。简单可靠,但并发度受限。
synchronized (lock) {
if (map.get(key) == null) {
map.put(key, value);
}
}
方案二:CAS 原子类(乐观策略)
java.util.concurrent.atomic 包提供了基于 CAS(Compare And Swap)的原子类,适合简单的读改写场景。
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // 原子的 i++
count.compareAndSet(0, 1); // 期望值为 0 时才设为 1
CAS 的底层是 CPU 的 cmpxchg 指令,单条指令保证原子性。高竞争下 CAS 自旋会浪费 CPU,此时 LongAdder 通过分段累加更高效。
方案三:并发容器
ConcurrentHashMap 的 computeIfAbsent 把「判断是否存在→不存在则写入」封装成原子操作,内部用分段锁/CAS + synchronized 实现。
ConcurrentHashMap<String, Object> cache = new ConcurrentHashMap<>();
cache.computeIfAbsent("key", k -> expensiveQuery(k));
AtomicReference 适合对引用对象做原子的条件更新。
方案四:不可变对象 + ThreadLocal
不可变对象天生线程安全,通过 final 字段配合防御性拷贝实现。ThreadLocal 让每个线程持有变量的独立副本,从根源上消除共享。
选型建议:简单计数用 AtomicInteger;Map 操作用 ConcurrentHashMap 的原子方法;复杂业务逻辑用 synchronized 或 ReentrantLock;能设计成不可变就不可变。原则是优先用更高层、更细粒度的工具,避免一把大锁锁全局。
3. synchronized 的锁升级过程
JDK 6 之前 synchronized 是重量级锁,直接走操作系统互斥量,性能差。JDK 6 引入「锁升级」机制,根据竞争程度自适应选择锁状态,大幅提升性能。
锁升级路径:无锁 → 偏向锁 → 轻量级锁 → 重量级锁,且不可降级(GC 时偏向锁可被批量撤销重置为无锁)。
核心数据结构:对象头 Mark Word
每个 Java 对象在内存中由对象头、实例数据、对齐填充组成。对象头的 Mark Word(64 位)在不同锁状态下存储不同信息:
| 锁状态 | 25bit | 31bit | 1bit | 4bit | 1bit(偏向标志) | 2bit(锁标志) |
|---|---|---|---|---|---|---|
| 无锁 | unused | hashCode | unused | 分代年龄 | 0 | 01 |
| 偏向锁 | 线程ID(54bit) + Epoch(2bit) | 分代年龄 | 1 | 01 | ||
| 轻量级锁 | 指向栈中锁记录的指针 | 00 | ||||
| 重量级锁 | 指向 Monitor 对象的指针 | 10 | ||||
| GC 标记 | 11 |
偏向锁:绝大多数情况下锁不仅不存在多线程竞争,而且总是由同一个线程多次获得。偏向锁会在 Mark Word 中记录线程 ID,后续该线程进入同步块只需判断 ID 是否匹配,无需任何 CAS。开销几乎为零。
升级触发:当第二个线程尝试获取锁时,偏向锁撤销(需要等到全局安全点),升级为轻量级锁。
轻量级锁:在线程栈帧中创建 Lock Record,用 CAS 把 Mark Word 替换为指向 Lock Record 的指针。成功则获得锁,失败则自旋重试。自旋次数自适应(JDK 6 后由 JVM 根据历史成功率动态调整)。
升级触发:自旋超过阈值仍未获得锁,或有第三个线程竞争,升级为重量级锁。
重量级锁:依赖操作系统的 Mutex(互斥锁)实现,未竞争到锁的线程被 park 挂起,进入等待队列,由操作系统调度唤醒。线程切换涉及用户态/内核态切换,开销大。
真实场景对照:一个 StringBuffer 的 append 操作在单线程下是偏向锁(几乎零开销);在两个线程交替执行时是轻量级锁(自旋 CAS);高并发抢购场景下是重量级锁(线程挂起排队)。开发者无需关心当前处于哪个状态,JVM 自动优化,这也是 synchronized 被称为「内置锁」的优势——使用简单、自适应。
4. 偏向锁在什么情况下会撤销
偏向锁撤销是指把对象的锁状态从「偏向锁」退回到「无锁」或升级为「轻量级锁」的过程。撤销不是无代价的,需要等待全局安全点(SafePoint,此时所有线程暂停,没有字节码在执行)。
主要撤销场景:
场景一:出现第二个线程竞争
这是最常见的撤销原因。偏向锁假设「锁只被一个线程持有」,一旦另一个线程尝试 CAS 获取锁失败,就触发撤销。撤销时需要遍历所有线程栈,找到持有偏向锁的线程,检查其是否仍在同步块内:已退出则撤销并升级为轻量级锁;仍在执行则升级为轻量级锁并让原线程暂停。
场景二:调用对象的 hashCode() 方法
偏向锁状态下 Mark Word 存的是线程 ID,没有空间存 hashCode。一旦调用 hashCode(),对象必须能存下 hashCode,偏向锁被撤销,对象进入无锁状态并永久不能再偏向(因为 hashCode 一旦生成就固定了)。同理 System.identityHashCode() 也会触发。
场景三:调用 wait() / notify()
这两个方法只存在于重量级锁的 ObjectMonitor 中,调用会直接将锁膨胀为重量级锁。
场景四:显式禁用偏向锁
JVM 参数 -XX:-UseBiasedLocking 关闭偏向锁(JDK 15 起默认关闭,因为现代应用多线程竞争频繁,偏向锁的撤销开销反而成为负担)。
批量撤销与批量重偏向
JVM 会对撤销做优化:当某个类的对象撤销次数达到阈值(默认 20),JVM 认为这个类的锁竞争激烈,触发批量撤销(该类所有实例的偏向锁失效);当达到另一阈值(默认 40)会触发批量重偏向(重置 Epoch,让该类新对象可以重新偏向到新线程)。
工程启示:在锁对象上调用 hashCode() 或在哈希容器中作为 key(会调用 hashCode),会破坏偏向锁优化。锁对象应尽量简单、不复用、不参与哈希计算。
5. 如果不用 synchronized,JUC 里有哪些锁
java.util.concurrent.locks 包提供了丰富的锁实现,按用途分几类:
互斥锁
ReentrantLock:可重入互斥锁,synchronized的增强版,支持公平/非公平、可中断、可超时、多 Condition。ReentrantReadWriteLock:读写锁,读读共享、读写互斥、写写互斥。适合读多写少场景,如缓存。
乐观读锁
StampedLock:JDK 8 引入,在读写锁基础上增加「乐观读」模式。乐观读不加锁,读完后校验 stamp 是否变化,未变则直接用,变了再升级为悲观读锁。读多写少且能容忍偶尔回退时性能优于读写锁。注意它不可重入,且不要在tryOptimisticRead和validate之间做耗时操作,否则 stamp 容易失效。
协调工具(广义的锁)
Semaphore:信号量,控制同时访问某资源的线程数。常用于限流、连接池。CountDownLatch:一次性倒计时器,一个线程等待 N 个线程完成。常用于服务启动后等待多个依赖初始化完成。CyclicBarrier:可复用屏障,N 个线程互相等待到齐后一起继续。常用于多线程分阶段计算。Phaser:JDK 7 引入,增强版 CyclicBarrier,支持动态注册参与者、分阶段执行。
选型对比:
| 锁类型 | 适用场景 | 关键特性 |
|---|---|---|
| ReentrantLock | 替代 synchronized 的通用互斥 | 可中断、可超时、多 Condition |
| ReentrantReadWriteLock | 读多写少的缓存、配置 | 读读共享、写互斥 |
| StampedLock | 读远多于写、追求极致读性能 | 乐观读、不可重入 |
| Semaphore | 限流、资源池 | 许可证数量控制 |
| CountDownLatch | 一次性等待多个任务完成 | 不可复用 |
| CyclicBarrier | 多线程分阶段同步 | 可复用 |
实际项目中,连接池用 Semaphore 限制连接数,本地缓存用 ReentrantReadWriteLock,启动流程用 CountDownLatch 等待多个组件就绪。
6. ReentrantLock 和 synchronized 的区别
两者都是可重入互斥锁,但 ReentrantLock 在功能上更丰富,synchronized 在使用上更简洁。
核心差异对比:
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现层面 | JVM 内置,关键字级别 | JDK API 层面,基于 AQS 实现 |
| 锁释放 | 自动释放(退出同步块或异常) | 必须手动 unlock(),通常放在 finally 块 |
| 可中断 | 不可中断,等待锁时无法响应 interrupt | lockInterruptibly() 可响应中断 |
| 超时获取 | 不支持 | tryLock(timeout) 支持超时 |
| 公平性 | 非公平 | 可选公平/非公平(构造参数) |
| 条件变量 | 一个 wait/notify 等待队列 |
多个 Condition,可分组等待 |
| 锁状态 | 不可查询 | isLocked()、getHoldCount() 可查询 |
| 性能(JDK6+) | 锁升级优化后差距很小 | 高竞争下略优,可超时可中断减少死锁 |
关键特性展开:
可重入性:两者都支持。同一线程获取锁后可再次获取,计数器加 1,释放时减 1,减到 0 才真正释放。这避免了线程自己锁自己的死锁。
公平锁:new ReentrantLock(true) 创建公平锁,按 FIFO 顺序分配锁。代价是线程切换开销大、吞吐量降低,实际中非公平锁(默认)更常用——它允许「插队」,刚释放锁的线程若立即再次获取,可以省去线程切换。
多 Condition:ReentrantLock 可以创建多个等待队列,实现精细化唤醒。经典场景是「生产者-消费者」用两个 Condition(notFull 和 notEmpty),生产者唤醒消费者时不会误唤醒其他生产者。
Lock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();
// 生产者:队列满时在 notFull 上等待,生产后唤醒 notEmpty
// 消费者:队列空时在 notEmpty 上等待,消费后唤醒 notFull
真实场景选型:
- 简单同步、不想管释放:用
synchronized,代码简洁不会忘记 unlock。 - 需要超时获取(避免死锁等待):用
tryLock(timeout),如分布式锁的本地实现。 - 需要中断响应:用
lockInterruptibly(),如可取消的长时间任务。 - 需要精细化条件等待:用多
Condition。
使用陷阱:ReentrantLock 忘记 finally unlock() 会导致锁永不释放;synchronized 异常时会自动释放,但要注意同步块内的业务异常处理。
7. AQS 的核心原理
AQS(AbstractQueuedSynchronizer)是 JUC 同步工具的基石框架,ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock 都基于它实现。理解 AQS 就理解了 JUC 锁的半壁江山。
核心三要素:state + CLH 队列 + 模板方法
一、state(同步状态)
一个 volatile int 变量,不同同步工具赋予不同语义:
| 同步工具 | state 语义 |
|---|---|
| ReentrantLock | 0 未锁定,>0 锁定次数(重入计数) |
| ReentrantReadWriteLock | 高 16 位读锁计数,低 16 位写锁计数 |
| Semaphore | 剩余许可数 |
| CountDownLatch | 剩余计数 |
state 的修改通过 CAS 保证原子性,volatile 保证可见性。
二、CLH 队列(FIFO 等待队列)
一个双向链表,存放等待获取锁的线程封装的 Node。获取锁失败的线程被封装成 Node 入队、park 挂起;锁释放时唤醒队首 Node,该线程 unpark 后重新尝试 CAS 获取。
三、模板方法模式
AQS 定义了获取/释放的骨架流程,子类只需实现「如何尝试获取/释放」的逻辑:
- 独占模式:
tryAcquire()、tryRelease()、isHeldExclusively() - 共享模式:
tryAcquireShared()、tryReleaseShared()
独占模式获取流程(以 ReentrantLock 为例):
1. tryAcquire() 尝试 CAS 修改 state
├─ 成功:设置当前线程为持有者,返回
└─ 失败:addWaiter() 封装 Node 入队
2. acquireQueued() 在队列中自旋等待
├─ 前驱是头节点:再次 tryAcquire()
│ ├─ 成功:自己成为新头节点,返回
│ └─ 失败:shouldParkAfterFailedAcquire() 判断是否可挂起
└─ 前驱非头节点:park 当前线程
3. 被唤醒后继续自旋尝试
共享模式获取流程(以 CountDownLatch 为例):
tryAcquireShared() 返回值有三种语义:负数表示获取失败、0 表示获取成功且后续无需传播、正数表示获取成功且需唤醒后继共享节点。CountDownLatch 中 state == 0 时返回 1(放行并传播唤醒)。
公平与非公平的差异:仅在于 tryAcquire() 是否先检查队列中是否有等待者。公平锁 hasQueuedPredecessors() 返回 true 时直接入队,避免插队;非公平锁直接 CAS 抢占。
设计精髓:AQS 把「队列管理、park/unpark、CAS 重试」等通用逻辑抽到父类,把「state 语义、获取/释放判定」这种与具体场景相关的逻辑留给子类,是模板方法模式的教科书级应用。
8. AQS 里 CLH 队列是怎么工作的
CLH(Craig, Landin, and Hagersten)队列是 AQS 等待队列的实现方式。AQS 借鉴了 CLH 队列的思想,但做了改造:原版 CLH 是单向链表且基于自旋,AQS 改成双向链表并引入 park/unpark 避免空转浪费 CPU。
节点结构
static final class Node {
static final Node SHARED = new Node(); // 共享模式标记
static final Node EXCLUSIVE = null; // 独占模式标记
volatile int waitStatus; // CANCELLED(1)/SIGNAL(-1)/CONDITION(-2)/PROPAGATE(-3)
volatile Node prev; // 前驱
volatile Node next; // 后继
volatile Thread thread; // 等待的线程
Node nextWaiter; // Condition 队列的下一个节点
}
入队流程(addWaiter)
1. 创建 Node,指向当前线程
2. CAS 设置 tail = newNode(自旋保证成功)
- prev 指向原 tail
- 原 tail.next 指向 newNode
3. 若队列为空,先初始化:创建哑节点作为 head,再 CAS 设置 tail
核心工作循环(acquireQueued)
自旋:
if (前驱 == head) {
if (tryAcquire() 成功) {
head = 当前节点; // 旧 head 出队
return;
}
}
if (shouldParkAfterFailedAcquire()) {
park 当前线程; // 阻塞,等待前驱唤醒
// 被 unpark 后继续自旋
}
shouldParkAfterFailedAcquire 的关键:只有前驱节点的 waitStatus == SIGNAL 时才安全 park。SIGNAL 表示「前驱释放时会唤醒我」。所以入队后先把前驱的 waitStatus CAS 改成 SIGNAL,下一轮自旋失败才真正 park。这避免了「前驱已经释放但还没来得及唤醒,我却 park 了」的漏唤醒问题。
出队与唤醒(release)
1. tryRelease() 修改 state
2. if (head.waitStatus != 0) {
unpark(head.next.thread); // 唤醒后继
}
唤醒后继节点时,从 tail 往前找第一个有效节点(跳过 CANCELLED),因为 next 指针可能未及时更新。
为什么用哑节点做 head
head 不存线程,是个哨兵。这样「持有锁的线程」和「等待队列」解耦:持有者不在队列里,队列里全是等待者。当持有者释放,唤醒 head.next 即可,逻辑清晰。
CANCELLED 状态处理
线程超时或被中断会把自己标记为 CANCELLED,并从队列中摘除:把前驱的 next 指向自己的后继,跳过自己。
工程意义:CLH 队列是「自旋 + 阻塞」的折中。短时间能拿到的锁用自旋避免线程切换开销,拿不到就 park 让出 CPU。相比纯自旋(浪费 CPU)和纯阻塞(频繁切换),这是性能和资源利用的最佳平衡。
9. 线程池的创建参数
ThreadPoolExecutor 的构造函数有 7 个参数,理解每个参数的语义和配合关系是正确使用线程池的前提。
public ThreadPoolExecutor(
int corePoolSize, // 核心线程数
int maximumPoolSize, // 最大线程数
long keepAliveTime, // 非核心线程空闲存活时间
TimeUnit unit, // 时间单位
BlockingQueue<Runnable> workQueue, // 任务队列
ThreadFactory threadFactory, // 线程工厂
RejectedExecutionHandler handler // 拒绝策略
)
逐个参数解析:
corePoolSize(核心线程数):线程池常驻线程数。即使空闲也不回收(除非设置 allowCoreThreadTimeOut(true))。CPU 密集型任务建议设为 CPU 核数 + 1;IO 密集型任务建议 CPU 核数 * 2 或更多(因为线程多在等待 IO,可以让 CPU 不闲着)。
maximumPoolSize(最大线程数):线程池能创建的线程上限。只有队列满了才会创建超出 corePoolSize 的非核心线程,直到达到 maximumPoolSize。
keepAliveTime + unit:非核心线程空闲超过这个时间会被回收销毁。核心线程默认不回收,除非显式开启 allowCoreThreadTimeOut。
workQueue(任务队列):保存待执行任务的阻塞队列,类型决定行为差异:
| 队列类型 | 特点 | 风险 |
|---|---|---|
LinkedBlockingQueue |
无界(默认 Integer.MAX_VALUE) | 任务堆积导致 OOM |
ArrayBlockingQueue |
有界,需指定容量 | 满了触发拒绝策略 |
SynchronousQueue |
不存元素,每个 put 必须等 take | 直接提交,无缓冲 |
PriorityBlockingQueue |
无界,支持优先级排序 | 任务优先级排序,仍可能 OOM |
threadFactory:创建线程的工厂,可自定义线程名、是否守护线程、优先级。强烈建议自定义命名(如 order-pool-1),便于排查线程问题。
handler(拒绝策略):队列满且线程数达到 maximumPoolSize 时,新任务的兜底处理。
任务提交流程(核心逻辑):
execute(task)
1. 当前线程数 < corePoolSize?→ 创建核心线程执行任务
2. 当前线程数 >= corePoolSize?→ 任务入 workQueue
3. workQueue 满 且 线程数 < maximumPoolSize?→ 创建非核心线程执行
4. workQueue 满 且 线程数 = maximumPoolSize?→ 触发拒绝策略
注意顺序:核心线程 → 队列 → 非核心线程 → 拒绝。这意味着用无界队列时,maximumPoolSize 永远不会被触发(队列永远不满),这是常见踩坑点。
Executors 工具类的陷阱:
newFixedThreadPool:core == max,用无界LinkedBlockingQueue,任务堆积 OOM。newCachedThreadPool:core=0、max=Integer.MAX_VALUE,用SynchronousQueue,高并发下创建大量线程导致 OOM。newSingleThreadExecutor:单线程 + 无界队列,同样 OOM 风险。
阿里规范明确禁止用 Executors 创建线程池,要求用 ThreadPoolExecutor 显式指定参数,就是为了避免无界队列和无限线程的 OOM 隐患。
真实场景:订单系统用「核心 20、最大 50、队列 1000、ArrayBlockingQueue、CallerRunsPolicy」,既保证正常流量快速处理,又能应对突发流量(队列缓冲 + 扩容线程),极限情况下拒绝策略保护系统不崩溃。
10. 线程池满了有哪些拒绝策略
当线程池的任务队列满且线程数达到 maximumPoolSize 时,新提交的任务会触发拒绝策略。JDK 内置四种,加自定义共五种。
一、AbortPolicy(默认)
直接抛出 RejectedExecutionException。调用方需 try-catch 处理。适合任务不能丢失且调用方有能力处理的场景,但若不捕获异常会导致调用链中断。
new ThreadPoolExecutor(..., new ThreadPoolExecutor.AbortPolicy());
二、CallerRunsPolicy
由提交任务的线程自己执行该任务。这相当于「退回」给生产者,起到天然的限流和背压效果——生产者线程被占着干活,就没法继续提交新任务,给消费者喘息时间。适合不希望丢任务、且能接受降速的场景。
三、DiscardPolicy
直接静默丢弃新任务,不抛异常。适合任务可容忍丢失、且不想感知错误的场景,如日志采集、监控指标上报。风险在于无声无息丢数据,生产环境慎用。
四、DiscardOldestPolicy
丢弃队列头部(最老)的任务,然后重新尝试提交新任务。逻辑是「新任务比老任务更有价值」,适合实时性要求高、旧数据无意义的场景,如行情推送、实时排行榜。
五、自定义拒绝策略
实现 RejectedExecutionHandler 接口。常见做法:
- 持久化到 MQ/DB:被拒任务写入 Kafka 或数据库,后台慢慢消费补偿。
- 降级返回默认值:返回缓存数据或兜底结果,保证接口可用。
- 记录日志 + 告警:丢任务但留痕,便于后续补偿和容量评估。
- 阻塞提交:用
offer(timeout)阻塞等待队列空位,变相限流。
public class PersistRejectHandler implements RejectedExecutionHandler {
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
// 持久化到 MQ,后台补偿
mqProducer.send(serialize(r));
log.warn("Task rejected and persisted, queue size={}", executor.getQueue().size());
}
}
选型建议:
| 策略 | 适用场景 | 风险 |
|---|---|---|
| AbortPolicy | 任务不能丢,调用方需感知失败 | 异常未处理导致调用失败 |
| CallerRunsPolicy | 限流降速,不丢任务 | 调用方线程被阻塞 |
| DiscardPolicy | 容忍丢失的辅助任务 | 静默丢数据 |
| DiscardOldestPolicy | 实时性优先,旧数据无意义 | 丢失未处理的有效任务 |
| 自定义持久化 | 核心业务,必须最终执行 | 实现复杂,需补偿机制 |
工程实践:生产环境通常用自定义策略——核心业务持久化兜底,非核心业务降级返回。同时配合监控线程池的 queue size、active count、reject count,在拒绝策略触发前扩容。
二、JVM 垃圾回收
11. JVM 有哪些垃圾回收器
垃圾回收器是 JVM 内存管理的核心组件,按分代理论和算法演进可分为几代。理解各回收器的定位和适用场景,是 JVM 调优的基础。
分代理论回顾:堆分为新生代(Eden + 2 个 Survivor)和老年代。新生代对象朝生夕死,用复制算法;老年代对象存活率高,用标记-清除或标记-整理算法。
主流回收器全景:
| 回收器 | 分代 | 算法 | 特点 | 适用场景 |
|---|---|---|---|---|
| Serial | 新生代 | 复制 | 单线程,STW | 客户端、小内存 |
| Serial Old | 老年代 | 标记-整理 | 单线程,STW | 客户端、CMS 的兜底 |
| ParNew | 新生代 | 复制 | 多线程版 Serial | 配合 CMS |
| Parallel Scavenge | 新生代 | 复制 | 多线程,吞吐量优先 | 后台计算、批处理 |
| Parallel Old | 老年代 | 标记-整理 | 多线程,吞吐量优先 | 配合 Parallel Scavenge |
| CMS | 老年代 | 标记-清除 | 低延迟,并发标记 | 对响应时间敏感的服务 |
| G1 | 全堆 | 分区 + 标记整理 | 可预测停顿,分区回收 | 大堆、低延迟 |
| ZGC | 全堆 | 染色指针 + 读屏障 | 亚毫秒级停顿,并发整理 | 超大堆、极致低延迟 |
| Shenandoah | 全堆 | Brooks 转发指针 | 并发整理,低停顿 | 超大堆、低延迟 |
逐个展开:
Serial / Serial Old:单线程回收,STW 期间其他线程全停。简单高效(无线程切换开销),适合单核、小内存(百 MB 级)的客户端应用或微服务边缘节点。
ParNew:Serial 的多线程版,通常配合 CMS 使用。-XX:+UseParNewGC -XX:+UseConcMarkSweepGC。
Parallel Scavenge / Parallel Old:JDK 8 默认回收器。特点是吞吐量优先(用户代码运行时间 / 总时间),适合后台运算型任务如离线数据分析、科学计算。相比 CMS 不追求低延迟,但吞吐量更高。
CMS(Concurrent Mark Sweep):以低停顿为目标,老年代回收分四阶段:
- 初始标记(STW):标记 GC Roots 直接引用的对象,速度快。
- 并发标记:与用户线程并发,从 GC Roots 可达性分析,标记所有存活对象。
- 重新标记(STW):修正并发标记期间用户线程导致的标记变化。
- 并发清除:与用户线程并发,清除垃圾对象。
CMS 的痛点:用标记-清除算法产生内存碎片(解决:定期 Full GC 做整理);并发阶段占用 CPU 影响吞吐;「Concurrent Mode Failure」晋升失败会退化为 Serial Old 全堆 STW。JDK 9 起被标记废弃,JDK 14 移除。
G1(Garbage First):JDK 9 起默认回收器。把堆划分为多个大小相等的 Region(默认 2048 个),每个 Region 可动态属于 Eden、Survivor、Old 或 Humongous。回收时优先选择垃圾最多的 Region(Garbage First 的由来),用停顿预测模型满足 -XX:MaxGCPauseMillis 设定的目标。
ZGC:JDK 11 引入,停顿时间 < 10ms 且不随堆大小增长。核心技术是染色指针(在 64 位指针的高位嵌入标记信息)和读屏障,实现并发标记、并发转移、并发重定位。适合 TB 级堆的金融、大数据场景。
Shenandoah:Red Hat 开发,与 ZGC 类似的低停顿目标,用 Brooks 转发指针实现并发整理。JDK 12 引入。
选型建议:
- 堆 < 4GB、追求低延迟:CMS(旧版本)或 G1。
- 堆 4GB~32GB、平衡吞吐和延迟:G1(当前主流)。
- 堆 > 32GB、极致低延迟:ZGC。
- 后台计算、吞吐优先:Parallel Scavenge + Parallel Old。
12. G1 回收器和 CMS 相比有什么优势
G1 是 CMS 的继任者,解决了 CMS 的几个根本缺陷,同时引入了更精细的控制能力。
一、内存模型差异
CMS 仍是传统分代:连续的年轻代和老年代。G1 把堆划分为 2048 个左右等大的 Region(1MB~32MB),每个 Region 可以动态切换角色。这不是简单的分区,而是逻辑分代物理不连续——老年代 Region 可以散落在堆的任意位置。
带来的好处:回收时可以只回收部分 Region(称为 GC Region 集合,CSet),而不是整个老年代。G1 通过计算每个 Region 的「回收价值」(垃圾占比 / 预计耗时),优先回收价值高的,在有限停顿时间内最大化回收量。
二、碎片问题
CMS 用标记-清除算法,长时间运行后老年代碎片严重,分配大对象时找不到连续空间,触发 Full GC。G1 的回收基于复制/整理:把存活对象从一个 Region 复制到另一个空 Region,原 Region 整体清空。这天然消除碎片,长时间运行也不会因为碎片触发 Full GC。
三、停顿可预测
CMS 的停顿时间难以预测,取决于对象存活率和碎片程度,极端情况晋升失败会退化为 Serial Old 的秒级 STW。G1 通过停顿预测模型,用户设定 -XX:MaxGCPauseMillis=200(默认 200ms),G1 会评估在 200ms 内能回收多少 Region,动态调整 CSet。这是「尽力而为」的软目标,但大多数情况能命中。
四、回收类型
- CMS:Minor GC(年轻代)+ Major GC(老年代,并发)+ Full GC(全堆 STW,兜底)。
- G1:Young GC(年轻代 Region)+ Mixed GC(年轻代 + 部分老年代 Region)+ Full GC(兜底,应避免)。
G1 的 Mixed GC 是核心特色:在一次回收中同时回收年轻代和部分老年代 Region,分摊老年代回收压力,避免老年代满才触发 Full GC。
五、Humongous 对象处理
大对象(超过 Region 一半)在 CMS 中直接进老年代,可能快速填满老年代触发 Full GC。G1 有专门的 Humongous Region 存放大对象,且在并发标记阶段就可以回收不再引用的 Humongous 对象,回收更及时。
六、劣势与代价
G1 并非全胜:
- 内存开销更大:每个 Region 需要 Remembered Set 记录跨 Region 引用,约占堆 5%~20%。
- 写屏障开销:维护 Remembered Set 的写屏障比 CMS 重。
- 小堆场景(< 4GB)性能可能不如 Parallel Scavenge,因为 G1 的元数据开销占比更大。
选型对照:
| 维度 | CMS | G1 |
|---|---|---|
| 内存布局 | 连续分代 | Region 分区 |
| 老年代算法 | 标记-清除(有碎片) | 复制/整理(无碎片) |
| 停顿预测 | 不可控 | 可设定目标,软可控 |
| 大对象 | 进老年代,易触发 Full GC | 专门 Humongous Region |
| 适用堆大小 | < 8GB | 4GB~32GB |
| 调参复杂度 | 参数多,需手动调 | 相对少,自适应性强 |
工程结论:JDK 8 升级到 11+ 时,从 CMS 切到 G1 是标配,通常开箱即用无需复杂调参。
三、Redis
13. Redis 的过期策略
Redis 对设置了过期时间的 key,采用「定期删除 + 惰性删除」组合策略,并配合内存淘汰策略兜底。
一、惰性删除
key 过期时不立即删除,而是等到下次访问时检查是否过期,过期则删除。实现简单,CPU 友好(不主动扫描),但问题是过期 key 若长期不被访问会一直占用内存,造成内存泄漏。
二、定期删除
弥补惰性删除的不足。Redis 默认每秒 10 次(由 hz 参数控制)从设置了过期时间的 key 中随机抽取一部分检查,删除已过期的。算法流程:
- 从过期字典中随机抽取 20 个 key。
- 删除其中已过期的。
- 如果过期比例超过 25%,重复步骤 1(说明过期 key 多,继续清理)。
- 每轮执行有时间限制,避免阻塞。
这是概率性清理,不保证所有过期 key 都被及时删除,但能控制过期 key 的总体积。
三、内存淘汰策略(兜底)
当内存达到 maxmemory 限制时,即使没过期的 key 也可能被淘汰。Redis 提供 8 种策略:
| 策略 | 范围 | 算法 |
|---|---|---|
noeviction |
- | 不淘汰,写入直接报错 |
volatile-lru |
设过期的 | 近似 LRU |
volatile-lfu |
设过期的 | LFU(频率优先) |
volatile-ttl |
设过期的 | 优先淘汰 TTL 最短的 |
volatile-random |
设过期的 | 随机 |
allkeys-lru |
全部 | 近似 LRU |
allkeys-lfu |
全部 | LFU |
allkeys-random |
全部 | 随机 |
LRU 的近似实现:Redis 不维护全局链表(开销大),而是在淘汰时随机采样 N 个 key(maxmemory-samples,默认 5),从中选最久未使用的淘汰。采样数越大越接近真实 LRU,但 CPU 开销越高。
LFU(Redis 4.0+):按访问频率淘汰,用「频率计数 + 衰减」实现。新 key 计数为 5,访问时按对数增长,长期不访问会衰减。比 LRU 更抗「扫描污染」——偶尔被访问一次的老数据不会挤掉高频热点。
真实场景选型:
- 缓存场景(数据可从 DB 恢复):
allkeys-lru或allkeys-lfu,最大化缓存命中率。 - 混合场景(部分 key 是持久数据):
volatile-lru,只淘汰设过期的,保护不过期的 key。 - 不能丢数据:
noeviction,但要做好写入限流。
过期 key 的删除时机细节:主从模式下,从节点不会主动删除过期 key,而是等主节点删除后发 DEL 命令同步。这是为什么读从节点可能读到已过期但未删除的 key(Redis 4.0 后从节点对读请求会做过期判断返回 nil,但不实际删除)。
14. Redis 缓存和数据库双写一致性问题怎么解决
缓存和数据库是两个独立存储,无法做事务原子操作,必然存在不一致窗口。工程上的目标不是「强一致」,而是「最终一致 + 控制不一致窗口」。
一、四种基础策略及其问题
策略 A:先更新数据库,再更新缓存
问题:并发下 A、B 两个写请求,A 先更新 DB、B 后更新 DB,但 B 先更新缓存、A 后更新缓存,缓存里是 A 的旧值,DB 是 B 的新值,不一致。且缓存可能被频繁重算浪费。不推荐。
策略 B:先更新缓存,再更新数据库
问题:缓存更新成功但 DB 更新失败,缓存是新值 DB 是旧值,且 DB 是数据源更不可丢。不推荐。
策略 C:先删除缓存,再更新数据库
问题:删除缓存后、更新 DB 完成前,另一个读请求发现缓存 miss,从 DB 读到旧值写入缓存,DB 更新后缓存还是旧值,不一致。
策略 D:先更新数据库,再删除缓存(Cache Aside)
问题:读请求读到 DB 新值准备写缓存前,缓存刚好被删(极端时序),写回旧值。但这种情况概率极低(要满足「读 DB 比写 DB 慢」等苛刻条件),是工业界主流方案。
Cache Aside 是推荐方案:读先查缓存,miss 则查 DB 并回写;写先更新 DB 再删缓存。
二、Cache Aside 的增强:延迟双删
针对策略 C 的不一致,加一次延迟删除:
1. 删除缓存
2. 更新数据库
3. 休眠 N 毫秒(覆盖读请求回写旧值的时间)
4. 再次删除缓存
第二次删除清掉读请求可能写入的旧值。N 的取值需根据业务读耗时评估,一般 500ms~1s。缺点是写请求阻塞 N 毫秒,可用异步线程做第二次删除。
三、消息队列 + 重试保证删除成功
「更新 DB 后删缓存」若失败(如 Redis 故障),缓存一直是旧值。解法是把删除任务投递到 MQ,消费失败重试,直到成功。
更新 DB → 删缓存(失败)→ 投递删除消息到 MQ → 消费者重试删除
四、订阅 binlog 异步删除(最可靠)
用 Canal 监听 MySQL binlog,把变更事件投递到 MQ,消费者收到后删除对应缓存。优点是 DB 与缓存解耦,业务代码不关心缓存;可靠性高,binlog 是 DB 的真实变更记录。
应用 → 写 DB
DB binlog → Canal → MQ → 消费者 → 删缓存
五、强一致场景的兜底
对一致性要求极高(如金融账户),上述方案仍有一致性窗口,需要:
- 加分布式读写锁:写时持锁,读时等锁释放,但牺牲并发。
- 缓存设短 TTL:即使不一致,过期后自动回源,限制不一致窗口。
- 串行化:写请求串行处理,读请求读 DB 时加版本号校验。
方案选型对照:
| 方案 | 一致性 | 复杂度 | 适用场景 |
|---|---|---|---|
| Cache Aside | 最终一致 | 低 | 大多数缓存场景 |
| 延迟双删 | 较高 | 中 | 写后立即读的高频场景 |
| MQ 重试删除 | 高 | 中 | 删除可能失败的场景 |
| Canal + binlog | 高 | 高 | 大型系统、多缓存源 |
| 分布式锁 | 强 | 高 | 金融、账户等强一致场景 |
工程实践:电商商品缓存用「Cache Aside + 短 TTL + Canal 兜底」组合,既保证日常一致性,又有 binlog 兜底防漏删。
四、消息队列
15. MQ 用过吗?消息重复消费怎么处理
消息重复是 MQ 的固有特性,不是 bug。原因包括:消费者处理完但 ack 失败、网络抖动导致 broker 重投、生产者重试、消费端宕机重启后重消费。核心解法是幂等性——同一条消息消费多次和消费一次效果相同。
幂等性实现方案:
一、数据库唯一约束
利用主键或唯一索引防重。消息带业务唯一 ID(如订单号),消费时插入数据库,重复插入会触发唯一约束冲突,捕获后视为已处理。
try {
orderDao.insert(order); // order_id 有唯一索引
} catch (DuplicateKeyException e) {
log.info("Duplicate message, order already exists: {}", order.getId());
return; // 幂等,正常 ack
}
适合写入型操作,简单可靠。前提是业务有天然唯一键。
二、Redis 去重(SETNX)
消费前用 SETNX message_id 标记已处理,设过期时间防内存膨胀。
Boolean isNew = redis.setIfAbsent("msg:" + msgId, "1", 24, TimeUnit.HOURS);
if (!isNew) {
return; // 已处理
}
// 处理业务
适合非落库操作(如发短信、调外部接口)。注意 Redis 与业务操作不是原子的,若 Redis 标记成功但业务失败,消息会丢失。改进:先处理业务再标记,但又有重复处理风险。更严谨的用 Redis + DB 组合或分布式事务。
三、状态机校验
业务有状态流转时,消费前校验当前状态是否允许该操作。如订单状态「待支付→已支付→已发货」,重复的「支付」消息在订单已是「已支付」时直接跳过。
Order order = orderDao.selectById(orderId);
if (order.getStatus() >= OrderStatus.PAID.getCode()) {
return; // 已支付,幂等跳过
}
orderDao.updateStatus(orderId, OrderStatus.PAID);
四、乐观锁(版本号)
更新操作带版本号,重复更新因版本不匹配而失效。
UPDATE account SET balance = balance + 100, version = version + 1
WHERE id = 1 AND version = 5;
五、Token 机制
生产端先申请一次性 token,消费端校验 token 有效性后处理并失效 token。适合主动防重场景,但流程较重。
方案选型:
| 方案 | 适用场景 | 优缺点 |
|---|---|---|
| 唯一约束 | 插入型操作,有业务唯一键 | 简单可靠,依赖 DB |
| Redis SETNX | 非落库操作、外部调用 | 高性能,需处理原子性 |
| 状态机 | 有状态流转的业务 | 业务语义清晰,需状态设计 |
| 乐观锁 | 更新型操作 | 防并发更新,需版本字段 |
| Token | 主动防重 | 流程重,适合高价值操作 |
工程原则:
- 消费端永远假设消息会重复,设计时就把幂等当默认要求。
- 优先用数据库唯一约束或状态机,这是业务自带的防重能力。
- Redis 去重要设合理过期时间,避免 key 无限增长。
- 记录消费日志,便于排查重复消费原因。
16. RocketMQ 是怎么保证消息顺序的
消息顺序分两个层级:全局顺序和分区顺序。全局顺序要求整个 Topic 所有消息严格按发送顺序消费,只能用单队列单线程,吞吐极低,几乎不用。实际工程用的是分区顺序(也叫局部顺序)——同一业务 key 的消息按顺序消费,不同 key 间可并行。
RocketMQ 的顺序保证贯穿「生产 → 存储 → 消费」三个环节。
一、生产端:按 key 路由到同一队列
RocketMQ 一个 Topic 有多个 MessageQueue(默认 4 个)。生产者用 MessageQueueSelector 根据业务 key 选择队列,相同 key 的消息总是进同一队列。
producer.send(msg, new MessageQueueSelector() {
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
String orderId = (String) arg;
int index = Math.abs(orderId.hashCode()) % mqs.size();
return mqs.get(index);
}
}, orderId);
这样「订单 123 的创建、支付、发货」三条消息都进同一队列,队列内天然有序(FIFO 存储)。
二、存储端:队列内顺序持久化
Broker 收到消息后按接收顺序写入 CommitLog,再异步构建 ConsumeQueue 索引。ConsumeQueue 是逻辑队列,按 offset 递增,保证同一队列内消息的存储顺序与发送顺序一致。
三、消费端:队列级锁 + 单线程消费
这是顺序消费的关键难点。RocketMQ 的 MessageListenerOrderly 保证:
- 分布式锁:消费者先通过 Broker 获取 MessageQueue 的分布式锁(基于文件锁 + 心跳续约),保证同一队列同一时刻只有一个消费者实例消费。
- 本地锁:消费者实例内对每个队列加锁(
ProcessQueue的lockConsume),保证同一队列单线程消费。 - 消费失败阻塞重试:消费失败不跳过(不像并发消费跳过当前消息),而是不断重试直到成功或达到最大重试次数,避免后序消息先消费。
消费者实例A 消费者实例B
│ │
├─ 获取 Queue0 锁(成功) ├─ 获取 Queue0 锁(失败) → 转去 Queue1
├─ 单线程顺序消费 Queue0 ├─ 单线程顺序消费 Queue1
└─ 消息1→消息2→消息3 └─ 消息A→消息B→消息C
顺序消费的代价与限制:
- 吞吐降低:单队列单线程,并发度 = 队列数。要提升吞吐只能增加队列数。
- 阻塞风险:一条消息消费卡住会阻塞整个队列后续消息。必须设合理的消费超时和重试上限。
- 不能并行扩缩容:队列数固定后,消费者实例数超过队列数时多出的实例空闲。
- Broker 故障切换可能破坏顺序:主从切换时未同步的消息可能丢失或乱序,需配合同步刷盘和同步主从。
典型场景:订单状态流转(创建→支付→发货→完成)、数据库 binlog 同步(保证 DML 顺序)、金融交易流水。这些场景下「同一实体的操作有序」比「全量高吞吐」更重要。
并发消费 vs 顺序消费的选择:能用并发消费就别用顺序消费。顺序消费是「为了正确性牺牲吞吐」的最后手段,仅在有严格业务依赖顺序时使用。
17. 如果 RocketMQ 出现消息大量积压,你怎么排查和处理
消息积压是生产环境的高频故障,表现为消费滞后(消费 offset 远落后于生产 offset)。处理思路是「先止血、再排查、后优化」。
一、快速止血(先恢复)
手段 1:紧急扩容消费者
最直接的方法。但要注意:Topic 的队列数固定,消费者实例数超过队列数无意义。若队列数 = 8,扩到 8 个消费者实例就到上限。
手段 2:临时 Topic 转发(队列数不够时)
当消费者实例数已达队列数上限仍积压,说明单队列处理速度跟不上。解法是创建一个临时 Topic(队列数设为原 Topic 的 N 倍),用一个「转发消费者」从原 Topic 读消息、转发到临时 Topic,再用大量消费者消费临时 Topic。
原Topic(8队列) → 转发消费者 → 临时Topic(80队列) → 80个消费者快速消费
转发消费者只做搬运不做业务,速度极快,等于把积压消息「摊薄」到更多队列上并行处理。积压清完后下线临时 Topic。
手段 3:跳过非关键消息
若积压的是时效性强的消息(如秒杀通知),积压几小时后再消费已无意义,可直接重置消费 offset 跳过积压段,从最新位置开始消费。
二、排查根因(找到瓶颈)
积压本质是「生产速度 > 消费速度」,排查消费慢的原因:
检查 1:消费者自身处理慢
- 看消费 TPS(RocketMQ Dashboard 有 consumer TPS 指标)。
- 若 TPS 低,看消费者线程栈是否卡在某个调用(如 DB 慢查询、外部接口超时)。
- 单条消息处理耗时是否异常上升(如从 50ms 涨到 2s)。
检查 2:下游依赖瓶颈
- 数据库:慢查询、连接池满、锁等待。
- 外部接口:超时、限流、熔断。
- Redis:大 key、热 key。
常见根因:消费者调用 DB,DB 连接池配 20,消费者线程配 64,大量线程等连接导致处理变慢。
检查 3:消费失败导致重试堆积
消息消费失败会进入重试队列 %RETRY%group,默认重试 16 次,每次间隔递增(10s、30s、1m…2h)。大量失败消息重试会占满消费线程。看死信队列 %DLQ%group 的消息量,判断是否失败重试导致。
检查 4:生产端突增
排查是否有异常的大流量写入(如批量任务、重试风暴、上游 bug 导致重复发送)。
三、长期优化(防复发)
- 消费者异步化:把同步调用改异步(如收到消息后投递到本地线程池),快速 ack 释放消费线程。注意线程池满的背压。
- 批量消费:
MessageListenerConcurrently一次拉取多条批量处理,减少单条开销。 - 水平扩容预案:预设 Topic 队列数足够(如 16~32),留出扩容空间。
- 监控告警:对消费滞后(lag)设阈值告警,在积压恶化前介入。
- 降级预案:积压超阈值自动降级(如跳过日志类消息、关闭非核心消费)。
排查流程总结:
发现积压(告警)
├─ 看 Dashboard:哪个 Topic/Consumer 积压?TPS 多少?
├─ 紧急扩容消费者到队列数上限 → 仍积压?
│ ├─ 是 → 转发到临时 Topic 扩容
│ └─ 否 → 持续观察
├─ jstack 看消费者线程在干什么
│ ├─ 卡在 DB → 查慢查询、扩连接池
│ ├─ 卡在外部接口 → 查超时、加熔断
│ └─ CPU 高 → 查死循环、GC
├─ 看死信队列 → 失败重试导致?
└─ 积压清完后:复盘 + 优化消费者 + 加监控
五、MySQL
18. MySQL 事务的隔离级别、MVCC 了解哪些
四个隔离级别
SQL 标准定义四个隔离级别,按隔离强度递增:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 |
|---|---|---|---|---|
| 读未提交(Read Uncommitted) | 可能 | 可能 | 可能 | 无 |
| 读已提交(Read Committed) | 避免 | 可能 | 可能 | MVCC,每次读新视图 |
| 可重复读(Repeatable Read) | 避免 | 避免 | 可能 | MVCC,事务开始时视图 |
| 串行化(Serializable) | 避免 | 避免 | 避免 | 加锁 |
- 脏读:读到其他事务未提交的数据。
- 不可重复读:同一事务内两次读同一行,结果不同(其他事务修改并提交了)。
- 幻读:同一事务内两次范围查询,结果集行数不同(其他事务插入并提交了)。
MySQL InnoDB 默认是可重复读,且在 RR 级别下通过 next-key lock 基本解决了幻读(标准定义 RR 会有幻读,但 InnoDB 做了增强)。
MVCC(多版本并发控制)
MVCC 是 InnoDB 实现高并发读写的核心机制。核心思想:读不加锁、写生成新版本,通过版本链让不同事务看到不同版本的数据,实现「读写不冲突」。
三大组件:
一、隐藏字段
每行数据有三个隐藏字段:
DB_TRX_ID(6 字节):最后一次修改该行的事务 ID。DB_ROLL_PTR(7 字节):回滚指针,指向 undo log 中该行的上一版本。DB_ROW_ID(6 字节):隐含主键(无主键时生成)。
二、undo log 版本链
每次更新一行,旧值写入 undo log,通过 DB_ROLL_PTR 串成链表。这条链就是该行的版本历史:
当前行(trx_id=300) → undo log(trx_id=200) → undo log(trx_id=100) → null
三、ReadView(读视图)
事务执行 SELECT 时生成的一致性快照。包含四个关键字段:
m_ids:生成 ReadView 时所有未提交事务的 ID 列表。min_trx_id:m_ids 中的最小值。max_trx_id:下一个将要分配的事务 ID(当前最大事务 ID + 1)。creator_trx_id:创建该 ReadView 的事务 ID。
可见性判断规则(对版本链上每个版本的 trx_id 判断):
若 trx_id == creator_trx_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 不在 m_ids 中:已提交,可见
RC 和 RR 的差异就在 ReadView 生成时机:
- RC:每次 SELECT 都生成新 ReadView。所以能看到其他事务提交的最新数据,导致不可重复读。
- RR:事务第一次 SELECT 时生成 ReadView,整个事务复用。所以多次读结果一致,实现可重复读。
MVCC 解决的问题与局限:
- 解决了脏读、不可重复读(RC 解决脏读,RR 解决不可重复读)。
- 普通快照读下,RR 仍有幻读(因为 ReadView 不阻挡新行被读到,新插入的行 trx_id 可能 >= max_trx_id 而不可见,但当前读会看到)。InnoDB 用 next-key lock(记录锁 + 间隙锁)在当前读时防幻读。
- MVCC 只对快照读(普通 SELECT)生效。当前读(
SELECT ... FOR UPDATE、UPDATE、DELETE)仍加锁,走两阶段锁协议。
真实场景对照:
- 账户余额查询用快照读,不阻塞其他事务的转账写入,靠 MVCC 保证一致性。
- 转账操作用
UPDATE balance SET ... WHERE id = ?(当前读 + 行锁),保证扣款原子性。 - 高并发下单扣库存,若用快照读判断库存再扣减会有超卖,必须用
SELECT ... FOR UPDATE当前读加锁,或乐观锁版本号。
六、Spring
19. Spring 里 Bean 的生命周期能讲一下吗?循环依赖会报错么
Bean 的生命周期
Spring Bean 从创建到销毁经历一系列阶段,核心流程可分为四大阶段:
一、实例化
调用构造方法或工厂方法创建对象,此时只是个「毛坯房」,属性都是默认值。这一步对应 BeanPostProcessor 的 postProcessBeforeInstantiation(实例化前)和 postProcessAfterInstantiation(实例化后、属性填充前)。
二、属性赋值
通过 setter 或反射注入依赖(@Autowired、@Value)。这一步会触发循环依赖的检测与处理。对应 InstantiationAwareBeanPostProcessor 的 postProcessProperties。
三、初始化
1. BeanNameAware.setBeanName()
2. BeanClassLoaderAware.setBeanClassLoader()
3. BeanFactoryAware.setBeanFactory()
── 以上是 Aware 接口回调 ──
4. BeanPostProcessor.postProcessBeforeInitialization()
5. @PostConstruct 标注的方法
6. InitializingBean.afterPropertiesSet()
7. 自定义 init-method
8. BeanPostProcessor.postProcessAfterInitialization()
── AOP 代理就在这一步生成 ──
BeanPostProcessor 是 Spring 扩展的灵魂,AOP、@Configuration 的 CGLIB 代理都在这里介入。postProcessAfterInitialization 返回的对象可能不是原始 Bean 而是代理对象。
四、使用与销毁
Bean 单例存入容器,被业务代码使用。容器关闭时执行销毁:
1. @PreDestroy 标注的方法
2. DisposableBean.destroy()
3. 自定义 destroy-method
完整流程图:
实例化 → 属性赋值 → Aware回调 → BeanPostProcessor前置 → 初始化方法 → BeanPostProcessor后置(代理) → 使用 → 销毁
循环依赖问题
循环依赖指 A 依赖 B、B 依赖 A。Spring 用三级缓存解决单例 Bean 的 setter 循环依赖。
三级缓存:
| 缓存 | 名称 | 内容 |
|---|---|---|
| 一级缓存 | singletonObjects | 完全初始化好的单例 Bean |
| 二级缓存 | earlySingletonObjects | 提前暴露的半成品 Bean(已实例化未初始化) |
| 三级缓存 | singletonFactories | ObjectFactory,能生成半成品或其代理 |
解决流程(A 依赖 B,B 依赖 A):
1. 创建 A:实例化 A,把 A 的 ObjectFactory 放入三级缓存
2. 填充 A 的属性:发现需要 B,去创建 B
3. 创建 B:实例化 B,把 B 的 ObjectFactory 放入三级缓存
4. 填充 B 的属性:发现需要 A
→ 查一级缓存:没有
→ 查二级缓存:没有
→ 查三级缓存:有 A 的 ObjectFactory,调用 getObject() 得到 A(若需代理则生成代理)
→ 把 A 放入二级缓存,移除三级缓存
→ B 拿到 A 的引用,完成属性填充
5. B 初始化完成,放入一级缓存
6. A 拿到 B,完成属性填充
7. A 初始化完成,放入一级缓存
为什么要三级而非两级?
核心是为了处理「AOP 代理」。若只有两级缓存,A 的代理对象需要在初始化后生成,但 B 此时需要的是 A 的引用——若 B 拿到的是原始 A,后续 A 变成代理对象,B 引用的还是原始对象,出错。三级缓存的 ObjectFactory 是个工厂,调用时才决定返回原始对象还是代理对象,且只生成一次(生成的对象放二级缓存,保证 B 和容器拿到的是同一个)。
哪些循环依赖会报错:
- 构造器循环依赖:直接报错。实例化阶段就需要依赖,此时还没放入三级缓存,无法提前暴露。
- 原型(prototype)作用域循环依赖:直接报错。原型每次都新建,无法用缓存,Spring 不处理。
@Async标注的 Bean 循环依赖:Spring 会报错。@Async通过AsyncAnnotationBeanPostProcessor生成代理,它的代理生成时机与三级缓存的提前暴露不兼容。解法是改用@Lazy注入或抽取异步方法到独立 Bean。@Configuration类的循环依赖:因 CGLIB 代理时机不同,可能报错,需重构。
工程建议:循环依赖是设计缺陷的信号,即使 Spring 能解决也应避免。重构方式:抽取公共逻辑到第三个 Bean、用事件解耦、改用 @Lazy 延迟加载。
七、算法
20. 给定整数数组和目标值,找出和为目标值的两个整数下标
这是 LeetCode 经典的「两数之和」(Two Sum)。
题目:给定一个整数数组 nums 和一个整数目标值 target,在数组中找出和为目标值的两个整数,返回它们的下标。假设每个输入只对应一个答案,且同一元素不能重复使用。
解法一:暴力枚举(O(n²))
两层循环遍历所有数对,找到和为 target 的返回。
public int[] twoSum(int[] nums, int target) {
for (int i = 0; i < nums.length; i++) {
for (int j = i + 1; j < nums.length; j++) {
if (nums[i] + nums[j] == target) {
return new int[]{i, j};
}
}
}
return new int[0];
}
时间复杂度 O(n²),空间 O(1)。简单直观,但数据量大时慢。
解法二:哈希表一次遍历(O(n),推荐)
核心思想:对每个元素 nums[i],我们要找的是 complement = target - nums[i]。用哈希表记录已遍历的「值→下标」,每次查表看 complement 是否已存在。
public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < nums.length; i++) {
int complement = target - nums[i];
if (map.containsKey(complement)) {
return new int[]{map.get(complement), i};
}
map.put(nums[i], i);
}
return new int[0];
}
时间复杂度 O(n),空间 O(n)。用空间换时间,是最优解。
为什么不能先全部放入 Map 再遍历?
可以先放再查,但要处理「数组中有重复元素且其中一个就是答案」的情况。例如 nums = [3, 3], target = 6,若先全放 Map,第二个 3 会覆盖第一个的下标,查询时找不到。一次遍历边查边放,天然避免覆盖问题,因为查的时候当前元素还没放进去。
解法三:排序 + 双指针(O(n log n))
若要返回值而非下标,或数组已有序,可用双指针。排序后左右指针向中间逼近:
public int[] twoSumSorted(int[] nums, int target) {
Arrays.sort(nums); // 若需保留下标需额外处理
int left = 0, right = nums.length - 1;
while (left < right) {
int sum = nums[left] + nums[right];
if (sum == target) {
return new int[]{left, right};
} else if (sum < target) {
left++;
} else {
right--;
}
}
return new int[0];
}
排序会打乱下标,若要返回原始下标需额外记录。适合「有序数组」或「返回值」的变体。
变体扩展:
- 三数之和:固定一个数,剩下两个用双指针,O(n²)。
- 四数之和:两层循环 + 双指针,O(n³)。
- 若数组有序:直接双指针,O(n)。
- 若要返回所有满足条件的数对:双指针找到后不返回、继续逼近,注意去重。
面试要点:
- 先说暴力解展示思路,再优化到哈希表解。
- 主动分析时空复杂度,说明哈希表用空间换时间的权衡。
- 提及边界:空数组、无解、重复元素、负数。
- 若面试官追问「数组有序」,主动给出双指针解。