很多开发者背了上百道八股文,进了公司却不知道这些东西到底用在哪。这个页面把每个经典面试题拆成三段:📖 教科书答案 → 💼 真实工作场景 → 🔧 实战代码与排查。读完你会明白:面试官问的不是"你背了多少",而是"你踩过坑没有"。
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.7 | JDK 1.8+ |
| HashMap结构 | 数组 + 链表 | 数组 + 链表 + 红黑树 |
| ConcurrentHashMap | Segment分段锁(默认16段) | CAS + synchronized 锁单个桶 |
| 并发死循环 | resize时链表成环,CPU 100% | 修复了成环,但仍会丢数据 |
💼 真实工作场景 — 一个本地缓存引发的线上事故
现象
大促活动刚开始10分钟,订单服务3台机器CPU全部飙到100%,接口大面积超时。
背景
订单服务里有一个"商品信息本地缓存",用 HashMap 实现,多线程读写。开发同学心想:"就是个缓存嘛,HashMap够用了。"
影响
下单接口不可用约15分钟,损失订单约2.3万笔。
报告人: 监控告警处理人: @张三2025-11-11 00:12
💡 生活类比:HashMap 就像单人卫生间(没有锁),多人同时挤进去就会出事(数据错乱、死循环);ConcurrentHashMap 就像公共卫生间(每个隔间独立有锁),大家各用各的隔间,互不干扰。
🔧 实战代码 — 错误写法 vs 正确写法
LocalCacheManager.java — order-service
📁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在多线程中使用
// ✅ 正确写法:用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%后,运维同学立刻执行了以下命令定位问题:
$ 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
$ top -Hp 28831
PID USER %CPU TIME+ COMMAND
28845 app 98.7 5:33.21 java -jar order-service.jar
$ printf "%x\n" 28845
70ad
$ 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 = 由提交任务的线程自己执行(变相限流)。
💼 真实工作场景 — 无界队列把服务撑爆
今天 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 手动配置
/**
* ❌ 错误写法:用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 类继承),运行时生成代理类织入切面逻辑。
/**
* ✅ 正确写法:手动构造 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 定位无界队列
$ 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万个任务!
[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秒
现象
用户反馈支付接口偶发"转圈",Grafana显示P99延迟每30秒出现一次峰值(2s+),平时正常(80ms)。
线索
运维查看Grafana的JVM面板,发现每次延迟峰值都精准对齐一次Full GC(停顿约1.8~2.2秒)。
报告人: 用户反馈处理人: @赵六2025-12-03
💭 理论→实战的连接点:面试背"Full GC会导致STW"只是几个字,但在Grafana上,你看到的是一条锯齿状的延迟曲线——用户每30秒就卡一下,那种焦虑感是真实存在的。调优的目标不是"GC次数最少",而是"停顿时间可控"。
排查过程 — jstat + GC日志三步走
$ 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
$ 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秒!
$ 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
# ❌ 调优前: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的爽
// ❌ 没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。
// ✅ 用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商品详情页缓存雪崩
现象
双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) 的随机偏移量——那种手抖的感觉,是八股文给不了你的。
🔧 实战代码 — 三个问题的解决方案
// ========== 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 触发条件。
// ========== 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。
// ========== 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 验证
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) ← 已被别人锁住,加锁失败
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秒
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),全学校只有一个地方能领钥匙,谁领到谁能进——跨教室也能协调。
💼 真实工作场景 — 秒杀超卖
现象
双12秒杀iPhone,库存1000件,结果卖出了1200件,超卖200件。客服收到大量投诉"付款了却说没货"。
根因
扣库存代码用了 synchronized。但订单服务部署了4台机器(4个JVM),synchronized 只能锁住单个JVM,4台机器各自扣减,导致超卖。
报告人: 客服系统处理人: @周八2025-12-12 10:05
💭 理论→实战的连接点:面试背"synchronized是 JVM 级别的锁",在工作中意味着——只要你的服务部署超过1台,synchronized对共享资源就失效了。这是从单机思维到分布式思维的第一课。
🔧 实战代码 — synchronized失效 vs Redis分布式锁
// ❌ 错误写法: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 临时顺序节点。
// ✅ 正确写法: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(类继承)。
常见坑 — 分布式锁的三大经典陷阱
// ========== 坑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 防误解锁。支持可重入锁、读写锁、信号量。
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秒
127.0.0.1:6379> TTL seckill:lock:10086
(integer) 29 ← 被续期了!看门狗把TTL重置到30秒
127.0.0.1:6379> DEL seckill:lock:10086
(integer) 1 ← 主动释放
单机锁 vs 分布式锁
单机 synchronized(只能锁1个JVM)
JVM-A
JVM-B
JVM-C
JVM-D
各锁各的,互不影响 → 超卖
分布式锁(全局唯一)
JVM-A
↘
Redis
统一锁管理处
↙
JVM-C
JVM-B
↗
|
↖
JVM-D
所有人抢同一把锁,全局互斥
★面试加分技巧 — 怎么把实战经验自然说出来
面试官问八股文,其实是想考察你有没有真实项目经验。下面是"普通回答"和"加分回答"的对比:
第一轮 · 普通回答
🧑💼
面试官
讲一下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改成尾插法?... 顺着这条线追问下去,面试官问什么都难不倒你,因为你不是在背答案,你是在复述你的理解。