阿里云-Java后端技术基础面试题深度解析

Cosolar 8 阅读 后端求职面试

覆盖并发编程、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 解决不了这类问题,工程上有四类方案:

方案一:加锁(悲观策略)

synchronizedReentrantLock 把复合操作包成临界区,串行化执行。简单可靠,但并发度受限。

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 通过分段累加更高效。

方案三:并发容器

ConcurrentHashMapcomputeIfAbsent 把「判断是否存在→不存在则写入」封装成原子操作,内部用分段锁/CAS + synchronized 实现。

ConcurrentHashMap<String, Object> cache = new ConcurrentHashMap<>();
cache.computeIfAbsent("key", k -> expensiveQuery(k));

AtomicReference 适合对引用对象做原子的条件更新。

方案四:不可变对象 + ThreadLocal

不可变对象天生线程安全,通过 final 字段配合防御性拷贝实现。ThreadLocal 让每个线程持有变量的独立副本,从根源上消除共享。

选型建议:简单计数用 AtomicInteger;Map 操作用 ConcurrentHashMap 的原子方法;复杂业务逻辑用 synchronizedReentrantLock;能设计成不可变就不可变。原则是优先用更高层、更细粒度的工具,避免一把大锁锁全局。

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 是否变化,未变则直接用,变了再升级为悲观读锁。读多写少且能容忍偶尔回退时性能优于读写锁。注意它不可重入,且不要在 tryOptimisticReadvalidate 之间做耗时操作,否则 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(notFullnotEmpty),生产者唤醒消费者时不会误唤醒其他生产者。

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 同步工具的基石框架,ReentrantLockSemaphoreCountDownLatchReentrantReadWriteLock 都基于它实现。理解 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 sizeactive countreject 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):以低停顿为目标,老年代回收分四阶段:

  1. 初始标记(STW):标记 GC Roots 直接引用的对象,速度快。
  2. 并发标记:与用户线程并发,从 GC Roots 可达性分析,标记所有存活对象。
  3. 重新标记(STW):修正并发标记期间用户线程导致的标记变化。
  4. 并发清除:与用户线程并发,清除垃圾对象。

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 中随机抽取一部分检查,删除已过期的。算法流程:

  1. 从过期字典中随机抽取 20 个 key。
  2. 删除其中已过期的。
  3. 如果过期比例超过 25%,重复步骤 1(说明过期 key 多,继续清理)。
  4. 每轮执行有时间限制,避免阻塞。

这是概率性清理,不保证所有过期 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-lruallkeys-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 保证:

  1. 分布式锁:消费者先通过 Broker 获取 MessageQueue 的分布式锁(基于文件锁 + 心跳续约),保证同一队列同一时刻只有一个消费者实例消费。
  2. 本地锁:消费者实例内对每个队列加锁(ProcessQueuelockConsume),保证同一队列单线程消费。
  3. 消费失败阻塞重试:消费失败不跳过(不像并发消费跳过当前消息),而是不断重试直到成功或达到最大重试次数,避免后序消息先消费。
消费者实例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 UPDATEUPDATEDELETE)仍加锁,走两阶段锁协议。

真实场景对照

  • 账户余额查询用快照读,不阻塞其他事务的转账写入,靠 MVCC 保证一致性。
  • 转账操作用 UPDATE balance SET ... WHERE id = ?(当前读 + 行锁),保证扣款原子性。
  • 高并发下单扣库存,若用快照读判断库存再扣减会有超卖,必须用 SELECT ... FOR UPDATE 当前读加锁,或乐观锁版本号。

六、Spring

19. Spring 里 Bean 的生命周期能讲一下吗?循环依赖会报错么

Bean 的生命周期

Spring Bean 从创建到销毁经历一系列阶段,核心流程可分为四大阶段:

一、实例化

调用构造方法或工厂方法创建对象,此时只是个「毛坯房」,属性都是默认值。这一步对应 BeanPostProcessorpostProcessBeforeInstantiation(实例化前)和 postProcessAfterInstantiation(实例化后、属性填充前)。

二、属性赋值

通过 setter 或反射注入依赖(@Autowired@Value)。这一步会触发循环依赖的检测与处理。对应 InstantiationAwareBeanPostProcessorpostProcessProperties

三、初始化

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)。
  • 若要返回所有满足条件的数对:双指针找到后不返回、继续逼近,注意去重。

面试要点

  • 先说暴力解展示思路,再优化到哈希表解。
  • 主动分析时空复杂度,说明哈希表用空间换时间的权衡。
  • 提及边界:空数组、无解、重复元素、负数。
  • 若面试官追问「数组有序」,主动给出双指针解。