知识全景图/ Java 生态全景图/ Java 后端面试深度指南

Java 后端面试深度指南

侧重深度硬核原理与实战场景题 —— 每个知识点包含详细解释、代码示例、面试追问与常见避坑 (2025-2026)

初级 (1-2 年) 中级 (2-5 年) 高级 (5-8 年) 架构师 (8+ 年)
定位: 本页不是入门教程速查表,而是一份面向中高级 Java 后端工程师的深度面试准备文档。每个问题都从「为什么」出发,不只回答「是什么」。配合 Java 生态全景图 使用效果更佳。

使用方式: 建议按模块逐个深入,不要只背结论。面试考的是你理解的深度和表达的能力,不是你记住了多少标准答案。
目录导航
一、JVM 底层与性能调优
高级 请详细描述 Java 对象的内存布局,以及对象头在锁升级中的作用

对象内存布局三部分:

  1. 对象头 (Header): 包含 Mark Word (存储运行时数据: hashCode、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 等) 和 Klass Pointer (指向类元数据的指针,压缩后 4 字节)。如果是数组,还有数组长度。
  2. 实例数据 (Instance Data): 对象真正存储的有效信息 — 各字段的内容,包括从父类继承的字段。
  3. 对齐填充 (Padding): JVM 要求对象大小必须是 8 字节的整数倍,不足则填充。

Mark Word 与锁升级:

  • 无锁态: Mark Word 存储 hashCode + GC 年龄
  • 偏向锁: Mark Word 存储线程 ID,同一个线程反复进入同步块无需 CAS
  • 轻量级锁: Mark Word 指向栈帧中的 Lock Record,通过 CAS 竞争
  • 重量级锁: Mark Word 指向 Monitor 对象 (ObjectMonitor),线程阻塞进入等待队列

锁升级是单向的 (偏向 → 轻量 → 重量),且不可降级。但有例外: 批量重偏向和批量撤销是 JVM 的全局优化策略。

// 锁升级过程示意 (HotSpot 源码层面) // 1. 偏向锁: 第一次进入同步块时,CAS 将线程 ID 写入 Mark Word // 成功 → 后续该线程进入无需任何同步操作 // 2. 轻量级锁: 另一个线程尝试获取时,偏向锁撤销 // 在当前线程栈帧中创建 Lock Record,CAS 将 Mark Word 替换为指向 Lock Record 的指针 // 3. 重量级锁: 自旋失败 (超过自适应自旋阈值) // 调用操作系统互斥量,线程进入 BLOCKED 状态 // JOL (Java Object Layout) 可以打印对象内存布局 import org.openjdk.jol.vm.VM; String layout = VM.current().of(new Object()); System.out.println(layout); // 输出: OFFSET | SIZE | TYPE DESCRIPTION | VALUE // 0 | 8 | Object Header | ... // 8 | 0 | loss | ...
面试追问:
- 偏向锁在 Java 15 之后默认关闭了,为什么?(高并发场景下偏向锁撤销的开销大于收益)
- 对象年龄达到多少岁进入老年代?默认 15,CMS 下默认 6。可以通过 -XX:MaxTenuringThreshold 调整。
- new Object() 在 JVM 中占多少内存?64 位 JVM 开启指针压缩: Mark Word(8B) + Klass Pointer(4B) + Padding(4B) = 16B
高级 G1 和 ZGC 的核心区别是什么?什么场景下应该选择 ZGC?

G1 (Garbage-First) 核心设计:

  • 堆被划分为大小相等的 Region (1-32MB),每个 Region 可以是 Eden/Survivor/Old/Humongous
  • 维护优先列表,回收收益最大的 Region (Garbage-First 名称由来)
  • 使用 写前屏障 (Write Barrier) 维护 RSet (Remember Set),记录跨 Region 引用
  • Young GC 是并行的,Mixed GC (同时回收 Young + 部分 Old Region) 是并发标记 + 并行回收
  • 停顿时间可预测: -XX:MaxGCPauseMillis=200 (默认 200ms,但不保证每次都达到)

ZGC (Z Garbage Collector) 核心设计:

  • 使用 着色指针 (Colored Pointers) — 在 64 位指针的高位存储 GC 元数据 (marked0/marked1/remapped/finalizable)
  • 使用 读屏障 (Load Barrier) — 在对象引用被加载时检查指针颜色,如果颜色不对则修正 (自愈)
  • 整个 GC 周期 (标记/转移/重定位) 都是并发的,STW 只在初始标记和最终标记的极短阶段
  • 停顿时间 < 1ms,且 不随堆大小增长而增长,支持 TB 级堆

选型建议:

维度G1ZGC
堆大小4-64GB 最佳8GB-16TB
停顿目标几十到 200ms< 1ms
吞吐量高 (屏障开销小)略低 (读屏障有 1-3% 开销)
适合场景通用后端服务延迟敏感交易、大数据内存分析、实时计算
面试追问:
- ZGC 的读屏障对性能影响有多大?实测大约 1-3% 吞吐量损失,但换来亚毫秒延迟。
- 为什么 G1 有 Humongous Region?对象超过 Region 大小的一半时直接在老年代分配大对象,可能导致 Full GC。
- 实际项目怎么监控 GC?使用 GC 日志 + Prometheus + Grafana (Micrometer 暴露 JVM 指标)
架构 线上服务 CPU 飙到 100%,请描述你的完整排查流程

排查流程 (按优先级):

  1. 定位进程: top -c 找到 CPU 最高的 Java 进程 PID
  2. 定位线程: top -Hp <PID> 找到 CPU 最高的线程 TID,printf '%x\n' <TID> 转为十六进制
  3. 线程堆栈: jstack <PID> | grep <HEX_TID> -A 30 查看该线程在做什么
  4. 确认原因分支:
    • GC 引起: jstat -gcutil <PID> 1000 5 看 FGC 频率和 O 元空间使用率。如果是频繁 Full GC → dump 堆分析 jmap -histo:live <PID>
    • 业务线程死循环: 多次 jstack 同一个线程,如果栈帧不变且在执行业务代码 → 代码死循环
    • 线程数爆炸: jstack <PID> | grep 'java.lang.Thread.State' | wc -l 查看线程总数。线程过多 → 检查线程池配置或是否有泄漏
  5. 在线诊断 (Arthas):
    • dashboard — 实时查看线程、内存、GC
    • thread -n 3 — 显示 CPU 最高的 3 个线程
    • profiler start / profiler stop — 生成火焰图
# 一键排查脚本 (生产环境常用) echo "=== Top CPU Threads ===" PID=$(jps -l | grep 'your-app' | awk '{print $1}') for tid in $(top -Hp $PID -b -n 1 | awk 'NR>7 {print $1}' | head -5); do hex=$(printf '%x\n' $tid) echo "--- Thread $tid (0x$hex) ---" jstack $PID | grep "0x$hex" -A 20 done
避坑:
- 不要在容器里直接用 jstack/jmap,可能看不到进程。使用 docker exec 或进入容器网络命名空间。
- jmap -dump 可能导致应用暂停,线上使用 arthas heapdump 更安全。
- CPU 100% 不一定是代码问题,可能是 GC、JIT 编译、类加载等 JVM 自身行为。
高级 类加载机制: 双亲委派模型是什么?它被打破的场景有哪些?

双亲委派模型: 当类加载器收到加载请求时,先委托给父加载器处理。只有父加载器无法加载时,子加载器才自己加载。

类加载器层次: Bootstrap CL (rt.jar) → Extension CL (ext 目录) → Application CL (classpath) → 自定义 CL

为什么需要双亲委派? 保证核心类 (如 java.lang.Object) 只被加载一次,避免用户自定义的同名类篡改核心类。

打破双亲委派的场景:

  1. Tomcat: 每个 Web 应用有自己的 WebAppClassLoader,优先加载 WEB-INF/classes 和 WEB-INF/lib 下的类,打破了先委托父加载器的规则。原因是不同 Web 应用可能依赖同一个库的不同版本。
  2. SPI 机制 (如 JDBC): 接口在 rt.jar (Bootstrap CL 加载),实现类在 classpath (Application CL 加载)。Bootstrap CL 无法看到 classpath 的类,所以引入线程上下文类加载器 (Thread Context ClassLoader) 来"逆向"委派。
  3. OSGi: 模块化框架,每个 Bundle 有自己的类加载器,形成网状委派关系而非树状。
  4. 热部署/热加载: 运行时重新加载类,必须创建新的 ClassLoader 实例 (同一个 ClassLoader 不能重复加载同一个类)。
面试追问:
- 自己写一个 java.lang.String 类能覆盖核心类吗?不能,因为 Bootstrap CL 会先检查安全包名。
- Spring 的类加载有什么特殊处理?Spring 使用 ClassPathXmlApplicationContext 时,如果存在多个 ClassLoader (如 Tomcat 环境),会根据场景选择合适的 ClassLoader。
- Java 9 模块系统 (JPMS) 对类加载有什么影响?模块有明确的导出/依赖声明,替代了 classpath 的扁平结构。
高级 JIT 编译器做了哪些优化?逃逸分析是怎么回事?

JIT (Just-In-Time) 编译: 将热点代码 (被频繁执行的字节码) 编译为本地机器码,提升运行速度。

热点探测: 方法调用计数器 + 回边计数器。超过阈值 (-XX:CompileThreshold,C1 默认 1500,C2 默认 10000) 触发编译。

分层编译 (Tiered Compilation):

  • 第 0 层: 解释执行
  • 第 1 层 (C1): 简单编译,快速启动
  • 第 2-3 层 (C1 + Profiling): 编译 + 收集性能数据
  • 第 4 层 (C2): 深度优化编译,运行最快但编译耗时

核心优化手段:

  1. 逃逸分析 (Escape Analysis): 分析对象的作用域是否逃出方法/线程。如果未逃逸 → 栈上分配 (不进入堆)、标量替换 (拆解为基本类型)、消除同步锁。
  2. 方法内联 (Inlining): 将被调用方法体直接嵌入调用处,消除方法调用开销。
  3. 循环优化: 循环展开、循环不变量外提。
  4. 分支预测优化: 根据 Profiling 数据优化分支。
// 逃逸分析示例 public void process() { // 这个 Point 对象不会逃出方法,JIT 可能做标量替换 // 实际上不会在堆上分配,而是把 x, y 直接用寄存器 Point p = new Point(1, 2); System.out.println(p.x + p.y); } // 对比: 逃逸的情况 private Point cachedPoint; public void processAndCache() { Point p = new Point(1, 2); // 赋值给成员变量 → 逃逸 this.cachedPoint = p; // JIT 不能做栈上分配 }
二、并发编程深度
高级 synchronized 的底层实现原理是什么?和 ReentrantLock 的本质区别?

synchronized 底层实现:

  • 同步代码块: 使用 monitorentermonitorexit 字节码指令。monitorenter 尝试获取对象的 Monitor (ObjectMonitor),计数器 +1;monitorexit 计数器 -1,为 0 时释放。
  • 同步方法: 方法访问标志中设置 ACC_SYNCHRONIZED 标志位,JVM 调用方法时自动检查。
  • JVM 层: ObjectMonitor 有 EntryList (阻塞等待队列) 和 WaitSet (wait 等待队列),线程阻塞时调用 OS 的 park/unpark,涉及用户态到内核态切换。

synchronized vs ReentrantLock 本质区别:

