Java 后端面试深度指南
侧重深度硬核原理与实战场景题 —— 每个知识点包含详细解释、代码示例、面试追问与常见避坑 (2025-2026)
初级 (1-2 年)
中级 (2-5 年)
高级 (5-8 年)
架构师 (8+ 年)
定位: 本页不是入门教程速查表,而是一份面向中高级 Java 后端工程师的深度面试准备文档。每个问题都从「为什么」出发,不只回答「是什么」。配合
Java 生态全景图 使用效果更佳。
使用方式: 建议按模块逐个深入,不要只背结论。面试考的是你理解的深度和表达的能力,不是你记住了多少标准答案。
高级
请详细描述 Java 对象的内存布局,以及对象头在锁升级中的作用
对象内存布局三部分:
- 对象头 (Header): 包含 Mark Word (存储运行时数据: hashCode、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 等) 和 Klass Pointer (指向类元数据的指针,压缩后 4 字节)。如果是数组,还有数组长度。
- 实例数据 (Instance Data): 对象真正存储的有效信息 — 各字段的内容,包括从父类继承的字段。
- 对齐填充 (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 级堆
选型建议:
| 维度 | G1 | ZGC |
| 堆大小 | 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%,请描述你的完整排查流程
排查流程 (按优先级):
- 定位进程:
top -c 找到 CPU 最高的 Java 进程 PID
- 定位线程:
top -Hp <PID> 找到 CPU 最高的线程 TID,printf '%x\n' <TID> 转为十六进制
- 线程堆栈:
jstack <PID> | grep <HEX_TID> -A 30 查看该线程在做什么
- 确认原因分支:
- 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 查看线程总数。线程过多 → 检查线程池配置或是否有泄漏
- 在线诊断 (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) 只被加载一次,避免用户自定义的同名类篡改核心类。
打破双亲委派的场景:
- Tomcat: 每个 Web 应用有自己的 WebAppClassLoader,优先加载 WEB-INF/classes 和 WEB-INF/lib 下的类,打破了先委托父加载器的规则。原因是不同 Web 应用可能依赖同一个库的不同版本。
- SPI 机制 (如 JDBC): 接口在 rt.jar (Bootstrap CL 加载),实现类在 classpath (Application CL 加载)。Bootstrap CL 无法看到 classpath 的类,所以引入线程上下文类加载器 (Thread Context ClassLoader) 来"逆向"委派。
- OSGi: 模块化框架,每个 Bundle 有自己的类加载器,形成网状委派关系而非树状。
- 热部署/热加载: 运行时重新加载类,必须创建新的 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): 深度优化编译,运行最快但编译耗时
核心优化手段:
- 逃逸分析 (Escape Analysis): 分析对象的作用域是否逃出方法/线程。如果未逃逸 → 栈上分配 (不进入堆)、标量替换 (拆解为基本类型)、消除同步锁。
- 方法内联 (Inlining): 将被调用方法体直接嵌入调用处,消除方法调用开销。
- 循环优化: 循环展开、循环不变量外提。
- 分支预测优化: 根据 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 底层实现:
- 同步代码块: 使用
monitorenter 和 monitorexit 字节码指令。monitorenter 尝试获取对象的 Monitor (ObjectMonitor),计数器 +1;monitorexit 计数器 -1,为 0 时释放。
- 同步方法: 方法访问标志中设置 ACC_SYNCHRONIZED 标志位,JVM 调用方法时自动检查。
- JVM 层: ObjectMonitor 有 EntryList (阻塞等待队列) 和 WaitSet (wait 等待队列),线程阻塞时调用 OS 的 park/unpark,涉及用户态到内核态切换。
synchronized vs ReentrantLock 本质区别:
| 维度 | synchronized | ReentrantLock |
| 实现层 | 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
内存泄漏原因:
- ThreadLocal 对象被回收 (弱引用被 GC),Key 变成 null
- 但 Value 仍然被 ThreadLocalMap 强引用
- 如果线程不销毁 (线程池场景),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 七大核心参数:
- corePoolSize: 核心线程数,即使空闲也不回收 (除非设置 allowCoreThreadTimeOut)
- maximumPoolSize: 最大线程数,包含核心线程 + 临时线程
- keepAliveTime: 临时线程空闲超过此时间后回收
- unit: keepAliveTime 的单位
- workQueue: 任务等待队列 (ArrayBlockingQueue / LinkedBlockingQueue / SynchronousQueue / PriorityBlockingQueue)
- threadFactory: 线程工厂,建议自定义命名 (方便排查)
- 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 结构: 数组 + 链表 + 红黑树
- 计算桶位置:
index = (n - 1) & hash,其中 hash 是扰动后的值 (h ^ h >>> 16)
- 链表 → 红黑树: 同一桶中链表长度 ≥ 8 且数组长度 ≥ 64 时,链表转红黑树 (treeifyBin)
- 红黑树 → 链表: 扩容或删除节点后树节点数 ≤ 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 流程:
- 计算 hash,定位到桶
- 桶为空 → CAS 写入 (无锁)
- 桶不为空 → synchronized 锁住头节点 (只锁一个桶)
- 如果正在扩容 → 协助迁移 (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 IoC 容器启动流程 + Bean 生命周期完整描述
容器启动流程 (AnnotationConfigApplicationContext):
- 创建 BeanFactory (DefaultListableBeanFactory)
- 注册内置后处理器 (AutowiredAnnotationBeanPostProcessor、CommonAnnotationBeanPostProcessor 等)
- 扫描 @ComponentScan 指定包下的类 → 解析为 BeanDefinition → 注册到 BeanFactory
- 执行 BeanFactoryPostProcessor (如 PropertySourcesPlaceholderConfigurer 替换 ${...})
- 注册 BeanPostProcessor
- 实例化所有非懒加载的单例 Bean
Bean 生命周期完整链路:
- 实例化: 反射调用构造函数创建对象
- 属性填充: @Autowired / @Value 注入依赖
- Aware 回调: BeanNameAware / BeanFactoryAware / ApplicationContextAware
- BeanPostProcessor#before: postProcessBeforeInitialization (如 @PostConstruct)
- InitializingBean: afterPropertiesSet()
- 自定义 init-method
- BeanPostProcessor#after: postProcessAfterInitialization (AOP 代理在此生成)
- 使用中
- DisposableBean#destroy
- 自定义 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 代理) |
| 三级 | singletonFactories | Bean 工厂 (ObjectFactory),调用 getObject() 时才决定返回原始对象还是代理对象 |
解决过程 (A 依赖 B,B 依赖 A):
- 创建 A → 实例化 A → 将 A 的 ObjectFactory 放入三级缓存
- 填充 A 的属性 → 发现需要 B → 去 getBean("B")
- 创建 B → 实例化 B → 将 B 的 ObjectFactory 放入三级缓存
- 填充 B 的属性 → 发现需要 A → getBean("A")
- getSingleton("A"): 一级没有 → 二级没有 → 三级有 → 调用 ObjectFactory.getObject() → 如果有 AOP 则返回代理对象 → 放入二级缓存
- B 拿到 A 的引用 → B 初始化完成 → 放入一级缓存
- 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 | 不能在事务中,否则抛异常 | 不允许事务的操作 |
事务失效的常见场景:
- 自调用: 同一类中方法 A 调 B,B 的 @Transactional 不生效 (没走代理)
- 非 public 方法: @Transactional 只对 public 方法生效
- 异常被 catch: 方法内部 try-catch 吞掉异常,事务不知道要回滚
- 异常类型不对: 默认只对 RuntimeException 和 Error 回滚,checked exception 不回滚 (除非设置 rollbackFor)
- 数据库引擎不支持: MySQL 的 MyISAM 不支持事务,要用 InnoDB
- 传播行为设置为 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): 最终一致性 — 经过一段时间后数据一致
实际选型:
| 场景 | 选择 | 例子 |
| 金融交易 | CP | ZooKeeper (牺牲可用性保证一致性) |
| 电商库存 | CP + 补偿 | Seata AT 模式 (最终一致性) |
| 社交动态 | AP | Cassandra (牺牲一致性保证可用性) |
| 配置中心 | CP | Nacos (Raft 协议选主) |
| 缓存 | AP | Redis 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 树:
- IO 次数更少: B+ 树非叶子节点只存 Key 不存数据,同样大小的磁盘页能容纳更多 Key → 树更矮 → 磁盘 IO 更少。InnoDB 一个页 16KB,三层 B+ 树可以存约 2000 万条记录。
- 范围查询高效: B+ 树叶子节点通过双向链表连接,范围查询只需找到起始点然后顺序扫描链表。B 树需要中序遍历。
- 查询稳定: 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) 核心组件:
- 隐藏列: 每行有 DB_TRX_ID (最后修改的事务 ID) 和 DB_ROLL_PTR (回滚指针,指向 undo log)
- Undo Log 版本链: 每次修改都生成一条 undo log,通过 DB_ROLL_PTR 串成链表
- 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 慢查询优化流程
优化流程:
- 开启慢查询日志:
slow_query_log=ON, long_query_time=1
- EXPLAIN 分析执行计划: 重点关注 type、key、rows、Extra
- type 从优到差: system > const > eq_ref > ref > range > index > ALL
- Extra 重要信息:
- Using Index → 覆盖索引 (好)
- Using Where → Server 层过滤 (需关注)
- Using Temporary → 临时表 (需优化)
- Using Filesort → 文件排序 (需优化)
- 优化策略:
// 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 实现类 → 自动注册 → 零修改
核心挑战: 瞬时高并发 (QPS 可能 10 万+)、库存不能超卖、防止机器人刷单
架构分层:
- 前端层: 静态资源 CDN + 按钮防抖 + 答题/验证码 + 请求随机丢弃 (保护后端)
- 网关层: 限流 (令牌桶) + 黑名单 + 用户级频率限制
- 服务层:
- 库存预扣减: Redis
DECR (原子操作) → 库存为 0 直接拒绝
- 订单异步化: 通过 MQ 异步创建订单 → 用户轮询或 WebSocket 通知结果
- 防止超卖: Redis + Lua 脚本保证原子性
- 数据库层: 乐观锁
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); // 异步下单
}
核心流程: 长链 → 唯一短码 → 存储映射 → 302 重定向
短码生成方案:
- 自增 ID + Base62: 数据库自增 → 转 62 进制 (a-zA-Z0-9)。简单但可预测。
- MurmurHash + 解决冲突: 哈希后检查是否存在,存在则加盐重试。不可预测。
- 预生成 + 发号器: 预先批量生成短码存入池中,使用时直接取。性能最高。
高并发读优化: 布隆过滤器判断短码存在 → 缓存映射关系 → 301/302 重定向。301 永久重定向可被浏览器缓存,但无法统计点击量;302 临时重定向每次都经过服务端,可统计。
方案对比:
| 方案 | 精度 | 吞吐 | 复杂度 | 适用 |
| 定时任务轮询 DB | 秒级 | 低 | 低 | 简单场景、数据量小 |
| DelayQueue (内存) | 毫秒 | 中 | 低 | 单机、数据量可控 |
| RocketMQ 延迟消息 | 秒级 (固定级别) | 高 | 低 | 通用方案首选 |
| Redis 过期通知 | 秒级 | 高 | 中 | 需要额外保证可靠投递 |
| Redis ZSet + 轮询 | 秒级 | 高 | 中 | 灵活控制延迟时间 |
| 时间轮 (Netty HashedWheelTimer) | 毫秒 | 高 | 高 | 单机高精度 |
Redis ZSet 方案 (生产常用):
- 添加任务:
ZADD delay_queue
- 轮询消费:
ZRANGEBYSCORE delay_queue 0 LIMIT 0 100
- 取出后:
ZREM delay_queue (原子性用 Lua 保证)
- 多实例部署: 用分布式锁保证同一任务只被消费一次
架构
线上接口突然变慢,你的排查思路是什么?
分层排查 (从外到内):
- 网络层: 是否有网络抖动/丢包?
ping / traceroute / CDN 日志
- 网关/负载均衡: Nginx access.log 看 upstream 响应时间;是否有限流触发
- 应用层:
- APM 工具 (SkyWalking/Jaeger) 看链路耗时分布
- Arthas
trace 命令追踪方法耗时
- GC 日志: 是否有长时间 STW
- 线程池监控: 是否有线程池满导致排队
- 数据库层: 慢查询日志 + EXPLAIN → 锁等待 (show engine innodb status) → 连接池满
- 缓存层: Redis 延迟 (redis-cli --latency) → 缓存命中率下降 → 大 Key (redis-cli --bigkeys)
- 外部依赖: 第三方 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 突发流量 |
| 滑动窗口 | 时间窗口滑动 | 平滑 | 内存开销大 |
| 漏桶 | 恒定速率处理请求 | 流量平滑 | 无法应对突发流量 |
| 令牌桶 | 恒定速率放入令牌,请求消耗令牌 | 允许突发流量 | 实现稍复杂 |
多级限流:
- 网关层: Nginx limit_req (IP 级) / Sentinel (集群级)
- 应用层: Guava RateLimiter / Sentinel (单机) / Redis + Lua (分布式)
- 下游保护: 线程池隔离 / 信号量隔离
// 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 三次握手和四次挥手的过程和原因
三次握手 (建立连接):
- 客户端 → SYN → 服务端: "我要连接了" (客户端进入 SYN_SENT)
- 服务端 → SYN+ACK → 客户端: "收到,我也要连接了" (服务端进入 SYN_RCVD)
- 客户端 → ACK → 服务端: "确认,开始传输" (双方进入 ESTABLISHED)
为什么是三次不是两次?
- 防止已失效的连接请求到达服务端。假设只有两次: 客户端发了一个 SYN 但延迟了 → 客户端超时重发 SYN → 两次握手建立了连接并传完数据 → 延迟的旧 SYN 到达 → 服务端又建了一个无人使用的连接 (资源浪费)。
- 三次握手让服务端知道客户端确实准备好了,确认双方的发送和接收能力都正常。
四次挥手 (断开连接):
- 主动方 → FIN → 被动方: "我没有数据要发了"
- 被动方 → ACK → 主动方: "收到" (半关闭状态,被动方可能还有数据要发)
- 被动方 → FIN → 主动方: "我也没有数据要发了"
- 主动方 → 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.1 | HTTP/2 | HTTP/3 |
| 传输层 | TCP | TCP | QUIC (基于 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):
- TCP 三次握手: 建立连接
- TLS 握手 (以 TLS 1.2 RSA 为例):
- Client Hello: 支持的 TLS 版本、加密套件列表、随机数 A
- Server Hello: 选定的加密套件、服务器证书、随机数 B
- 客户端验证证书: 检查 CA 签名、域名、有效期
- 客户端生成预主密钥 (Pre-Master Secret),用服务器公钥加密发送
- 双方用 随机数A + 随机数B + 预主密钥 → 计算出会话密钥 (对称密钥)
- 对称加密通信: 后续数据用会话密钥加密 (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 + Session | Token (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 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-percentage 和 auto-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 --bigkeys、MEMORY USAGE key、STRLEN/LLEN/HLEN/SCARD/ZCARD
- 处理:
- String 类型: 压缩 (gzip/snappy) 后存储,或拆分
- 集合类型: 按业务维度拆分 (如 hash 按字段分组)
- 删除: 使用
UNLINK (异步删除,不阻塞主线程) 而非 DEL
- 大集合删除:
hscan + hdel 分批删除
HotKey 问题:
- 定义: 单个 key 被极高频率访问 (如热点新闻、秒杀商品)
- 危害: 集群中某个节点负载过高 (数据倾斜)、打爆网卡
- 检测:
redis-cli --hotkeys (需要开启 maxmemory-policy 为 LFU)、MONITOR 命令 (仅调试)、业务层统计
- 处理:
- 本地缓存 (Caffeine/Guava Cache) 作为一级缓存
- 读写分离: 热点 key 复制到多个从节点
- Key 打散:
hotkey_1 ~ hotkey_N,客户端随机读取
高级
服务注册发现: Nacos 的 AP/CP 模式切换原理
Nacos 同时支持 AP 和 CP:
- 临时实例 (AP 模式,默认): 客户端主动向 Nacos Server 注册,通过心跳保活。心跳失败 → 剔除实例。使用 Distro 协议 (类似 Gossip) 做集群内数据同步。适合服务发现场景 (容忍短暂不一致)。
- 持久实例 (CP 模式): Server 主动探测实例健康状态 (TCP/HTTP 探测)。使用 Raft 协议做集群内数据一致性。适合需要强一致的场景 (如数据库实例、配置信息)。
AP 模式下的注册流程:
- 服务启动 → 向 Nacos Server 发送注册请求 (POST /nacos/v1/ns/instance)
- Server 写入本地注册表 + 异步同步给集群其他节点
- 客户端每 5 秒发送心跳 → Server 超过 15 秒未收到 → 标记不健康 → 超过 30 秒 → 剔除
- 消费者订阅服务 → Server 推送变更 (UDP 长连接) + 消费者定时拉取 (兜底)
Eureka vs Nacos vs Consul:
| 维度 | Eureka | Nacos | Consul |
| 一致性 | AP | AP + CP | CP (Raft) |
| 健康检查 | 客户端心跳 | 心跳 + 服务端探测 | Agent 探测 |
| 配置中心 | 不支持 | 支持 | 支持 (KV Store) |
| 雪崩保护 | 有 (自我保护) | 有 (阈值保护) | 无 |
| 现状 | 已停止维护 | 国内主流 | 海外常用 |
面试追问:
- Nacos 集群部署至少几台?推荐 3 台 (Raft 协议过半数选举)。AP 模式下 2 台也能工作但不推荐。
- 注册中心的可用性如何保证?Nacos 集群 + MySQL 持久化 + VIP/SLB 前置。客户端本地缓存注册表,Nacos 宕机时降级使用缓存。
- K8s 环境还需要 Nacos 吗?如果所有服务都在 K8s 内,可以用 Service 做服务发现。但如果混合部署 (部分服务在 K8s 外),Nacos 仍然有用。
高级
API 网关的核心功能与 Spring Cloud Gateway 原理
网关核心功能:
- 路由: 根据请求路径/Header/参数转发到后端服务
- 认证鉴权: 统一入口做 JWT 校验、OAuth2 校验
- 限流熔断: 令牌桶限流、Sentinel 集成
- 负载均衡: 集成 Spring Cloud LoadBalancer
- 协议转换: HTTP → gRPC、WebSocket 代理
- 灰度发布: 根据权重/Header 路由到不同版本
- 跨域/日志/监控: 统一处理 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 传递机制:
- HTTP 调用: 通过 HTTP Header 传递。OpenTelemetry 标准使用
traceparent Header (格式: version-traceid-spanid-flags)。Spring Cloud Sleuth / Micrometer Tracing 自动在 RestTemplate/WebClient/Feign 中注入 Header。
- 消息队列: 通过消息属性 (Properties/Headers) 传递。Kafka 使用 Record Headers,RabbitMQ 使用 AMQP Headers。
- 线程池: 需要手动传递。使用
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。
关键过滤器 (按顺序):
- SecurityContextFilter: 从 Session/Redis 加载 SecurityContext (认证信息)
- CorsFilter: 处理跨域请求
- CsrfFilter: CSRF 防护 (前后端分离通常关闭)
- LogoutFilter: 处理登出请求
- UsernamePasswordAuthenticationFilter: 处理表单登录 (POST /login)
- BearerTokenAuthenticationFilter: 处理 JWT Token 认证
- ExceptionTranslationFilter: 捕获认证/授权异常,返回 401/403
- AuthorizationFilter: 最终授权检查 (@PreAuthorize 在这里生效)
认证流程:
- 请求携带 Token → BearerTokenAuthenticationFilter 解析 JWT
- 解析成功 → 创建 Authentication 对象 → 存入 SecurityContextHolder
- 后续 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、优先队列 |
| TimSort | O(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 个数
| 方案 | 时间复杂度 | 空间复杂度 | 适用场景 |
| 排序后取前 K | O(n log n) | O(1) | 数据量小,能全部加载到内存 |
| 最小堆 (PriorityQueue) | O(n log k) | O(k) | 数据可全部加载,K 较小 |
| 快排 Partition | O(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):
- S (Situation): 业务背景和技术环境。"我们是一个日均 500 万订单的电商系统,使用 Spring Cloud + MySQL + Redis..."
- T (Task): 遇到了什么具体问题。"大促期间订单接口 P99 延迟从 50ms 飙到 3 秒,导致部分用户下单失败..."
- A (Action): 你做了什么 (要有深度和细节)。
- "通过 Arthas trace 定位到数据库查询耗时"
- "发现某查询走了全表扫描,分析 EXPLAIN 确认缺失联合索引"
- "但加索引后发现问题只缓解了 30%,继续排查发现 Redis 缓存命中率只有 40%"
- "分析发现是缓存 Key 设计不合理,导致大量无效查询"
- "重新设计缓存策略: 布隆过滤器 + 本地缓存 + Redis 多级缓存"
- R (Result): 量化的结果。"P99 延迟降到 60ms,缓存命中率提升到 95%,大促期间零故障。这个方案后来推广到了其他 3 个核心服务。"
避坑:
- 不要只描述问题不描述解决方案。
- 不要用"我们"模糊掉你自己的贡献,但也不要夸大。
- 不要选太简单的问题 (如"接口 404 是因为路径写错了")。
- 结果要有数据支撑,不要说"好了很多"。
中级
面试常问的非技术问题参考
| 问题 | 面试官想了解什么 | 建议回答方向 |
| 你为什么离开上一家公司? | 稳定性、价值观 | 正面表达: 追求更大挑战/技术成长,不要抱怨前公司 |
| 你的职业规划是什么? | 目标感、稳定性 | 3 年技术深耕 → 5 年带团队/做架构,与岗位匹配 |
| 你有什么想问我们的? | 好奇心、主动性 | 团队技术栈、业务挑战、协作模式、技术氛围 |
| 你觉得自己有什么不足? | 自我认知 | 真实的不足 + 你正在怎么改进 (不要说"太追求完美") |
| 你如何保持技术学习? | 学习能力、热情 | 具体: 看源码、写博客、参与开源、技术书籍 |
总结:
Java 后端面试的深度考察集中在四个层面:
- 原理层: JVM 底层、并发模型、Spring 源码、数据库内核、Redis 数据结构 — 回答"为什么是这样"
- 设计层: 分布式架构、系统设计、设计模式、微服务治理 — 回答"你会怎么设计"
- 实战层: 线上排查、性能优化、方案选型、安全防御 — 回答"你实际做过什么"
- 基础层: 网络协议、数据结构与算法 — 回答"基本功是否扎实"
最后建议: 面试不是考试,是技术对话。展示你的思考过程比给出标准答案更有价值。遇到不会的问题,坦率地说"这个我没有深入研究过,但我的理解是..."然后给出你的推理 — 这比硬编一个错误答案好得多。