Java面试八股文实战映射 — 从理论到真实工作场景

面试八股文不是为了背,而是为了用。理解了实战场景,面试时才能答得自然、有深度。

HashMap 线程池 JVM GC Spring AOP Redis 分布式锁

很多开发者背了上百道八股文,进了公司却不知道这些东西到底用在哪。这个页面把每个经典面试题拆成三段:📖 教科书答案💼 真实工作场景🔧 实战代码与排查。读完你会明白:面试官问的不是"你背了多少",而是"你踩过坑没有"。

01HashMap vs ConcurrentHashMap — 面试第一题,线上第一坑

📖 面试标准答案

面试官:"讲一下HashMap的底层原理?为什么需要ConcurrentHashMap?"

标准答案要点:

知识点标准答法
底层结构数组 + 链表(JDK1.8后链表长度>8转红黑树)
默认容量16,负载因子0.75,阈值 = 容量 × 负载因子
hash算法(n-1) & hash,扰动 = (h = key.hashCode()) ^ (h >>> 16)
扩容超过阈值触发resize,容量翻倍,重新rehash
线程安全HashMap非线程安全;ConcurrentHashMap保证安全

JDK版本差异(面试必问):

维度JDK 1.7JDK 1.8+
HashMap结构数组 + 链表数组 + 链表 + 红黑树
ConcurrentHashMapSegment分段锁(默认16段)CAS + synchronized 锁单个桶
并发死循环resize时链表成环,CPU 100%修复了成环,但仍会丢数据

💼 真实工作场景 — 一个本地缓存引发的线上事故

Bug #JIRA-2299 — 线上服务CPU飙升到100%P0

现象

大促活动刚开始10分钟,订单服务3台机器CPU全部飙到100%,接口大面积超时。

背景

订单服务里有一个"商品信息本地缓存",用 HashMap 实现,多线程读写。开发同学心想:"就是个缓存嘛,HashMap够用了。"

影响

下单接口不可用约15分钟,损失订单约2.3万笔。

报告人: 监控告警处理人: @张三2025-11-11 00:12
💡 生活类比:HashMap 就像单人卫生间(没有锁),多人同时挤进去就会出事(数据错乱、死循环);ConcurrentHashMap 就像公共卫生间(每个隔间独立有锁),大家各用各的隔间,互不干扰。

🔧 实战代码 — 错误写法 vs 正确写法

LocalCacheManager.java — order-service
LocalCacheManager.java
📁order-service
📁cache
LocalCacheManager
📁service
📁controller
1// ❌ 错误写法:用HashMap做多线程缓存 —— 线上事故元凶
2@Component
3public class LocalCacheManager {
4 // 多线程并发put会死循环(JDK7)或丢数据(JDK8)
5 private static final Map<String, ProductInfo> cache
6 = new HashMap<>(1024);
7 
8 public ProductInfo get(String key) {
9 ProductInfo val = cache.get(key);
10 if (val == null) {
11 val = loadFromDb(key); // 缓存未命中,查DB
12 cache.put(key, val); // ❌ 并发put触发resize → 死循环
13 }
14 return val;
15 }
16}
UTF-8LFJava⚠ 警告: 非线程安全的HashMap在多线程中使用
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 cache
📄 ConfigCache.java
📄 SystemConfig.java
// ✅ 正确写法:用ConcurrentHashMap替代HashMap @Component public class LocalCacheManager { // ConcurrentHashMap JDK8用 CAS + synchronized 保证线程安全 private static final ConcurrentHashMap<String, ProductInfo> cache = new ConcurrentHashMap<>(1024); // 用 computeIfAbsent 原子地 "查缓存/加载/回填",避免重复查库 public ProductInfo get(String key) { return cache.computeIfAbsent(key, k -> loadFromDb(k)); } }
面试金句 线程安全 Map = 多线程并发读写 Map 的标准答案:ConcurrentHashMap。面试三连问:(1) 为什么不用 HashMap?(多线程扩容导致死循环/数据丢失)(2) 为什么不用 Collections.synchronizedMap?(锁粒度太粗,全表锁)(3) 为什么不用 Hashtable?(同一把锁,性能极差)。CHM:JDK8 后 CAS + synchronized 分段锁,锁粒度=桶。
💭 理论→实战的连接点:面试时背"HashMap线程不安全"只要3秒,但在工作中,你真的会在代码评审时盯住每一个 new HashMap<>(),问一句:"这个Map会被多线程访问吗?"——这就是经验的价值。

排查过程 — jstack 找到死循环线程

线上CPU 100%后,运维同学立刻执行了以下命令定位问题:

# 第1步:找到CPU占用最高的Java进程 $ top -c PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 28831 app 20 0 4.5g 2.1g 15m S 100.3 6.5 8:12.44 java -jar order-service.jar # 第2步:找出该进程里CPU最高的线程 $ top -Hp 28831 PID USER %CPU TIME+ COMMAND 28845 app 98.7 5:33.21 java -jar order-service.jar # 第3步:线程ID转16进制(28845 → 0x70ad) $ printf "%x\n" 28845 70ad # 第4步:dump线程栈,grep那个线程 $ jstack 28831 | grep -A 30 "nid=0x70ad" "http-nio-8080-exec-12" #45 daemon prio=5 os_prio=0 java.lang.Thread.State: RUNNABLE at java.util.HashMap.resize(HashMap.java:703) at java.util.HashMap.putVal(HashMap.java:662) at java.util.HashMap.put(HashMap.java:611) at com.order.cache.LocalCacheManager.get(LocalCacheManager.java:12) at com.order.service.OrderService.createOrder(OrderService.java:87) ↑ 卡在 HashMap.resize() —— 经典的JDK7死循环!
实战提示:看到 HashMap.resize() 在RUNNABLE状态、CPU打满,几乎可以100%确认是HashMap并发问题。JDK8虽然修复了链表成环,但并发put仍会丢数据,所以必须用ConcurrentHashMap。
HashMap 底层结构(JDK 1.8)
Node[0] Node[1] Node[2] → K1 → K2 → K3 → K4 → K5 → K6 → K7 → K8 (→转红黑树) ... Node[15]
put(k,v): hash(k) & (16-1) → 定位桶 → 链表尾插(JDK8)/ 头插(JDK7,会成环)

02线程池 — 七个参数背得溜,无界队列把锅背

📖 面试标准答案

面试官:"线程池的核心参数有哪些?拒绝策略有哪些?怎么合理配置线程数?"

ThreadPoolExecutor 七大参数:

参数含义典型值
corePoolSize核心线程数(不会回收)CPU密集型: N+1,IO密集型: 2N
maximumPoolSize最大线程数corePoolSize × 2~4
keepAliveTime非核心线程空闲存活时间60s
unit存活时间单位TimeUnit.SECONDS
workQueue任务队列有界队列(必须!)
threadFactory线程工厂(命名)建议自定义,方便排查
handler拒绝策略CallerRunsPolicy 最常用

四种拒绝策略:

策略行为适用场景
AbortPolicy(默认)抛RejectedExecutionException重要任务,必须感知失败
CallerRunsPolicy由提交任务的线程自己执行不丢任务,削峰填谷
DiscardPolicy静默丢弃可容忍丢失(如日志)
DiscardOldestPolicy丢弃队列最老的任务只关心最新任务
💡 核心机制:线程池 = 线程复用工厂。corePoolSize = 常驻核心线程(始终存活);workQueue = 任务缓冲队列(核心线程都在忙时排队);maximumPoolSize = 临时扩容线程(队列满了才创建);拒绝策略 = 线程和队列都满了怎么办——AbortPolicy = 抛异常拒绝任务,CallerRunsPolicy = 由提交任务的线程自己执行(变相限流)。

💼 真实工作场景 — 无界队列把服务撑爆

🔊 线上告警群 (12人)
今天 14:32
🤖
Grafana监控机器人
🚨 [P0告警] user-service 内存使用率 92% 且持续上升,已触发OOM预警。JVM Heap: 3.6G / 4G
👨‍💻
@王五 (后端)
看到了,我登Arthas看下线程栈和堆
👨‍💻
@王五 (后端)
找到了。有个异步通知服务用 Executors.newFixedThreadPool(10),底层是 new LinkedBlockingQueue<>() 无界队列,堆积了 97万个Runnable任务对象,把堆撑爆了。
👩‍💼
@产品经理
那怎么改?
👨‍💻
@王五 (后端)
改成有界队列 + 合理的最大线程数 + CallerRunsPolicy,让调用方降速。
💭 理论→实战的连接点:面试时你背"线程池有七个参数",工作中你要记住——Executors 工具类的四个工厂方法全都不推荐用(阿里规约强制禁止),因为 newFixedThreadPool 和 newSingleThreadExecutor 用的是无界队列,newCachedThreadPool 最大线程数是Integer.MAX_VALUE。必须手动 new ThreadPoolExecutor。