维度synchronizedReentrantLock
实现层JVM 层 (字节码指令 + Monitor)Java API 层 (AQS + CAS + LockSupport)
锁获取不可中断可中断 (lockInterruptibly)
超时获取不支持tryLock(timeout) 支持
公平性非公平可选公平/非公平
条件变量一个 (wait/notify)多个 (Condition)
释放锁自动 (退出同步块)手动 (finally 中 unlock)
锁绑定多个条件不支持支持 (newCondition)
// ReentrantLock 核心原理: AQS (AbstractQueuedSynchronizer) // 1. state = 0 → 无锁 // 2. CAS(state, 0, 1) 成功 → 获取锁,记录独占线程 // 3. CAS 失败 → 加入 CLH 等待队列,LockSupport.park() 挂起 // 4. 可重入: 同一线程再次获取 → state++ (可多次) // 5. 释放: state-- → state=0 时唤醒队列中下一个线程 // 公平锁: 获取前检查 CLH 队列是否有等待线程 // 非公平锁: 直接 CAS 抢锁,抢不到再入队 ReentrantLock lock = new ReentrantLock(true); // true = 公平锁 Condition notEmpty = lock.newCondition(); Condition notFull = lock.newCondition();
避坑:
- synchronized 在 JDK 6 之后已经做了大量优化 (偏向锁、轻量级锁、自旋锁),大多数场景性能不输 ReentrantLock。
- 不要因为"ReentrantLock 性能更好"就无脑替换 synchronized。除非你需要 tryLock、公平锁、多条件变量等高级特性。
- 使用 ReentrantLock 一定要在 finally 中 unlock,否则死锁。
高级 volatile 能保证线程安全吗?它的内存语义是什么?

volatile 能保证:

  • 可见性: 写操作后立即刷新到主内存,读操作直接从主内存读取。
  • 有序性: 禁止指令重排序。写操作前的所有写操作对其他线程可见;读操作后的所有读操作不会被重排到读之前。

volatile 不能保证原子性:

经典反例: volatile int count; count++ 不是线程安全的。因为 count++ 是 读取→+1→写回 三步操作,中间可能被其他线程打断。

内存屏障实现:

  • volatile 写前插入 StoreStore 屏障 → 确保前面的写已完成
  • volatile 写后插入 StoreLoad 屏障 → 确保写对后续读可见
  • volatile 读后插入 LoadLoad + LoadStore 屏障 → 确保读到的数据是最新的
// volatile 不保证原子性的经典例子 public class VolatileNotAtomic { private volatile int count = 0; public void increment() { count++; // 不是原子操作!字节码层面是: 读取 → +1 → 写回 } // 解决方案 1: AtomicInteger private AtomicInteger atomicCount = new AtomicInteger(0); public void safeIncrement() { atomicCount.incrementAndGet(); // CAS 保证原子性 } // 解决方案 2: synchronized // 解决方案 3: LongAdder (高并发场景更优) } // DCL (双重检查锁定) 单例: volatile 是必须的 public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { // 第一次检查 (无锁) synchronized (Singleton.class) { if (instance == null) { // 第二次检查 (有锁) instance = new Singleton(); // volatile 防止指令重排 } } } return instance; } } // new Singleton() 不是原子操作: 分配内存 → 初始化 → 引用赋值 // 没有 volatile 时,可能重排为: 分配内存 → 引用赋值 → 初始化 // 其他线程可能拿到未初始化完成的对象
高级 ThreadLocal 原理与内存泄漏问题

核心原理:

  • 每个 Thread 对象内部有一个 ThreadLocalMap 字段 (threadLocals)
  • ThreadLocalMap 的 Key 是 ThreadLocal 对象的弱引用 (WeakReference),Value 是实际存储的值
  • threadLocal.set(value) → 获取当前线程的 ThreadLocalMap → 以 this (ThreadLocal 对象) 为 Key 存入 value

内存泄漏原因:

  1. ThreadLocal 对象被回收 (弱引用被 GC),Key 变成 null
  2. 但 Value 仍然被 ThreadLocalMap 强引用
  3. 如果线程不销毁 (线程池场景),Value 永远无法回收 → 内存泄漏

正确使用方式:

  • 使用完后必须在 finally 中调用 threadLocal.remove()
  • 线程池场景尤其重要 — 线程会被复用,ThreadLocalMap 会一直存在
// 正确的 ThreadLocal 使用模式 private static final ThreadLocal<UserContext> userHolder = new ThreadLocal<>(); public void handleRequest(Request req) { try { userHolder.set(new UserContext(req.getUserId())); doBusiness(); } finally { userHolder.remove(); // 必须清理! } } // 线程池场景的陷阱 ExecutorService pool = Executors.newFixedThreadPool(10); pool.submit(() -> { userHolder.set(ctx); // 如果这里抛异常,没有 remove → 下一个复用这个线程的任务 // 可能拿到上一个任务的 UserContext!这是严重的安全问题 userHolder.remove(); });
避坑:
- 线程池 + ThreadLocal 是 OOM 的高发组合,务必 remove。
- Spring 的 RequestContextHolder 内部就是 ThreadLocal,Tomcat 线程复用时会自动清理,但自定义的 ThreadLocal 没有。
- Java 21 Virtual Threads 场景下 ThreadLocal 可能消耗大量内存 (每个虚拟线程都有自己的副本),建议改用 Scoped Values (预览特性)。
高级 线程池的核心参数和最佳实践

ThreadPoolExecutor 七大核心参数:

  1. corePoolSize: 核心线程数,即使空闲也不回收 (除非设置 allowCoreThreadTimeOut)
  2. maximumPoolSize: 最大线程数,包含核心线程 + 临时线程
  3. keepAliveTime: 临时线程空闲超过此时间后回收
  4. unit: keepAliveTime 的单位
  5. workQueue: 任务等待队列 (ArrayBlockingQueue / LinkedBlockingQueue / SynchronousQueue / PriorityBlockingQueue)
  6. threadFactory: 线程工厂,建议自定义命名 (方便排查)
  7. handler: 拒绝策略 (AbortException / CallerRuns / Discard / DiscardOldest)

任务提交流程: corePool 满了 → 放入队列 → 队列满了 → 创建临时线程到 maxPool → maxPool 满了 → 拒绝策略

// 生产级线程池配置示例 ThreadPoolExecutor executor = new ThreadPoolExecutor( 8, // corePoolSize: CPU 密集型 = CPU 核数 16, // maxPoolSize: IO 密集型 = CPU 核数 * 2 (或更高) 60, TimeUnit.SECONDS, // 临时线程存活 60s new LinkedBlockingQueue<>(1000), // 有界队列,容量 1000 new ThreadFactoryBuilder() // Guava / 自定义命名 .setNameFormat("order-pool-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() // 调用者线程执行 ); // 为什么禁止 Executors.newFixedThreadPool()? // 因为内部使用 LinkedBlockingQueue (无界队列) // 任务堆积 → OOM // Executors.newCachedThreadPool() 同理 → maxPoolSize = Integer.MAX_VALUE
面试追问:
- 如何合理设置线程池大小?CPU 密集型: 核数 + 1;IO 密集型: 核数 * 2 或用公式 N * (1 + W/C) (W=等待时间, C=计算时间)。
- 拒绝策略怎么选?生产环境推荐自定义拒绝策略: 记录日志 + 持久化到 DB + 告警。CallerRunsPolicy 是退路不是策略。
- 线程池如何做监控?暴露活跃线程数、队列大小、已完成任务数、拒绝次数等指标到 Prometheus。
三、集合框架原理
高级 HashMap 的底层实现,为什么用红黑树?扩容机制是什么?

JDK 8 的 HashMap 结构: 数组 + 链表 + 红黑树

  1. 计算桶位置: index = (n - 1) & hash,其中 hash 是扰动后的值 (h ^ h >>> 16)
  2. 链表 → 红黑树: 同一桶中链表长度 ≥ 8 且数组长度 ≥ 64 时,链表转红黑树 (treeifyBin)
  3. 红黑树 → 链表: 扩容或删除节点后树节点数 ≤ 6 时退化回链表

为什么阈值是 8? 泊松分布计算,hash 良好的情况下链表长度达到 8 的概率约千万分之一。如果频繁触发树化,说明 hash 函数有问题。

扩容机制:

  • 触发条件: size > capacity * loadFactor (默认 12 = 16 * 0.75)
  • 新容量 = 旧容量 * 2 (始终保持 2 的幂次)
  • JDK 8 优化: 不需要重新计算 hash,用 hash & oldCap 判断节点在新数组中的位置 (原位置 or 原位置 + oldCap)
// JDK 8 扩容的核心优化 (resize 方法) // 假设 oldCap = 16 (二进制 10000) // 节点 A: hash = 15 (01111), index = 15 & 15 = 15 // 节点 B: hash = 31 (11111), index = 31 & 15 = 15 ← 同一个桶 // 扩容后 newCap = 32 // 节点 A: (hash & oldCap) = 15 & 16 = 0 → 新位置 = 15 (不变) // 节点 B: (hash & oldCap) = 31 & 16 = 16 → 新位置 = 15 + 16 = 31 // 避免了重新计算每个节点的 hash,提升扩容效率 // HashMap 死循环问题 (JDK 7 多线程并发扩容) // JDK 7 的 transfer 方法使用头插法 → 并发时链表可能形成环 → 死循环 // JDK 8 改为尾插法 + 高低位链表拆分 → 不会形成环 // 但 HashMap 仍然不是线程安全的!并发 put 可能丢失数据 // 线程安全替代: ConcurrentHashMap
面试追问:
- HashMap 的 key 可以是 null 吗?可以,null 的 hash 固定为 0,放在第 0 个桶。ConcurrentHashMap 不允许 null key/value。
- 为什么 ConcurrentHashMap 不允许 null?因为并发场景下 get 返回 null 有二义性: 是 key 不存在还是 value 就是 null?
- loadFactor 为什么是 0.75?时间和空间的折中。太小浪费空间,太大 hash 冲突多。
高级 ConcurrentHashMap 的并发安全是怎么实现的?JDK 7 和 8 有什么区别?

JDK 7: Segment 分段锁

  • 内部由 Segment[] 数组组成,每个 Segment 继承 ReentrantLock
  • 默认 16 个 Segment,每个 Segment 内部是一个 HashEntry 数组 + 链表
  • put 时只锁当前 Segment,不同 Segment 可以并发操作 → 并发度 = Segment 数量

JDK 8: CAS + synchronized + 细粒度锁

  • 取消 Segment,直接使用 Node[] 数组 (和 HashMap 类似)
  • put 流程:
    1. 计算 hash,定位到桶
    2. 桶为空 → CAS 写入 (无锁)
    3. 桶不为空 → synchronized 锁住头节点 (只锁一个桶)
    4. 如果正在扩容 → 协助迁移 (multi-threaded resize)
  • size 统计: 使用 baseCount + CounterCell[] 分散计数 (类似 LongAdder),避免 CAS 竞争
// ConcurrentHashMap put 核心流程 (JDK 8 简化) final V putVal(K key, V value) { int hash = spread(key.hashCode()); for (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; if (tab == null || (n = tab.length) == 0) tab = initTable(); // 懒初始化 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<>(hash, key, value))) break; // CAS 成功,无锁写入 } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); // 协助扩容 else { synchronized (f) { // 锁住头节点 // 遍历链表/树,插入或更新 } } } addCount(1L, binCount); // 分散计数 }
四、Spring 全家桶深度
高级 Spring IoC 容器启动流程 + Bean 生命周期完整描述

