2026年了,Java面试还是躲不开八股。与其抱怨“面试造火箭、工作拧螺丝”,不如换个角度想:八股题考察的恰恰是知识体系的完整性和深度,而这两点在大型项目里直接决定了你排查问题的上限。我自己近两年帮团队做技术面试,也辅导过不少应届生同事,发现一个规律——能把八股答得有条理、能接住追问的人,上手写代码和定位线上问题的速度也普遍更快。所以这篇文章我按高频考点整理了一份速查清单,覆盖集合、并发、JVM、Spring、数据库、缓存、分布式这几大块,每一条都附上面试官视角的追问方向和组织答案的思路,适合正在准备校招或社招的同学,也适合带新人的老手当提纲用。
1. Java基础高频考点速查
1.1 面向对象三大特性与多态的实现原理
封装、继承、多态这“三大特性”人人都能报出名字,但多态一旦追问到底层就容易卡壳。多态成立的三个条件是:有继承或者实现关系、子类重写父类方法、父类引用指向子类对象。真正决定调用哪个版本方法的机制是分派——重载是静态分派,编译期就确定了;重写是动态分派,运行期通过虚方法表(vtable)查找实际类型对应的方法入口。
面试官常往前再走一步:private方法、static方法、final方法能被重写吗?答案是都不能。private方法和static方法是编译期静态绑定的,final方法虽然允许被继承但禁止重写,所以这三者都不具有多态性。答题时顺手提一句JDK 17之后sealed class也对继承做了显式约束,会让面试官觉得你一直在跟进新版本特性。另外一个容易混淆的点是“重写”与“隐藏”的区别:子类定义一个和父类同名的static方法,这叫隐藏,调用的入口取决于引用类型,而不是对象实际类型。理解了这条,后面看Spring AOP为什么对static方法无效也就顺理成章了。
1.2 String、StringBuilder、StringBuffer的区别与底层原理
这道题从基础到源码能分出三重深度。第一重是基本区别:String不可变,StringBuilder和StringBuffer可变,StringBuffer的public方法大多加了synchronized,所以线程安全但性能略低。第二重是底层存储:JDK 8及之前String内部是final char[],JDK 9之后变成了byte[]加编码标识,这是为了内存优化——纯拉丁字符的场景下每个char占用两个字节太浪费。第三重是字符串常量池:字面量直接进池,new String("xx")则会在堆上创建对象并可能同时在池中创建一份(取决于池中是否已有内容)。所以经典问题“new String("xx")到底创建几个对象”要分情况:如果池里没有“xx”,创建两个,一个在常量池,一个在堆;如果池里已有,只创建一个堆对象。
追问通常落在“字符串拼接做了什么”上。字面量加号拼接是编译器优化,字符串变量拼接则会在编译期生成StringBuilder或StringConcatFactory的调用逻辑。所以循环内拼接字符串,用+和用StringBuilder在字节码层面差距不大,但为了防止编译期优化失效,还是要养成显式使用StringBuilder的习惯。顺便记住:JDK 7之后字符串常量池挪到了堆里,所以intern()方法的结果在特定场景下可以直接和new出来的引用比较,这个细节很多人记混。
1.3 异常体系:受检异常与非受检异常
画一遍异常继承树:Throwable下面分Error和Exception,Exception分RuntimeException(非受检)和其他受检异常。面试里最容易丢分的其实是语义理解。受检异常强制调用方处理,代表“外部环境导致的、可恢复的异常”,比如文件不存在、网络超时;非受检异常代表程序员错误,比如空指针、数组越界、非法参数。Spring的@Transactional就是一个典型的结合点:默认只对RuntimeException和Error回滚,受检异常不会触发回滚,这是很多线上数据不一致问题的根源。
还有几个高频坑。第一,try块里return了,finally里再return,会覆盖之前的返回值,因为finally执行在真正的返回之前。第二,try-with-resources自动关闭资源的原理是编译器把close()调用放进隐藏的finally块,并且如果try块和close()同时抛异常,原始异常会被保留,被压制的异常会被添加到suppressed数组中,可以通过Throwable.getSuppressed()取到。第三,不要在catch里吞异常不打日志,这是排查事故时最恨的操作,面试官如果问“线上出问题怎么定位”,一定会绕回来考这一点。
1.4 反射机制与动态代理的底层区别
反射是Java提供运行时自省能力的基础,核心类是Class、Method、Field、Constructor。获取Class对象有三种方式:类名.class、对象.getClass()、Class.forName(),后者的区别在于真正触发类加载和初始化。反射的常见使用场景是框架层:Spring的Bean创建、MyBatis的结果集映射、Jackson的对象序列化,都依赖反射。但反射带来的性能开销和绕过访问检查的安全问题也不能回避,所以JDK反射内部有inflation机制——前几次调用走NativeMethodAccessor,达到阈值后换成字节码生成的GeneratedMethodAccessor,这个细节答出来很加分。
动态代理和反射是两码事。JDK动态代理要求目标类必须实现接口,运行时生成的是一个代理类;CGLIB则通过ASM生成目标类的子类,覆盖父类方法来实现增强。Spring AOP默认在有接口时选JDK动态代理,没有接口时才用CGLIB,Spring Boot 2.x之后行为发生了变化,强制CGLIB优先,原因之一是JDK代理对象的类型转换不够灵活。这个演进轨迹如果能在面试里讲清楚,说明你不只是在背结论,而是真的看过框架源码。
2. 集合框架重点盲区与源码级回答
2.1 HashMap的PUT流程与红黑树升级条件
HashMap是Java面试的“必考题之王”,几乎没有一次面试跳得过去。核心流程要能完整复述:put(key, value)先对key计算hash;JDK 8的hash算法是key.hashCode()的高16位和低16位做异或,目的是让高位也参与数组下标的计算,降低哈希冲突;然后用(length - 1) & hash算出桶下标,因为length恒为2的幂,所以位运算可以替代取模;如果该位置为空直接放新节点,不为空则顺着链表或红黑树查找,key相等则覆盖旧值;插入完成后检查当前size是否超过threshold,超过则扩容。
树化的条件非常容易记错:链表长度达到8且数组长度达到64,两个条件同时满足才会转红黑树。如果数组长度不到64,就算链表再长也先走扩容,因为扩容后链表会被拆成两段,冲突自然会缓解。为什么阈值选8?源码注释里有泊松分布的推导,负载因子0.75下,某个桶链表长度达到8的概率已经降到千万分之一级别。树退化成链表的阈值是6,中间留了1的缓冲,避免频繁的树化和退化。扩容为什么必须扩成2的幂?因为这样元素迁移时不需要重新计算hash,只需要看新增的高位bit是0还是1,是0留在原位,是1挪到原位置加旧容量的下标处,这是JDK 8拆分链表的精髓。
2.2 ConcurrentHashMap的并发控制演进
早期版本的ConcurrentHashMap用分段锁:整个Map分成若干个Segment,每个Segment继承ReentrantLock,put操作只锁对应的Segment,理论上支持Segment数量的并发写入。JDK 8直接放弃了分段锁,改成CAS加synchronized的组合——第一次放入空桶时用CAS,避免加锁开销;桶里已经有节点时synchronized锁住头节点,锁粒度从Segment缩小到单个桶。这背后其实是JVM对synchronized做了大量优化,锁竞争成本大幅下降,导致分段锁反倒显得笨重。
面试追问常集中在三处。第一,size()的实现:JDK 8通过baseCount加CounterCell数组累加,并发度高时有竞争就分散到CounterCell,最后把baseCount和所有CounterCell求和,这是一种“分散竞争”的计数器思想。第二,读操作为什么完全不加锁:因为value和next字段都是volatile,Node数组本身也是volatile引用,读线程能拿到最新发布的写入结果,这依赖JMM的happens-before。第三,扩容时其他线程怎么帮忙:通过ForwardingNode标记迁移中的桶,其他线程put时发现桶被标记为ForwardingNode,就加入迁移任务,这叫协助扩容机制。
2.3 ArrayList与LinkedList的应用场景取舍
这道题看似基础,但能翻出不少源码细节。ArrayList底层是Object数组,默认容量10,扩容时用Arrays.copyOf把旧数组复制到新数组,容量变成原来的1.5倍,具体计算是oldCapacity + (oldCapacity >> 1)。LinkedList底层是双向链表,每个节点维护prev、next、item三个字段,内存占用明显更大。所以“LinkedList插入快”的说法有条件:只有在已知位置指针的情况下插入才是O(1),按索引插入仍然要遍历链表,复杂度O(n)。ArrayList的随机访问是O(1),插入删除因为涉及元素移动通常是O(n)。实际业务中ArrayList的出场率高得多,因为局部性更好,遍历缓存友好。
追问容易落在fail-fast机制上。ArrayList的modCount记录结构性修改次数,迭代器创建时保存expectedModCount,每次迭代校验两者一致,不一致就抛ConcurrentModificationException。单线程里一边遍历一边删除也会触发;用iterator.remove()不会,因为迭代器会同步更新expectedModCount。JDK 8的ArrayList.forEach也有类似校验。还有一个容易忽略的点:subList返回的是内部视图,对子列表的修改会反映到原列表,并且修改原列表结构后子列表再操作会抛异常,很多人在这里踩过坑。
3. 并发编程:从八股到实战
3.1 线程状态流转与Wait/Sleep的区别
线程六种状态先背熟:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。很多人把RUNNABLE理解成“正在运行”,其实JVM层面的RUNNABLE包含了“运行中”和“等待CPU调度”两个状态,与操作系统状态不是一一对应。Java的BLOCKED只出现在等待monitor锁的场景,比如进入synchronized块但锁被别人持有。WAITING是执行了wait、join、park,需要在外部唤醒;TIMED_WAITING是带超时参数的版本,sleep、wait(timeout)、join(timeout)都会进入这个状态。
wait和sleep的区别是面试官的最爱。第一,wait必须搭配synchronized使用,因为它的语义是“释放monitor并挂起”,没有持有锁就没资格释放;sleep只是暂停执行,不释放任何锁,所以sleep在持有锁期间会阻塞其他线程。第二,wait之后线程进入WAITING状态,sleep进入TIMED_WAITING。第三,wait依赖notify或者notifyAll唤醒,sleep到时间自然恢复。顺带答一句“为什么wait挂在Object而不是Thread上”:因为锁是对象级别的,任何对象都能当monitor,wait的语义是“当前持有该对象锁的线程放弃锁并等待”,自然只能挂在所有对象的父类Object上。
3.2 synchronized的锁升级全过程
理解synchronized要先知道对象头里的Mark Word,64位机器上它存储了对象的哈希码、GC分代年龄、锁状态标记。锁状态从低到高依次是:无锁、偏向锁、轻量级锁、重量级锁。偏向锁的逻辑是:第一次获取锁时把线程ID记录到Mark Word,之后这个线程再次进入时无需任何CAS操作,直接判断偏向线程是不是自己。一旦出现其他线程竞争,偏向锁撤销并膨胀为轻量级锁,轻量级锁通过自旋CAS尝试获取锁,自旋次数可控且会自适应。如果自旋还是抢不到,就膨胀为重量级锁,进入操作系统内核态的mutex,线程被挂起,涉及用户态和内核态切换,成本最高。
这里有几个细节很加分。第一,偏向锁在JDK 15开始被默认禁用,原因是它和现代多线程应用的竞争模型不匹配,维护成本高于收益。第二,锁升级是不可逆的,一旦膨胀到重量级就不会降回来。第三,同一线程重入不需要重新竞争,synchronized是可重入锁,Monitor内部有recursions计数,这也是为什么递归调用不会自己锁死自己。第四,wait和notify必须和synchronized搭配的底层原因找到了:Monitor里有一套等待队列和唤醒机制,离开了Monitor就无从谈起。
3.3 volatile与JMM的可见性和有序性
JMM(Java内存模型)的核心是三大特性:原子性、可见性、有序性。volatile保证可见性和有序性,不保证原子性。所谓可见性,是volatile变量的写入会强制刷新到主内存,读操作会从主内存重新加载,绕开线程工作内存里的缓存副本。底层是通过lock前缀指令触发缓存一致性协议(MESI)实现的。有序性则是通过内存屏障:volatile写之后插入StoreStore屏障和StoreLoad屏障,volatile读之前插入LoadLoad屏障和LoadStore屏障,防止指令重排越过边界。
经典考点是DCL单例为什么必须加volatile。Instance = new Singleton()这行代码经过指令重排后可能变成:先分配内存、然后把引用赋值给变量、最后才执行构造函数。另一个线程看到引用了不为null,直接拿去使用,结果对象还没完成初始化,轻则拿到半成品,重则直接崩溃。加了volatile之后,构造函数完成之前不会暴露引用,这就是“防止重排序导致的安全发布失败”。Happens-Before原则也要背几条:程序次序规则、volatile变量规则、锁规则、传递性规则。答题的时候举一个实际的并发场景,比如标记位控制线程结束循环,比干背原则生动得多。
3.4 线程池七大参数与执行流程
ThreadPoolExecutor的七个参数背一遍:corePoolSize核心线程数、maximumPoolSize最大线程数、keepAliveTime空闲线程存活时间、unit时间单位、workQueue任务队列、threadFactory线程工厂、handler拒绝策略。执行流程要能画出来:提交任务先判断当前线程数是否小于核心线程数,小于则创建新线程执行;否则尝试放入队列;队列满了再看当前线程数是否小于最大线程数,小于则创建临时线程(非核心线程)执行;如果已经到了最大线程数,拒绝策略上场。默认的Executors工厂方法各有坑:newFixedThreadPool用无界LinkedBlockingQueue,任务堆积可能占满内存;newCachedThreadPool最大线程数是Integer.MAX_VALUE,极端情况下线程数失控;newScheduledThreadPool的队列是无界DelayQueue。所以大厂规范里直接禁止用Executors创建线程池,理由就是这里。
拒绝策略有四种:AbortPolicy抛异常、CallerRunsPolicy让提交任务的线程自己执行、DiscardPolicy直接丢弃、DiscardOldestPolicy丢弃队列里最老的任务后重新提交。实际项目中CallerRunsPolicy比较常用,因为慢下来至少不丢任务,还能起到天然限流效果。再往深问就是参数设置:CPU密集型建议corePoolSize = CPU核心数 + 1,IO密集型建议CPU核心数 × 2或者更多,因为IO等待期间线程让出了CPU。手动调整线程池也有API,setCorePoolSize和setMaximumPoolSize可以动态改。优雅关闭要用shutdown先拒绝新任务、处理完队列再终止,shutdownNow直接中断正在执行的线程并返回队列里剩余的任务清单。
3.5 ThreadLocal的内存泄漏问题
ThreadLocal的原理是每个Thread内部有一个ThreadLocalMap,Entry的key是ThreadLocal对象的弱引用,value是强引用。问题就出在这个“key弱、value强”上:ThreadLocal外部强引用被置null后,key会被GC回收变成null,但value依然被当前线程的Entry引用,如果线程是线程池里的长生命周期线程,value就永远无法回收,形成内存泄漏。所以规范用法是每次用完都调用remove(),本质是显式断开Thread -> ThreadLocalMap -> Entry -> value这条引用链。另外ThreadLocal的初始值用重写initialValue的方式来实现延后设置,而不是重写构造器里赋值的逻辑。ThreadLocalMap采用线性探测法处理哈希冲突,而不是像HashMap那样用链表,因为Entry数量少,线性探测实现简单且局部性好,这也算一个面试冷门点。
实战中ThreadLocal最典型的场景是做链路追踪ID或无状态上下文传递:前置拦截器把用户信息塞进ThreadLocal,业务代码直接取用,请求结束在finally里remove。但一旦业务代码里开了异步线程,子线程拿不到父线程的ThreadLocal值,因为有继承需求时会用到InheritableThreadLocal,但线程池场景下这个类也会失效,因为线程池里的工作线程不是每次请求都新建的。阿里开源组件里有一个TransmittableThreadLocal专门解决线程池上下文传递问题,能在八股基础上聊到工具层面,面试官的好感度会明显提升。
4. JVM内存与垃圾回收核心考点
4.1 运行时数据区与对象创建过程
先把运行时数据区的分区说清楚。线程私有部分:程序计数器、虚拟机栈、本地方法栈。程序计数器存当前执行字节码指令的地址,唯一没有OOM的区域;虚拟机栈管理Java方法调用,栈帧里有局部变量表、操作数栈、动态链接、方法返回地址,栈深度不够抛StackOverflowError;本地方法栈服务native方法。线程共享部分:堆存放几乎所有对象实例和数组,方法区存放类型元信息、常量、静态变量,JDK 8之后方法区落到元空间(Metaspace),使用直接内存,不再受堆大小限制,同时字符串常量池被挪到了堆里。
对象创建完整过程是一整条链路:类加载检查 -> 在堆上分配内存(指针碰撞或空闲列表,取决于GC是否紧凑)-> 内存零值初始化 -> 设置对象头(Mark Word、类型指针、数组长度)-> 执行构造方法。分配内存并发安全靠CAS加失败重试,或者线程本地分配缓冲区(TLAB),每个线程在堆里预先申请一小块区域,减少竞争。这里顺便能回答“对象一定在堆上吗”:随着逃逸分析和标量替换的成熟,如果对象不会逃逸出方法,JIT可能直接在栈上分配甚至完全消除对象,这算加分回答。
4.2 GC算法与主流垃圾收集器对比
判断对象是否可回收用可达性分析,从GC Roots出发向下搜索,不可达的才是垃圾。GC Roots包括:虚拟机栈局部变量表引用的对象、静态变量引用的对象、常量池引用对象、本地方法栈引用的对象,以及同步锁持有的对象。常用的回收算法:复制算法适合存活率低的新生代,标记-清除产生内存碎片,标记-整理适合老年代,移动对象消除碎片但要停顿。分代收集就是新生代配复制算法、老年代配标记整理或清除。
主流收集器的演进要能对比着说。新生代用Parallel Scavenge的并行复制,老年代用Parallel Old,这是JDK 8默认组合。G1是JDK 9之后的默认收集器,它把堆分成多个Region,区分Eden、Survivor、Old、Humongous,靠维护每个Region的回收价值来做可预测停顿。G1的Mixed GC收集年轻代加部分老年代,Full GC退化时会变成Serial Old单线程收集,停顿非常长。ZGC主打几乎完全的并发垃圾回收,用染色指针和读屏障,目标停顿不超过10ms。到了JDK 17、21时代,ZGC已经大幅完善,可以结合低延迟业务场景讲它为什么能替代G1。回答这类问题时用“JDK 8用什么 -> JDK 11默认换成了什么 -> 为什么换”这种叙事方式,逻辑性会强很多。
4.3 类加载机制与双亲委派
类的生命周期:加载、验证、准备、解析、初始化、使用、卸载。加载阶段读取字节码生成Class对象;验证保证字节码安全;准备阶段为静态变量分配内存并设置零值;解析把符号引用替换为直接引用;初始化执行类构造器和静态代码块。什么时候触发初始化有明确规则:new、反射、读取静态字段、调用静态方法、初始化子类触发父类初始化、作为JVM启动主类,这些叫主动引用,除此之外访问父类静态字段不会初始化子类。
双亲委派模型是三层:启动类加载器(Bootstrap) -> 平台类加载器(Platform) -> 应用类加载器(App)。加载一个类时先委托给父加载器,父加载器找不到才由自己加载,好处是保证Java核心类库不会被篡改,也避免类被重复加载。打破双亲委派的经典场景是JDBC:DriverManager是启动类加载器加载的,而具体Driver实现是应用类加载器加载的,所以JDK引入服务提供者机制(SPI),用一种“父加载器请求子加载器”的线程上下文加载器来解决。另一个场景是Tomcat的Web应用类加载器,每个Web应用有独立加载器,实现应用之间类隔离。这里能聊到类加载器隔离,说明你对生产环境的理解已经超出背题范畴。
5. Spring核心:Bean生命周期与循环依赖
5.1 Bean的生命周期详解
Spring容器中一个Bean从创建到销毁的完整生命周期能列出十几个步骤:实例化 -> 属性填充 -> 处理Aware接口(BeanNameAware、BeanFactoryAware、ApplicationContextAware) -> BeanPostProcessor的postProcessBeforeInitialization -> InitializingBean的afterPropertiesSet -> 自定义init-method -> BeanPostProcessor的postProcessAfterInitialization -> 使用中 -> 容器关闭时调用DisposableBean的destroy -> 自定义destroy-method。真正在业务代码里常用的扩展点是后置处理器和ApplicationContextAware,比如实现BeanPostProcessor对特定类型Bean做代理增强,或者通过ApplicationContextAware拿到容器手动管理Bean。
Spring Boot和传统Spring的差异也可以顺带提一句:Spring Boot通过自动配置类AutowiredAutoConfiguration读取META-INF里的工厂文件,结合条件注解@ConditionalOnClass和@ConditionalOnMissingBean决定哪些Bean生效。所以一个Bean在Spring Boot里被创建的判定逻辑比传统XML复杂得多。面试时如果能从“生命周期”串到“自动配置原理”,再串到“为什么自己定义的Bean能覆盖默认配置”,面试官会认为你真的理解Boot的运作机制而不只是会写注解。
5.2 三级缓存如何解决循环依赖
循环依赖的经典场景:A依赖B,B依赖A,单例模式下两者都配置了属性注入。Spring用三级缓存解决这个问题。一级缓存存放完整的单例对象;二级缓存存放早期暴露的、尚未完成属性填充的对象(半成品);三级缓存存放的是ObjectFactory工厂对象,用来生成早期单例的代理对象。执行顺序是:A开始创建,实例化后放入三级缓存,接着填充B;B同样流程,实例化后放入三级缓存,填充依赖的A时从三级缓存取出A的ObjectFactory,执行getEarlyBeanReference拿到提前暴露的A引用并放入二级缓存,B成功注入A后完成创建并放进一级缓存;A继续填充自己的B属性,拿到完整的B后也完成创建。
面试官最爱问的是“为什么必须是三级缓存,二级不行吗”。答案的关键是AOP:如果A需要在创建过程中生成代理对象,而A被B提前引用,B拿到的必须是代理对象而不是原始对象。三级缓存里的ObjectFactory可以在提前暴露时通过getEarlyBeanReference应用BeanPostProcessor生成代理;如果只有二级缓存,就需要提前完成所有BeanPostProcessor调用,这会破坏正常的生命周期顺序。另外一个必须能回答的边界是:构造器注入导致的循环依赖无法解决,因为对象还没完成实例化,根本没有半成品可以被提前暴露;@Async注解导致的循环依赖也可能直接报错,因为异步代理的创建时机特殊。
5.3 Spring事务的传播行为与失效场景
事务传播行为定义了一个方法调用另一个方法时事务如何传递,常用的几个要说清楚:REQUIRED默认,有事务就加入,没有就新建;REQUIRES_NEW永远挂起当前事务并新建,两个事务互不影响;NESTED保存点机制,内层回滚只回滚到保存点,外层能捕获这个回滚;SUPPORTS有事务就用,没有就以非事务执行。理解传播行为之前先想清楚Spring事务的本质:它基于AOP动态代理拦截目标方法,在方法执行前开启事务,异常时回滚,正常时提交。
事务失效的八大场景值得背一遍:方法不是public的,因为CGLIB代理只能拦截非final的public方法;自调用,也就是this调用同类方法,绕过代理对象,事务注解不生效;异常被try-catch吞掉,Spring看到方法正常返回就提交了;抛出的是受检异常而默认不回滚,需要rollbackFor显式指定;数据库引擎不支持事务,比如MyISAM表;多线程调用,新线程拿不到原线程的事务上下文;propagation设置成了NOT_SUPPORTED;类没有被Spring管理,没扫到或者没加@Component。实际排查事务问题时,先轻声问自己一句“调的是代理对象吗”,能解决八成疑难杂症。
6. 数据库:索引、事务与锁
6.1 InnoDB索引结构为什么选B+树
哈希索引能做O(1)精确查找但不支持范围查询和排序;AVL树或红黑树虽然平衡但每个节点只存一份数据,树高度太高,磁盘IO次数太多;B树的非叶子节点也存数据,导致同样高度下能容纳的索引项少;B+树则是所有数据只存在叶子节点,非叶子只存索引键,单个节点能存放更多索引项,树更矮,IO次数更少。更重要的是B+树所有叶子节点通过链表相连,范围查询只需要找到起点然后顺序扫链表,所以InnoDB默认选择B+树。
InnoDB数据文件本身就是按主键索引组织的聚簇索引表:叶子节点存整行数据,二级索引叶子节点存主键值,所以通过二级索引查数据要经历两次B+树查找,第一次拿到主键,第二次回到聚簇索引回表。覆盖索引就是让查询列全部落在二级索引里,直接免去回表,这是优化超高频查询最有效的招。联合索引的底层结构也值得讲:先按第一列排序,第一列相同再按第二列排序,这就决定了最左前缀法则,查询条件必须从联合索引最左边的列开始连续命中。一道常见考察题是“A联合索引能不能走索引”:能走,因为最左列存在,但如果查询条件只有B没有A,索引无法利用。MySQL 8.0的优化器还有hash join、窗口函数、降序索引这些特性,回答时提一句能体现出你用的新版本。
6.2 事务隔离级别与MVCC实现
SQL标准定义了四个隔离级别:读未提交可能出现脏读、读已提交解决了脏读但出现不可重复读、可重复读解决了不可重复读但可能出现幻读、串行化全部解决但并发性能最差。MySQL的默认隔离级别是可重复读,但InnoDB的可重复读靠MVCC和间隙锁在绝大多数场景下消灭了幻读,所以它不同于标准SQL里的可重复读。
MVCC的机制核心是三块:隐藏列、undo log版本链、ReadView。每行数据有trx_id(最近修改的事务id)和roll_pointer(指向undo log版本链)。读已提交每次快照读都生成一个新的ReadView,所以同一事务里两次查询可能看到不同版本的数据,这就是不可重复读的由来;可重复读只在第一次快照读时生成ReadView,之后复用,所以同事务内查询结果始终一致。遇到“当前读”(select for update、update、delete)时没法用快照,只能读最新版本并加锁,幻读就是通过间隙锁去锁住记录间的区间来防止新记录插入。这就能顺理成章地回答“为什么MySQL的RR隔离级别下很少遇到真正的幻读”。数据一致性在读场景下还能进一步聊到undo log的回收策略和长事务对版本链的影响,这会是一个很自然的加分延伸。
6.3 慢查询优化与索引失效场景
慢查询优化的第一步永远是对着慢SQL做EXPLAIN解释计划,重点看type列和Extra列。type从优到劣依次是system、const、eq_ref、ref、range、index、ALL,见到ALL就要警惕全表扫描。key列看命中的索引,rows列估一下扫描行数。Extra里的Using filesort代表文件排序,Using temporary代表临时表,都是优化机会点。
索引失效的场景是老生常谈但永远考:对索引列做函数运算(where DATE(create_time) = '20260101')、隐式类型转换(字段是字符串但传数字)、like的前缀通配(%xx)、or一侧不是索引列、联合索引没遵守最左前缀法。深分页也是高频问题:limit 100000, 20 意味着扫描十万行再丢弃前十万行,优化思路是用覆盖索引先查主键,再通过主键回原表取完整数据,也就是“延迟关联”。排序字段没有覆盖索引会导致filesort,解决办法是让排序字段也用上索引或调整查询。这些优化思路的核心原则很朴素:让数据库每次IO尽可能读出更多有效数据,减少无效扫描量。
7. Redis高频考点梳理
7.1 缓存穿透、缓存击穿与缓存雪崩
三个“缓存异常”是Redis面试的安全区,但很多人说不到细节。缓存穿透是查询一个必然不存在的数据,每次请求都直接打到数据库,防护手段是布隆过滤器拦截不存在的数据,或者缓存空值并设置较短过期时间防止雪崩式穿透。布隆过滤器原理是多个哈希函数映射到一个位数组,判断一个值“不存在”是准确的,判断“存在”有误判率,误判率能通过位数组长度和哈希函数数量来调节。
缓存击穿是某个热点key在过期的瞬间,大量请求同时到数据库,解决办法是热点key互斥锁,拿到锁的线程查库回填缓存,其他线程等待后直接取缓存;也可以用逻辑过期值,缓存里存过期时间戳,发现过期后由后台线程异步重建缓存,前端线程快速返回旧值。缓存雪崩是大批量key同时过期或Redis整个不可用,导致数据库被压垮,解决思路是过期时间加随机值打散、宕机时做限流降级、构建多级缓存,以及Redis本身的集群高可用。三个问题的本质差别要先找准:穿透是数据根本不存在,击穿是单点key过期,雪崩是大规模过期或宕机。
7.2 Redis持久化与主从架构
RDB是快照持久化,通过fork子进程把内存数据完整写入磁盘文件,优点是恢复快、适合备份,缺点是两次快照之间的数据会丢。AOF是追加日志,每次写命令记录到日志文件,恢复时重放命令,丢失数据少但文件大、恢复慢。Redis 4.0之后有了混合持久化:RDB做全量快照,AOF记录增量日志,兼顾恢复速度和数据完整性。在答复持久化时补充一句“AOF是写后日志”的意思——先执行命令再记录日志,好处是避免记录错误的命令,坏处是命令执行成功但日志还没落盘可能丢数据。刷盘策略有always每秒,和操作系统默认的everysec,这个选项直接决定最多丢多少数据。
主从复制能解决读压力:主节点写入,从节点同步。同步分全量同步和部分重同步,部分重同步依赖repl_backlog缓冲区,断线重连后能通过偏移量续传。哨兵模式做故障自动切换,监控主节点状态,Master挂了从从节点中选新主。Cluster集群则把数据分片到16384个slot,每个节点分管一部分slot,支持水平扩展。大key和热key也是近两年面试常客:大key会导致网络阻塞和主从延迟拉长,排查用redis-cli --bigkeys,优化方向是拆分成多个小key;热key在大促场景需要通过本地缓存或多副本分散流量。
8. 分布式基础:CAP与消息队列
8.1 CAP定理与BASE理论
CAP指出分布式系统在一致性、可用性、分区容错性三者中最多同时满足两个。关键是理解网络分区是常态,一旦发生节点间通信中断,系统只能在C和A之间选一个。实际设计里几乎都保P,然后在C和A之间取舍。AP系统优先保证可用性,各节点接受写入,分区恢复后通过异步同步补偿,比如服务注册发现;CP系统优先保持一致,分区期间拒绝写请求或返回错误,比如分布式配置中心和分布式锁。BASE理论是AP方向的延伸:基本可用、软状态、最终一致。绝大多数互联网业务都接受最终一致,因为用户操作后延迟几毫秒看到结果完全感知不到差异。
答这道题有个加分技巧:不要只背定义,要结合具体组件说明。比如注册中心选AP还是CP,要看业务是更需要“服务随时可查”还是“数据严格一致”;分布式锁为什么倾向于CP,因为锁的标志被错误读到会导致并发重复执行;消息队列天然是最终一致的模型,生产端发送成功、消费端延迟处理便从强一致降为最终一致。把CAP从一道判断题变成一道权衡分析题,回答档次完全不同。
8.2 消息队列如何保证消息不丢不重
消息丢失分三段看。生产端:使用confirm机制等Broker确认,没确认就重发。Broker端:确认消息后先刷盘再返回成功,并对消息做持久化存储。消费端:手动确认而非自动ack,处理完成后再提交offset。这三段链路都堵住,基本就没有丢失空间了。但保证不丢失的同时会引入重复消息:生产端重发会导致消息重复,消费端处理失败后的重试也会导致重复,所以幂等是必须的。
幂等方案按场景选:数据库唯一约束直接防止重复插入;Redis分布式锁配合业务唯一ID,处理前先加锁;更通用的是在消息体里带全局唯一ID,消费时查重表。主流消息队列的选择本身也能聊几句:Kafka吞吐极高但可能重复消费而offset回退;RocketMQ支持事务消息和延迟消息,适合复杂业务流;RabbitMQ灵活性好但吞吐不如Kafka。如果面试官往深问Kafka的存储机制,还能补一句“为什么Kafka快”——顺序写磁盘加页缓存,零拷贝直接就决定了它和数据库写入不是一个量级的性能模型。
我自己面试时最怕的其实是“背了但说不出为什么”。后来每次准备一个考点,都逼自己往深处再想一层:HashMap为什么用位运算、Spring为什么要三级缓存、MySQL为什么默认RR、Redis为什么要混合持久化,多问几个为什么,答案就能从机械记忆变成真正的理解。面试官顺着一个点连续追问,你能撑过三轮,这场面试基本就稳了。准备2026年面试的同学,建议按这篇文章的章节顺序做知识框架,再挑各节里的核心问题做逐字稿练习,练到不用思索就能组织出“结论先行、底层原理补充分、最后接一个实际案例”的程度,心里就会踏实很多。