先说明一点:“8月花3天速刷 Java 后端八股文,面试通过率可达 99%”这种说法,在真实求职场景里更多是一种“标题党”式激励。没有任何一份题单能保证通过率,八股文能帮你快速建立知识框架、应对面试前期的技术广度和基础深度考察,但真正决定 offer 的,仍然是你的项目经验、问题排查思路和工程落地能力。
所以这篇文章的实际定位是:帮你把 Java 后端面试中最常见的高频题做一次系统梳理,并配合回答要点、追问方向和避坑说明,让你用 3 天时间完成一轮高效复习。内容以面试准备为视角,覆盖 Java 基础、集合、并发、JVM、Spring、MySQL、Redis、消息队列与分布式等核心板块。
无论你是刚开始投递实习岗位,还是准备跳槽中级后端开发,都可以直接照着这篇文章拆解出来的题目清单进行查漏补缺。
1. 为什么 Java 后端面试还要刷八股文
1.1 八股文到底考察什么
很多同学对八股文有抵触情绪,觉得“背得好不等于写得好”。但从面试官的角度看,八股文题目是快速筛选候选人的最低成本方式:
- 考察你是否有完整的计算机基础知识体系。
- 考察你能否用清晰的语言表达抽象概念。
- 考察你在真实项目中是否理解底层原理,而不只是会调用 API。
换句话说,八股文不只是背答案,它是在检验你“能不能把一个复杂问题讲清楚”。这一能力直接反映到日常开发中的技术方案设计、代码评审和问题排查。
1.2 为什么需要 3 天速刷
3 天时间非常紧迫,不可能从头系统学习 Java。速刷的前提是:你已经有了一定的编码经验,只是知识点分散、缺少面试视角的串联。
速刷的真正目标有三个:
- 形成知识地图:拿到一个题目,能快速定位到对应知识域。
- 掌握回答模板:面试官问到一个知识点,能按“概念 + 原理 + 场景 + 优缺点”的结构回答。
- 暴露盲区:刷题过程中发现哪些知识点自己只会用、不会说,及时补齐。
1.3 面试通过率怎么看待
任何“通过率 99%”的宣传都不可信。面试能否通过,取决于岗位匹配度、面试官风格、项目经历、临场沟通、甚至当天的HC(招聘名额)情况。
把八股文当作“基础线”而不是“天花板”:
- 八股文保证你不挂在一个基础问题上。
- 项目深度决定你能走多远的复试。
- 算法题决定大厂是否能给你 offer。
所以这篇文章的最终目标是:让你在 3 天内把基础线拉满,把时间留给项目和算法。
2. 3 天速刷路线规划
2.1 每天的复习节奏
建议把每天拆成三个时间段,每个时间段 2 到 3 小时:
- 上午:记忆型知识点,比如 JVM、MySQL 索引、Spring 生命周期。
- 下午:理解型知识点,比如并发编程、HashMap 原理、事务传播机制。
- 晚上:实战演练,包括手写单例、手写线程池、SQL 练习、模拟面试录音。
下面是一份可执行的 3 天计划表。
| 天数 | 上午 | 下午 | 晚上 |
|---|---|---|---|
| 第 1 天 | Java 基础 + 集合框架 | JVM 内存模型 + GC 算法 | 手写 HashMap 核心逻辑 + 复习错题 |
| 第 2 天 | 并发编程 + JUC | Spring IoC / AOP / 事务 | 模拟面试:并发 + Spring 主题 |
| 第 3 天 | MySQL 索引 + 事务 + Redis | 消息队列 + 分布式 + 系统设计 | 全真模拟面试 + 查漏补缺 |
2.2 复习资料怎么选
不建议同时看好几份资料,否则信息过载。建议按以下组合:
- 1 份系统化的面试题整理,比如本文。
- 1 本 Java 基础参考书,如《Java 核心技术》或《Java 编程思想》按需查阅。
- 1 个在线 OJ 平台,每天至少刷 2 道算法题。
- 1 个录音工具,模拟面试后用录音复盘表达逻辑。
2.3 如何判断自己是否掌握一个题目
用“三分钟法则”自测:
- 拿到题目后,能否在 1 分钟内说清楚概念。
- 能否在接下来 1 分钟说清楚原理或流程。
- 能否在最后 1 分钟给出项目中的实际应用场景。
任何一环节卡住,都说明这个知识点还没真正掌握,需要重新梳理。
3. Java 基础与集合高频面试题
3.1 重写 equals 时为什么必须重写 hashCode
这是 Java 基础里最高频的题目之一,几乎每场面试都会问到。
回答要点:
equals()用于判断两个对象逻辑上是否相等。hashCode()返回对象的哈希值,用于散列存储结构中定位对象。- 如果两个对象通过
equals()比较是相等的,那么它们的hashCode()必须相等。 - 如果只重写
equals()不重写hashCode(),在使用HashMap、HashSet等集合时,可能出现“对象相等但存储在不同桶里”的问题。
举个例子:
import java.util.HashSet; public class User { private String name; public User(String name) { this.name = name; } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj == null || getClass() != obj.getClass()) { return false; } User user = (User) obj; return name != null ? name.equals(user.name) : user.name == null; } // 如果不重写 hashCode,下面的去重逻辑就会出错 @Override public int hashCode() { return name != null ? name.hashCode() : 0; } public static void main(String[] args) { HashSet<User> userSet = new HashSet<>(); userSet.add(new User("张三")); userSet.add(new User("张三")); System.out.println(userSet.size()); // 正确输出 1 } }如果去掉hashCode()重写,HashSet会认为两个User对象不在同一个哈希桶,导致重复元素无法去重。
面试追问:
- HashSet 如何判断两个对象是否重复?
- HashMap 的 put 过程是怎样的?
- 为什么
String类重写了hashCode()?
3.2 ArrayList 和 LinkedList 的区别
这题看起来简单,但很容易回答得过于表面。面试官真正想听的是“不同场景下的选择依据”。
核心区别如下:
- 底层数据结构:
ArrayList基于动态数组,LinkedList基于双向链表。 - 随机访问:
ArrayList通过下标访问,时间复杂度 O(1);LinkedList需要从头遍历,时间复杂度 O(n)。 - 插入删除:
ArrayList在中间插入会移动元素,LinkedList只需要调整指针。 - 内存占用:
ArrayList会预留容量可能浪费空间,LinkedList每个节点额外存储前后指针,占用更多。 - 应用场景:
ArrayList更适合读多写少的场景,LinkedList更适合频繁在头部或中部插入删除的场景。
需要特别注意的是实际使用中:
List<Integer> list = new LinkedList<>(); list.get(500000); // 如果链表很长,这会非常慢所以不要只背“LinkedList 增删快”,而忽略它随机访问性能极差的问题。
3.3 HashMap 底层原理(高频中的高频)
这是 Java 后端面试必考题,建议按以下顺序回答:
- 数据结构:数组 + 链表 + 红黑树(JDK 1.8 及以后)。
- put 流程:
- 计算 key 的 hash 值,扰动函数处理后确定桶下标。
- 如果没有哈希冲突,直接放入数组。
- 如果有冲突,使用链地址法,将新节点追加到链表尾部。
- 当链表长度超过阈值 8 且数组长度大于等于 64 时,链表转为红黑树。
- 当容量超过负载因子 * 当前容量时,触发扩容。默认负载因子 0.75,默认初始容量 16。
- get 流程:
- 计算 key 的 hash 值,定位桶位置。
- 遍历链表或红黑树,通过
equals()方法找到目标节点。
- 扩容机制:
- 重新计算容量翻倍。
- 元素重新散列到新数组中。
- 红黑树在扩容时可能会拆分为多个链表或树。
面试官常问:
- 为什么负载因子是 0.75?
- 为什么链表转红黑树的阈值是 8?
- JDK 1.7 和 1.8 的 HashMap 有什么区别?
- HashMap 为什么线程不安全?
回答“线程不安全”时,要能说清楚具体表现:JDK 1.7 并发 put 可能会导致环形链表和数据丢失,JDK 1.8 修复了环形链表问题,但多线程同时 put 仍可能发生数据覆盖。并发场景应使用ConcurrentHashMap。
3.4 说一下 fail-fast 机制
fail-fast是 Java 集合框架中的一种快速失败机制。在迭代器遍历集合的过程中,如果集合结构被修改,会抛出ConcurrentModificationException。
原理:
- 迭代器内部维护一个
modCount修改计数器。 - 每次调用
next()时会检查modCount是否发生变化。 - 如果发生变化,说明集合被并发修改,立即抛出异常。
示例:
import java.util.ArrayList; import java.util.Iterator; import java.util.List; public class FailFastDemo { public static void main(String[] args) { List<String> list = new ArrayList<>(); list.add("A"); list.add("B"); Iterator<String> iterator = list.iterator(); while (iterator.hasNext()) { String item = iterator.next(); if ("A".equals(item)) { list.remove(item); // 抛出 ConcurrentModificationException } } } }要删除元素,应该使用iterator.remove(),或者在普通 for 循环中倒序删除。
但注意,fail-fast并不能保证绝对正确,它只是一种错误检测机制,不能依赖它来实现并发安全。
3.5 泛型是什么,为什么需要泛型
泛型是 Java 类型安全的重要机制。
回答结构:
- 泛型提供了编译时类型检查,在编译阶段就能发现类型不匹配问题。
- 泛型可以避免强制类型转换的麻烦和潜在 ClassCastException。
- 泛型在 Java 中通过类型擦除实现,运行时并不会保留具体泛型类型。
常见误区:
List<String>不能赋值给List<Object>,因为泛型是不变的(invariant)。- 但
List<String>可以赋值给List<? extends Object>,这是协变。
面试中可以用这段代码说明:
List<String> strings = new ArrayList<>(); List<Object> objects = strings; // 编译错误4. JVM 与内存高频面试题
4.1 JVM 内存区域划分
要准确说出以下五个核心区域,以及哪些是线程私有、哪些是线程共享:
- 程序计数器:记录当前线程执行的字节码行号,线程私有,不会发生 OOM。
- Java 虚拟机栈:存放栈帧,每个方法调用对应一个栈帧,线程私有,栈深度不足抛 StackOverflowError。
- 本地方法栈:为 native 方法服务,线程私有。
- 堆:存放对象实例,线程共享,是 GC 的主要区域。
- 方法区:存储类信息、常量、静态变量等,线程共享,JDK 1.8 之后使用元空间(Metaspace)实现。
另外 JDK 1.8 后,字符串常量池被移到了堆中,运行时常量池在方法区中。
面试常问的版本差异:
| 版本 | 永久代 | 元空间 |
|---|---|---|
| JDK 1.7 | 方法区由永久代实现 | 无 |
| JDK 1.8 | 移除 | 使用本地内存实现方法区 |
4.2 JVM 如何判断对象可以被回收
经典回答有两种算法:
- 引用计数法:每个对象维护一个引用计数器,计数为 0 时回收。优点是简单高效,缺点是无法解决循环引用问题。
- 可达性分析算法:从 GC Roots 出发,遍历引用链,不可达的对象判定为可回收。主流 JVM 采用此方案。
GC Roots 包括:
- 虚拟机栈中引用的对象。
- 方法区中静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中 JNI 引用的对象。
追问:
- 什么情况下对象明明没有引用链但不会被回收?—— 对象重写了
finalize()方法,在第一次标记时进入 F-Queue,可能自救一次。 finalize()为什么不被推荐使用?—— 执行时间不确定,可能导致内存无法及时释放,在 JDK 9 中被标记为废弃。
4.3 常见的垃圾回收算法
- 标记-清除:先标记可回收对象,再统一回收。缺点是会产生内存碎片。
- 标记-复制:将内存分为两块,每次只使用一块,回收时将存活对象复制到另一块。适合存活率低的场景,比如新生代。
- 标记-整理:标记后把所有存活对象向一端移动,然后清理边界以外的内存。适合老年代。
从实际 JVM 来说,新生代采用复制算法,老年代采用标记-整理或标记-清除,这也是为什么需要分代收集的原因。
4.4 什么是 OOM,如何排查 OutOfMemoryError
OOM 全称 OutOfMemoryError,是 JVM 无法继续分配内存时抛出的错误。
常见类型:
Java heap space:堆内存不足。Metaspace:元空间不足。Unable to create new native thread:无法创建本地线程。GC overhead limit exceeded:GC 时间过长但回收效果很差。
排查思路:
- 在启动参数中添加
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof。 - 拿到 Heap Dump 文件后,使用 MAT 或 VisualVM 分析。
- 查看是哪个对象占用了大量内存,定位到代码位置。
一个常见的原因是内存泄漏,例如:
public class OOMDemo { private static final List<byte[]> LIST = new ArrayList<>(); public static void main(String[] args) { while (true) { LIST.add(new byte[1024 * 1024]); // 不断向静态集合添加数据 } } }这个例子中LIST是静态集合,持有所有对象的强引用,导致 GC 无法回收,最终堆内存耗尽。
解决方式是排查代码中的大对象、静态集合、未关闭的连接等,必要时调整堆大小参数-Xmx。
4.5 类的加载过程
类加载过程分为三个阶段:
- 加载:通过类的全限定名获取二进制字节流,将静态存储结构转换为方法区运行时数据结构,并在堆中生成 Class 对象。
- 连接:包括验证、准备、解析。
- 验证:校验字节码文件的安全性。
- 准备:为类变量分配内存并设置初始值。
- 解析:将符号引用替换为直接引用。
- 初始化:执行类构造器
<clinit>(),为静态变量赋值。
双亲委派模型也是高频追问:
- 类加载器层级:启动类加载器、扩展类加载器、应用程序类加载器。
- 工作流程:当一个类加载器收到加载请求时,先委托给父加载器,父加载器无法完成时才自己加载。
- 优点:避免核心类被篡改,防止类重复加载。
5. 并发编程高频面试题
5.1 synchronized 的实现原理
这是并发编程里最核心的题目。推荐按“使用方式 + 原理 + 锁升级”结构回答。
使用方式:
- 修饰实例方法,锁的是当前实例对象。
- 修饰静态方法,锁的是当前类的 Class 对象。
- 修饰代码块,锁的是括号内指定的对象。
原理:
- JVM 通过 Monitor(监视器锁)实现 synchronized。
- 每个对象都关联一个 Monitor,进入方法或代码块时执行
monitorenter,退出时执行monitorexit。 - 只有获取到 Monitor 的线程才能执行,其他线程进入阻塞状态。
锁升级:
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。这个升级过程是 JDK 1.6 之后为了减少锁竞争开销而设计的。锁升级方向不可逆,重量级锁不会自动降级。
面试追问:
- 偏向锁为什么被废弃?—— 在现代应用场景中,线程竞争普遍,偏向锁的撤销成本较高,JDK 15 中默认禁用偏向锁。
- 锁消除和锁粗化是什么?—— 前者是 JIT 编译器检测到不可能存在竞争时消除锁;后者是把多个相邻的锁合并为一个粗粒度锁,减少锁的获取释放次数。
5.2 volatile 关键字的作用
volatile是 Java 中轻量级的同步机制,两个核心作用是可见性和有序性。
- 可见性:一个线程修改变量后,其他线程能立即看到修改后的值。
- 有序性:通过内存屏障禁止指令重排序。
但它不能保证原子性。经典案例:
public class VolatileDemo { private static volatile int count = 0; public static void main(String[] args) throws InterruptedException { Runnable task = () -> { for (int i = 0; i < 10000; i++) { count++; } }; Thread t1 = new Thread(task); Thread t2 = new Thread(task); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(count); // 结果可能小于 20000 } }原因在于count++是“读-改-写”三步操作,volatile 只保证读和写的可见性,无法保证这三步的原子性。解决方法是使用AtomicInteger或synchronized。
5.3 ThreadLocal 原理与内存泄漏问题
ThreadLocal 用于在每个线程中保存线程私有变量,避免参数反复传递。
原理:
- 每个 Thread 对象内部有
ThreadLocalMap。 - 调用
set(value)时,将数据保存到当前线程的 Map 中。 - 调用
get()时,从当前线程的 Map 中取出数据。
内存泄漏风险:
- ThreadLocalMap 中的 key 是 ToThreadLocal 的弱引用。
- 当 ThreadLocal 外部强引用被回收后,key 会变成 null。
- 但 value 仍然被强引用持有,如果线程长期存活,value 就一直无法回收。
解决方式:
- 每次使用完 ThreadLocal 后,调用
remove()清除。 - 使用 try-finally 结构确保执行。
private static final ThreadLocal<String> USER_CONTEXT = new ThreadLocal<>(); public void process() { try { USER_CONTEXT.set("tom"); // 业务处理 } finally { USER_CONTEXT.remove(); // 防止内存泄漏 } }追问:
- 为什么 key 使用弱引用而不是 value?
- 线程池中使用 ThreadLocal 有什么问题?
5.4 线程池的核心参数
线程池是并发编程高频题,核心是能说出ThreadPoolExecutor的七个参数并解释执行流程。
new ThreadPoolExecutor( int corePoolSize, // 核心线程数 int maximumPoolSize, // 最大线程数 long keepAliveTime, // 空闲线程存活时间 TimeUnit unit, // 时间单位 BlockingQueue<Runnable> workQueue, // 阻塞队列 ThreadFactory threadFactory, // 线程工厂 RejectedExecutionHandler handler // 拒绝策略 );执行流程:
- 当前线程数小于核心线程数时,创建新线程执行任务。
- 当前线程数大于等于核心线程数时,任务进入阻塞队列。
- 队列已满且线程数小于最大线程数时,创建新线程执行任务。
- 队列已满且线程数达到最大线程数时,执行拒绝策略。
四种拒绝策略:
AbortPolicy:直接抛出 RejectedExecutionException,默认策略。CallerRunsPolicy:由调用者所在线程执行任务。DiscardPolicy:直接丢弃任务。DiscardOldestPolicy:丢弃队列中最旧的任务,然后重新尝试提交。
为什么要用线程池?主要解决线程频繁创建销毁带来的性能开销,同时通过队列做缓冲,防止瞬间高流量打垮系统。
5.5 CAS 是什么,有什么问题
CAS 全称 Compare And Swap,是乐观锁的核心实现。比较内存中的值是否等于预期值,如果相等才更新为新值,整个过程是原子的。
Java 中AtomicInteger等原子类底层就是依赖 CAS + volatile 实现的。
import java.util.concurrent.atomic.AtomicInteger; public class CASDemo { private static final AtomicInteger COUNT = new AtomicInteger(0); public static void main(String[] args) throws InterruptedException { Runnable task = () -> { for (int i = 0; i < 10000; i++) { COUNT.incrementAndGet(); } }; Thread t1 = new Thread(task); Thread t2 = new Thread(task); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println(COUNT.get()); // 输出 20000 } }CAS 存在的问题:
- ABA 问题:变量被修改为 A,再改回 A,CAS 无法感知。解决方式是使用版本号,
AtomicStampedReference。 - 自旋开销:高并发场景下 CAS 长时间自旋会消耗 CPU。
- 只能保证单个变量的原子操作。
6. Spring 与 Spring Boot 高频面试题
6.1 什么是 IoC,什么是 AOP
IoC(控制反转)是一种设计思想,将对象的创建和管理交给 Spring 容器,而不是在代码中通过new手动创建。这样做的好处是降低模块间的耦合。
AOP(面向切面编程)是对 OOP 的补充,将日志、事务、权限等横切逻辑从业务代码中剥离出来,通过代理方式动态织入。
AOP 的核心术语:
- 切面(Aspect):横切逻辑的模块化单元。
- 连接点(Join Point):程序执行过程中的某个点,比如方法调用。
- 通知(Advice):在连接点执行的动作。
- 切入点(Pointcut):匹配连接点的表达式。
@Aspect @Component public class LogAspect { @Around("execution(* com.example.service.*.*(..))") public Object log(ProceedingJoinPoint joinPoint) throws Throwable { long start = System.currentTimeMillis(); Object result = joinPoint.proceed(); long cost = System.currentTimeMillis() - start; System.out.println("方法耗时:" + cost + "ms"); return result; } }面试追问:
- Spring AOP 和 AspectJ 有什么区别?
- JDK 动态代理和 CGLIB 代理分别是什么?
回答思路:Spring AOP 支持两种代理方式,如果目标类实现了接口,默认使用 JDK 动态代理,基于接口生成代理类;如果没有实现接口,则使用 CGLIB,通过继承目标类生成子类。
6.2 Bean 的生命周期
这是一道必须背熟的题,推荐按以下阶段简答:
- Bean 实例化:通过构造器创建实例。
- 属性填充:通过 setter 或字段注入依赖。
- Aware 接口回调:例如 BeanNameAware、ApplicationContextAware。
- BeanPostProcessor 的 postProcessBeforeInitialization。
- InitializingBean 的 afterPropertiesSet,或自定义 init-method。
- BeanPostProcessor 的 postProcessAfterInitialization。
- 使用 Bean。
- 容器关闭时销毁,调用 DisposableBean 的 destroy 方法或自定义 destroy-method。
实际开发中常用的是@PostConstruct和@PreDestroy,注意它们与 InitializingBean 的执行顺序。
6.3 Spring 事务的传播行为
事务传播行为是指多个事务方法调用时,事务边界的控制方式。最常用的是:
- REQUIRED:如果当前存在事务则加入,否则新建事务。默认选项。
- REQUIRED_NEW:无论当前是否存在事务,都创建一个新事务,并挂起当前事务。
- SUPPORTS:当前存在事务则加入,否则非事务执行。
- NOT_SUPPORTED:以非事务方式执行,挂起当前事务。
- MANDATORY:当前必须存在事务,否则抛出异常。
- NEVER:当前必须不存在事务,否则抛出异常。
- NESTED:嵌套事务,基于 Savepoint 实现。
需要特别注意的坑:REQUIRED传播行为下,如果内部方法抛了异常,外部方法没有捕获,整个事务都会回滚。很多同学在try-catch中捕获了内部异常,导致事务无法回滚,这是实际项目中最常见的 Spring 事务失效场景之一。
6.4 Spring Boot 自动配置原理
Spring Boot 的自动配置基于@EnableAutoConfiguration注解实现。核心步骤:
- 通过
@Import(AutoConfigurationImportSelector.class)导入配置选择器。 - 配置选择器读取
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件。 - 加载所有自动配置类,通过
@Conditional条件注解按需生效。
例如,只有当项目中存在DataSource类并且没有自定义配置时,DataSourceAutoConfiguration才会生效。
条件注解常见的有:
@ConditionalOnClass:类路径存在指定类时生效。@ConditionalOnMissingBean:容器中不存在指定 Bean 时生效。@ConditionalOnProperty:配置文件中存在指定属性时生效。
7. MySQL 与 Redis 高频面试题
7.1 索引失效的常见场景
MySQL 索引用不好,几乎每场面试都会踩坑。高频失效场景有:
- 违反最左前缀法则:联合索引 (a, b, c),查询条件只有 b 或 c 时会失效。
- 在索引列上做函数运算或隐式类型转换。
- 使用 LIKE 时
%开头,比如LIKE '%abc'。 - 使用 OR 连接非索引字段。
- 索引列参与计算,例如
WHERE age + 1 = 20。
比如联合索引:
CREATE INDEX idx_user ON user (name, age, city);以下查询可以使用索引:
SELECT * FROM user WHERE name = '张三'; SELECT * FROM user WHERE name = '张三' AND age = 18;以下查询索引失效:
SELECT * FROM user WHERE age = 18; -- 缺少最左列 name SELECT * FROM user WHERE name = '张三' AND city = '上海'; -- 跳过了 age 列使用EXPLAIN关键字可以确认是否走索引。
7.2 事务隔离级别与 MVCC
MySQL 有四个事务隔离级别:
- READ UNCOMMITTED:可能读到脏数据。
- READ COMMITTED:解决脏读,但不可重复读。
- REPEATABLE READ:解决不可重复读,MySQL 默认级别。
- SERIALIZABLE:串行化,性能最低。
MVCC(多版本并发控制)是 MySQL 在 READ COMMITTED 和 REPEATABLE READ 下实现一致性读的核心机制。
- 每一行记录有隐藏的版本号字段。
- 事务读取数据时,根据 ReadView 找到当前事务可见的版本。
- UPDATE 不会直接覆盖旧数据,而是生成新版本。
- 在 REPEATABLE READ 下,事务第一次查询时生成 ReadView,之后复用,因此保证可重复读。
面试追问间隙可以补充:当前读(SELECT ... FOR UPDATE、UPDATE、DELETE)使用的是最新版本,直接加锁。
7.3 Redis 缓存穿透、击穿和雪崩
这是 Redis 面试题里最经典的“三兄弟”。
- 缓存穿透:请求查询一个不存在的数据,缓存和数据库中都没有,请求直接打到数据库。解决方式:缓存空对象、布隆过滤器。
- 缓存击穿:某个热点 key 过期,高并发请求同时打到数据库。解决方式:互斥锁、逻辑过期、热点 key 永不过期。
- 缓存雪崩:大量 key 同时过期,或者 Redis 宕机,导致大批请求打到数据库。解决方式:过期时间增加随机值、集群部署、限流降级。
布隆过滤器示例思路:
// 使用 Redisson 或 Guava 的布隆过滤器 // 初始化时将所有可能存在的数据 key 存入过滤器 // 请求进来先判断 key 是否存在于过滤器中 // 不存在则直接返回,避免击穿数据库7.4 Redis 持久化机制
RDB 和 AOF 是两种主流方案。
- RDB:定时生成内存快照,文件小,恢复快,但可能会丢失最后一次快照之后的数据。适合做灾备和冷备份。
- AOF:记录每次写操作指令,数据安全性高,但文件大、恢复慢。可以配置 everysec、always、no 三种刷盘策略。
生产环境通常采用 RDB + AOF 混合持久化方案,兼顾安全性和恢复速度。
7.5 分布式锁如何实现
常见实现方式是使用 Redis 的SET NX EX命令。
SET lock_key unique_value NX EX 30- NX:只有 key 不存在时才能设置成功。
- EX:设置过期时间,防止持有锁的线程宕机导致死锁。
- unique_value:每次请求生成的唯一标识,释放锁时校验,避免误删别人的锁。
释放锁时必须使用 Lua 脚本保证原子性:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end还可以延伸提到 Redisson 看门狗机制,它可以在锁的过期时间快到时自动续期,避免业务逻辑还没执行完锁就过期了。
8. 消息队列与分布式高频面试题
8.1 Kafka 为什么这么快
Kafka 的高性能是消息队列面试中的高频题。回答可以从几个维度展开:
- 顺序写磁盘:Kafka 将消息追加写入 Partition 日志文件,充分利用磁盘顺序写性能。
- 页缓存:利用操作系统的 Page Cache,不强制刷盘。
- 零拷贝:消费端读取文件时使用 sendfile,数据不经过用户态拷贝。
- 批量处理:Producer 批量发送,Consumer 批量拉取。
- 分区并行:Topic 拆分为多个 Partition,提高并行度。
追问:
- Kafka 如何保证消息不丢失?需要从生产者、Broker、消费者三端分别说明 ACK 机制、副本机制和手动提交 offset。
- Kafka 如何保证消息顺序?同一个 Partition 内有序,生产者指定 key 分区,但全局有序需要单一 Partition,会牺牲性能。
8.2 分布式事务有哪些方案
这块题目偏架构,但中级开发也常被问到。
- 两阶段提交(2PC):引入协调者,准备阶段和提交阶段,存在阻塞和协调者单点问题。
- TCC:Try、Confirm、Cancel 三个阶段,需要业务实现接口,侵入性强但性能较高。
- 本地消息表:将业务操作和消息写入同一个本地事务,异步发送消息。
- 消息最终一致性:利用 MQ 的事务消息或重试机制,达到最终一致。
以 RocketMQ 事务消息为例子,Producer 发送 half 消息,Broker 不会立即投递给消费者;本地事务执行成功后提交确认;如果长时间没有确认,Broker 会回查事务状态。
8.3 如何实现接口幂等
幂等性问题是后端开发日常高频需求,面试官通常会结合项目让你讲。
常见方案:
- 唯一索引:利用数据库唯一约束,重复插入直接报错。
- Token 机制:请求前获取 token,请求时携带 token,服务端校验并删除。
- 状态机:业务状态有明确流转,只有特定状态才允许执行。
- 分布式锁:同一业务 key 加锁,保证只有一个请求能执行。
在支付场景中,还会根据请求中的业务订单号做去重,防止重复扣款。
9. 面试答题技巧与追问应对
9.1 使用“总分总”结构答题
面试官问到一个比较宽泛的问题时,不要想到什么说什么。推荐使用“总分总”结构:
- 先一句话定义问题。
- 再分点讲核心原理或流程。
- 最后落回到实际项目和场景。
- 可选的结尾抛出自己擅长的话题,引导面试官继续追问。
例如被问到“HashMap 是线程安全的吗”,不要只说“不是”。可以回答:
不是线程安全的。HashMap 在并发写入时可能发生数据覆盖,JDK 1.7 还可能出现环形链表导致死循环。如果并发场景需要线程安全,我会使用 ConcurrentHashMap,它通过 CAS + synchronized 对桶节点加锁,细粒度锁冲突更低。
这样的回答既有结论、有原因、有对比、有解决方案,信息量充足。
9.2 遇到不会的题怎么办
真实面试中一定会遇到不会的题,处理方式比答案更重要。
- 不要直接说“我不会”。
- 先复述一遍题目,确认自己理解是否正确。
- 说出自己已知的相关知识点,哪怕是模糊的。
- 表达自己的推导过程,展示思考能力。
- 最后坦诚说明“这块我没有深入实践过,后续会补上”。
面试官更看重候选人的学习能力和思维的透明度,而不是希望听到“背下来但理解不深”的答案。
9.3 如何引导面试官问自己擅长的问题
在回答某个问题时,可以有意识地抛出自己项目中做过的亮点细节。
例如:
之前在做订单系统时,我遇到了缓存击穿问题,当时没有直接用互斥锁,而是用了逻辑过期方案,因为并发量比较高,互斥锁会导致大量线程等待。
如果面试官对这个“逻辑过期方案”感兴趣,自然会继续追问,你就有了展示深度的时间和机会。
10. 常见问题与学习建议
10.1 为什么背了很多题,面试还是挂
这种情况通常不是因为背得少,而是因为:
- 只背结论不理解原理,追问一下就答不上来。
- 项目经验与题目无关,无法让面试官产生信任感。
- 表达没有结构,面试官抓不住重点。
建议每次复习完一个题目后,用录音或文档把回答写下来,提取关键词树状图,而不是一整段死背。
10.2 3 天速刷后还需要做什么
3 天能帮你构建完整知识框架,但深度明显不足。建议速刷完之后:
- 选择 1 到 2 个感兴趣的源码去精读,比如 HashMap 或 Spring 的 Bean 生命周期。
- 选择一个业务场景做系统设计练习,比如秒杀系统或短链系统。
- 每天继续刷 2 道算法题,保持手感。
- 复盘面试录音,不断优化表达结构和语速。
10.3 推荐一个有效的复习闭环
复习不是看一遍就结束,建议按下面闭环执行:
- 拿到题目,先尝试自己回答。
- 对照参考答案,找出遗漏和错误。
- 理解后在纸上画出知识结构图。
- 第二天再自测一遍,检验记忆效果。
- 结合项目场景,设计一个应用该知识点的例子。
这样循环下来,一个知识点至少经过三遍加工,记忆效果远超单纯背诵。
10.4 心态建议
最后说点实际的。八股文可以速成,项目经验不能速成。面试前的心态准备同样重要:
- 不要因为一道题没答上来就慌乱,后面的题还有机会。
- 不要背答案背到“没有感情”,面试官更愿意看到有思考的候选人。
- 不要指望靠押题通关,把知识体系搭好,题目怎么变都不会慌。
如果你还在面试流程中,建议把本文里的题目当成一根索引,顺着它去查漏补缺,不要只停留在这篇文章本身。真正让自己稳下来的,永远是你能扎扎实实讲清楚的项目经历和靠得住的底层原理。把这轮复习做完,再回头去精读一份源码、复盘一次项目,你会发现自己对“Java 后端”这四个字的理解,已经比三天前清晰了不止一个层级。