容器启动流程 (AnnotationConfigApplicationContext):

  1. 创建 BeanFactory (DefaultListableBeanFactory)
  2. 注册内置后处理器 (AutowiredAnnotationBeanPostProcessor、CommonAnnotationBeanPostProcessor 等)
  3. 扫描 @ComponentScan 指定包下的类 → 解析为 BeanDefinition → 注册到 BeanFactory
  4. 执行 BeanFactoryPostProcessor (如 PropertySourcesPlaceholderConfigurer 替换 ${...})
  5. 注册 BeanPostProcessor
  6. 实例化所有非懒加载的单例 Bean

Bean 生命周期完整链路:

  1. 实例化: 反射调用构造函数创建对象
  2. 属性填充: @Autowired / @Value 注入依赖
  3. Aware 回调: BeanNameAware / BeanFactoryAware / ApplicationContextAware
  4. BeanPostProcessor#before: postProcessBeforeInitialization (如 @PostConstruct)
  5. InitializingBean: afterPropertiesSet()
  6. 自定义 init-method
  7. BeanPostProcessor#after: postProcessAfterInitialization (AOP 代理在此生成)
  8. 使用中
  9. DisposableBean#destroy
  10. 自定义 destroy-method / @PreDestroy
// 生命周期验证代码 @Component public class LifecycleBean implements BeanNameAware, InitializingBean, DisposableBean { public LifecycleBean() { log.info("1. 构造函数"); } @Autowired public void setDependency(SomeDep dep) { log.info("2. 属性注入"); } @Override public void setBeanName(String name) { log.info("3. BeanNameAware: {}", name); } @PostConstruct public void postConstruct() { log.info("4. @PostConstruct"); } @Override public void afterPropertiesSet() { log.info("5. afterPropertiesSet"); } @PreDestroy public void preDestroy() { log.info("6. @PreDestroy"); } @Override public void destroy() { log.info("7. destroy"); } }
高级 Spring 如何解决循环依赖?三级缓存的作用是什么?

三级缓存:

缓存名称存储内容
一级singletonObjects完全初始化好的 Bean (成品)
二级earlySingletonObjects提前暴露的 Bean 引用 (半成品,可能被 AOP 代理)
三级singletonFactoriesBean 工厂 (ObjectFactory),调用 getObject() 时才决定返回原始对象还是代理对象

解决过程 (A 依赖 B,B 依赖 A):

  1. 创建 A → 实例化 A → 将 A 的 ObjectFactory 放入三级缓存
  2. 填充 A 的属性 → 发现需要 B → 去 getBean("B")
  3. 创建 B → 实例化 B → 将 B 的 ObjectFactory 放入三级缓存
  4. 填充 B 的属性 → 发现需要 A → getBean("A")
  5. getSingleton("A"): 一级没有 → 二级没有 → 三级有 → 调用 ObjectFactory.getObject() → 如果有 AOP 则返回代理对象 → 放入二级缓存
  6. B 拿到 A 的引用 → B 初始化完成 → 放入一级缓存
  7. A 拿到 B → A 初始化完成 → 放入一级缓存

为什么需要三级缓存而不是二级? 如果只有二级缓存,AOP 代理对象的创建时机会有问题。三级缓存的 ObjectFactory 延迟了代理的创建 — 只有在被其他 Bean 依赖时才提前创建代理,否则在初始化后正常创建。

避坑:
- 构造器注入的循环依赖无法解决 (因为实例化阶段就需要依赖,还没进入三级缓存)
- @Async 标注的 Bean 循环依赖会报错 (因为 @Async 的代理是通过 AsyncAnnotationBeanPostProcessor 创建的,它实现的是 SmartInstantiationAwareBeanPostProcessor,在某些场景下不支持提前暴露代理)
- Spring Boot 2.6 默认禁止循环依赖 (spring.main.allow-circular-references=false)
高级 Spring AOP 的实现原理?JDK 动态代理和 CGLIB 代理的区别?

JDK 动态代理:

  • 基于接口,目标类必须实现至少一个接口
  • 运行时生成实现接口的代理类 (Proxy.newProxyInstance)
  • InvocationHandler.invoke() 拦截所有方法调用

CGLIB 代理:

  • 基于继承,生成目标类的子类
  • 通过 MethodInterceptor.intercept() 拦截方法调用
  • 不能代理 final 类和 final 方法

Spring 的选择策略:

  • 目标类实现了接口 → 默认使用 JDK 代理 (Spring 5.x 之前)
  • 目标类没实现接口 → 使用 CGLIB
  • Spring Boot 2.x+ 默认使用 CGLIB (spring.aop.proxy-target-class=true)
// JDK 动态代理原理 Object proxy = Proxy.newProxyInstance( target.getClass().getClassLoader(), target.getClass().getInterfaces(), (Object proxyObj, Method method, Object[] args) -> { // 前置通知 Object result = method.invoke(target, args); // 反射调用原方法 // 后置通知 return result; } ); // CGLIB 代理原理 Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(target.getClass()); enhancer.setCallback((MethodInterceptor) (obj, method, args, proxy) -> { // 前置通知 Object result = proxy.invokeSuper(obj, args); // 调用父类方法 (FastClass 优化) // 后置通知 return result; }); Object proxy = enhancer.create();
面试追问:
- CGLIB 为什么比 JDK 代理快?CGLIB 使用 FastClass 机制 (为代理类和目标类各生成一个索引类),通过方法索引直接调用,避免了反射开销。
- 为什么 @Transactional 注解加在 private 方法上不生效?因为代理只能拦截 public/protected 方法,private 方法不会被代理拦截。
- 同一个类中方法 A 调用方法 B (B 有 @Async),B 的异步不生效?是的,因为是 this.B() 而不是 proxy.B()。解决方案: 注入自身代理 / AopContext.currentProxy()。
高级 Spring 事务传播机制与失效场景

7 种传播行为:

传播行为含义使用场景
REQUIRED (默认)有事务就加入,没有就新建大多数业务方法
REQUIRES_NEW总是新建事务,挂起当前事务独立日志/审计,不受外层回滚影响
NESTED有事务就嵌套 (Savepoint),没有就新建子操作可独立回滚
SUPPORTS有事务就加入,没有就非事务执行查询方法
NOT_SUPPORTED非事务执行,挂起当前事务不需要事务的操作
MANDATORY必须在事务中,否则抛异常被其他事务方法调用的内部方法
NEVER不能在事务中,否则抛异常不允许事务的操作

事务失效的常见场景:

  1. 自调用: 同一类中方法 A 调 B,B 的 @Transactional 不生效 (没走代理)
  2. 非 public 方法: @Transactional 只对 public 方法生效
  3. 异常被 catch: 方法内部 try-catch 吞掉异常,事务不知道要回滚
  4. 异常类型不对: 默认只对 RuntimeException 和 Error 回滚,checked exception 不回滚 (除非设置 rollbackFor)
  5. 数据库引擎不支持: MySQL 的 MyISAM 不支持事务,要用 InnoDB
  6. 传播行为设置为 NOT_SUPPORTED / NEVER
// 事务失效示例: 异常被 catch @Transactional public void transfer(String from, String to, BigDecimal amount) { accountMapper.deduct(from, amount); try { accountMapper.add(to, amount); } catch (Exception e) { log.error("转账失败", e); // 异常被吞掉了!事务不会回滚 → 钱扣了但没到账 } } // 正确做法 @Transactional(rollbackFor = Exception.class) public void transfer(String from, String to, BigDecimal amount) { accountMapper.deduct(from, amount); accountMapper.add(to, amount); // 异常自然抛出,事务回滚 }
五、分布式系统
高级 CAP 定理和 BASE 理论的关系?分布式系统中如何做取舍?

CAP 定理: 分布式系统最多同时满足三个中的两个:

  • C (Consistency): 所有节点看到的数据一致
  • A (Availability): 每个请求都能得到非错误响应
  • P (Partition Tolerance): 网络分区时系统继续工作

关键理解: 网络分区 (P) 是必然发生的,所以实际上只在 CP 和 AP 之间选择。

BASE 理论 (对 CAP 中 AP 的延伸):

  • BA (Basically Available): 基本可用 — 允许响应时间增加或功能降级
  • S (Soft State): 软状态 — 允许中间状态
  • E (Eventual Consistency): 最终一致性 — 经过一段时间后数据一致

实际选型:

场景选择例子
金融交易CPZooKeeper (牺牲可用性保证一致性)
电商库存CP + 补偿Seata AT 模式 (最终一致性)
社交动态APCassandra (牺牲一致性保证可用性)
配置中心CPNacos (Raft 协议选主)
缓存APRedis Cluster (异步复制,可能丢数据)
架构 分布式事务的解决方案对比: 2PC / TCC / Saga / 本地消息表
方案原理优点缺点适用场景
2PC (XA) 协调者 → Phase 1 (prepare) → Phase 2 (commit/rollback) 强一致性 同步阻塞、单点故障、性能差 传统数据库跨库事务
TCC Try (预留) → Confirm (确认) → Cancel (补偿) 无锁、性能好 业务侵入大 (每个操作要写三个方法)、空回滚/幂等问题 资金类、库存扣减
Saga 长事务拆为多个本地事务,每个本地事务有补偿操作 无锁、适合长流程 缺乏隔离性 (可能脏读)、补偿逻辑复杂 跨服务编排流程
本地消息表 业务操作 + 写本地消息表在同一事务 → 后台线程发送消息 实现简单、可靠 消息表会增长、需要定期清理 大多数业务场景首选
事务消息 Half 消息 → 本地事务 → Commit/Rollback (RocketMQ 原生支持) 无本地表、性能好 依赖 MQ 中间件 异步场景、解耦服务
// 本地消息表模式 (最常用的可靠消息方案) @Transactional public void createOrder(Order order) { // 1. 业务操作 orderMapper.insert(order); // 2. 写本地消息表 (和业务操作在同一个事务中) OutboxMessage msg = new OutboxMessage(); msg.setType("ORDER_CREATED"); msg.setPayload(JSON.toJSONString(order)); msg.setStatus("PENDING"); outboxMapper.insert(msg); // 和 order 的 insert 是同一个数据库事务 } // 3. 定时任务扫描消息表,发送到 MQ @Scheduled(fixedDelay = 5000) public void sendPendingMessages() { List<OutboxMessage> msgs = outboxMapper.selectByStatus("PENDING"); for (OutboxMessage msg : msgs) { mqProducer.send(msg.getTopic(), msg.getPayload()); outboxMapper.updateStatus(msg.getId(), "SENT"); } } // 消费端: 消费 MQ 消息,执行下游操作。消费失败 → MQ 重试 // 最终一致性: 业务操作一定成功 + 消息一定能发出 (重试) + 消费一定成功 (重试)
高级 分布式锁的实现方案对比
方案实现优点缺点
Redis (SETNX + 过期) SET key value NX EX 30 性能高、实现简单 锁续期问题、主从切换可能丢锁
Redis (Redlock) 向 N 个独立 Redis 实例获取锁,多数成功才算成功 更安全 性能差、Martin Kleppmann 认为不安全
Redisson 看门狗自动续期 + Lua 脚本保证原子性 生产可用、API 简单 依赖 Redis 可用性
ZooKeeper 创建临时顺序节点,最小节点获得锁 强一致、支持公平锁 性能低于 Redis
// Redisson 分布式锁 (推荐方案) RLock lock = redissonClient.getLock("order:123"); try { // waitTime: 等待获取锁的最大时间 // leaseTime: 持有锁的最大时间 (看门狗默认 30s) if (lock.tryLock(10, 30, TimeUnit.SECONDS)) { doBusiness(); } else { throw new BusinessException("获取锁失败"); } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } // Redisson 看门狗原理: // lock.tryLock 不传 leaseTime 时启用看门狗 // 后台线程每 10s (lockWatchdogTimeout/3) 检查锁是否还被持有 // 如果是 → 自动续期到 30s。客户端宕机 → 看门狗停 → 锁自动过期释放
避坑:
- Redis 主从切换可能导致锁丢失: 客户端在 master 获取锁 → master 宕机 → slave 还没同步锁 → 另一个客户端在 slave (新 master) 获取锁 → 两个客户端同时持有锁。
- 如果业务执行时间可能超过锁的过期时间,必须使用看门狗续期。
- 释放锁时要检查是否是自己持有的锁 (Redisson 的 isHeldByCurrentThread)。
六、数据库与存储
高级 MySQL InnoDB 的索引结构 (B+ 树) 为什么不用 B 树或红黑树?