🔧 实战代码 — Executors埋坑 vs 手动配置

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 aspect
📄 LogAspect.java
📄 AuthAspect.java
/** * ❌ 错误写法:用Executors工厂方法 —— 阿里规约明令禁止 * newFixedThreadPool 底层: new LinkedBlockingQueue<>() 无界队列! * 任务无限堆积 → OOM */ ExecutorService executor = Executors.newFixedThreadPool(10); ExecutorService single = Executors.newSingleThreadExecutor(); ExecutorService cached = Executors.newCachedThreadPool(); // 看JDK源码就懂:LinkedBlockingQueue 无参构造 = Integer.MAX_VALUE 容量
专业定义 AOP 日志切面 = @Aspect + @Around 拦截标注注解的方法。本质:动态代理(JDK Proxy 接口 / CGLIB 类继承),运行时生成代理类织入切面逻辑。
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 threadpool
📄 ThreadPoolDemo.java
📄 AsyncConfig.java
/** * ✅ 正确写法:手动构造 ThreadPoolExecutor * 关键点:有界队列 + 合理线程数 + 命名线程工厂 + 降级策略 */ public class AsyncNotifyExecutor { private static final int CPU_COUNT = Runtime.getRuntime().availableProcessors(); // 核心线程数:IO密集型 = 2N (发短信/推送主要是网络等待) private static final int CORE = CPU_COUNT * 2; private static final int MAX = CPU_COUNT * 4; public static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor( CORE, // 1. corePoolSize MAX, // 2. maximumPoolSize 60L, // 3. keepAliveTime TimeUnit.SECONDS, // 4. unit new ArrayBlockingQueue<>(500), // 5. workQueue —— 有界! new ThreadFactoryBuilder() // 6. threadFactory —— 带名字 .setNameFormat("notify-pool-%d").build(), new ThreadPoolExecutor.CallerRunsPolicy() // 7. 拒绝策略 —— 调用方自己跑 ); }
面试金句 Executors 埋坑 = 阿里规约明令禁止 Executors 创建线程池。原因:newFixedThreadPool 底层用 无界队列 LinkedBlockingQueue,任务无限堆积 → OOM;newCachedThreadPool 最大线程数 Integer.MAX_VALUE → 创建大量线程 → OOM。必须手动构造 ThreadPoolExecutor

排查过程 — Arthas 定位无界队列

# 使用 Arthas 在线诊断(无需重启应用) $ java -jar arthas-boot.jar 28831 [arthas@28831]$ thread -n 3 # 查看CPU最高的3个线程 Threads CPU State Name notify-pool-1 0.0% WAITING notify-pool-1 notify-pool-2 0.0% WAITING notify-pool-2 ... pool-worker 0.0% RUNNABLE (主线程在疯狂提交任务) # 查看线程池的队列堆积情况 [arthas@28831]$ ognl '@com.user.AsyncConfig.executor.getQueue().size()' @Integer[972343] ← 无界队列堆积了97万个任务! # dump堆看对象占用 [arthas@28831]$ heapdump --live /tmp/user-heap.hprof # 用 MAT 分析后发现:java.util.concurrent.LinkedBlockingQueue$Node # 占用堆内存 1.8G / 4G,其中 972343 个 Runnable 任务对象
线程池工作流程(重要!面试要画出来)

提交任务 核心线程数未满?
是 → 创建核心线程执行 | 否 ↓
加入队列 队列已满?
是 → 创建非核心线程 | 否 ↑ 入队等待
线程已达max + 队列已满 → 触发拒绝策略
线程数配置公式:CPU密集型(计算、排序)= N+1;IO密集型(网络、DB、文件)= 2N;混合型 = 拆分成两个线程池。其中 N = Runtime.getRuntime().availableProcessors()

03JVM GC调优 — 答得出分代,救得了卡顿

📖 面试标准答案

面试官:"JVM的垃圾回收器有哪些?CMS和G1的区别?怎么选?"

回收器分代算法特点
Serial / Serial Old单线程复制/标记整理client模式,STW严重
Parallel Scavenge并行复制吞吐量优先,JDK8默认
CMS老年代标记清除低延迟,但有碎片,已废弃
G1分Region标记整理+复制JDK9默认,可预测停顿
ZGC分Region染色指针停顿<10ms,JDK15+生产可用

核心概念:

  • STW (Stop-The-World):GC时暂停所有应用线程,是卡顿元凶
  • Minor GC:清理Eden区,频率高、时间短
  • Full GC:清理整个堆,频率低但时间长(几百ms~几秒)
  • 大对象:直接进老年代(> -XX:PretenureSizeThreshold
💡 生活类比:GC = 打扫房间。Minor GC = 日常收拾桌面(快,随手做,几分钟搞定);Full GC = 年底大扫除(要停下来、全家一起做,搬家具、擦窗户,半天才能干完)。G1就像分区打扫——每天只彻底打扫一个房间,停顿短。

💼 真实工作场景 — 接口间歇性卡顿2秒

Bug #JIRA-2317 — 支付接口每30秒卡顿2秒P1

现象

用户反馈支付接口偶发"转圈",Grafana显示P99延迟每30秒出现一次峰值(2s+),平时正常(80ms)。

线索

运维查看Grafana的JVM面板,发现每次延迟峰值都精准对齐一次Full GC(停顿约1.8~2.2秒)。

报告人: 用户反馈处理人: @赵六2025-12-03
💭 理论→实战的连接点:面试背"Full GC会导致STW"只是几个字,但在Grafana上,你看到的是一条锯齿状的延迟曲线——用户每30秒就卡一下,那种焦虑感是真实存在的。调优的目标不是"GC次数最少",而是"停顿时间可控"。

排查过程 — jstat + GC日志三步走

# 第1步:jstat 每秒观察各代内存变化(PID = 28831) $ jstat -gcutil 28831 1000 20 S0 S1 E O M YGC YGCT FGC FGCT 0.00 45.33 80.12 91.20 95.40 142 3.210 8 15.230 45.33 0.00 12.45 93.88 95.40 143 3.228 8 15.230 0.00 55.12 67.89 96.10 95.40 144 3.245 8 15.230 ↑ 老年代(O)使用率持续升高到96%,马上触发Full GC 55.12 0.00 20.11 40.22 95.40 145 3.260 ↑ Full GC发生!老年代从96%降到40%,但停顿了约2秒 0.00 55.12 34.56 42.10 95.40 145 3.260 9 17.410 # 观察:每30秒触发一次Full GC,停顿约2秒 → 接口卡顿的真凶 # 第2步:查看GC日志(需要JVM启动参数加 -Xlog:gc*=info) $ tail -50 gc.log [2025-12-03T14:32:01.234+0800] GC pause (G1 Evacuation Pause) (young) [Eden: 120M->0M(120M)] Survivors: 16M->16M Heap: 1.8G->1.2G(3G)] [Times: user=0.08 sys=0.01, real=0.02 secs] ← Minor GC 只要20ms,OK [2025-12-03T14:32:31.456+0800] Full GC (Allocation Failure) [Old: 2.4G->1.0G(2.4G)] [Metaspace: 280M->278M(320M)] [Times: user=2.10 sys=0.02, real=1.92 secs] ← Full GC 停顿1.92秒! # 第3步:MAT分析dump,发现老年代里塞满了大对象(byte[]) $ jmap -histo:live 28831 | head -10 num #instances #bytes class name 1: 12,341 820,000,000 [B ← byte[] 占了820MB!大对象直接进老年代 2: 28,102 120,400,000 java.lang.String 3: 45,231 18,090,000 java.util.HashMap$Node
根因定位:代码里有个导出报表功能,一次性把整张表查出来拼成 byte[](单个2MB+),这些大对象超过了 -XX:PretenureSizeThreshold,直接进入老年代,频繁触发Full GC。

🔧 调优方案 — 从CMS切换到G1

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 cache
📄 CacheGuard.java
📄 BloomFilter.java
# ❌ 调优前:CMS配置,大对象进老年代,Full GC频繁 -Xms3g -Xmx3g -Xmn1g -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=70 -XX:+UseCMSInitiatingOccupancyOnly # ✅ 调优后:切换到G1 + 设定停顿目标 + 限制大对象 -Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 # 目标停顿200ms(G1会尽量满足) -XX:G1HeapRegionSize=16m # Region设大一点,减少大对象直接进old -XX:InitiatingHeapOccupancyPercent=45 # 堆使用45%就开始并发标记,提前回收 -XX:+ParallelRefProcEnabled # 并行处理引用,减少STW -XX:+HeapDumpOnOutOfMemoryError # OOM自动dump -Xlog:gc*=info:file=gc.log:time,uptime,level,tags
面试金句 缓存穿透 = 查不存在的 key 打到 DB。方案:布隆过滤器(bit数组+hash,有误判但不漏判)或空值缓存(缓存null短TTL)。
JVM 堆内存分代结构

Heap (4G)
Young Generation (1.5G) Old Generation (2.5G)
Eden (1.2G) S0 (150M) S1 (150M) ← 对象晋升到这里 / 大对象直接进
Minor GC 只清 Young(快)    Full GC 清整个堆(慢,要STW)

04Spring AOP — 切面不只是概念,是每天在用的工具

📖 面试标准答案

面试官:"Spring AOP的实现原理?JDK动态代理和CGLIB有什么区别?"

维度JDK动态代理CGLIB代理
原理基于接口,反射调用基于继承,生成子类
要求目标类必须实现接口目标类不能是final
性能创建快,调用稍慢创建慢,调用快
Spring默认Spring Framework默认Spring Boot 2.x默认(proxyTargetClass=true)

AOP 核心术语:

  • Aspect(切面):一个类,里面有多个增强逻辑
  • Pointcut(切点):定义"在哪些方法上生效",如 execution(* com.order.controller.*.*(..))
  • Advice(通知):@Before / @After / @Around,定义"什么时候插入逻辑"
  • JoinPoint(连接点):每个被拦截到的方法执行
💡 生活类比:AOP = 交叉路口装摄像头。你不需要在每个路口都建一个收费站(侵入业务代码),只需要装摄像头(切面)就能记录所有车辆的通行。日志、权限、限流、埋点——这些"横切关注点"全部用切面统一处理,业务代码干干净净。

💼 真实工作场景 — 每个接口都要记日志

🔊 代码评审群
今天 10:15
👩‍💼
@产品经理
所有接口都要记录操作日志(谁、什么时候、调了什么、传了什么参数、返回了什么),方便审计。
👨‍💻
@新人小李
那我给每个Controller方法都加上 log.info(...) 吧,不就几十个方法嘛 😅
🧑‍💼
@组长
别!用Spring AOP写一个切面,一个注解搞定所有接口,而且以后加新接口自动生效。

🔧 实战代码 — 没AOP的痛 vs 有AOP的爽

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 config
📄 ThreadPoolConfig.java
📄 AsyncConfig.java
// ❌ 没AOP:每个方法都要手写日志 —— 复制粘贴地狱 @RestController public class OrderController { @PostMapping("/order/create") public Result createOrder(@RequestBody OrderReq req) { log.info("收到下单请求, userId={}, productId={}", req.getUserId(), req.getProductId()); Result result = orderService.create(req); log.info("下单返回, result={}", result); return result; } @PostMapping("/order/cancel") public Result cancelOrder(@RequestBody CancelReq req) { log.info("收到取消订单请求, orderId={}", req.getOrderId()); // 又写一遍... Result result = orderService.cancel(req); log.info("取消返回, result={}", result); // 又写一遍... return result; } // ... 还有50个方法,每个都要写两行log,改起来想死 }
专业定义 ThreadPoolExecutor 7 参数 = (1) corePoolSize 核心线程数 (2) maxPoolSize 最大线程数 (3) keepAliveTime 空闲存活时间 (4) unit 时间单位 (5) workQueue 任务队列 (6) threadFactory 线程工厂 (7) handler 拒绝策略。面试加分:CPU密集型 = N+1线程,IO密集型 = 2N线程(N=CPU核数)。有界队列防 OOM。
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 cache
📄 HotKeyGuard.java
📄 CacheGuard.java
// ✅ 用AOP:一个切面搞定所有Controller的日志 @Aspect @Component @Slf4j public class WebLogAspect { // 切点:拦截 controller 包下所有方法 @Pointcut("execution(* com.order.controller..*.*(..))") public void controllerPointcut() {} // 环绕通知:方法执行前后都能拿到 @Around("controllerPointcut()") public Object around(ProceedingJoinPoint pjp) throws Throwable { String method = pjp.getSignature().toShortString(); Object[] args = pjp.getArgs(); long start = System.currentTimeMillis(); log.info("→ [{}] 请求参数={}", method, Arrays.toString(args)); Object result; try { result = pjp.proceed(); // 执行目标方法 } catch (Throwable e) { log.error("✗ [{}] 异常={}", method, e.getMessage()); throw e; } long cost = System.currentTimeMillis() - start; log.info("← [{}] 耗时={}ms 返回={}", method, cost, result); return result; } } // Controller 现在干干净净,业务逻辑一目了然 @RestController public class OrderController { @PostMapping("/order/create") public Result createOrder(@RequestBody OrderReq req) { return orderService.create(req); // 日志?切面自动记录! } }
面试金句 缓存击穿 = 热点 key 过期瞬间穿透到 DB。方案:互斥锁(Redis SETNX)或逻辑过期(后台异步刷新)。单机 synchronized,分布式 Redis 分布式锁。
💭 理论→实战的连接点:面试官问"AOP原理",其实更想听你说:"我在项目里用 @Around 做了统一日志和接口耗时统计,线上排查慢接口特别有用。"——这就把理论和实战连起来了。

常见坑 — AOP自调用失效

OrderService.java — AOP失效的坑
📁service
OrderService
PaymentService
1@Service
2public class OrderService {
3 
4 // 方法A上有 @Transactional(切面的一种)
5 @Transactional
6 public void methodA() {
7 methodB(); // ❌ 内部调用,不走代理!
8 }
9 
10 @Transactional(rollbackFor = Exception.class)
11 public void methodB() {
12 // 事务/切面不会生效!因为this调用不走代理对象
13 }
14 
15 // ✅ 解决:注入自己,走代理
16 @Autowired
17 @Lazy private OrderService self;
18 
19 public void methodAFixed() {
20 self.methodB(); // ✅ 通过代理对象调用,切面生效
21 }
22}
原因: AOP基于动态代理,内部this调用绕过了代理对象
JDK动态代理工作原理

调用方 Client Proxy(代理对象)
由JDK运行时生成
Target(真实对象)
代理对象实现了和Target一样的接口,在调用前后插入切面逻辑

05Redis缓存 — 穿透击穿雪崩,三个词值一个P0

📖 面试标准答案

面试官:"Redis缓存穿透、击穿、雪崩是什么?怎么解决?"

问题定义解决方案
穿透查根本不存在的数据(如恶意请求 id=-1)布隆过滤器 / 缓存空值
击穿热点key突然过期,瞬间大量请求打DB互斥锁(SETNX)/ 永不过期
雪崩大量key同时过期,DB瞬间压力暴增随机TTL / 多级缓存 / 限流降级
💡 生活类比:
缓存穿透 = 有人拿假身份证查档案 → 布隆过滤器先验证身份证真伪,假的直接拒绝
缓存击穿 = 保镖(缓存)请假了,粉丝(请求)瞬间冲进来 → 临时找个替补(互斥锁),只放一个人进去
缓存雪崩 = 电梯坏了,整栋楼的人一起挤楼梯 → 错峰上下班(随机TTL),别一起到

💼 真实工作场景 — 双11商品详情页缓存雪崩

Bug #JIRA-2401 — 双11零点商品页缓存雪崩,DB被打垮P0

现象

双11零点开抢,商品详情页接口QPS从平时2000暴涨到8万,1分钟后MySQL CPU飙到95%,连接池耗尽,整站不可用。

根因

预热缓存时,所有商品的TTL都设成了固定的30分钟。零点开抢前2分钟统一预热,结果00:32分这批缓存同时过期,8万个请求瞬间全部打到DB。

报告人: DBA处理人: @孙七2025-11-11 00:33
💭 理论→实战的连接点:面试时背"缓存雪崩加随机TTL"只要5个字,但真实事故里,你要在凌晨1点紧急写代码给所有缓存key补上 ThreadLocalRandom.nextInt(600) 的随机偏移量——那种手抖的感觉,是八股文给不了你的。

🔧 实战代码 — 三个问题的解决方案

📁 order-service
📂 deploy
📄 jvm-args.sh
📄 gc-log.conf
// ========== 1. 缓存穿透:查不存在的数据 ========== // 场景:黑客用不存在的商品ID疯狂请求,每次都打到DB // ❌ 错误写法:直接查缓存,没命中就查DB public Product getProduct(String id) { Product p = (Product) redis.get("product:" + id); if (p == null) { p = productMapper.findById(id); // 不存在的id每次都查DB! if (p != null) redis.set("product:" + id, p, 30, MINUTES); } return p; } // ✅ 正确写法:布隆过滤器 + 缓存空值 @Autowired private RBloomFilter<String> productBloom; // Redisson布隆过滤器 public Product getProduct(String id) { // 第一道防线:布隆过滤器判断id是否存在(O(1),内存占用极小) if (!productBloom.contains(id)) { return null; // 直接拒绝,不查DB } Product p = (Product) redis.get("product:" + id); if (p != null) return p; p = productMapper.findById(id); if (p != null) { redis.set("product:" + id, p, 30, MINUTES); } else { // 缓存空值,防止穿透(短TTL,比如5分钟) redis.set("product:" + id, "NULL", 5, MINUTES); } return p; }
专业定义 JVM GC 调优 = CMS → G1 迁移的核心差异:CMS 用标记-清除产生内存碎片,G1 用Region+SATB避免碎片。G1 参数:-XX:MaxGCPauseMillis=200(目标停顿时间),-XX:G1HeapRegionSize(Region 大小)。面试加分:说明 G1 的 Mixed GC 和 Full GC 触发条件。
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 cache
📄 AntiAvalanche.java
📄 HotKeyGuard.java
// ========== 2. 缓存击穿:热点key过期 ========== // 场景:爆款商品缓存过期瞬间,1万个请求同时查DB // ✅ 解决:用Redis SETNX做互斥锁,只让一个线程查DB,其他等待 public Product getHotProduct(String id) { Product p = (Product) redis.get("product:" + id); if (p != null) return p; // 缓存未命中,尝试加锁(SETNX = SET if Not eXists) String lockKey = "lock:product:" + id; String requestId = UUID.randomUUID().toString(); // 加锁,10秒自动过期(防止死锁) Boolean locked = redis.setIfAbsent(lockKey, requestId, 10, SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 双重检查:拿到锁后再查一次缓存(可能别的线程刚回填) p = (Product) redis.get("product:" + id); if (p != null) return p; p = productMapper.findById(id); // 只有一个线程查DB redis.set("product:" + id, p, 30, MINUTES); return p; } finally { // 释放锁:用Lua脚本保证"判断+删除"原子性 String lua = "if redis.call('get',KEYS[1])==ARGV[1] then " + "return redis.call('del',KEYS[1]) else return 0 end"; redis.eval(lua, Collections.singletonList(lockKey), Collections.singletonList(requestId)); } } else { // 没拿到锁,短暂等待后重试 Thread.sleep(50); return getHotProduct(id); // 递归重试 } }
面试金句 缓存雪崩 = 大量 key 同时过期。方案:TTL 加随机偏移。面试三连:穿透→布隆过滤器,击穿→互斥锁,雪崩→随机TTL。
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 controller
📄 OrderController.java
📄 UserController.java
// ========== 3. 缓存雪崩:大量key同时过期 ========== // ✅ 解决:给TTL加上随机偏移量,让过期时间分散 @Autowired private RedisTemplate<String, Object> redis; public void cacheProduct(Product p) { String key = "product:" + p.getId(); int baseTtl = 30; // 基础TTL 30分钟 // 关键:加随机偏移,避免同时过期 int randomOffset = ThreadLocalRandom.current().nextInt(600); // 0~10分钟随机 redis.opsForValue().set(key, p, baseTtl * 60 + randomOffset, SECONDS); } // 批量预热时更要加随机TTL public void preloadCache(List<Product> list) { for (Product p : list) { cacheProduct(p); // 每个key的TTL都不一样,不会同时过期 } }
面试金句 没有 AOP 的痛 = 每个 Controller 方法都要手写日志/权限/耗时统计,代码重复率极高。面试反问:这些横切逻辑(日志/权限/监控)和业务逻辑是否有关联?如果没有,为什么要耦合在一起?AOP 的本质:将横切关注点从业务代码中分离

排查过程 — redis-cli 验证

# 验证互斥锁(SETNX)的原子操作 127.0.0.1:6379> SET lock:product:10086 "uuid-xxx" NX EX 10 OK ← 加锁成功 127.0.0.1:6379> SET lock:product:10086 "uuid-yyy" NX EX 10 (nil) ← 已被别人锁住,加锁失败 # 查看key的TTL(验证随机过期时间) 127.0.0.1:6379> TTL product:1001 (integer) 1823 ← 30分23秒 127.0.0.1:6379> TTL product:1002 (integer) 2104 ← 35分4秒,TTL不一样! 127.0.0.1:6379> TTL product:1003 (integer) 1687 ← 28分7秒 # 用Lua脚本安全释放锁(判断value一致才删) 127.0.0.1:6379> EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 lock:product:10086 "uuid-xxx" (integer) 1 ← 删除成功(value匹配)
缓存击穿 vs 雪崩 — 一张图看懂

击穿(单点失效)
热点key过期 1万请求同时查DB 互斥锁:只放1个进
雪崩(大面积失效)
大批key同时过期 DB被打垮 随机TTL:错峰过期

06分布式锁 — synchronized救不了多机房

📖 面试标准答案

面试官:"分布式锁怎么实现?Redis和Zookeeper的分布式锁有什么区别?"

维度Redis分布式锁Zookeeper分布式锁
实现方式SETNX + 过期时间临时顺序节点 + Watch
一致性AP(最终一致)CP(强一致)
性能高(内存操作)较低(需要集群同步)
锁失效依赖过期时间,宕机需等待客户端断开自动释放(session)
推荐工具Redisson(含看门狗续期)Curator InterProcessMutex

Redis 分布式锁演进:

  • v1: SETNX key value + EXPIRE key 10 —— 非原子,中间宕机会死锁
  • v2: SET key value NX EX 10 —— 原子加锁,但业务超时会误删别人的锁
  • v3: value设唯一ID + Lua脚本校验删除 —— 解决误删
  • v4: Redisson + 看门狗自动续期 —— 生产标准方案
💡 生活类比:单机锁(synchronized)= 每个教室各有自己的门锁,只能锁住本教室的人;分布式锁 = 共享的钥匙管理处(Redis/Zookeeper),全学校只有一个地方能领钥匙,谁领到谁能进——跨教室也能协调。

💼 真实工作场景 — 秒杀超卖

Bug #JIRA-2450 — 秒杀活动超卖200件P0

现象

双12秒杀iPhone,库存1000件,结果卖出了1200件,超卖200件。客服收到大量投诉"付款了却说没货"。

根因

扣库存代码用了 synchronized。但订单服务部署了4台机器(4个JVM),synchronized 只能锁住单个JVM,4台机器各自扣减,导致超卖。

报告人: 客服系统处理人: @周八2025-12-12 10:05
💭 理论→实战的连接点:面试背"synchronized是 JVM 级别的锁",在工作中意味着——只要你的服务部署超过1台,synchronized对共享资源就失效了。这是从单机思维到分布式思维的第一课。

🔧 实战代码 — synchronized失效 vs Redis分布式锁

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 service
📄 SeckillService.java
📄 OrderService.java
// ❌ 错误写法:synchronized 在多机器部署下完全失效 @Service public class SeckillService { // synchronized 只锁当前JVM!4台机器 = 4把各不相关的锁 public synchronized boolean deductStock(String productId) { Integer stock = productMapper.getStock(productId); if (stock <= 0) return false; productMapper.updateStock(productId, stock - 1); // 4台机器同时到这里 → 超卖 return true; } }
面试金句 synchronized 分布式失效 = JVM 锁只锁当前 JVM,多机器各自有锁。分布式用 Redis 分布式锁(Redisson)或 ZK 临时顺序节点。
📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 aspect
📄 LogAspect.java
📄 AuthAspect.java
// ✅ 正确写法:Redisson 分布式锁(生产首选) @Service public class SeckillService { @Autowired private RedissonClient redisson; public boolean deductStock(String productId) { String lockKey = "seckill:lock:" + productId; RLock lock = redisson.getLock(lockKey); boolean locked = false; try { // 尝试加锁:最多等待3秒,锁自动续期(看门狗,默认30秒) locked = lock.tryLock(3, TimeUnit.SECONDS); if (!locked) { return false; // 没抢到锁,快速返回 } // 扣库存(此时全局只有当前线程能进入) Integer stock = productMapper.getStock(productId); if (stock <= 0) return false; productMapper.updateStock(productId, stock - 1); return true; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } finally { // 安全释放:Redisson内部会校验锁是否还属于当前线程 if (locked && lock.isHeldByCurrentThread()) { lock.unlock(); } } } }
专业定义 AOP 日志切面 = @Aspect + @Around("@annotation(logAnnotation)"),自动拦截标注了自定义注解的方法。ProceedingJoinPoint.proceed() 控制是否执行原方法。本质:动态代理——Spring 运行时生成代理类,在方法前后织入切面逻辑。面试深问:JDK Proxy(接口)vs CGLIB(类继承)。

常见坑 — 分布式锁的三大经典陷阱

📁 order-service
📂 src
📂 main
📂 java
📂 com
📂 shop
📂 service
📄 SeckillService.java
📄 OrderService.java
// ========== 坑1:锁不加过期时间 → 服务宕机,锁永远不释放 ========== // ❌ redis.setIfAbsent("lock", "v1"); // 没加过期!进程崩了就死锁 // ✅ 必须加过期时间,且用原子命令 redis.setIfAbsent("lock", "v1", 10, SECONDS); // SET NX EX 原子操作 // ========== 坑2:业务超时,锁提前释放 → 别人进来了 ========== // 业务执行了15秒,但锁10秒就过期了,第11秒别的线程拿到锁 // → 两个线程同时在执行临界区 → 又超卖了! // ✅ Redisson 看门狗自动续期(默认每10秒续到30秒) RLock lock = redisson.getLock("lock"); lock.lock(); // 不传过期时间 = 启用看门狗,自动续期 // 看门狗原理:定时(每 watchdogTimeout/3 = 10秒)检查锁还在不在, // 如果持有线程还活着,就把过期时间重置为30秒 // ========== 坑3:解锁时删了别人的锁 ========== // 线程A业务超时,锁过期;线程B拿到锁;线程A执行完finally,del lock // → 把线程B的锁删了!线程C又能进来 → 超卖 // ❌ 直接del redis.delete("lock"); // 可能删的是别人的锁 // ✅ 用Lua脚本:判断value是不是自己加的,是才删 String lua = "if redis.call('get', KEYS[1]) == ARGV[1] then " + " return redis.call('del', KEYS[1]) " + "else return 0 end"; // 加锁时 value 要用唯一ID(如UUID) redis.setIfAbsent("lock", requestId, 10, SECONDS); // 解锁时传入同一个 requestId redis.eval(lua, Collections.singletonList("lock"), Collections.singletonList(requestId));
面试金句 Redisson 分布式锁 = Lua 脚本原子操作 + WatchDog 自动续期(默认10s)+ UUID 防误解锁。支持可重入锁、读写锁、信号量。
# Redisson 看门狗续期的底层原理(观察key的TTL变化) 127.0.0.1:6379> SET seckill:lock:10086 "uuid-abc" NX PX 30000 OK 127.0.0.1:6379> TTL seckill:lock:10086 (integer) 28 ← 刚加完,剩28秒 # ... 过了10秒(看门狗触发续期) 127.0.0.1:6379> TTL seckill:lock:10086 (integer) 29 ← 被续期了!看门狗把TTL重置到30秒 # ... 业务执行完,unlock 127.0.0.1:6379> DEL seckill:lock:10086 (integer) 1 ← 主动释放 # 如果持有锁的JVM崩溃了,看门狗停止续期 → TTL自然过期 → 自动释放
单机锁 vs 分布式锁

单机 synchronized(只能锁1个JVM)
JVM-A JVM-B JVM-C JVM-D
各锁各的,互不影响 → 超卖
分布式锁(全局唯一)
JVM-A Redis
统一锁管理处
JVM-C
JVM-B | JVM-D
所有人抢同一把锁,全局互斥

面试加分技巧 — 怎么把实战经验自然说出来

面试官问八股文,其实是想考察你有没有真实项目经验。下面是"普通回答"和"加分回答"的对比:

模拟面试 — "说说ConcurrentHashMap"
第一轮 · 普通回答
🧑‍💼
面试官
讲一下ConcurrentHashMap的原理?
👤
候选人(普通)
ConcurrentHashMap JDK7用的是Segment分段锁,JDK8改成了CAS+synchronized锁单个桶,默认16个段...(背诵中)
🧑‍💼
面试官
(内心:又一个背八股文的)嗯,那实际中你用过吗?
第二轮 · 加分回答
🧑‍💼
面试官
讲一下ConcurrentHashMap的原理?
🌟
候选人(加分)
原理上JDK8用CAS+synchronized,这些我背过。但我想说一个实际踩过的坑:我们订单服务有个本地缓存,之前用HashMap,大促时多线程并发put导致CPU 100%,jstack一看卡在resize方法。后来换成ConcurrentHashMap并用computeIfAbsent,问题就解决了。所以我现在做Code Review,看到HashMap第一反应就是问:这个会被多线程访问吗?
🧑‍💼
面试官
(眼前一亮)嗯,有实战经验。继续聊聊那次事故的细节?
加分公式:八股文概念(30%) + 真实场景描述(40%) + 排查过程/解决方案(20%) + 总结出的习惯(10%)。只要你能把"我用jstack/jstat/Arthas排查过"自然说出来,就秒杀80%的候选人。
💭 终极心法:面试不是为了考倒你,而是想确认"这个人能不能干活"。所以——不要怕说"我踩过坑",踩过坑并能讲清楚的人,比只会背书的人值钱10倍。

六个主题的核心一句话总结

面试题一句话实战记忆点
HashMap多线程用HashMap = 线上CPU 100%,jstack会看到卡在resize()
线程池Executors工厂方法 = 无界队列OOM,必须手动new ThreadPoolExecutor
JVM GC接口间歇卡顿 = 查Grafana看GC停顿,jstat -gcutil 看分代变化
Spring AOP切面用于日志/事务,但自调用会失效(this不走代理)
Redis缓存穿透=布隆过滤器,击穿=SETNX互斥锁,雪崩=随机TTL
分布式锁synchronized只锁单JVM,多机器必须用Redisson(带看门狗续期)
最后一段话送给你:八股文不是终点,是起点。真正的高手,是把每一个"为什么"都问到底——HashMap为什么线程不安全?因为resize会成环。为什么会成环?因为头插法+并发。为什么JDK8改成尾插法?... 顺着这条线追问下去,面试官问什么都难不倒你,因为你不是在背答案,你是在复述你的理解