Java 后端技术栈速查
Java 语言基础、JVM 调优、并发编程、Spring 生态、MySQL 深度、Redis、消息队列、高并发、监控排障与场景设计题。
定位:这是一份备用题库,不是主攻方向。你简历主线是 前端 → AI Agent 全栈(Python/FastAPI),Java 系岗位属于"投递面放宽时的第二落点"。 怎么用:① 只投 Python/AI 岗 → 本篇不用背,但把「第八章 高并发与性能优化」「第九章 监控与排障」「第十章 场景设计题」扫一遍——这三章的思路与语言无关,是通用加分项;② 要投 Java 后端 → 按第一到第七章顺序刷,MySQL/Redis/并发三块是必考且与语言无关的重合区,投入产出比最高。 答题原则:Java 岗位面试同样是"先说结论 → 再说原理 → 最后结合场景"。原理题不要背八股,要能说出**"为什么这样设计"和"什么场景会失效"**——面试官区分层级就看这个。 结构:共 11 章。⭐ 标记 = 高频必考;🔥 标记 = 能拉开档次的深度题。
一、Java 语言基础
1. == 和 equals 的区别?为什么重写 equals 必须重写 hashCode?★
==:基本类型比值,引用类型比地址。equals:Object 默认也是比地址,String/Integer 等重写成了比值。- 为什么必须一起重写:
HashMap/HashSet靠hashCode定位桶、再用equals比较。如果只重写equals,两个"相等"的对象可能落在不同桶里 →set.add(a); set.contains(b)返回 false,集合语义直接坏掉。 - 契约三条:①
equals为 true →hashCode必须相等;②hashCode相等equals不一定 true(哈希冲突);③ 参与equals的字段必须也参与hashCode。 - 加分:用 Lombok
@EqualsAndHashCode或 IDE 生成;可变对象做 map 的 key 是灾难(改了字段 hash 变了就再也找不到)。
2. String 为什么不可变?String / StringBuilder / StringBuffer 怎么选?
- 不可变的原因:
final class+private final char[](JDK9+ 是byte[]+ coder)+ 不提供修改方法。 - 为什么这样设计:① 线程安全、可自由共享;② 字符串常量池可缓存(复用);③
hashCode可缓存(HashMap 的 key 高频用);④ 安全性(类加载器路径、SQL 参数不会被中途篡改)。 - 选型:少量拼接用
+(编译器会优化成 StringBuilder);循环内拼接用 StringBuilder(单线程)、多线程共享用 StringBuffer(synchronized)。 - 坑:
new String("a")会创建 2 个对象(常量池 1 个 + 堆 1 个);intern()手动入池,别滥用(JDK6 在永久代会 OOM,JDK7+ 移到堆里好一些)。
3. HashMap 的底层原理?(必考,要能一路讲到红黑树)★
| 维度 | 答案 |
|---|---|
| 结构 | 数组 + 链表 + 红黑树(JDK8+) |
| 初始容量 / 负载因子 | 16 / 0.75(空间与冲突的折中) |
| 索引计算 | (n - 1) & hash(n 是 2 的幂,等价于取模但更快) |
| 扩容 | 容量翻倍,JDK8 用高低位链表拆分(不用重新计算 hash) |
| 树化条件 | 链表长度 ≥ 8 且 数组长度 ≥ 64;否则先扩容 |
| 退化条件 | 树节点 ≤ 6 退化为链表 |
| 为什么是 8 | 泊松分布下链表长度到 8 的概率约千万分之六,是"树化成本 vs 链表查询成本"的平衡点 |
并发问题(必须主动说):HashMap 线程不安全——JDK7 头插法扩容会形成环形链表导致死循环;JDK8 改为尾插,但仍会丢数据/覆盖。并发场景用 ConcurrentHashMap。
为什么容量必须是 2 的幂:(n-1) & hash 只有 n 是 2 的幂时才等价于 hash % n,且能让散列更均匀。所以 new HashMap<>(17) 实际容量是 32(会向上取到最近的 2 的幂)。
4. ConcurrentHashMap 怎么保证线程安全?JDK7 和 JDK8 有什么区别?★
- JDK7:分段锁 Segment(继承 ReentrantLock),默认 16 段,锁粒度是段。
- JDK8:放弃分段锁,改为
CAS + synchronized——只锁当前桶的头节点,锁粒度更细,并发度更高。put:桶为空 → CAS 写入;桶不为空 →synchronized(头节点)后插入。size:用baseCount + CounterCell[]分散计数(类似 LongAdder),避免热点。
- 不允许 null key/value:并发下无法区分"不存在"和"值为 null"(HashMap 允许)。
- 加分:
get基本无锁(volatile保证可见性)。
5. ArrayList vs LinkedList?扩容机制?
- ArrayList:数组,随机访问 O(1),中间插入 O(n);默认容量 10,扩容 1.5 倍(
oldCap + (oldCap >> 1)),用Arrays.copyOf。 - LinkedList:双向链表,头尾操作 O(1),随机访问 O(n);每个节点额外存两个指针,内存开销大、缓存不友好。
- 结论:绝大多数场景用 ArrayList——CPU 缓存友好,即使中间插入,小数据量下数组移动也比遍历链表快。LinkedList 只在"频繁头尾增删 + 不做随机访问"(当队列用)时才有优势,而那个场景更该用
ArrayDeque。
6. 反射与动态代理:JDK Proxy 和 CGLIB 的区别?★
| 维度 | JDK 动态代理 | CGLIB |
|---|---|---|
| 原理 | 基于接口,生成实现类 | 基于继承,生成子类(ASM 字节码) |
| 前提 | 目标类必须有接口 | 类不能是 final、方法不能是 final/private |
| 性能 | JDK8+ 已优化,创建快 | 创建慢(生成字节码),执行略快 |
| Spring 选择 | 目标有接口 → 默认 JDK | 无接口 → 自动切 CGLIB(Spring Boot 2.x 起默认强制 CGLIB) |
为什么 Spring AOP 自调用会失效:代理对象才能拦截,this.method() 是原始对象内部调用,不走代理 → 事务/切面失效。解法:注入自己(@Lazy 或 AopContext.currentProxy())、拆分到另一个 Bean、或改用 TransactionTemplate。
7. IO / NIO / Netty 的关系?
- BIO:一连接一线程,阻塞,
accept和read都阻塞 → 连接数上不去。 - NIO:Selector 多路复用(epoll),一个线程管多连接;三个核心:Channel / Buffer / Selector。
- AIO:异步回调,Linux 下实现不成熟,实际用得少。
- Netty:基于 NIO 的封装,主从 Reactor 多线程模型;解决粘包/拆包(长度字段/分隔符)、零拷贝(
CompositeByteBuf/transferTo)、内存池(ByteBuf)。 - 加分:
epoll的 水平触发(LT)vs 边缘触发(ET)——ET 效率高但必须一次读完,Netty 默认用 LT(JDK epoll 默认 LT)。
8. Java 8 → 25 的关键新特性(问"平时用什么版本"时的加分点)
- 8:Lambda、Stream、
Optional、接口 default 方法、CompletableFuture、新日期 API。 - 11:
var、HttpClient、字符串新方法。 - 17:record(不可变数据类)、sealed class(密封继承)、文本块。
- 21(LTS):虚拟线程(Virtual Threads)、模式匹配 switch、分代 ZGC。
- 24:JEP 491 让虚拟线程在
synchronized中阻塞时也能释放载体线程,消除了绝大多数 monitor pinning;native/foreign 调用等少数场景仍可能 pin。 - 25(LTS):模块导入声明、紧凑源文件与实例
main方法、灵活构造器函数体转正;结构化并发仍是预览特性,面试时不要说成正式 API。 - 虚拟线程要点(2026 高频):由 JVM 调度、M:N 映射到平台线程,适合高并发 IO 密集;不适合 CPU 密集;不要池化(创建成本极低)。版本口径要说清:JDK 21–23 的
synchronized阻塞可能 pin 住载体线程;JDK 24+ 已由 JEP 491 解决绝大多数此类 pinning,因此不应再把“统一改成ReentrantLock”当作通用结论。
二、JVM 与性能调优
9. JVM 内存区域划分?★
| 区域 | 线程 | 存什么 | 会 OOM 吗 |
|---|---|---|---|
| 程序计数器 | 私有 | 当前字节码行号 | 不会 |
| 虚拟机栈 | 私有 | 栈帧(局部变量表/操作数栈) | 会(StackOverflowError / OOM) |
| 本地方法栈 | 私有 | native 方法 | 会 |
| 堆 | 共享 | 对象实例、数组 | 会(最常见) |
| 方法区/元空间 | 共享 | 类元信息、常量、静态变量 | 会(元空间默认不限,受本地内存限制) |
| 直接内存 | 共享 | NIO DirectByteBuffer | 会(受 -XX:MaxDirectMemorySize 限制) |
JDK8 的变化:永久代(PermGen)→ 元空间(Metaspace),从堆移到本地内存。原因:永久代大小难预估,容易 OOM,且 JRockit 合并需要统一。
10. 对象创建过程?对象在内存里长什么样?
- 创建流程:类加载检查 → 分配内存(指针碰撞或空闲列表;并发用 TLAB)→ 初始化零值 → 设置对象头 → 执行构造方法。
- 内存布局:对象头(Mark Word + 类型指针)+ 实例数据 + 对齐填充(8 字节对齐)。
- Mark Word:存 hashcode、GC 分代年龄、锁状态标记(无锁/偏向/轻量级/重量级)——这是理解锁升级的关键。
- 逃逸分析(加分):JIT 判断对象不会逃出方法 → 栈上分配 / 标量替换 / 锁消除。所以"对象都在堆上"是不准确的。
11. 垃圾回收:怎么判断对象死了?★
- 不是引用计数(循环引用无法回收)→ 可达性分析:从 GC Roots 出发。
- GC Roots 包括:虚拟机栈中引用的对象、静态变量引用的对象、常量引用的对象、native 引用、活跃线程。
- 引用类型四级:强 / 软(内存不足才回收,适合缓存)/ 弱(下次 GC 就回收)/ 虚(回收通知)。
- finalize:不推荐(执行时机不确定、可能"复活"对象)。
12. 垃圾回收算法与收集器?G1 为什么是默认?★
三种基础算法:标记-清除(碎片)、标记-复制(浪费空间,用于新生代)、标记-整理(无碎片,耗时)。
| 收集器 | 分代 | 特点 | 停顿 |
|---|---|---|---|
| Serial / ParNew | 新生代 | 单线程 / 多线程复制 | 有 STW |
| Parallel Scavenge | 新生代 | 吞吐量优先(适合批处理) | 有 STW |
| CMS | 老年代 | 并发标记清除,低延迟(已废弃) | 短停顿,但有碎片和 Concurrent Mode Failure |
| G1(JDK9+ 默认) | 不分代(逻辑分代) | Region 化 + 可预测停顿模型(-XX:MaxGCPauseMillis) | 可设定目标停顿 |
| ZGC(JDK15+ 正式) | — | 染色指针 + 读屏障,停顿 < 10ms(与堆大小无关) | 极低 |
| Shenandoah | — | 类似 ZGC | 极低 |
G1 的核心:把堆分成大小相等的 Region(1~32MB),按"垃圾最多的 Region 优先回收"(Garbage First);用 Remembered Set 记录跨 Region 引用;大对象放 Humongous Region。
选型话术:延迟敏感(在线服务)选 G1/ZGC;吞吐优先(批处理/离线)选 Parallel。 决定前先看 GC 日志,别凭感觉调参。
13. 类加载机制与双亲委派?★
- 过程:加载 → 验证 → 准备 → 解析 → 初始化。
- 双亲委派:收到加载请求 → 先委派父加载器 → 父加载不了才自己加载。
- 三个加载器:Bootstrap(
rt.jar/核心库)→ Extension/Platform → Application(classpath)→ 自定义。
- 三个加载器:Bootstrap(
- 为什么这样设计:① 安全(防止自定义
java.lang.String替换核心类);② 避免重复加载。 - 打破双亲委派的场景(加分):SPI(JDBC 的
DriverManager用线程上下文类加载器)、Tomcat(每个 webapp 一个 WebAppClassLoader,实现应用隔离)、OSGi/热部署。
14. 常见 OOM 类型与排查?★
| 类型 | 原因 | 排查 |
|---|---|---|
Java heap space | 大对象 / 内存泄漏 / 堆太小 | jmap -dump + MAT 看支配树,找"谁持有最多" |
GC overhead limit exceeded | GC 太频繁但回收效果差(>98% 时间、回收 <2%) | 同上,通常是泄漏 |
Metaspace | 动态生成类过多(CGLIB/反射/JSP) | -XX:MaxMetaspaceSize + 看类加载数 |
unable to create new native thread | 线程数超限(内存/ulimit) | 看线程数,通常是线程池没限制 |
Direct buffer memory | NIO 直接内存没释放 | -XX:MaxDirectMemorySize |
排查 SOP(背下来):
top/top -Hp <pid>找高分线程 →printf '%x'转十六进制 →jstack搜 nid 定位代码行;- 内存:
jstat -gcutil <pid> 1000看 GC 频率与老年代增长 → 有泄漏就jmap -dump:live→ MAT 分析; - 线上首选 Arthas(
dashboard/thread -n 3/heapdump/trace方法级耗时),不用重启。
15. CPU 100% 怎么排查?(高频实操题)★
top确认是 JVM 进程;top -Hp <pid>找出占 CPU 最高的线程 ID;printf "%x\n" <tid>转 16 进制;jstack <pid> | grep -A 30 <hex>找到线程栈,定位到具体代码行;- 常见根因:死循环 / 正则回溯(ReDoS)/ 频繁 Full GC / 序列化大对象 / 日志打太多 / 加密计算。
三、并发编程
16. 线程池的 7 个参数与执行流程?★
new ThreadPoolExecutor(
corePoolSize, // 核心线程数
maximumPoolSize, // 最大线程数
keepAliveTime, // 非核心线程空闲存活时间
unit, // 时间单位
workQueue, // 阻塞队列
threadFactory, // 线程工厂(命名!便于排查)
handler // 拒绝策略
);
执行流程(必背):核心线程未满 → 建核心线程;核心满 → 入队列;队列满 → 建非核心线程;线程数到 max 且队列满 → 执行拒绝策略。
四种拒绝策略:AbortPolicy(默认,抛异常)/ CallerRunsPolicy(调用者执行,天然背压)/ DiscardPolicy(静默丢弃)/ DiscardOldestPolicy(丢最老的)。
线程数怎么定(高频追问):
- CPU 密集:
N + 1(N = 核数); - IO 密集:
N × (1 + 等待时间/计算时间),经验值2N ~ 4N,最终靠压测确定。 - 关键:线程池要按业务隔离(订单池/通知池分开),否则一个慢任务会拖垮全部。
- 为什么不用 Executors:
newFixedThreadPool/newSingleThreadExecutor用无界队列(OOM 风险);newCachedThreadPool最大线程数是Integer.MAX_VALUE(创建过多线程)。必须手写 ThreadPoolExecutor。
动态调参(2026 加分项):线程池参数放配置中心,支持运行时调整;但改核心线程数不会立即缩容,要配合 allowCoreThreadTimeOut。
17. synchronized 的原理?锁升级过程?★
- 原理:修饰代码块 →
monitorenter/monitorexit指令,靠对象头 Mark Word + Monitor(管程);修饰方法 →ACC_SYNCHRONIZED标志。 - 锁升级(不可逆):无锁 → 偏向锁 → 轻量级锁 → 重量级锁。
- 偏向锁:只有一个线程访问,把线程 ID 记在 Mark Word,无 CAS 开销(JDK15 起默认禁用,因为撤销成本高)。
- 轻量级锁:多线程交替执行,CAS 自旋抢锁。
- 重量级锁:竞争激烈,OS 互斥量,线程阻塞挂起(用户态↔内核态切换)。
- 优化:锁消除(逃逸分析)、锁粗化、自适应自旋。
18. volatile 的作用?能保证原子性吗?
- 两个语义:① 可见性(写立即刷主存,读从主存读);② 禁止指令重排序(内存屏障)。
- 不保证原子性:
i++是"读-改-写"三步,多线程下会丢更新。要原子性用AtomicInteger/synchronized。 - 典型应用:DCL 单例的
instance必须加 volatile(防止"分配内存→初始化→引用赋值"重排序导致拿到半成品对象)。 - 底层:汇编层面加
lock前缀指令 → MESI 缓存一致性协议 + 内存屏障。
19. AQS 原理?ReentrantLock 和 synchronized 的区别?★
- AQS(AbstractQueuedSynchronizer):核心是
volatile int state+ CLH 双向队列。- 获取锁:CAS 改 state,失败则封装成 Node 入队并 park;
- 释放锁:改 state,唤醒后继节点。
- 支持独占(ReentrantLock)和共享(Semaphore/CountDownLatch)两种模式。
- 对比:
| 维度 | synchronized | ReentrantLock |
| --- | --- | --- |
| 实现 | JVM 层面(monitor) | JDK 层面(AQS) |
| 锁释放 | 自动(异常也释放) | 必须手动
unlock(放 finally) | | 公平锁 | 不支持 | 支持(new ReentrantLock(true)) | | 可中断 / 超时 | 不支持 | 支持lockInterruptibly/tryLock(timeout)| | 条件变量 | 一个 | 多个 Condition | - 怎么选:能用
synchronized就用(简单、JVM 优化好);需要超时/中断/公平/多条件 →ReentrantLock。
20. ThreadLocal 原理与内存泄漏?★
- 结构:每个 Thread 里有一个
ThreadLocalMap,key 是 ThreadLocal 的弱引用,value 是强引用。 - 为什么会泄漏:ThreadLocal 被回收后,key 变 null,但 value 仍被 Thread 强引用——线程池里线程长期存活 → value 永远不释放。
- 解法:用完必须
remove()(放 finally)。 - 为什么 key 用弱引用:至少让 ThreadLocal 本身能被回收(一种折中保护),但不能根治,必须手动 remove。
- 典型用途:用户上下文、traceId、事务连接、
SimpleDateFormat隔离。
21. CAS 与 ABA 问题?
- CAS:
compareAndSwap(expected, update),靠 CPU 的cmpxchg+lock指令保证原子。 - 三个问题:① ABA(A→B→A,值没变但状态变了)→ 用
AtomicStampedReference加版本号;② 自旋开销大 → 用LongAdder分散热点;③ 只能保证单个变量原子性 →AtomicReference包对象。 LongAddervsAtomicLong(加分):AtomicLong所有线程 CAS 同一个变量(高并发下自旋多);LongAdder分散到多个 Cell 累加,读时求和——写多读少场景吞吐高得多。
22. 死锁的四个必要条件与排查?★
- 四个条件:互斥、持有并等待、不可剥夺、循环等待。
- 破坏方式:按固定顺序加锁(破坏循环等待,最实用)、
tryLock加超时(破坏不可剥夺)、一次性申请全部资源。 - 排查:
jstack <pid>会直接输出Found one Java-level deadlock;Arthas 的thread -b一条命令找阻塞源头。 - 预防:加锁顺序规范 + 超时 + 锁粒度尽量小 + 避免嵌套锁。
23. CompletableFuture 怎么用?(异步编排必考)
- 与 Future 的区别:Future 只能
get()阻塞取,无法编排回调;CompletableFuture 支持链式 + 组合 + 异常处理。 - 常用组合:
thenApply(转换)/thenAccept(消费)/thenRun(不关心结果);thenCompose(串行依赖,扁平化)/thenCombine(并行后合并);allOf(等全部完成)/anyOf(任一完成);exceptionally/handle(异常兜底,必须写)。
- 三个坑:① 默认用 ForkJoinPool.commonPool,IO 密集任务会打满 → 必须传自定义线程池;② 不写异常处理会静默吞掉异常;③
allOf返回的 future 不携带结果,要自己收集。
24. 阻塞队列与生产消费者?
| 队列 | 特点 |
|---|---|
ArrayBlockingQueue | 数组、有界、一把锁 |
LinkedBlockingQueue | 链表、默认 Integer.MAX_VALUE(小心 OOM) |
SynchronousQueue | 不存储,直接移交(newCachedThreadPool 用) |
PriorityBlockingQueue | 优先级、无界 |
DelayQueue | 延迟队列(订单超时关闭经典实现) |
LinkedTransferQueue | 高吞吐、支持 transfer |
线程池为什么要用有界队列:无界队列会让任务无限堆积 → 内存 OOM,且拒绝策略永不触发,背压失效(这跟 LLM 应用里"队列满了要快速失败而不是默默堆积"是同一个道理)。
四、Spring 生态
25. IoC 和 DI 是什么?Bean 的生命周期?★★
- IoC:控制反转——对象的创建与依赖管理交给容器,不再是
new。DI 是 IoC 的实现方式(构造器/Setter/字段注入)。 - Bean 生命周期(完整版,能背出顺序就赢一半):
- 实例化(构造器)
- 属性填充(依赖注入)
- Aware 回调:
BeanNameAware→BeanFactoryAware→ApplicationContextAware BeanPostProcessor.postProcessBeforeInitialization@PostConstruct→InitializingBean.afterPropertiesSet→init-methodBeanPostProcessor.postProcessAfterInitialization← AOP 代理在这里生成- 使用中
@PreDestroy→DisposableBean.destroy→destroy-method
- 为什么要背:几乎所有"为什么我的 Bean 拿到的不是原始对象"(AOP 代理)"为什么
@PostConstruct里拿不到事务"都能从这里推出答案。
26. Spring 怎么解决循环依赖?为什么需要三级缓存?🔥
什么是循环依赖:A 依赖 B,B 依赖 A。
三级缓存:
| 缓存 | 内容 | 作用 |
|---|---|---|
一级 singletonObjects | 完整的成品 Bean | 最终使用 |
二级 earlySingletonObjects | 半成品(已实例化、未填充属性) | 提前暴露 |
三级 singletonFactories | 生成代理的工厂(ObjectFactory) | 延迟决定是否需要代理 |
流程:A 实例化后把自己的工厂放入三级缓存 → 填充属性发现需要 B → 创建 B → B 填充属性需要 A → 从三级缓存拿到工厂生成 early A,放入二级缓存 → B 完成 → A 完成。
为什么非要三级,二级不行吗(这才是区分度):
- 核心原因是 AOP:如果 A 需要被代理,必须保证 B 注入的 A 和最终放进容器的 A 是同一个代理对象。
- 只用二级缓存:要么提前生成代理(但"是否需要代理"此时还没定,
BeanPostProcessor还没跑完),要么拿到原始对象(和最终代理不是同一个 → 注入的对象和容器里的对象不一致)。 - 三级缓存存的是工厂,把"是否生成代理"延迟到真正被引用时再决定——用一次函数调用换取代理的一致性。
解决不了的场景:
- 构造器注入的循环依赖(实例化阶段就卡住,三级缓存救不了)→ 改用
@Lazy或 setter 注入; - 原型(prototype)Bean 的循环依赖 → 不支持(不缓存);
@Async代理的循环依赖 → 常见坑,Spring Boot 2.6+ 默认禁止循环依赖(spring.main.allow-circular-references=false),要显式开启。
三级缓存解决循环依赖图解:
flowchart TB
A1["创建 A:实例化(构造器)<br/>此时 A 是半成品"] --> A2["把 A 的 ObjectFactory<br/>放入【三级缓存】"]
A2 --> A3["A 填充属性 → 发现依赖 B"]
A3 --> B1["创建 B:实例化 → 填充属性<br/>发现依赖 A"]
B1 --> B2{"从三级缓存取 A 的工厂"}
B2 -->|"调用工厂"| B3{"A 需要 AOP 代理吗?"}
B3 -->|"需要"| B4["生成 A 的代理对象<br/>放入【二级缓存】"]
B3 -->|"不需要"| B5["返回 A 原始对象<br/>放入【二级缓存】"]
B4 --> B6["B 注入到 A 的引用<br/>B 完成 → 放入【一级缓存】"]
B5 --> B6
B6 --> A4["A 继续填充完成<br/>放入【一级缓存】"]
A2 -.- N1["三级缓存存的是【工厂】<br/>延迟决定是否代理"]
B2 -.- N2["核心:保证 B 注入的 A<br/>= 容器最终持有的 A"]
为什么必须是三级(二级不行的原因):如果只做二级缓存,A 必须在"还没跑完 BeanPostProcessor"时就被放进缓存——此时无法确定要不要生成代理。要么提前生成代理(时机不对),要么存原始对象(和最终放进容器的代理不是同一个对象,B 拿到的 A 与容器里的 A 不一致)。三级缓存用一个工厂函数把"是否代理"的判断延迟到真正被引用时,用一次函数调用换来代理的一致性。
27. @Transactional 为什么会失效?(8 种场景,必考)★★
| # | 场景 | 原因 | 解法 |
|---|---|---|---|
| 1 | 同类内部自调用 | this.method() 不走代理 | 拆到另一个 Bean / 注入自己 / AopContext.currentProxy() |
| 2 | 方法不是 public | CGLIB/JDK 代理不拦非 public | 改 public |
| 3 | 异常被 catch 吞掉 | 容器感知不到异常 | 抛出去,或 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() |
| 4 | 抛的是受检异常 | 默认只回滚 RuntimeException/Error | @Transactional(rollbackFor = Exception.class) |
| 5 | 类没被 Spring 管理 | 自己 new 的对象没有代理 | 交给容器 |
| 6 | 数据库引擎不支持事务 | MyISAM 无事务 | 用 InnoDB |
| 7 | 传播行为设置不当 | REQUIRES_NEW 会新开事务,外部回滚不影响它 | 理解 7 种传播级别 |
| 8 | 多线程调用 | 事务绑定在 ThreadLocal 上,子线程拿不到 | 别在事务里开异步;或手动管理 |
7 种传播级别(记三个就够用):REQUIRED(默认,有则加入无则新建)、REQUIRES_NEW(总是新建,挂起外部)、NESTED(嵌套,靠 savepoint)。
28. Spring AOP 的原理与代理选择?
- 两种代理:有接口 → JDK 动态代理;无接口 → CGLIB(Spring Boot 2.x 起
proxyTargetClass=true默认强制 CGLIB)。 - 通知类型:
@Before/@After/@AfterReturning/@AfterThrowing/@Around(最强,可控制是否执行)。 @Around的坑:必须return proceed(),否则原方法不执行;必须把异常往外抛,否则事务失效。- 多个切面的顺序:
@Order控制,值越小越先进入、越后退出。
29. Spring MVC 的一次请求流程?★
DispatcherServlet 接收 → HandlerMapping 找处理器(含拦截器链)→ HandlerAdapter 适配调用 → 参数解析(HandlerMethodArgumentResolver)→ 执行 Controller → 返回值处理(HandlerMethodReturnValueHandler + HttpMessageConverter 序列化)→ 渲染或写响应 → 异常走 HandlerExceptionResolver。
三个易混组件:
| 组件 | 作用域 | 能否拿到 Bean | 典型用途 |
|---|---|---|---|
| Filter | Servlet 容器 | 可以(容器管理) | 编码、跨域、请求包装 |
| Interceptor | Spring MVC | 可以 | 登录校验、权限、日志 |
| AOP | Spring Bean 方法 | 可以 | 事务、缓存、幂等 |
顺序:Filter → Interceptor → AOP → Controller。
30. Spring Boot 自动配置原理?★
@SpringBootApplication = @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan。
核心链路:
@EnableAutoConfiguration通过@Import(AutoConfigurationImportSelector.class)生效;- 读取
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(Spring Boot 2.7+ 新位置,之前是spring.factories)拿到候选配置类全限定名列表; - 用
@Conditional系列筛选:@ConditionalOnClass(类存在才生效)、@ConditionalOnMissingBean(用户没定义才生效)、@ConditionalOnProperty(配置开关); - 生效的配置类注册 Bean。
关键理解:自动配置是**"约定优于配置 + 条件装配",而且用户的 Bean 优先**(@ConditionalOnMissingBean 保证可覆盖)。
怎么自定义一个 Starter(加分):写 xxx-spring-boot-starter 模块 → 定义 AutoConfiguration 类 + @ConditionalOnProperty 开关 → 写 AutoConfiguration.imports 文件 → 加 @ConfigurationProperties 支持外部配置。
31. Spring Cloud 核心组件(微服务全景)★
| 能力 | 主流选型 | 要点 |
|---|---|---|
| 注册中心 | Nacos(推荐)/ Eureka / Consul / Zookeeper | Nacos 兼具配置中心;CP vs AP 要能说清 |
| 配置中心 | Nacos / Apollo | 动态刷新 @RefreshScope;灰度发布配置 |
| 网关 | Spring Cloud Gateway / Zuul | 基于 WebFlux 非阻塞;路由、限流、鉴权、灰度 |
| 服务调用 | OpenFeign / RestTemplate | 声明式;整合负载均衡 LoadBalancer |
| 负载均衡 | Spring Cloud LoadBalancer / Ribbon | 轮询/随机/权重;要能自定义 |
| 熔断降级 | Sentinel / Resilience4j / Hystrix(停更) | 熔断三态:关闭→打开→半开 |
| 分布式事务 | Seata | AT/TCC/Saga 模式 |
| 链路追踪 | SkyWalking / Sleuth+Zipkin | traceId 贯穿全链路 |
| 消息驱动 | Spring Cloud Stream | 屏蔽 MQ 差异 |
Nacos 为什么用 CP+AP 混合:临时实例用 AP(心跳、可容忍不一致),持久化实例用 CP(Raft);注册中心的可用性比一致性更重要——这是设计取舍的经典案例。
32. 分布式锁的三种实现与对比?★★
| 实现 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Redis(SETNX + 过期时间) | SET key value NX PX 30000 | 性能最好 | 锁过期但业务没跑完 → 误删 |
| Redisson | 看门狗自动续期 + Lua 保证原子解锁 | 生产推荐 | 依赖 Redis 可用性 |
| ZooKeeper | 临时顺序节点 + Watch | 强一致,会话断开锁自动释放 | 性能不如 Redis |
| 数据库唯一索引/悲观锁 | insert 唯一键冲突 | 简单、不引组件 | 性能差、DB 压力大 |
三个必须说清的细节:
- 释放锁必须校验持有者:value 存
UUID + 线程ID,用 Lua 脚本保证"判断 + 删除"是原子的(if redis.call('get',KEYS[1])==ARGV[1] then del end)。 - 锁超时怎么续期:Redisson 的看门狗默认 30s 锁、每 10s(1/3)续期一次,直到业务结束。
- Redlock 的争议:Martin Kleppmann 认为它依赖系统时钟、非完全可靠;工程上更推荐"加锁只作为优化,正确性靠数据库唯一约束/状态机兜底"——锁失效了业务也不能出错,这才是稳的思路。
33. 分布式事务?六种方案怎么选?
| 方案 | 一致性 | 性能 | 适用 |
|---|---|---|---|
| 2PC/XA | 强 | 差(同步阻塞) | 传统金融、单库多源 |
| TCC | 强(业务补偿) | 中 | 资金/库存等强一致场景,开发成本高 |
| 可靠消息(最终一致) | 最终 | 好 | 订单 → 积分/通知,最常用 |
| 本地消息表 | 最终 | 好 | 无 MQ 时的兜底 |
| Saga | 最终(补偿) | 好 | 长流程业务 |
| Seata AT | 强(自动回滚) | 中 | 接入成本最低,但全局锁有性能损耗 |
最优解往往是"别用分布式事务":把强一致需求收敛到单个服务 + 单库事务内(服务边界设计得好,就不需要跨服务事务);跨服务的用最终一致 + 幂等。
五、MySQL 深度(与主攻方向重合,投入产出比最高)
34. 一条 SQL 的执行流程?
客户端 → 连接器(权限、连接管理)→ 查询缓存(8.0 已移除,因为命中率低)→ 解析器(词法/语法分析)→ 优化器(选索引、定执行计划)→ 执行器(调存储引擎接口)→ 存储引擎(InnoDB,操作 Buffer Pool + redo/undo log)→ 返回。
35. 为什么索引用 B+ 树?和 B 树、哈希、红黑树的区别?★
| 结构 | 为什么不用 |
|---|---|
| 哈希 | 等值查询 O(1) 很快,但不支持范围查询和排序 |
| 二叉/红黑树 | 数据量大时树太高(百万数据 ~20 层),一次查询 20 次磁盘 IO |
| B 树 | 值存在非叶子节点 → 单节点容纳的 key 更少 → 树更高;范围查询要中序遍历、跳来跳去 |
| B+ 树(选中) | ① 只有叶子存数据,非叶子只存 key → 扇出大、树矮(3~4 层可存千万级);② 叶子形成有序双向链表 → 范围查询和排序极快(order by 走索引免排序) |
为什么是"矮胖"而不是"瘦高":磁盘 IO 是瓶颈,每次 IO 读一页(16KB),一页能放的 key 越多,树越矮,IO 次数越少——本质是"用节点内的二分换磁盘 IO 次数"。
36. 聚簇索引 vs 二级索引?回表?覆盖索引?★
- 聚簇索引(主键索引):叶子节点存整行数据。所以 InnoDB 必须有主键(没定义会自动生成隐藏 6 字节 row_id)。
- 二级索引(辅助索引):叶子存主键值,不是行数据。
- 回表:走二级索引查到主键 → 再回聚簇索引查整行。多一次 B+ 树查找。
- 覆盖索引:查询字段全在索引里 → 不需要回表。这是最重要的优化手段之一(
explain看Extra: Using index)。 - 为什么推荐自增主键:随机主键(如 UUID)会导致页分裂和碎片,插入性能差、索引占用大;自增主键总是追加在最后。
37. 最左前缀 + 索引失效的 8 种场景?★★
最左前缀:联合索引 (a, b, c) 能支持 a / a,b / a,b,c;跳过 a 直接查 b 用不上索引(8.0 的索引下推 ICP 和跳跃扫描有所缓解,但别依赖)。
索引失效清单(必背):
| # | 写法 | 为什么失效 |
|---|---|---|
| 1 | where name like '%张' | 前导 % 无法定位,只能全扫 |
| 2 | where id + 1 = 5 / where func(col) = x | 对索引列做运算或函数 |
| 3 | where phone = 13800000000(phone 是 varchar) | 隐式类型转换(等价于 CAST(phone AS int)) |
| 4 | where status != 1 / not in / is not null | 否定条件,优化器判断全表扫更快 |
| 5 | where a = 1 or b = 2(b 无索引) | OR 导致全表扫 |
| 6 | order by 字段与索引顺序不一致 | 无法用索引排序 |
| 7 | 联合索引跳过最左列 | 违反最左前缀 |
| 8 | 区分度太低(如性别) | 优化器觉得回表成本 > 全表扫,主动放弃索引 |
注意第 8 条:"索引失效"不总是写法问题,也可能是优化器基于成本的主动选择——这句能体现你懂优化器,而不是只背清单。
38. 事务隔离级别与 MVCC?★★
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | ✓ | ✓ | ✓ |
| 读已提交(RC) | ✗ | ✓ | ✓ |
| 可重复读(RR,MySQL 默认) | ✗ | ✗ | InnoDB 靠间隙锁基本解决 |
| 串行化 | ✗ | ✗ | ✗ |
MVCC 实现三件套:隐藏字段(DB_TRX_ID 事务ID、DB_ROLL_PTR 回滚指针)+ undo log 版本链 + ReadView。
- ReadView 四要素:当前活跃事务 ID 列表
m_ids、最小活跃 IDmin_trx_id、下一个待分配 IDmax_trx_id、创建者 ID。 - 可见性判断:行的
trx_id < min_trx_id→ 可见;>= max_trx_id→ 不可见;在中间 → 查m_ids,在里面说明还没提交 → 不可见 → 沿 undo log 找上一个版本。 - RC vs RR 的唯一区别:ReadView 的生成时机——RC 每次查询都生成新的(能读到别人新提交的),RR 只在第一次查询时生成一次(所以整个事务看到的是同一个快照)。
快照读 vs 当前读(高频追问):
- 快照读(普通
select)→ 走 MVCC,不加锁; - 当前读(
select ... for update/lock in share mode/update/delete/insert)→ 读最新版本并加锁。
MVCC 可见性判断图解:
flowchart TB
S["执行快照读 select<br/>找到目标行"] --> R["读取行的隐藏字段<br/>DB_TRX_ID(最后修改它的事务ID)"]
R --> C1{"trx_id < min_trx_id ?<br/>(比所有活跃事务都老)"}
C1 -->|"是"| V1["✅ 可见<br/>此版本在本次快照前已提交"]
C1 -->|"否"| C2{"trx_id >= max_trx_id ?<br/>(是快照之后才开启的事务)"}
C2 -->|"是"| V2["❌ 不可见"]
C2 -->|"否"| C3{"trx_id 在 m_ids 活跃列表里?"}
C3 -->|"是(还没提交)"| V2
C3 -->|"否(已提交)"| V1
V2 --> U["沿 undo log 回滚指针<br/>找上一个版本,重新判断"]
U --> R
V1 -.- N1["RR:ReadView 只在<br/>第一次查询时生成一次"]
V2 -.- N2["RC:每次查询<br/>都重新生成 ReadView"]
RC 与 RR 的唯一区别就在 ReadView 的生成时机:RC 每次 select 都生成新的 ReadView → 能读到别的事务新提交的数据;RR 只在第一次查询时生成一次并复用 → 整个事务看到的是同一个一致性快照。这就是"可重复读"的实现方式。
39. InnoDB 的锁:行锁、间隙锁、临键锁?★
- 行锁:加在索引上(没有索引就退化成表锁——这是经典事故)。
- 记录锁(Record Lock):锁单条索引记录。
- 间隙锁(Gap Lock):锁区间,防止插入(解决幻读);只在 RR 级别生效。
- 临键锁(Next-Key Lock):记录锁 + 间隙锁 = 锁"左开右闭区间",RR 下默认。
- 插入意向锁:INSERT 前的特殊间隙锁,多个事务可在同一间隙插入不同值。
- 死锁排查:
show engine innodb status看LATEST DETECTED DEADLOCK;常见原因:两个事务加锁顺序相反、无索引更新导致锁全表、间隙锁范围重叠。
40. redo log / undo log / binlog 的区别与两阶段提交?★
| 日志 | 层 | 内容 | 作用 |
|---|---|---|---|
| redo log | InnoDB | 物理日志(页的修改) | 崩溃恢复(WAL 先写日志再写盘) |
| undo log | InnoDB | 逻辑日志(反向操作) | 事务回滚 + MVCC 版本链 |
| binlog | Server 层 | 逻辑日志(SQL/行变更) | 主从复制 + 数据恢复;三种格式 STATEMENT/ROW/MIXED |
两阶段提交(为什么需要):prepare(写 redo,标记 prepare)→ 写 binlog → commit(写 redo commit 标记)。目的是让 redo 与 binlog 逻辑一致——如果只写了一个,主从/恢复就会数据不一致。崩溃恢复规则:redo 有 prepare 且 binlog 完整 → 提交;否则回滚。
WAL 的意义:随机写变顺序写——先把变更写进 redo(顺序追加),再异步刷脏页,避免了每次都要随机写磁盘页。
41. explain 怎么用?慢 SQL 优化 SOP?★★
explain 关键列:
| 列 | 关键值 | 含义 |
|---|---|---|
type | system > const > eq_ref > ref > range > index > ALL | 至少要到 range,出现 ALL 要警惕 |
key | 实际用的索引 | NULL = 没走索引 |
rows | 预估扫描行数 | 越小越好 |
filtered | 过滤比例 | 越低说明扫了很多无用行 |
Extra | Using index(覆盖索引,好)/ Using filesort(要优化)/ Using temporary(要优化)/ Using index condition(ICP) | 最重要的诊断信息 |
优化 SOP(方法论,比背结论重要):
- 先定位:开慢查询日志(
slow_query_log+long_query_time),或performance_schema/sys.schema找 TOP SQL; - 再解释:
explain看 type/key/rows/Extra; - 对症下药(按收益排序):加合适索引(含覆盖索引)> 改写 SQL(避免函数/隐式转换)> 减少回表 > 拆大 SQL > 加缓存 > 分库分表;
- 验证并回归:改完再看
explain+ 实际耗时,不做没验证的优化。
深分页优化(高频):limit 1000000, 10 会扫 100 万行再丢弃。
- 方案 A:延迟关联——
select * from t join (select id from t order by id limit 1000000,10) x on t.id = x.id(先走覆盖索引拿 id,再回表 10 次); - 方案 B:游标/书签——
where id > 上次最大 id limit 10(推荐,无偏移量问题)。
42. 分库分表怎么做?分片键怎么选?
- 垂直拆分:按业务拆库(订单库/用户库)、按字段拆表(大字段单独拆)。
- 水平拆分:按行拆(取模/Range/一致性哈希)。
- 中间件:ShardingSphere(JDBC 客户端,无代理损耗)/ MyCat(代理)。
- 分片键选择原则:高基数、分布均匀、查询必带。订单表按
user_id分,这样"查我的订单"能命中单库;如果按order_id分,查用户订单就要扫所有分片。 - 必须解决的问题:
- 跨分片查询:聚合、分页、排序 → 用宽表/ES 冗余或中间件内存归并;
- 全局唯一 ID:雪花算法(
Snowflake,注意时钟回拨)、号段模式(美团 Leaf); - 分布式事务:尽量让分片键相同的事务落在一个库;
- 扩容:取模难扩容 → 用一致性哈希/预分片(如分成 1024 个逻辑表映射到物理表)。
别急着分库分表(面试加分):先做索引优化 → 读写分离 → 归档冷数据 → 加缓存,这些做完还扛不住才分片——分片是架构复杂度的一次跃迁。
43. 主从复制原理与延迟怎么解决?★
- 原理:主库写 binlog → 从库
IO 线程拉取写 relay log →SQL 线程重放。 - 复制模式:异步(默认,最快但可能丢数据)/ 半同步(至少一个从库确认)/ 全同步(MGR/组复制)。
- 主从延迟原因:从库单线程重放(5.7+ 支持并行复制:按库/组提交并行)、大事务、从库压力大。
- 延迟的应对:
- 强一致读走主库(如"写后立刻读"场景,用
/* master */hint); - 读写分离要防"读到自己没写的数据":写后一段时间内强制走主库;
- 拆分大事务、提升从库配置。
- 强一致读走主库(如"写后立刻读"场景,用
- 读写分离中间件:ShardingSphere-JDBC / MyCat / ProxySQL。
44. 高并发扣库存怎么做?(秒杀必考)★★
四个必须解决的点:
| 问题 | 方案 |
|---|---|
| 超卖 | update stock set num = num - 1 where id = ? and num > 0(用 DB 的原子性兜底,判断受影响行数);或 Redis DECR + Lua 原子判断 |
| 热点行更新 | 单行锁竞争激烈 → 库存分段(把 1 个库存行拆成 10 行,每行 1/10,随机路由);或 Redis 原子扣减 |
| 瞬时流量 | 前端限流(按钮置灰、验证码)→ 网关限流 → MQ 削峰(下单请求入队,异步创建订单)→ 库存预热到 Redis |
| 幂等 | 用户 ID + 商品 ID 唯一索引 / Redis 去重,防重复下单 |
完整链路:前端限流 → 网关限流 → Redis 预扣库存(Lua 保证原子) → 通过则发 MQ → 消费者异步落库创建订单 → DB 扣减(带 num > 0 条件兜底)→ 失败则回补 Redis 库存。
关键设计哲学:"漏斗式过滤"——每一层都拦掉大部分无效流量,让 DB 只承受最终要成交的那部分请求。别让 100 万请求都打到数据库。
秒杀系统分层漏斗图解:
flowchart LR
U["100 万用户<br/>同一时刻点击"] --> L1["① 前端层<br/>按钮置灰 / 验证码 / 答题<br/>→ 剩 30 万"]
L1 --> L2["② 网关层<br/>限流 / 黑名单 / 防刷<br/>→ 剩 10 万"]
L2 --> L3["③ Redis 层<br/>库存预热 + Lua 原子扣减<br/>(判断库存 + 防重购买一次完成)<br/>→ 剩 1 万"]
L3 --> L4["④ MQ 削峰<br/>请求入队,异步处理"]
L4 --> L5["⑤ 消费者<br/>异步创建订单 + 落库"]
L5 --> L6["⑥ DB 兜底<br/>update ... where num > 0<br/>+ 唯一索引防重复"]
L3 -.- N1["Redis 是主战场:<br/>扛住 90% 的无效请求"]
L6 -.- N2["DB 只承受<br/>最终要成交的量"]
L4 -.- N3["削峰本质:<br/>把同步变异步"]
为什么不能让 DB 直接扛:单行库存的 update 会加行锁,1 万个并发请求排队等锁 → 连接池瞬间打满 → 整个应用被这一个接口拖垮(哪怕是无关的接口也没连接可用)。所以必须用 Redis + MQ 把 DB 保护起来。
六、Redis
45. Redis 为什么快?单线程还是多线程?★
- 快的原因:① 纯内存操作;② 单线程避免锁竞争和上下文切换;③ IO 多路复用(epoll);④ 高效数据结构(SDS、跳表、ziplist、listpack)。
- "单线程"的准确说法:命令执行是单线程;网络 IO 在 6.0 后是多线程(
io-threads);持久化、异步删除(UNLINK)用后台线程。 - 单线程的代价:一个慢命令会阻塞所有请求(
keys *、hgetall大 key、flushall)——这是最常见的线上事故来源。
46. Redis 的数据结构与底层编码?★
| 类型 | 底层编码 | 典型场景 |
|---|---|---|
| String | int / embstr / raw(SDS) | 缓存、计数器、分布式锁 |
| Hash | listpack(小)/ hashtable(大) | 对象存储、购物车 |
| List | listpack / quicklist | 消息队列(不推荐)、最新列表 |
| Set | intset / listpack / hashtable | 去重、共同好友(交集) |
| ZSet | listpack / 跳表 + 字典 | 排行榜、延迟队列 |
| Bitmap / HyperLogLog / GEO / Stream | 特殊结构 | 签到、UV 去重、附近的人、消息流 |
跳表为什么替代红黑树:实现简单、范围查询友好(天然有序链表 + 多层索引),并发场景更好实现平衡(Redis 用跳表就是因为 zset 的 ZRANGE 操作更简单)。
47. 缓存穿透 / 击穿 / 雪崩怎么解决?★★
| 问题 | 现象 | 方案 |
|---|---|---|
| 穿透 | 查不存在的数据,每次都打到 DB | ① 缓存空值(短 TTL,如 60s);② 布隆过滤器(前置拦截,注意不支持删除);③ 参数校验 |
| 击穿 | 单个热点 key 过期,瞬间大量请求打 DB | ① 互斥锁/分布式锁(只放一个请求查 DB);② 逻辑过期(不设 TTL,异步刷新);③ 热点 key 永不过期 |
| 雪崩 | 大量 key 同时过期 或 Redis 挂了 | ① 过期时间加随机值;② 多级缓存(本地 Caffeine + Redis);③ 集群/哨兵高可用;④ 限流降级兜底 |
三个问题的本质区别(背这个比背方案有用):穿透是"数据不存在",击穿是"单个热点失效",雪崩是"大面积同时失效"——原因不同,解法自然不同。
48. 缓存与数据库的一致性?🔥
主流方案:
| 方案 | 说明 | 问题 |
|---|---|---|
| Cache Aside(旁路缓存) | 读:先读缓存,没有则读 DB 并回填;写:先更新 DB,再删除缓存 | 最常用 |
| 先删缓存再更新 DB | — | 并发下容易脏(读请求把旧值回填) |
| 延迟双删 | 更新 DB 前后各删一次缓存,中间 sleep | 缓解但不彻底,sleep 时间难定 |
| 订阅 binlog(Canal)异步删缓存 | 解耦、可靠 | 工程上最推荐 |
为什么是"删除"缓存而不是"更新"缓存:
- 更新缓存的并发风险更高(两个写请求的到达顺序和 DB 提交顺序可能相反 → 缓存里留旧值);
- 懒加载更省资源(不常读的数据不必回填);
- 删除是幂等的,重复执行没问题。
必答的一句:"缓存一致性做不到强一致,只能做到最终一致"——因为缓存和 DB 是两个系统,除非上分布式事务(得不偿失)。业务上要接受短暂不一致,用 TTL + binlog 补偿把窗口压到最小。
更彻底的方案:读走缓存、写走 DB + binlog 同步,或者用 Redis 作为唯一数据源 + 定期落库(仅适用于能接受丢失的场景)。
49. 持久化:RDB vs AOF?★
| 维度 | RDB | AOF |
|---|---|---|
| 内容 | 快照(二进制) | 写命令(追加) |
| 恢复速度 | 快 | 慢(要重放) |
| 数据安全 | 差(可能丢几分钟) | 好(appendfsync everysec 最多丢 1s) |
| 文件大小 | 小(压缩) | 大 |
| 阻塞 | fork 时有短暂阻塞 | 追加几乎无阻塞;重写时 fork |
混合持久化(4.0+,推荐):AOF 重写时把当前数据以 RDB 格式写入 AOF 文件头 → 恢复快 + 丢得少。
关键点:fork 用写时复制(COW),但如果有大量写操作,会复制内存页 → 内存可能翻倍(这是 Redis 内存突增的常见原因)。
50. 过期删除策略与内存淘汰?
- 过期删除:惰性删除(访问时才检查)+ 定期删除(每 100ms 随机抽样检查,避免全量扫描)。
- 内存淘汰 8 种策略(
maxmemory-policy):noeviction(默认,写满报错)/allkeys-lru/allkeys-lfu/allkeys-random/volatile-lru/volatile-lfu/volatile-random/volatile-ttl。- 选型:缓存场景用
allkeys-lru(或allkeys-lfu,热点访问更准);有持久化数据不能丢 →volatile-*(只淘汰有 TTL 的)。
- 为什么不用"全量扫描过期 key":CPU 成本太高,抽样 + 惰性是时间与空间的折中。
51. 大 key / 热 key 怎么处理?★
- 大 key 的危害:阻塞主线程(单线程执行删除/读取)、网络拥塞、内存不均。
- 发现:
redis-cli --bigkeys、MEMORY USAGE key、--hotkeys(需 LFU 策略)。 - 处理:① 拆分(大 Hash 拆成多个小 Hash,用分片键路由);② 异步删除
UNLINK替代DEL;③ 读用HSCAN分批。 - 热 key 的处理:① 本地缓存(Caffeine,多一层,但要注意一致性);② key 加随机后缀分散到多个节点(读时随机选一个);③ 读写分离 + 多副本。
52. Redis 分布式锁的正确实现?★
// 加锁:SET key value NX PX 30000(value 用 UUID+线程ID)
// 解锁:Lua 保证"校验 + 删除"原子
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
四个坑:
- 忘了设过期时间 → 死锁(进程挂了锁永远在);
- 误删别人的锁 → 必须用唯一 value 校验(没有原子校验 = 等于没锁);
- 业务超时锁自动释放 → 用 Redisson 看门狗续期;
- 主从切换丢锁 → 主库写了锁还没同步就宕机 → 从库升主后别人也能拿到锁。Redlock 试图解决但仍有争议;工程上更推荐用数据库唯一约束/状态机做最终兜底。
七、消息队列
53. 为什么用 MQ?三大用途与代价?
- 三大用途:解耦(生产者不关心消费者)、异步(缩短响应时间)、削峰(峰值流量缓冲)。
- 代价:系统复杂度上升、一致性问题(消息丢失/重复/顺序)、运维成本、端到端延迟增加(不是所有场景都该异步)。
- 选型: | MQ | 特点 | 适用 | | --- | --- | --- | | Kafka | 吞吐最高、分区顺序、生态好 | 日志、埋点、大数据管道 | | RocketMQ | 事务消息、延迟消息、消息回溯 | 电商/金融业务(Java 生态友好) | | RabbitMQ | 路由灵活、延迟低 | 业务解耦、中小规模 | | Pulsar | 存算分离、多租户 | 云原生场景 |
54. 消息丢失、重复、顺序怎么解决?★★
丢失(三段都要防):
| 环节 | 方案 |
|---|---|
| 生产者 → Broker | 同步发送 + 确认机制(Kafka acks=all、RocketMQ 同步双写);失败重试 |
| Broker 存盘 | 副本数 ≥ 2 + min.insync.replicas;刷盘策略(同步刷盘更安全) |
| Broker → 消费者 | 手动 ACK(处理完业务才提交 offset),别用自动提交 |
重复(必答:只能"幂等",不能"不重复"):
- 原因:网络重试、ACK 丢失导致重投、消费者重启。
- 幂等三招:① 唯一索引(最强,DB 兜底);② 去重表/Redis 记录消息 ID(注意 TTL);③ 状态机(只允许特定状态流转,天然幂等)。
- 金句:"MQ 保证的是 at-least-once,业务必须自己做成幂等,别指望 exactly-once。"
顺序:
- 全局有序代价极高(单分区/单队列),大多数场景只需要"局部有序";
- 方案:按业务键(如 orderId)哈希到同一分区/队列 → 同一订单的消息天然有序 + 消费端单线程处理该分区。
- 坑:消费端为了提速用了线程池 → 顺序又乱;重试/死信队列也会打乱顺序。
55. 消息积压怎么办?
- 先定位原因:消费者挂了?消费变慢(下游 DB/接口慢)?流量突增?
- 临时扩容:加消费者实例(注意分区数上限——Kafka 里消费者数不能超过分区数);
- 紧急处理:新建临时 topic + 更多分区,写个转发程序把积压消息快速搬过去,再用大量消费者并行消费(这是标准应急方案);
- 降级:非核心消息直接丢弃(记录日志);
- 治本:消费端异步化、批量消费、下游加缓存、设置合理的重试与死信队列(别让坏消息无限重试堵住整个队列)。
八、高并发与性能优化(与语言无关,主攻方向也能用)
56. 高并发系统的分层优化方法论?★★
核心思路:先分层定位,再逐层优化;不要一上来就加缓存。
flowchart TB
L1["① 客户端层<br/>合并请求 / 本地缓存 / 防重复提交"] --> L2["② 接入层(CDN + 网关)<br/>静态资源 CDN / 限流 / 鉴权 / 灰度"]
L2 --> L3["③ 应用层(无状态 + 水平扩容)<br/>线程池隔离 / 异步化 / 连接池调优"]
L3 --> L4["④ 缓存层(收益最高)<br/>本地缓存 → Redis → 多级缓存"]
L4 --> L5["⑤ 存储层<br/>索引优化 / 读写分离 / 分库分表 / 归档"]
L5 --> L6["⑥ 异步与削峰<br/>MQ 解耦 / 任务队列 / 批量处理"]
L3 -.- N1["扩容之前先确认:<br/>应用是不是无状态"]
L4 -.- N2["缓存是性价比之王<br/>但先解决一致性问题"]
L6 -.- N3["削峰的本质是<br/>把同步变异步"]
优化优先级(按性价比排序):
- 加缓存(命中即 0 成本,收益最大);
- 异步化(不该同步等的别等:发通知、写日志、统计);
- 减少调用次数(N+1 查询、循环内 RPC → 批量合并);
- SQL 与索引优化(往往是"慢"的真正原因);
- 水平扩容(应用无状态后最简单,但扩不动 DB);
- 分库分表(最后的复杂手段,谨慎用)。
57. 连接池怎么配?
- HikariCP 关键参数:
| 参数 | 建议 | 说明 |
| --- | --- | --- |
|
maximumPoolSize| 不是越大越好 | 经验公式核数 × 2 + 磁盘数;DB 的连接数是全局瓶颈,池太大反而让 DB 抖动 | |minimumIdle| 与 max 相同 | 避免连接频繁创建销毁 | |connectionTimeout| 3s | 拿不到连接要快速失败,别默默等待 | |maxLifetime| 比 DB 的wait_timeout小 | 防止拿到已被 DB 关闭的死连接 | |leakDetectionThreshold| 10s | 连接泄漏检测(排查"连接忘了关"神器) | - 常见事故:连接池打满 → 接口全超时。排查:看
activeConnections与waitingThreads;根因通常是慢 SQL 占住连接或事务范围过大。 - 硬规则:事务里绝不放 RPC/HTTP 调用(把事务和连接一起拖长)。
58. 限流算法有哪几种?★
| 算法 | 原理 | 特点 |
|---|---|---|
| 固定窗口 | 每个时间窗口计数 | 简单,但窗口边界有双倍流量问题 |
| 滑动窗口 | 细分小窗口滚动统计 | 平滑,Redis ZSet / Sentinel 实现 |
| 漏桶 | 恒定速率流出,多余排队 | 输出绝对平滑,但无法应对突发 |
| 令牌桶(最常用) | 按速率放令牌,取到才放行 | 允许一定突发(桶容量决定) |
| Sentinel 的匀速排队 + 冷启动 | 漏桶 + 预热 | 生产推荐 |
- 限流维度:按用户 / IP / 接口 / 全局;多维度组合更安全(如"单用户 10 QPS + 全局 5000 QPS")。
- 限流放哪:网关层做第一道(挡掉大部分)+ 应用层做兜底(网关可能被绕过)。
- 被限流后:返回 429 +
Retry-After,绝不静默丢弃(客户端要能感知并退避重试)。
59. 熔断、降级、隔离的区别?★
| 手段 | 目的 | 关键点 |
|---|---|---|
| 熔断 | 防止故障扩散(下游挂了别把自己拖死) | 三态:关闭 → 打开 → 半开;打开期间快速失败 |
| 降级 | 牺牲非核心保核心 | 返回兜底数据(默认值/缓存/缓存快照);降级方案必须提前演练 |
| 隔离 | 限制故障影响范围 | 线程池隔离(每个下游独立线程池)/ 信号量隔离;服务按业务隔离 |
经典组合:超时(最基础的自我保护,必须设)+ 重试(带退避与抖动)+ 熔断(Sentinel/Resilience4j)+ 降级兜底 + 限流。
重试的三个坑:① 重试要带上限和退避(否则把下游打死);② 重试请求必须幂等;③ 多层都重试会放大流量(如 3 层各重试 3 次 = 27 倍)——只在最外层重试。
60. 性能优化怎么落地?(方法论,能体现工程素养)★
- 先量再改:没有度量就没有优化——先建指标(P50/P95/P99、QPS、错误率、资源水位);
- 找瓶颈:分段埋点(网关/应用/缓存/DB),看关键路径最长的一段;用火焰图找 CPU 热点,用
trace找慢方法; - 先做收益最大的:按"改动成本 vs 收益"排序,别一上来就重构架构;
- 改完必回归:压测对比 + 生产灰度验证,禁止猜测型优化;
- 加门禁:把性能阈值写进 CI(响应时间、内存增长、慢 SQL 数量),防止回退。
九、监控与排障
61. 可观测性三大支柱 + 黄金指标?★
- 三大支柱:Metrics(趋势与告警)+ Logs(细节定位)+ Traces(全链路找慢在哪)。
- 四个黄金指标(Google SRE):延迟、流量、错误、饱和度。
- RED(微服务):Rate(请求率)、Errors(错误率)、Duration(延迟分布)。
- USE(资源):Utilization(使用率)、Saturation(饱和度)、Errors(错误数)。
- 必答的一句:延迟要看分位数(P95/P99),不要看平均值——平均值会被少量慢请求掩盖,而用户体验由长尾决定。
62. 线上问题定位 SOP?(综合题)
四步法:
- 确认影响面:是所有用户还是部分?所有接口还是某个?——先止损再排查(回滚/扩容/降级);
- 看监控大盘:QPS、错误率、延迟曲线拐点时间,与发布/配置变更时间对齐(80% 的线上问题是变更引起的);
- 分层定位:网关 → 应用 → 缓存 → DB → 中间件,每层看自己的指标;
- 深入根因:应用层用 Arthas(
dashboard看全局、thread -n看最忙线程、trace看方法耗时、watch看入参出参);DB 层看慢查询与锁等待。
工具速查:
| 场景 | 工具 |
|---|---|
| 看 JVM 整体 | jstat -gcutil / Arthas dashboard |
| 看线程 | jstack / Arthas thread -n 3 / thread -b(找阻塞源) |
| 看内存对象 | jmap -histo:live / MAT |
| 方法级耗时 | Arthas trace(线上无侵入,神器) |
| 动态改日志级别 | Arthas logger |
| 慢 SQL | 慢查询日志 + explain + Druid 监控页 |
| 全链路 | SkyWalking / Zipkin |
63. 接口变慢的常见根因清单(排障时的检查表)★
- DB:无索引/索引失效、N+1 查询、大事务、锁等待、连接池打满;
- 缓存:缓存失效(穿透/击穿)、大 key 阻塞、序列化开销;
- 应用:线程池打满、连接池打满、同步阻塞调用、大对象序列化、日志打太多(尤其是循环里打印);
- 下游:第三方超时重试、MQ 积压;
- GC:Full GC 频繁(看 GC 日志),STW 导致"世界暂停";
- 外部:带宽打满、DNS 解析慢。
纪律:先止损、再看数据、最后改代码;禁止无度量地"我觉得是 XX 问题"。
十、场景设计题(综合能力,最能拉开差距)
64. 设计一个秒杀系统 ★★
分层漏斗设计(每一层都拦掉大部分流量):
- 前端:按钮置灰、验证码、答题、静态资源 CDN 化、限流;
- 网关:IP/用户级限流、黑名单、防刷;
- 应用:库存预热到 Redis、Lua 脚本原子扣减(判断库存 + 扣减 + 防重复购买一次原子完成);
- 异步:扣减成功 → 发 MQ → 消费者异步创建订单、落库;
- DB:
update stock set num = num-1 where id=? and num>0兜底防超卖;唯一索引防重复下单; - 兜底:失败回补 Redis 库存,未支付订单超时取消(
DelayQueue/延迟消息)并回补。
加分点:库存分段(热点行拆分)、答案随机化防脚本、降级预案(Redis 挂了怎么办)、压测容量与限流阈值的关系。
65. 订单 30 分钟未支付自动关闭,怎么实现?★★
| 方案 | 优点 | 缺点 |
|---|---|---|
| 定时任务轮询 | 简单 | 精度差 + 扫表压力大(几百万订单每分钟扫一次不可接受) |
| JDK DelayQueue | 无依赖 | 单机、重启丢失 |
| MQ 延迟消息(RocketMQ 18 级延迟 / RabbitMQ 死信 + TTL) | 可靠、解耦 | 时间粒度受限(RocketMQ 固定档位) |
| Redis 过期事件 + 监听 | 简单 | 事件不保证送达,不能作为唯一手段 |
| 时间轮(Netty HashedWheelTimer) | 高效 | 单机 |
| 推荐:Redis ZSet 时间轮 + 定时扫描 + MQ | 精度与可靠兼顾 | 需自己实现(ZADD 时间戳,定时 ZRANGEBYSCORE 取到期任务) |
关键:关闭订单要做幂等(检查订单状态是否还是"待支付")+ 回补库存/优惠券要与关单在同一事务或用可靠消息。
66. 分布式 ID 怎么生成?★
| 方案 | 特点 | 问题 |
|---|---|---|
| UUID | 本地生成、无依赖 | 无序(作主键会导致页分裂)、36 位太长 |
| DB 自增 | 简单、趋势递增 | 单点瓶颈、分库分表后需步长 |
| 号段模式(Leaf-segment) | 批量取号、性能好 | 依赖 DB(可双 buffer 容错) |
| 雪花算法(Snowflake) | 趋势递增、性能高、无依赖 | 时钟回拨问题;需分配 workerId |
Redis INCR | 简单 | 依赖 Redis 可用性 |
雪花算法结构:1 位符号 + 41 位时间戳(约 69 年) + 10 位机器 ID(1024 台) + 12 位序列号(每毫秒 4096 个)。 时钟回拨怎么办:① 检测到回拨且幅度小 → 等待追平;② 幅度大 → 抛错让上层换 workerId;③ 用 Leaf / 百度 UidGenerator 这类已解决该问题的实现。
67. 接口幂等怎么设计?★
四个层次:
- 前端防重:按钮置灰、
loading(最弱,只能挡误操作); - Token 机制:进入页面先取 token,提交时带上并删除(Redis 原子删除);
- 唯一索引 / 去重表:
order_no唯一键,DB 层最终兜底(最强); - 状态机:只允许合法状态流转(
待支付 → 已支付,重复支付请求因状态不符被拒)。
不同操作选不同方案:查询天然幂等;新增用唯一键/token;更新用乐观锁(version);删除天然幂等。
68. 其他高频场景速答
| 场景 | 关键设计 |
|---|---|
| 排行榜 | Redis ZSet(ZINCRBY 更新、ZREVRANGE 取榜);海量数据用分桶(按分数段)+ 定期落库;实时性要求高用 ZSet 直接算,超大规模用 Redis + 离线计算 混合 |
| 短链系统 | 发号器(自增/雪花)→ Base62 编码;跳转用 302(便于统计)而非 301;Redis 缓存映射,读多写少;防冲突用布隆过滤器 / 唯一索引 |
| 附近的人 | Redis GEO(底层 GeoHash + ZSet)或 PostGIS;先粗筛(网格/GeoHash 前缀)再精算(Haversine) |
| 分布式限流(集群级) | Redis + Lua(原子)+ 滑动窗口 ZSet;注意 Redis 成为新瓶颈 → 可用"本地预分配令牌"降低 Redis 压力 |
| 高并发计数 | Redis INCR(最终一致)或 分片计数 + 定时聚合;强一致场景用 DB 行锁(性能差) |
| 大文件上传 | 分片上传 + 断点续传(记录分片状态)+ 秒传(文件 MD5 查重)+ 合并(服务端按序拼接) |
| 定时任务集群化 | 分布式锁(同一时刻只有一个实例执行)或 XXL-JOB(调度与执行分离、支持分片广播) |
十一、快问快答清单(30 秒内答完)
equals和hashCode的关系? →equals相等则hashCode必须相等;反之不必。只重写一个会让 HashMap/HashSet 语义失效。- HashMap 为什么容量是 2 的幂? →
(n-1) & hash等价取模但更快,且分布更均匀。 - ConcurrentHashMap JDK8 怎么保证线程安全? → CAS + synchronized 锁桶头节点(替代 JDK7 的分段锁),锁粒度更细。
- volatile 保证原子性吗? → 不保证,只保证可见性和禁止重排序。
i++仍需AtomicInteger。 - ThreadLocal 为什么会内存泄漏? → key 是弱引用会被回收,value 是强引用被 Thread 持有;必须显式
remove()。 - 线程池执行流程? → 核心线程 → 队列 → 非核心线程 → 拒绝策略。
- 线程数怎么定? → CPU 密集
N+1;IO 密集N×(1+等待/计算),最终靠压测。 - Spring 为什么需要三级缓存? → 为了在需要时才决定是否生成 AOP 代理,保证注入的对象和容器里的代理是同一个。
@Transactional最常见的失效原因? → 同类内部自调用(不走代理);其次是异常被吞、非 public、受检异常未配rollbackFor。- Spring Boot 自动配置原理? →
@EnableAutoConfiguration读取AutoConfiguration.imports候选类 →@Conditional按条件筛选 → 用户 Bean 优先。 - B+ 树为什么比 B 树适合做索引? → 非叶子不存数据 → 扇出更大、树更矮(IO 更少);叶子有序链表 → 范围查询与排序友好。
- 回表和覆盖索引? → 二级索引拿到主键再查聚簇索引叫回表;查询字段全在索引里就是覆盖索引,免回表。
- RR 和 RC 的 MVCC 区别在哪? → ReadView 生成时机:RC 每次查询生成,RR 只在第一次查询生成(所以整个事务看到同一快照)。
- 没有索引的
update会怎样? → 行锁退化成表锁,并发直接崩——这是最常见的事故原因之一。 - redo / undo / binlog 各干什么? → redo 崩溃恢复(物理)、undo 回滚与 MVCC(逻辑)、binlog 主从复制与恢复(Server 层)。
- 两阶段提交为什么必要? → 保证 redo 与 binlog 逻辑一致,否则崩溃恢复后主从数据会不一致。
- 缓存穿透/击穿/雪崩的区别? → 数据不存在 / 单个热点失效 / 大面积同时失效;对应布隆过滤器+空值缓存 / 互斥锁 / 随机 TTL+多级缓存。
- 为什么缓存是"删除"而不是"更新"? → 并发下更新缓存容易留旧值(写顺序与提交顺序可能相反),删除幂等且懒加载更省资源。
- 分布式锁的三个必备要素? → 唯一 value + 过期时间 + Lua 原子校验删除;生产用 Redisson(看门狗续期)。
- MQ 能保证不重复消费吗? → 不能,只保证 at-least-once;业务必须自己做幂等(唯一索引/去重表/状态机)。
- MQ 消息顺序怎么保证? → 按业务键哈希到同一分区/队列 + 该分区单线程消费;全局有序代价极高。
- 限流算法选哪个? → 令牌桶(允许突发,最常用);漏桶(绝对平滑);滑动窗口(Sentinel 常用)。
- 重试要注意什么? → 上限 + 退避 + 抖动、必须幂等、只在最外层重试(多层重试会放大流量)。
- 延迟为什么看 P99 不看平均值? → 平均值被少量慢请求掩盖,而用户体验由长尾决定。
- CPU 100% 怎么排查? →
top -Hp找线程 → 转 16 进制 →jstack定位代码行;或 Arthasthread -n 3。 - 80% 的线上问题源于什么? → 变更(发布/配置/DDL)。所以先看变更时间线。
附一:Java 方向与你的项目怎么衔接(被问"你是前端/ Python,为什么投 Java")
话术模板(诚实 + 有准备):
我的主线是前端到 AI Agent 全栈,后端主力语言是 Python(FastAPI + MySQL + Redis + LangGraph)。Java 我是按工程需要补的——做智慧中台时和 Java 团队深度协作,做权限模型和接口约定时必须看得懂 Spring 的分层与事务边界;自己也在用 Spring Boot + MyBatis 做过完整的 CRUD 与权限模块。 我认为后端的核心能力是语言无关的:MySQL 索引与事务、Redis 缓存一致性、并发与限流降级、可观测性——这些我在 AI 小灵项目里是实打实做过并扛过线上流量的(日均 5w+ 任务、1000+ 门店、API P95 < 2s)。 语言层面我能快速迁移:JVM 内存模型和线程池、Spring 的 IoC/AOP 与事务失效场景、MyBatis 与 JPA 的取舍,这些都是我已经系统梳理过的。
迁移映射表(把 Python 侧经验翻译成 Java 侧语言):
| 你在 Python 侧做过的 | Java 侧的对应说法 |
|---|---|
| FastAPI 异步 + 连接池 | Spring Boot + HikariCP 连接池调优 |
| SQLAlchemy / 原生 SQL 优化 | MyBatis + explain 索引优化(同一套方法论) |
| Redis 缓存 + 事件日志(append-only) | Redis + binlog/Canal 异步刷缓存 |
| Pydantic 校验 | Bean Validation(@Valid) |
| 依赖注入(FastAPI Depends) | Spring IoC / 构造器注入 |
| 中间件 / 装饰器 | Spring Interceptor / AOP 切面 |
| 幂等键 + 状态机 | 唯一索引 + 状态机 / Token 机制 |
| 限流、降级、重试(应用内) | Sentinel / Resilience4j + 网关限流 |
| 结构化日志 + trace | SkyWalking / Micrometer + 结构化日志 |
最诚实的一句收尾:
我不会假装自己是十年 Java 老手。但我能保证的是:给我一个 Java 项目,我能在两周内读懂分层和事务边界、定位慢 SQL、把接口压测和监控接上——因为这套方法论我在自己的项目里反复用过。
附二:刷题优先级(时间有限时只看这些)
| 优先级 | 章节 | 说明 |
|---|---|---|
| P0(必看) | 第五章 MySQL 深度 + 第六章 Redis + 第三章 并发编程 | 与语言无关的高频必考区,投入产出比最高 |
| P1(重点) | 第四章 Spring 生态(事务失效/循环依赖/AOP)+ 第八章 高并发 + 第十章 场景设计 | 中级岗的区分度题 |
| P2(补漏) | 第二章 JVM + 第七章 MQ + 第九章 监控排障 | 高级岗加分题,初级岗问得少 |
| P3(扫过) | 第一章 Java 语言基础 | 容易,面试前扫一遍即可 |
面试前 3 天的最优动作:把 第五、六章(MySQL + Redis)通读 + 默写第 41/44/47/48/54 题(慢 SQL 优化 SOP、扣库存、缓存三兄弟、缓存一致性、消息不丢不重不乱)——这五题在 Java 后端面试里的出现频率超过 70%。