B+ 树 vs B 树:

  1. IO 次数更少: B+ 树非叶子节点只存 Key 不存数据,同样大小的磁盘页能容纳更多 Key → 树更矮 → 磁盘 IO 更少。InnoDB 一个页 16KB,三层 B+ 树可以存约 2000 万条记录。
  2. 范围查询高效: B+ 树叶子节点通过双向链表连接,范围查询只需找到起始点然后顺序扫描链表。B 树需要中序遍历。
  3. 查询稳定: B+ 树所有数据都在叶子节点,每次查询路径长度相同。

B+ 树 vs 红黑树:

  • 红黑树是二叉树,高度远大于 B+ 树 (同样 2000 万条数据,红黑树高度约 24,B+ 树高度 3)
  • 更多的高度 = 更多的磁盘 IO = 性能灾难

聚簇索引 vs 非聚簇索引 (二级索引):

  • 聚簇索引: 叶子节点存储完整的行数据。一张表只能有一个 (通常是主键)。
  • 二级索引: 叶子节点存储主键值。查询非索引列时需要回表 (通过主键值去聚簇索引查找)。
  • 覆盖索引: 查询的所有列都在二级索引中 → 不需要回表 → 性能大幅提升。
// 覆盖索引示例 // 表: user (id PK, name, age, email) // 索引: idx_name_age (name, age) — 联合索引 // 需要回表 SELECT * FROM user WHERE name = '张三'; // 命中 idx_name_age → 找到主键 id → 回聚簇索引取整行 // 覆盖索引 (不需要回表) SELECT name, age FROM user WHERE name = '张三'; // 命中 idx_name_age → name 和 age 都在索引中 → 直接返回 // 最左前缀原则 // idx_name_age (name, age) 可以支持: // WHERE name = ? ✓ // WHERE name = ? AND age = ? ✓ // WHERE age = ? ✗ (跳过了 name) // WHERE name LIKE '张%' AND age > 20 ✓ (name 走索引, age 走索引下推)
面试追问:
- 什么是索引下推 (ICP, Index Condition Pushdown)? MySQL 5.6+ 优化,在存储层就根据索引条件过滤,减少回表次数。
- 联合索引 (a, b, c),WHERE a = 1 AND c = 3 能走索引吗?a 能走,c 不能直接走 (跳过了 b),但 MySQL 5.6+ 有 ICP 可以在索引层过滤 c。
- 为什么主键建议自增?非自增主键会导致 B+ 树频繁分裂和页分裂,写入性能下降。
高级 MySQL 事务隔离级别和 MVCC 实现原理

四种隔离级别:

隔离级别脏读不可重复读幻读InnoDB 实现
READ UNCOMMITTED可能可能可能无特殊处理
READ COMMITTED (RC)不会可能可能每次 SELECT 生成新 Read View
REPEATABLE READ (RR, 默认)不会不会可能首次 SELECT 生成 Read View + Gap Lock
SERIALIZABLE不会不会不会所有 SELECT 加共享锁

MVCC (Multi-Version Concurrency Control) 核心组件:

  1. 隐藏列: 每行有 DB_TRX_ID (最后修改的事务 ID) 和 DB_ROLL_PTR (回滚指针,指向 undo log)
  2. Undo Log 版本链: 每次修改都生成一条 undo log,通过 DB_ROLL_PTR 串成链表
  3. Read View: 记录当前活跃事务 ID 列表 (m_ids)、最小事务 ID (min_trx_id)、下一个事务 ID (max_trx_id)

可见性判断:

  • DB_TRX_ID < min_trx_id → 在 Read View 创建前已提交 → 可见
  • DB_TRX_ID ≥ max_trx_id → 在 Read View 创建后才开始 → 不可见
  • DB_TRX_ID 在 m_ids 中 → 在 Read View 创建时还未提交 → 不可见
  • DB_TRX_ID 不在 m_ids 中 → 在 Read View 创建时已提交 → 可见

RC vs RR 的区别: RC 每次 SELECT 创建新的 Read View → 能看到其他已提交事务的修改。RR 只在首次 SELECT 创建 Read View → 整个事务期间看到的数据一致。

高级 MySQL 慢查询优化流程

优化流程:

  1. 开启慢查询日志: slow_query_log=ON, long_query_time=1
  2. EXPLAIN 分析执行计划: 重点关注 type、key、rows、Extra
  3. type 从优到差: system > const > eq_ref > ref > range > index > ALL
  4. Extra 重要信息:
    • Using Index → 覆盖索引 (好)
    • Using Where → Server 层过滤 (需关注)
    • Using Temporary → 临时表 (需优化)
    • Using Filesort → 文件排序 (需优化)
  5. 优化策略:
// EXPLAIN 输出示例与分析 EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 'PAID'; // +----+-------+-------+---------+------+-----------------------+ // | id | type | key | key_len | rows | Extra | // +----+-------+-------+---------+------+-----------------------+ // | 1 | ALL | NULL | NULL | 100k | Using Where | // +----+-------+-------+---------+------+-----------------------+ // 问题: type=ALL (全表扫描), key=NULL (没走索引) // 优化 1: 添加联合索引 ALTER TABLE orders ADD INDEX idx_user_status (user_id, status); // 再次 EXPLAIN: type=ref, key=idx_user_status, rows=100 // 优化 2: 只查需要的列 (覆盖索引) SELECT id, user_id, status, amount FROM orders WHERE user_id = 100; // 如果 idx_user_status 包含 amount → Extra: Using Index (覆盖索引) // 优化 3: 深分页优化 // 慢: SELECT * FROM orders ORDER BY id LIMIT 1000000, 10; // 快: 延迟关联 SELECT o.* FROM orders o INNER JOIN (SELECT id FROM orders ORDER BY id LIMIT 1000000, 10) t ON o.id = t.id; // 子查询只走索引 (覆盖索引), 外层关联取完整数据
七、缓存架构
高级 缓存穿透、缓存击穿、缓存雪崩的区别和解决方案
问题描述解决方案
缓存穿透 查询不存在的数据,缓存没有,数据库也没有,每次请求都打到数据库 1. 布隆过滤器 (在缓存前加一层 Bloom Filter,不存在的 Key 直接拦截)
2. 缓存空值 (查询为空也缓存,设较短 TTL)
3. 参数校验 (非法请求直接拦截)
缓存击穿 热点 Key 过期的瞬间,大量并发请求同时打到数据库 1. 互斥锁 (只允许一个线程查数据库并回填缓存)
2. 逻辑过期 (不设 TTL,数据中加逻辑过期时间,过期后异步更新)
3. 热点 Key 永不过期 + 异步刷新
缓存雪崩 大量 Key 同时过期,或 Redis 宕机,所有请求打到数据库 1. 过期时间加随机值 (TTL = base + random)
2. Redis 集群 (主从 + 哨兵 / Cluster)
3. 多级缓存 (本地缓存 + Redis)
4. 限流降级 (兜底方案)
// 缓存击穿: 互斥锁方案 (Redisson 实现) public String getWithMutex(String key) { String value = redis.get(key); if (value == null) { RLock lock = redisson.getLock("lock:" + key); try { if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { // Double check value = redis.get(key); if (value == null) { value = db.query(key); redis.set(key, value, 30, TimeUnit.MINUTES); } } } finally { lock.unlock(); } } return value; } // 缓存一致性: Cache Aside Pattern (旁路缓存) // 读: 先读缓存 → 没有则读数据库 → 写入缓存 // 写: 先更新数据库 → 再删除缓存 // 为什么是删除而不是更新缓存? // 并发写: 写A → 写B → 缓存更新A → 缓存更新B (OK) // 但如果顺序是: 写A → 缓存更新A → 写B → 缓存更新B → 此时缓存是B (OK) // 问题: 写A → 写B → 缓存更新B → 缓存更新A → 缓存是旧值A! // 删除缓存就没有这个问题,下次读取时自然会加载最新值
八、消息队列
高级 如何保证消息不丢失?消息顺序性?消息幂等性?

1. 消息不丢失 (三个环节):

环节风险解决方案
生产者 → MQ网络故障、发送失败同步发送 + 重试 + 确认回调
MQ 存储MQ 宕机丢消息同步刷盘 + 主从复制
MQ → 消费者消费者处理失败手动 ACK (处理完再确认)

2. 消息顺序性:

  • 全局有序: 单 Partition/Queue (牺牲并发性能)
  • 局部有序: 相同业务 Key 的消息发到同一个 Partition (如相同订单 ID)
  • 消费者单线程消费同一 Partition (或用内存队列保证顺序)

3. 消息幂等性:

  • 数据库唯一键约束
  • Redis SETNX 去重
  • 状态机判断 (订单已支付 → 忽略重复支付消息)
  • 幂等 Token (请求带 token,服务端验证并删除)
// RocketMQ 事务消息示例 (保证生产端 + 本地事务 + 消息发送的原子性) public class OrderTransactionListener implements TransactionListener { @Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { try { orderService.createOrder((Order) arg); return LocalTransactionState.COMMIT_MESSAGE; } catch (Exception e) { return LocalTransactionState.ROLLBACK_MESSAGE; } } @Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { // 回查: 订单是否创建成功 String orderId = msg.getUserProperty("orderId"); return orderService.exists(orderId) ? LocalTransactionState.COMMIT_MESSAGE : LocalTransactionState.ROLLBACK_MESSAGE; } } // 幂等消费示例 (数据库唯一键) @RocketMQMessageListener(topic = "ORDER_PAY", consumerGroup = "pay-group") public class PayConsumer implements RocketMQListener<String> { @Override public void onMessage(String message) { PayEvent event = JSON.parseObject(message, PayEvent.class); try { payService.process(event); } catch (DuplicateKeyException e) { log.info("重复消费,忽略: {}", event.getOrderId()); } } }
九、设计模式与架构
中级 Spring 中用到了哪些设计模式?请结合源码说明
模式Spring 中的应用
工厂模式BeanFactory / ApplicationContext 创建和管理 Bean
单例模式Bean 默认 scope=singleton。注意: Spring 的单例是通过 ConcurrentHashMap 实现的 (注册式单例),不是经典的 private 构造器 + static
代理模式AOP 使用 JDK 动态代理或 CGLIB 代理。@Transactional、@Async、@Cacheable 都通过代理实现
模板方法JdbcTemplate、RestTemplate、RedisTemplate 封装了固定流程,暴露可扩展点
观察者模式ApplicationEvent + @EventListener。事件发布订阅机制。
策略模式Resource 接口 (ClassPathResource, FileSystemResource, UrlResource);HandlerMapping 选择不同的请求处理策略
适配器模式HandlerAdapter 适配不同的 Controller (注解式、实现接口式)
装饰器模式BeanWrapper、HttpRequestWrapper 对对象进行功能增强
责任链模式Filter Chain、Interceptor Chain、Security Filter Chain
高级 策略模式 + 工厂模式消除 if-else 的实战方案

