Java面试八股文核心真题:并发、JVM、MySQL与Redis深度解析
每次面试前总有人问我Java“八股文”到底要不要背。我的态度很明确不仅要背还得背出深度。你光知道synchronized是锁面试官下一句就是“锁升级过程讲一下”你光知道HashMap底层是数组加链表紧接着就是“什么时候转红黑树为什么是8”。这篇“下篇”接着上篇我把并发编程、JVM、集合源码、Spring、MySQL、Redis和分布式这些高频板块里最容易被追问的真题再筛一遍每题都给出面试官想听的回答框架和背后的原理希望能帮你撑过二轮三轮的连环深挖。1. 并发编程核心题Synchronized、Volatile与线程池的追问链1.1 Synchronized的锁升级别只说“偏向锁到轻量级锁”这道题几乎是并发板块的开场必问。回答的关键在于把四个状态和触发条件讲清楚而不是背概念。无锁状态下JVM会在对象头的Mark Word里记录线程ID这就是偏向锁——第一个拿到锁的线程不需要CAS操作之后再来直接比对线程ID即可。一旦有第二个线程竞争撤销偏向锁并升级为轻量级锁此时通过自旋CAS来抢锁。自旋超过一定次数默认10次或自适应调整或者等待线程数过多就膨胀为重量级锁依赖操作系统互斥量实现阻塞。面试追问的坑通常在这两点一是偏向锁在Java 15开始默认禁用Java 21中已经移除了偏向锁相关的JVM参数原因在于偏向锁撤销成本高、收益在现代多核高并发场景下不明显二是轻量级锁的自旋是消耗CPU的所以它适合临界区执行时间短、竞争不激烈的场景如果临界区耗时过长自旋等待纯属浪费。有个很好的类比偏向锁像公司固定工位一个员工一直用轻量级锁是临时开会谁抢到谁坐重量级锁是会议室预约系统抢不到就去排队等着。1.2 Volatile的内存语义与DCL单例的经典连环问volatile修饰的变量读操作一定会从主内存拿最新值写操作一定会同步回主内存这就保证了可见性。但要注意它不能保证原子性i这种读改写复合操作在并发下依然不安全。面试官接下来几乎必问DCL单例。双检锁为什么还要加volatile根本原因在于指令重排序。你写的代码是分配内存、初始化对象、把引用赋值给变量。但CPU和编译器可能把后面两步重排也就是先赋值引用、再执行构造方法。此时另一个线程读到非空引用拿到的对象可能还没初始化完成使用就会出问题。加上volatile之后用StoreStore屏障和LoadLoad屏障禁止重排问题就解决了。这里要补充一个经验volatile的底层实现是缓存一致性协议MESI的扩展写操作会让其他CPU核心的缓存行失效。但别把缓存一致性协议和volatile强绑定更准确的理解是JMM通过内存屏障实现volatile语义底层硬件可能是锁总线或锁缓存行。1.3 线程池核心参数与拒绝策略的匹配逻辑线程池的七个参数里最容易答漏的是任务队列长度和工作线程数的关系。很多人背了“核心线程数、最大线程数、空闲时间、队列长度”但面试官一句“假设核心线程4、最大线程10、队列容量100现在来50个任务会创建新线程吗”就露馅了。正确的处理流程是核心线程被占满后新任务进入队列队列满了才尝试创建非核心线程非核心线程也满了才触发拒绝策略所以50个任务在这个配置下全部进队列一个非核心线程都不会创建。设置线程数时也要考虑队列类型比如SynchronousQueue不存任务直接交付给线程适合任务量少但每个执行时间长的场景LinkedBlockingQueue默认无界极端情况下队列积压会导致内存膨胀实际生产基本都会显式指定容量。拒绝策略的四种选择也容易被追问。AbortPolicy抛异常CallerRunsPolicy让提交者线程自己跑DiscardPolicy直接丢弃DiscardOldestPolicy丢弃队列头部最老的任务。个人推荐核心业务用AbortPolicy配合告警绝不能静默丢弃非核心业务可以用DiscardOldestPolicy宁可丢旧任务也不阻塞主流程。1.4 AQS的模板方法模式与ReentrantLock的公平性AbstractQueuedSynchronizer是Java并发包的基石。它的核心设计是用一个volatile int state作为资源状态加上一个CLH变体队列来管理等待线程。独占锁和共享锁分别在tryAcquire和tryAcquireShared上做文章模板方法模式在这里的体现是AQS固定了入队、出队、唤醒的骨架逻辑把资源是否可获取的判断留给子类实现。ReentrantLock的公平锁和非公平锁差别就在tryAcquire里。非公平锁会先CAS抢一次抢不到再排队公平锁则只要队列里有前驱节点就直接入队。这叫“插队机制”。面试官如果问“为什么默认非公平”答案是公平锁的线程切换开销更大非公平锁的吞吐量通常更高虽然可能造成线程饥饿但和整体性能相比大多数业务场景更看重吞吐。2. JVM内存区域与垃圾回收从分区到GC Roots的完整链路2.1 运行时数据区的划分重点在栈和堆的协作Java 8之后元空间取代了永久代这是面试必考的点。元空间使用本地内存所以不再受-XX:MaxPermSize限制而是受操作系统可用内存限制。为什么要换永久代很难调优类元数据的大小不好预估而且Full GC时清理永久代会拖慢停顿时间。虚拟机栈是线程私有的栈帧里存了局部变量表、操作数栈、动态链接、方法返回地址。这里有个高频追问方法内的局部变量是否安全答案是如果局部变量只是一个固定的基本类型不会有线程安全问题如果是对象引用指向堆中的共享对象或者变量作为参数传递给了其他线程就可能不安全。比如public void test() { ListInteger list new ArrayList(); new Thread(() - list.add(1)).start(); }这个局部变量list就被线程共享了存在线程安全问题。堆内存划分成新生代和老年代新生代又分Eden、S0、S1。默认比例是8:1:1这背后的逻辑是绝大多数对象朝生夕灭Eden区塞满后一次Minor GC能清扫掉大部分对象两个幸存区轮换着用来复制存活对象减少碎片化。2.2 垃圾回收算法的演进思路与混合回收标记-复制、标记-清除、标记-整理这三个算法面试时要讲清楚各自的适用场景和缺陷。新生代用复制算法因为存活对象少复制成本低老年代用标记-整理或标记-清除因为存活对象多复制不划算。G1为什么不用老年代全量整理因为它把堆划分成多个Region以Region为单位做局部回收。G1的Mixed GC是重点。它不只收集新生代还会收集部分标记为垃圾比例较高的老年代Region。-XX:G1MixedGCLiveThresholdPercent控制回收阈值默认85%也就是存活对象占比超过85%的Region不回收因为复制这些Region的成本太高。Current GC和Full GC的区别也要能说清楚G1的Full GC退化为单线程Serial Old这是最糟糕的情况一旦出现基本说明堆内存严重不足或者巨型对象过多。Java 17之后主流的ZGC值得了解。ZGC目标是把停顿时间控制在10ms以内它用了染色指针和读屏障并发整理内存但代价是牺牲一点CPU。注意区分G1是Region布局加分代ZGC不区分分代至少默认实现如此更强调大堆下的极低延迟。2.3 类加载机制双亲委派到底防的是什么双亲委派模型这个知识点面试官要听到的不仅是“先让父类加载器尝试加载”还要你说出它解决的核心问题防止核心API被随意替换。比如你自己写了一个java.lang.String如果应用类加载器直接加载它那JVM里的String就乱了类库的信任体系崩塌。而双亲委派保证了String一定由启动类加载器加载自定义的版本根本没机会。破坏双亲委派的场景也要准备一下。JDBC就是典型例子DriverManager由启动类加载器加载但它要调用厂商的MySQL驱动实现类启动类加载器根本找不到这个类。所以JDBC用了服务提供者机制通过线程上下文类加载器来打破双亲委派。类似机制在JNDI、JAXB等场景也存在。2.4 生产环境中JVM调优的三个真实切入点很多应届生一听到JVM调优就以为要设置花哨的参数实际上生产环境遇到最多的问题是这三类GC频繁导致接口RT抖高——优先调整堆大小和新生代比例内存泄漏导致OOM——用jmap导出堆转储文件分析CPU飙升——先top -Hp找线程再用jstack看线程栈这里分享一个复用过的排查案例某服务的Full GC间隔越来越短从10分钟一次到1分钟一次jmap -dump分析后发现一个静态Map不断放进缓存对象没有清理机制对象全部堆积在老年代。修复方案很朴素把静态Map替换为带过期时间的本地缓存回收机制换取存活下限。调优不是炫技而是先定位问题再改参数。3. 集合类源码细节HashMap与ConcurrentHashMap的必答模块3.1 HashMap的Put流程和为什么要在7和8之间切换HashMap的put流程画个链路是这样的计算(n - 1) hash得到桶位置如果桶为空直接放冲突了则分情况——链表就尾插红黑树就按树节点插入插入完成后判断链表长度是否达到树化阈值真正树化条件是链表长度大于等于8且数组长度大于等于64。两个高频追问点为什么要用尾插法而不是头插法因为JDK 7的头插法在并发扩容时会产生环形链表导致get死循环。JDK 8改成尾插后即使并发扩容也不容易出现环形结构但HashMap本身线程依旧不安全。树化阈值为什么是8这是个概率统计问题。源码注释里写了哈希冲突遵循泊松分布在负载因子0.75下桶中元素数量达到8的概率已经低到千万分之六树化是为了防止极端哈希冲突场景下性能退化到O(n)。反过来当链表长度降到6时红黑树退化为链表因为退化阈值低于树化阈值避免频繁切换。3.2 ConcurrentHashMap为什么抛弃了分段锁JDK 7的ConcurrentHashMap用Segment分段锁就是把整个Map拆成16段每段一把锁锁竞争被分散了。但问题是定位一个元素需要两次哈希且锁粒度还是偏大。JDK 8直接用CAS加Synchronized锁住数组的每个桶。这里要讲清楚为什么锁单个桶就够了并发读不需要锁因为节点的val和next都是volatile修饰并发写时只要多个线程操作的桶下标不一样就不存在竞争如果操作同一个桶最多也就是这个桶上的线程互相等待其他桶照常工作。相比之下锁粒度从一个Segment精确到一个Node。扩容是ConcurrentHashMap实现里最复杂的部分。sizeCtl作为并发扩容的控制标识负数表示有线程正在扩容。迁移时每个线程领取一段连续的桶位用ForwardingNode标记已迁移位置新线程看到这个标记就知道该桶已经搬过了同时可以协助扩容。面试能讲到“协助扩容”这一步基本就能证明你看过源码而不是只看了简历速成笔记。3.3 ArrayList、LinkedList、Vector的选型逻辑这道题低难度但出现频率极高。回答的要点不是“数组查询快、链表插入快”这种教科书说法而是结合底层机制。ArrayList扩容是grow方法里oldCapacity (oldCapacity 1)也就是1.5倍扩容然后Arrays.copyOf复制数组。LinkedList每个节点是独立对象内存分散随机访问要遍历。所以实际项目中绝大多数场景选ArrayListLinkedList的唯一优势体现在头部频繁插入删除且不关心随机访问的场景比如实现队列。Vector几乎已经被淘汰了它所有方法都加synchronized锁粒度太大。如果你答一句“Vector是Hashtable一样的老古董现在用CopyOnWriteArrayList替代并发读多写少的场景”面试官会点头。4. Spring核心原理IOC容器与AOP的底层逻辑4.1 Bean的生命周期不能跳过BeanPostProcessorSpring的Bean生命周期一直流传着很多版本的说法面试官要的是一份完整的链路实例化构造方法属性填充依赖注入执行Aware接口的回调BeanNameAware、BeanFactoryAware等执行BeanPostProcessor的前置方法执行InitializingBean.afterPropertiesSet和PostConstruct执行BeanPostProcessor的后置方法如果配置了AOP通过AbstractAutoProxyCreator生成代理对象使用阶段销毁阶段执行DisposableBean.destroy和PreDestroy很多人会把初始化方法的位置搞混。PostConstruct和InitializingBean的执行顺序究竟是怎样的先PostConstruct再afterPropertiesSet最后才是Spring XML里配置的init-method。但要注意这三个方法都在BeanPostProcessor前后置之间执行AOP代理反而是后置处理器之后才创建的。这意味着如果一个Bean要被代理它的PostConstruct是在原始对象上执行的代理对象在最后一步才出现。4.2 AOP代理选择JDK动态代理与CGLIB的判断依据Spring AOP的底层代理选择逻辑如果目标类实现了接口默认用JDK动态代理如果没有接口用CGLIB。但Spring Boot 2.x之后默认proxyTargetClasstrue也就是说优先用CGLIB即使类有接口。CGLIB是通过生成目标类的子类来实现代理的所以被代理的类和方法不能是final的否则无法生成子类或重写方法。JDK动态代理基于接口要求目标类必须实现接口代理类实现同一接口并持有InvocationHandler。这里有个实际坑某些老项目升级Spring Boot后一个没有实现接口的Service突然报错大多是因为之前的Spring版本默认JDK代理而新版Spring Boot切到CGLIB后代理对象变成了目标类的子类类型转换和getClass()判断都会受影响。解决思路是允许用EnableAspectJAutoProxy(proxyTargetClass false)切回JDK代理但更好的做法是代码里别依赖具体实现类做转换。4.3 Spring事务失效的7种场景最容易漏的是同一个类调用Spring事务失效是面试必考。常规能答出来的是方法不是public的、异常被catch了、异常类型不是RuntimeException、数据库引擎不支持事务。但最常考的是“同类内方法自调用失效”。原因很简单Spring事务通过AOP代理实现只有通过代理对象调用的方法才会被拦截。当一个类的方法A调用方法BB上加Transactional但A直接用this.B()调用this是原始对象不是代理对象事务注解等于白写了。解决办法有三种注入自己Autowired自身代理、或者用AopContext.currentProxy()、或者把自调用方法拆到另一个Service里。还有一个容易漏的细节Transactional默认只回滚RuntimeException和Error受检异常不会触发回滚。想让受检异常也回滚要设置rollbackFor Exception.class。5. MySQL索引与事务隔离数据库识别的分水岭5.1 作为索引的B树的三个特性和恒等式MySQL InnoDB的索引底层是BTree不是B-Tree。面试官要听B树和B-Tree的差别B树只有叶子节点存数据非叶子节点只存索引键叶子节点之间用链表串联范围查询可以顺序扫描B-Tree每个节点都存数据范围查询需要回溯。为什么InnoDB用B树而不用红黑树或哈希表哈希表适合等值查询但不支持范围查询Redis里的哈希结构也是如此限制红黑树在数据量大时高度太高一次查询要访问很多次磁盘B树的高度通常只有3到4层意味着最多3到4次磁盘IO就能找到目标。最左前缀法则的本质是联合索引的排序规则索引先按第一列排序再按第二列排序所以只用第二列查询时无法利用索引的顺序性索引就失效了。比如(a, b, c)联合索引查询条件只有b 1时走全表扫描只有a 1时能走索引a 1 and c 2能用到索引的一部分但c的条件无法用索引下推之前的索引范围来看的话实际上这里还有索引下推优化讲过这一步就很加分。5.2 事务隔离级别与MVCC的ReadView生成时机四个隔离级别都得能说出解决的问题和残留现象。可重复读是MySQL默认隔离级别它是通过MVCC保证的——事务内每次普通读都复用第一个Select生成的ReadView所以看到的数据是这个快照的视图不会因为别的事务提交而改变。关键问题来了可重复读怎么解决幻读它没有完全解决只是用间隙锁在写操作上锁住了范围阻止其他事务在范围内插入数据。但快照读在可重复读级别下已经天然看不到其他事务新插入的数据所以大部分场景不会感受到幻读。只有当前读SELECT ... FOR UPDATE、UPDATE、DELETE才会被其他事务的插入影响此时依靠间隙锁或Next-Key Lock来防护。MVCC的ReadView里有四个关键字段分别是当前活跃事务ID列表、最小活跃事务ID、最大事务ID加一、创建ReadView的事务ID。判断可见性的规则是如果事务ID小于最小活跃ID说明已提交可见大于最大ID说明是未来事务不可见在两者之间且不在活跃列表里也可见否则不可见。这一整套能顺畅讲下来基本没人会卡你。5.3 索引失效的常见场景与慢查询排查面试收集的索引失效场景非常多记住高频的几类就够应付。对索引列使用函数或计算比如WHERE YEAR(create_time) 2024索引就失效了隐式类型转换比如字符串字段传了数值不符合最左前缀法则使用LIKE左模糊%key无法走索引OR连接的条件中有非索引列实际上工作中排查慢SQL常用的套路是先开启慢查询日志拿到执行慢的SQL再执行EXPLAIN看type、key、rows这几列。type从all全表扫描到const主键等值all和index的代价都很高rows预估扫描行数和实际返回行数差距过大的话大概率是统计信息失真或者条件写岔了。6. Redis高频真题缓存一致性、分布式锁与持久化取舍6.1 五种数据结构的底层实现和缓存三兄弟Redis的String、Hash、List、Set、ZSet这五种结构面试官会问底层编码。简单的String用SDS简单动态字符串存储append操作不需要O(n)重建字符串Hash在元素少时用ziplist超过阈值用hashtableZSet在元素少时用ziplist或listpack超过阈值后用跳表加哈希表。跳表为什么不是红黑树因为跳表实现更简单、范围查询友好ZSet的核心操作就是有序范围查询。缓存穿透、缓存击穿、缓存雪崩各自含义必须分得清穿透查询一个一定不存在的数据请求打到数据库。解决用布隆过滤器拦截或者缓存空值击穿热点key过期瞬间大量并发打到数据库。解决用互斥锁或者逻辑过期雪崩大面积key同时过期或者Redis宕机。解决是把过期时间加随机数或者用高可用集群追问“缓存空值”时要想清楚一个问题如果缓存不存在值的数量过多空值缓存会占用大量内存所以要设置较短的过期时间比如1分钟到5分钟。6.2 缓存与数据库双写一致性的四种方案双写一致性的面试题比的是方案取舍。最常用的四种先更新数据库再删除缓存。删除失败时用重试机制补偿先删缓存再更新数据库。更新期间读请求会把旧数据回填缓存存在短暂不一致订阅数据库的变更日志消费后删缓存适合可靠性要求高的场景延迟双删先删缓存更新数据库等待几百毫秒再删一次缓存我个人比较推荐第一种配合消息队列重试。为什么不是先更新缓存因为缓存不是数据源更新不受事务控制一旦数据库回滚缓存就是脏数据。删除缓存比更新缓存更安全因为下次读请求会触发回填回填时读到的必然是数据库里的最新值。6.3 Redis分布式锁和RedLock的取舍Redis分布式锁的经典实现是SET lock_key unique_value NX PX timeoutNX保证原子创建PX防止死锁。释放锁时用Lua脚本比对唯一值再删除避免误删别人的锁。追问点如果锁到期了但业务还没执行完怎么办答案是加“看门狗”续期Redisson的WatchDog默认每10秒续期一次将锁有效期延长到30秒。但极端的GC停顿下看门狗也可能没续期就被别人抢到锁。紫禁之巅级别的讨论是RedLock算法它向多个独立节点加锁过半成功才算加锁成功。对于普通项目RedLock带来的复杂度需要多个独立Redis实例远超收益个人不推荐在小规模场景用RedLock做好客户端续期和超时兜底更实际。7. 分布式架构与微服务常见追问7.1 分布式事务的最终一致方案对比面试官问分布式事务先分清选型2PC两阶段提交强一致但阻塞性能差适合单机事务扩展的旧场景TCCTry-Confirm-Cancel三段式业务侵入性强每个操作要写三个接口还要处理空回滚和幂等但性能比较好本地消息表加消息队列最终一致实现简单适合大多数交易类业务Saga长事务每个步骤都有补偿但隔离性差中间状态对外可见说得具体一点TCC最容易被追问“幂等和空回滚怎么处理”。空回滚是指Try没执行成功Cancel却进来了这种情况需要记录事务状态Cancel阶段先查状态没有Try记录就直接返回成功。幂等则是在Confirm和Cancel阶段都带上唯一事务ID消费方用事务ID做去重。7.2 微服务熔断、限流、降级的核心区别熔断、限流、降级这三个词经常混用。熔断是下游故障时快速失败不再继续调用比如熔断器从closed到open状态限流是保护自己控制入口QPS不让流量打垮服务降级是主动牺牲非核心功能保核心功能比如大促时关闭商品推荐服务保证下单链路。实现层面Sentinel和Resilience4j是两个主流方案。Sentinel的滑动窗口限流比令牌桶更灵活支持按秒、按分钟、按调用来源做精细化控制。面试官问到“Sentinel和Hystrix有什么区别为什么Hystrix不更新了”关键点在于Hystrix基于线程池隔离线程切换开销大Sentinel基于信号量加滑动窗口性能更好而且规则可以动态下推不需要重启应用。8. 真题实战模拟一题多问的应对思路再多说一道能串起多个知识点的综合题从“浏览器输入一个商品详情页URL到页面渲染”开始面试官可能会把链路拆碎了问。你输入URL后DNS解析找到目标IP请求到达Nginx通过负载均衡转发到某个Spring Boot实例。Spring的拦截器鉴权通过后进入ControllerService层用Transactional处理库存和订单。库存数据查Redis缓存缓存没命中就查MySQL并回填。写入操作通过消息队列异步更新解耦且削峰。整个链路上可能出现的问题Redis缓存雪崩怎么办、MySQL慢查询怎么优化、消息重复消费怎么保证幂等。这道题答得好不好反映的不是单点知识而是能否把各个组件串成一个体系。所以我建议刷八股文时别按知识点背完就丢试着每隔几天把几个关联知识点串起来讲一遍。比如“并发编程与MySQL锁”串在一起讲讲乐观锁在库存扣减中的应用先查库存版本号更新时带上版本号条件受影响行数为0说明冲突重试或提示用户刷新。这样同一道题能讲出两层深度面试官显然会更认可。这一步跨过去之后面试就不再是背书而是向对方展现你理解并发本质的路径。回归到八股文的初衷它像一个脑内索引面试官从索引快速定位你的知识边界而你从索引还原出一整套可运行的系统认知。希望大家都能靠这些真题搭起属于自己的知识体系。