面试准备(Java的面试问题)
第一部分:Java 基础与并发
一、集合框架
1. ArrayList 和 LinkedList 的区别?各自适合什么场景?
回答:
ArrayList 底层是动态数组,内存连续,支持随机访问,时间复杂度 O(1),但插入删除需要移动元素,是 O(n)。
LinkedList 底层是双向链表,内存不连续,插入删除只需改变指针,是 O(1),但随机访问需要遍历,是 O(n)。
场景选择:
- ArrayList 适合读多写少、需要频繁随机访问的场景
- LinkedList 适合频繁在头尾插入删除的场景,比如实现队列
实际开发中,ArrayList 用得更多,因为现代 CPU 对连续内存有缓存优化,即使插入删除也比 LinkedList 快。
2. HashMap 的 put 流程是怎样的?什么时候扩容?
回答:
HashMap 的 put 流程分这几步:
- 计算 hash:对 key 的 hashCode 进行扰动处理(高 16 位异或低 16 位),减少碰撞
- 定位桶:用
(n-1) & hash计算数组下标 - 处理碰撞:
- 如果桶为空,直接放入新节点
- 如果桶不为空,判断 key 是否相等(先比 hash,再比 equals)
- 相等则覆盖 value
- 不相等则挂到链表尾部(JDK8 是尾插法)
- 树化判断:链表长度超过 8 且数组长度超过 64,转为红黑树
- 扩容判断:元素个数超过
容量 × 负载因子(默认 0.75),触发扩容
扩容时机:
- 当
size > capacity * loadFactor时扩容为原来的 2 倍 - 扩容需要 rehash,所以最好预估容量,避免频繁扩容
3. HashMap 链表转红黑树的阈值为什么是 8?退化阈值为什么是 6?
回答:
选择 8 是基于泊松分布的概率计算。在理想的 hash 分布下,链表长度达到 8 的概率只有千万分之六,属于极端情况。这时候转红黑树能把查询从 O(n) 优化到 O(log n)。
退化阈值选 6 而不是 8,是为了避免频繁转换。如果都是 8,那在 8 附近增删元素会导致链表和红黑树反复转换,浪费性能。中间留个缓冲区(7),增加稳定性。
补充一点:转红黑树还有个前提是数组长度要大于 64,否则优先扩容而不是树化,因为扩容能更有效地减少碰撞。
4. HashMap 在多线程下会出现什么问题?
回答:
HashMap 是线程不安全的,多线程下有两个主要问题:
1. 数据丢失
- 两个线程同时 put,计算到同一个桶位置
- 线程 A 判断为空准备插入,被挂起
- 线程 B 也判断为空,插入成功
- 线程 A 恢复后直接覆盖,B 的数据丢失
2. JDK7 的死循环(JDK8 已修复)
- JDK7 扩容用头插法,多线程下可能形成环形链表
- get 操作会死循环,CPU 飙升到 100%
解决方案:
- 使用
ConcurrentHashMap:分段锁(JDK7)或 CAS + synchronized(JDK8) - 使用
Collections.synchronizedMap():全表锁,性能较差
5. ConcurrentHashMap 的实现原理?JDK7 和 JDK8 有什么区别?
回答:
JDK7 的实现:分段锁(Segment)
- 把数据分成 16 个段,每个段是一个小的 HashMap
- 每个段有独立的锁,不同段可以并发访问
- 并发度最高 16,扩容时锁粒度较大
JDK8 的实现:CAS + synchronized
- 取消分段锁,改为对每个桶头节点加锁
- 空桶用 CAS 插入,非空桶用 synchronized 锁头节点
- 锁粒度更细,并发度等于桶数量
- 同样支持链表转红黑树
为什么 JDK8 改用 synchronized?
- JVM 对 synchronized 做了大量优化(偏向锁、轻量级锁)
- 实际性能不比 ReentrantLock 差
- 代码更简洁,内存占用更少
二、多线程与并发
6. 创建线程有哪几种方式?
回答:
严格来说只有两种方式创建线程:
1. 继承 Thread 类
class MyThread extends Thread {
public void run() { ... }
}
new MyThread().start();
2. 实现 Runnable 接口
new Thread(() -> { ... }).start();
Callable + FutureTask、线程池等本质上还是通过这两种方式创建。
推荐使用 Runnable/Callable + 线程池,原因:
- Java 单继承,继承 Thread 就不能继承其他类了
- 线程池可以复用线程,避免频繁创建销毁的开销
- 便于统一管理和监控
7. 线程池的 7 个核心参数分别是什么?
回答:
new ThreadPoolExecutor(
corePoolSize, // 核心线程数,即使空闲也不回收
maximumPoolSize, // 最大线程数
keepAliveTime, // 非核心线程的空闲存活时间
TimeUnit, // 时间单位
workQueue, // 任务队列
threadFactory, // 线程工厂,可以自定义线程名
handler // 拒绝策略
);
任务提交流程:
- 线程数 < corePoolSize → 创建核心线程执行
- 核心线程满 → 放入 workQueue
- 队列满 → 创建非核心线程(不超过 maximumPoolSize)
- 线程数达到 maximumPoolSize 且队列满 → 执行拒绝策略
记忆口诀:先核心,再队列,后扩展,都满了就拒绝。
8. 线程池的拒绝策略有哪些?怎么选择?
回答:
JDK 提供 4 种内置策略:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy | 抛出 RejectedExecutionException | 默认策略,需要调用方感知并处理 |
| CallerRunsPolicy | 由提交任务的线程执行 | 不允许丢弃,可以接受延迟 |
| DiscardPolicy | 静默丢弃 | 允许丢弃,不需要通知 |
| DiscardOldestPolicy | 丢弃队列最老任务 | 只关心最新数据 |
实际开发中,我一般自定义拒绝策略:
- 记录日志报警
- 持久化到数据库或 MQ,后续补偿处理
- 返回兜底结果
9. 核心线程数设置多少合适?
回答:
要根据任务类型来定:
CPU 密集型(计算为主)
- 公式:
核心线程数 = CPU 核数 + 1 - 多一个是为了防止偶发的缺页中断等导致线程阻塞
IO 密集型(网络/磁盘 IO 为主)
- 公式:
核心线程数 = CPU 核数 × 2或CPU 核数 / (1 - 阻塞系数) - 阻塞系数一般 0.8~0.9
实际开发中,这只是起点,还需要:
- 压测调优,观察 CPU 利用率和响应时间
- 结合业务特点,比如下游依赖的响应时间
- 预留 buffer,不要打满 CPU
10. synchronized 和 ReentrantLock 的区别?
回答:
| 对比项 | synchronized | ReentrantLock |
|---|---|---|
| 实现层面 | JVM 层面,字节码指令 | API 层面,Java 代码 |
| 锁释放 | 自动释放 | 必须手动 unlock |
| 中断响应 | 不支持 | 支持 lockInterruptibly |
| 超时获取 | 不支持 | 支持 tryLock(timeout) |
| 公平锁 | 只有非公平 | 支持公平/非公平 |
| 条件变量 | 只有一个 wait/notify | 可以多个 Condition |
选择建议:
- 简单场景优先 synchronized,JVM 已优化得很好
- 需要高级功能(超时、中断、多条件)时用 ReentrantLock
- 用 ReentrantLock 一定要在 finally 里 unlock
11. volatile 关键字的作用?能保证原子性吗?
回答:
volatile 有两个作用:
1. 保证可见性
- 写 volatile 变量时,会把工作内存的值刷新到主内存
- 读 volatile 变量时,会从主内存重新加载
- 一个线程修改后,其他线程立即可见
2. 禁止指令重排序
- 通过内存屏障实现
- 经典场景:双重检查锁定的单例模式
但是,volatile 不能保证原子性!
比如 count++ 实际上是三步操作:读取、加 1、写入。volatile 只保证每一步的可见性,不保证三步的原子性。
要保证原子性,可以用:
- synchronized
- AtomicInteger 等原子类
- Lock
三、JVM
12. JVM 内存结构分哪几块?
回答:
JVM 运行时数据区分为 5 块:
线程私有:
- 程序计数器:记录当前线程执行的字节码行号
- 虚拟机栈:存储局部变量表、操作数栈、方法出口等
- 本地方法栈:为 Native 方法服务
线程共享:
4. 堆:对象实例和数组,GC 的主要区域
5. 方法区:类信息、常量、静态变量(JDK8 后叫元空间,用本地内存)
常见问题区分:
- StackOverflowError → 栈溢出,递归太深
- OutOfMemoryError: Java heap space → 堆溢出,对象太多
- OutOfMemoryError: Metaspace → 元空间溢出,类太多
13. 哪些对象可以作为 GC Roots?
回答:
GC Roots 是垃圾回收的起点,从这些根出发能到达的对象都是存活的。
可以作为 GC Roots 的对象:
- 虚拟机栈中引用的对象(局部变量表)
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI 引用的对象
- 同步锁持有的对象
- JVM 内部引用(基本类型对应的 Class 对象、常驻异常等)
记忆技巧:想想哪些对象是"活着的代码"正在使用的——栈里的、静态的、常量的、Native 的。
14. CMS 和 G1 的区别?
回答:
| 对比项 | CMS | G1 |
|---|---|---|
| 目标 | 最短停顿时间 | 可预测的停顿时间 |
| 内存布局 | 传统分代(新生代、老年代) | Region 化,逻辑分代 |
| 回收算法 | 标记-清除 | 标记-整理(Region 间复制) |
| 碎片问题 | 有,需要 Full GC 整理 | 无,每次回收都整理 |
| 并发阶段 | 初始标记→并发标记→重新标记→并发清除 | 初始标记→并发标记→最终标记→筛选回收 |
选择建议:
- 堆内存 < 8G,可以用 CMS
- 堆内存 > 8G,推荐 G1
- JDK9 开始,G1 是默认收集器,CMS 已被标记废弃
15. 什么情况下会发生 Full GC?
回答:
Full GC 会回收整个堆,停顿时间长,要尽量避免:
- 老年代空间不足:大对象或长期存活对象太多
- 元空间不足:加载的类太多
- 显式调用
System.gc():建议禁用 - CMS 并发失败:并发回收速度赶不上分配速度
- 晋升失败:Young GC 时老年代放不下晋升对象
优化方向:
- 合理设置堆大小和各区比例
- 避免创建过大对象
- 减少内存泄漏
- 选择合适的 GC 收集器
第二部分:框架与微服务
一、Spring 核心
16. Spring IOC 是什么?解决了什么问题?
回答:
IOC(控制反转)是一种设计思想,把对象的创建和依赖管理交给容器,而不是在代码里 new。
传统方式:
// 紧耦合,UserService 依赖具体的 UserDaoImpl
public class UserService {
private UserDao userDao = new UserDaoImpl();
}
IOC 方式:
// 松耦合,依赖由容器注入
@Service
public class UserService {
@Autowired
private UserDao userDao; // 容器注入,可以是任何实现
}
解决的问题:
- 解耦:类不需要知道依赖的具体实现
- 便于测试:可以注入 Mock 对象
- 统一管理:对象的生命周期、配置、AOP 增强都由容器管理
17. Spring Bean 的生命周期是怎样的?
回答:
Bean 的生命周期可以分为四个阶段:
1. 实例化(Instantiation)
- 通过反射创建 Bean 实例
- 此时是个空对象,属性还没填充
2. 属性填充(Populate)
- 注入依赖的其他 Bean
- 注入配置的属性值
3. 初始化(Initialization)
- 调用 Aware 接口方法(BeanNameAware、ApplicationContextAware 等)
- 调用 BeanPostProcessor 的
postProcessBeforeInitialization - 调用
@PostConstruct标注的方法 - 调用
InitializingBean.afterPropertiesSet() - 调用自定义的
init-method - 调用 BeanPostProcessor 的
postProcessAfterInitialization(AOP 代理在这里生成)
4. 销毁(Destruction)
- 调用
@PreDestroy标注的方法 - 调用
DisposableBean.destroy() - 调用自定义的
destroy-method
面试时画个流程图更清晰。
18. Spring 如何解决循环依赖?三级缓存各自存什么?
回答:
Spring 通过三级缓存解决 setter 注入的循环依赖:
// 一级缓存:完整的单例 Bean
Map<String, Object> singletonObjects
// 二级缓存:早期暴露的 Bean(已实例化,未填充属性)
Map<String, Object> earlySingletonObjects
// 三级缓存:Bean 工厂,用于生成早期引用
Map<String, ObjectFactory<?>> singletonFactories
解决流程(A 依赖 B,B 依赖 A):
- 创建 A,实例化后放入三级缓存(工厂)
- 填充 A 的属性,发现依赖 B
- 创建 B,实例化后放入三级缓存
- 填充 B 的属性,发现依赖 A
- 从三级缓存获取 A 的早期引用,放入二级缓存
- B 初始化完成,放入一级缓存
- A 继续填充,获取到完整的 B
- A 初始化完成,放入一级缓存
为什么要三级而不是二级?
- 三级缓存存的是工厂,可以延迟创建代理对象
- 如果 A 需要被 AOP 代理,在第 5 步会通过工厂生成代理对象
19. 构造器注入为什么不能解决循环依赖?
回答:
因为构造器注入时,Bean 还没有实例化,没法放入缓存提前暴露。
setter 注入: 先 new 出空对象 → 放入缓存 → 再填充属性
构造器注入: 必须先准备好所有构造参数 → 才能 new 对象
如果 A 的构造器依赖 B,B 的构造器依赖 A,就形成了死锁:
- 创建 A 需要先创建 B
- 创建 B 需要先创建 A
- 都没法先实例化
解决方案:
- 改用 setter 注入
- 使用
@Lazy延迟加载其中一个 - 重新设计,消除循环依赖(推荐)
20. @Transactional 失效的场景有哪些?
回答:
事务失效是高频坑点,常见场景:
1. 方法不是 public
- Spring AOP 默认只代理 public 方法
2. 自调用(同类方法调用)
public void methodA() {
this.methodB(); // 失效!this 不是代理对象
}
@Transactional
public void methodB() { ... }
- 解决:注入自己或使用 AopContext
3. 异常被吞掉
@Transactional
public void method() {
try {
// 业务代码
} catch (Exception e) {
log.error("出错了"); // 异常被吞,事务不回滚
}
}
4. 异常类型不对
- 默认只回滚 RuntimeException 和 Error
- 检查异常不回滚,需要配置
rollbackFor = Exception.class
5. 数据库引擎不支持
- MyISAM 不支持事务,要用 InnoDB
6. 没有被 Spring 管理
- Bean 没有
@Service等注解
二、MyBatis
21. MyBatis 的 #{} 和 ${} 有什么区别?
回答:
| 对比项 | #{} | ${} |
|---|---|---|
| 处理方式 | 预编译,参数占位符 | 字符串拼接 |
| SQL 注入 | 安全,自动转义 | 不安全,可能被注入 |
| 性能 | 可复用预编译 SQL | 每次都要编译 |
| 使用场景 | 传递参数值 | 动态表名、列名、排序 |
举例:
-- #{} 编译后
SELECT * FROM user WHERE id = ?
-- ${} 编译后(假设传入 1 OR 1=1)
SELECT * FROM user WHERE id = 1 OR 1=1 -- SQL 注入!
原则:能用 #{} 就不用 ${},必须用 ${} 时要做好参数校验。
22. MyBatis 一级缓存和二级缓存的区别?
回答:
一级缓存(SqlSession 级别)
- 默认开启,同一个 SqlSession 内有效
- 同一个查询多次执行,第二次直接返回缓存
- SqlSession 关闭或执行增删改后失效
二级缓存(Mapper 级别)
- 需要手动开启,跨 SqlSession 有效
- 以 namespace 为单位,同一个 Mapper 共享
- SqlSession 关闭后数据才进入二级缓存
坑点:
- 一级缓存在分布式环境下可能导致脏读
- 二级缓存如果跨 Mapper 关联查询可能读到脏数据
- 生产环境建议用 Redis 做分布式缓存,关闭 MyBatis 缓存
第三部分:数据库与中间件
一、MySQL
23. 索引的数据结构是什么?为什么用 B+ 树?
回答:
MySQL InnoDB 的索引使用 B+ 树。
为什么不用其他结构:
| 结构 | 问题 |
|---|---|
| 哈希表 | 只支持等值查询,不支持范围查询 |
| 二叉搜索树 | 可能退化成链表,高度不可控 |
| 红黑树 | 高度较高,大数据量时磁盘 IO 多 |
| B 树 | 非叶子节点也存数据,叶子节点没有链表 |
B+ 树的优势:
- 矮胖:非叶子节点只存键,能存更多索引,树更矮,IO 更少
- 范围查询高效:叶子节点形成双向链表,范围查询直接遍历
- 查询稳定:所有查询都要走到叶子节点,性能稳定
补充数据:3 层的 B+ 树大约能存储 2000 万行数据,绝大多数查询只需要 3 次磁盘 IO。
24. 什么情况下索引会失效?
回答:
这是高频题,我总结了 8 种常见情况:
1. 最左前缀不满足
- 联合索引 (a, b, c),查询条件没有 a 则索引失效
2. 对索引列做函数操作
WHERE YEAR(create_time) = 2024 -- 失效
WHERE create_time >= '2024-01-01' -- 走索引
3. 隐式类型转换
-- phone 是 varchar
WHERE phone = 13800138000 -- 失效,数字会转成字符串
WHERE phone = '13800138000' -- 走索引
4. LIKE 左模糊
WHERE name LIKE '%张' -- 失效
WHERE name LIKE '张%' -- 走索引
5. OR 连接非索引列
-- 只有 a 有索引
WHERE a = 1 OR b = 2 -- 失效
6. 范围查询后的列
-- 联合索引 (a, b, c)
WHERE a > 1 AND b = 2 -- b 走不了索引
7. NOT IN、!=、<>
- 某些情况优化器认为全表扫描更快
8. 数据分布不均
- 优化器估算后认为全表扫描更快
25. 聚簇索引和非聚簇索引的区别?什么是回表?
回答:
聚簇索引(主键索引)
- 叶子节点存储的是完整的行数据
- 一个表只能有一个聚簇索引
- InnoDB 按主键聚集数据
非聚簇索引(二级索引)
- 叶子节点存储的是主键值
- 一个表可以有多个二级索引
什么是回表?
通过二级索引查询时,先找到主键值,再用主键去聚簇索引查完整数据,这个过程叫回表。
如何避免回表?
使用覆盖索引:查询的列都在索引中,不需要回表。
-- 假设有联合索引 (name, age)
SELECT name, age FROM user WHERE name = '张三' -- 覆盖索引,不回表
SELECT * FROM user WHERE name = '张三' -- 需要回表
26. MySQL 的四种隔离级别分别是什么?
回答:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 说明 |
|---|---|---|---|---|
| READ UNCOMMITTED | ✓ | ✓ | ✓ | 能读到未提交数据 |
| READ COMMITTED | ✗ | ✓ | ✓ | 只能读已提交数据 |
| REPEATABLE READ | ✗ | ✗ | ✓(部分) | MySQL 默认级别 |
| SERIALIZABLE | ✗ | ✗ | ✗ | 串行执行 |
MySQL 默认是 REPEATABLE READ,并通过 MVCC + 间隙锁解决大部分幻读。
三种问题的解释:
- 脏读:读到其他事务未提交的数据
- 不可重复读:同一事务内两次读取结果不同(被其他事务 UPDATE)
- 幻读:同一事务内两次查询结果集不同(被其他事务 INSERT)
27. MVCC 原理是什么?
回答:
MVCC(多版本并发控制)让读写互不阻塞,实现高并发。
核心机制:
- 隐藏列:每行有
trx_id(事务 ID)和roll_pointer(指向 undo log) - undo log:保存历史版本,形成版本链
- Read View:事务快照,决定能看到哪些版本
Read View 包含:
m_ids:创建时所有活跃事务 IDmin_trx_id:最小活跃事务 IDmax_trx_id:下一个要分配的事务 IDcreator_trx_id:创建该 Read View 的事务 ID
可见性判断:
trx_id < min_trx_id→ 可见(事务已提交)trx_id >= max_trx_id→ 不可见(事务在快照后开启)trx_id在m_ids中 → 不可见(事务还未提交)- 否则 → 可见
RC 和 RR 的区别:
- RC:每次 SELECT 都创建新的 Read View
- RR:只在第一次 SELECT 时创建,后续复用
二、Redis
28. Redis 五种数据结构及使用场景
回答:
| 类型 | 底层实现 | 典型场景 |
|---|---|---|
| String | SDS | 缓存、计数器、分布式锁 |
| Hash | ziplist / hashtable | 对象存储(用户信息) |
| List | quicklist | 消息队列、最新列表 |
| Set | intset / hashtable | 去重、交集并集(共同好友) |
| ZSet | ziplist / skiplist + dict | 排行榜、延迟队列 |
补充高级类型:
- HyperLogLog:基数统计(UV),12KB 存储亿级数据,误差 0.81%
- Bitmap:位图,签到、在线状态
- GEO:地理位置,附近的人
29. 缓存穿透、缓存雪崩、缓存击穿分别是什么?怎么解决?
回答:
1. 缓存穿透
- 问题:查询不存在的数据,缓存和数据库都没有,每次都打到 DB
- 场景:恶意攻击,大量请求不存在的 ID
- 解决:
- 布隆过滤器:快速判断数据是否存在
- 缓存空值:查不到也缓存,设置短过期时间
2. 缓存雪崩
- 问题:大量缓存同时过期,请求全部打到 DB
- 场景:缓存批量设置相同过期时间
- 解决:
- 过期时间加随机值,打散过期时间
- 热点数据永不过期,后台异步更新
- 限流降级,保护数据库
3. 缓存击穿
- 问题:热点 key 过期瞬间,大量请求打到 DB
- 场景:秒杀商品详情页
- 解决:
- 互斥锁:只允许一个请求去查 DB 并回填
- 热点数据永不过期
30. Redis 分布式锁怎么实现?有什么问题?
回答:
基本实现(SETNX + 过期时间):
SET lock_key unique_value NX PX 30000
- NX:只在 key 不存在时设置
- PX:设置毫秒级过期时间
- unique_value:唯一标识,防止误删别人的锁
释放锁(Lua 脚本保证原子性):
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
存在的问题:
-
锁超时问题:业务没执行完锁就过期了
- 解决:Redisson 看门狗机制,自动续期
-
主从同步延迟:主节点加锁成功,同步前主节点挂了,从节点变主节点,锁丢失
- 解决:RedLock(红锁),向多个独立 Redis 实例加锁
-
可重入问题:同一线程多次加锁
- 解决:Redisson 支持可重入锁,用 Hash 记录线程和重入次数
31. Redisson 看门狗机制是什么?
回答:
看门狗(WatchDog)是 Redisson 解决锁超时问题的机制。
工作原理:
- 加锁时不指定过期时间,默认 30 秒(lockWatchdogTimeout)
- 后台启动定时任务,每 10 秒(30/3)检查一次
- 如果锁还被持有,就重置过期时间为 30 秒
- 释放锁或线程挂掉,定时任务停止,锁自动过期
代码示例:
RLock lock = redissonClient.getLock("myLock");
lock.lock(); // 不指定时间,启用看门狗
try {
// 业务逻辑
} finally {
lock.unlock();
}
注意:如果指定了过期时间 lock.lock(10, TimeUnit.SECONDS),看门狗不会启动!
三、RabbitMQ
32. RabbitMQ 的交换机类型有哪些?
回答:
| 类型 | 路由规则 | 场景 |
|---|---|---|
| Direct | 精确匹配 routing key | 点对点,指定队列 |
| Fanout | 忽略 routing key,广播到所有绑定队列 | 广播通知 |
| Topic | 通配符匹配(* 一个词,# 多个词) | 按主题分类 |
| Headers | 匹配消息头 | 很少用 |
示例:
- Topic 交换机,routing key 为
order.created.vip - 队列 A 绑定
order.#→ 收到 - 队列 B 绑定
order.created.*→ 收到 - 队列 C 绑定
order.canceled.*→ 收不到
33. 如何保证消息不丢失?
回答:
消息可能在三个地方丢失,需要分别处理:
1. 生产者 → MQ(生产端丢失)
- confirm 机制:消息持久化到磁盘后返回 ack
- 配置:
publisher-confirm-type: correlated - 没收到 ack 就重试或记录日志
2. MQ 自身(Broker 丢失)
- 交换机持久化:
durable = true - 队列持久化:
durable = true - 消息持久化:
deliveryMode = 2
3. MQ → 消费者(消费端丢失)
- 手动 ACK:消费成功后手动确认
- 配置:
acknowledge-mode: manual - 消费失败:nack 重回队列或进死信队列
一句话总结:生产端 confirm + 持久化 + 消费端手动 ACK。
34. 如何保证消息不重复消费?
回答:
MQ 不保证消息只投递一次,所以消费端必须做幂等。
常见幂等方案:
1. 唯一 ID + 去重表
// 消费前检查
if (dedupMapper.exists(messageId)) {
return; // 已处理,跳过
}
// 处理业务
// 写入去重表
dedupMapper.insert(messageId);
2. 数据库唯一约束
- 利用主键或唯一索引,重复插入会失败
3. 状态机
- 订单状态:待支付 → 已支付 → 已发货
- 只有特定状态才允许流转,否则跳过
4. Redis 原子操作
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(messageId, "1", 1, TimeUnit.HOURS);
if (!success) {
return; // 已处理
}
35. 消息积压怎么处理?
回答:
紧急处理:
- 增加消费者数量:临时扩容消费端
- 批量消费:一次拉取多条消息处理
- 跳过非关键消息:如果是日志类消息,可以直接丢弃
根因分析:
- 消费速度 < 生产速度:优化消费逻辑、增加消费者
- 消费者挂了:检查消费者健康状态、报警机制
- 下游依赖慢:优化下游或做异步处理
长期优化:
- 消费端使用线程池并行处理
- 拆分队列,按业务分流
- 监控告警,提前发现
第四部分:架构与设计
36. CAP 理论是什么?
回答:
CAP 指分布式系统中三个特性最多只能同时满足两个:
- C(Consistency)一致性:所有节点看到的数据一致
- A(Availability)可用性:每个请求都能得到响应
- P(Partition tolerance)分区容错:网络分区时系统仍能运行
为什么只能三选二?
网络分区是必然会发生的,所以 P 必须保证,只能在 C 和 A 之间选择:
- CP:保证一致性,可能拒绝服务(如 ZooKeeper)
- AP:保证可用性,可能数据不一致(如 Eureka)
实际应用中,通常选择 BASE(最终一致性):
- 基本可用
- 软状态
- 最终一致
37. 分布式事务有哪些解决方案?
回答:
| 方案 | 一致性 | 复杂度 | 性能 | 场景 |
|---|---|---|---|---|
| 2PC | 强一致 | 中 | 低 | 数据库事务 |
| TCC | 强一致 | 高 | 中 | 资金交易 |
| SAGA | 最终一致 | 中 | 高 | 长事务流程 |
| 本地消息表 | 最终一致 | 中 | 高 | 异步场景 |
| MQ 事务消息 | 最终一致 | 低 | 高 | 异步解耦 |
我项目中用的是本地消息表模式:
- 业务操作和消息写入在同一个本地事务
- 后台定时扫描未发送的消息
- 消费端处理成功后更新消息状态
- 需要做好幂等处理
38. 如何设计一个高并发系统?
回答:
高并发设计遵循几个核心原则:
1. 缓存
- 多级缓存:本地缓存 → Redis → 数据库
- 热点数据预热
2. 异步
- MQ 解耦非核心流程
- 异步写入、批量处理
3. 池化
- 连接池、线程池复用资源
4. 分离
- 读写分离、动静分离、冷热分离
5. 拆分
- 服务拆分(微服务)
- 数据拆分(分库分表)
6. 限流降级
- 限流保护系统
- 熔断快速失败
- 降级保证核心功能
具体到我的短链项目:
- 布隆过滤器拦截无效请求
- Redis 缓存热点短链
- MQ 异步写入访问日志
- 分布式锁控制并发创建
39. 常用的设计模式及场景?
回答:
创建型:
- 单例模式:Spring Bean 默认单例、线程池
- 工厂模式:Spring BeanFactory、日志工厂
结构型:
- 代理模式:Spring AOP、MyBatis Mapper
- 装饰器模式:Java IO 流
行为型:
- 策略模式:支付方式选择、多渠道发送
- 模板方法模式:JdbcTemplate、RestTemplate
- 观察者模式:Spring 事件机制
我项目中用到的:
- 策略模式:邮件系统多通道适配,抽象发送接口,不同通道不同实现
- 模板方法:定义发送流程骨架,子类实现具体发送逻辑
第五部分:短链接项目深度问答
项目整体
40. 简单介绍一下你的短链接项目?
回答:
这是一个短链接服务系统,主要解决长链接不便分享、无法追踪访问数据的问题。
核心功能:
- 短链生成:长链接转换为 6 位短码
- 短链跳转:访问短链 302 重定向到原始 URL
- 访问统计:记录 PV、UV、访问来源、设备分布等
技术架构:
- 后端:Spring Boot + MyBatis-Plus
- 缓存:Redis(缓存 + 分布式锁 + HyperLogLog)
- 消息队列:RabbitMQ(异步统计)
- 防穿透:Guava 布隆过滤器
核心设计:
- 分布式锁防止同一 URL 并发创建多个短码
- 布隆过滤器 + 缓存 + DB 三级读取
- MQ 解耦统计写入,保证跳转链路快速响应
41. 短链创建的完整流程是什么?
回答:
短链创建流程分 7 步:
- 加分布式锁:用
originalUrl.hashCode()作为锁 key,防止同一 URL 并发创建 - 二次校验:持锁后查数据库,如果已存在直接返回
- 生成短码:时间戳 + 机器 ID + 序列号生成唯一 ID,Base62 编码为 6 位短码
- 布隆过滤器检查:如果短码"可能存在",重新生成
- 写入数据库:保存短链映射关系
- 更新布隆过滤器:加入新短码
- 写入 Redis 缓存:缓存原始 URL,过期时间加随机抖动
为什么这么设计:
- 分布式锁 + 二次校验:保证幂等性
- 布隆过滤器:快速排除冲突短码
- 随机过期时间:防止缓存雪崩
42. 短链跳转的完整流程是什么?
回答:
跳转流程分 5 步,核心是快速返回:
- 格式校验:正则检查短码是否为 6 位字母数字
- 布隆过滤器:判断短码是否可能存在,不存在直接返回 404
- 查 Redis 缓存:命中直接拿到原始 URL
- 缓存未命中查 DB:查数据库并回填缓存
- 异步发送 MQ:构建访问日志消息发送到队列
- 302 重定向:立即返回重定向响应
关键设计:
- 布隆过滤器前置拦截,减少无效请求
- 统计走 MQ 异步,不阻塞跳转响应
- 跳转响应时间要控制在 50ms 以内
技术细节
43. 为什么短链创建要用分布式锁?
回答:
为了防止同一 URL 并发创建多个短码。
场景举例:
- 用户 A 请求创建短链,URL 是
https://example.com - 同时用户 B 也请求创建,URL 也是
https://example.com - 两个请求都查询数据库发现不存在
- 两个请求都生成新短码并插入
- 结果:同一个 URL 有了两个短码
加锁后:
- 锁 key 用
originalUrl.hashCode() - 同一 URL 的请求串行执行
- 第二个请求拿到锁后,二次查询发现已存在,直接返回
为什么用 hashCode?
- URL 可能很长,用 hashCode 缩短锁 key
- hash 碰撞只会影响性能,不影响正确性
44. Redisson 分布式锁的参数怎么设置的?
回答:
lock.tryLock(5, 10, TimeUnit.SECONDS);
// 参数1:等待获取锁的最大时间 5 秒
// 参数2:锁的自动释放时间 10 秒
为什么等待 5 秒?
- 高并发时可能多个请求竞争同一把锁
- 等待 5 秒可以让请求排队获取锁
- 避免直接失败影响用户体验
为什么超时 10 秒?
- 防止死锁,比如进程崩溃没释放锁
- 10 秒足够完成业务逻辑
- 如果业务确实需要更长时间,可以用看门狗自动续期
实际生产中:如果不指定超时时间,Redisson 会启用看门狗,默认 30 秒超时并自动续期。
45. 布隆过滤器误判了怎么办?
回答:
布隆过滤器的特性是:
- 判断不存在,则一定不存在
- 判断可能存在,则需要进一步确认
误判的影响:
- 创建短码时误判:重新生成短码,多循环一次,性能损耗可接受
- 跳转时误判:多查一次数据库,查不到返回 404
所以误判不会导致错误结果,只会多一次 DB 查询。
控制误判率:
BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8),
1000000, // 预期元素数量
0.001 // 误判率 0.1%
);
如果误判率升高:
- 元素数量超过预期容量,需要重建过滤器
- 或者使用 Redis 的 Bloom Filter 模块,支持动态扩容
46. 缓存和数据库如何保持一致?
回答:
我采用的是 Cache Aside 策略,允许短时间不一致。
读流程:
- 先查缓存,命中直接返回
- 未命中查数据库
- 回填缓存,设置过期时间
写流程(创建短链):
- 先写数据库
- 再写缓存
为什么不先写缓存?
- 如果数据库写入失败,缓存里就有脏数据
会不会不一致?
- 短链系统是只增不改的场景
- 短链创建后不会修改原始 URL
- 所以不存在更新不一致的问题
如果是修改场景,一般用延迟双删:
- 先删除缓存
- 更新数据库
- 延迟一段时间后再删缓存
47. 为什么用 HyperLogLog 统计 UV?
回答:
UV 是独立访客数,需要对访问 IP 去重。
如果用 Set:
- 每个短链的访问 IP 都存一个 Set
- 100 万 IP 大约占用 50MB 内存
- 短链数量多的话内存扛不住
HyperLogLog 的优势:
- 固定只占 12KB 内存
- 可以统计亿级基数
- 误差率约 0.81%,UV 统计可接受
代码实现:
// 总 UV
stringRedisTemplate.opsForHyperLogLog().add(uvKey, ip);
// 今日 UV
String todayUvKey = UV_KEY_PREFIX + shortCode + ":" + LocalDate.now();
stringRedisTemplate.opsForHyperLogLog().add(todayUvKey, ip);
缺点:
- 只能估算基数,不能获取具体元素
- 有一定误差,对精确统计不适用
48. 访问统计为什么走 MQ 异步?
回答:
因为跳转链路要尽量短。
如果同步统计:
- 查询原始 URL
- 写入访问日志表
- 更新 Redis PV
- 更新 HyperLogLog UV
- 302 重定向
统计操作涉及数据库写入和多次 Redis 操作,可能耗时 50-100ms,用户会明显感知到延迟。
异步后:
- 查询原始 URL
- 发送 MQ 消息(1-2ms)
- 302 重定向
统计操作放到消费端异步处理:
- 跳转响应时间从 100ms 降到 20ms
- 消费端处理慢不影响用户体验
- MQ 还能起到削峰作用
49. MQ 消费失败怎么处理?
回答:
我用的是手动 ACK + 重回队列:
try {
// 处理业务逻辑
channel.basicAck(deliveryTag, false); // 成功确认
} catch (Exception e) {
channel.basicNack(deliveryTag, false, true); // 失败重回队列
}
存在的问题:
- 如果消息一直处理失败,会无限重试
- 可能造成消息积压
改进方案:
- 设置重试上限:消息头记录重试次数
- 死信队列:超过重试上限进入死信队列
- 人工处理:监控死信队列,报警后人工处理
// 改进后的消费逻辑
int retryCount = getRetryCount(message);
if (retryCount >= 3) {
// 进入死信队列
channel.basicReject(deliveryTag, false);
} else {
// 重试
channel.basicNack(deliveryTag, false, true);
}
50. 如果让你优化这个项目,你会怎么做?
回答:
1. 布隆过滤器优化
- 当前是本地内存,启动时全量加载
- 改用 Redis Bloom Filter 模块,支持分布式、持久化
2. MQ 幂等优化
- 当前消费可能重复
- 增加去重表,消费前检查消息 ID 是否已处理
3. 分库分表
- 短链表和访问日志表数据量大
- 按短码 hash 分表,按时间分库
4. 限流熔断
- 增加 Sentinel 限流
- 热点短链保护,防止单个短链流量打爆
5. 监控完善
- 接入 Prometheus + Grafana
- 监控 QPS、响应时间、缓存命中率、队列积压
第六部分:邮件推送中心项目深度问答
项目整体
51. 简单介绍一下邮件推送中心项目?
回答:
这是一个邮件推送服务,支持模板化发送和定时发送。
核心功能:
- 模板管理:创建、编辑邮件模板,支持变量占位符
- 即时发送:创建任务后立即发送
- 定时发送:指定时间发送
- 失败重试:发送失败自动重试
- 发送记录:记录每次发送的结果
技术架构:
- 后端:Spring Boot + MyBatis-Plus
- 消息队列:RabbitMQ
- 定时任务:Spring @Scheduled
- 邮件通道:阿里云 DirectMail
核心设计:
- 任务队列解耦,接口快速响应
- 定时扫描支持延迟发送
- 失败重试机制保证可靠性
52. 为什么要把发送拆成"创建任务 + 队列消费"?
回答:
这是异步解耦的设计。
如果同步发送:
- 接收请求
- 渲染模板
- 调用邮件通道发送(可能耗时 1-3 秒)
- 返回响应
接口响应时间长,用户体验差。如果通道超时,还会影响调用方。
异步后:
- 接收请求
- 渲染模板,创建发送任务,落库
- 任务 ID 入队
- 立即返回任务编号
好处:
- 接口响应快,用户立即得到反馈
- 发送失败可以重试,不影响调用方
- 队列削峰,应对突发流量
- 便于扩展消费端并行度
53. 即时发送和定时发送在流程上有什么区别?
回答:
即时发送:
- 创建任务,状态为"待发送"
- 立即将任务 ID 投递到 MQ
- 消费端处理发送
定时发送:
- 创建任务,状态为"待发送",记录
scheduledTime - 不入队,只落库
- 定时扫描器每 10 秒查询一次
- 查到"待发送 + 定时发送 + 已到期"的任务
- 将到期任务投递到 MQ
代码区分:
if (request.getSendType() == 1) { // 即时发送
rabbitTemplate.convertAndSend(EXCHANGE, ROUTING_KEY, task.getId());
}
// 定时发送不做任何操作,等待扫描器处理
54. 消费端如何做到幂等?
回答:
当前实现用的是状态判断:
// 如果已经是成功或失败终态,直接跳过
if (task.getStatus() == 2 || task.getStatus() == 3) {
return;
}
存在的问题:
- 如果消息在"发送中"状态时重复投递,可能重复发送
- 比如:消费端处理完但没来得及 ACK 就崩了,消息重回队列
更完善的方案:
1. 乐观锁
// 用版本号控制
UPDATE send_task SET status = 1, version = version + 1
WHERE id = ? AND version = ?
// 更新影响行数为 0 说明被其他消费者处理了
2. 唯一约束
// 发送记录表对 (task_id, send_time) 加唯一索引
// 重复插入会失败
3. 分布式锁
String lockKey = "email:send:" + taskId;
if (redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 1, TimeUnit.HOURS)) {
// 获取锁成功,处理发送
} else {
// 已被其他消费者处理
}
55. 失败重试的策略是怎么设计的?
回答:
当前是等待重试 + 定时扫描的模式:
失败时:
if (task.getRetryCount() < MAX_RETRY) {
task.setRetryCount(task.getRetryCount() + 1);
task.setStatus(4); // 等待重试
} else {
task.setStatus(3); // 最终失败
}
定时扫描(每 20 秒):
// 查询等待重试的任务
wrapper.eq(SendTask::getStatus, 4);
List<SendTask> tasks = sendTaskMapper.selectList(wrapper);
for (SendTask task : tasks) {
task.setStatus(0); // 重置为待发送
sendTaskMapper.updateById(task);
rabbitTemplate.convertAndSend(..., task.getId()); // 重新入队
}
为什么不立即重试?
- 立即重试可能遇到相同问题(通道故障)
- 拉开间隔可以等待故障恢复
- 避免短时间大量重试放大压力
可优化点:
- 指数退避:第 1 次 20 秒,第 2 次 40 秒,第 3 次 80 秒
- 错误分类:网络超时可重试,参数错误不重试
56. 模板变量替换有什么边界问题?
回答:
当前用的是简单字符串替换:
for (Map.Entry<String, String> entry : variables.entrySet()) {
result = result.replace("${" + entry.getKey() + "}", entry.getValue());
}
存在的问题:
1. 变量缺失
- 模板有
${name}但没传 name 变量 - 结果:邮件内容包含
${name}占位符
2. 特殊字符
- 变量值包含 HTML 标签或脚本
- 可能导致 XSS 或显示异常
3. 性能问题
- 变量很多时,多次 replace 效率低
改进方案:
- 变量校验:解析模板必填变量,和传入变量对比
- HTML 转义:对变量值做 escape 处理
- 模板引擎:使用 FreeMarker 或 Thymeleaf
57. 如果扩展到多邮件通道,怎么设计?
回答:
使用策略模式进行抽象:
1. 定义统一接口
public interface EmailSender {
void send(String toEmail, String subject, String content) throws Exception;
String getChannelCode(); // 通道标识
}
2. 多通道实现
@Component
public class AliyunEmailSender implements EmailSender {
public String getChannelCode() { return "aliyun"; }
public void send(...) { /* 阿里云实现 */ }
}
@Component
public class TencentEmailSender implements EmailSender {
public String getChannelCode() { return "tencent"; }
public void send(...) { /* 腾讯云实现 */ }
}
3. 策略选择器
@Component
public class EmailSenderSelector {
@Autowired
private List<EmailSender> senders;
private Map<String, EmailSender> senderMap;
@PostConstruct
public void init() {
senderMap = senders.stream()
.collect(Collectors.toMap(EmailSender::getChannelCode, s -> s));
}
public EmailSender select(String channelCode) {
return senderMap.getOrDefault(channelCode, defaultSender);
}
}
4. 支持降级
- 主通道失败自动切换备用通道
- 配置权重实现灰度发布
58. 邮件通道密钥如何安全管理?
回答:
当前问题:
密钥写在配置文件里,存在安全风险。
改进方案:
1. 环境变量
aliyun:
access-key-id: ${ALIYUN_ACCESS_KEY_ID}
access-key-secret: ${ALIYUN_ACCESS_KEY_SECRET}
2. 配置中心加密
- Nacos 配置加密
- Apollo 密文存储
3. 密钥管理服务
- 阿里云 KMS
- HashiCorp Vault
4. 权限控制
- 生产环境配置文件只有运维能访问
- 审计日志记录密钥使用
第七部分:场景设计题
59. 如果让你设计一个高并发短链系统,QPS 要达到 10 万,你会怎么设计?
回答:
10 万 QPS 需要从多个层面优化:
1. 多级缓存
- 本地缓存(Caffeine):热点短链缓存在 JVM
- Redis 集群:分布式缓存层
- 减少 Redis 访问和网络开销
2. 读写分离
- 跳转是读操作,创建是写操作
- 读写比 100:1,重点优化读链路
3. 分库分表
- 短链表按短码 hash 分表
- 访问日志表按时间分表
4. 集群部署
- 服务无状态化,水平扩展
- Nginx 负载均衡
5. 异步化
- 统计完全异步
- MQ 集群保证吞吐
6. 限流降级
- 单短链限流,防止热点打爆
- 统计可降级,保证核心跳转
架构图:
用户 → CDN → Nginx 集群 → 服务集群 → 本地缓存 → Redis 集群 → MySQL 集群
↓
MQ 集群 → 统计服务
60. 短链接被恶意刷量怎么办?
回答:
识别恶意流量:
- 单 IP 高频访问:同一 IP 短时间大量请求
- UA 异常:无 UA 或 UA 明显是爬虫
- 访问模式异常:凌晨流量暴增
防护方案:
1. 限流
// 单 IP 限流:每分钟最多 100 次
String limitKey = "ratelimit:" + ip;
Long count = redisTemplate.opsForValue().increment(limitKey);
if (count == 1) {
redisTemplate.expire(limitKey, 1, TimeUnit.MINUTES);
}
if (count > 100) {
throw new RateLimitException("请求过于频繁");
}
2. 验证码
- 检测到异常访问,返回验证页面
- 验证通过后才真正跳转
3. IP 黑名单
- 识别到恶意 IP 加入黑名单
- 请求先过黑名单检查
4. 统计隔离
- 可疑流量单独标记
- 不计入正常统计
更多推荐



所有评论(0)