场景: 支付系统根据不同支付类型走不同的支付逻辑。

// 反面教材: if-else 地狱 public PayResult pay(PayRequest request) { if ("ALIPAY".equals(request.getType())) { // 支付宝支付逻辑 } else if ("WECHAT".equals(request.getType())) { // 微信支付逻辑 } else if ("UNION".equals(request.getType())) { // 银联支付逻辑 } // 每增加一种支付方式,都要修改这个方法 → 违反开闭原则 } // 正确: 策略模式 + Spring 自动注入 public interface PayStrategy { String getType(); // 策略标识 PayResult pay(PayRequest request); } @Service public class AlipayStrategy implements PayStrategy { @Override public String getType() { return "ALIPAY"; } @Override public PayResult pay(PayRequest r) { /* ... */ } } @Service public class PayStrategyFactory { private final Map<String, PayStrategy> strategyMap; // Spring 自动注入所有 PayStrategy 实现 public PayStrategyFactory(List<PayStrategy> strategies) { this.strategyMap = strategies.stream() .collect(Collectors.toMap(PayStrategy::getType, s -> s)); } public PayStrategy getStrategy(String type) { PayStrategy strategy = strategyMap.get(type); if (strategy == null) throw new IllegalArgumentException("不支持的支付类型: " + type); return strategy; } } // 使用 payStrategyFactory.getStrategy(request.getType()).pay(request); // 新增支付方式: 只需新增一个 PayStrategy 实现类 → 自动注册 → 零修改
十、系统设计实战

场景题 1: 设计一个秒杀系统

难度: 高级/架构师 | 高并发 + 有限库存 + 强一致性

核心挑战: 瞬时高并发 (QPS 可能 10 万+)、库存不能超卖、防止机器人刷单

架构分层:

  1. 前端层: 静态资源 CDN + 按钮防抖 + 答题/验证码 + 请求随机丢弃 (保护后端)
  2. 网关层: 限流 (令牌桶) + 黑名单 + 用户级频率限制
  3. 服务层:
    • 库存预扣减: Redis DECR (原子操作) → 库存为 0 直接拒绝
    • 订单异步化: 通过 MQ 异步创建订单 → 用户轮询或 WebSocket 通知结果
    • 防止超卖: Redis + Lua 脚本保证原子性
  4. 数据库层: 乐观锁 UPDATE stock SET count = count - 1 WHERE id = ? AND count > 0
// Redis + Lua 库存扣减 (原子操作) String luaScript = """ if redis.call('get', KEYS[1]) <= tonumber(ARGV[1]) then return -1 -- 库存不足 end redis.call('decr', KEYS[1]) return 1 -- 扣减成功 """; Long result = redisTemplate.execute( new DefaultRedisScript<>(luaScript, Long.class), List.of("seckill:stock:" + productId), "1" ); if (result == 1) { mqProducer.send("SECKILL_ORDER", orderMsg); // 异步下单 }

场景题 2: 设计一个短链系统

难度: 中级/高级 | 高并发读 + 唯一性 + 低延迟

核心流程: 长链 → 唯一短码 → 存储映射 → 302 重定向

短码生成方案:

  1. 自增 ID + Base62: 数据库自增 → 转 62 进制 (a-zA-Z0-9)。简单但可预测。
  2. MurmurHash + 解决冲突: 哈希后检查是否存在,存在则加盐重试。不可预测。
  3. 预生成 + 发号器: 预先批量生成短码存入池中,使用时直接取。性能最高。

高并发读优化: 布隆过滤器判断短码存在 → 缓存映射关系 → 301/302 重定向。301 永久重定向可被浏览器缓存,但无法统计点击量;302 临时重定向每次都经过服务端,可统计。

场景题 3: 设计一个延迟任务系统

难度: 高级 | 订单超时取消、定时推送、延迟通知

方案对比:

方案精度吞吐复杂度适用
定时任务轮询 DB秒级简单场景、数据量小
DelayQueue (内存)毫秒单机、数据量可控
RocketMQ 延迟消息秒级 (固定级别)通用方案首选
Redis 过期通知秒级需要额外保证可靠投递
Redis ZSet + 轮询秒级灵活控制延迟时间
时间轮 (Netty HashedWheelTimer)毫秒单机高精度

Redis ZSet 方案 (生产常用):

  1. 添加任务: ZADD delay_queue
  2. 轮询消费: ZRANGEBYSCORE delay_queue 0 LIMIT 0 100
  3. 取出后: ZREM delay_queue (原子性用 Lua 保证)
  4. 多实例部署: 用分布式锁保证同一任务只被消费一次
十一、场景实战追问
架构 线上接口突然变慢,你的排查思路是什么?

分层排查 (从外到内):

  1. 网络层: 是否有网络抖动/丢包?ping / traceroute / CDN 日志
  2. 网关/负载均衡: Nginx access.log 看 upstream 响应时间;是否有限流触发
  3. 应用层:
    • APM 工具 (SkyWalking/Jaeger) 看链路耗时分布
    • Arthas trace 命令追踪方法耗时
    • GC 日志: 是否有长时间 STW
    • 线程池监控: 是否有线程池满导致排队
  4. 数据库层: 慢查询日志 + EXPLAIN → 锁等待 (show engine innodb status) → 连接池满
  5. 缓存层: Redis 延迟 (redis-cli --latency) → 缓存命中率下降 → 大 Key (redis-cli --bigkeys)
  6. 外部依赖: 第三方 API 超时 → 熔断是否生效 → 超时配置是否合理
// Arthas 追踪方法耗时 trace com.example.OrderService createOrder // 输出: // +---[200ms] createOrder() // +---[5ms] validate() // +---[180ms] saveOrder() ← 瓶颈在这里 // +---[170ms] db.insert() ← 数据库慢 // +---[10ms] redis.set() // 继续深入数据库 trace com.example.mapper.OrderMapper insert // 如果是锁等待 → 检查是否有长事务 // 如果是全表扫描 → 检查索引
高级 如何设计一个接口的限流方案?

限流算法对比:

算法原理优点缺点
固定窗口固定时间窗口内计数简单窗口边界可能 2x 突发流量
滑动窗口时间窗口滑动平滑内存开销大
漏桶恒定速率处理请求流量平滑无法应对突发流量
令牌桶恒定速率放入令牌,请求消耗令牌允许突发流量实现稍复杂

多级限流:

  1. 网关层: Nginx limit_req (IP 级) / Sentinel (集群级)
  2. 应用层: Guava RateLimiter / Sentinel (单机) / Redis + Lua (分布式)
  3. 下游保护: 线程池隔离 / 信号量隔离
// Sentinel 限流配置示例 @SentinelResource(value = "createOrder", blockHandler = "handleBlock") public OrderResult createOrder(OrderRequest req) { return orderService.create(req); } public OrderResult handleBlock(OrderRequest req, BlockException ex) { throw new RateLimitException("系统繁忙,请稍后重试"); } // Redis + Lua 分布式限流 (滑动窗口) String script = """ local key = KEYS[1] local limit = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) redis.call('zremrangebyscore', key, 0, now - window) local count = redis.call('zcard', key) if count < limit then redis.call('zadd', key, now, now .. ':' .. math.random()) redis.call('expire', key, window / 1000) return 1 end return 0 """;
十二、网络与协议
中级 TCP 三次握手和四次挥手的过程和原因

三次握手 (建立连接):

  1. 客户端 → SYN → 服务端: "我要连接了" (客户端进入 SYN_SENT)
  2. 服务端 → SYN+ACK → 客户端: "收到,我也要连接了" (服务端进入 SYN_RCVD)
  3. 客户端 → ACK → 服务端: "确认,开始传输" (双方进入 ESTABLISHED)

为什么是三次不是两次?

  • 防止已失效的连接请求到达服务端。假设只有两次: 客户端发了一个 SYN 但延迟了 → 客户端超时重发 SYN → 两次握手建立了连接并传完数据 → 延迟的旧 SYN 到达 → 服务端又建了一个无人使用的连接 (资源浪费)。
  • 三次握手让服务端知道客户端确实准备好了,确认双方的发送和接收能力都正常。

四次挥手 (断开连接):

  1. 主动方 → FIN → 被动方: "我没有数据要发了"
  2. 被动方 → ACK → 主动方: "收到" (半关闭状态,被动方可能还有数据要发)
  3. 被动方 → FIN → 主动方: "我也没有数据要发了"
  4. 主动方 → ACK → 被动方: "确认" (主动方进入 TIME_WAIT,等 2MSL 后关闭)

为什么是四次不是三次? 因为 TCP 是全双工的。FIN 只表示"我这边没有数据了",对方可能还有数据要继续发。所以 ACK 和 FIN 不能合并 (不像三次握手中 SYN+ACK 可以合并,因为那时服务端还没有建立连接)。

TIME_WAIT 的作用:

  • 确保最后一个 ACK 能到达被动方 (如果丢失,被动方会重发 FIN)
  • 等待网络中残留的延迟报文消失,避免新连接收到旧连接的数据
面试追问:
- 大量 TIME_WAIT 怎么处理?服务器端出现大量 TIME_WAIT 说明是主动关闭方。解决方案: 使用连接池 (长连接)、设置 SO_REUSEADDR、调整 tcp_tw_reuse 参数。
- SYN Flood 攻击怎么防御?SYN Cookie 技术 — 不立即分配资源,通过加密计算返回特殊 SYN+ACK,收到 ACK 后再分配连接。
- 为什么客户端最后要等 2MSL?1MSL 确保最后一个 ACK 到达被动方 + 1MSL 确保被动方重发的 FIN 消失。
中级 HTTP/1.1 vs HTTP/2 vs HTTP/3 的核心区别
特性HTTP/1.1HTTP/2HTTP/3
传输层TCPTCPQUIC (基于 UDP)
连接复用Keep-Alive (串行)多路复用 (并行)多路复用 (并行)
头部处理纯文本,每次都传HPACK 压缩QPACK 压缩
队头阻塞应用层 (一个请求阻塞后续)TCP 层 (一个包丢失阻塞所有流)无 (QUIC 独立流)
连接建立TCP 三次握手TCP 三次 + TLS 1.2 握手QUIC 合并握手 (0-RTT/1-RTT)
服务器推送不支持支持 (Server Push)支持
流量控制TCP 层TCP + HTTP/2 流级QUIC 连接级 + 流级

HTTP/2 多路复用: 在同一个 TCP 连接上并行传输多个请求/响应,通过帧 (Frame) 和流 (Stream) 标识属于哪个请求。解决了 HTTP/1.1 的应用层队头阻塞。

HTTP/2 的队头阻塞问题: 多路复用共享一条 TCP 连接,如果某个 TCP 包丢失,所有流都要等重传 → 这就是 TCP 层的队头阻塞。HTTP/3 用 QUIC 解决 — 每个流独立,一个流的包丢失不影响其他流。

面试追问:
- 为什么 HTTP/3 选 UDP 而不是新设计一个传输层协议?UDP 在网络设备中普遍支持,而新的传输层协议可能被中间设备 (防火墙/NAT) 丢弃。QUIC 在用户态实现了 TCP 的可靠性。
- 实际项目中怎么用 HTTP/2?浏览器已普遍支持。后端 Tomcat/Undertow 默认开启,Nginx 配置 listen 443 ssl http2
中级 HTTPS 的加密过程

HTTPS = HTTP + TLS (Transport Layer Security):

  1. TCP 三次握手: 建立连接
  2. TLS 握手 (以 TLS 1.2 RSA 为例):
    • Client Hello: 支持的 TLS 版本、加密套件列表、随机数 A
    • Server Hello: 选定的加密套件、服务器证书、随机数 B
    • 客户端验证证书: 检查 CA 签名、域名、有效期
    • 客户端生成预主密钥 (Pre-Master Secret),用服务器公钥加密发送
    • 双方用 随机数A + 随机数B + 预主密钥 → 计算出会话密钥 (对称密钥)
  3. 对称加密通信: 后续数据用会话密钥加密 (AES-GCM 等)

为什么不全用非对称加密? 非对称加密 (RSA) 比对称加密 (AES) 慢约 1000 倍。所以只用非对称加密交换密钥,实际数据传输用对称加密。

TLS 1.3 优化: 把握手从 2-RTT 减少到 1-RTT,支持 0-RTT 恢复。移除了不安全的加密套件 (RSA 密钥交换、CBC 模式等)。

避坑:
- TLS 1.3 不再支持 RSA 密钥交换,只支持 ECDHE (前向保密: 即使长期私钥泄露,历史会话也无法解密)。
- 证书链验证很重要 — 客户端要验证完整链: 服务器证书 → 中间 CA → 根 CA。
- HTTP Strict Transport Security (HSTS) 强制浏览器使用 HTTPS,防止降级攻击。
中级 Cookie、Session、Token 的区别与选型
维度Cookie + SessionToken (JWT)
存储位置Session 在服务端,SessionID 通过 Cookie 传给客户端Token 存客户端 (localStorage/Cookie)
服务端状态有状态 (需存储 Session)无状态 (不存储,通过签名验证)
扩展性差 (需要 Session 共享/粘性 Session)好 (天然支持分布式)
安全性CSRF 风险 (Cookie 自动携带)、Session 劫持XSS 风险 (Token 存 localStorage)、需手动设置 Header
注销服务端删 Session 即可JWT 无法主动失效 (需黑名单/短过期)
适用场景传统 Web 应用、SSR 页面前后端分离、移动端、微服务

JWT (JSON Web Token) 结构:

  • Header: {"alg": "HS256", "typ": "JWT"}
  • Payload: 用户信息 (sub, name, role, exp, iat)。不要放敏感信息 (密码等)。
  • Signature: HMACSHA256(base64(header) + "." + base64(payload), secret)
// Spring Security + JWT 实战 @Component public class JwtTokenProvider { @Value("${jwt.secret}") private String secret; @Value("${jwt.access-expiration:3600000}") private long accessExpiration; // 1 小时 @Value("${jwt.refresh-expiration:604800000}") private long refreshExpiration; // 7 天 public String createAccessToken(UserDetails user) { return Jwts.builder() .subject(user.getUsername()) .claim("roles", user.getAuthorities()) .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + accessExpiration)) .signWith(getSigningKey()) .compact(); } // 双 Token 模式: Access Token (短期) + Refresh Token (长期) // Access Token 过期 → 用 Refresh Token 换新的 Access Token // Refresh Token 过期 → 重新登录 // 好处: Access Token 被盗 → 很快过期; Refresh Token 被盗 → 可以在服务端主动撤销 }
十三、Redis 深度专题
高级 Redis 五种基础数据结构的底层实现
数据结构底层编码 (Redis 7)核心实现典型使用场景
String int / embstr / raw SDS (Simple Dynamic String): 带长度和空闲空间的 char[]。
int: 整数值直接存在 ptr 指针位置。
embstr: ≤44 字节,一次内存分配。
raw: >44 字节,两次内存分配。
缓存、计数器、分布式锁、Session
List listpack (旧版 quicklist/ziplist) 双向链表 + 每个节点是 listpack (紧凑连续内存)。兼顾链表的灵活和数组的内存连续性。 消息队列、最新列表、延迟队列
Hash listpack / hashtable 元素少时用 listpack (省内存),元素多时用 hashtable (dict,链地址法解决冲突)。 对象属性、购物车、用户信息
Set intset / listpack / hashtable 全是整数且元素少 → intset (有序整数数组)。
元素少 → listpack。
否则 → hashtable。
标签、共同好友、去重
ZSet (Sorted Set) listpack / skiplist + hashtable 元素少 → listpack。
元素多 → skiplist (O(logN) 查找) + hashtable (O(1) 查 member→score)。
跳表: 多层链表,每层是下层的子集。
排行榜、延迟队列、带权重的消息

跳表 (Skip List) 为什么被 Redis 选用而不是红黑树?

  • 实现简单,代码易维护
  • 范围查询效率高 (链表直接遍历),红黑树范围查询需要中序遍历 (更复杂)
  • 插入/删除只需修改相邻节点指针,红黑树可能需要旋转多级
  • 内存消耗可以通过调整概率参数控制 (每个节点平均 1.33 个指针)
面试追问:
- 为什么 String 用 SDS 而不是 C 字符串?SDS 有 len 字段 → O(1) 获取长度 (C 字符串要遍历);SDS 二进制安全 (不依赖 \0 结尾);SDS 有空间预分配 → 减少内存重分配次数。
- 什么时候 listpack 转 hashtable?元素个数超过 hash-max-listpack-entries (默认 128) 或单个元素超过 hash-max-listpack-value (默认 64 字节)。
- Redis 7 为什么用 listpack 替代 ziplist?ziplist 有级联更新问题 — 中间元素修改导致后续元素大小变化,引发连锁 realloc。listpack 解决了这个问题。
高级 Redis 持久化: RDB vs AOF vs 混合持久化
维度RDB (快照)AOF (追加日志)
原理定时将内存数据全量快照写入 .rdb 文件每条写命令追加到 .aof 文件
数据安全可能丢失两次快照之间的数据最多丢失 1 秒数据 (everysec 策略)
文件大小小 (二进制压缩)大 (文本命令)
恢复速度快 (直接加载二进制)慢 (重放所有命令)
对性能影响fork 子进程做快照,COW 机制每次写入都要刷盘 (影响取决于策略)
适用场景备份、灾难恢复、从库初始化数据安全要求高

AOF 三种刷盘策略:

  • always: 每条命令都 fsync → 最安全,性能最差
  • everysec (推荐): 每秒 fsync 一次 → 最多丢 1 秒数据,性能好
  • no: 由 OS 决定何时刷盘 → 性能最好,可能丢数据

混合持久化 (Redis 4.0+, 推荐): AOF 重写时前半段用 RDB 格式 (紧凑),后半段用 AOF 格式 (增量)。兼顾文件小和可读性。

AOF 重写: fork 子进程,遍历当前内存数据生成最简命令。比如对同一个 key 做 100 次 SET,重写后只保留最后一次。重写期间新命令写入 AOF 重写缓冲区,重写完成后追加到新文件。

避坑:
- RDB 的 fork() 在数据量大时可能耗时 (复制页表),期间 Redis 主线程被阻塞。如果数据集 10GB+,fork 可能卡住几百毫秒。
- AOF 重写也会 fork,同样的问题。配置 auto-aof-rewrite-percentageauto-aof-rewrite-min-size 控制重写频率。
- 生产环境推荐: 开启混合持久化 aof-use-rdb-preamble yes,同时保留 RDB 做冷备份。
高级 Redis 集群方案: 主从复制 vs 哨兵 vs Cluster

1. 主从复制:

  • 一个 Master + 多个 Slave,数据单向复制 (Master → Slave)
  • 全量同步: Master 生成 RDB 发给 Slave → Slave 加载 → 后续用 repl_backlog 做增量同步
  • 问题: Master 宕机需要手动切换

2. 哨兵 (Sentinel):

  • 在主从基础上增加哨兵进程,自动监控和故障转移
  • 主观下线: 单个哨兵认为 Master 不可用
  • 客观下线: 超过半数哨兵认为 Master 不可用 → 执行故障转移
  • 选举新 Master 的规则: slave-priority → 复制偏移量最大 → run_id 最小
  • 问题: 数据分片仍需客户端处理

3. Cluster (推荐):

  • 数据分片: 16384 个哈希槽 (hash slot),分配到不同节点。slot = CRC16(key) % 16384
  • 每个节点负责一部分槽,节点之间通过 Gossip 协议通信
  • 故障检测: PING/PONG → 标记 PFAIL → 超过半数 Master 确认 → 标记 FAIL → 从节点升主
  • 客户端访问: 发送命令到任意节点 → 如果 key 不在该节点 → 返回 MOVED 重定向
// Redis Cluster 路由示例 // 客户端向 Node A 发送: SET user:1001 "张三" // CRC16("user:1001") % 16384 = 5760 // Node A 不负责 slot 5760 → 返回 MOVED 5760 NodeB_IP:Port // 客户端重定向到 Node B 执行命令 // 智能客户端 (JedisCluster/Lettuce) 会缓存 slot→node 映射,减少重定向 // Hash Tag 机制: 让相关的 key 在同一个 slot // {user}:1001 和 {user}:1002 → 只对 {} 内的部分计算 hash // 这样可以用 MGET/MSET 批量操作,避免跨 slot MSET {user}:1001 "张三" {user}:1002 "李四"
面试追问:
- 为什么 Cluster 有 16384 个槽而不是更多?节点数通常不超过 1000,16384 个槽已足够均匀分布。更多槽会增加 Gossip 消息大小。
- Cluster 支持 multi-key 操作吗?只有同一 slot 的 key 支持 (用 Hash Tag)。跨 slot 的事务需要用 Lua 脚本 + Hash Tag。
- 数据迁移怎么做?redis-cli --cluster reshard,源节点逐个迁移 slot 中的 key,迁移期间客户端可能收到 ASK 重定向。
高级 BigKey 和 HotKey 的检测与处理

BigKey 问题:

  • 定义: String > 10KB 或 集合类型元素 > 5000 (取决于业务)
  • 危害: 网络传输慢、阻塞其他命令 (Redis 单线程)、AOF 重写/COW 内存翻倍、集群迁移卡顿
  • 检测: redis-cli --bigkeysMEMORY USAGE keySTRLEN/LLEN/HLEN/SCARD/ZCARD
  • 处理:
    1. String 类型: 压缩 (gzip/snappy) 后存储,或拆分
    2. 集合类型: 按业务维度拆分 (如 hash 按字段分组)
    3. 删除: 使用 UNLINK (异步删除,不阻塞主线程) 而非 DEL
    4. 大集合删除: hscan + hdel 分批删除

HotKey 问题:

  • 定义: 单个 key 被极高频率访问 (如热点新闻、秒杀商品)
  • 危害: 集群中某个节点负载过高 (数据倾斜)、打爆网卡
  • 检测: redis-cli --hotkeys (需要开启 maxmemory-policy 为 LFU)、MONITOR 命令 (仅调试)、业务层统计
  • 处理:
    1. 本地缓存 (Caffeine/Guava Cache) 作为一级缓存
    2. 读写分离: 热点 key 复制到多个从节点
    3. Key 打散: hotkey_1 ~ hotkey_N,客户端随机读取
十四、微服务深度
高级 服务注册发现: Nacos 的 AP/CP 模式切换原理

Nacos 同时支持 AP 和 CP:

  • 临时实例 (AP 模式,默认): 客户端主动向 Nacos Server 注册,通过心跳保活。心跳失败 → 剔除实例。使用 Distro 协议 (类似 Gossip) 做集群内数据同步。适合服务发现场景 (容忍短暂不一致)。
  • 持久实例 (CP 模式): Server 主动探测实例健康状态 (TCP/HTTP 探测)。使用 Raft 协议做集群内数据一致性。适合需要强一致的场景 (如数据库实例、配置信息)。

AP 模式下的注册流程:

  1. 服务启动 → 向 Nacos Server 发送注册请求 (POST /nacos/v1/ns/instance)
  2. Server 写入本地注册表 + 异步同步给集群其他节点
  3. 客户端每 5 秒发送心跳 → Server 超过 15 秒未收到 → 标记不健康 → 超过 30 秒 → 剔除
  4. 消费者订阅服务 → Server 推送变更 (UDP 长连接) + 消费者定时拉取 (兜底)

Eureka vs Nacos vs Consul:

维度EurekaNacosConsul
一致性APAP + CPCP (Raft)
健康检查客户端心跳心跳 + 服务端探测Agent 探测
配置中心不支持支持支持 (KV Store)
雪崩保护有 (自我保护)有 (阈值保护)
现状已停止维护国内主流海外常用
面试追问:
- Nacos 集群部署至少几台?推荐 3 台 (Raft 协议过半数选举)。AP 模式下 2 台也能工作但不推荐。
- 注册中心的可用性如何保证?Nacos 集群 + MySQL 持久化 + VIP/SLB 前置。客户端本地缓存注册表,Nacos 宕机时降级使用缓存。
- K8s 环境还需要 Nacos 吗?如果所有服务都在 K8s 内,可以用 Service 做服务发现。但如果混合部署 (部分服务在 K8s 外),Nacos 仍然有用。
高级 API 网关的核心功能与 Spring Cloud Gateway 原理

网关核心功能:

  1. 路由: 根据请求路径/Header/参数转发到后端服务
  2. 认证鉴权: 统一入口做 JWT 校验、OAuth2 校验
  3. 限流熔断: 令牌桶限流、Sentinel 集成
  4. 负载均衡: 集成 Spring Cloud LoadBalancer
  5. 协议转换: HTTP → gRPC、WebSocket 代理
  6. 灰度发布: 根据权重/Header 路由到不同版本
  7. 跨域/日志/监控: 统一处理 CORS、请求日志、指标采集

Spring Cloud Gateway 架构:

  • 基于 Spring WebFlux + Netty (非阻塞、响应式)
  • 核心概念: Route (路由) = Predicate (断言) + Filter (过滤器)
  • 请求处理流程: 请求 → Handler Mapping (匹配 Route) → Web Filter Chain → 代理转发 → 响应
  • Filter 链: Global Filter (全局) + Gateway Filter (路由级别),可做前置/后置处理
// Spring Cloud Gateway 配置示例 @Configuration public class GatewayConfig { @Bean public RouteLocator customRouteLocator(RouteLocatorBuilder builder) { return builder.routes() .route("order-service", r -> r .path("/api/orders/**") .filters(f -> f .stripPrefix(1) // 去掉 /api 前缀 .requestRateLimiter(c -> c // 限流 .setRedisRateLimiter(new RedisRateLimiter(100, 200))) .circuitBreaker(c -> c // 熔断 .setName("orderCircuitBreaker") .setFallbackUri("forward:/fallback"))) .uri("lb://order-service")) // 负载均衡 .build(); } } // 全局认证过滤器 @Component public class AuthGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String token = exchange.getRequest().getHeaders().getFirst("Authorization"); if (token == null || !jwtUtil.validate(token)) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } // 将用户信息传递给下游服务 exchange.getRequest().mutate().header("X-User-Id", jwtUtil.getUserId(token)).build(); return chain.filter(exchange); } @Override public int getOrder() { return -1; } }
高级 链路追踪原理: TraceId 如何在微服务间传递?

核心概念:

  • Trace: 一次完整请求链路,由一个全局唯一的 TraceId 标识
  • Span: 链路中的一个调用节点 (如一次 HTTP 请求、一次 DB 查询),有 SpanId 和 ParentSpanId

TraceId 传递机制:

  1. HTTP 调用: 通过 HTTP Header 传递。OpenTelemetry 标准使用 traceparent Header (格式: version-traceid-spanid-flags)。Spring Cloud Sleuth / Micrometer Tracing 自动在 RestTemplate/WebClient/Feign 中注入 Header。
  2. 消息队列: 通过消息属性 (Properties/Headers) 传递。Kafka 使用 Record Headers,RabbitMQ 使用 AMQP Headers。
  3. 线程池: 需要手动传递。使用 ContextExecutorService 包装线程池,或用 ThreadLocal 在任务提交时捕获上下文。

OpenTelemetry (OTel) 是当前标准:

  • 统一了 Traces + Metrics + Logs 三大支柱
  • SDK 自动埋点: Servlet、Spring MVC、JDBC、Redis、Kafka、gRPC...
  • 导出到 Jaeger/Zipkin/Tempo/Grafana 等 Backend
// 线程池中传递 TraceId 的正确方式 // 错误: TraceId 在子线程中丢失 executor.submit(() -> callServiceB()); // 新线程没有 TraceId // 正确: 使用 ContextExecutorService 包装 ExecutorService tracedExecutor = new ContextExecutorService(executor, context); // 或者手动传递 (不推荐,容易遗漏) String traceId = tracer.currentSpan().context().traceId(); executor.submit(() -> { try (Span span = tracer.nextSpan().name("async-task").start()) { callServiceB(); } }); // Virtual Threads 场景下: Scoped Values (预览) 可以替代 ThreadLocal // 自动传递上下文,不需要手动包装
高级 配置中心: 灰度发布与多环境管理

Nacos 配置管理核心能力:

  • 多环境: namespace 隔离环境 (dev/staging/prod),group 隔离项目,dataId 隔离配置文件
  • 灰度发布: Beta 发布 — 指定 IP 列表的客户端优先获取新配置,验证后全量发布
  • 配置监听: 客户端通过长轮询监听配置变更,变更后实时推送
  • 回滚: 保留历史版本,一键回滚

Apollo (携程) 的灰度策略:

  • 按 IP、按应用实例、按集群维度灰度
  • 灰度规则可动态调整
  • 权限管控: 配置修改需要审批
  • 发布审计: 谁在什么时候改了什么
// Spring Cloud Nacos 配置加载 // bootstrap.yml spring: application: name: order-service cloud: nacos: config: server-addr: nacos:8848 namespace: prod group: DEFAULT_GROUP file-extension: yaml shared-configs: // 共享配置 - data-id: common-db.yaml group: SHARED refresh: true // 支持热刷新 // 使用 @RefreshScope 或 @ConfigurationProperties // 配置变更后自动刷新 Bean @RestController @RefreshScope public class OrderController { @Value("${order.max-amount:10000}") private BigDecimal maxAmount; // 配置变更后自动刷新 }
十五、安全与认证
高级 OAuth 2.0 四种授权模式及适用场景
模式流程适用场景安全性
授权码模式 (Authorization Code) 用户 → 授权页 → 携带 code 回调 → 后端用 code 换 token 有后端的 Web 应用、第三方登录 最高 (code 一次性、token 不经过前端)
PKCE 增强 在授权码模式基础上增加 code_verifier 和 code_challenge 移动端、SPA、无后端的客户端 高 (防止授权码被截获)
客户端凭证模式 (Client Credentials) 直接用 client_id + client_secret 换 token 服务间调用、M2M (Machine to Machine) 中 (无用户参与)
设备码模式 (Device Code) 设备显示验证码 → 用户在另一设备上授权 IoT 设备、智能电视、CLI 工具

已废弃的模式: 隐式模式 (Implicit) — token 直接在前端获取,安全性差;密码模式 (Resource Owner Password Credentials) — 直接传用户名密码,违反最小权限原则。

OIDC (OpenID Connect): 在 OAuth 2.0 之上加了身份层。新增 id_token (JWT 格式),包含用户身份信息。大多数"OAuth 登录"实际上是 OIDC。

// Spring Authorization Server 配置 (Spring 官方 OAuth2 授权服务器) @Configuration public class AuthorizationServerConfig { @Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("web-app") .clientSecret("{noop}secret") // 生产环境用 {bcrypt} .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri("http://localhost:8080/login/oauth2/code/web-app") .scope("read").scope("write") .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofHours(1)) .refreshTokenTimeToLive(Duration.ofDays(30)) .reuseRefreshTokens(false)) // 每次刷新生成新 RT .build(); return new InMemoryRegisteredClientRepository(client); } }
中级 Spring Security 过滤器链的工作原理

核心架构: Spring Security 基于 Servlet Filter 链,核心是 FilterChainProxy,内部维护一个或多个 SecurityFilterChain

关键过滤器 (按顺序):

  1. SecurityContextFilter: 从 Session/Redis 加载 SecurityContext (认证信息)
  2. CorsFilter: 处理跨域请求
  3. CsrfFilter: CSRF 防护 (前后端分离通常关闭)
  4. LogoutFilter: 处理登出请求
  5. UsernamePasswordAuthenticationFilter: 处理表单登录 (POST /login)
  6. BearerTokenAuthenticationFilter: 处理 JWT Token 认证
  7. ExceptionTranslationFilter: 捕获认证/授权异常,返回 401/403
  8. AuthorizationFilter: 最终授权检查 (@PreAuthorize 在这里生效)

认证流程:

  1. 请求携带 Token → BearerTokenAuthenticationFilter 解析 JWT
  2. 解析成功 → 创建 Authentication 对象 → 存入 SecurityContextHolder
  3. 后续 Filter/Controller 通过 SecurityContextHolder 获取当前用户
// 自定义 JWT 认证过滤器 public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider tokenProvider; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) { String token = resolveToken(request); if (token != null && tokenProvider.validate(token)) { Authentication auth = tokenProvider.getAuthentication(token); SecurityContextHolder.getContext().setAuthentication(auth); } chain.doFilter(request, response); } } // SecurityConfig 中配置 @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) .addFilterBefore(new JwtAuthenticationFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class); return http.build(); }
中级 SQL 注入、XSS、CSRF 的原理和防御
攻击类型原理防御方案
SQL 注入 用户输入被拼接到 SQL 语句中,改变 SQL 语义。
例: SELECT * FROM user WHERE id = ' + input + ',input = 1' OR '1'='1
1. 参数化查询 (PreparedStatement / MyBatis #{})
2. 输入校验和过滤
3. 最小权限原则 (数据库用户只授权必要权限)
4. MyBatis 中 ${} vs #{}: ${} 是字符串拼接 (不安全),#{} 是预编译参数 (安全)
XSS (跨站脚本) 恶意脚本被注入到页面中,在其他用户浏览器中执行。
存储型: 存入 DB → 所有用户看到。
反射型: URL 参数直接回显到页面。
1. 输出编码 (HTML Entity Encode)
2. CSP (Content-Security-Policy) Header
3. HttpOnly Cookie (JS 无法读取)
4. 输入过滤/白名单
5. 前端框架默认转义 (React/Vue)
CSRF (跨站请求伪造) 用户在已登录的状态下,访问恶意页面,页面自动发起对目标网站的请求 (携带 Cookie)。 1. CSRF Token (表单/请求头携带服务端生成的 token)
2. SameSite Cookie (Strict/Lax)
3. 检查 Referer/Origin Header
4. 前后端分离用 JWT (不放 Cookie) 天然免疫
// MyBatis SQL 注入安全示例 // 安全: #{} 使用预编译参数 @Select("SELECT * FROM user WHERE id = #{id}") User findById(@Param("id") String id); // 危险: ${} 直接拼接字符串 @Select("SELECT * FROM user WHERE name = '${name}'") // input: ' OR '1'='1 → SELECT * FROM user WHERE name = '' OR '1'='1' // ${} 唯一合法用途: 动态表名/列名 (不能预编译的地方) @Select("SELECT * FROM ${tableName} ORDER BY ${orderColumn}") // 必须在代码层做白名单校验 // Spring Security CSRF 防护 // 前后端分离 (JWT) 通常关闭 CSRF: // http.csrf(csrf -> csrf.disable()) // 原因: JWT 不放 Cookie → 浏览器不会自动携带 → 不受 CSRF 影响 // CSP Header 示例 // Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' // 禁止内联脚本、只允许加载同域资源
避坑:
- 不要信任任何用户输入,包括 HTTP Header (Referer、User-Agent 都可以被伪造)。
- XSS 防御要在输出时编码,而不是输入时过滤 (因为不同的输出上下文需要不同的编码方式)。
- 文件上传也是一个攻击面: 不只验证扩展名,还要验证文件内容 (Magic Bytes),存储在 Web 根目录外。
十六、算法与数据结构
中级 常见排序算法对比与稳定性
算法平均最坏空间稳定适用场景
冒泡排序O(n²)O(n²)O(1)稳定教学、小数据量
插入排序O(n²)O(n²)O(1)稳定近乎有序的数据
快速排序O(n log n)O(n²)O(log n)不稳定通用排序 (Arrays.sort 基本类型)
归并排序O(n log n)O(n log n)O(n)稳定大数据量、外部排序、需要稳定
堆排序O(n log n)O(n log n)O(1)不稳定TopK、优先队列
TimSortO(n log n)O(n log n)O(n)稳定Java Arrays.sort 对象、Python sorted

Java 中的排序:

  • Arrays.sort(int[]): 双轴快速排序 (Dual-Pivot Quicksort)
  • Arrays.sort(Object[]) / Collections.sort(): TimSort (归并+插入混合)
  • Arrays.parallelSort(): 并行排序 (Fork/Join)

面试重点: 快排的 partition 过程 (挖坑填数 / 左右指针)、归并的合并过程、堆的 siftDown 过程。

// 快速排序 (面试手写最常考) public static void quickSort(int[] arr, int left, int right) { if (left >= right) return; int pivot = arr[left + (right - left) / 2]; // 中间值做 pivot 避免最坏情况 int i = left, j = right; while (i <= j) { while (arr[i] < pivot) i++; while (arr[j] > pivot) j--; if (i <= j) { int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; i++; j--; } } quickSort(arr, left, j); quickSort(arr, i, right); } // TopK 问题: 最小的 K 个数 // 方案 1: 快排 partition — O(n) 平均,但会修改原数组 // 方案 2: 最大堆 (PriorityQueue) — O(n log k),不修改原数组,适合流式数据 public static int[] topK(int[] arr, int k) { PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder()); for (int num : arr) { maxHeap.offer(num); if (maxHeap.size() > k) maxHeap.poll(); // 保持堆大小为 k } return maxHeap.stream().mapToInt(Integer::intValue).toArray(); }
中级 LRU Cache 的实现

要求: get 和 put 操作都是 O(1) 时间复杂度。

数据结构选择:

  • HashMap: O(1) 查找 key → node
  • 双向链表: O(1) 移动节点到头部 / 删除尾部节点
  • 两者结合: HashMap 存 key → 链表节点的映射

核心逻辑:

  • get(key): HashMap 找到节点 → 从链表中摘出 → 移到头部
  • put(key, value): key 存在 → 更新值 → 移到头部;不存在 → 新建节点放头部 → 超容量则删除尾部
// LRU Cache 手写实现 (面试常考) public class LRUCache { private final int capacity; private final Map<Integer, Node> map; private final Node head, tail; // 哨兵节点 static class Node { int key, val; Node prev, next; Node(int k, int v) { key = k; val = v; } } public LRUCache(int capacity) { this.capacity = capacity; map = new HashMap<>(); head = new Node(0, 0); tail = new Node(0, 0); head.next = tail; tail.prev = head; } public int get(int key) { if (!map.containsKey(key)) return -1; Node node = map.get(key); remove(node); addToHead(node); return node.val; } public void put(int key, int value) { if (map.containsKey(key)) { Node node = map.get(key); node.val = value; remove(node); addToHead(node); } else { Node node = new Node(key, value); map.put(key, node); addToHead(node); if (map.size() > capacity) { Node lru = tail.prev; remove(lru); map.remove(lru.key); } } } private void remove(Node n) { n.prev.next = n.next; n.next.prev = n.prev; } private void addToHead(Node n) { n.next = head.next; n.prev = head; head.next.prev = n; head.next = n; } } // 生产环境: Caffeine / Guava Cache // Caffeine 使用 Window TinyLFU 算法,比 LRU 命中率更高
中级 二叉树遍历 (递归 + 迭代) + 常见题型

三种遍历:

  • 前序 (Pre-order): 根 → 左 → 右
  • 中序 (In-order): 左 → 根 → 右 (BST 中序遍历是有序的)
  • 后序 (Post-order): 左 → 右 → 根

面试高频题型:

  • 翻转二叉树 (简单)
  • 最大深度 / 最小深度
  • 验证 BST (中序遍历检查递增)
  • 最近公共祖先 (LCA)
  • 路径总和 (DFS + 回溯)
  • 序列化/反序列化二叉树
  • 层序遍历 (BFS,用队列)
// 中序遍历: 迭代写法 (面试常考) public List<Integer> inorderTraversal(TreeNode root) { List<Integer> result = new ArrayList<>(); Deque<TreeNode> stack = new ArrayDeque<>(); TreeNode curr = root; while (curr != null || !stack.isEmpty()) { while (curr != null) { // 一路向左走到底 stack.push(curr); curr = curr.left; } curr = stack.pop(); // 弹出 → 访问 result.add(curr.val); curr = curr.right; // 转向右子树 } return result; } // 层序遍历 (BFS) public List<List<Integer>> levelOrder(TreeNode root) { List<List<Integer>> result = new ArrayList<>(); if (root == null) return result; Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); while (!queue.isEmpty()) { int size = queue.size(); List<Integer> level = new ArrayList<>(); for (int i = 0; i < size; i++) { TreeNode node = queue.poll(); level.add(node.val); if (node.left != null) queue.offer(node.left); if (node.right != null) queue.offer(node.right); } result.add(level); } return result; }
中级 TopK 问题的多种解法

场景: 找到数组中出现频率最高的 K 个元素 / 海量数据中找最大的 K 个数

方案时间复杂度空间复杂度适用场景
排序后取前 KO(n log n)O(1)数据量小,能全部加载到内存
最小堆 (PriorityQueue)O(n log k)O(k)数据可全部加载,K 较小
快排 PartitionO(n) 平均O(1)数据可全部加载,允许修改原数组
计数排序 (HashMap + 桶排序)O(n)O(n)频率范围有限 (如求出现频率最高的 K 个)
分治 + 归并O(n log m)O(k)海量数据分片 (m 个文件),每片取 TopK 后归并
// 频率 TopK (HashMap + 最小堆) public int[] topKFrequent(int[] nums, int k) { // 1. 统计频率 Map<Integer, Integer> freq = new HashMap<>(); for (int n : nums) freq.merge(n, 1, Integer::sum); // 2. 最小堆: 按频率排序,堆顶是频率最小的 PriorityQueue<Map.Entry<Integer, Integer>> heap = new PriorityQueue<>(Comparator.comparingInt(Map.Entry::getValue)); for (Map.Entry<Integer, Integer> e : freq.entrySet()) { heap.offer(e); if (heap.size() > k) heap.poll(); // 超出 K 个就弹出最小的 } return heap.stream().mapToInt(Map.Entry::getKey).toArray(); } // 海量数据 TopK: 分片 + 归并 // 100 亿条数据找最大的 100 个数 // 1. 分成 100 个文件,每个文件 1 亿条 // 2. 每个文件取 Top100 → 100 个 Top100 // 3. 100 * 100 = 10000 个数中再取 Top100 // 总时间: O(N log K) * 分片数 + O(分片数 * K log K) // 空间: 只需要 O(K) 的堆
十七、软实力与面试策略
中级 如何回答"你遇到过最有挑战的技术问题是什么?"

STAR 法则 (Situation → Task → Action → Result):

  1. S (Situation): 业务背景和技术环境。"我们是一个日均 500 万订单的电商系统,使用 Spring Cloud + MySQL + Redis..."
  2. T (Task): 遇到了什么具体问题。"大促期间订单接口 P99 延迟从 50ms 飙到 3 秒,导致部分用户下单失败..."
  3. A (Action): 你做了什么 (要有深度和细节)。
    • "通过 Arthas trace 定位到数据库查询耗时"
    • "发现某查询走了全表扫描,分析 EXPLAIN 确认缺失联合索引"
    • "但加索引后发现问题只缓解了 30%,继续排查发现 Redis 缓存命中率只有 40%"
    • "分析发现是缓存 Key 设计不合理,导致大量无效查询"
    • "重新设计缓存策略: 布隆过滤器 + 本地缓存 + Redis 多级缓存"
  4. R (Result): 量化的结果。"P99 延迟降到 60ms,缓存命中率提升到 95%,大促期间零故障。这个方案后来推广到了其他 3 个核心服务。"
避坑:
- 不要只描述问题不描述解决方案。
- 不要用"我们"模糊掉你自己的贡献,但也不要夸大。
- 不要选太简单的问题 (如"接口 404 是因为路径写错了")。
- 结果要有数据支撑,不要说"好了很多"。
中级 面试常问的非技术问题参考
问题面试官想了解什么建议回答方向
你为什么离开上一家公司?稳定性、价值观正面表达: 追求更大挑战/技术成长,不要抱怨前公司
你的职业规划是什么?目标感、稳定性3 年技术深耕 → 5 年带团队/做架构,与岗位匹配
你有什么想问我们的?好奇心、主动性团队技术栈、业务挑战、协作模式、技术氛围
你觉得自己有什么不足?自我认知真实的不足 + 你正在怎么改进 (不要说"太追求完美")
你如何保持技术学习?学习能力、热情具体: 看源码、写博客、参与开源、技术书籍
总结:
Java 后端面试的深度考察集中在四个层面:
- 原理层: JVM 底层、并发模型、Spring 源码、数据库内核、Redis 数据结构 — 回答"为什么是这样"
- 设计层: 分布式架构、系统设计、设计模式、微服务治理 — 回答"你会怎么设计"
- 实战层: 线上排查、性能优化、方案选型、安全防御 — 回答"你实际做过什么"
- 基础层: 网络协议、数据结构与算法 — 回答"基本功是否扎实"

最后建议: 面试不是考试,是技术对话。展示你的思考过程比给出标准答案更有价值。遇到不会的问题,坦率地说"这个我没有深入研究过,但我的理解是..."然后给出你的推理 — 这比硬编一个错误答案好得多。