学习顺序
- 补充:Java 里面有哪些集合?分别有什么特性?
- Q37:ArrayList 和 LinkedList 区别?各自时间复杂度?
- Q34:String、StringBuilder、StringBuffer 区别?String 为什么不可变?
- Q35:
==和equals区别?为什么重写 equals 必须重写 hashCode? - Q36:Exception 和 Error 区别?RuntimeException 和 CheckedException 区别?
- Q40:反射是什么?优缺点?用在哪些场景?
- Q92:Java 8 Stream 怎么用?中间操作和终端操作的区别?
- Q96:深拷贝和浅拷贝的区别?
- Q39:Java 四种引用类型?(强/软/弱/虚)
- Q41:JVM 内存区域有哪些?各自存什么?哪些线程私有、哪些共享?
- Q42:栈和堆的区别?
- Q97:对象创建的过程?内存分配策略?
- Q98:对象什么时候进老年代?
- Q43:垃圾回收怎么判断对象可回收?引用计数法和可达性分析法区别?
- Q44:GC Roots 有哪些?
- Q45:四种垃圾回收算法?标记清除、标记整理、标记复制各自优缺点?
- Q46:为什么新生代用复制算法、老年代用标记清除/整理?
- Q47:Minor GC 和 Full GC 的区别?什么时候触发 Full GC?
- Q48:CMS 四个阶段?哪个阶段 STW?碎片问题怎么产生?
- Q49:G1 的核心改进?Region 和回收收益列表是什么?怎么做到可控停顿?
- Q50:什么情况会 OOM?(堆、栈、方法区各举一例)
- Q51:OOM 怎么排查?最常用的两个 JVM 参数是什么?
- Q99:JVM 调优常用参数有哪些?
- Q52:类加载过程?双亲委派模型是什么?为什么要这样设计?
- Q53:打破双亲委派模型的场景有哪些?
- Q100:类加载器有哪些?Bootstrap、Extension、Application 分别加载什么?
- Q33:创建线程有哪几种方式?Callable 和 Runnable 的区别?
- Q26:volatile 的三大作用?为什么能保证可见性和禁止重排,但不保证原子性?
- Q27:volatile 的典型应用场景?
- Q28:双重检查锁单例为什么需要 volatile?
- Q29:CAS 是什么?ABA 问题怎么解决?
- Q14:synchronized 和 ReentrantLock 的区别?
- Q15:synchronized 的锁升级过程?无锁→偏向锁→轻量级锁→重量级锁
- Q16:偏向锁什么时候撤销?轻量级锁自旋多少次升级?
- Q17:AQS 是什么?state 变量和 CLH 队列各自做什么?
- Q18:同步队列和等待队列的区别?
- Q19:为什么说 synchronized 只支持单条件队列,而 ReentrantLock 支持多条件队列?
- Q20:Condition 的 await() 和 signal() 底层怎么做的?
- Q30:ThreadLocal 原理?为什么 key 用弱引用?内存泄漏怎么产生?怎么避免?
- Q31:线程池场景下 ThreadLocal 有什么额外风险?
- Q21:线程池七大参数?任务执行的完整流程?
- Q22:为什么先放队列而不是直接扩容?
- Q23:拒绝策略有哪四种?默认是哪个?
- Q24:如果队列用无界队列会有什么问题?
- Q25:IO 密集和 CPU 密集任务,核心线程数怎么设?
- Q32:CountDownLatch 和 CyclicBarrier 的区别?
- Q1:HashMap 的底层数据结构?put 的完整流程?
- Q2:扰动函数为什么要高 16 位和低 16 位异或?
- Q3:为什么数组长度必须是 2 的幂次?
- Q4:为什么负载因子是 0.75?
- Q5:树化条件为什么有两个?(链表 ≥8 且数组 ≥64)
- Q6:扩容机制?什么时候触发?扩容后元素怎么重新分配?
- Q7:HashMap 为什么线程不安全?(并发 put 覆盖、size 不准确、1.7 环形链表)
- Q8:JDK 1.7 头插法和 1.8 尾插法的区别?为什么改?
- Q38:HashMap 和 Hashtable 的区别?
- Q9:ConcurrentHashMap 1.7 怎么保证线程安全?分段锁 Segment 原理?
- Q10:ConcurrentHashMap 1.8 怎么保证线程安全?CAS + synchronized 锁桶原理?
- Q11:put 时怎么区分用 CAS 还是 synchronized?
- Q12:读操作为什么不需要加锁?volatile 在这里起什么作用?
- Q13:size() 方法怎么统计的?CounterCell 是什么?
- Q95:fail-fast 和 fail-safe 的区别?
- Q54:IoC 是什么?和 DI 的关系?有什么好处?
- Q55:AOP 是什么?底层怎么实现?JDK 动态代理和 CGLIB 区别?
- Q56:Spring Bean 的生命周期?
- Q57:初始化方式有哪三种?优先级顺序?
- Q58:AOP 代理对象在生命周期哪一步创建?
- Q59:Bean 的作用域有哪些?默认是哪个?
- Q60:循环依赖怎么解决?三级缓存各自存什么?
- Q61:@Transactional 失效场景?
- Q62:@Transactional 默认回滚什么异常?CheckedException 回滚吗?
- Q102:Spring 事务传播行为有哪些?REQUIRED 和 REQUIRES_NEW 的区别?
- Q63:Spring Boot 自动配置原理?@SpringBootApplication 三个核心注解是什么?
- Q64:@Conditional 系列注解有哪些?@ConditionalOnMissingBean 怎么实现“自定义覆盖默认”?
- Q101:Spring MVC 请求处理流程?DispatcherServlet 怎么工作?
- Q103:Spring 的 BeanFactory 和 ApplicationContext 的区别?
- Q104:@Autowired 和 @Resource 的区别?
- Q65:Spring 用了哪些设计模式?分别用在哪里?
- Q66:ACID 四大特性?分别靠什么机制实现?
- Q67:redo log、undo log、binlog 的区别?
- Q68:四种隔离级别?MySQL 默认是哪个?
- Q69:脏读、不可重复读、幻读的区别?
- Q70:MVCC 怎么实现的?隐藏字段、版本链、ReadView 分别是什么?
- Q71:ReadView 的可见性判断四条规则?
- Q72:RR 和 RC 在 MVCC 上的区别?ReadView 创建时机有何不同?
- Q73:索引底层数据结构?为什么是 B+ 树而不是 B 树?不是红黑树?不是 Hash?
- Q74:B+ 树和 B 树的核心区别?
- Q75:聚簇索引和非聚簇索引区别?什么叫回表?什么叫覆盖索引?
- Q76:联合索引的最左前缀法则?
- Q77:索引失效的常见情况?
- Q78:隐式类型转换为什么索引失效?
- Q79:
WHERE age = '26'(int 列查字符串值)会失效吗?为什么不会? - Q80:Explain 各字段含义?type 字段从好到差排序?
- Q105:MySQL 有哪些锁?(表锁/行锁/共享锁/排他锁/记录锁/间隙锁/临键锁)
- Q106:主从复制原理?读写分离怎么做?
- Q107:分库分表什么时候需要考虑?
- Q108:慢 SQL 怎么排查优化?
- Q81:Redis 为什么快?
- Q82:Redis 五种基本数据类型?各自使用场景?
- Q109:Redis 每种数据类型的底层数据结构?
- Q83:缓存穿透、击穿、雪崩区别和解决方案?
- Q84:布隆过滤器原理?
- Q85:Redis 过期策略和内存淘汰策略?
- Q86:RDB 和 AOF 持久化区别?混合持久化是什么?
- Q87:分布式锁 SETNX + 过期时间有什么问题?Redisson 看门狗怎么解决?
- Q88:Redis 主从切换锁丢失怎么办?RedLock 原理和争议?
- Q89:缓存和数据库一致性怎么保证?
- Q90:Redis 哨兵机制?主观下线和客观下线?故障转移流程?
- Q91:Redis 集群模式?16384 个哈希槽怎么分配?
- Q110:缓存预热怎么做?
- Q111:TCP 三次握手和四次挥手?为什么三次、为什么四次?
- Q112:TCP 和 UDP 的区别?各自是全双工吗?
- Q140:TCP 的拥塞控制算法?(慢启动、拥塞避免、快重传、快恢复)
- Q113:HTTP 和 HTTPS 区别?加密流程?
- Q114:HTTP 常见状态码?(200/301/302/400/401/403/404/500)
- Q115:HTTP 1.0/1.1/2.0 区别?
- Q116:GET 和 POST 的区别?
- Q93:BIO 和 NIO 的区别?NIO 的三大核心组件?
- Q94:什么是 IO 多路复用?Redis 怎么用它的?
- Q117:单例模式有哪几种写法?双重检查锁为什么需要 volatile?枚举为什么最安全?
- Q118:工厂模式和抽象工厂模式区别?
- Q119:策略模式是什么?什么场景用?
- Q120:适配器模式是什么?什么场景用?
- Q121:代理模式是什么?Spring AOP 是哪种代理?
- Q136:Docker 基本概念?镜像和容器的区别?
- Q137:Git 常用命令?merge 和 rebase 的区别?
- Q138:Maven 生命周期?
- Q139:MD5 和 CRC 的区别?为什么秒传用 MD5 不用 CRC?
- Q128:MCP 协议解决什么问题?类比什么?
- Q129:Function Calling 是什么?流程是怎样的?
- Q130:Skills 是什么?和 MCP 工具的区别?
- Q131:Agent 的短期记忆和长期记忆怎么设计?
- Q132:多智能体系统有什么优势和复杂性?
- Q133:RAG 是什么?基本流程?
- Q134:RAG 和微调的区别?什么时候用哪个?
- Q135:大模型幻觉怎么缓解?
🔥 八股文
Q1:HashMap 的底层数据结构?put 的完整流程?
面试标准回答:
HashMap 的底层数据结构是数组 + 链表 + 红黑树。
put 一个元素的全流程是这样的:
- 计算哈希值: 调用 key 的
hashCode()方法得到一个哈希值,然后通过扰动函数将哈希值的高 16 位与低 16 位做异或运算。这一步是把高位特征混合到低位,让最终定位桶位时分布更均匀,减少哈希碰撞。 - 定位桶位: 用扰动后的哈希值对数组长度减一进行位与运算(
hash & (n-1)),这等价于取模但性能更高,前提是数组长度必须是 2 的幂次。 - 桶空直接放: 如果定位到的桶位置是空的,直接把键值对包装成 Node 节点放入桶中。
- 桶非空判断冲突: 如果桶非空,先判断桶中的第一个节点是红黑树节点(TreeNode)还是普通链表节点(Node)。
- 红黑树处理: 如果是红黑树节点,调用树的插入方法
putTreeVal,遍历红黑树按 key 的哈希和比较结果决定插入位置,如果 key 相同则替换旧值。 - 链表遍历: 如果是普通链表,遍历链表逐个比较 key(先比较哈希,再比较
==或equals)。若找到相同 key,用新值覆盖旧值,直接返回旧值;若遍历到末尾仍未找到,采用尾插法将新节点插入链表尾部。 - 树化判断: 尾插之后,检查当前链表长度是否大于等于 8。如果是,还要再判断数组长度是否大于等于 64。只有两个条件同时满足,才会调用
treeifyBin将链表转换为红黑树。如果数组长度不足 64,优先进行扩容,因为扩容可以重新分散元素从而缩短链表。 - 扩容判断: 整个 put 完成后,检查当前元素总数
size是否超过容量 × 负载因子(默认 0.75)。如果超过,触发扩容,容量翻倍,然后将所有元素重新哈希分配到新数组中。
追问储备:
- 扰动函数为什么是异或高 16 位和低 16 位? 因为数组长度较小时只有低位参与位运算,高位的特征会被浪费。异或操作能保留高位的随机性,让散列更均匀,减少链表长度。
- 1.7 和 1.8 插入方式的区别? 1.7 是头插法,扩容时转移链表会倒序,并发时可能形成环链;1.8 改为尾插法,避免了倒序和环链,但依然不能保证并发安全。
Q2:扰动函数为什么要高 16 位和低 16 位异或?
面试标准回答:
扰动函数 (h = key.hashCode()) ^ (h >>> 16) 的作用是把哈希值的高位特征混合到低位。
原因在于:HashMap 用 hash & (n-1) 定位桶位,当数组长度 n 较小时(比如默认容量 16),n-1 只用了低 4 位参与位运算。如果直接使用 key 的原始哈希值,那么高 28 位完全被忽略,导致哈希碰撞概率增加。
通过将高 16 位和低 16 位异或,把高位的信息压缩到低位,使得即使数组很小,低位也包含了高位的随机性,元素分布更均匀,链表长度和红黑树转换概率更低,从而提升查询效率。
这是典型的空间换时间 + 分散冲突的设计,也是 JDK 源码中的经典优化。
追问储备:
- 为什么用异或不用与或非? 异或可以保留更多的信息量,0 和 1 各占 50% 概率,混合后的随机性更好;与操作偏向 0,或操作偏向 1,都不理想。
- 为什么扰动只做一次? 一次高 16 位和低 16 位异或已经足够将高位信息充分混合到低位,多次扰动效果提升不大,反而增加计算开销。
Q3:为什么数组长度必须是 2 的幂次?
面试标准回答:
HashMap 的数组长度设计为 2 的幂次,主要基于两个原因:
第一,位运算代替取模提升性能。
我们需要根据哈希值计算元素应该在数组的哪个桶:index = hash % n。当 n 是 2 的幂次(如 16)时,hash % n 等价于 hash & (n-1)。位运算比取模快得多,这对高频 put/get 操作至关重要。
第二,让元素分布更均匀。
2 的幂次保证了 n-1 的二进制全是低位 1(比如 16-1=15,二进制 1111)。这样位与运算的结果完全取决于哈希值的低位,分散性最好。如果 n 不是 2 的幂次,比如 n=10,n-1=9 (1001),某些位总是 0,导致有些桶永远不会被定位到,造成严重碰撞。
另外,扩容时容量翻倍也是 2 的幂次,这样元素迁移时只需要判断新增的那一位是 0 还是 1,就能确定是留在原索引还是原索引+旧容量,计算非常高效。
追问储备:
- HashMap 默认容量为什么是 16? 16 是一个经验和性能的折中,太小频繁扩容,太大浪费空间。同时 16 作为桶数量,在负载因子 0.75 下,几乎不会出现大量碰撞。
- 为什么创建时指定容量 17,HashMap 会变成 32? 内部会通过
tableSizeFor方法将容量调整为大于等于指定值的最小 2 的幂次,所以 17 → 32。
Q4:为什么负载因子是 0.75?
面试标准回答:
负载因子 0.75 是空间利用率和时间效率之间的一个折中。
- 如果负载因子太大(比如 1.0): 空间利用率高,数组装得越满才扩容,但此时哈希碰撞会更频繁,链表/红黑树变长,put 和 get 的时间复杂度从 O(1) 趋近于 O(log n) 甚至 O(n),查询性能下降。
- 如果负载因子太小(比如 0.5): 空间利用率低,数组刚装一半就扩容,浪费很多内存,而且频繁扩容(涉及 rehash)也会消耗性能。
0.75 这个值是在时间和空间之间取得的一个平衡。根据泊松分布,在负载因子 0.75 的情况下,链表长度达到 8 的概率约为亿分之六(0.00000006),超过 8 的概率小于千万分之一,几乎不可能因为正常插入而转换成红黑树,所以 0.75 可以保证绝大多数情况下链表很短,查询接近 O(1)。
追问储备:
- 可以自定义负载因子吗? 可以,通过构造函数传入。但通常不建议轻易修改,除非非常清楚场景特性,比如内存极度紧张可以调大,但会牺牲性能。
- 为什么 String 的 hashCode 设计得很分散? 为了在 HashMap 中作为 key 时能够很好地散列,减少碰撞。
Q5:树化条件为什么有两个?(链表 ≥8 且数组 ≥64)
面试标准回答:
树化的两个条件是:链表长度达到 8,并且数组长度达到 64。缺一不可。
链表长度 ≥8 的原因:
红黑树节点占用的内存空间大约是链表节点的两倍,所以在元素很少时,链表更省内存。只有当链表过长、查询性能明显下降时,才值得用空间换时间。根据泊松分布,在负载因子 0.75 下,链表长度达到 8 的概率极小(小于千万分之一),所以正常情况下根本不会树化。一旦达到 8,说明碰撞严重,很可能是因为 key 的哈希分布不均,此时转红黑树(O(log n))来保证查询性能。
数组长度 ≥64 的原因:
如果数组长度很小(比如 16),某个桶的链表却很长,说明元素总数其实已经很大,哈希碰撞是因为数组太小造成的。此时优先扩容,将容量翻倍后重新哈希,可以迅速把链表拆短,解决问题且成本更低。扩容能同时优化所有桶,而树化只优化一个桶。只有当数组已经足够大(≥64),扩容也无法显著缓解碰撞时,才去树化那个特定的桶。
追问储备:
- 链表树化阈值为什么是 8,不是 6? 8 是经过泊松分布计算出的极低概率阈值,低于 8 时链表查找性能和空间占用都优于红黑树。
- 为什么树退化为链表的阈值是 6? 这是为了避免在 7 或 8 附近反复转换(树⇄链表)造成震荡,设置一个缓冲区间。
Q6:扩容机制?什么时候触发?扩容后元素怎么重新分配?
面试标准回答:
HashMap 的扩容是在元素个数超过阈值(容量 × 负载因子) 时触发的。默认负载因子 0.75,所以当元素数量达到容量的 75% 时,就扩容。
扩容过程:
- 创建新数组,容量为原来的两倍(保证仍然是 2 的幂次)。
- 遍历旧数组的每个桶,将里面的元素迁移到新数组。
- 对于每个桶,如果是单个节点,直接重新计算
新索引 = hash & (newCap-1)。如果是链表,会进行拆分:因为新容量是旧容量的两倍,新索引只取决于哈希值的新增的那一位(旧容量对应的位)。- 如果该位是 0,保留在原索引(原位置)。
- 如果该位是 1,移动到原索引 + 旧容量。
- 红黑树同样按此逻辑拆分为两棵子树,如果某一棵子树节点数过少(≤6),会退化为链表。
- 整个迁移过程完成后,新数组投入使用。
这种重新分配的算法比 JDK 1.7 逐个重新计算哈希的方式高效得多,减少了计算开销。
追问储备:
- 扩容是线程安全的吗? 不是,并发扩容可能导致死循环(1.7)或数据丢失(1.8),所以 HashMap 不适合多线程环境。
- 1.8 扩容为什么不会出现环链? 因为链表插入改用尾插法,迁移时保持原有顺序,不会倒置。
Q7:HashMap 为什么线程不安全?(并发 put 覆盖、size 不准确、1.7 环形链表)
面试标准回答:
HashMap 的设计初衷是用于单线程环境,它在并发场景下主要有三个典型问题:
1. 并发 put 导致数据覆盖:
如果两个线程同时 put 不同的 key,但定位到同一个空桶,并且都判断桶为空,那么后执行的线程会直接把自己的节点放进去,覆盖掉前一个线程的节点,造成数据丢失。
2. size 计数不准确:
HashMap 用 size 字段记录元素个数。每次 put 时会执行 size++,但这个操作不是原子的。多线程下,size++ 可能会丢失递增,导致实际元素数量与 size 字段不符,甚至引发后续扩容判断出错。
3. JDK 1.7 扩容导致环形链表(死循环):
1.7 在扩容迁移链表时使用头插法,这会导致链表倒置。如果两个线程同时扩容,一个线程在迁移中途被挂起,另一个线程完成迁移,链表顺序已经反转。当第一个线程恢复后继续迁移时,可能出现节点相互指向,形成环形链表。后续 get 操作进入该环时会死循环,CPU 飙到 100%。
JDK 1.8 的改进: 将头插法改为尾插法,避免了倒序和环链,但前两个问题(覆盖、size 不准确)依然存在,所以 1.8 的 HashMap 仍然线程不安全。
追问储备:
- 1.8 尾插法为什么解决不了覆盖问题? 尾插法只解决链表插入时的顺序和环链问题,但并发判断桶为空然后插入这一过程本身没有锁保护,依然可能覆盖。
- 多线程环境该用哪个? ConcurrentHashMap 或 Hashtable(不推荐),或者用
Collections.synchronizedMap包装。
Q8:JDK 1.7 头插法和 1.8 尾插法的区别?为什么改?
面试标准回答:
1.7 头插法: 新节点插入链表时,放在链表头部,成为新的桶内第一个节点。优点是实现简单,插入快 O(1)。但问题是在扩容迁移链表元素时,头插法会导致链表倒序。如果多个线程同时扩容,可能因顺序颠倒而形成环形链表,导致死循环。
1.8 尾插法: 新节点插入链表时,遍历到链表末尾再插入。插入操作本身比头插慢(需要遍历到尾部),但保持了插入顺序。扩容迁移时,链表元素的顺序完全不变,因此杜绝了环形链表的产生,解决了死循环问题。
改动原因: 虽然 1.8 的尾插法单线程插入稍慢,但在多线程扩容的安全性上有了质的提升。即使 HashMap 本身不是设计给并发使用的,但实际使用中难免被误用,尾插法至少避免了最严重的死循环问题,是一个工程上的折中与让步。
追问储备:
- 尾插法为什么可以避免环链? 因为迁移时链表保持原有顺序,每个节点只会被处理一次,不会出现相互引用。
- 尾插法是否完全解决了并发安全问题? 否,只解决了扩容成环问题。并发 put 覆盖、size 计数不准确等问题依然存在。
Q9:ConcurrentHashMap 1.7 怎么保证线程安全?分段锁 Segment 原理?
面试标准回答:
JDK 1.7 的 ConcurrentHashMap 采用分段锁(Segment) 机制保证线程安全。
它内部维护了一个 Segment 数组,每个 Segment 继承自 ReentrantLock,独立管理一部分哈希桶。默认有 16 个 Segment,或者说并发度为 16。
当线程要操作某个 key 时:
- 先对 key 的哈希值进行高位取模,定位到某个 Segment。
- 尝试获取该 Segment 的锁(
lock())。获取成功后,才能对这个 Segment 内部的哈希桶数组进行 put、get、remove 等操作。 - 不同 Segment 之间加的是不同的锁,所以多个线程如果操作落在不同的 Segment,可以并行执行,提升了并发度。
另外,读操作在大部分情况下是不需要加锁的,因为 Segment 内部的字段(如 count、HashEntry 的 value 和 next)都使用了 volatile 修饰,保证了可见性。
缺点: Segment 的数量在初始化时就固定了,无法动态扩容,并发度上限被锁死。而且锁粒度仍然比较粗,如果并发度设置不合理(过小),多个线程仍然会在同一个 Segment 上竞争。
追问储备:
- Segment 数量和并发度的关系? 并发度决定了同一时刻最多有多少个线程可以真正并行执行写操作,默认 16,可以在构造时指定,之后不能修改。
- 1.7 的 size() 怎么算的? 尝试两次不加锁统计所有 Segment 的 count,如果两次统计一致就返回;如果不一致,则对所有 Segment 加锁后重新统计。
Q10:ConcurrentHashMap 1.8 怎么保证线程安全?CAS + synchronized 锁桶原理?
面试标准回答:
JDK 1.8 彻底放弃了分段锁设计,改用CAS(Compare-And-Swap)+ synchronized 来保证线程安全,锁的粒度从 Segment 细化到每个桶的头节点。
具体策略:
空桶插入——用 CAS:
如果通过哈希定位到的桶位置为空,则直接用 CAS 尝试将新节点设置到该桶。CAS 是原子操作,只有一个线程能成功。失败的线程会重新自旋,走后续逻辑。空桶用 CAS 而不用锁,因为操作简单、开销小,避免了加锁的上下文切换。
非空桶插入或修改——用 synchronized:
如果桶位置不为空(已有链表或红黑树),则需要获取桶中第一个节点的对象锁(synchronized)。锁住头节点后,遍历链表或红黑树进行查找、替换或追加。释放锁后,其他线程才能继续操作这个桶。
读操作——完全无锁:
节点的 val 和 next 字段都用 volatile 修饰。任何线程对链表的修改都会立即刷新到主存,而读线程每次都从主存取最新值,因此读操作不需要加锁,和写操作互不阻塞,极大提升了读并发性能。
这种 CAS(轻量无锁)+ synchronized(重量锁细粒度) 的混合策略,在低竞争时用 CAS 自旋,高竞争时才膨胀为重量级锁,性能大幅优于 1.7 的分段锁。
追问储备:
- 为什么 1.8 不用 ReentrantLock 而用 synchronized? 因为 JDK 1.6 对 synchronized 做了大量优化(偏向锁、轻量锁、自旋),性能已经不输 ReentrantLock,而且 synchronized 是 JVM 内置,更简洁,还能在锁膨胀时使用更高效的底层机制。
- 扩容时怎么保证安全? 扩容期间,帮助扩容的线程也会参与转移元素,通过
transferIndex和 CAS 协作完成。
Q11:put 时怎么区分用 CAS 还是 synchronized?
面试标准回答:
在 ConcurrentHashMap 1.8 的 put 操作中,区分使用 CAS 还是 synchronized 的核心依据是目标桶是否为空。
1. 桶为空 → CAS 插入:
如果通过 tabAt 方法获取到的桶位置是 null,说明该桶还没存放任何节点。此时会使用 casTabAt 方法(底层是 Unsafe.compareAndSwapObject)尝试原子性地将新节点放入该桶。如果 CAS 成功,插入完成;如果 CAS 失败(被其他线程抢先了),则回到循环开头重新检查该桶的情况。
2. 桶非空 → synchronized 锁头节点:
如果桶已经不为空(可能是链表头节点或红黑树根节点),那么不能再用 CAS 了,因为修改的是链表内部结构。此时用 synchronized 锁住桶中的第一个节点(也就是 f),然后在这个锁的保护下遍历链表或红黑树进行查找、更新或追加(尾插)。
为什么这么设计?
- 空桶场景操作简单,只需要修改数组的一个槽位,用 CAS 自旋的成本远低于加锁。
- 非空桶需要遍历和修改链表/树内部指针,逻辑复杂,用锁保护更安全,而且锁粒度已经细化到单桶,竞争不会太激烈。
追问储备:
- 如果 CAS 失败怎么办? 自旋重试,重新读取桶的最新状态,进入下一轮循环,可能桶已变为非空,那就走 synchronized 分支。
- 头节点被锁住时,其他线程能读吗? 能,因为节点的 value 和 next 是 volatile,读线程不需要获取锁。
Q12:读操作为什么不需要加锁?volatile 在这里起什么作用?
面试标准回答:
ConcurrentHashMap 1.8 的读操作(get、containsKey 等)完全不需要加锁,因为它的数据可见性由 volatile 保证。
具体机制:
- 数组本身虽然是通过
volatile修饰的(实际上通过 Unsafe 保证数组元素的 volatile 语义),但最关键的是每个 Node 节点的val和next字段都使用volatile修饰。 - 当写线程修改节点的值或改变链表的
next指针时,根据 JMM(Java 内存模型)的 volatile 语义,修改会立即刷到主存。 - 读线程读取这些 volatile 字段时,强制从主存获取最新值,而不会从线程本地缓存(工作内存)中读旧值。
- 因此,读线程总能“看到”最新的修改,无需锁也能保证读到一致的数据。即使有线程正在对同一个桶的链表进行修改,读线程也能读到修改前或修改后的完整快照,不会读到中间状态。
追问储备:
- volatile 能保证原子性吗? 不能,所以只适用于读,写操作依然需要 CAS 或锁来保证复合操作的原子性。
- 那为什么 get 不需要加锁而 put 需要? get 只是读取一个简单的变量值(volatile),不需要保持跨多个变量的原子性。而 put 涉及链表指针修改、元素计数等多步骤操作,必须通过加锁或 CAS 来保证整体原子性。
Q13:size() 方法怎么统计的?CounterCell 是什么?
面试标准回答:
ConcurrentHashMap 1.8 的 size() 方法不是简单地读取一个 int 变量,因为那样会有并发统计不准确的问题。它采用了一种类似 LongAdder 的分段计数机制,核心组件是 baseCount 和 CounterCell 数组。
统计过程:
- baseCount: 一个基础的计数字段,更新时通过 CAS 尝试直接增加。如果 CAS 成功,计数就完成了。
- CounterCell 数组: 如果 CAS 更新
baseCount失败(说明有竞争),就说明有多个线程在同时修改元素计数。此时,线程不再直接竞争baseCount,而是随机选取一个 CounterCell(每个 CounterCell 也是一个独立的计数器),通过对其value字段进行 CAS 累加。这样多个线程分散到不同的 CounterCell 上,大幅降低了竞争。 - 求和: 调用
size()时,返回的是baseCount + 所有 CounterCell 的 value 之和。这个求和过程没有加锁,所以可能统计到中间状态,但 ConcurrentHashMap 的 size 方法本来就是一个估算值(并不要求精确强一致),因为在高并发下,size 可能刚返回就已经变了。
追问储备:
- 为什么不用 AtomicLong? AtomicLong 在高竞争下性能差,因为大量线程会自旋重试。CounterCell 分散了热点,类似 JDK 8 新增的 LongAdder。
- 1.7 的 size() 怎么做的? 1.7 是先尝试两次无锁统计所有 Segment 的 count,如果前后一致则返回,否则对所有 Segment 加锁后重算。这是加锁开销很大的设计,1.8 用 CounterCell 优化了。
Q14:synchronized 和 ReentrantLock 的区别?
面试标准回答:
synchronized 和 ReentrantLock 都是 Java 中的可重入互斥锁,它们的核心区别体现在以下五个方面:
第一,底层实现不同。 synchronized 是 JVM 层面的关键字,由 JVM 内置的 Monitor 机制实现,加锁和解锁都是隐式自动完成的——代码块执行完或抛出异常时自动释放锁。ReentrantLock 是 JDK 层面的类,基于 AQS(AbstractQueuedSynchronizer)实现,需要显式调用 lock() 加锁,并且必须在 finally 块中调用 unlock() 手动释放锁,否则会死锁。
第二,公平性不同。 synchronized 只能是非公平锁,线程抢锁完全随机,可能导致某些线程长时间获取不到锁(线程饥饿)。ReentrantLock 在构造时可以传入参数选择公平锁或非公平锁。公平锁按照线程等待的先后顺序分配锁,避免饥饿但性能稍差;非公平锁性能更好但可能出现插队。
第三,可中断性不同。 synchronized 获取不到锁时,线程只能阻塞等待,不可中断;即使外部调用 interrupt() 也无法让它停止等待。ReentrantLock 提供了 lockInterruptibly() 方法,允许线程在等待锁的过程中响应中断,从而可以优雅地取消等待。
第四,超时机制不同。 synchronized 没有超时功能,获取不到锁就一直等。ReentrantLock 提供了 tryLock(long timeout, TimeUnit unit) 方法,允许线程等待指定时间后自动放弃,返回 false 表示获取失败,线程可以去执行其他逻辑而不是死等。
第五,条件队列不同。 synchronized 配合 wait()/notify()/notifyAll() 使用时,只能有一个隐式的等待队列。当调用 notifyAll() 时,所有等待的线程都被唤醒,造成大量无效唤醒。ReentrantLock 通过 newCondition() 可以创建多个 Condition 条件队列,每个 Condition 维护独立的等待队列,可以实现精准唤醒——只唤醒满足特定条件的那一批线程,大大减少了无效竞争。
追问储备:
- 共同点有哪些? 两者都是可重入锁(通过内部计数器实现同一线程重复获取同一把锁),都是互斥锁(同一时刻只有一个线程持有)。
- 性能上谁更好? JDK 1.6 之前 ReentrantLock 性能明显优于 synchronized。1.6 之后 synchronized 引入了偏向锁、轻量级锁、自旋锁等大量优化,两者性能已基本持平,看场景选即可。
Q15:synchronized 的锁升级过程?无锁→偏向锁→轻量级锁→重量级锁
面试标准回答:
JDK 1.6 之后,synchronized 不再是直接使用重量级操作系统互斥锁,而是引入了锁升级机制,锁的状态从低到高依次为:无锁 → 偏向锁 → 轻量级锁 → 重量级锁。升级是单向不可逆的,目的是根据竞争激烈程度动态选择最优的锁实现。
1. 偏向锁: 当锁第一次被某个线程获取时,JVM 会在对象头的 Mark Word 中记录这个线程的 ID。之后同一个线程再次进入同步块时,只需检查 Mark Word 中的线程 ID 是否是自己,如果是就直接进入,无需任何同步操作(连 CAS 都省了)。偏向锁适用于总是同一个线程反复获取锁的场景,比如单线程的 Vector 操作。
2. 轻量级锁(CAS 自旋锁): 当第二个线程尝试获取偏向锁,但持有锁的线程还活着且还在同步块内,偏向锁就会被撤销,升级为轻量级锁。升级时,JVM 会等原持有线程到达安全点后,将偏向锁撤销,然后在当前线程的栈帧中开辟一个锁记录空间(Lock Record),通过 CAS 自旋 尝试把对象头指向这个栈空间。轻量级锁适用于竞争不激烈、持有时间短的场景——自旋消耗 CPU 比线程阻塞切换的代价小。
3. 重量级锁: 如果有多个线程同时竞争轻量级锁,且自旋等待超过一定次数(默认自适应),JVM 会认为竞争激烈,将锁升级为重量级锁。重量级锁基于操作系统内核的 Mutex 实现,获取不到锁的线程会被挂起(Park),让出 CPU,等锁释放后被唤醒。挂起和唤醒涉及用户态和内核态的切换,成本较高,但能在竞争激烈时避免 CPU 空转。
追问储备:
- 偏向锁什么时候撤销? 当有另一个线程尝试竞争偏向锁,且原持有线程已退出同步块或到达安全点时触发撤销,撤销偏向锁需要在全局安全点(Safepoint)执行,开销较大。
- 偏向锁延迟是什么? JVM 默认启动 4 秒后才会开启偏向锁,因为启动时大量锁竞争,提前开启反而增加撤销开销。
- 锁能降级吗? 不能,一旦升级到重量级锁,即使竞争消失也不会降回轻量级或偏向锁。
Q16:偏向锁什么时候撤销?轻量级锁自旋多少次升级?
面试标准回答:
偏向锁的撤销发生在有其他线程来竞争这把锁的时候。具体流程是:当线程 B 尝试获取偏向锁,但发现锁对象头中记录的线程 ID 是线程 A,说明这把锁当前偏向线程 A。此时 JVM 会检查线程 A 是否还持有这把锁。如果线程 A 已经退出同步块(或已经死亡),JVM 会在一个全局安全点(Safepoint)暂停线程 B,将锁对象头恢复到无锁状态,然后让线程 B 重新竞争。如果线程 A 还活着且在同步块内,则将偏向锁撤销并膨胀为轻量级锁,继续让线程 A 持有。撤销偏向锁需要在安全点执行,这意味着会带来一定的 STW 开销。
轻量级锁自旋次数: 轻量级锁使用 CAS 自旋来等待锁释放。JDK 1.6 之前是固定自旋次数(默认 10 次),可以通过参数 -XX:PreBlockSpin 调整。JDK 1.6 之后引入了自适应自旋:JVM 会根据上一次同一把锁的自旋结果动态决定这次要自旋多久。如果上次自旋成功获得锁了,那这次就多自旋一会儿;如果上次自旋失败了(最终膨胀为重量级锁),那这次就少自旋甚至不自旋,直接升级为重量级锁。这种方式更智能,减少了不必要的 CPU 空转。
追问储备:
- 自旋等待和线程挂起如何选择? 自旋不会释放 CPU(忙等),适合锁持有时间短、竞争不激烈的场景;挂起会释放 CPU 但涉及上下文切换开销,适合锁持有时间长、竞争激烈的场景。JVM 通过自适应自旋来动态平衡。
- 为什么延迟开启偏向锁? 因为 JVM 启动时大量类加载和初始化操作都需要锁,这些锁竞争通常很激烈,偏向锁会频繁撤销,反而拖慢速度。延迟开启可以避开这个阶段。
Q17:AQS 是什么?state 变量和 CLH 队列各自做什么?
面试标准回答:
AQS 全称 AbstractQueuedSynchronizer(抽象队列同步器),它是 JUC 并发包的核心基础框架。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock 等工具类的底层实现全都依赖 AQS。AQS 就像一个盖房子的脚手架,提供了一套通用的机制来构建锁和同步器。
AQS 的核心只有两个东西:一个 state 变量 和 一个 CLH 队列。
state 变量: 这是一个被 volatile 修饰的 int 类型变量,代表了同步资源的状态。不同的实现类对这个变量的解读不同。在 ReentrantLock 中,state = 0 表示锁空闲,state = 1 表示锁被占用,state > 1 表示锁被同一个线程重入了多次(可重入)。在 Semaphore 中,state 代表剩余的许可证数量。所有线程对 state 的修改都通过 CAS 操作来保证原子性。
CLH 队列: 这是一个虚拟的双向队列(CLH 变体),用于存放获取锁失败的线程。当一个线程试图通过 CAS 修改 state 抢锁但失败时,AQS 会把这个线程包装成一个 Node 节点,通过 CAS 自旋把它追加到 CLH 队列的尾部。入队后,前驱节点的状态会决定这个线程是被挂起(Park)还是继续自旋。当持有锁的线程释放锁(修改 state)时,会去唤醒 CLH 队列的头节点后的第一个有效节点,让它重新尝试获取锁。这个队列实现了公平排队(公平锁严格按照队列顺序唤醒,非公平锁允许新线程插队抢锁)。
追问储备:
- AQS 的独占模式和共享模式有什么区别? 独占模式(ReentrantLock)同一时刻只有一个线程能持有锁;共享模式(Semaphore、CountDownLatch)允许多个线程同时获取资源。
- CLH 队列为什么是"虚拟"的? 原始的 CLH 锁队列中,每个节点自旋检查前驱节点的状态。AQS 改成了检查前驱节点状态后挂起当前线程,由前驱节点释放时唤醒,比纯自旋更节省 CPU。
Q18:同步队列和等待队列的区别?
面试标准回答:
在 AQS 中,同步队列(Sync Queue) 和等待队列(Wait Queue,也叫条件队列 Condition Queue) 是两个作用完全不同的队列。
我用一个烤肉店的比喻来区分它们:
同步队列——排号等吃饭的队伍: 你想吃饭(获取锁),但现在没空位了。于是你被安排在门外排队(进入同步队列)。这个队列里的线程,都是想要获取锁但还没拿到的线程。它们被挂起等待,直到前面的人吃完(释放锁)后,按照顺序被唤醒。同步队列是一个双向链表(AQS 里的 CLH 变体)。
等待队列——吃了一半去旁边等上菜的队伍:
你已经进去坐下了(拿到了锁),但发现烤肉还没熟(某个业务条件不满足)。服务员说:“你别占着桌子,先去旁边小板凳上等菜,把桌子让出来。” 于是你主动释放了锁,移到旁边的小板凳上(进入等待队列)。这个队列里的线程,都是已经拿到过锁,但因为条件不满足主动释放了锁的线程。它们在等别人来通知它们"菜好了"(条件满足了)。等待队列是一个单向链表,通过 Condition 对象来维护。
关键转折: 当"菜好了"的通知来了(Condition.signal()),你在小板凳上(等待队列)被叫起来了。但你不能直接回去继续吃,你必须先回到门外重新排队(从等待队列转移到同步队列),再次抢到桌子(拿到锁)后,才能继续吃。
追问储备:
- 为什么要从等待队列转移到同步队列? 因为锁已经释放给其他人了,等条件的线程被唤醒后,必须和其他抢锁的线程公平竞争,重新获取锁后才能继续执行。
- 等待队列的节点和同步队列的节点是同一个类吗? 是,都用
Node类。但在等待队列中时,Node处于 CONDITION 状态,单向链接;转移到同步队列后,Node状态改为 SIGNAL,双向链接。
Q19:为什么说 synchronized 只支持单条件队列,而 ReentrantLock 支持多条件队列?
面试标准回答:
这是 synchronized 和 ReentrantLock 在等待通知机制上的核心差异。
synchronized 单条件队列: synchronized 配合 wait()/notify()/notifyAll() 使用时,每个对象只有一个隐式的等待队列(WaitSet)。所有因为不同条件而调用 wait() 的线程,全都被扔进同一个等待队列里。当有人调用 notifyAll() 时,它会把这个队列里所有的线程都唤醒。但很多线程等待的条件可能并没有满足——比如生产者和消费者都在同一个队列里,生产者唤醒后发现有数据没有被消费,消费者唤醒后发现有数据还没被生产。这些"被错误唤醒"的线程不得不重新检查条件,发现不满足后又调用 wait() 回去睡觉。这种现象叫无效唤醒,浪费了大量 CPU 在无意义的等待和检查上。
ReentrantLock 多条件队列: ReentrantLock 通过 newCondition() 可以创建多个独立的 Condition 对象,每个 Condition 维护自己的等待队列。比如生产者线程可以调用 notFullCondition.await() 进入"缓冲区没满"的等待队列;消费者线程可以调用 notEmptyCondition.await() 进入"缓冲区非空"的等待队列。当生产者生产了数据,只需调用 notEmptyCondition.signal()(或 signalAll()),只唤醒消费者队列中的线程,生产者队列完全不受影响。这就是精准唤醒,彻底避免了无效唤醒和线程间的无意义竞争。
追问储备:
- Condition 是接口,它的实现类是什么? AQS 内部实现了
ConditionObject类,它实现了Condition接口,维护了一个单向的等待队列。 - signal() 和 signalAll() 的区别? signal() 只唤醒等待队列中的第一个线程(队头),signalAll() 唤醒等待队列中的所有线程。精准唤醒时通常用 signal(),避免大面积唤醒造成的竞争。
Q20:Condition 的 await() 和 signal() 底层怎么做的?
面试标准回答:
Condition 的 await() 和 signal() 是 AQS 中实现等待通知机制的核心方法,它们的底层操作涉及线程从同步队列和等待队列之间的来回转移。
await() 的底层流程:
- 调用
await()的前提是当前线程已经持有锁。 await()内部会创建一个新的 Node 节点,将节点状态设置为 CONDITION,然后把这个节点追加到当前 Condition 的等待队列(单向链表)的尾部。- 入队后,线程完全释放锁(调用 AQS 的
release()方法,将state清零),并唤醒同步队列中的下一个等待线程去抢锁。 - 当前线程挂起(Park),进入等待状态,直到被
signal()唤醒。 - 被唤醒后,线程的节点会从等待队列转移到同步队列(AQS 的 CLH 队列)的尾部,然后线程重新去竞争锁(调用
acquireQueued())。拿到锁后,await()方法才会返回。
signal() 的底层流程:
- 调用
signal()的前提同样是当前线程持有锁。 signal()会去检查当前的等待队列,如果队列不为空,取出队头节点(第一个进入等待队列的线程)。- 通过 CAS 将这个节点从等待队列中移除(断开链接)。
- 把这个节点重新放回同步队列的尾部(通过
enq()方法),并将它的前驱节点的状态设为 SIGNAL。 - 被转移到同步队列的线程之后会因为前驱节点释放锁而被唤醒,继而重新获取锁继续执行。
追问储备:
- await() 和 wait() 的相似之处? 两者都会释放锁然后挂起当前线程,被唤醒后都会重新竞争锁。但
wait()是在对象监视器上等待,而await()是在 AQS 的 Condition 中等待。 - signal() 必须持有锁吗? 是的,调用
signal()时必须持有锁,否则会抛出IllegalMonitorStateException。这保证了等待队列操作的线程安全。
Q21:线程池七大参数?任务执行的完整流程?
面试标准回答:
线程池有七个核心参数,通过 ThreadPoolExecutor 的构造函数来设置:
- corePoolSize(核心线程数): 线程池中长期保持存活的线程数量,即使它们处于空闲状态也不会被回收(除非设置了
allowCoreThreadTimeOut)。 - maximumPoolSize(最大线程数): 线程池中允许存在的最大线程数量。
- keepAliveTime(存活时间): 非核心线程空闲超过这个时间后会被回收。
- unit(时间单位): keepAliveTime 的时间单位。
- workQueue(任务队列): 用于存放等待执行的任务的阻塞队列。常用有
ArrayBlockingQueue(有界)、LinkedBlockingQueue(默认无界)、SynchronousQueue(不存储任务,直接交给线程)。 - threadFactory(线程工厂): 用来创建新线程,可以自定义线程名、优先级等。
- handler(拒绝策略): 当线程池和队列都满了,无法处理新任务时的处理方式。
任务执行流程:
- 任务提交后,如果当前线程数小于 corePoolSize,直接创建一个新的核心线程来执行任务。
- 如果核心线程数已满,将任务放入 workQueue 排队等待。
- 如果 workQueue 也满了,且当前线程数小于 maximumPoolSize,创建一个非核心线程来执行任务。
- 如果线程数已达到 maximumPoolSize 且队列也满了,执行拒绝策略。
追问储备:
- 核心线程会被回收吗? 默认不会。但如果设置了
allowCoreThreadTimeOut(true),核心线程空闲超过 keepAliveTime 也会被回收。 - 怎么知道线程池当前有多少活跃线程?
getActiveCount()可以获取正在执行任务的线程数,getPoolSize()获取当前池中总线程数。
Q22:为什么先放队列而不是直接扩容?
面试标准回答:
线程池在执行任务时,核心线程满了先放队列,队列满了才扩容到最大线程,这个设计是为了复用线程,减少线程创建和销毁的开销。
线程的创建是昂贵的:每个线程在 JVM 中都占用独立的栈空间。创建一个新线程涉及内核级别的调用,非常消耗资源。销毁线程同样有成本。
为什么要先复用现有线程: 将任务放入队列,让核心线程处理完手头的任务后,直接从队列中取下一个任务继续执行。这种方式充分利用了已经创建好的线程,避免频繁创建和销毁线程的开销。线程从一个任务切换到另一个任务的成本,远低于新建一个线程。
如果反过来设计(先扩容到最大线程,再放队列): 会导致线程池中瞬间创建大量线程。每个线程都要占用栈内存。大量线程同时运行会导致线程上下文切换频繁,反而降低处理效率。而且很多线程可能刚创建完还没干多少活就空闲了,造成资源浪费。
实际场景举例: 假设核心线程 10 个,最大线程 100 个,队列容量 500。如果有 50 个任务突然涌入,当前的设计只用 10 个核心线程处理,40 个任务在队列中等。如果反过来设计,会立刻创建 50 个线程,之后队列基本闲置,大量线程无事可做。
追问储备:
- 什么时候需要无界队列? 如果任务的等待时间可以很长,且不想因队列满而触发拒绝或扩容,可以用无界队列。但无界队列可能造成任务堆积导致 OOM。
- 什么时候需要 SynchronousQueue? 如果希望任务被立即执行,不能被堆积在队列中,SynchronousQueue 可以做到"一手交任务,一手交线程",没有容量,必须有线程立即可用。
Q23:拒绝策略有哪四种?默认是哪个?
面试标准回答:
线程池的拒绝策略是当线程池和任务队列都满了,无法处理新任务时的处理方式。JDK 内置了四种拒绝策略,都实现了 RejectedExecutionHandler 接口。
1. AbortPolicy(中止策略,默认): 直接抛出 RejectedExecutionException 运行时异常,阻止系统正常运行。这是 JDK 的默认策略,因为它能及时通知调用方"任务被拒绝了",调用方可以根据异常来做相应的处理,比如记录日志或降级。适用于不能丢任务的关键业务。
2. CallerRunsPolicy(调用者运行策略): 不抛弃任务,也不抛异常,而是把任务退回给提交任务的线程去执行。也就是说,如果线程池满了,提交任务的那个线程自己来跑这个任务。这样做降低了一点新任务的提交速度(因为提交线程在忙),但给线程池争取了消化积压的时间。适用于允许任务延迟执行、但不能丢的场景。
3. DiscardPolicy(静默丢弃策略): 直接丢弃无法处理的任务,不抛异常,不通知。任务就默默地消失了。适用于允许丢失一些不重要的任务(比如某些日志记录、非关键的统计数据)。
4. DiscardOldestPolicy(丢弃最老策略): 丢弃任务队列中等待最久的那个任务(队头的任务),然后把当前任务重新提交给线程池。常用于只需要最新数据的场景,比如实时位置更新,丢弃旧的位置数据,处理最新的。
追问储备:
- 可以自定义拒绝策略吗? 可以,实现
RejectedExecutionHandler接口,在rejectedExecution方法里写自己的逻辑,比如记录日志到数据库、发送告警通知等。 - CallerRunsPolicy 会有什么副作用? 如果调用方是主线程,主线程执行任务会阻塞后续请求的提交,可能导致整体吞吐量下降。
Q24:如果队列用无界队列会有什么问题?
面试标准回答:
如果线程池的任务队列使用无界队列(比如没有指定容量的 LinkedBlockingQueue,其默认容量为 Integer.MAX_VALUE),会引发两个严重问题。
第一个问题:最大线程数参数失效。 线程池的执行逻辑是"核心线程→队列→非核心线程→拒绝"。因为队列永远不会满,所以任务永远会被无限地塞进队列中,永远不会走到"创建非核心线程"那一步。这意味着线程池中的线程数永远不会超过 corePoolSize,你设置的 maximumPoolSize 参数完全失效了。
第二个问题:任务堆积导致 OOM。 无界队列没有上限,当任务提交速度远大于处理速度时,任务会在队列中无限堆积。每个任务都是一个对象,会持续占用堆内存。一旦堆积的任务数量足够多,就会撑爆 JVM 的堆内存,抛出 OutOfMemoryError。这种情况在高并发下特别容易发生,而且很难恢复。
实际建议: 在生产环境,线程池的任务队列几乎都应该使用有界队列(如 ArrayBlockingQueue 或指定容量的 LinkedBlockingQueue),并配上合适的拒绝策略。这个有界值可以根据任务的允许等待时间和处理速率来估算。
追问储备:
- LinkedBlockingQueue 默认容量多大?
Integer.MAX_VALUE(约 21 亿),在快速提交场景下足以让内存溢出。 - 无界队列有什么使用场景? 只有在任务提交速率可控、或内存非常充裕且不希望丢失任务的情况下才考虑,比如单线程的 ScheduledThreadPoolExecutor 内部使用的
DelayedWorkQueue也是无界的。
Q25:IO 密集和 CPU 密集任务,核心线程数怎么设?
面试标准回答:
线程池的核心线程数设置要区分任务类型,因为不同类型的任务对 CPU 和 IO 的依赖度不同。
CPU 密集型任务: 这类任务主要消耗 CPU 进行运算(比如大量数学计算、编解码、正则匹配),几乎不涉及网络和磁盘 IO。CPU 密集型任务让 CPU 一直在忙,线程再多也无法提升吞吐量,反而因为线程切换增加上下文开销。
推荐公式: 核心线程数 = CPU 核数 + 1。这多出来的 1 个线程是为了在某个线程因缺页中断或其他原因暂停时,能立刻顶上去继续利用 CPU。
IO 密集型任务: 这类任务大部分时间在等待网络或磁盘 IO(比如数据库查询、远程 API 调用),CPU 大部分时间空闲。所以可以多开一些线程,让一个线程在等 IO 时,另一个线程上 CPU 运算。
推荐公式: 核心线程数 = CPU 核数 × 2。更进一步,有个更精确的公式:线程数 = CPU 核数 × (1 + 平均等待时间 / 平均工作时间)。比如一个任务平均等 IO 100ms,运算 20ms,那么等待/工作比 = 5,就可以开 CPU 核数 × 6 个线程。
追问储备:
- 如何确定自己的任务是哪种类型? 可以通过监控 CPU 使用率和线程阻塞时间来判断。如果 CPU 使用率一直是 100%,那就是 CPU 密集型;如果 CPU 使用率很低但线程很多,那就是 IO 密集型。
- 实际项目你用什么线程池? 常见的组合是:CPU 密集型用固定线程数的 FixedThreadPool,IO 密集型用 CachedThreadPool 或自定义参数。具体数值还要通过压测来最终确定。
Q26:volatile 的三大作用?为什么能保证可见性和禁止重排,但不保证原子性?
面试标准回答:
volatile 是 Java 中最轻量的同步机制,它有三大作用:保证可见性、禁止指令重排、不保证原子性。
保证可见性: 当一个线程修改了 volatile 变量的值,新值会立即被写回主内存。同时,根据 JMM(Java 内存模型)的规则,其他线程在读取这个 volatile 变量时,会强制从主内存重新读取最新值,而不是使用自己工作内存中的缓存值。这就是 volatile 的可见性机制。实现原理是在写操作后插入一条写屏障(Store Barrier),强制将工作内存的改动刷到主内存;在读操作前插入一条读屏障(Load Barrier),强制从主内存读取。
禁止指令重排: 编译器和 CPU 为了性能,可能会对指令的执行顺序进行重排。volatile 通过内存屏障(Memory Barrier) 来禁止特定类型的重排。具体来说,volatile 写之前的操作不会被重排到写之后,volatile 读之后的操作不会被重排到读之前。这就是经典的"先行发生原则"(Happens-Before):对一个 volatile 变量的写操作,Happens-Before 于后续对这个 volatile 变量的读操作。
为什么不保证原子性: volatile 只保证单次读写的原子性(Java 中对 int 等基本类型的单次读写本身是原子的)。但对于复合操作(如 i++,本质是 读→加一→写),volatile 无能为力。因为多个线程可以同时读取到正确的值,然后各自加一,再各自写回,最终结果会少加。所以 volatile 不能替代 synchronized 或 CAS。
追问储备:
- 什么场景下 volatile 够用? 单线程写多线程读的状态标记,比如
boolean flag = true;,一个线程改标志位,其他线程立刻看到变化。 - DCL 单例为什么需要 volatile? 为了防止指令重排——对象初始化(分配内存→初始化对象→引用指向内存)中后两步可能被重排,导致其他线程拿到一个未初始化完成的对象。
Q27:volatile 的典型应用场景?
面试标准回答:
volatile 的典型应用场景主要有两个:状态标记和双重检查锁(DCL,Double-Checked Locking)。
场景一:状态标记。 当一个变量作为多个线程之间的状态开关时,用 volatile 最合适。例如一个线程负责监听关闭信号,将 running 标志设为 false;其他工作线程循环检查这个标志。因为读写是单次操作,不涉及复合运算,volatile 的可见性能保证工作线程立即感知到状态变化。
场景二:双重检查锁单例中的 instance 变量。 这是 volatile 最经典的用法。在 DCL 单例模式中,instance = new Singleton() 这个操作在 JVM 中实际分为三步:(1)分配内存空间(2)初始化对象(3)将 instance 引用指向内存空间。如果(2)和(3)被指令重排,instance 先指向了一块未初始化完成的内存,另一个线程在第一次 if (instance == null) 检查时发现非空,直接返回这个半成品对象,导致程序出错。给 instance 加上 volatile 后,禁止了"初始化对象"和"引用指向内存"的重排,保证了线程安全。
追问储备:
- 除了状态标记和 DCL,还有其他场景吗? 在
ConcurrentHashMap的 Node 节点中,val和next字段也是 volatile,用来保证读操作的可见性,实现无锁读。 - 状态标记为什么不用原子类? 状态标记只是简单的 true/false 赋值,开销比原子类更小,volatile 足够。
Q28:双重检查锁单例为什么需要 volatile?
面试标准回答:
双重检查锁(DCL)单例模式的核心代码如下:
| |
这里的 volatile 是必须的,原因在于 instance = new Singleton() 这一行代码不是原子操作。它在 JVM 层面至少分为三个步骤:
- 给对象分配内存空间
- 调用构造方法,初始化对象
- 将 instance 引用指向分配的内存空间
问题在于,JIT 编译器或 CPU 可能会对步骤 2 和 3 进行指令重排。如果步骤 3 在步骤 2 之前完成,此时 instance 已经不是 null 了(已经指向了一块内存),但对象还没有初始化完成。此时如果另一个线程执行第一次 if (instance == null) 发现不为空,就直接返回了这个半成品对象,导致程序使用一个未初始化完成的对象,出现不可预知的错误。
给 instance 加上 volatile 后,步骤 2 和 3 之间被插入了内存屏障,禁止了这两步的指令重排。这样,任何线程在看到 instance 不为 null 时,都能保证它拿到的是一个完全初始化好的对象。
追问储备:
- 如果不加 volatile,这个 Bug 一定能复现吗? 不一定,它依赖于 JIT 编译器的优化行为和 CPU 的乱序执行,在一些机器上很难复现,但确实存在隐患。根据 JMM 规范,这是一种线程不安全的实现。
- 有更好的单例实现吗? 静态内部类和枚举都能避免这个问题。静态内部类利用类加载时机来保证线程安全;枚举是《Effective Java》推荐的方式,自动防反射和反序列化攻击。
Q29:CAS 是什么?ABA 问题怎么解决?
面试标准回答:
CAS(Compare And Swap,比较并交换) 是一种无锁算法,是 Java 并发包中很多原子类的底层实现机制。
它的工作原理是:CAS 操作包含三个操作数——内存值 V、预期原值 A、要修改的新值 B。执行 CAS 时,先比较内存值 V 是否等于预期值 A。如果相等,说明在这段时间内没有其他线程修改过这个值,则将 V 更新为 B,操作成功;如果不相等,说明被其他线程改过了,不更新,操作失败。整个过程由底层 CPU 指令(如 cmpxchg)保证原子性,Java 通过 sun.misc.Unsafe 类的 native 方法来调用。
ABA 问题: 这是 CAS 操作的一个经典漏洞。设想线程一要将变量从 A 改为 C。它读取到 A 后,线程二介入,把 A 改为 B,又改回 A。线程一重新运行 CAS,发现内存值仍是 A(预期值),认为一切没变,于是将 A 改为 C。但中间其实已经发生过"A→B→A"的变化,在某些场景下这会导致逻辑错误。典型例子是栈操作:你先看到栈顶是 A,想弹出 A,但实际上 A 被弹出过又压回来了,期间 A 的 next 指针可能已经变了。
解决方案:加版本号。 Java 提供了 AtomicStampedReference 和 AtomicMarkableReference。AtomicStampedReference 维护一个"版本号"(stamp),每次修改不仅比较值,还要比较版本号,修改时版本号递增。这样即使值变回了 A,版本号也变成了 3(假设 A→B→A 三次变动),CAS 能察觉到中间发生过变化。
追问储备:
- ABA 问题在实际生产中常见吗? 在基本类型和简单引用场景下通常问题不大。但在链表、栈、队列等依赖指针的数据结构中需要特别小心。
- 除了 ABA,CAS 还有什么缺点? 自旋开销——CAS 失败后会循环重试,竞争激烈时 CPU 空转严重;只能保证一个共享变量的原子操作,不能保护代码块。
Q30:ThreadLocal 原理?为什么 key 用弱引用?内存泄漏怎么产生?怎么避免?
面试标准回答:
ThreadLocal 的原理: 每个线程 Thread 对象内部都有一个 ThreadLocalMap,这是一个自定义的哈希表。ThreadLocal 对象本身在这个 Map 中充当 key 的角色,而我们要存储的值则是 value。当调用 ThreadLocal.get() 时,先从当前线程中取出 ThreadLocalMap,再用当前 ThreadLocal 对象作为 key 去查这个 Map,找到对应的值。因为每个线程操作的是自己内部的那份 ThreadLocalMap,所以线程之间的数据天然隔离,这是 ThreadLocal 保证线程安全的核心。
key 为什么是弱引用: 在 ThreadLocalMap 的 Entry 中,key 被包装为 WeakReference<ThreadLocal>。这样设计是为了在 ThreadLocal 对象外部不再使用时,让它能被 GC 回收。如果 key 是强引用,那么只要线程还在(比如线程池中的线程一直不销毁),即使我们代码中不再有 ThreadLocal 对象的引用,ThreadLocalMap 中的 key 还强引着它,导致 ThreadLocal 对象本身也无法被回收。
内存泄漏怎么产生: key 是弱引用解决了 ThreadLocal 对象的回收问题,但引出了新问题。当 ThreadLocal 对象被 GC 回收后,key 变成了 null,但 value 仍然是强引用,无法被回收。而这个 value 也永远无法被访问到了(因为 key 为 null,找不到入口),它就这样一直占据着内存。如果线程是线程池中的线程(长期存活),这个 value 会一直存在,越积越多,最终导致 OOM。
怎么避免: 使用完 ThreadLocal 后,必须手动调用 remove() 方法。remove() 会显式地在 ThreadLocalMap 中清除以当前 ThreadLocal 为 key 的 Entry,彻底断开对 value 的引用,让 GC 能回收。此外,ThreadLocal 本身在 get()、set()、remove() 操作时也会触发 expungeStaleEntry() 来惰性清理 key 为 null 的过期 Entry,但这种清理并不保证及时覆盖所有泄漏点,因此手动调用 remove() 仍然是最可靠的方案。
追问储备:
- 线程池场景下 ThreadLocal 特别容易出问题吗? 是的。线程池复用了线程,线程不会死,ThreadLocalMap 也不会清空。一个任务没调用 remove(),下一个任务可能读到上一个任务的脏数据,而且内存泄漏会持续累积。
- ThreadLocal 还在哪些框架中大量使用? Spring 的事务管理用它来保存当前线程的数据库连接 Connection,保证一个事务中的所有操作用的是同一个连接。
Q31:线程池场景下 ThreadLocal 有什么额外风险?
面试标准回答:
在线程池场景下使用 ThreadLocal,有两个核心风险:数据污染 和内存泄漏加剧。
数据污染: 线程池的核心特点是线程复用。线程在完成任务后不会被销毁,而是等待执行下一个任务。如果前一个任务在使用 ThreadLocal 存储了数据,但没有调用 remove() 清理,那么这个数据会一直留在线程的 ThreadLocalMap 中。下一个任务被同一个线程处理时,可能意外地读取到上一个任务遗留的"脏数据",造成业务逻辑错误。例如,第一个请求把用户信息存到 ThreadLocal 里,忘记清理,第二个请求被同一个线程处理时,发现 ThreadLocal 里已经有用户信息了,可能错误地认为是之前那个用户在操作。
内存泄漏加剧: 普通的 Web 应用,每个请求使用一个线程,请求结束线程返回池中,虽然 ThreadLocal 没清,但至少请求之间有时间间隔。而线程池中线程是常驻的,一辈子不销毁。如果每个任务都往 ThreadLocal 里存一点东西又忘了清,这些 value 就会永远积攒在线程中,不停累积直到堆内存被挤爆。而因为线程一直活跃,ThreadLocal 对象(key)也不会被 GC 回收,连 key 都泄露了。
正确做法: 使用 ThreadLocal 时,必须将 get()/set() 和 remove() 放在 try-finally 块中,保证无论任务正常处理还是抛出异常,remove() 都能被执行。
| |
追问储备:
- 框架层面有什么办法避免吗? 自定义线程池可以覆写
afterExecute()方法,在任务结束后统一清理 ThreadLocal。阿里的TransmittableThreadLocal也提供了更好的线程池上下文传递方案。 - InheritableThreadLocal 有什么特点? 子线程可以继承父线程的 ThreadLocal 值,但在线程池场景下,线程都是提前创建好的,不存在父子关系,所以 InheritableThreadLocal 在线程池中也无效。
Q32:CountDownLatch 和 CyclicBarrier 的区别?
面试标准回答:
CountDownLatch 和 CyclicBarrier 都是 JUC 并发包中的同步辅助类,但它们的设计目标和用法截然不同。
CountDownLatch(倒计数门闩): 它是一个一次性的同步工具。一般一个或多个线程等待另外 N 个线程完成任务。主线程调用 await() 阻塞等待,其他工作线程完成任务后调用 countDown() 将计数器减一,当计数器归零时,主线程被唤醒继续执行。计数器不可重置,用完一次就废了。典型应用场景:主线程等待所有子线程初始化完毕,或者等待多个外部依赖都就绪后启动服务。
CyclicBarrier(循环栅栏): 它是可重复使用的同步点。让一组线程互相等待,直到所有线程都到达这个屏障点,然后一起冲破屏障继续执行。每个线程调用 await() 后阻塞,当最后一个线程也到达时,所有线程被同时唤醒。计数器可以自动重置(即循环使用)。典型应用场景:多线程计算大数据,分片并行处理,等待所有分片都处理完,汇总中间结果,然后进入下一轮计算。
核心区别:
- CountDownLatch 是一个线程等其他多个线程,CyclicBarrier 是多个线程互等。
- CountDownLatch 是一次性的,计数归零后不能重置;CyclicBarrier 是可循环的,所有线程到达后计数器自动重置。
- CountDownLatch 调用
countDown()后线程可以继续做其他事;CyclicBarrier 调用await()后线程必须等待直到所有线程到齐。
追问储备:
- CyclicBarrier 的构造器能传一个 Runnable 参数,有什么用? 这个 Runnable 会在所有线程到达屏障后、被唤醒之前执行,可以先做汇总工作,典型用在线程分片计算后先合并结果再让各线程继续。
- 在实际中用过它们吗? 我的项目里用 CountDownLatch 做过应用启动时的预热——等所有缓存加载完之后才对外开放接口。
Q33:创建线程有哪几种方式?Callable 和 Runnable 的区别?
面试标准回答:
Java 中创建线程主要有四种方式,但本质上都是依赖 Thread 类的 start() 方法来启动的。
1. 继承 Thread 类: 覆盖 run() 方法,创建对象直接 start()。缺点是因为 Java 单继承,无法再继承其他类。
2. 实现 Runnable 接口: 将实现类对象传入 Thread 构造器。优点是任务和线程分离,可以灵活复用任务,也可以继承其他类。Runnable 的 run() 方法没有返回值,也不能抛出受检异常。
3. 实现 Callable 接口 + FutureTask: Callable 的 call() 方法有返回值,也可以抛出受检异常。要配合 FutureTask 使用——FutureTask 实现了 RunnableFuture 接口,既可以作为任务被线程执行,又可以通过 get() 方法获取返回结果。get() 会阻塞直到任务执行完毕。
4. 线程池(推荐): 通过 Executors 或直接使用 ThreadPoolExecutor。这是实际生产中最推荐的方式,原因是不需要自己管理线程的生命周期,可以复用线程,统一配置参数和监控。
Callable 和 Runnable 的区别:
Runnable.run()无返回值,不能抛受检异常;Callable.call()有返回值(泛型),可以抛受检异常。- Runnable 可以直接被 Thread 执行;Callable 需要经过 FutureTask 包装后才能被 Thread 执行。
追问储备:
- 为什么线程池最推荐? 减少线程创建销毁的开销、统一管理和监控、防止资源耗尽(比如每个线程占 1MB 栈空间,无限创建会 OOM)。
- FutureTask 的 get() 方法为什么阻塞? 内部通过 AQS 的同步机制实现,任务没完成时调用 get() 会进入等待队列,任务完成后通过 Unsafe 唤醒等待线程。
Q34:String、StringBuilder、StringBuffer 区别?String 为什么不可变?
面试标准回答:
三者的核心区别在于可变性和线程安全性。
String:不可变。 它的底层在 JDK 9 及之后是 byte[](之前是 char[]),并且这个数组被 private final 修饰。每次对 String 的修改(拼接、截取等)都会创建一个新的 String 对象,原对象不变。因为不可变,String 天然是线程安全的,多个线程读同一个 String 没有安全问题。
StringBuilder:可变,非线程安全。 它的底层数组是可变的 byte[],每次拼接直接在这个数组后面追加,不会创建新对象。没有加同步锁,性能最好。适合在单线程环境下拼接字符串。
StringBuffer:可变,线程安全。 和 StringBuilder 几乎一样,但它的主要方法(如 append())都用 synchronized 修饰,保证了多线程环境下的安全。由于锁的开销,性能比 StringBuilder 稍差。适合多线程环境。
String 为什么设计成不可变:
- 字符串常量池: 不可变性是实现字符串常量池的前提。两个变量引用相同的 String 字面量时,它们指向常量池中的同一个对象。如果 String 可变,一个引用改了内容,另一个引用看到的也变了,后果不堪设想。
- 线程安全: 不可变意味着多线程下不用加锁就能安全使用。
- Hash 缓存: String 经常作为 HashMap 的 key。不可变的 String 可以在第一次计算 hashCode 后缓存起来,后续直接用,提升性能。如果 String 可变,每次都要重新计算 hashCode。
- 安全性: String 广泛用于类名、文件路径、数据库连接等。如果 String 可变,恶意代码可能在这些地方篡改内容,产生安全漏洞。
追问储备:
- JDK 9 为什么把 char[] 换成 byte[]? 为了节省内存。大部分字符在 Latin-1 编码下只需要一个字节,用 byte[] 加编码标记比 char[] 省了一半空间。
String s = "a" + "b"创建了几个对象? 编译器会优化为"ab",只创建 1 个对象,放在常量池。
Q35:== 和 equals 区别?为什么重写 equals 必须重写 hashCode?
面试标准回答:
== 对比的是引用(内存地址)。 对于基本类型,== 比的是值;对于引用类型,== 比的是两个引用是否指向堆中的同一个对象。a == b 意味着 a 和 b 在内存中是同一个对象。
equals 对比的是内容。 Object 类中 equals() 的默认实现就是 ==(直接比较地址)。但 String、Integer 等包装类都重写了 equals(),改为比较实际内容。比如 "abc".equals("abc") 返回 true,即使这是两个不同的对象。
为什么重写 equals 必须重写 hashCode: 这是 Java 规范强制要求的"等价契约"。原因在于依赖哈希值的数据结构——HashMap、HashSet、Hashtable 等。
这些容器的查找逻辑是:先用 hashCode() 定位到桶(bucket),再用 equals() 在桶内比较真正的 key。如果两个对象 a.equals(b) 返回 true(说明它们逻辑相等),但 a.hashCode() != b.hashCode()(哈希值不同),那么它们会被 HashMap 分配到不同桶里,导致 HashMap 中可能存在两个"相同"的 key,完全破坏了容器的语义。
一个经典的例子是重写了 equals 的 Student 类。如果只重写 equals 不重写 hashCode,同一个学号的学生可能被 HashMap 视为不同的 key,造成数据混乱。
追问储备:
- hashCode 的约定是什么? 如果 a.equals(b) 为 true,则 a.hashCode() 必须等于 b.hashCode()。反过来不强制(不同对象可以有相同哈希值,即哈希碰撞)。
- 重写 equals 时要注意什么? 自反性、对称性、传递性、一致性、非空性。IDEA 可以自动生成规范的 equals 和 hashCode 方法。
Q36:Exception 和 Error 区别?RuntimeException 和 CheckedException 区别?
面试标准回答:
Java 的异常体系都继承自 Throwable,Throwable 下面有两个分支:Error 和 Exception。
Error: 是程序无法处理的严重问题,通常和 JVM 相关。比如 OutOfMemoryError(堆内存溢出)、StackOverflowError(栈溢出)、NoClassDefFoundError(类找不到)。这些错误发生的条件往往是不可恢复的,JVM 本身运行出现了不可逆转的故障。应用程序不应该也不建议在代码中 try-catch Error,因为抓到了也基本做不了什么有用的处理,让它兜上去即可。
Exception: 是程序可以处理的异常。分为 RuntimeException 和 CheckedException。
RuntimeException(运行时异常): 也叫非受检异常。特点是编译器不强制要求进行捕获或声明抛出。通常由程序逻辑 Bug 引起,比如 NullPointerException(空指针)、ArrayIndexOutOfBoundsException(数组越界)、ArithmeticException(除零)。这些异常理论上可以通过良好的编码来预防,所以编译器不强求捕获。
CheckedException(受检异常): 编译器强制要求处理。要么用 try-catch 捕获,要么在方法签名上通过 throws 声明抛出。比如 IOException、SQLException、FileNotFoundException。这类异常通常由外部因素导致(文件不存在、网络中断),不是程序 Bug,而且往往可以恢复,所以编译器要求必须显式处理。
追问储备:
- @Transactional 对两者有区别吗? 有。@Transactional 默认只回滚 RuntimeException 和 Error,对 CheckedException 默认不回滚。需要回滚 CheckedException 时必须配置
rollbackFor = Exception.class。 - NoClassDefFoundError 和 ClassNotFoundException 有什么区别? 前者是 Error,发生在类以前存在但在运行时找不到了;后者是 Exception,是显式通过反射或 Class.forName() 加载类时发现找不到。
Q37:ArrayList 和 LinkedList 区别?各自时间复杂度?
面试标准回答:
ArrayList 和 LinkedList 都实现了 List 接口,但底层数据结构完全不同,因此性能特征也不同。
底层实现:
- ArrayList: 底层是动态数组(Object[])。内存中一块连续的空间,元素依次排列。
- LinkedList: 底层是双向链表。每个节点(Node)存储了当前元素、前驱指针和后继指针,节点在内存中零散分布。
查询操作:
- ArrayList 支持随机访问,因为数组是连续的,给定下标可以一步计算出内存偏移量,直接拿到元素,时间复杂度 O(1)。
- LinkedList 必须从头节点或尾节点开始,顺着指针一个节点一个节点地找,时间复杂度 O(n)。即使是
get(0)和get(size-1),LinkedList 内部会优化为直接取头尾节点,O(1);但中间的任意位置就是 O(n)。
插入/删除操作:
- ArrayList 在末尾插入 O(1)(不考虑扩容)。在中间插入或删除需要把后续所有元素整体平移一位,时间复杂度 O(n)。
- LinkedList 在头尾插入 O(1),因为直接修改指针即可。在中间插入或删除,需要先找到目标位置(靠遍历,O(n)),然后修改前后节点的指针,修改指针本身是 O(1),但定位需要 O(n)。
空间开销:
- ArrayList 只在数组末尾预留空间,空间利用率高。
- LinkedList 每个节点除了存元素还要存两个指针(prev、next),额外内存开销大。
适用场景总结:
- 场景以读为主、随机访问多 → 用 ArrayList。
- 场景以头尾增删为主(比如队列、栈)→ 用 LinkedList 或用 ArrayDeque 更佳。
追问储备:
- ArrayList 扩容机制? 默认容量 10。满时扩容为原容量的 1.5 倍,通过
Arrays.copyOf()复制到新数组。 - 为什么说 LinkedList 中间插入也不是真正的 O(1)? 因为还需要遍历定位到目标位置,遍历是 O(n),所以总体还是 O(n)。
Q38:HashMap 和 Hashtable 的区别?
面试标准回答:
HashMap 和 Hashtable 都是基于哈希表实现 Map 接口,但 Hashtable 是 JDK 1.0 的遗留类,现代开发已基本被淘汰。
线程安全性:
- HashMap 非线程安全,多线程下会出现并发 put 覆盖、size 计数不准、扩容成环(1.7)等问题。
- Hashtable 线程安全,几乎所有方法都用
synchronized修饰,锁住整个表,同一时刻只有一个线程能访问。这保证了安全,但并发性能极差。
null 键/值的支持:
- HashMap 允许一个 null 键、多个 null 值。null 键被放在哈希值为 0 的桶里。
- Hashtable 不允许 null 键和 null 值,会抛出
NullPointerException。因为 Hashtable 继承了早期的设计哲学,认为 null 表示"未找到"的意思,所以不允许存 null。
初始容量和扩容:
- HashMap 默认容量 16,扩容为原来的两倍。
- Hashtable 默认容量 11,扩容为原来的两倍加一(2n+1),为了在取模时更均匀分布(因为 Hashtable 用取模而非位运算)。
迭代器:
- HashMap 的迭代器是 fail-fast 的——在遍历过程中如果结构被修改了(非迭代器自身操作),会立即抛出
ConcurrentModificationException。 - Hashtable 的迭代器(Enumeration 和 Iterator)同样是 fail-fast 的,内部也维护了 modCount 的检查。但实际并发场景中,因为 Hashtable 的所有修改方法都加了锁,同一时刻只有一个线程能修改,所以 fail-fast 机制很少被触发。这与 ConcurrentHashMap 的 fail-safe(弱一致性)不同,ConcurrentHashMap 的迭代器在遍历时允许并发修改且不抛异常。
现代替代: 多线程下不要用 Hashtable,改用 ConcurrentHashMap(锁粒度更细,性能更高)。单线程下用 HashMap。
追问储备:
- 为什么 Hashtable 默认容量是 11? 因为 Hashtable 用
%取模定位桶,使用质数(素数)能让哈希分布更均匀,减少碰撞。而 HashMap 用 2 的幂次加位运算,不需要质数。 - Hashtable 和 ConcurrentHashMap 在 Iterator 上的区别? Hashtable 的遍历是直接锁整个表,迭代器是 fail-fast 的;ConcurrentHashMap 是弱一致的,不锁表,遍历时允许并发修改,是 fail-safe 的,也不抛异常。
Q39:Java 四种引用类型?(强/软/弱/虚)
面试标准回答:
Java 从 JDK 1.2 开始,将对象的引用分为了四个级别,由高到低依次是:强引用、软引用、弱引用、虚引用。引用级别越高,GC 越难回收;级别越低,GC 越容易回收。
1. 强引用(Strong Reference): 最常见。Object obj = new Object() 就是强引用。只要强引用存在,GC 永远不会回收这个对象。即使内存不够抛出 OOM,也不会回收强引用指向的对象。这是保证程序正常运行的基石。
2. 软引用(Soft Reference): 通过 SoftReference 类创建。软引用指向的对象,在系统内存足够时不会被回收,在内存不足(即将 OOM)时会被 GC 回收。常用于实现内存敏感的缓存。比如加载图片到内存,内存够就留着快速显示,不够就清掉重新加载。
3. 弱引用(Weak Reference): 通过 WeakReference 类创建。弱引用指向的对象,只要发生 GC,无论内存是否足够,都会被立刻回收。典型应用是 ThreadLocal 中的 key——ThreadLocalMap 的 Entry 中,key 对 ThreadLocal 对象是弱引用。这样当外部不再使用 ThreadLocal 对象时,它就能被 GC 回收,key 变 null。还有 WeakHashMap 也用弱引用,当 key 不再被强引用时,对应条目会在下次 GC 时被自动移除。
4. 虚引用(Phantom Reference): 通过 PhantomReference 类创建。虚引用是最弱的引用。它不能单独使用,必须和 ReferenceQueue 配合。一个对象被虚引用关联时,它的存在不会影响其生命周期,也无法通过虚引用获取对象实例(get() 方法总返回 null)。虚引用的唯一作用是在对象被 GC 回收时,会收到一个系统通知。主要用于管理**直接内存(堆外内存)**的释放,比如 NIO 的 DirectByteBuffer,通过 Cleaner(一种虚引用)在对象被回收时清理堆外内存。
追问储备:
- 软引用和弱引用回收时机有何不同? 软引用只在即将 OOM 时回收,弱引用见 GC 就回收。所以软引用适合缓存,弱引用适合清理标记。
- 虚引用的
get()为什么总是 null? 虚引用本就不是用来访问对象的,它是用来跟踪对象是否被回收的哨兵。如果允许 get(),可能会让对象重新变成强可达,违反设计初衷。
Q40:反射是什么?优缺点?用在哪些场景?
面试标准回答:
反射(Reflection) 是 Java 提供的一种在运行时动态获取类信息并操作对象的机制。通过反射,可以在程序运行时获取任意一个类的所有属性和方法,创建对象实例,调用私有方法,修改私有字段——即使这些信息在编译时是不可知的。
优点:
- 框架的基石: Spring 的 IoC 容器通过反射读取 XML 配置或注解,创建 Bean 实例并注入依赖。动态代理(JDK Proxy)也是通过反射实现。
- 极大的灵活性: 可以实现运行时的动态加载和动态代理,打破编译时的限制。
- 开发和调试便利: IDE 的智能提示、单元测试框架(JUnit)都依赖反射机制。
缺点:
- 性能开销大: 反射调用比直接调用慢很多,因为它绕过了编译器的类型检查和优化,需要额外的安全检查、方法查找等步骤。在高频调用场景下影响明显。
- 破坏封装性: 可以调用私有方法、访问私有字段,破坏了面向对象的封装原则。
- 代码可读性变差: 大量反射代码难阅读、难维护。
- 编译期安全丢失: 反射绕过编译期类型检查,错误推迟到运行时才暴露。
典型应用场景:
- Spring 的 Bean 容器:通过反射读取类的注解并创建实例。
- JDK 动态代理:
InvocationHandler.invoke()通过反射调用目标方法。 - 单元测试框架:JUnit 通过反射调用被
@Test标注的方法。 - 序列化框架:Jackson、Gson 通过反射读取对象字段并序列化。
追问储备:
- 如何优化反射性能?
setAccessible(true)可跳过安全检查,显著提升速度。另外,将反射获取的Method对象缓存起来重复使用,避免反复查找。 - 反射能拿到泛型信息吗? 部分可以。泛型在编译后被擦除,但字段和方法的泛型签名会被保留在字节码中,可以通过
Field.getGenericType()等方法获取。
Q41:JVM 内存区域有哪些?各自存什么?哪些线程私有、哪些共享?
面试标准回答:
JVM 内存区域分为五个部分,按线程共享和私有可以分为两类。
线程共享的有两个:
第一个是方法区(元空间)。它存储类的元信息(类名、方法描述、字段描述)、方法的字节码、运行时常量池以及静态变量。JDK 8 之后方法区的实现从永久代改为了元空间,使用的是本地内存而非堆内存,因此不再容易出现 java.lang.OutOfMemoryError: PermGen space。
第二个是堆。堆是 JVM 中最大的一块内存区域,存放所有的对象实例和数组。堆分为新生代和老年代,新生代又分为 Eden 区和两个 Survivor 区(S0、S1)。堆是垃圾回收的主要区域。
线程私有的有三个:
第一个是虚拟机栈。每个方法调用时会创建一个栈帧压入栈中。栈帧包含局部变量表(存基本数据类型和对象引用)、操作数栈(运算过程中的临时数据)、动态链接(方法调用时的符号引用转换)和方法出口(方法调用的返回位置)。栈的大小可以固定,也可以动态扩展。
第二个是本地方法栈。和虚拟机栈类似,区别是它服务于 native 方法(Java 调用的 C/C++ 代码)。
第三个是程序计数器。记录当前线程执行到的字节码行号。它是 JVM 中唯一不会发生 OOM 的区域,因为只存一个行号地址,占用空间极小。程序计数器的作用是保证 CPU 在多线程切换时,每个线程能恢复到正确的执行位置。
追问储备:
- 堆和方法区的区别? 堆存对象实例(活的),方法区存类的描述信息(模板)。一个类可以创建多个对象在堆中,但类的定义只在方法区有一份。
- 为什么元空间替代永久代? 永久代有固定大小上限,容易 OOM。元空间使用本地内存,只受系统实际可用内存限制,而且 GC 效率更高。
Q42:栈和堆的区别?
面试标准回答:
栈和堆是 JVM 中两块最核心的内存区域,它们的区别体现在存储内容、管理方式、生命周期和内存特点等方方面面。
1. 存储内容不同。 堆存储对象实例和数组。栈(虚拟机栈)存储局部变量表、方法调用链——每个方法调用对应一个栈帧,栈帧里存着局部变量(基本类型和对象引用)、操作数栈、方法出口等信息。
2. 线程归属不同。 堆是线程共享的,所有线程都能访问堆中的对象。栈是线程私有的,每个线程有自己的栈,互不干扰。
3. 生命周期不同。 堆中的对象通过 GC 管理,生命周期较长,可能在很长一段时间后才会被回收。栈中的数据以栈帧为单位,方法调用时压入栈,方法结束时弹出栈,生命周期很短且是确定的。
4. 内存特点不同。 堆的空间大(通过 -Xmx 控制,可达 GB 级别),但分配和回收速度相对慢(需要 GC 参与)。栈的空间较小(默认 1MB 左右),但分配和回收非常快,方法调用完栈帧就清了,是先进后出的顺序。
5. 错误类型不同。 堆溢出抛出 OutOfMemoryError: Java heap space。栈溢出(递归太深或线程太多)抛出 StackOverflowError 或 OutOfMemoryError: unable to create native thread。
6. 内存是否连续。 堆在逻辑上连续但物理上可以不连续(由操作系统分配)。栈是连续的内存空间,通过栈顶指针的移动来压栈和弹栈。
追问储备:
- 对象引用存在哪? 对象引用(如
Object obj中的obj)存在栈帧的局部变量表中,而真正的对象实例存在堆中。 - 为什么栈不容易出现内存碎片? 因为栈是严格后进先出的,分配和释放都是移动栈顶指针,内存始终连续,不会产生碎片。
Q43:垃圾回收怎么判断对象可回收?引用计数法和可达性分析法区别?
面试标准回答:
判断对象是否可回收,有两种主流算法:引用计数法 和可达性分析法。Java 采用的是可达性分析法。
引用计数法: 给每个对象分配一个引用计数器。当有一个地方引用这个对象时,计数器加一;引用失效时,计数器减一。当计数器归零时,对象就可以被回收。优点是实现简单,判断效率高(实时性)。致命缺陷是无法解决循环引用——例如 A 对象引用 B,B 对象引用 A,两者计数器永远为一,即使它们已经不再被外部使用,也永远无法被回收,造成内存泄漏。历史上 Python 的早期版本使用引用计数,但它需要配合专门的循环检测器来处理循环引用问题。
可达性分析法(Java 采用): 从一组被称为 GC Roots 的根节点出发,沿着引用链向下搜索。搜索走过的路径称为引用链。如果某个对象到 GC Roots 之间没有任何引用链相连(即从 GC Roots 到这个对象不可达),则判定这个对象是可回收的。反之,通过引用链可以从 GC Roots 到达的对象,就是存活对象。
可达性分析法能完美解决循环引用问题——即使 A 和 B 互相引用,只要它们都到不了 GC Roots,就会被判定为垃圾。
追问储备:
- 引用计数法在 Java 中有使用吗? 极少数场景,比如 COM 对象和早期的 JNI 引用,但没有用于主流的 GC 算法。
- 剪不断理还乱的引用链怎么处理? 可达性分析会从 GC Roots 开始做一次全遍历,标记所有存活对象,没被标记的就是垃圾,无论它们之间怎么互相引用都不影响判断。
Q44:GC Roots 有哪些?
面试标准回答:
GC Roots 是可达性分析法的起点,是一组必须存活的对象。从这些根对象出发,能找到的所有对象都是存活的,找不到的就是可回收的垃圾。
在 Java 中,GC Roots 主要包括以下四类:
1. 虚拟机栈(栈帧中的局部变量表)中引用的对象。 也就是当前正在执行的方法里的局部变量所引用的对象。比如方法里 User user = new User(),这个 user 指向的对象就是存活的。方法执行完,栈帧出栈,这个引用消失,对象才可能被回收。
2. 方法区中类的静态属性引用的对象。 即 static 变量引用的对象。比如全局的单例对象,它一直被类引用着,永远是存活的。
3. 方法区中常量引用的对象。 即字符串常量池中的引用。比如 String s = "abc" 这个 "abc" 对象在常量池中,作为 GC Root。
4. 本地方法栈中 JNI(Native 方法)引用的对象。 比如 Java 调用 C 代码,C 代码里创建并引用的 Java 对象。
5. 所有被同步锁(synchronized)持有的对象。 正在被当作锁使用的对象,不能被回收。
6. JVM 内部的引用。 比如基本数据类型的 Class 对象、常驻的异常对象(NullPointerException)、系统类加载器等。
追问储备:
- 为什么线程对象算 GC Root? 因为正在运行的线程本身必须存活,线程的栈帧和局部变量表也是 GC Root 的来源。
- GC Roots 越多,GC 越慢吗? 是的,可达性分析需要从所有 GC Roots 出发遍历对象图,GC Roots 越多,初始标记阶段就越耗时。
Q45:四种垃圾回收算法?标记清除、标记整理、标记复制各自优缺点?
面试标准回答:
Java 的垃圾回收主要有四种算法:标记清除、标记整理、标记复制、分代收集。其中前三种是基础算法,第四种是组合策略。
1. 标记清除(Mark-Sweep):
- 原理:首先标记出所有需要回收的对象(或存活对象),标记完成后统一回收所有未被标记的对象。
- 优点:实现简单,不需要移动对象。
- 缺点:产生大量不连续的内存碎片。碎片太多会导致后续需要分配大对象时找不到足够的连续空间,提前触发 GC。
2. 标记整理(Mark-Compact):
- 原理:先标记存活对象,然后把所有存活对象向内存的一端移动,最后直接清理掉边界以外的所有内存空间。
- 优点:不会产生内存碎片,清理后内存是连续的。
- 缺点:对象移动需要消耗性能(STW),而且需要更新所有指向被移动对象的引用。
3. 标记复制(Mark-Copy):
- 原理:将可用内存划分为大小相等的两块,每次只使用其中一块。当这块用完了,就把存活对象复制到另一块上去,然后一次清掉刚才那块的全部空间。
- 优点:速度快(复制少量对象 + 整块清空),不会产生碎片。
- 缺点:内存利用率只有一半。如果存活对象很多,复制的开销也会变大。
4. 分代收集(Generational Collection):
- 原理:根据对象的存活周期,把堆分为新生代和老年代,分别采用最适合的回收算法。
- 新生代:每次回收都有大量对象死去,只有少量存活。用复制算法,因为复制少量对象的成本很划算。
- 老年代:对象存活率高,不适合复制。用标记清除或者标记整理。
追问储备:
- 为什么新生代用复制算法只需要两块 Survivor 而不是一半一半? 新生代 90% 的对象都熬不过第一次 GC,复制时只需要搬移极少数存活对象。用 Eden + 两块 Survivor(8:1:1)更高效,只浪费 10% 的空间。
- 老年代清理时是混合使用标记清除和标记整理吗? 是的。CMS 老年代平时用标记清除,碎片多了再用标记整理做一次 Full GC。
Q46:为什么新生代用复制算法、老年代用标记清除/整理?
面试标准回答:
新生代和老年代采用不同的垃圾回收算法,是根据对象的存活周期特征决定的。
新生代适合复制算法: 因为大部分对象"朝生夕死"。根据 IBM 的研究,98% 的对象在创建后很快就变成垃圾。用复制算法时,只需要把极少数存活的对象复制到 Survivor 区,剩下的大片空间直接清空,成本极低。这也就是为什么新生代采用 Eden + S0 + S1(默认比例 8:1:1)的设计——只浪费一块 Survivor(10% 空间)来接收复制过来的存活对象。
老年代适合标记清除/标记整理: 老年代的对象都是经过多次 GC 还存活下来的,存活率很高。如果用复制算法,需要复制大量对象,而且还得留 50% 空间当备用区,内存浪费严重,性能也差。所以老年代采用标记清除(平时)或标记整理(碎片严重时做 Full GC)。标记清除平时不需要移动对象,速度快;标记整理在需要时消除碎片,为之后的大对象分配腾出连续空间。
追问储备:
- 如果新生代对象很大,直接进老年代吗? 是的。大对象(如长数组、大字符串)如果超过
-XX:PretenureSizeThreshold参数设置的值,会直接分配到老年代,避免在新生代来回复制。 - 为什么复制算法只需要移动少量对象? 因为新生代存活下来的对象在每次 Minor GC 中只占总量的一小部分,所以复制成本低。
Q47:Minor GC 和 Full GC 的区别?什么时候触发 Full GC?
面试标准回答:
Minor GC 和 Full GC 是 GC 的两种主要类型,它们的触发条件、回收范围和影响完全不同。
Minor GC(新生代 GC):
- 回收范围:只针对新生代(Eden + Survivor)。
- 触发条件:Eden 区满了。
- 特点:频繁,速度较快。因为新生代空间小,且大部分对象都是垃圾,回收效率高。Minor GC 会引发 STW(Stop The World),但时间通常很短,在几十毫秒以内。
- 操作:将 Eden 和 From Survivor 中的存活对象复制到 To Survivor 中,年龄加一,达到阈值(默认 15)的对象晋升到老年代。
Full GC(全局 GC):
- 回收范围:整个堆(新生代 + 老年代)+ 方法区(元空间)。
- 触发条件(主要有五种):
- 老年代空间不足。 当要晋升的对象大小超过老年代剩余空间。
- 元空间(方法区)不足。 加载的类太多,方法区满了。
- 显式调用
System.gc()。 告诉 JVM 建议执行 Full GC,但不保证一定执行。 - CMS 的并发模式失败(Concurrent Mode Failure)。 CMS 并发清理时老年代满得太快,被迫退化为串行 Full GC。
- Survivor 空间不够存放 Minor GC 后存活的对象,且这些对象也无法被老年代接纳,会触发 Full GC。
- 特点:耗时长(几秒到几十秒),会导致长时间的 STW,对应用响应影响巨大。生产环境应通过调优尽量避免频繁 Full GC。
追问储备:
- 如何调优减少 Full GC? 增大老年代空间、合理设置新生代和老年代比例、及时清理方法区、避免频繁
System.gc()、使用 G1 等可预测停顿的收集器。 - Minor GC 的 STW 为什么相对短? 因为它只回收新生代(小空间),且主要是复制少量存活对象,工作量大半是死亡对象直接清空。
Q48:CMS 四个阶段?哪个阶段 STW?碎片问题怎么产生?
面试标准回答:
CMS(Concurrent Mark Sweep)是一款以获取最短回收停顿时间为目标的老年代垃圾收集器。它的回收过程分为四个阶段:
1. 初始标记(Initial Mark)—— STW。 只标记 GC Roots 能直接关联到的对象,不向下深度遍历。因为只标记直接引用,所以速度非常快,STW 时间极短。
2. 并发标记(Concurrent Mark)—— 无 STW。 GC 线程和用户线程同时运行,从 GC Roots 直接关联的对象开始,向下进行完整的可达性分析,标记所有存活对象。这个阶段耗时最长,但因为是并发的,不会让应用停顿。
3. 重新标记(Remark)—— STW。 修正并发标记期间,因为用户线程继续运行而导致的标记变动(有些对象可能新产生或被垃圾化)。这个阶段用 STW 保证准确性,但比初始标记稍长。
4. 并发清除(Concurrent Sweep)—— 无 STW。 和用户线程同时运行,清理掉被标记为垃圾的对象。这个阶段用的是标记清除算法,所以会产生内存碎片。
碎片问题的产生: 第四阶段并发清除使用的是标记清除算法,只把垃圾对象占的内存空间标记为空闲,但不会移动存活对象来整理空间。久而久之,老年代会布满不连续的内存碎片。当一个大对象需要分配时,虽然总空闲空间足够,但没有一段连续的空间容纳它,就会触发 Full GC(通常退化为 Serial Old 用标记整理清理碎片),导致不可预料的长时间停顿。
CMS 的碎片应对措施: CMS 提供了参数 -XX:+UseCMSCompactAtFullCollection(在不可避免地发生 Full GC 时进行碎片压缩,JDK 9 之后默认开启)和 -XX:CMSFullGCsBeforeCompaction(控制经历多少次 Full GC 后进行一次压缩),可以在一定程度缓解碎片问题,但无法从根源上解决。
追问储备:
- CMS 现在已经不推荐使用,为什么还要学它? CMS 的思想——染指 STW 分段、并发回收——被 G1 和 ZGC 继承并发扬。理解 CMS 有助于理解现代收集器的设计动机。
- CMS 的并发模式失败是什么? 当并发清理速度赶不上对象分配速度,老年代在回收完成前就满了,CMS 被迫退化为串行 Full GC。
Q49:G1 的核心改进?Region 和回收收益列表是什么?怎么做到可控停顿?
面试标准回答:
G1(Garbage First)是 JDK 9 之后默认的垃圾收集器,它的设计目标是在可控的停顿时间内,尽可能提高吞吐量。相比 CMS,G1 做了三个核心改进:
第一,用 Region 替代物理分代。 G1 不再把堆硬性划分成连续的新生代和老年代,而是把堆划分为若干个大小相等的 Region(默认 2048 个,每个 1~32MB)。每个 Region 既可以是 Eden 区,也可以是 Survivor 区,还可以是 Old 区,甚至可以专门用来存大对象(Humongous Region)。这种灵活性让内存分配和回收更加精细。
第二,回收收益列表(Collection Set)。 G1 维护了一个优先级队列,记录每个 Region 的回收价值——回收这个 Region 能释放多少垃圾、需要多少 STW 时间。G1 根据用户设定的期望停顿时间(-XX:MaxGCPauseMillis,比如 200ms),从这个队列里挑选回收收益最高的一批 Region 进行回收,而不是一次回收所有 Region。这就是"Garbage First"的含义——优先回收垃圾最多的 Region。
第三,基于复制算法,无碎片。 G1 回收 Region 时,把里面的存活对象复制到空闲 Region,然后整个 Region 一次性清空。这类似于复制算法,既回收了垃圾,又不会产生碎片。同时,因为每次只回收少量 Region,复制开销可控,停顿时间也很短。
可控停顿的秘诀: 停顿时间可预测正是因为 G1 通过收益列表和期望停顿时间,每次只回收挑选出来的那部分 Region。如果期望停顿设得小,G1 就少回收几个 Region;如果可以容忍大停顿,G1 就多回收几个 Region。把不可预料的长时间停顿,变成了可预料的、可控制的短停顿。
追问储备:
- G1 还保留了分代的概念吗? 逻辑上保留了。Eden 区、Survivor 区、Old 区仍然在 Region 中标记,但它们在物理上不再连续,是一组逻辑区域(Eden Region Set、Survivor Region Set 等)。
- G1 的 Mixed GC 是什么? 回收年轻代 + 部分老年代 Region 的 GC,根据收益列表挑选的老年代 Region 一起回收。这是 G1 的核心 GC 类型。
Q50:什么情况会 OOM?(堆、栈、方法区各举一例)
面试标准回答:
OOM(OutOfMemoryError)是 Java 程序在内存不足时抛出的错误。不同内存区域抛出的 OOM 信息不同,排查方向也不同。
1. 堆溢出(最常见):
- 错误信息:
java.lang.OutOfMemoryError: Java heap space - 原因:创建了过多对象,超过了堆的最大容量。比如一次从数据库中查出几百万条数据放入 List 中没有分页处理,或者死循环里往集合中不断 add 对象。还可能是内存泄漏——被无用的对象持有引用导致 GC 无法回收,比如使用了
static的集合不断累积元素。 - 解决:增大堆大小(
-Xmx),检查代码是否有内存泄漏,或者使用分页、流式处理来减少单次内存占用。
2. 栈溢出:
- 错误信息:
StackOverflowError(通常不算 OOM 但也是内存溢出) 或OutOfMemoryError: unable to create native thread - 原因:递归调用层级过深且没有终止条件,栈帧不断压入导致栈空间耗完。或者创建了过多线程,每个线程栈占用内存(默认 1MB),把本机内存耗光了。
- 解决:检查递归终止条件,调整栈大小(
-Xss),减少线程数量,使用线程池。
3. 方法区溢出:
- 错误信息:JDK 8 以前
OutOfMemoryError: PermGen space;JDK 8 以后OutOfMemoryError: Metaspace - 原因:加载了过多的类。典型场景是用 CGLIB 或动态代理不加节制地生成代理类,或者大量 JSP 文件被动态编译成类。
- 解决:增大元空间大小(
-XX:MaxMetaspaceSize),合理使用动态代理,检查是否有类加载器泄漏。
追问储备:
- Direct Memory 也会 OOM 吗? 会,堆外直接内存(NIO 的 DirectByteBuffer)满了会抛出
OutOfMemoryError: Direct buffer memory。这是操作系统本地内存,不受-Xmx限制。 - OOM 和 StackOverflowError 是 Error 还是 Exception? 都是 Error,属于 Throwable 的子类,理论上不应该被 try-catch 捕获,因为程序通常无法恢复。
Q51:OOM 怎么排查?最常用的两个 JVM 参数是什么?
面试标准回答:
排查 OOM 的核心思路是捕获现场→分析根因。在 JVM 启动参数中配置以下两个关键参数,就可以在 OOM 发生时保留下完整的内存快照。
最常用的两个 JVM 参数:
-XX:+HeapDumpOnOutOfMemoryError: 指示 JVM 在发生 OOM 时自动生成堆转储文件(Heap Dump)。这个文件记录了当时堆中所有对象的详细信息。-XX:HeapDumpPath=/path/to/dump: 指定堆转储文件的存储路径,防止文件丢失或覆盖。
配合这两个参数,一旦 OOM 发生,就能拿到一份"犯罪现场证据"。
排查步骤:
- 拿到 Heap Dump 文件后,使用 MAT(Memory Analyzer Tool)工具打开。
- 重点查看 Histogram(直方图):按对象类型统计,找出占据内存最多的前几个类。
- 重点关注 Dominator Tree(支配树):找出持有最大内存的单个对象及其引用链,追溯到它被哪个业务对象持有没有释放。
- 分析是否有大量重复对象,或者某个集合(HashMap、ArrayList)明显异常膨胀。
- 结合 jstack 命令查看线程堆栈,找出是哪个线程、哪段业务代码产生的这些对象。
追问储备:
- 除了 MAT 还能用其他工具吗? jvisualvm(JDK 自带)、JProfiler、YourKit 都是常用的内存分析工具。
- 如果没有配置 HeapDumpOnOutOfMemoryError 怎么办? 可以用
jmap -dump:format=b,file=xxx.hprof <pid>手动导出堆转储文件,但要尽快,否则 GC 后现场可能被清理。
Q52:类加载过程?双亲委派模型是什么?为什么要这样设计?
面试标准回答:
类加载过程分为五个阶段:
- 加载(Loading): 通过类的全限定名获取它的二进制字节流(通常从文件系统、网络、jar 包等),将字节流转换为方法区中的运行时数据结构,并在堆中生成对应的
java.lang.Class对象。 - 验证(Verification): 确保字节码符合 JVM 规范,不会危害 JVM 自身安全(比如文件格式验证、元数据验证、字节码验证)。
- 准备(Preparation): 为类中的静态变量分配内存,并设置默认初始值(int 置 0,引用置 null)。注意这里只是默认值,显式赋值要到初始化阶段才执行。
- 解析(Resolution): 将常量池中的符号引用替换为直接引用(内存地址偏移量)。符号引用是一组符号描述,直接引用是指向目标的指针。
- 初始化(Initialization): 执行类构造器
<clinit>()方法,包括静态变量的显式赋值和静态代码块的执行。这是类加载的最后一步。
双亲委派模型:
Java 的类加载器分为三个层次:
- 启动类加载器(Bootstrap ClassLoader): 加载
<JAVA_HOME>/lib下的核心类库,如rt.jar。 - 扩展类加载器(Extension ClassLoader): 加载
<JAVA_HOME>/lib/ext下的扩展类库。 - 应用程序类加载器(Application ClassLoader): 加载用户类路径(Classpath)下的类。
双亲委派机制: 当一个类加载器收到类加载请求时,不会自己先尝试加载,而是把请求委托给父类加载器去完成。只有当父加载器反馈无法完成这个加载请求时,子加载器才会自己去加载。
为什么这样设计:
- 安全: 防止恶意代码篡改核心类库。比如有人自己写了一个
java.lang.String类,通过双亲委派,这个类会被启动类加载器拦截并加载 JVM 自带的String,而不是用户自定义的那个。 - 避免重复加载: 一个类只要被父类加载器加载过一次,子加载器就不会再次加载,保证了类在内存中的唯一性。
追问储备:
- 打破双亲委派模型的场景有哪些? Tomcat 的 Web 应用隔离(每个应用使用自己的 WebAppClassLoader)、JDBC 的 SPI 机制(通过
Thread.currentThread().getContextClassLoader()使用线程上下文类加载器)。 - 类加载器是继承关系吗? 不是 Java 语法的继承,而是通过组合关系实现的,在构造子加载器时传入一个父加载器对象。
Q53:打破双亲委派模型的场景有哪些?
面试标准回答:
双亲委派模型是 Java 推荐的类加载机制,但在某些场景下需要打破它。打破双亲委派,意味着子类加载器先尝试自己加载,加载不了再委托给父加载器,或者加载的时候不用父委派。主要有以下典型场景:
1. JDBC(SPI 机制):
- 问题:JDBC 的接口(
java.sql.Driver)由启动类加载器加载。但具体的数据库驱动实现(如 MySQL 的com.mysql.cj.jdbc.Driver)由应用程序类加载器加载。启动类加载器无法看见 AppClassLoader 加载的类。 - 解决:JDBC 4.0 通过 SPI 机制,使用
ServiceLoader加载 Driver 实现。ServiceLoader使用线程上下文类加载器(Thread Context ClassLoader) 来加载具体的驱动类,绕过了双亲委派。
2. Tomcat 等 Web 容器:
- 问题:一个 Tomcat 可能部署多个 Web 应用。不同应用可能依赖同一个类库的不同版本(如 Spring 4 和 Spring 5),也可能有自己独立的类需要隔离。
- 解决:Tomcat 为每个 Web 应用设置一个独立的
WebAppClassLoader。这个类加载器打破双亲委派,优先加载自己应用目录下的类,而不是向上委托。这样就实现了应用间的类隔离。
3. OSGi 和热部署(Hot Swap):
- OSGi 框架里,每个模块(Bundle)有自己独立的类加载器。模块升级时,直接替换类加载器,不需要重启应用。OSGi 的类加载机制是一个网状结构,完全打破了双亲委派的层次结构,根据模块依赖关系来委托加载。
4. 代码生成框架(如 CGLIB、ByteBuddy):
- 动态生成的代理类(如 AOP 代理)需要在运行时被加载。这些类的字节码是动态生成的,传统的类加载器无法从文件系统找到它们。通过自定义类加载器,从字节数组直接加载。
追问储备:
- 线程上下文类加载器为什么能打破双亲委派? 因为它的设置和获取不受双亲委派约束,可以在一个线程中随时设置。SPI 接口在启动类加载器中,但具体实现由应用程序类加载器加载,通过线程上下文类加载器桥接。
- 模块化(JPMS)对双亲委派有影响吗? JDK 9 引入模块系统后,类加载器架构仍是三层,但增加了模块间的依赖和封装规则,某些方面进一步弱化了双亲委派。
Q54:IoC 是什么?和 DI 的关系?有什么好处?
面试标准回答:
IoC(Inversion of Control,控制反转) 是一种设计思想。它将对象的创建和依赖管理从由程序自己主动 new 出来,变成了交给容器统一管理。以前我们写代码,需要哪个对象就 new UserService(),程序是主动控制者。引入 IoC 后,对象是直接从容器里找容器分配过来的,控制权被反转了,所以叫控制反转。
IoC 不是一种具体的技术,而是一种设计原则。它的核心目的是解耦。
DI(Dependency Injection,依赖注入) 是实现 IoC 的一种方式。它是指容器在创建 Bean 时,将它所依赖的其他 Bean 自动注入进来,不需要 Bean 自己去查找或创建。除了 DI,还有另一种实现 IoC 的方式叫"依赖查找"(Dependency Lookup,比如 ApplicationContext.getBean()),但 DI 更符合"不主动索取"的设计理念,所以是主流。
我们可以这样理解它们的关系:IoC 是思想,DI 是实现手段。
IoC 的好处:
- 解耦: 对象之间只依赖接口,不依赖具体实现。换一个实现类,只需要改变容器配置,业务逻辑代码不用动。
- 便于测试: 依赖通过接口注入,单元测试时可以方便地 Mock 一个假实现注入进去。
- 统一管理生命周期: Bean 的创建、初始化、销毁全由容器管理,开发者不用手动管理。
- 更易于扩展: 比如你想给一个 Service 统一加上事务,只需通过 AOP 配置,比改源码容易得多。
追问储备:
- Spring 中注入 Bean 的方式有哪些? 构造器注入(推荐,强制依赖)、Setter 注入(可选依赖)、字段注入(@Autowired 在字段上,简洁但不利于测试)。
- @Autowired 和 @Resource 的区别? @Autowired 是 Spring 提供的,默认按类型注入;@Resource 是 JDK 提供的,默认按名称注入。
Q55:AOP 是什么?底层怎么实现?JDK 动态代理和 CGLIB 区别?
面试标准回答:
AOP(Aspect-Oriented Programming,面向切面编程) 是一种编程思想。它将横切关注点(cross-cutting concerns,比如日志、事务、权限校验)从核心业务逻辑中分离出来,封装成一个独立的模块(切面),在运行的时候动态地把这些逻辑织入到目标方法的周围。
这样做的好处是:核心业务代码保持纯净,只关注自己的逻辑;横切功能集中管理,修改时不用每个类都改一遍。
Spring AOP 的底层实现是动态代理。 Spring 会为目标对象生成一个代理对象。当调用代理对象的方法时,代理对象会在调用前先执行前置通知、后置通知等逻辑,然后再真正调用目标方法。
Spring 提供了两种动态代理方式,默认会智能选择:
JDK 动态代理:
- 原理:基于接口反射。通过
java.lang.reflect.Proxy和InvocationHandler,在运行时动态生成一个实现同一接口的代理类。 - 前提:被代理的类必须实现至少一个接口。
- 优点:JDK 原生支持,不依赖第三方库。
- 缺点:没有接口的类无法代理。
CGLIB 动态代理:
- 原理:基于继承。通过 ASM 字节码框架,在运行时生成被代理类的一个子类,通过重写父类方法来插入增强逻辑。
- 前提:被代理类不能是 final 的,方法不能是 final 的。
- 优点:不需要接口,可以代理普通类。
- 缺点:final 类和方法无法代理;生成了额外的子类,可能增加元空间的负担。
Spring Boot 2.x 之后,默认采用 CGLIB 作为代理方式。
追问储备:
- 为什么 Spring Boot 默认用 CGLIB? CGLIB 不要求目标类实现接口,使用更方便。而且顶层 Controller 等类如果没有接口也能被代理。
- 两者在性能上哪个更好? 创建代理对象时,JDK 动态代理更快;调用代理方法时,CGLIB 稍快。但 JDK 8 之后差距极小。
Q56:Spring Bean 的生命周期?
面试标准回答:
Spring Bean 的生命周期分为实例化 → 属性注入 → 初始化 → 使用 → 销毁五个大阶段,中间穿插着各种 Aware 回调接口和 BeanPostProcessor 的前后处理。详细流程如下:
实例化: Spring 通过反射调用构造方法创建出一个 Bean 的壳子(此时属性还没有填充)。
属性注入: Spring 扫描 Bean 中的
@Autowired、@Value等注解,或根据 XML 配置,将依赖的 Bean 和配置值通过反射注入进来。Aware 接口回调: 如果 Bean 实现了
BeanNameAware、BeanClassLoaderAware、BeanFactoryAware、EnvironmentAware、ApplicationContextAware等接口,Spring 会按照由低到高的依赖顺序依次调用它们的 set 方法,让 Bean 逐步感知到自己的名字、类加载器、所在容器以及上下文环境。例如BeanNameAware最先被调用,ApplicationContextAware最后被调用,因为后者依赖于前面注入的信息。BeanPostProcessor 前置处理: 执行所有
BeanPostProcessor的postProcessBeforeInitialization()方法。在这个阶段,Bean 已经装配好了,但还可以做最后的校验或修改。初始化方法执行: 按以下优先级顺序执行初始化操作:
- ①
@PostConstruct注解标注的方法(优先级最高,最常用)。 - ②
InitializingBean接口的afterPropertiesSet()方法。 - ③
@Bean(initMethod = "xxx")指定的自定义初始化方法。
- ①
BeanPostProcessor 后置处理: 执行
postProcessAfterInitialization()方法。AOP 代理对象的创建就在这里发生。如果 Bean 需要被切面增强,Spring 会在这里生成一个代理对象(JDK 动态代理或 CGLIB),用这个代理对象替换原始 Bean。使用: Bean 完全就绪,可以被应用程序正常调用。
销毁: 容器关闭时,执行销毁操作。和初始化类似,也有三种方式:
@PreDestroy注解、DisposableBean接口的destroy()方法、@Bean(destroyMethod = "xxx")。
追问储备:
- Aware 接口的回调顺序为什么要这样设计? 因为后面的 Aware 接口可能需要用到前面的信息。比如
ApplicationContextAware依赖BeanFactoryAware
Q57:初始化方式有哪三种?优先级顺序?
面试标准回答:
Spring Bean 的初始化有三种方式,它们的优先级从高到低分别是:
1. @PostConstruct 注解(优先级最高,最推荐):
- 用法:在 Bean 的方法上标注
@PostConstruct(来自javax.annotation包)。 - 原理:Spring 通过
CommonAnnotationBeanPostProcessor这个 BeanPostProcessor 来解析@PostConstruct注解,在初始化阶段的前置处理后、afterPropertiesSet 之前调用它。 - 优点:代码简洁,注解和 Bean 在一处,侵入性低,是目前主流用法。
2. InitializingBean 接口(优先级中等):
- 用法:Bean 实现
org.springframework.beans.factory.InitializingBean接口,并覆写afterPropertiesSet()方法。 - 原理:Spring 在初始化阶段,属性填充完毕后直接调用
afterPropertiesSet()。 - 缺点:强依赖 Spring 的接口,代码侵入性较高,不够纯粹。
3. @Bean 注解的 initMethod 属性(优先级最低):
- 用法:在
@Bean(initMethod = "myInit")中指定初始化方法名,然后在 Bean 类中定义myInit()方法。 - 原理:Spring 在初始化阶段的前两种方式执行完毕后,通过反射调用指定的方法。
- 适用场景:当 Bean 类是第三方库的类,不能加
@PostConstruct或实现InitializingBean时使用。
执行顺序: @PostConstruct → afterPropertiesSet() → initMethod 指定的方法。
追问储备:
- 这三种方式可以同时用吗? 可以同时用,按优先级顺序依次执行,不会互相冲突。
- 销毁方式是否也有对应的三种? 是的,
@PreDestroy、DisposableBean.destroy()、@Bean(destroyMethod = "xxx"),优先级也是从高到低。
Q58:AOP 代理对象在生命周期哪一步创建?
面试标准回答:
AOP 代理对象是在 Bean 生命周期的初始化后、后置处理阶段创建的,具体一点是在 BeanPostProcessor 的 postProcessAfterInitialization() 方法中。
整个创建流程是:
- Bean 实例化完成、属性注入完成。
- 执行
@PostConstruct、afterPropertiesSet()等初始化方法。 - 初始化完成后,Spring 遍历所有
BeanPostProcessor,调用它们的postProcessAfterInitialization()方法。 - 其中,最关键的一个后置处理器叫
AbstractAutoProxyCreator(抽象自动代理创建器)。它的postProcessAfterInitialization()方法会检查当前 Bean 是否被切面(Aspect)的切点(Pointcut)匹配到。 - 如果匹配到,说明这个 Bean 需要对它应用 AOP 增强。
AbstractAutoProxyCreator会根据 Bean 的情况,决定使用 JDK 动态代理还是 CGLIB,然后创建一个代理对象。 - 这个代理对象会替换掉原始 Bean,放进容器的一级缓存(singletonObjects)。之后其他 Bean 注入的都是这个代理对象。
为什么放在初始化之后? 因为这时候 Bean 已经完全初始化好了,是个"成品"。代理是在这个成品外面再套一层壳,而不是替代品。如果放在初始化之前,代理对象可能没有执行初始化方法。
追问储备:
- 循环依赖中比这里更早获得代理吗? 是的。如果出现循环依赖,Bean 需要在实例化后、属性填充前就提前暴露到三级缓存。此时
AbstractAutoProxyCreator的getEarlyBeanReference()方法会被提前调用,生成一个"早期代理对象"放到二级缓存中。 - 什么情况下 AOP 代理不生效? 同类方法内部调用(this.method()),因为绕过了代理对象;方法不是 public 的;被 final 修饰的方法。这些场景下 AOP 不会生效。
Q59:Bean 的作用域有哪些?默认是哪个?
面试标准回答:
Spring 的 Bean 作用域定义了 Bean 在容器中的生命周期和可见范围。主要有以下几种:
1. singleton(单例,默认作用域):
- 含义:在整个 Spring IoC 容器中,只存在一个 Bean 实例。所有对该 Bean 的请求都返回同一个实例。
- 生命周期:容器启动时创建,容器关闭时销毁。
- 适用:无状态的 Bean,如 Service、Dao、Controller。
2. prototype(多例/原型):
- 含义:每次请求该 Bean 时,创建一个新的实例。
- 生命周期:Spring 负责创建但不负责销毁。创建后交给调用者,容器不再管理它的生命周期。
- 适用:有状态的 Bean,或每个调用者需要自己独立对象的场景。
3. request(仅 Web 环境):
- 含义:每次 HTTP 请求创建一个新的 Bean 实例,该实例只在当前请求内有效。
- 适用:一个请求中需要共享的上下文数据。
4. session(仅 Web 环境):
- 含义:每个 HTTP Session 创建一个 Bean 实例,该实例在 Session 生命周期内有效。
- 适用:用户会话级别的数据,如登录信息。
5. application(仅 Web 环境):
- 含义:在整个 ServletContext 生命周期内只有一个 Bean 实例。和 singleton 类似,但作用域绑定到 ServletContext 上。
6. websocket(仅 WebSocket 环境):
- 含义:每个 WebSocket 会话创建一个 Bean 实例。
追问储备:
- singleton Bean 是线程安全的吗? 不是,singleton 本身不提供任何线程安全保证。如果 singleton Bean 中有可变状态(成员变量),需要开发者自己保证线程安全。通常设计成无状态的。
- prototype Bean 被注入到 singleton Bean 中会怎样? singleton Bean 只会被初始化一次,所以它持有的 prototype Bean 也只被注入一次,之后不会变化。如果需要每次获取新的 prototype Bean,可以通过
@Lookup注解或ApplicationContext.getBean()。
Q60:循环依赖怎么解决?三级缓存各自存什么?
面试标准回答:
Spring 解决循环依赖的核心机制是三级缓存。这三层缓存,其实就是三个不同特性的 Map,它们协同工作,在 Bean 创建的半成品阶段就暴露对象的引用,打破循环。
三级缓存的组成:
一级缓存:
singletonObjects(成品仓库) 存放的是已经完全初始化好的、可以直接使用的成品 Bean。你平时通过getBean()拿到的对象就来自这里。三级缓存:
singletonFactories(胚子加工厂) 存放的是一个能提前暴露 Bean 引用的工厂对象(ObjectFactory)。在 Bean 刚实例化完、属性还没来得及注入时,Spring 就把这个工厂放进三级缓存。当其他 Bean 需要依赖它时,调用工厂的getObject()方法,拿到这个 Bean 的早期引用。二级缓存:
earlySingletonObjects(半成品存放区) 存放的是从三级缓存工厂里真正被生产出来的早期半成品对象。当一个半成品被从三级工厂里取出来用过一次之后,就会从三级缓存移到二级缓存,免得每次都让工厂重新处理一遍。
解决过程(以 A 依赖 B,B 依赖 A 为例):
- 创建 A 时,实例化完 A,把 A 的 ObjectFactory 放进三级缓存。
- 给 A 注入属性时,发现需要 B,去创建 B。
- B 实例化完,也把自己的 ObjectFactory 放入三级缓存。
- 给 B 注入属性时,发现需要 A。B 去查缓存,先在三级缓存找到了 A 的工厂,调用
getObject()拿到 A 的早期引用(如果 A 需要 AOP,此时拿到的就是 A 的代理对象,而不是原始 A)。B 拿到这个早期 A,把它放入二级缓存,然后完成 B 的初始化。 - B 初始化完了,A 拿到完整的 B,A 也完成初始化。最终 A 和 B 都被放入一级缓存。
为什么需要第三级缓存? 核心是为了延迟创建代理对象。正常流程中,AOP 代理是在 postProcessAfterInitialization 阶段统一创建的。但如果发生循环依赖,必须提前暴露引用。三级缓存中的 ObjectFactory 通过 getEarlyBeanReference() 可以在需要时按需生成早期代理对象,而不必在一开始就把所有半成品都升级为代理。这样既保证了循环依赖时的 AOP 语义,又避免了不必要的代理对象提前创建。
追问储备:
- prototype 的循环依赖能解决吗? 不能,Spring 只解决单例 Bean 的循环依赖。prototype Bean 每次都要创建新对象,无法缓存半成品,发现循环依赖直接抛异常。
- 构造器注入的循环依赖能解决吗? 不能,因为构造器注入发生在实例化阶段,此时三级缓存的半成品还没创建出来。
Q61:@Transactional 失效场景?
面试标准回答:
@Transactional 注解失效的场景大多跟 Spring AOP 的代理机制有关。因为它本质上是靠 AOP 代理来实现的,只要代理没生效,事务就失效。以下是几种最常见的失效情况:
1. 同类方法内部调用(this 调用)。 在同一个 Service 类中,一个没有 @Transactional 的方法直接调用另一个有 @Transactional 的方法,这叫自调用。自调用时走的是 this.method(),而不是代理对象 .method(),所以代理不生效,事务也不会生效。解决方案:自注入代理对象,或者在 ApplicationContext 中取出代理对象再调用。
2. 方法不是 public 的。 Spring AOP 的代理只能拦截 public 方法。如果 @Transactional 加在 protected、private 或默认包可见的方法上,代理根本看不到它,事务自然形同虚设。
3. 异常被 try-catch 吞掉没有抛出。 在标注了 @Transactional 的方法内部,如果异常被 try-catch 捕获了但没有重新抛出,代理对象完全感知不到异常发生,以为一切正常,就会正常提交事务。常见于开发者想把异常转换成友好的错误提示,但忘了将致命异常重新 throw。
4. 抛出的异常不是 Spring 默认回滚的异常类型。 Spring 事务默认只对 RuntimeException 和 Error 进行回滚。如果方法抛出的是 Checked Exception(如 IOException、SQLException),即使是异常,Spring 也不会回滚。解决方法是在 @Transactional 上配置 rollbackFor = Exception.class。
5. 类或方法被 final 修饰。 CGLIB 动态代理通过继承来创建代理对象。如果类被 final 修饰,无法创建子类;如果方法被 final 修饰,无法被重写。这两种情况代理都创建不了,事务失效。
6. static 方法。 代理对象拦截的是实例方法的调用,static 方法属于类本身,不经过代理对象。
7. 数据库引擎不支持事务。 比如 MySQL 的 MyISAM 引擎不支持事务,@Transactional 就算配得再对也没用,必须使用 InnoDB 引擎。
追问储备:
- 同类方法调用怎么解决? 注入自己(
@Autowired private XxxService self;)通过 self 调用;或者把需要事务的方法拆到另一个 Service 里。 - @Transactional 加到接口上和加到实现类上有区别吗? 如果使用 JDK 动态代理(基于接口),注解必须加在接口上;如果使用 CGLIB,注解可以加在实现类上。Spring Boot 默认 CGLIB,所以注解加在实现类上即可。
Q62:@Transactional 默认回滚什么异常?CheckedException 回滚吗?
面试标准回答:
Spring 的 @Transactional 注解对异常的回滚策略非常明确:默认只对 RuntimeException(运行时异常)和 Error 进行回滚。对于 CheckedException(受检异常,如 IOException、SQLException、FileNotFoundException 等),默认不回滚。
这个设计是符合 Java 异常处理哲学的:
- RuntimeException 和 Error 通常是由代码 Bug 或者 JVM 层面的严重问题引起的,属于不可预料的错误。当事务中发生了这些异常,说明业务流程已经出现不可控的错误,数据一致性无法保证,应当立刻回滚。
- CheckedException 是编译器强制检查的异常,通常代表一种可以预见的、可以被业务逻辑优雅处理的场景(比如文件不存在、网络超时)。这些场景并非必然意味着数据不一致,可能只需记录日志或返回错误给用户。Spring 默认不替你回滚,把决定权留给开发者。
如果希望 CheckedException 也能触发回滚,需要在 @Transactional 注解上配置 rollbackFor 属性:
| |
也可以只指定某些特定的 CheckedException:
| |
追问储备:
- noRollbackFor 是什么? 指定哪些异常不触发回滚,即使在默认会回滚的异常范围内。比如
@Transactional(noRollbackFor = {IllegalArgumentException.class})。 - rollbackFor 和 rollbackForClassName 的区别? 前者指定异常类,后者指定异常类的全限定名(字符串)。效果一样,只是写法不同,通常用前者。
Q63:Spring Boot 自动配置原理?@SpringBootApplication 三个核心注解是什么?
面试标准回答:
Spring Boot 自动配置的原理可以用一句话概括:通过 SPI 机制批量加载所有候选的自动配置类,再通过条件注解按需取舍,最终实现"约定大于配置"。它让开发者不再需要手动写繁琐的 XML 配置或 @Configuration 类,只需引入对应的 Starter 依赖,Spring Boot 就会自动帮我们配置好相关组件。
@SpringBootApplication 是一个复合注解,由三个核心注解组成:
@SpringBootConfiguration: 等价于
@Configuration,表示当前类是一个配置类,Spring 会扫描它里面的@Bean定义并加载到容器中。@EnableAutoConfiguration(自动配置的灵魂): 这个注解内部通过
AutoConfigurationImportSelector类,基于 SPI 机制从 Classpath 下扫描所有 jar 包中的META-INF/spring/AutoConfiguration.imports文件(Spring Boot 2.7 之前是spring.factories)。这些文件里记录了所有内置的自动配置类的全限定名列表。AutoConfigurationImportSelector 会把这些类名全部读出来,作为候选的自动配置类。@ComponentScan: 开启组件扫描,默认扫描当前启动类所在的包及其子包下的所有组件(@Component、@Service、@Repository、@Controller 等)。
自动配置三部曲:加载 → 过滤 → 生效。
- 加载: 通过 SPI 机制批量读取所有内置的候选自动配置类名称。
- 过滤: 并不是读到的所有配置类都会生效。每个自动配置类上都有
@Conditional系列条件注解,比如@ConditionalOnClass(Classpath 里必须有某个类才生效)、@ConditionalOnMissingBean(用户没自己定义同类型 Bean 才用默认的)、@ConditionalOnProperty(配置了某属性才生效)。通过这些条件过滤后,只留下符合当前环境的配置类。 - 生效: 最终留下来的自动配置类会被 Spring IoC 容器加载,创建对应的 Bean,完成组件的初始化。
约定大于配置: 你不配置时,Spring Boot 按默认行为启动(比如引入 spring-boot-starter-web 就自动配好 DispatcherServlet)。如果你自己定义了同类型的 Bean,默认的自动配置会自动退让(通过 @ConditionalOnMissingBean),以你的自定义配置为准。
追问储备:
- Spring Boot 2.7 前后加载配置文件的区别? 2.7 之前扫描的是
META-INF/spring.factories(键值对格式,解析慢);2.7 之后改为META-INF/spring/AutoConfiguration.imports(纯文本格式,每行一个类名,解析更快)。 - 如果想写一个自定义的 Starter,该怎么做? 定义一个自动配置类,加上
@Configuration和@Conditional系列注解,然后在AutoConfiguration.imports文件中注册该类即可。
Q64:@Conditional 系列注解有哪些?@ConditionalOnMissingBean 怎么实现“自定义覆盖默认”?
面试标准回答:
@Conditional 是 Spring Framework 提供的一套条件装配机制,Spring Boot 在此基础上扩展出了一系列常用的条件注解,让自动配置可以根据当前环境的实际情况灵活启用或禁用。常用的一共有以下几个:
1. @ConditionalOnClass: 当 Classpath 中存在指定的类时,配置才生效。比如 @ConditionalOnClass({ RedisTemplate.class }) 表示只有引入了 Spring Data Redis 的依赖时,Redis 自动配置才生效。
2. @ConditionalOnMissingClass: 与上面相反,当 Classpath 中缺少指定的类时才生效。
3. @ConditionalOnBean: 当 Spring 容器中存在指定类型的 Bean 时才生效。通常用于依赖某个外部组件已经初始化好之后自己才启动的场景。
4. @ConditionalOnMissingBean(核心): 当 Spring 容器中不存在指定类型或名称的 Bean 时才生效。这是实现"约定大于配置"中用户自定义覆盖默认的关键注解。
5. @ConditionalOnProperty: 当配置文件中存在指定的配置属性且值满足条件时才生效。比如 @ConditionalOnProperty(name = "my.feature.enabled", havingValue = "true")。
6. @ConditionalOnExpression: 基于 SpEL 表达式的结果来决定是否生效,适合复杂的条件判断。
7. @ConditionalOnResource: 当 Classpath 中存在指定的资源文件时才生效。
8. @ConditionalOnWebApplication / @ConditionalOnNotWebApplication: 判断当前应用是否是 Web 应用。
@ConditionalOnMissingBean 怎么实现"自定义覆盖默认":
这是自动配置最精妙的一环。Spring Boot 内置的自动配置类上大量使用 @ConditionalOnMissingBean。比如内置的 RedisTemplate 自动配置:
| |
当用户自己定义了一个 redisTemplate Bean:
| |
此时容器中先注册了用户自定义的 redisTemplate。Spring Boot 执行到自己的自动配置类时,发现 @ConditionalOnMissingBean 条件不满足了(因为已经存在一个 redisTemplate Bean),于是跳过自己的默认配置,用户的 Bean 成为最终的 Bean。这就完成了"默认全自动,自定义全手动"的无缝切换。
追问储备:
- @ConditionalOnMissingBean 和 @Primary 区别? @ConditionalOnMissingBean 是"有就不再注册",直接跳过;@Primary 是"两个都注册,但我优先",两个都存在于容器中。
- 所有条件注解可以叠加使用吗? 可以,多个条件注解叠加是"与"关系,必须全部满足才生效。
Q65:Spring 用了哪些设计模式?分别用在哪里?
面试标准回答:
Spring 框架在设计中大量运用了经典设计模式,这也是它高扩展性和灵活性的根源。常用的主要有以下九种:
1. 单例模式(Singleton): Spring 中 Bean 的默认作用域就是单例(singleton)。每个 Bean 在容器中只有一个实例,通过一级缓存 singletonObjects 维护。
2. 工厂模式(Factory): BeanFactory 和 ApplicationContext 都是工厂模式的体现。它们负责创建和管理 Bean,客户端只需通过名字或类型获取,不关心创建细节。
3. 代理模式(Proxy): AOP 的底层就是动态代理。Spring 根据被代理类是否实现了接口,分别选用 JDK 动态代理(基于接口)或 CGLIB(基于继承)。
4. 模板方法模式(Template Method): JdbcTemplate、RestTemplate、RedisTemplate 等大量 Template 类都是模板方法模式的体现。它们定义了操作的主流程骨架(如获取连接、执行、释放连接),将具体变化的部分(如 SQL 语句、参数)交给回调或子类实现。
5. 观察者模式(Observer): Spring 的事件机制(ApplicationEvent、ApplicationListener)就是观察者模式的实现。一个 Bean 发布事件,所有订阅了该事件的监听器都会收到通知并执行。
6. 策略模式(Strategy): AOP 中选择 JDK 动态代理还是 CGLIB 是一种策略模式的应用。还有 Resource 接口的不同实现(ClassPathResource、FileSystemResource、UrlResource)也是根据资源类型选择不同策略。
7. 适配器模式(Adapter): Spring MVC 中的 HandlerAdapter 就是适配器模式。不同类型的 Controller(如 @Controller、HttpRequestHandler、Servlet)有不同的处理方法,HandlerAdapter 将这些不同的接口适配成统一的 handle() 调用。
8. 装饰器模式(Decorator): Spring 中各种 Wrapper 和 Decorator 命名结尾的类就是装饰器模式。比如 BeanDefinitionDecorator 用来动态增强 Bean 的定义。
9. 责任链模式(Chain of Responsibility): Spring MVC 中的拦截器链(HandlerInterceptor)、Spring Security 的过滤器链(FilterChain)都是责任链模式的体现。请求沿着链条依次经过每个处理节点。
追问储备:
- Spring 中有用到建造者模式吗? 有。
RestTemplateBuilder、MockMvcBuilder等都是建造者模式的体现,用于分步构建复杂对象。 - 你在项目里刻意用过什么设计模式? (可以结合 AgentForge 回答)比如用策略模式实现了动态路由树——不同的编排类型是不同策略,通过节点类型选择不同执行策略;用工厂模式管理了 MCP 工具的三种接入方式。
Q66:ACID 四大特性?分别靠什么机制实现?
面试标准回答:
ACID 是数据库事务的四个核心特性,分别是原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。其中一致性是最终目标,其他三个特性都是为达成一致性服务的。
1. 原子性(Atomicity): 事务中的所有操作是一个不可分割的整体,要么全部成功提交,要么全部失败回滚,不存在只执行一半的情况。底层通过 undo log(回滚日志) 实现——事务执行过程中,每次修改前先把数据的旧版本写入 undo log,如果事务失败,就根据 undo log 中记录的原值把数据恢复回去,就像什么都没发生过一样。
2. 一致性(Consistency): 事务执行前后,数据库从一个一致性状态转换到另一个一致性状态,并且始终满足所有约束条件(主键、外键、唯一约束、业务规则等)。一致性不是靠某一个机制单独实现的,而是通过原子性、隔离性、持久性三者共同保障的最终结果。
3. 隔离性(Isolation): 多个事务并发执行时,彼此之间互不干扰,一个事务不会看到另一个事务未提交的中间状态。底层通过 MVCC(多版本并发控制) + 锁机制 实现。MVCC 让读操作不被写操作阻塞,写操作通过行级锁和间隙锁来防止并发冲突。
4. 持久性(Durability): 事务一旦提交,对数据库的修改就是永久性的,即使数据库宕机崩溃也不会丢失。底层通过 redo log(重做日志) 和 WAL(Write-Ahead Logging,日志先行)策略 实现。每次修改数据前,先把操作记录写入 redo log,事务提交时日志必须先落盘,之后才慢慢将脏页刷到磁盘。如果数据库在刷盘过程中崩溃了,重启时根据 redo log 重放已提交的操作,保证数据不丢。
追问储备:
- 为什么一致性是目的而不是机制? 因为一致性更像是一个约束条件,而不是一个具体的实现手段。原子性、隔离性、持久性都是可以具体落地实现的技术,但一致性是这三者共同达成的效果。
- redo log 是怎么保证持久性的? 通过 WAL 策略——日志先行。事务提交前 redo log 必须持久化到磁盘,即使之后数据页还没来得及刷盘,重启后也能通过 redo log 恢复。redo log 是顺序写,性能比随机写数据页快得多。
Q67:redo log、undo log、binlog 的区别?
面试标准回答:
这三种日志是 MySQL 中最重要的三大日志,它们产生的位置、记录的格式、承担的职责完全不同。
1. redo log(重做日志):
- 产生位置: InnoDB 存储引擎层。
- 记录格式: 物理日志。记录的是"某个数据页的某个偏移量被修改成了什么值",直来直去,没有 SQL 语句的逻辑。
- 职责: 保证持久性。主要用于崩溃恢复——事务提交前必须将 redo log 落盘,即使之后数据页还没刷盘就宕机,重启后也能靠 redo log 重放已提交事务,确保数据不丢。
- 特点: 顺序写,速度快;循环使用,固定总大小(
innodb_log_file_size)。
2. undo log(回滚日志):
- 产生位置: InnoDB 存储引擎层。
- 记录格式: 逻辑日志。记录的是"数据被修改之前长什么样"。比如:插入一行,undo log 记录这行的主键,回滚时根据主键删除;更新一行,undo log 记录这个字段修改前的值,回滚时改回去。
- 职责: 保证原子性——事务回滚时用它恢复旧数据;同时为 MVCC 提供版本链——读操作通过 undo log 找到数据的老版本,实现非锁定读。
- 特点: 串联成版本链,通过 roll_pointer 指针链接历史版本。
3. binlog(归档日志/二进制日志):
- 产生位置: MySQL Server 层(与存储引擎无关)。
- 记录格式: 逻辑日志。记录的是"执行了什么 SQL 语句"(Statement 格式)或"那行数据被改成什么样了"(Row 格式)。
- 职责: 主要用于主从复制(从节点通过拉取主节点的 binlog 重放来同步数据)和数据恢复(通过 binlog 按时间点恢复数据)。
- 特点: 追加写,不会循环覆盖;事务提交时一次性写入,而不是边执行边写。
三者关系: 事务执行时,undo log 记录旧版本保证可回滚;redo log 记录修改行为保证崩溃后可恢复;事务提交时,binlog 记录完整 SQL 用于复制。经典的"两阶段提交"就是协调 redo log 和 binlog 的一致性。
追问储备:
- 为什么需要两阶段提交? 为了保证 redo log 和 binlog 的一致性。如果先写 redo log 后写 binlog,redo log 写完后宕机,binlog 没写,从节点丢失这个事务;如果先写 binlog 后写 redo log,binlog 写完后宕机,redo log 没写,主节点重启后丢失事务,但从节点通过 binlog 重放了,导致主从数据不一致。两阶段提交通过"prepare → commit"流程解决这个问题。
- redo log 的刷盘时机?
innodb_flush_log_at_trx_commit = 1(默认)每次提交都刷盘,最安全;等于 0 每秒刷一次,可能丢一秒数据;等于 2 提交时写 OS 缓存,每秒刷盘,可能丢 OS 缓存中的数据。
Q68:四种隔离级别?MySQL 默认是哪个?
面试标准回答:
SQL 标准定义了四种隔离级别,并发安全性从低到高排列,性能则相反。
1. 读未提交(Read Uncommitted): 一个事务可以读到其他事务还没提交的数据。这个级别安全最低,会出现脏读(读到未提交的数据被回滚了)。生产环境几乎不用。
2. 读已提交(Read Committed): 一个事务只能读到其他事务已经提交的数据。解决了脏读,但会出现不可重复读——同一个事务中两次读同一行数据,得到的结果不一样,因为中间有其他事务提交了修改。Oracle 和 PostgreSQL 的默认级别就是这个。
3. 可重复读(Repeatable Read): 同一个事务中多次读取同一行数据,结果始终一致。通过 MVCC 实现了快照读的一致性,解决了不可重复读。对于幻读(两次相同条件查询出来的行数不一样),MySQL 通过 MVCC 的快照读加上间隙锁(Gap Lock)在当前读中也能部分解决。这是 MySQL 的默认隔离级别。
4. 串行化(Serializable): 所有事务严格串行执行,完全隔离。本质上是把并发的事务变成了排队执行,读加共享锁,写加排他锁,范围锁防止插入。性能最差,生产环境很少使用。
MySQL 为什么默认是可重复读? 有一个历史原因:早期 MySQL 的 binlog 只有 Statement 格式(记录 SQL 语句)。在 RC 隔离级别下,如果一个事务的多次修改因为提交顺序不同,导致 binlog 中记录的顺序和实际执行顺序不一致,主从复制就会出现数据不一致。而 RR 级别通过间隙锁等手段保证了一致性,所以 MySQL 默认选择了 RR。虽然后来的 binlog Row 格式解决了这个问题,但 MySQL 的默认级别已定格在 RR。
追问储备:
- 脏读、不可重复读、幻读的具体区别? 脏读是读到了别人还没提交的数据(可能被回滚);不可重复读是同一行数据两次读到不同的值(别人修改了);幻读是两次相同条件查到不同行数(别人插入或删除了)。
- Oracle 为什么用 RC 而不是 RR? Oracle 其实也提供类似 RR 的"SERIALIZABLE",但它的实现和 MySQL 不同。Oracle 默认 RC 是出于并发性能的考量,通过 undo 表空间提供一致性读,不需要像 MySQL 那样通过间隙锁来防止幻读,锁开销更小。
Q69:脏读、不可重复读、幻读的区别?
面试标准回答:
这三个概念是数据库并发事务中的经典问题,它们的核心区别在于读到的数据"出错"的方式不同。
1. 脏读(Dirty Read): 事务 A 读取到了事务 B 还未提交的数据。如果事务 B 后来回滚了,事务 A 读到的就是一段"不存在的数据",就像看到了别人写在草稿纸上的数字,人家后来擦了,你的依据就是错的。
例子:B 给账户转账 100 元,还没提交,A 查询余额看到了多出的 100 元,认为转账已成功。结果 B 回滚了转账操作,A 读到的就是脏数据。
2. 不可重复读(Non-Repeatable Read): 事务 A 内两次读取同一行数据,结果却不一样。因为两次读取之间,事务 B 修改了这行数据并提交了。
例子:A 开始事务,第一次查询账户余额为 500 元。B 此时修改了这行数据并提交,把余额改成了 600 元。A 第二次查询时看到余额变成了 600 元。同一个事务内两次读到不同值,违背了"可重复读"的预期。
3. 幻读(Phantom Read): 事务 A 两次执行相同条件的范围查询,返回的行数不一样。因为两次查询之间,事务 B 在这个范围内插入或删除了行并提交。
例子:A 查询所有余额大于 1000 的用户,得到 10 行。此时 B 插入了一个新用户,余额 2000,提交。A 再次执行相同查询,返回了 11 行。凭空多了一行数据,像幻觉一样。
核心区别记忆法:
- 脏读 → 读到了没提交的数据(读的数据本身是假的)
- 不可重复读 → 读到了被修改的数据(同一行数据值变了)
- 幻读 → 读到了被插入或删除的数据(多了一行或少了一行)
追问储备:
- 哪个隔离级别解决了哪些问题? 读未提交三个都解决不了;读已提交解决了脏读;可重复读解决了脏读和不可重复读,对幻读部分解决;串行化全部解决。
- 幻读和不可重复读的根本区别? 不可重复读关注的是同一行的值变化,幻读关注的是行数的变化(插入和删除)。一个是修改操作引起的,一个是插入/删除操作引起的。
Q70:MVCC 怎么实现的?隐藏字段、版本链、ReadView 分别是什么?
面试标准回答:
MVCC(Multi-Version Concurrency Control,多版本并发控制)是 MySQL InnoDB 实现事务隔离性的核心机制。它通过为每行数据保留多个历史版本,让读操作不用等待写操作释放锁,也不用读最新提交的数据,而是读一个之前的一致快照。
MVCC 的实现依赖三个核心组件:
1. 隐藏字段(每行数据自带):
- trx_id(事务 ID): 记录最后一次修改这行数据的事务 ID。每当一行数据被修改,trx_id 就会更新为当前事务的 ID。
- roll_pointer(回滚指针): 指向这行数据的上一个历史版本。历史版本存储在 undo log 中,通过 roll_pointer 串联起来。
2. 版本链(undo log 链): 每次修改一行数据时,都会把修改前的旧版本写入 undo log,并让新版本的 roll_pointer 指向这个旧版本。随着修改次数增加,undo log 中的各个历史版本通过 roll_pointer 串联成一条从新到旧的链表,这就是版本链。
3. ReadView(读视图): 当事务执行快照读(普通 SELECT)时,会创建一个 ReadView。ReadView 记录了当前系统中活跃的(未提交的)事务 ID 列表,以及这些活跃事务中最小的事务 ID(m_ids → min_trx_id)和下一个即将分配的事务 ID(max_trx_id)。
可见性判断流程: 事务要读取一行数据时,先从版本链的表头(最新版本)开始,用 ReadView 中的信息去比较这个版本的 trx_id:
- 如果版本是当前事务自己修改的(trx_id == 当前事务 ID),可见。
- 如果版本的 trx_id 小于 ReadView 中的最小活跃事务 ID,说明修改它的事务在 ReadView 创建前就提交了,可见。
- 如果大于等于最大事务 ID,说明在 ReadView 创建后才开始修改的,不可见。
- 如果在最小和最大之间,需要查活跃列表:如果 trx_id 在活跃列表中,说明修改它的事务还没有提交,不可见;如果不在列表中,说明已提交,可见。
如果当前版本不可见,就沿着 roll_pointer 找到上一个版本,重复判断,直到找到第一个可见的版本为止。
追问储备:
- MVCC 能读到最新的已提交数据吗? 快照读(普通 SELECT)不保证读到最新已提交数据,它读的是 ReadView 创建时的快照;当前读(SELECT … FOR UPDATE / UPDATE / DELETE)总是读到最新已提交版本,不走 MVCC,而是通过行锁来保证。
- 为什么 MVCC 不能完全解决幻读? 快照读通过 ReadView 可以看到一个一致的范围,但当前读(加锁读)需要依赖间隙锁来防止其他事务在范围内插入新数据。
Q71:ReadView 的可见性判断四条规则?
面试标准回答:
当 RR 或 RC 隔离级别下执行快照读时,事务会生成一个 ReadView。ReadView 包含三个关键信息:
- creator_trx_id: 当前事务自己的 ID。
- m_ids: 生成 ReadView 时,系统中所有活跃的(未提交的) 事务 ID 列表。
- min_trx_id: m_ids 中的最小值(即最老的那个未提交事务的 ID)。
- max_trx_id: 下一个即将被分配的事务 ID(即全局事务 ID + 1)。
当我们遍历版本链,对某个版本的 trx_id 进行可见性判断时,走的是一条一套逻辑:
规则 1:trx_id 等于 creator_trx_id。 这个版本是当前事务自己修改的。自己对自己的改动当然是可见的。
规则 2:trx_id 小于 min_trx_id。 说明修改这个版本的事务在 ReadView 创建之前就已经提交了。既然已经提交,这个版本就应该被看到。
规则 3:trx_id 大于等于 max_trx_id。 说明这个版本是由 ReadView 创建之后才开始的事务修改的。因为这个事务在"拍照"时还没出生,这个版本当然不可见。
规则 4:trx_id 在 min_trx_id 和 max_trx_id 之间。 需要查看 m_ids 活跃列表。如果 trx_id 在活跃列表中,说明这个事务在 ReadView 创建时还是未提交状态,版本不可见;如果不在列表中,说明在 ReadView 创建时已经提交了,版本可见。
如果根据以上规则判断当前版本不可见,就沿着 roll_pointer 指针找到版本链的下一个更早版本,重复这个判断过程,直到找到第一个可见的版本为止。这就是 MVCC 快照读的完整可见性判断流程。
追问储备:
- 为什么需要 max_trx_id? 因为 m_ids 只记录了当前活跃的事务,不包含未来才出现的事务。当生成 ReadView 后,新的活跃事务分配的 trx_id 一定大于等于 max_trx_id,直接排除掉,无需查活跃列表。
- 判断顺序为什么是严格的? 只有 trx_id 与当前事务一致时才可见(规则 1),已经提交的(规则 2)也可见。其他情况必须满足"已提交又不在活跃列表"才可见。
Q72:RR 和 RC 在 MVCC 上的区别?ReadView 创建时机有何不同?
面试标准回答:
RR 和 RC 在 MVCC 机制上的核心区别就在于ReadView 的创建时机不同,这也是它们隔离性差异的根本原因。
在 RC(Read Committed)级别下: 事务中每次执行快照读时,都会生成一个全新的 ReadView。这意味着,如果两次 SELECT 之间其他事务提交了对同一行的修改,第二个 SELECT 生成的 ReadView 已经不再排斥这个已提交的事务,因此当前事务能看到外界的最新修改,导致同一事务内两次读到的结果不一致——也就是不可重复读。
在 RR(Repeatable Read)级别下: 事务中只在第一次执行快照读时生成一个 ReadView,后续所有的快照读都复用同一个 ReadView。不论外界事务怎么提交修改,当前事务始终使用最早的那个 ReadView 去判断可见性。这样,只要事务没结束,它看到的永远是启动时刻的那份数据快照,保证了可重复读。
举例: 假设事务 A 开始,第一次查询余额 500 元(生成 ReadView_RR)。随后事务 B 修改余额为 100 并提交。在 RC 下,A 再次 SELECT 会生成新 ReadView_RC,看到 0 元的余额(因为 B 已提交,不在新 ReadView 的活跃列表中)。在 RR 下,A 复用最早的 ReadView_RR,仍然认为 B 处于活跃未提交状态,所以余额还是 500 元。
追问储备:
- RR 级别的 ReadView 是在 BEGIN 时创建的吗? 不是。RR 的 ReadView 不是事务一开始就生成,而是在第一次 SELECT 快照读时才生成。如果事务开始后先执行 UPDATE,再 SELECT,ReadView 生成时可能已经有其他提交影响当前事务所处的"快照时刻"。
- 为什么 RC 可以解决脏读? 因为无论是 RC 还是 RR,ReadView 都会排除未提交的事务。RC 只是能让事务看到已提交的修改,绝对不会看到没提交的"脏"数据。
Q73:索引底层数据结构?为什么是 B+ 树而不是 B 树?不是红黑树?不是 Hash?
面试标准回答:
MySQL InnoDB 引擎的索引底层使用 B+ 树作为默认数据结构。选择 B+ 树的原因可以从以下几个维度来看:
1. 为什么是 B+ 树而不是 B 树?
- 磁盘 IO 更少: B+ 树只在叶子节点存储完整的数据行(或指向数据的指针),非叶子节点只存储索引键值。这使得非叶子节点能容纳更多的键值,树的层级更低(更矮胖),从根节点到叶子节点的路径更短,每次查询需要的磁盘 IO 次数更少。
- 查询稳定性更好: B 树可能在非叶子节点就找到数据,导致不同查询的 IO 次数不一致(有的 1 次,有的 3 次)。B+ 树的全部数据都在叶子节点,每次查询都必须从根走到叶子,IO 次数完全由树的高度决定,查询性能稳定可预测。
- 范围查询和排序性能更好: B+ 树的叶子节点之间双向链表相连,且内部数据按顺序排列。要做范围查询(如 SELECT * FROM t WHERE id BETWEEN 10 AND 100),只需定位到起始叶子节点,然后顺着链表往后遍历即可,不用像 B 树那样需要中序遍历整棵树。
2. 为什么不是红黑树? 红黑树是二叉树,当数据量很大时树会变得非常高。如果数据存在磁盘上,每访问一个节点就可能是一次磁盘 IO。红黑树每个节点存的数据很少,导致树的高度很高,IO 次数多。B+ 树是 N 叉树,每个节点能存成百上千个索引键,树的高度极低(通常只有 2~3 层),IO 次数远少于红黑树。
3. 为什么不是 Hash 表?
- Hash 表虽然等值查询 O(1) 很快,但Hash 索引的键值是无序存储的,任何范围查询(如
BETWEEN、>、ORDER BY)都无法利用 Hash 索引,必须退化为全表扫描。而数据库的实际查询中,范围查询和排序操作极为普遍。 - Hash 表存在哈希碰撞问题,且对磁盘存储不友好。B+ 树的页结构非常适合磁盘的预读和缓存。
- MySQL 实际上也提供了 Hash 索引(Memory 引擎),但 InnoDB 的自适应哈希索引(Adaptive Hash Index)也是基于 B+ 树的热点页临时构建的,底层依旧是 B+ 树。
追问储备:
- B+ 树的叶子节点是单向链表还是双向? 双向。这样可以支持升序和降序的范围查询,前一个叶子节点到后一个很方便。
- 为什么 MongoDB 用 B 树? MongoDB 的设计场景更侧重单文档查询,且 B 树可以在非叶子节点一层就返回数据,避免了 B+ 树每次都要走到叶子层,对单文档查询有一定优势。但 MongoDB 也提供了多种索引引擎,并非绝对。
Q74:B+ 树和 B 树的核心区别?
面试标准回答:
B+ 树和 B 树虽然都是平衡多路搜索树,但在数据结构的设计上有三个核心区别,这也是 MySQL 选择 B+ 树的关键原因。
1. 数据存储位置不同:
- B 树:所有节点(包括非叶子和叶子)都存储完整的数据记录(或指向完整记录的指针)。
- B+ 树:只有叶子节点存储完整数据,非叶子节点只存储索引键值,不存数据。
这导致 B+ 树的非叶子节点能存储比 B 树多得多的键值,树的高度更低,磁盘 IO 更少。
2. 查询稳定性不同:
- B 树:查询某个数据可能在非叶子节点就命中(比如正好落在根节点),可能 1 次 IO 就返回;也可能要走到最底层的叶子,开销 IO 次数多一些。查询性能不稳定。
- B+ 树:所有数据都在叶子节点,任何查询都必须从根走到叶子,IO 次数等于树的高度,查询性能非常稳定。
3. 叶子节点结构不同(这是范围查询的根源):
- B 树:叶子节点各自独立,节点间没有链接。做范围查询需要中序遍历整棵树,从根节点去回溯父节点来确定下一个叶子是哪个,效率低。
- B+ 树:叶子节点之间通过双向链表连接,且节点内部数据按关键字顺序排列。做范围查询时,定位到起始叶子后,直接顺着链表往后扫即可,非常高效。
总结一句话: MySQL 选择 B+ 树,是因为数据库频繁的范围扫描和排序,而 B+ 树叶子节点的有序双向链表让这些操作天然支持。同时 B+ 树的层级更低,查询稳定,完美适配数据库磁盘 IO 的访问特性。
追问储备:
- B+ 树的高度一般是多少? 树高跟数据量和页大小有关。假设每页 16KB,一个索引键 8 字节 + 指针 6 字节 = 14 字节,每个非叶子节点可以存约 1170 个键值。三层的 B+ 树大概能存 2000 万行数据。所以一般 B+ 树的层高就 2~3 层,极少超过 4 层。
- B+ 树是棵平衡树吗? 绝对平衡,所有叶子节点到根的距离完全相同。这是 B+ 树分裂和合并机制保证的。
Q75:聚簇索引和非聚簇索引区别?什么叫回表?什么叫覆盖索引?
面试标准回答:
这三个概念是 MySQL 索引机制中最常被问到的组合问题,理解它们对 SQL 调优至关重要。
1. 聚簇索引(Clustered Index): 指数据行的物理存储顺序与索引的逻辑顺序一致。InnoDB 中,主键索引就是聚簇索引。它的叶子节点存储的是完整的行数据。因为数据行本身就在叶子节点中,所以通过主键查询可以一步到位查找到所有字段。
2. 非聚簇索引(Non-Clustered Index,也叫二级索引、辅助索引): 聚簇索引以外创建的索引都是二级索引。它的叶子节点不存储完整行数据,而是存储索引键值 + 对应的主键值。也就是说,通过二级索引找到一个键值后,只拿到了主键值。
3. 回表: 当查询需要的字段不在二级索引中时,通过二级索引先查到主键值,然后拿着这个主键值再去聚簇索引中查找一次完整的行数据。这个过程就叫回表。回表涉及两次 B+ 树的查找,性能会受影响。
4. 覆盖索引: 当查询语句所使用的字段全部包含在某个二级索引中时,就不再需要回表了。因为查询的所有数据都能在这个二级索引的叶子节点直接拿到。这种被查询语句完全"覆盖"的索引就叫覆盖索引。在 Explain 输出中,Extra 列会显示 “Using index”。
举例说明: 表 user(id, name, age),有联合索引 idx_name_age(name, age)。
- 查询
SELECT id, name, age FROM user WHERE name = '张三'→ 覆盖索引,数据全在 idx_name_age 叶子中,不需要回表。 - 查询
SELECT * FROM user WHERE name = '张三'→ 若 * 包含了不在 idx_name_age 中的字段(如 phone),则需要回表,先通过 idx_name_age 拿到主键 id,再根据 id 去聚簇索引中取完整行。
追问储备:
- 为什么二级索引不存储完整行数据? 为了节省空间。如果每个索引都存一份完整数据,磁盘占用会翻好几倍,且数据修改时需要更新所有索引,性能变差。
- 覆盖索引一定比回表快吗? 绝大多数情况下是。但有时如果主键索引已经缓存到内存中(buffer pool),回表的成本也会降低。但不能依赖这种偶然情况,调优时还是尽量构造覆盖索引。
Q76:联合索引的最左前缀法则?
面试标准回答:
最左前缀法则是联合索引(Compound Index)最重要的一条使用规则。它规定:查询条件必须从联合索引的第一个字段开始匹配,并且不能跳过中间的字段,这样才能利用到这个联合索引。
举例: 假设创建了联合索引 INDEX(a, b, c)。
能走索引的查询:
WHERE a = 1(匹配第一列,走了 a 的索引)WHERE a = 1 AND b = 2(匹配了 a 和 b)WHERE a = 1 AND b = 2 AND c = 3(匹配了 a、b、c 全列)WHERE a = 1 AND c = 3(能走到 a,但 c 被跳过的 b 阻断,索引覆盖只有 a 列)
不能走索引的查询:
WHERE b = 2(没有第一个字段 a,索引直接失效)WHERE b = 2 AND c = 3(还是没有 a)WHERE c = 3(缺少 a 和 b)
为什么叫"最左前缀"? 因为索引 B+ 树在构建时,首先按第一列排序,第一列相同再按第二列排序,以此类推。就像字典先按拼音首字母排序,字母相同再按第二个字母排序。你要找"ba"开头的字,如果直接从第二个字母 a 开始找,字典里根本没有这个顺序,自然无法利用索引。
范围查询的特殊规则: 如果查询中使用范围条件(> < BETWEEN LIKE 'abc%'),那么范围条件之后的索引列会失效。比如 WHERE a = 1 AND b > 10 AND c = 3,索引能走到 a 和 b(范围),但 b 用了范围之后 c 就无法走索引了。
追问储备:
- LIKE ‘%abc’ 能走索引吗? 不能,因为是左模糊,不满足最左前缀法则。但
LIKE 'abc%'可以,它相当于一个范围查询,abc 作为前缀在 B+ 树中是有序的。 - OR 条件会导致索引失效吗? 如果 OR 两边的字段都是同一个索引的字段(如 WHERE a = 1 OR a = 2),可以走索引。如果 OR 一边有索引一边没索引,优化器通常选择全表扫描。
Q77:索引失效的常见情况?
面试标准回答:
索引失效,简单说就是数据库优化器放弃使用索引,转而进行全表扫描。常见的有以下几种:
1. 在索引列上使用函数。 比如 WHERE YEAR(create_time) = 2025。优化器要比较的是 YEAR(create_time) 而不是 create_time 本身,而索引是根据 create_time 原始值排序的,函数破坏了有序性,导致索引失效。
2. 隐式类型转换。 当索引列是 varchar 类型,但查询条件是数字值时——WHERE phone = 13800138000(phone 是 varchar)。MySQL 会把所有的 phone 值通过 CONVERT 函数转成数字再和 13800138000 比较(因为数字比较效率高)。给列加了函数,索引就失效了。 反过来,如果列是 int 类型,查询条件是字符串 WHERE age = '25',会把字符串转成数字 25,列没有加函数,索引不会失效。
3. 左模糊查询。 WHERE name LIKE '%张'。B+ 树索引是按 name 值的顺序排列的,但左模糊意味着必须扫描所有 name 才能确定哪些是以 ‘张’ 结尾的,排序没用。所以要扫全表。LIKE '张%' 是可以走索引的。
4. 违反最左前缀法则。 对于联合索引 INDEX(a, b, c),如果查询条件跳过了第一列(比如 WHERE b = 1),索引直接失效。
5. OR 条件。 WHERE a = 1 OR b = 2,如果 a 有索引,b 没有,优化器就会想:反正要找 b 也要全表扫描,那 a 的索引用了也白用,不如直接全表扫。但如果 a 和 b 都有索引,优化器可能会选择分别走两个索引,然后合并结果(Extra 显示 Using union),不一定失效。
6. 负向查询。 WHERE id != 1、WHERE id NOT IN (1,2)。这些排除条件会让优化器认为大部分数据都需要返回(除非排除的只是极少数),走索引再回表的代价可能大于全表扫描,于是优化器选择不走索引。
7. IS NULL 和 IS NOT NULL。 在某些情况下也会导致索引失效,但取决于数据分布。
追问储备:
- 为什么不直接说"这几种情况索引就失效",而要说"优化器可能选择不走索引"? 因为索引是否生效最终由优化器的成本估算决定。有时即使满足了最左前缀,但如果预估需要回表的行数接近全表,优化器也可能放弃索引直接全表扫描。
- 能具体说一下隐式类型转换的内在机制吗? MySQL 中,当字符串和数字比较时,会把字符串转数字(因为数字比较效率更高)。转换需要在列值上进行,相当于给列加了 CAST 函数,违背了"不能对列用函数"的原则。
Q78:隐式类型转换为什么索引失效?
面试标准回答:
隐式类型转换导致索引失效的核心在于:MySQL 在执行比较时,会对索引列应用转换函数。根据索引的 B+ 树排序规则,索引是基于原始数据类型有序排列的,一旦对列值做了任何转换,排序顺序就被打破了,优化器无法利用这个有序结构快速定位,只能放弃索引。
实际例子: 假设 phone 字段是 VARCHAR(20),有一个索引 idx_phone(phone)。
- 查询:
SELECT * FROM users WHERE phone = 13800138000(数字值) - MySQL 解析这个查询时,发现等号两边类型不匹配。它不会把数字类型转成字符串,而是把列 phone 的值隐式转换成数字,等价于
WHERE CAST(phone AS UNSIGNED) = 13800138000。 - CAST 函数作用在列上,使得索引中的原始 VARCHAR 值在比较前被转换成了数字,索引的有序性被破坏,数据字典的顺序变成了转化后数字的序列,不是原始索引的序列。因此,查询变成了全表扫描。
反例(为什么另一种情况不会失效): 如果列 age 是 INT,查询条件是 WHERE age = '25'。MySQL 会选择把字符串 '25' 转换成数字 25(因为字符串转数字没有歧义),而不是给 age 列加函数。转换函数作用在常量值上,不会影响列的索引有序性,所以这个查询可以正常走索引。
追问储备:
- 为什么字符串转数字会有问题? 因为字符串如 ‘0100’ 和 ‘100’ 在字符串比较时不同,但转为数字都是 100。MySQL 不得不把所有列值转成统一的数字才能比较,列被函数包裹。
- 如何避免隐式转换? 在写查询参数时保持与应用定义的类型一致。如 phone 是字符串,传参就用
phone = '13800138000'。MyBatis 中使用#{}预编译占位符可以自动处理类型匹配,防止这类问题。
Q79:WHERE age = '26'(int 列查字符串值)会失效吗?为什么不会?
面试标准回答:
不会索引失效。 原因在于 MySQL 的类型转换方向:当整型列和字符串值比较时,MySQL 优先选择将字符串值转换为数字,而不是将整型列转换为字符串。这个转换发生在等式右边的常量上,而不是左边的列上。
具体来说,age 是 INT 列,查询条件是 WHERE age = '26'。MySQL 解析器会识别出左列是整型,右值是字符串。它会对右值进行类似 CAST('26' AS UNSIGNED) 的操作,将 '26' 转为数字 26,然后用 age = 26 去执行查询。这种转换对查询的索引列没有任何副作用,列的值保持不变,索引顺序仍然是整型的原生有序状态,所以优化器可以正常使用索引。
反例对比: 如果 phone 是 VARCHAR 列,条件是 phone = 13800138000(整数值),MySQL 走的是相反方向——将电话字符串转为整型再比较(因为整型比较效率高),此时转换作用在了左列 phone 上,破坏了索引有序性,进而导致索引失效。这就是隐式类型转换索引失效的典型场景。
追问储备:
- 字符串转数字为什么效率比反过来高? 原因在于整型比较是 CPU 的基础运算,非常快;而字符串比较需要逐字符比对,且涉及编码规则,成本高。MySQL 的优化器代价估算倾向于用整型比较。
- 如果列是 VARCHAR,传入 ‘123’ 这样的纯数字字符串也会类型转换吗? 会把 ‘123’ 当成字符串,正常走索引。但若传入 123(整型),就触发隐式类型转换,索引失效。所以务必保持参数类型与列类型一致,MyBatis 的
#{}可以帮助自动处理。
Q80:Explain 各字段含义?type 字段从好到差排序?
面试标准回答:
Explain 是 MySQL 提供的 SQL 执行计划查看工具,它告诉我们一条 SQL 语句如何执行——是否用到索引、扫描了多少行、使用什么连接方式等。调优时我们主要关注以下几个核心字段:
1. id: SELECT 查询的序号。值越大越先执行,相同时从上往下执行。通过它能看出子查询或 union 的执行顺序。
2. select_type: 查询类型,常见的有 SIMPLE(简单查询)、PRIMARY(最外层的查询)、SUBQUERY(子查询)、DERIVED(派生表/子查询在 from 中的临时表)、UNION(联合查询中的后续部分)。
3. table: 正在访问的是哪张表(或派生表的别名)。
4. type(最重要): 访问类型,表示 MySQL 在表中找到所需行的方式。从好到差排序如下:
- system: 表只有一行(系统表),这是最快的连接类型,几乎没见过。
- const: 通过主键或唯一索引查出一行,放在查询计划里当常量处理。比如
WHERE id = 1。 - eq_ref: 关联查询时,被驱动表的连接条件是主键或唯一索引,且只匹配一行。这是 join 操作中最好的访问方式。
- ref: 使用非唯一索引的等值查询,可能匹配多行。这是大多数正常查询能达到的级别。
- range: 索引范围查询,如
BETWEEN、>、<、IN等。至少优化到这个级别,否则全表扫描。 - index: 扫描整个索引树(Full Index Scan)。虽然走了索引,但遍历了整棵索引树,开销较大。
- ALL: 全表扫描,这是最差的情况,必须全力避免。
5. possible_keys: 查询可能使用到的索引。注意:不一定都用。
6. key: 实际使用的索引。如果为 NULL,说明没走索引。
7. key_len: 索引使用的字节数。通过它可以判断最左前缀法则中到底用到了联合索引的哪几列。
8. rows: 优化器预估的需要扫描的行数。这个数越小越好,越小说明过滤性好。
9. Extra: 包含额外重要信息:
- Using index: 覆盖索引,不需要回表,性能很好。
- Using where: 存储引擎返回行后,Server 层还需要用 WHERE 条件过滤。
- Using temporary: 使用了临时表,通常出现在 GROUP BY 和 ORDER BY 不同字段的情况,需要优化。
- Using filesort: 没有利用索引排序,使用了外部文件排序。对于 ORDER BY 的查询,如果驱动表用不到索引排序,就出现这个,需要重点关注。
追问储备:
- 什么级别算是及格的 SQL? 对于单表查询,至少应达到
range;对于联表查询,驱动表尽量ALL或index不要紧,被驱动表最好能达到eq_ref或ref。 - key_len 怎么辅助判断最左前缀使用了多少列? 联合索引用到的字段根据字节长度表现。比如 utf8mb4 的 varchar(50) 占 50*4+2 字节,联合索引三列用到几列就从 key_len 判断出来。
Q81:Redis 为什么快?
面试标准回答:
Redis 的单机 QPS(每秒查询数)可以达到 10 万级别,比传统关系型数据库快几个数量级。它之所以这么快,主要归功于四个方面:
1. 纯内存操作。 这是 Redis 快的最根本原因。Redis 把所有数据都存放在内存中,读写操作直接与内存打交道。内存的访问速度是磁盘的 10 万倍以上——磁盘的一次寻址大约需要几毫秒,而内存只需要纳秒级。传统数据库的数据最终要落到磁盘上,每次读写都可能触发磁盘 IO,Redis 完全避开了这个瓶颈。
2. 单线程模型(核心执行单线程 + 网络 IO 多线程)。 Redis 6.0 之前是纯单线程,6.0 之后引入了多线程处理网络 IO(将网络请求的读取和写入交给多线程),但命令的执行依然是单线程。单线程的好处是完全避免了多线程的锁竞争和 CPU 上下文切换开销。而且 Redis 的性能瓶颈不在 CPU 的命令执行上(一条命令只需要微秒级),真正的瓶颈在网络的读写——所以 6.0 把网络 IO 改为多线程,命令执行保持单线程,是一个非常精准的优化。
3. 高效的数据结构。 Redis 为每种数据类型量身定制了底层数据结构,针对内存操作做了极致优化。比如:String 类型使用 SDS(简单动态字符串),可以 O(1) 获取长度、防止缓冲区溢出;List 使用 QuickList(结合了 ziplist 和 linkedlist 的优点);ZSet 使用跳表 + 哈希表的组合,保证范围查找和单点查询都是 O(log n)。这些数据结构在时间和空间上都做到了非常好的平衡。
4. IO 多路复用。 Redis 采用 epoll(Linux 环境下)作为 IO 多路复用机制。一个单线程能同时监听成千上万个客户端连接——当某个连接有数据到达时,epoll 通过事件通知主线程去处理,而不是让线程轮询或阻塞等待。主线程只处理"准备好"的连接,最大化 CPU 利用率。
追问储备:
- Redis 6.0 之前号称单线程,是完全只有一个线程吗? 不是。主线程处理命令确实是单线程,但后台线程也在干活,比如持久化(RDB/AOF)、异步删除大 key、集群通信等。
- 单线程处理所有命令,会不会因为一个慢命令卡住整个 Redis? 会。这就是 Redis 的一个经典问题——大 key 的 DEL 命令、KEYS 命令等 O(n) 操作会阻塞后续所有命令。解决方案是使用
UNLINK替代DEL(异步删除),用SCAN替代KEYS(分批迭代)。
Q82:Redis 五种基本数据类型?各自使用场景?
面试标准回答:
Redis 有五种基本数据类型,每种类型的底层实现和适用场景都不同。
1. String(字符串): 最基本的数据类型,可以存储任意形式的字符串(JSON、序列化对象、整数值)。内部通过 SDS(简单动态字符串)实现。
- 应用: 缓存(JSON 序列化对象)、计数器(
INCR原子递增)、分布式锁(SETNX + 过期时间)、Session 共享。
2. Hash(哈希): 类似于 Java 的 HashMap,field-value 结构。适合存储对象的多个属性,可以直接读写对象的某个字段,无需整体序列化。
- 应用: 购物车(用户 ID → 商品 ID → 数量)、用户信息(分散字段更新)、配置项存储。
3. List(列表): 有序可重复的字符串链表,插入顺序保持。支持从两端压入和弹出。
- 应用: 消息队列(LPUSH/RPOP 生产者消费者模式)、最新动态列表、时间线。底层用 QuickList(ziplist + linkedlist 结合体)。
4. Set(集合): 无序、不可重复的字符串集合。支持交集、并集、差集操作。
- 应用: 共同好友(交集)、抽奖(SRANDMEMBER 随机抽取)、去重、用户标签。
5. ZSet(有序集合): 在 Set 基础上为每个元素关联一个分数(score),按分数排序。底层用跳表 + 哈希表实现。
- 应用: 排行榜(实时积分排名)、延迟队列(按时间戳排序)、热搜榜。
追问储备:
- 除了这五种,还有哪些扩展数据类型? Redis 5.0 引入 Stream(消息流),6.2 后增强了 Bitmap(位图,统计签到)、HyperLogLog(基数统计,UV 统计)、GEO(地理位置计算)。
- ZSet 为什么用跳表而不用红黑树? 跳表实现简单,支持范围查找的效率与红黑树接近(O(log n)),且跳表在范围查询时不需要像红黑树那样做中序遍历,代码更直观。另外跳表的层级可以通过随机算法控制,插入和删除的开销更低。
Q83:缓存穿透、击穿、雪崩区别和解决方案?
面试标准回答:
这三个"缓存问题"是高并发场景下的经典难题,核心区别在于触发条件和针对的目标不同。
1. 缓存穿透: 查询的数据在 Redis 和 MySQL 中都不存在。每次查询都穿过了缓存层,直接打到数据库上。如果有恶意攻击者用大量不存在的 key 频繁请求,缓存形同虚设,数据库承受巨大压力。
解决方案:
- 布隆过滤器(最推荐): 在 Redis 前面加一层布隆过滤器。把所有合法数据的 key 通过多个哈希函数映射到位数组上。查询时先问布隆,判定不存在就一定不存在,直接拒绝;判定存在才走缓存。虽然有概率误判,但可设为万分之一以下。
- 缓存空值: 查询不到时,把 key 对应的 value 设为 null 或空,设置一个较短的过期时间(比如 5 分钟)。下次再查同一 key 就走缓存返回空值,不查数据库。
- 参数校验: 在入口层对请求参数做合法性过滤,比如 ID 必须满足长度、格式要求。
2. 缓存击穿: 某个热点 key 在过期的一瞬间,大量并发请求同时穿过缓存,直接打到数据库上。比如一个爆款商品的详情页缓存刚好过期,几千个请求同时查 MySQL。
解决方案:
- 互斥锁(分布式锁): 当缓存未命中时,允许一个线程去数据库查并回写缓存,其他线程自旋等待或休眠后重试查缓存。
- 逻辑过期(永不过期): 热点 key 不设物理过期时间,而是在 value 中存储一个逻辑过期时间戳。读取时判断逻辑时间是否过期,如果过期则异步更新缓存,当前请求返回旧值。这样不会阻塞用户请求。
3. 缓存雪崩: 大量 key 在同一时间过期,或者Redis 整个服务宕机,导致海量请求同时打到 MySQL。这往往是因为缓存预热时所有 key 用了一样的过期时间。
解决方案:
- 随机过期时间: 设置过期时间时,在基础 TTL 上加上一个随机值(比如 3600s + random(0~3600)s)。
- 多级缓存: 本地缓存(Caffeine / Guava)+ Redis + MySQL 组成三级缓存,Redis 挂了本地缓存还能挡一波。
- Redis 高可用: 主从复制 + 哨兵机制,保证 Redis 集群不宕机。
- 服务降级和限流: 当缓存雪崩发生时,对非核心业务做降级处理,核心业务限流保护数据库。
追问储备:
- 缓存空值和布隆过滤器怎么选择? 缓存空值更简单,但占用额外的缓存空间;布隆过滤器节省空间但实现稍复杂,且无法删除已添加的 key(除非用 Counting Bloom Filter)。
- 逻辑过期和互斥锁怎么选? 逻辑过期允许返回"旧数据",适合对数据实时性要求不高的场景;互斥锁能保证返回最新数据,但会有短暂的阻塞。
Q84:布隆过滤器原理?
面试标准回答:
布隆过滤器是一种空间效率极高的概率性数据结构,用于判断一个元素是否在一个集合中。核心特点是——“说不存在,一定不存在;说存在,则可能存在,也可能不存在”(存在一定的误判率)。
工作原理: 布隆过滤器底层是一个位数组(Bitmap) 和 多个哈希函数。
- 添加元素: 当一个元素要加入布隆过滤器时,用 K 个哈希函数分别对这个元素计算哈希值,得到 K 个位置,然后把位数组中这 K 个位置全部设为 1。
- 查询元素: 查询一个元素是否在集合中时,同样用这 K 个哈希函数计算出 K 个位置,然后检查这些位置是否都是 1。如果任意一个位置是 0,则该元素一定没有被添加过(不存在一定不存在)。如果所有位置都是 1,则该元素可能被添加过(存在可能不存在,因为这些 1 可能是被其他元素在之前设置的)。
为什么会有误判? 因为不同的元素经过哈希函数后,可能映射到相同的位。随着插入的元素越来越多,位数组中被设为 1 的位也越来越多。即使某个元素没有被添加过,它经过哈希计算的位置可能已经被其他元素全部置为 1,导致误判为"存在"。
如何控制误判率? 通过调整两个参数:位数组的大小 m 和 哈希函数的个数 k。位数组越大,误判率越低;哈希函数个数有一个最优值(大约 k = m/n × ln2,n 为预估的元素数量),过多或过少都会影响准确性。
追问储备:
- 布隆过滤器能删除元素吗? 传统的布隆过滤器不能直接删除。因为删除某个元素需要将它的 K 个位置全部清零,但可能这些位置同时也与其他元素相关,导致误删。可以通过 Counting Bloom Filter(将每个位换成计数器,删除时计数减一)来支持删除,但会牺牲部分空间效率。
- Redis 里怎么用布隆过滤器? Redis 4.0 之后通过模块(RedisBloom)提供了布隆过滤器功能,命令包括
BF.ADD、BF.EXISTS。或者可以用 Bitmap 配合多个哈希函数手动实现。
Q85:Redis 过期策略和内存淘汰策略?
面试标准回答:
Redis 通过过期策略和内存淘汰策略两套机制来管理内存。过期策略决定了已设置过期时间的 key 何时被删除;淘汰策略决定了当内存满时,该踢掉哪些 key 为新数据腾出空间。
过期策略(两种配合使用)
1. 惰性删除(Lazy Expiration): 当客户端访问某个 key 时,Redis 先检查这个 key 是否设置了过期时间、是否已过期。如果过期了,就立刻删除它,然后返回"不存在"。优点是 CPU 友好,只在访问时检查;缺点是如果过期 key 很久没被访问,它就一直占着内存,形成"过期垃圾"。
2. 定期删除(Active Expiration): Redis 每隔一段时间(默认 100ms),随机抽取一定数量的带过期时间的 key,检查其中过期的并删除。定期删除是惰性删除的补充——它扫除那些长期不被访问的"过期垃圾"。优点是内存友好,能逐步清理;缺点是随机抽检可能会遗漏一些过期 key,而且一次性检查太多 key 会消耗 CPU。
两者互补:惰性删除保证 CPU 不过载,定期删除保证内存不被大量过期 key 撑爆。
内存淘汰策略(八种)
当 Redis 内存使用达到 maxmemory 上限时,触发内存淘汰。Redis 6.0 之后有八种策略:
1. noeviction(默认): 不淘汰任何数据,对所有写操作返回错误。适用于保险优先,不丢数据的场景。
2. allkeys-lru: 在所有 key 中,淘汰最近最少使用的 key(LRU 近似算法)。这是最常用的策略。
3. allkeys-lfu(4.0 引入): 在所有 key 中,淘汰使用频率最低的 key(LFU)。
4. volatile-lru: 只在设置了过期时间的 key 中,淘汰最近最少使用的。
5. volatile-lfu: 只在设置了过期时间的 key 中,淘汰使用频率最低的。
6. volatile-ttl: 只在设置了过期时间的 key 中,淘汰即将过期的(剩余 TTL 最短的)。
7. volatile-random: 只在设置了过期时间的 key 中,随机淘汰。
8. allkeys-random: 在所有 key 中,随机淘汰。
追问储备:
- Redis 的 LRU 是精确的吗? 不是。Redis 采用近似 LRU 算法:随机抽取少量 key(默认 5 个),淘汰其中最久未被访问的那个。这种方式比精确 LRU 省内存,而且性能损失极小。
- LRU 和 LFU 的区别? LRU 关注"最近是否被访问",LFU 关注"访问频率"。LFU 更能反映长期热点,避免偶然访问导致热点被误淘汰。
Q86:RDB 和 AOF 持久化区别?混合持久化是什么?
面试标准回答:
Redis 是内存数据库,数据在内存中是易失的。为了在宕机或重启后不丢数据,Redis 提供了两种持久化机制:RDB 和 AOF。
1. RDB(Redis Database,快照持久化): 每隔一段时间,把内存中的全量数据写入磁盘,生成一个二进制快照文件(dump.rdb)。触发生成 RDB 的方式有自动触发(配置 save m n 规则,m 秒内 n 个 key 发生变化就触发)和手动触发(SAVE 阻塞主线程,BGSAVE fork 子进程异步保存)。
- 优点: 文件紧凑(压缩的二进制格式),恢复速度快,适合做冷备份和灾备。
- 缺点: 实时性差——两次快照之间如果宕机,最后一次快照之后的数据全部丢失。而且 fork 子进程时如果数据量大,可能短暂阻塞主线程。
2. AOF(Append Only File,命令日志持久化): 记录服务器执行的每条写命令,以追加的方式写入 AOF 文件。当 Redis 重启时,通过回放 AOF 文件中的命令来恢复数据。
- 优点: 数据安全性高,可以做到每秒刷盘一次(
appendfsync everysec),宕机最多丢 1 秒数据。AOF 文件是纯文本,可读可改。 - 缺点: AOF 文件通常比 RDB 文件大;恢复速度比 RDB 慢(需要回放所有命令);随着时间推移 AOF 会持续膨胀,需要定期进行 AOF 重写(rewrite)来压缩。
3. 混合持久化(Redis 4.0 引入): 结合 RDB 和 AOF 的优势。在 AOF 重写时,子进程先把当前内存中的数据以 RDB 格式写入 AOF 文件(作为文件头),再把重写期间的增量写命令以 AOF 格式追加到文件末尾。这样生成的 AOF 文件前半部分是紧凑的 RDB 数据(恢复快),后半部分是增量命令(不丢数据)。
- 优点: 恢复速度快(大部分数据通过 RDB 加载),数据安全性高(增量命令通过 AOF 保证)。
追问储备:
- 生产环境怎么配持久化策略? 通常同时开启 RDB 和 AOF,AOF 刷盘频率设为
everysec。如果对性能敏感,也可以只用 RDB + 冷备份,接受少量数据丢失。 - AOF 重写的原理? fork 一个子进程,读取当前内存数据库生成一个新的 AOF 文件(只包含恢复当前状态所需的最小命令集)。重写过程中父进程继续处理新命令,这些新命令会同时追加到旧 AOF 文件和重写缓冲区中;子进程完成后,父进程把重写缓冲区中的增量命令追加到新文件,然后原子的替换旧文件。
Q87:分布式锁 SETNX + 过期时间有什么问题?Redisson 看门狗怎么解决?
面试标准回答:
最简单的 Redis 分布式锁实现是使用 SET key value NX EX 30 命令——NX 保证只有不存在的 key 才能 SET 成功(互斥),EX 设置 30 秒过期时间防止死锁。但这个方案有两个致命缺陷:
问题 1:锁提前过期。 业务逻辑执行时间可能超过锁的过期时间。比如锁设了 30s,但业务由于 GC 停顿或网络延迟跑了 35s,在 30s 时锁自动过期了,另一个线程获取到了锁,两个线程同时操作临界资源,锁的保护就失效了。
问题 2:误删他人锁。 线程 A 的业务还没执行完,锁自动过期了。线程 B 获取到了锁。线程 A 的业务最终执行完了,执行了 DEL 释放锁——但它释放的是线程 B 的锁!此时线程 C 又能获取锁,连锁引发一系列并发安全问题。
问题 2 的解决方案——Lua 原子脚本: 释放锁之前,先判断锁的 value 是否是自己设置的(比如 value 存当前线程的唯一标识 UUID)。只有是自己的锁才执行 DEL。而且判断和删除必须用 Lua 脚本原子执行,因为在 Redis 中,GET 和 DEL 是两个命令,中间可能被打断。Lua 脚本执行期间不会被其他命令插入,保证了判断+删除的原子性。
问题 1 的解决方案——Redisson 看门狗(Watchdog): Redisson 是 Redis 的 Java 客户端,提供了分布式锁的高级封装。看门狗机制的原理是——锁默认过期时间设为 30 秒(lockWatchdogTimeout)。加锁后,Redisson 会启动一个后台定时任务(看门狗),每隔 10 秒检查一次:如果锁还在被当前线程持有(即未手动调用 unlock),则自动将锁的过期时间续期回 30 秒。只要服务进程正常运行,看门狗就持续续期,锁永远不会在业务完成前过期。当手动调用 unlock 或进程宕机时,看门狗也随之消失,锁在 30 秒后自动过期,不会死锁。
追问储备:
- 看门狗默认续期间隔为什么是 10 秒? 这是为了防止锁因网络延迟或其他短期问题而意外过期。10 秒的间隔是 30 秒的 1/3,既能及时续期又不至于太频繁消耗资源。
- 如果业务代码中忘了 unlock 怎么办? 看门狗会因为线程仍持有锁而持续续期,直到进程结束。锁在进程存活期间不会被释放,会导致死锁。这就是为什么必须在 finally 块中调用 unlock。
Q88:Redis 主从切换锁丢失怎么办?RedLock 原理和争议?
面试标准回答:
主从切换丢锁的原因: Redis 的主从复制是异步的。假设客户端在主节点上成功获取了锁(SET NX 成功),主节点还没来得及把这条数据同步给从节点就宕机了。随后哨兵将从节点提升为新主节点,但新主节点上并没有那把锁的信息。此时另一个客户端向新主节点请求同样的锁,也能获取成功,导致两把锁同时存在,分布式锁互斥失效。
RedLock(红锁)原理: 为了应对主从切换丢锁的问题,Redis 作者 Antirez 提出了 RedLock 算法。它的思路是——不要只在一个 Redis 实例上加锁,而是跨多个完全独立的主节点(通常 5 个)加锁。
加锁流程:客户端依次向 N 个独立的 Master 节点请求加锁,设置较短的锁超时时间(通常远小于锁的有效时间)。当且仅当在超过一半(N/2+1)的节点上加锁成功,且总耗时小于锁的有效时间,才算最终获取到了锁。如果加锁失败(没跨过半数的节点),客户端必须向所有节点发送释放锁的请求。
这样,即使某个主节点宕机,只要其余半数以上的节点还持有这把锁,另一个客户端在试图加锁时无法在半数以上节点成功,就不会出现双锁并发问题。
争议(Martin Kleppmann 的批判):
- GC 导致锁提前过期: 客户端 A 获取锁后,由于长时间的 Full GC 停顿,等它醒来时锁已经在所有节点上过期了。此时客户端 B 获取到了锁。A 不知道自己停过,继续执行,导致冲突。所以任何基于 TTL 的锁本质上是不安全的。
- 时钟跳跃问题: RedLock 的安全性依赖系统时钟的单调性。如果某个节点的系统时间被人为调快或调慢,锁的过期判定可能出错。分布式系统中,各个节点的时钟不可能完全同步。
Antirez 的反驳: 他认为在实际生产环境中,长时间 GC 停顿非常罕见,而且可以通过设置合理的锁超时时间(比如 30 秒)来大大降低风险。RedLock 虽然不能解决所有理论安全的问题,但它能在绝大多数场景下提供比单节点锁更高的可靠性。
追问储备:
- Martin 推荐什么替代方案? 他推荐用 Fencing Token(防护令牌)——每次获取锁时返回一个单调递增的 token,在写回存储层时带上这个 token,存储层只接受 token 大于等于当前已处理的最大 token 的操作。这本质上是乐观锁的思想。
- 生产环境该用 RedLock 还是单节点锁? 如果一致性要求极高(金融领域),可以考虑 ZooKeeper(基于临时节点,依赖心跳而非 TTL)。对于互联网大并发场景,单节点 Redis 锁 + 看门狗 + 主从高可用一般就够了。
Q89:缓存和数据库一致性怎么保证?
面试标准回答:
这是缓存与数据库交互中核心的难题。因为 Redis 和 MySQL 是两套独立的系统,操作顺序和中间故障必然导致短暂的不一致,我们追求的通常是最终一致性而不是即时强一致。
1. 旁路缓存(Cache Aside Pattern,最常用): 更新数据库,然后删除缓存。
- 读流程:先读缓存,命中则返回;未命中去数据库查,查到后写回缓存并返回。
- 写流程:先更新数据库,然后删除缓存(而不是更新缓存)。
为什么是删除缓存而不是更新缓存? 因为许多缓存不是从单表直接取的,而是经过了复杂计算。更新缓存的成本可能很高,而且如果写入很频繁而读很少,更新的这些缓存都没有被读到,白白浪费性能。直接删除缓存,等下次请求再用 Lazy 加载的方式重建缓存,更高效。
为什么是先更新数据库,再删除缓存? 如果反过来操作——先删缓存再更新数据库,会出现一个灾难性问题:A 先删了缓存,B 读请求发现缓存没有就去数据库查,查到旧数据写回缓存。接着 A 更新数据库。此时缓存里是旧数据,数据库是新数据,这个不一致会一直持续到缓存过期。
2. 延时双删: 在更新数据库完成后,休眠一定时间(比如 500ms),再删一次缓存。这可以在极高并发的场景下消除缓存重建的竞态。
3. 订阅 MySQL binlog + MQ 异步更新: 通过 Canal 等工具监听 MySQL 的 binlog,当数据发生变化时,把变化的 key 发给消息队列,由后端消费者去更新或删除对应的缓存。这种方式是异步的,可以实时感知数据变化,一致性较高。
追问储备:
- 删除缓存失败了怎么办? 重试机制——把删除失败的 key 放入消息队列,利用消费者重试直到删除成功。如果缓存长时间不一致,可以给缓存设置过期时间(TTL),让不一致自然过期修复。
- 为什么不建议缓存设置过长的 TTL? TTL 太长,数据不一致窗口变长;TTL 太短,缓存命中率降低。一般根据业务容忍不一致窗口来定,比如推荐类数据可设 1~5 分钟,支付类数据需要更短的 TTL。
Q90:Redis 哨兵机制?主观下线和客观下线?故障转移流程?
面试标准回答:
Redis Sentinel(哨兵)是一个分布式系统,用于对 Redis 主从结构进行监控、故障通知和自动故障转移。它由多个哨兵节点组成,共同协作来保证 Redis 的高可用。
核心功能:
- 监控: 哨兵定期向主节点和从节点发送 PING 命令,检查它们是否在线。
- 通知: 发现某个节点有问题时,通过 API 通知管理员或其他系统。
- 自动故障转移: 如果主节点挂了,哨兵会从从节点中选举一个新主节点,并让其他从节点切换到新主。
主观下线(SDOWN): 每个哨兵节点独立判断。当某个哨兵在指定时间内(down-after-milliseconds)未收到目标节点的 PONG 回复,它就主观认为该节点不可达了,将标记为 主观下线(Subjectively Down)。
客观下线(ODOWN): 主观下线只是单点判断,可能因为网络抖动等原因误判。当多数(quorum)哨兵都认为主节点主观下线时,哨兵通过投票达成共识,将主节点标记为 客观下线(Objectively Down)。只有客观下线才会触发故障转移。
故障转移流程:
- 主节点被判定为客观下线,哨兵集群选举出一个 Leader 哨兵来执行故障转移。
- Leader 哨兵从所有可用的从节点中,按照优先级(越小优先级越高)、复制偏移量(offset 越大数据越新)、runid 最小(字典序) 的顺序选出一个最优的从节点,将其提升为新主节点。
- 被选中的从节点执行
SLAVEOF NO ONE,成为新主节点。 - Leader 哨兵让其他从节点改为复制新主节点(
SLAVEOF newMaster)。 - 当旧主节点恢复上线时,会被自动降级为新主节点的从节点。
追问储备:
- 哨兵集群最少需要几个哨兵? 最少 3 个,且建议部署为奇数(3、5 等)。哨兵需要多数票,2 个哨兵如果其中一个挂了,剩下的一个达不到 quorum,无法判断客观下线。
- 哨兵和集群(Cluster)模式的区别? 哨兵解决高可用问题(主从自动切换),集群解决水平扩展问题(数据分片)。两者可结合使用,也可以直接用 Redis Cluster 模式,Cluster 内置主从切换机制。
Q91:Redis 集群模式?16384 个哈希槽怎么分配?
面试标准回答:
Redis Cluster(集群模式)是 Redis 的分布式方案,用于解决单节点容量有限和性能瓶颈的问题。它在多个主节点之间进行数据分片(Sharding),每个主节点只负责一部分数据,从而实现水平扩展。
数据分片——16384 个哈希槽: Redis Cluster 不采用一致性哈希,而是使用了 16384 个哈希槽(slot) 来管理数据分布。为什么是 16384?这是一段经典的故事——16384 是 2 的 14 次方,足够在 1000 个节点间均匀分布(每个节点约 16 个槽)。相比于 65536,心跳包大小更小(每个节点维护所有槽的状态,槽越多,心跳包越大)。
哈希槽分配规则: 对每个 key 做 CRC16(key) % 16384,计算出这个 key 属于哪个槽。集群中每个主节点负责一部分槽(比如节点 A 负责 0-5460,节点 B 负责 5461-10922,节点 C 负责 10923-16383)。
请求路由: 客户端可以向集群中任何节点发请求。如果 key 对应的槽就在这个节点上,直接处理。如果不在,节点会返回一个 MOVED 重定向,告诉客户端"你要找的 key 在别的节点上,请去 XX 节点访问"。客户端收到 MOVED 后会切换连接到正确的节点,并缓存槽和节点的映射关系,下次直接命中。
重定向的两个类型:
- MOVED: 槽已经永久迁移到了别的节点,客户端应立即切换并更新缓存。
- ASK: 槽正在迁移过程中(部分 key 还在旧节点,部分已迁到新节点),客户端临时去新节点查一次。ASK 是一次性的,不更新客户端缓存。
主从高可用: 集群中每个主节点都配备一个或多个从节点。当主节点宕机时,它的从节点通过选举成为新主节点,接管原来负责的哈希槽。集群内部的心跳和故障检测机制类似哨兵。
追问储备:
- 集群模式有什么限制? 多 key 操作(如 mget)要求所有 key 在同一个槽中;事务(MULTI/EXEC)只能在同一个节点内执行;无法跨节点使用 Lua 脚本。如果跨节点用多个 key,可以使用
hash_tag(用花括号把 key 的一部分包起来,如{user:1}:name和{user:1}:age),确保它们落入同一个槽。 - 集群和哨兵模式怎么选? 如果数据量不大(10GB 以内),哨兵模式(主从 + 哨兵)就够用,部署简单。数据量大需要水平扩展就选集群模式。两者也可以混用,集群内部每个分片本身是主从结构,自带哨兵功能。
Q92:Java 8 Stream 怎么用?中间操作和终端操作的区别?
面试标准回答:
Stream 是 Java 8 引入的一套函数式数据处理 API,让我们可以用声明式的方式对集合进行过滤、映射、排序、聚合等操作,代码更简洁易读。Stream 的操作分为两类:中间操作和终端操作。
中间操作(Intermediate Operation): 这类操作返回的是一个新的 Stream 对象,因此可以链式调用。中间操作是惰性求值的——它们不会立刻执行,只是记录下来要做的操作步骤,等到终端操作被调用时才真正开始处理数据。常见的有:
filter(Predicate):过滤,保留满足条件的元素。map(Function):将每个元素映射成另一个值。flatMap(Function):将每个元素映射成一个 Stream,然后把所有 Stream 展平合并。distinct():去重。sorted():排序。limit(n):截取前 n 个元素。skip(n):跳过前 n 个元素。peek(Consumer):对每个元素执行一个副作用操作,常用于调试。
终端操作(Terminal Operation): 这类操作会触发 Stream 管道中所有操作的执行,并产生一个最终结果。执行完毕后,这个 Stream 就被消费完了,不能再复用。常见的有:
forEach(Consumer):遍历每个元素。collect(Collector):将元素收集到集合中(List、Set、Map 等)。toList()/toSet()(Java 16+):直接收集为 List 或 Set。count():统计元素个数。reduce(identity, BinaryOperator):归约操作,将元素反复合并成一个值(如求和)。anyMatch(Predicate)/allMatch()/noneMatch():检查是否有/全部/没有元素满足条件(短路操作)。findFirst()/findAny():返回第一个/任意一个元素(短路操作)。
惰性求值的好处: 没有终端操作时,中间操作只是一个计划。当终端操作执行时,数据并不是一次性处理完一个步骤再进入下一个步骤,而是每个元素在管道中完整走完所有中间操作,再处理下一个元素(类似流水线加工)。这可以减少中间临时集合的创建,提高性能。
追问储备:
- 并行流怎么用? 通过
parallelStream()获取一个并行流,底层使用 ForkJoinPool 自动将数据分片,多线程并行处理。注意并行流不一定比串行快——数据量小或任务有 IO 操作时,线程切换开销可能大于收益。 - Stream 和集合的区别? 集合关注"存储"(数据静态存放),Stream 关注"计算"(数据如何流动和处理)。流用完就没了,不存储数据。
Q93:BIO 和 NIO 的区别?NIO 的三大核心组件?
面试标准回答:
BIO 和 NIO 是 Java 中两种完全不同的 I/O 模型,它们的核心区别在于线程模型和阻塞机制。
BIO(Blocking I/O,同步阻塞 I/O):
- 模型:一个连接对应一个线程(Thread-Per-Connection)。
- 工作原理:服务端为每个客户端连接分配一个独立线程。线程调用
read()时,如果客户端还没有发数据,这个线程会傻等(阻塞),直到有数据到达。在此期间线程做不了任何其他事情。 - 缺点:如果连接数很高(比如 10 万个并发连接),就需要 10 万个线程。每个线程分配独立的栈空间(默认 1MB),内存直接爆炸;而且大量的线程上下文切换会吃掉 CPU。
- 适用:连接数少、数据传输密集的早期应用,现在基本被淘汰。
NIO(Non-blocking I/O,同步非阻塞 I/O):
- 模型:一个线程管理成千上万个连接(通过 Selector 多路复用)。
- 工作原理:把所有连接注册到一个 Selector(多路复用器) 上。单一线程循环调用
selector.select(),它只返回那些确实有数据可读或可写的连接的列表。然后这个线程逐一处理这些就绪的连接,处理完立刻回头继续 select。线程不会在没数据的连接上浪费时间。 - 优点:用极少的线程(甚至可以是一个)支撑极高的并发连接。极大的内存和 CPU 都得到节省。
- 适用:高并发短连接或长连接场景,如聊天服务器、推送服务。主流的网络框架 Netty 就是基于 NIO 实现的。
NIO 的三大核心组件:
- Buffer(缓冲区): NIO 中所有数据的读写都必须经过 Buffer。它是一个内存数组,提供了位置指针和容量限制,可以双向读写。常用的实现有 ByteBuffer、CharBuffer 等。
- Channel(通道): 代替了 BIO 的 Stream(流)。Channel 是全双工的——既可以读也可以写。Stream 是单向的(InputStream 只能读,OutputStream 只能写)。常见实现:SocketChannel、ServerSocketChannel、FileChannel。
- Selector(选择器 / 多路复用器): NIO 的灵魂。一个 Selector 可以同时监控多个 Channel 的状态(连接就绪、读就绪、写就绪)。当某个 Channel 有事件就绪时,Selector 会通过
select()返回这些 Channel 的集合,线程只处理这些就绪的 Channel。
追问储备:
- NIO 是真正的异步 I/O 吗? 不是,NIO 是非阻塞 I/O,AIO(Asynchronous I/O,NIO 2.0)才是真正的异步 I/O。NIO 的 select 操作是阻塞等待就绪事件(但可以设超时),而 AIO 是完全基于回调的。
- BIO 和 NIO 的应用差异? 当下几乎所有高并发 Java 服务器都用 NIO。BIO 在一些简单场景(比如老旧的 Tomcat 连接池)还有遗留。
Q94:什么是 IO 多路复用?Redis 怎么用它的?
面试标准回答:
IO 多路复用是一种用一个线程同时监听多个 I/O 事件的技术。它的核心思想是:把多个 I/O 流(网络连接、文件描述符)注册到一个复用器上,线程阻塞在这个复用器上等待,一旦某个流有数据可读或可写,复用器就通知线程去处理。
打个比方:一个餐厅的大堂经理(单线程)同时盯着 100 桌客人(100 个连接)。他不可能一桌一桌去问"你要点菜了吗?"(轮询,效率低)。他站在大厅中央,哪桌客人举起手(发来数据事件),他看到了就去服务哪桌。这就是多路复用的精髓——把"主动询问"变为"被动通知"。
在 Linux 上,IO 多路复用有 select、poll、epoll 三种系统调用。epoll 是最先进的,它通过事件驱动和内存映射,解决了 select 和 poll 需要线性扫描所有连接、且连接数有上限的问题。epoll 能轻松处理百万级并发连接。
Redis 如何使用 IO 多路复用: Redis 的主线程就是一个单线程 Reactor 模型,底层依赖 epoll。Redis 服务启动时,创建一个 epoll 实例,把所有客户端连接的 socket 文件描述符注册到 epoll 中。主线程在一个循环中调用 epoll_wait(),阻塞等待事件。当某个客户端发送命令过来时,epoll 返回就绪的文件描述符列表。主线程取出列表,逐一读取命令并执行,执行结果也通过文件描述符写回客户端。整个过程中,Redis 的主线程不需要为每个客户端创建一个线程,也不需要挨个轮询,而是靠 epoll 的事件通知高效处理海量并发。
追问储备:
- 为什么 Redis 不直接用多线程处理所有命令? 因为 Redis 的性能瓶颈在网络 IO 而非 CPU。单线程避免了锁竞争和上下文切换。6.0 之后只在网络 IO 层引入多线程,命令执行仍单线程,精准优化了瓶颈点。
- epoll 为什么比 select 高效? select 每次调用都需要把全部文件描述符从用户态拷贝到内核态,且内核需要遍历所有 fd 才能找到就绪的。epoll 通过
epoll_ctl提前注册 fd,通过epoll_wait直接返回已就绪的 fd 列表,省去了拷贝和遍历的开销。epoll 使用红黑树存储 fd,用链表返回就绪事件。
Q95:fail-fast 和 fail-safe 的区别?
面试标准回答:
这两个概念描述的是集合在迭代过程中,如果结构被修改,迭代器会做出怎样的反应。
fail-fast(快速失败): 当迭代器遍历集合时,如果发现集合的结构被修改了(比如新增或删除了元素),而且这个修改不是通过迭代器自身的 remove 方法进行的,迭代器会立刻抛出 ConcurrentModificationException,停止遍历。Java 标准库中 java.util 包下的集合类(如 ArrayList、HashMap)都是 fail-fast 的。
实现原理:迭代器在创建时会记录一个 expectedModCount,它等于集合的 modCount(修改计数)。每次调用 next() 时,迭代器都会检查 expectedModCount 是否还等于 modCount。如果不相等,说明集合被外部修改了,立刻抛异常。
fail-safe(安全失败): 当迭代器遍历集合时,即使原集合的结构被修改了,迭代器也不会抛异常。它是在遍历一个原集合的副本,因此外部修改不影响这个副本的遍历。java.util.concurrent 包下的并发集合类(如 ConcurrentHashMap、CopyOnWriteArrayList)的迭代器都是 fail-safe 的。
实现原理:ConcurrentHashMap 的迭代器遍历的是数组的当前快照,不会因遍历期间的 put/remove 抛异常,但可能漏掉或重复某些更新。CopyOnWriteArrayList 在每次修改时复制整个底层数组,迭代器遍历的是旧数组,修改操作在新数组上,互不影响。
核心区别总结:
- fail-fast 通过检查
modCount,修改立刻报错,适合快速定位并发 Bug。 - fail-safe 通过副本或快照,修改不报错,适合高并发环境的容忍性,但代价是可能读取到过期数据。
追问储备:
- fail-fast 的迭代器能自己安全删除元素吗? 可以,通过迭代器自身的
remove()方法。这个方法删除后会自动更新内部的expectedModCount,保证下次检查通过。 - 单线程下也会 fail-fast 吗? 会。如果在增强 for 循环里调 list.remove(),即使只有单线程,也会抛 ConcurrentModificationException。
Q96:深拷贝和浅拷贝的区别?
面试标准回答:
深拷贝和浅拷贝是对一个对象进行复制时,对引用类型字段的处理方式不同。
浅拷贝(Shallow Copy): 复制对象时,基本数据类型字段复制值,引用类型字段复制的是引用(即复制指针,而不是指针指向的对象)。也就是说,原对象和拷贝对象内部的引用类型字段,指向的是堆中的同一个对象。修改拷贝对象里这个引用指向的内容,原对象也会跟着变。
Java 中实现浅拷贝:让类实现 Cloneable 接口,重写 clone() 方法(默认的 Object.clone() 就是浅拷贝)。或者通过 BeanUtils 等工具。
深拷贝(Deep Copy): 复制对象时,基本类型复制值,引用类型字段复制的是整个被引用的对象及其内部所有嵌套对象(递归复制到底)。原对象和拷贝对象完全独立,互不影响。
Java 中实现深拷贝:
- 手动递归复制每个引用字段,直到所有引用都被完整复制。
- 通过序列化和反序列化(如
ObjectMapper将对象序列化成 JSON 再反序列化回来,或通过 Java 原生序列化ByteArrayOutputStream+ObjectOutputStream)。这种方式要求所有嵌套类都实现Serializable。 - 使用第三方库如 Apache Commons 的
SerializationUtils.clone()。
举例: 假设类 A 包含一个 int[] 字段。
- 浅拷贝:拷贝后的 A’ 内部的
int[]字段,和原 A 的int[]字段是同一个数组对象。修改 A’ 的数组,A 也会变。 - 深拷贝:拷贝时创建了一个全新的数组对象,并把原数组的值复制进去。A’ 的数组和 A 的数组是两个独立的对象。
追问储备:
- clone() 是深拷贝还是浅拷贝?
Object.clone()默认是浅拷贝,只复制引用。要实现深拷贝必须在 clone 方法里递归调用子对象的 clone 方法。 - 不推荐用 clone 的原因? clone 的实现复杂,容易出现浅拷贝遗漏;而且 Cloneable 是一个标记接口,破坏 Java 设计原则。现代开发更倾向用拷贝构造器或序列化方式。
Q97:对象创建的过程?内存分配策略?
面试标准回答:
Java 对象的创建过程在 JVM 层面分为四个主要步骤,同时可能伴随类加载。
1. 类加载检查(Class Loading Check): 当虚拟机遇到 new 指令时,首先检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化。如果没有,则必须先执行相应的类加载过程。
2. 内存分配(Memory Allocation): 类加载完成后,JVM 为新生对象在堆中分配内存。对象所需的内存大小在类加载完成后就已经确定。分配方式有两种:
- 指针碰撞(Bump the Pointer): 如果堆内存是规整的(Serial、ParNew 等带压缩整理的收集器),用过的内存和空闲内存中间有一个指针作为分界点。分配时只需把指针向空闲空间那边挪动与对象大小相等的距离即可。
- 空闲列表(Free List): 如果堆内存不规整(CMS 基于标记清除),已用内存和空闲内存相互交错,虚拟机会维护一个列表记录哪些内存块可用。分配时从列表中找出足够大的块划给对象,更新列表。
TLAB(Thread Local Allocation Buffer): 为了避免多线程分配内存时的并发竞争,JVM 为每个线程在堆中预先分配一小块私有内存(TLAB)。线程在自己的 TLAB 中分配对象,TLAB 用完了再申请新的。这大幅减少了同步开销。通过 -XX:+UseTLAB 开启(默认开启)。
3. 初始化零值(Zeroing): 分配完内存后,JVM 将分配到的内存空间全部初始化为零值(不包括对象头)。这样,Java 对象的实例字段在代码中没有赋初始值就直接使用,访问到的是这些字段的零值(int 是 0,boolean 是 false,引用类型是 null)。
4. 设置对象头(Object Header Setup): JVM 对对象进行必要的设置,比如这个对象是哪个类的实例、对象的哈希码、GC 分代年龄、是否开启偏向锁等。这些信息存放在对象头中。
5. 执行 <init> 方法: 以上步骤完成后,从 JVM 视角来看,一个新对象已经诞生了。但从 Java 程序的视角来看,对象创建才刚刚开始——接下来会执行构造方法(<init>),按照开发者的意愿对对象进行初始化。
追问储备:
- 对象头里存放什么信息? 对象头分为 Mark Word(哈希码、GC 分代年龄、锁状态标志等,动态变化)和 Klass Pointer(指向方法区中的类元数据的指针)两部分。
- TLAB 能完全消除分配竞争吗? 不能完全消除,但能大幅减少。TLAB 用完后需要从堆中再申请新块,这个申请过程需要加锁。可以通过
-XX:TLABSize调整每个线程 TLAB 的大小。
Q98:对象什么时候进老年代?
面试标准回答:
新生代中的对象在满足一定条件后,会被**晋升(Promotion)**到老年代。主要有四种情况:
1. 对象年龄达到阈值(默认 15 岁)。 对象每熬过一次 Minor GC 且还存活,它的分代年龄加一。当年龄达到 MaxTenuringThreshold(默认 15)时,对象晋升到老年代。每次 Minor GC 后,Survivor 中的对象年龄都会增加,JVM 通过 -XX:MaxTenuringThreshold 控制这个上限。
2. 大对象直接分配在老年代。 当一个对象的大小超过了 -XX:PretenureSizeThreshold 参数(默认 0,即所有对象都先尝试在 Eden 区分配),并且 JVM 判定如果放在新生代可能会导致复制开销过大,就会直接把对象分配到老年代。常见的长字符串、大数组都属于大对象。
3. 动态年龄判断(Survivor 空间占满)。 并不是一定要年龄达到 15 才晋升。如果在 Survivor 空间中,相同年龄的所有对象的大小之和,超过了 Survivor 空间的一半,那么年龄大于等于这个年龄的对象,都可以直接进入老年代,无需等到 15 岁。这是为了 Survivor 不被持续占满。
4. 空间分配担保(Handle Promotion Failure)。 当发生 Minor GC 时,如果 Survivor 空间不够存放存活的对象,老年代会作为担保空间——直接把 Survivor 放不下的对象送到老年代。如果老年代也放不下了,会触发 Full GC。
追问储备:
- 为什么晋升年龄默认是 15? 对象头中只用了 4 个 bit 存储分代年龄,最大值就是二进制的 1111,即 15。所以不能超过。
- 为什么老年代不回收频繁? 老年代存活率高,且空间大。回收老年代需要 Full GC(G1 除外),Full GC 的 STW 时间长,对应用影响大,所以希望对象尽量在新生代就被回收掉。
Q99:JVM 调优常用参数有哪些?
面试标准回答:
JVM 调优参数非常多,但在日常生产中最常接触和调整的主要是以下几类:
1. 堆内存设置(最核心):
-Xms:堆初始大小。通常和-Xmx设置一样,避免堆扩容带来的性能波动。-Xmx:堆最大大小。根据物理内存和业务需要设置,一般不超过物理内存的 75%。-Xmn:新生代大小(等价于-XX:NewSize和-XX:MaxNewSize同时设置)。新生代越大,Minor GC 越频繁但单次回收快;新生代越小,Minor GC 间隔长但单次成本高。-XX:NewRatio:老年代与新生代的比例。比如-XX:NewRatio=2表示新生代占整个堆的 1/3,老年代占 2/3。
2. 垃圾收集器选择与调优:
-XX:+UseG1GC:使用 G1 收集器(JDK 9 以后默认)。-XX:+UseConcMarkSweepGC或-XX:+UseParNewGC:使用特定的年轻代或老年代收集器(JDK 9 后逐渐淘汰)。-XX:MaxGCPauseMillis:G1 的预期最大停顿时间。设置小一点(如 100ms),G1 每次回收更少的 Region,停顿更短但吞吐会下降;设大一点(如 500ms),停顿变长但吞吐上升。-XX:InitiatingHeapOccupancyPercent:触发 Mixed GC 的堆占用阈值,默认 45%。
3. 内存溢出排查参数(必配):
-XX:+HeapDumpOnOutOfMemoryError:OOM 时自动生成堆转储文件。-XX:HeapDumpPath=/path/to/log:指定 dump 文件的存放路径。-XX:OnOutOfMemoryError:发生 OOM 时执行指定的脚本(如重启服务或发送告警)。
4. GC 日志参数:
- JDK 8 及之前:
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/gc.log - JDK 9 及之后(统一日志系统):
-Xlog:gc*:file=/path/gc.log:time,level,tags新版的统一日志参数更灵活,可以精细控制日志级别和输出目标。面试时提到版本差异能体现你对 JDK 演进的关注。
5. 元空间设置:
-XX:MetaspaceSize:元空间初始大小。超过此值会触发 Full GC。-XX:MaxMetaspaceSize:元空间最大值。限制加载类的数量,防止元空间无限膨胀。
6. 线程栈大小:
-Xss:每个线程的栈大小,默认约 1MB。如果应用的线程很多,可以适当调小;如果有很深的递归,可以适当调大。
7. 其他常见参数:
-XX:+DisableExplicitGC:忽略System.gc()的调用,防止代码显式触发 Full GC。
追问储备:
- -Xms 和 -Xmx 设成一样的好处? 防止 JVM 在运行过程中动态调整堆大小(扩容收缩),减少堆大小变化带来的 GC 开销和系统停顿。
- G1 的 MaxGCPauseMillis 设多少合适? 没有固定值,需要根据实际业务的延迟敏感度来压测。常见设置范围是 200~500ms。
Q100:类加载器有哪些?Bootstrap、Extension、Application 分别加载什么?
面试标准回答:
Java 的类加载器分为三个主要层次,分别加载不同来源的类库。它们不是通过继承关系关联的,而是通过**双亲委派(Parents Delegation)**的组合关系相互委派。
1. Bootstrap ClassLoader(启动类加载器):
- 负责加载 Java 最核心的类库,主要是
<JAVA_HOME>/lib下的 jar 包,或者-Xbootclasspath参数指定的 jar 包。这部分包括rt.jar、resources.jar、charsets.jar等。 - 它是 JVM 的一部分,由 C++ 实现,在 Java 代码中用
null表示(因为 Java 层无法引用到 native 层的加载器)。
2. Extension ClassLoader(扩展类加载器):
- 负责加载
<JAVA_HOME>/lib/ext目录下的 jar 包,或者java.ext.dirs系统变量指定的路径中的 jar 包。 - 这是一个 Java 类(
sun.misc.Launcher$ExtClassLoader),以 Bootstrap ClassLoader 为其父加载器。
3. Application ClassLoader(应用程序类加载器 / 系统类加载器):
- 负责加载用户类路径(Classpath)下所指定的 jar 包和目录下的类。就是我们平时写的业务代码、以及 Maven/Gradle 引入的第三方库。
- 这也是一个 Java 类(
sun.misc.Launcher$AppClassLoader),以 Extension ClassLoader 为其父加载器。 - 通过
ClassLoader.getSystemClassLoader()获取到的就是它。
JDK 9 模块化后的变化: JDK 9 引入了模块系统(JPMS),类加载器架构变为:仍然保留 Bootstrap ClassLoader、Platform ClassLoader(替代 Extension)、Application ClassLoader。但不再是严格的层次结构,各模块的依赖和访问权限受严格的封装规则管控。
追问储备:
- 什么是线程上下文类加载器(Thread Context ClassLoader)? 它是与线程绑定的类加载器,可以从
Thread.currentThread().getContextClassLoader()获取。用于打破双亲委派,比如 JDBC 的 SPI 就是通过它加载第三方驱动的。 - 自定义类加载器的场景? 加载加密后的类文件、从网络或数据库中加载类、实现热部署(不同版本的同一个类需要隔离)。
Q101:Spring MVC 请求处理流程?DispatcherServlet 怎么工作?
面试标准回答:
Spring MVC 的请求处理流程是围绕前端控制器 DispatcherServlet 展开的,它是一个分发中心,负责协调各个组件完成请求的接收、处理和响应。
完整流程:
请求进入 DispatcherServlet: 客户端的所有 HTTP 请求首先到达
DispatcherServlet。它是 Spring MVC 的核心,也是一切请求的总控。映射处理器(HandlerMapping):
DispatcherServlet根据请求的 URL、HTTP 方法等信息,调用HandlerMapping找出哪个 Controller 的哪个方法应该处理这个请求。HandlerMapping返回一个HandlerExecutionChain,它不仅包含找到的 Controller,还包含对该请求适用的拦截器(HandlerInterceptor)。适配处理器(HandlerAdapter): 找到 Handler(Controller 方法)之后,
DispatcherServlet需要执行它。不同类型的 Controller(如@Controller注解的类、HttpRequestHandler 等)有不同的执行方式。HandlerAdapter作为适配器,负责统一调用这些不同类型的 Handler。最常用的是RequestMappingHandlerAdapter。执行拦截器 preHandle: 在真正执行 Controller 方法前,执行所有匹配到的拦截器的
preHandle()方法。如果有任何一个拦截器返回false,后面的所有流程直接终止。参数解析与调用 Controller 方法:
HandlerAdapter调用目标方法。方法调用前,Spring 会通过一系列参数解析器(ArgumentResolver) 自动解析方法参数(比如@RequestParam、@RequestBody、@PathVariable等),并传入正确的值。处理返回值: 方法执行完毕返回一个结果(可能是视图名、
ModelAndView、@ResponseBody直接返回对象)。HandlerAdapter拿到返回值后,通过返回值处理器(ReturnValueHandler) 决定如何处理这个返回值。执行拦截器 postHandle: 如果之前有拦截器,此时执行它们的
postHandle()方法。视图解析(如果返回视图): 如果返回值是视图名,
DispatcherServlet调用ViewResolver将视图名解析为实际的View对象(JSP、Thymeleaf 等),然后执行视图渲染。消息转换(如果返回 JSON/XML): 如果注解了
@ResponseBody(或类上@RestController),Spring 使用 HTTP 消息转换器(HttpMessageConverter) 将返回对象序列化为 JSON(通常用MappingJackson2HttpMessageConverter),并设置 Content-Type 为application/json,写入响应体。执行拦截器 afterCompletion: 最终,执行拦截器的
afterCompletion()方法,做清理工作(无论有没有异常都会执行)。
追问储备:
- DispatcherServlet 是单例还是多例? 单例。它的无状态特性允许作为单例处理所有请求。
- @RestController 和 @Controller 的区别?
@RestController等于@Controller+@ResponseBody,表示所有方法都直接返回数据(JSON)而不走视图解析器。
Q102:Spring 事务传播行为有哪些?REQUIRED 和 REQUIRES_NEW 的区别?
面试标准回答:
Spring 事务传播行为(Propagation Behavior)定义了当前存在事务时,方法如何参与事务,或者在方法被调用时,事务该怎样传播。Spring 共定义了七种传播行为,以下是最关键的几种:
1. REQUIRED(默认): 支持当前事务。如果当前有事务,就加入这个事务;如果当前没有事务,就新建一个事务。这是最常用的策略,确保所有操作要么全成功要么全失败。
2. REQUIRES_NEW: 挂起当前事务(如果存在),并启动一个全新的事务。新事务独立提交或回滚,与外层事务互不影响。外层事务和新事务有各自的数据库连接,各自提交和回滚。
3. SUPPORTS: 支持当前事务。如果当前有事务,就加入;如果没有,就以非事务方式执行(不新建事务)。
4. NOT_SUPPORTED: 以非事务方式执行。如果当前有事务,就把当前事务挂起,方法执行在没有任何事务的环境中。
5. MANDATORY: 强制要求当前必须有事务。如果没有事务,直接抛异常。
6. NEVER: 强制要求当前不能有事务。如果有事务,直接抛异常。
7. NESTED: 如果当前有事务,则执行一个嵌套事务。嵌套事务可以看作外层事务的一个"保存点(Savepoint)",嵌套事务回滚只回滚到这个保存点,不影响外层事务;外层事务回滚,嵌套事务也跟着回滚。
REQUIRED 和 REQUIRES_NEW 的区别举例: 方法 A 调用方法 B。
- 如果 B 的传播行为是 REQUIRED,那么 B 使用和 A 同一个事务。一旦 B 抛出异常,A 也回滚。
- 如果 B 的传播行为是 REQUIRES_NEW,那么 A 的事务被挂起,B 在自己的独立事务中运行。B 抛出异常只回滚 B 自己的事务,A 可以不受影响继续执行(如果 A 捕获了 B 的异常),也可以受影响(如果 A 不捕获 B 的异常)。
追问储备:
- REQUIRES_NEW 底层怎么实现的? Spring 会为 REQUIRES_NEW 分配一个新的数据库连接,在新连接上开启新事务。原连接的旧事务被挂起。
- NESTED 和 REQUIRES_NEW 的区别? NESTED 依赖 JDBC 的 Savepoint 机制,回滚的是同一个事务的一个阶段;REQUIRES_NEW 是完全独立的事务,两个事务互不影响。NESTED 性能比 REQUIRES_NEW 好,因为不用分配新连接。
Q103:Spring 的 BeanFactory 和 ApplicationContext 的区别?
面试标准回答:
BeanFactory 和 ApplicationContext 都是 Spring 的 IoC 容器,负责管理 Bean 的生命周期和依赖关系。但 ApplicationContext 是 BeanFactory 的增强版,提供了更多企业级功能。
1. 延迟加载 vs 预加载(核心区别):
BeanFactory:采用延迟加载(Lazy Loading) 策略。容器启动时不创建 Bean,直到第一次调用getBean()时才实例化。这样可以节省启动时间,但启动后不知道 Bean 是否有问题。ApplicationContext:采用预加载(Eager Loading) 策略。容器启动时就把所有单例 Bean 全部实例化。这样启动稍慢,但启动后 Bean 全就绪,运行快,而且启动阶段就能发现 Bean 的配置错误。
2. 功能差异:
BeanFactory只提供最基本的 IoC 功能——创建 Bean、注入依赖。ApplicationContext在BeanFactory基础上扩展了大量企业级功能:自动识别 Spring 配置、国际化支持(MessageSource)、事件发布(ApplicationEvent和ApplicationListener)、资源加载(ResourceLoader可以加载文件、URL 等)、统一的资源访问方式。
3. 使用场景:
BeanFactory适用于资源受限的环境(如 Applet、嵌入式设备),不关心 Bean 的验证和扩展功能。ApplicationContext是在 Web 应用、企业服务器中永远的首选。
4. 关系: ApplicationContext 并不是继承 BeanFactory,而是通过组合持有一个 BeanFactory 实例(通常是 DefaultListableBeanFactory),在它之上提供扩展功能。
追问储备:
- ApplicationContext 三种常见实现?
ClassPathXmlApplicationContext(从 Classpath 加载 XML 配置)、FileSystemXmlApplicationContext(从文件系统加载 XML)、AnnotationConfigApplicationContext(基于 Java 配置和注解,Spring Boot 主要使用)。 - Spring 用 ApplicationContext 还是 BeanFactory 来管理 Bean? Spring 实际使用
DefaultListableBeanFactory(同时实现了BeanFactory和BeanDefinitionRegistry),但外部暴露为ApplicationContext,让开发者享受扩展功能。
Q104:@Autowired 和 @Resource 的区别?
面试标准回答:
@Autowired 和 @Resource 都可以用来实现依赖注入,但它们来源不同、注入策略不同、支持的参数也不同。
1. 来源不同:
@Autowired是 Spring 框架 提供的注解(org.springframework.beans.factory.annotation.Autowired),是 Spring 生态的核心。@Resource是 JDK(JSR-250) 提供的注解(javax.annotation.Resource),是 Java 标准,Spring 也支持它。
2. 注入策略不同(核心区别):
@Autowired默认按类型(byType) 注入。如果找到多个同类型的 Bean,则根据名称(byName)进一步筛选(通过@Qualifier指定名称,或把字段名作为 Bean 名称去匹配)。@Resource默认按名称(byName) 注入。它会优先根据@Resource(name = "xxx")中指定的 name 去匹配 Bean;如果没有指定 name,则使用被注解的字段名 或方法参数名 作为 Bean 名称去查找。如果按名称找不到,再回退到按类型(byType) 查找。
3. 注解位置不同:
@Autowired可以用在字段、构造方法、Setter 方法、普通方法参数上。@Resource可以用在字段、Setter 方法上,不能用在构造方法上。
4. 强制注入 vs 可选注入:
@Autowired默认要求依赖必须存在(required = true)。如果找不到对应的 Bean,Spring 启动时会抛出异常。可以通过设置@Autowired(required = false)变为可选。@Resource默认也要求依赖存在,但缺少required属性。如果 Bean 找不到,容器启动时也会抛异常。
举例:
| |
追问储备:
- 什么情况下用 @Resource 更好? 当有多个同类型的 Bean,且你明确想按名称注入时,用
@Resource更直观(默认就是按名称)。用@Autowired也可以配合@Qualifier达到同样效果。 - 构造器注入和字段注入哪个更好? 构造器注入(
@Autowired在构造器上)更好,因为它能保证依赖不可变(final),也便于单元测试 Mock。字段注入(直接在字段上@Autowired)代码简洁,但不利于测试,且依赖隐藏不满意。
Q105:MySQL 有哪些锁?(表锁/行锁/共享锁/排他锁/记录锁/间隙锁/临键锁)
面试标准回答:
MySQL 的锁体系是保证并发事务正确性的核心机制。按不同的维度可以划分出多种类型。
按锁的粒度:
- 表锁: 锁住整张表。MyISAM 引擎主要使用表锁。优点是开销小、不会死锁;缺点是并发度极低,写操作会阻塞所有其他读写。
- 行锁: 锁住表中的某些行。InnoDB 引擎默认使用行锁。优点是并发度高;缺点是开销大,加锁慢,容易死锁。
按锁的类型(访问权限):
- 共享锁(S 锁 / 读锁): 多个事务可以同时对同一行加共享锁,并读取数据,但不能修改。共享锁之间兼容,与排他锁互斥。
- 排他锁(X 锁 / 写锁): 一个事务对某行加排他锁后,其他事务不能再对该行加任何锁,直到排他锁释放。用于写操作。
InnoDB 的行锁实现(三种具体的行锁):
- 记录锁(Record Lock): 锁定索引中的某一条记录。例如
SELECT * FROM t WHERE id = 10 FOR UPDATE会对 id=10 这条索引记录加锁。 - 间隙锁(Gap Lock): 锁定索引记录之间的间隙,但不包含记录本身。目的是防止其他事务在间隙中插入新记录,从而解决幻读问题。例如
WHERE id BETWEEN 10 AND 20 FOR UPDATE,如果表中只有 id=10 和 id=20 两条记录,间隙锁会锁住 (10,20) 这个开区间。 - 临键锁(Next-Key Lock): 记录锁 + 间隙锁 的组合。锁住一个左开右闭的区间。InnoDB 在可重复读(RR)隔离级别下,默认使用临键锁来同时防止幻读和不可重复读。例如上述例子,临键锁会锁住 (10, 20] 以及 (20, +∞) 的相应间隙,彻底堵死插入。
意向锁(Intention Lock): 表级锁,用于协调行锁和表锁的冲突。当事务要加行锁时,先自动在表上加意向锁(意向共享锁 IS 或意向排他锁 IX),然后才申请行锁。这样另一个事务想加表锁时,只需要检查表上有没有意向锁,而不需要逐行检查有没有行锁,提高了效率。
追问储备:
- 为什么 InnoDB 要用间隙锁,会引发什么问题? 间隙锁解决了幻读,但降低了并发度,因为一个事务锁住间隙后,其他事务无法在该间隙插入。此外,间隙锁之间不冲突(可以同时有多个间隙锁),但容易发生死锁。
- 怎么查看当前锁的状态?
SHOW ENGINE INNODB STATUS或SELECT * FROM performance_schema.data_locks(MySQL 8.0+)。
Q106:主从复制原理?读写分离怎么做?
面试标准回答:
主从复制原理: MySQL 的主从复制是一种异步复制机制,将主库上的写操作(DDL 和 DML)同步到一台或多台从库上,从而实现数据冗余、读写分离和备份。
复制流程分三步:
- 主库写 binlog: 主库上所有修改数据的操作(INSERT、UPDATE、DELETE)都被记录到 binlog(二进制日志)中,按事务提交顺序严格记录。
- 从库拉取 binlog: 从库启动一个 IO 线程,连接主库,请求从某个 binlog 位置开始读取。主库为此启动一个 Binlog Dump 线程,将 binlog 内容推送给从库。从库的 IO 线程将收到的日志写入本地的中继日志(Relay Log) 中。
- 从库回放 Relay Log: 从库的 SQL 线程 读取 Relay Log 中的事件,并在从库上重放执行,完成数据同步。
复制模式(binlog 格式):
- Statement 格式: 记录 SQL 语句原样。优点是日志量小;缺点是不确定函数(如 NOW())会导致主从结果不一致。
- Row 格式: 记录每一行数据的实际变化。优点是准确;缺点是日志量大。
- Mixed 格式: 默认用 Statement,有不确定时自动切换到 Row。
读写分离怎么做: 读写分离的目的是让主库只管写,从库分摊读请求,提升读吞吐。
实现方式:
- 客户端路由(代理层): 引入一个中间件(如 MyCat、ShardingSphere、Atlas),它解析 SQL 类型,将写/事务操作发给主库,读操作随机或轮询分给从库。
- 应用层路由(自研): 在代码中通过注解或 AOP 决定数据源。Spring 提供
AbstractRoutingDataSource及动态数据源切换功能,可以根据线程上下文决定路由到主库还是从库。
主从延迟问题: 因为复制是异步的,从库的数据可能比主库滞后几秒。对于写入后立即读取的场景(如刚下单就查订单详情),如果读请求落在从库可能读不到最新数据。解决方案:对于这类实时性要求高的写后读,强制走主库。
追问储备:
- 主从延迟严重怎么办? 优化主库的 binlog 刷盘和同步速度;从库用更好的硬件;考虑半同步复制(主库等至少一个从库确认收到 binlog);或者干脆强走主库。
- 什么是 GTID 复制? Global Transaction ID,不用记 binlog 文件和偏移量,每个事务有全局唯一 ID,从库自动追踪已复制的 GTID 集合,方便故障切换。
Q107:分库分表什么时候需要考虑?
面试标准回答:
分库分表是将原来存放在单一数据库、单一数据表中的数据,按某种规则拆分到多个数据库或多个表中,以解决单库单表性能瓶颈的问题。
什么时候需要考虑分库分表?(达到以下其中的一两个信号)
- 数据量过大,单表撑不住。 单表数据量达到上千万级(根据业务和硬件不同而不同,有说 500 万~2000 万)。此时 B+ 树的层级可能增加,索引覆盖不够,查询和写入性能明显下降。
- 写并发过高,单库扛不住。 数据库的 TPS 打满,磁盘 IO 成为瓶颈,即使加了索引和缓存也难以缓解。
- 存储空间不足。 单台机器硬盘快满了,无法继续扩容。
- 业务隔离需要。 部分业务数据敏感或访问模式完全不同,从业务出发需要分离开,方便独立扩容和维护。
常见的分片方案:
- 水平分表: 在同一个数据库里,把一张大表的数据按行拆分到 N 张结构相同的小表(如
order_0、order_1…)。 - 水平分库: 把同一张表的数据拆分到不同的数据库实例上,每个实例里表结构相同。
- 垂直分库: 按业务模块拆分,把用户库、订单库、商品库拆分成各自独立的数据库。
- 垂直分表: 把宽表的大字段或不常用字段拆到扩展表中,减少主表的宽度和扫描代价。
分库分表带来的挑战(必须准备):
- 分布式 ID 生成: 不能用自增主键(不同库各自自增会重复),需要全局唯一 ID 生成方案(如雪花算法 Snowflake)。
- 跨库事务: 分库后传统事务失效,需要分布式事务(Seata、消息队列最终一致性、TCC)。
- 跨库查询: 之前一条 SQL 能搞定的分页、排序、关联,现在要自己拼结果。需要用中间件(如 ShardingSphere)或应用层代码合并。
- 路由规则: 必须通过分片键(sharding key)确定数据落在哪个库哪个表。如果查询不带分片键,就要广播到所有分片再合并,性能极差。
追问储备:
- 为什么不建议过早分库分表? 复杂度极高。先通过优化 SQL、加索引、分库、读写分离、归档历史数据等方案解决。分库分表是最后的核武器。
- 常用的分片中间件有哪些? ShardingSphere(原 Sharding-JDBC)、MyCat、Vitess(YouTube 出的,用于大规模 MySQL 集群)。
Q108:慢 SQL 怎么排查优化?
面试标准回答:
排查慢 SQL 是一个系统的优化流程,目标是找到执行效率低下的 SQL,分析原因并进行优化。
1. 开启慢查询日志。 设置参数:
slow_query_log = ON:开启慢查询日志。long_query_time = 1:超过 1 秒的 SQL 会被记录。log_queries_not_using_indexes = ON:同时记录未使用索引的查询。slow_query_log_file = /path/to/slow.log:日志存放位置。 这样慢 SQL 就会被记录到文件,以便后续分析。
2. 使用工具分析。 拿到慢日志后,用 mysqldumpslow 或 Percona Toolkit 的 pt-query-digest 工具分析,找出执行次数最多、耗时最长的 SQL。
3. 用 Explain 分析执行计划。 找到目标 SQL 后,在 SQL 前加 EXPLAIN 查看执行计划。
- 重点检查 type 字段:至少要达到
range级别,ALL是最差的全表扫描。 - 检查 key 字段:是否使用了预期的索引。如果是
NULL,说明没走索引。 - 检查 rows 字段:预估扫描的行数是否过大。
- 检查 Extra 字段:
Using filesort(需要文件排序,说明 ORDER BY 没走索引)、Using temporary(使用了临时表,通常 GROUP BY 和 ORDER BY 不同字段导致)、Using where(服务层还需要过滤)。
4. 确认索引是否失效。 常见失效原因:
- 索引列上用了函数(
WHERE YEAR(create_time) = 2025) - 隐式类型转换(字符串列 vs 整数值)
- 左模糊查询(
LIKE '%abc') - 违反最左前缀法则
- OR 操作涉及无索引列
5. 优化手段:
- 加索引: 根据需要创建合适的索引(单一、联合、覆盖索引)。避免过多索引(写入变慢)。
- 改写 SQL: 将子查询改为 JOIN;用
LIMIT限制返回行;避免SELECT *;使用小表驱动大表。 - 优化分页: 大偏移量的
LIMIT N, M改为基于上一次查询结果的WHERE id > last_id LIMIT M。 - 架构优化: 通过 Redis 缓存热点数据;读写分离分摊读压力;最终分库分表。
追问储备:
- Explain 的 key_len 怎么用? key_len 显示索引使用的字节数。对于联合索引,可以通过 key_len 判断最左前缀到底用了几个字段。
- 慢 SQL 一定是问题吗? 不一定。一些统计类型的后台导出任务允许慢,只要不影响主业务。建议将慢查询日志和分析工具结合起来。
Q109:Redis 每种数据类型的底层数据结构?
面试标准回答:
Redis 为五种基本数据类型设计了多种底层实现,根据存储的数据量和特征动态切换,以在时间和空间上取得最优平衡。
1. String(字符串): 底层用 SDS(Simple Dynamic String,简单动态字符串)。它比 C 语言原生的 char* 多了 O(1) 获取长度、自动预分配空间、二进制安全等特性。
2. List(列表): 底层用 QuickList(快速列表)。Redis 3.2 之前用的是 linkedlist(双向链表)和 ziplist(压缩列表)的组合;3.2 后统一为 QuickList。QuickList 是 ziplist 的列表——每个 QuickList 节点包含一个 ziplist,节点之间用双向链表连接。它兼具了链表插入快和 ziplist 内存紧凑的优点。
3. Hash(哈希字典): 底层有两种:
- ziplist(压缩列表): 当元素数量少(默认 ≤ 512 个)且每个 field 和 value 的长度短(默认 ≤ 64 字节)时使用。特点:连续内存、极省空间。
- hashtable(哈希表): 超过上述阈值后转为真正的哈希表(类似
HashMap),支持快速 O(1) 查找。
4. Set(集合): 底层有两种:
- intset(整数集合): 当集合的所有元素都是整数,且数量不多(默认 ≤ 512 个)时使用。特点:内存连续、有序、可二分查找。
- hashtable(哈希表): 不满足 intset 条件时转为字典,key 是集合元素,value 是 NULL。
5. ZSet(有序集合): 底层是 skiplist(跳表)+ hashtable(字典) 的组合。
- 跳表负责按 score 排序(支持范围查找,如
ZRANGE),每个跳表节点还存储成员的值。 - 字典负责按成员名快速 O(1) 找到它的 score(支持
ZSCORE)。 - 早期少量数据时也用 ziplist 存储。
追问储备:
- ziplist 是什么? 一段连续内存,按照
<length><content>的格式紧凑存放多个元素,节省内存但插入删除需要移动后续元素。只能用于少量元素场景。 - 为什么 ZSet 用跳表而不用红黑树? 跳表实现更简单,范围查找同样高效,插入删除时跳表通过随机层数控制平衡,避免了红黑树的复杂旋转操作。且 Redis 的跳表可以方便地支持按排名查找(
ZRANK)。
Q110:缓存预热怎么做?
面试标准回答:
缓存预热是当系统启动或缓存被清空后,提前将热点数据加载到缓存中,避免线上流量直接打到后端数据库导致数据库压力过高、响应延迟变大。
实现方式:
线上增量预热(最常用): 系统启动时不一次性加载全部数据,而是在正常处理请求时,发现缓存未命中,就查询数据库并回写缓存。这就是典型的旁路缓存模式。这种方式简单,不需要代码预热逻辑。缺点是前几波请求会穿透缓存直接打到数据库,可能造成启动时瞬时压力。
后台全量预热: 在系统启动时,启动一个后台线程,将全部热点数据从数据库加载到缓存中,完成后再对外开放服务。适用于数据量不大、启动必须满载的场景。缺点是启动时间延长。
定时任务预热: 用定时任务(如 Spring
@Scheduled或 xxl-job)定期扫描数据库中最新的热门数据,刷新缓存。适用于热点数据变化有规律的场景(如每天更新一次热搜榜)。基于消息队列的异步预热: 当其他服务更新了数据,发送 MQ 消息,消费者监听到后更新对应的缓存。保证缓存实时性。
防止缓存雪崩的预热技巧: 在做全量预热时,所有 key 如果使用相同的过期时间,到期时间点一到,缓存全部失效,造成雪崩。所以预热时为每个 key 设置基础 TTL + 随机偏移值。比如原本 3600 秒的缓存,在此之上加一个 random(0~3600) 秒的偏移值,把过期时间打散在一个范围内。
追问储备:
- 预热和缓存穿透的区别? 预热是提前准备已知存在的数据,避免查询流向数据库;穿透是处理不存在的数据,需要通过布隆过滤器或缓存空值防御。
- 缓存预热的缺点是什么? 占用了启动时间,并可能将冷数据也加载进内存浪费缓存空间。需要根据业务评估预热数据量和频率。
Q111:TCP 三次握手和四次挥手?为什么三次、为什么四次?
面试标准回答:
三次握手——建立连接:
- 第一次握手(客户端 → 服务端): 客户端发送 SYN 报文(SYN=1,随机初始序列号 seq=x),客户端进入 SYN_SENT 状态。
- 第二次握手(服务端 → 客户端): 服务端收到 SYN 后,回复 SYN+ACK 报文(SYN=1,ACK=1,确认号 ack=x+1,随机序列号 seq=y),服务端进入 SYN_RCVD 状态。
- 第三次握手(客户端 → 服务端): 客户端收到 SYN+ACK 后,回复 ACK 报文(ACK=1,ack=y+1),客户端进入 ESTABLISHED 状态,服务端收到 ACK 后也进入 ESTABLISHED 状态,连接建立。
为什么是三次,不是两次? 核心原因:防止已失效的连接请求报文突然又传到了服务端,导致错误建立连接。假如只有两次握手:客户端发了一个旧的、已过期的 SYN 到服务端,服务端一看 SYN 来了就回 SYN+ACK 建立连接,但客户端根本不理这个过期的 SYN,服务端却已经分配了资源空等。三次握手让客户端在最后确认一次,避免资源的浪费。
四次挥手——释放连接:
- 第一次挥手(主动方 → 被动方): 主动方发送 FIN 报文(FIN=1,seq=u),进入 FIN_WAIT_1 状态。
- 第二次挥手(被动方 → 主动方): 被动方收到 FIN 后,回复 ACK 报文(ACK=1,ack=u+1),被动方进入 CLOSE_WAIT 状态,主动方收到 ACK 进入 FIN_WAIT_2 状态。此时主动方不再发送数据,但可以接收数据。
- 第三次挥手(被动方 → 主动方): 被动方处理完剩余数据后,也发送 FIN 报文(FIN=1,seq=v),被动方进入 LAST_ACK 状态。
- 第四次挥手(主动方 → 被动方): 主动方收到 FIN 后,回复 ACK 报文(ACK=1,ack=v+1),主动方进入 TIME_WAIT 状态,等待 2 个 MSL 后关闭。被动方收到 ACK 后进入 CLOSED 状态。
为什么是四次,不是三次? 当主动方发送 FIN 时,可能被动方还有数据没发完。被动方必须先回复 ACK 表示知道了,等自己数据发完了再发送 FIN。所以中间的 ACK 和 FIN 不能合并,必须是两次,总共四次。
追问储备:
- TIME_WAIT 为什么等 2MSL? 确保最后的 ACK 能到达被动方(如果 ACK 丢了,被动方会重传 FIN)。同时让旧连接的包在网络中彻底消失,不干扰新连接。
- SYN 攻击怎么防范? SYN Cookie:服务端收到 SYN 时不马上分配资源,而是用客户端 IP 和端口等算一个 cookie 回给客户端,客户端回 ACK 时带上它,通过验证才真正分配资源。
Q112:TCP 和 UDP 的区别?各自是全双工吗?
面试标准回答:
TCP 和 UDP 是传输层的两大协议,设计哲学完全不同。
TCP 的特点:
- 面向连接: 通信前必须通过三次握手建立连接,通信结束通过四次挥手断开。
- 可靠传输: 通过确认重传、校验和、流量控制、拥塞控制等保证数据无差错、不丢失、不重复、按序到达。
- 全双工: 建立连接后,双方可以同时发送和接收数据,互不干扰。
- 面向字节流: 数据没有边界,连续的字节流,需要应用层自己处理包边界。
- 适用场景: 文件传输、网页浏览、邮件、即时通讯文字——不允许数据丢失和乱序的场景。
UDP 的特点:
- 无连接: 直接发送数据,不需要建立和释放连接。
- 不可靠传输: 不保证数据一定到达,不保证顺序,没有拥塞控制和重传机制。
- 全双工: UDP 也是全双工的! 绑定了同一个 UDP 端口的 Socket 可以同时调用 send 发送和 recv 接收,双方互不干扰。很多人误以为 UDP 是半双工或单工,这是错误的。
- 面向数据报: 每个包都有边界,一次发送的数据对应一个完整的报文。
- 适用场景: 视频直播、语音通话、DNS 查询、在线游戏——允许少量丢包、要求低延迟和实时性的场景。
追问储备:
- 如何保证 UDP 可靠? 可以自己在应用层增加确认机制、序号、重传逻辑,比如 QUIC 协议(HTTP/3 的传输层)就基于 UDP 实现了可靠传输。
- TCP 的全双工在代码中如何表现? 在一个 Socket 连接中,既有
InputStream也有OutputStream,可以同时读写,不需要关闭其中一个才用另一个。
Q113:HTTP 和 HTTPS 区别?加密流程?
面试标准回答:
区别:
- 安全性: HTTP 是明文传输,数据在网络中可以被轻易窃听和篡改;HTTPS = HTTP + SSL/TLS,数据在传输过程中被加密。
- 端口: HTTP 默认 80 端口,HTTPS 默认 443 端口。
- 证书: HTTPS 需要向 CA(证书颁发机构)申请数字证书,用于验证服务器身份。
- 连接建立过程: HTTP 只经过 TCP 三次握手即可;HTTPS 在 TCP 三次握手后还要进行 TLS 握手,协商加密算法和密钥。
HTTPS 的加密流程(TLS 握手,简化版):
- 客户端 Hello: 客户端向服务器发送支持的 TLS 版本、加密套件列表、一个随机数(Client Random)。
- 服务器 Hello: 服务器回复选定的加密套件、自己的随机数(Server Random)、以及数字证书(包含服务器的公钥)。
- 客户端验证证书: 客户端利用操作系统或浏览器内置的 CA 根证书,校验服务器的证书是否合法有效(未过期、域名匹配、未被篡改)。
- 客户端生成 Premaster Secret: 客户端生成一个随机字符串(Premaster Secret),用服务器证书里的公钥加密它,然后发送给服务器。服务器用私钥解密获得 Premaster Secret。这一步用了非对称加密,保证了密钥传输的安全性。
- 生成对称密钥: 客户端和服务器各自使用 Client Random、Server Random、Premaster Secret 这三个随机数,通过同样的算法(PRF)生成最终的对称会话密钥。
- 对称加密通信: 之后的通信全部使用这个对称密钥进行对称加密。因为对称加密速度远快于非对称加密,适合大量数据的传输。
TLS 1.3 的改进(加分点): 现代 HTTPS 广泛使用 TLS 1.3,它废弃了旧的 RSA 密钥交换,改用 ECDHE(椭圆曲线 Diffie-Hellman 临时密钥交换) 来协商密钥。这样做的好处是实现了前向安全性(Forward Secrecy)——即使服务器的私钥未来被泄露,过去的会话也无法被解密。
追问储备:
- 为什么需要非对称加密来交换对称密钥? 因为对称密钥不能直接在网上明文传输(可能被窃听)。非对称加密的私钥只在服务器端,公钥可以在网络中公开。客户端用公钥加密的密钥只有服务器的私钥能解开。
- HTTP/2 和 HTTP/3 还需要 HTTPS 吗? 都需要。HTTP/2 可以与 HTTPS 一起使用(HPACK 头部压缩需要端到端加密)。HTTP/3 基于 QUIC,加密是内置的(QUIC 天生加密,协商 TLS 1.3)。
Q114:HTTP 常见状态码?(200/301/302/400/401/403/404/500)
面试标准回答:
HTTP 状态码是服务器对客户端请求的响应结果标识,按首位数字分为五类。
1xx:信息性状态码。 请求已接收,继续处理。如 100 Continue(请客户端继续发送请求体)。
2xx:成功。 请求被正常处理。
- 200 OK: 请求成功,GET/POST 等返回数据。
- 201 Created: 请求成功并且服务器创建了新的资源(常用于 POST 请求后)。
- 204 No Content: 请求成功,但响应没有内容(常用于删除成功)。
3xx:重定向。 需要进一步操作才能完成请求。
- 301 Moved Permanently(永久重定向): 资源已被永久移至新 URL,搜索引擎会把权重转移给新 URL。浏览器会缓存这个跳转。
- 302 Found(临时重定向): 资源临时移动到另一个 URL。浏览器不缓存,每次还请求原 URL。
- 304 Not Modified: 客户端带
If-None-Match等条件请求时,服务器返回 304 表示资源没变,请用本地缓存。
4xx:客户端错误。 客户端请求有问题。
- 400 Bad Request: 请求语法错误,服务器无法理解。
- 401 Unauthorized: 未认证,需要先登录(WWW-Authenticate 头)。
- 403 Forbidden: 已认证但没有权限访问该资源(与 401 不同,登录了也不行)。
- 404 Not Found: 请求的资源不存在或路径错误。
- 405 Method Not Allowed: 使用了不被允许的 HTTP 方法。
5xx:服务端错误。 服务器处理请求时出错。
- 500 Internal Server Error: 服务器内部错误(一般代码逻辑或配置问题)。
- 502 Bad Gateway: 网关或代理服务器从上游服务器收到无效响应。
- 503 Service Unavailable: 服务暂时不可用(过载、停机维护)。
- 504 Gateway Timeout: 网关等待上游服务器响应超时。
追问储备:
- 301 和 302 在实际开发中怎么选择? 如果要永久更换域名,用 301(让搜索引擎也更新索引)。如果只是临时的活动跳转或维护,用 302,浏览器下次仍会请求原 URL。
- 发送请求时遇到 401,Spring Security 怎么处理? Spring Security 会返回登录页面(如果是表单登录)或返回 401 响应(REST API),客户端收到后自动跳转登录。
Q115:HTTP 1.0/1.1/2.0 区别?
面试标准回答:
HTTP/1.0(1996 年):
- 短连接。 每次 HTTP 请求都会建立一个新的 TCP 连接,响应完毕后立即关闭连接。下次请求再重复一次三次握手,开销极大。
- 后来引入
Connection: keep-alive来维持一定时间的连接,但这需要双方显示支持,不是默认。
HTTP/1.1(1997 年,现在仍广泛使用):
- 长连接(默认开启)。 同一个 TCP 连接可以处理多个 HTTP 请求和响应,减少了重复握手的开销。
- 管道化(Pipelining): 客户端可以不等待上一个响应就直接发出多个请求。但服务器必须按照请求的顺序返回响应,这导致了队头阻塞(Head-of-Line Blocking):如果第一个请求处理很慢,后面的请求全被堵着。
- 更多缓存控制头: 如
Cache-Control、ETag、If-None-Match。 - Host 头(必须): 支持在一台物理服务器上部署多个虚拟主机(通过域名区分)。
HTTP/2(2015 年):
- 二进制帧协议: 不再像 1.x 用文本形式传输,而是用二进制帧分片传输,解析更高效。
- 多路复用(Multiplexing): 同一个 TCP 连接上,可以并行发送多个请求和响应,各请求的帧交错传递,接收端根据帧的标识重新组装。彻底解决了 HTTP/1.x 的队头阻塞问题。
- 头部压缩(HPACK): 用索引表压缩请求和响应的 HTTP 头,减少重复传输。因为 1.x 每一次请求都会带上完整的 Cookie 和 Authorization 头。
- 服务器推送: 服务器可以主动将还未被请求的资源(如 CSS、JS)提前推送给客户端,加快页面加载。
追问储备:
- HTTP/2 有什么缺点? 仍然基于 TCP,传输层的 TCP 本身也存在队头阻塞(当 TCP 包丢失时,整个窗口都等待重传)。HTTP/3 基于 QUIC(UDP)解决了此问题。
- 现今主流环境用哪个版本? 大多数大型网站已经支持 HTTP/2,但 HTTP/1.1 仍广泛存在。负载均衡器和客户端都有兼容处理。
Q116:GET 和 POST 的区别?
面试标准回答:
GET 和 POST 是 HTTP 最常用的两种请求方法,区别体现在语义、参数传递、安全性和幂等性上。
1. 语义不同:
- GET:用于获取资源,是读操作,不应该有副作用。
- POST:用于提交数据(创建或更新资源),是写操作。
2. 参数传递方式不同:
- GET 请求参数拼接在 URL 的
?后面(如/api?id=1)。因此参数可见,且 URL 长度受限(浏览器一般限制 2KB 左右,但标准无限制)。 - POST 请求参数放在请求体中,更隐蔽且数据量无理论上限(但实际服务器可以设置上传大小限制)。
3. 安全性: 都不是绝对安全的。GET 参数暴露在 URL 中,更容易被窃取(如浏览器历史记录、web 服务器日志),不应传递敏感信息。POST 相对隐蔽一些,但仍需 HTTPS 加密才能真正保护。
4. 幂等性(多次执行结果是否相同):
- GET 是幂等的。多次请求同一 URL 应返回相同的结果(即使有微小数据变化也不算违背幂等)。
- POST 不幂等。多次提交同一个 POST 请求会创建多个资源(比如下两个订单)。
5. 缓存和书签:
- GET 请求可以被浏览器缓存,可以被添加为书签。
- POST 一般不缓存,也不能添加书签。
6. 字符编码: GET 参数通过 URL 传递,只支持 ASCII 字符(非 ASCII 需要编码)。POST 可以传二进制数据(如图片、文件)。
追问储备:
- 除 GET 和 POST 外还有哪些常用方法? PUT(更新整个资源,幂等)、PATCH(部分更新)、DELETE(删除资源,幂等)、HEAD(仅获取响应头,用于资源检查)。
- 表单文件上传一定要用 POST 吗? 通常用 POST,因为 GET 只能传文本且长度受限,文件一般是二进制的。技术上 HTTP 没有禁止 GET 带 body,但主流浏览器和 CDN 都不支持。
Q117:单例模式有哪几种写法?双重检查锁为什么需要 volatile?枚举为什么最安全?
面试标准回答:
单例模式确保一个类在 JVM 中只有一个实例,并提供一个全局访问点。
常见写法:
饿汉式: 类加载时就创建实例(
private static Singleton instance = new Singleton()),天生线程安全。但可能浪费内存,因为如果没有用到这个单例,也会初始化。懒汉式(非线程安全): 首次调用
getInstance()才创建。多线程下可能创建多个实例,不安全。懒汉式(同步方法,线程安全但性能差): 把
getInstance()用synchronized修饰,每次调用都锁一下,并发性能极差。双重检查锁(DCL): 先检查一次,如果实例已存在直接返回;如果不存在才进入同步块,同步块内再检查一次,然后创建实例。这是最经典的写法,但必须给实例字段加
volatile。
DCL 为什么需要 volatile: instance = new Singleton() 不是原子操作,大致分 3 步:分配内存 → 初始化对象 → 将引用指向内存。CPU 可能重排后两步,导致 instance 指向了未初始化完成的对象。另一个线程在第一次 if (instance == null) 时发现非空,直接返回了半成品对象。volatile 通过内存屏障禁止了这种重排,保证读到的对象是完全初始化的。
静态内部类: 利用类加载机制的线程安全,线程安全且延迟加载。
SingletonHolder类只在首次调用getInstance()时被加载,由 JVM 保证现场安全。枚举(Effective Java 推荐,最安全):
enum Singleton { INSTANCE; }。枚举天生是线程安全的,JVM 保证每个枚举值只会被实例化一次。枚举还能防止反射破坏(newInstance()会抛异常)和反序列化破坏(反序列化不会创建新对象)。
追问储备:
- 为什么反射可以破坏单例? 通过
Constructor.setAccessible(true)强行调用私有构造器,可以创建多个实例。枚举可以防止反射攻击,因为newInstance内部对枚举进行了校验,如果发现是枚举类型,直接抛出异常。 - spring 的单例是哪种写法? Spring 的 singleton Bean 与设计模式单例概念相似,但实现上是通过容器缓存(ConcurrentHashMap 的一级缓存)来保证的,不是靠 Java 代码或枚举。
Q118:工厂模式和抽象工厂模式区别?
面试标准回答:
工厂模式(Factory Method): 定义一个创建对象的接口,让子类决定实例化哪一个类。它通过继承来完成对象的创建,一个工厂子类只生产一个产品。比如一个抽象的 AnimalFactory 接口,有子类 DogFactory 和 CatFactory,各自生产狗和猫的对象。适用于产品类型单一,但具体产品类别多的场景。
抽象工厂模式(Abstract Factory): 提供一个创建一系列相关或相互依赖的对象的接口,而无需指定它们具体的类。它通过组合来完成一组对象的创建,一个工厂能生产多个相关的产品。比如一个 GUIFactory 抽象工厂,可以为 Windows 风格和 Mac 风格各自提供一整套产品(按钮、文本框、菜单)。每个具体工厂都实现创建这一系列产品的多个方法。
核心区别:
- 产品族 vs 产品等级: 工厂方法针对的是一个产品等级(比如各种各样玩具),抽象工厂针对的是一个产品族(比如同一系列的主题——现代的椅子+沙发+桌子就是一族)。
- 扩展方式: 增加新产品类型时,工厂方法只需增加一个工厂子类,抽象工厂可能需要修改抽象接口和所有具体工厂。
追问储备:
- Spring 中有哪些地方用了工厂模式?
BeanFactory本身就是一个超级工厂,负责创建和管理所有 bean。FactoryBean是典型的工厂方法模式,在getObject()中自定义创建逻辑。 - 你在项目里用过工厂模式吗? 是的,在 MCP 工具的整合中,我使用工厂模式对不同的 连接类型(SSE/Stdio/Local)创建对应的 MCP 连接服务,统一输出 ToolCallback。
Q119:策略模式是什么?什么场景用?
面试标准回答:
策略模式(Strategy Pattern): 定义一系列算法,把它们封装起来,并使它们可以相互替换。策略模式让算法的变化独立于使用算法的客户。
通俗说,就是把不同的处理方式封装成一个个独立的策略类,使用者根据需要选择一个策略执行,而不需要写一大堆 if-else。
结构:
- 上下文(Context): 持有一个策略对象的引用,负责维护和调用策略。
- 策略接口(Strategy): 抽象的策略接口,定义策略的方法。
- 具体策略(ConcreteStrategy): 实现了策略接口的不同算法。
典型应用场景:
- 支付方式: 微信支付、支付宝支付、银行卡支付等,各自策略实现同一套支付接口。
- 日志记录: 按级别产生不同的日志处理策略。
- 排序算法: 传递给排序函数一个 Comparator 比较器,就是一种策略模式的应用。
在项目中的应用: 在 AgentForge 中,我使用了策略模式实现智能体的路由编排。不同的编排类型(串行、并行、循环)是实现同一接口的不同策略,编排引擎根据 YAML 配置为每个编排节点选择对应的策略去执行。这样如果要增加一种新的编排类型,只需要新增一个策略类,完全不影响已有逻辑。
追问储备:
- 策略模式和工厂模式如何结合? 通常由工厂模式负责创建策略对象(根据类型返回具体的策略),而策略模式负责执行对应的逻辑。两者经常一起出现。
- 策略模式和状态模式的区别? 策略模式是客户端主动选择不同的算法;状态模式是对象根据其内部状态自动改变行为。一个是主动切换,一个是被动响应状态变化。
Q120:适配器模式是什么?什么场景用?
面试标准回答:
适配器模式(Adapter Pattern): 将一个类的接口转换成客户期望的另一个接口。适配器让原本由于接口不兼容而不能一起工作的类可以一起工作。相当于一个“转接头”。
结构:
- 目标接口(Target): 客户期望的接口。
- 适配者(Adaptee): 已存在的、需要被适配的类(通常来自第三方或旧系统)。
- 适配器(Adapter): 实现 Target 接口,内部包装一个 Adaptee 对象,将 Target 方法的调用转化为对 Adaptee 方法的具体调用。
典型应用场景:
- 第三方库替换: 之前用 A 短信服务,现在想换成 B 短信服务,B 的 API 和 A 完全不同。不修改业务代码的情况下,为 B 写一个适配器实现 A 的接口,业务代码无感切换。
- 适配不同消息中间件: Kafka、RabbitMQ、RocketMQ 的 API 不一样,通过适配器统一成内部的消息接口。
在项目中的应用: 在 AgentForge 中,Google ADK 和 Spring AI 的流式返回框架和消息格式完全不兼容——ADK 用 Flowable(RxJava3),Spring AI 用 Flux(Reactor)。我设计的适配层就是适配器模式的体现:MySpringAI 继承 ADK 的 BaseLlm(Target 接口),内部包装 Spring AI 的 ChatModel(Adaptee),将 Adaptee 的 Flux 流适配成 Flowable 流返回给 ADK。上层 ADK 完全不知道底层是 Spring AI 在工作。
追问储备:
- 适配器模式和装饰器模式的区别? 适配器改变接口,装饰器不改变接口但增强功能。适配器是为了解决接口不匹配的问题,装饰器是为了动态添加职责。
- 适配器模式和外观模式的区别? 适配器解决一个接口的转换;外观模式为子系统提供统一的简化接口,通常背后涉及多个类。
Q121:代理模式是什么?Spring AOP 是哪种代理?
面试标准回答:
代理模式(Proxy Pattern): 为另一个对象提供一个替身(代理对象) 以控制对这个对象的访问。代理对象在客户端和目标对象之间起到中介作用,可以在不修改原始类的情况下,添加额外的功能(如访问控制、日志记录、延迟加载、缓存等)。
代理模式的分类:
- 静态代理: 代理类和目标类在编译时就确定了关系。需要手动为每个目标类编写一个代理类,一旦接口或方法增加,代理类也要跟着修改。工作量大、扩展性差。
- 动态代理: 代理类在运行时动态生成。Java 通过反射或字节码生成,根据目标类的类型动态创建代理对象。动态代理又分为 JDK 动态代理和 CGLIB 代理。
Spring AOP 的代理实现:
Spring AOP 的底层就是动态代理。Spring 在启动时会判断被代理的 Bean 是否实现了接口:
- 如果实现了接口,使用 JDK 动态代理。 通过
java.lang.reflect.Proxy和InvocationHandler生成一个实现相同接口的代理类。代理类持有目标对象的引用,调用代理方法时分发给InvocationHandler,在那里实现横切逻辑(@Before、@After等),再反射调用原始目标方法。 - 如果没有实现接口,使用 CGLIB。 CGLIB 通过 ASM 字节码框架,生成目标类的一个子类,在子类中重写父类方法,通过方法拦截器插入增强逻辑。因此,目标类不能是
final的,需要增强的方法也不能是final或static。
Spring Boot 2.x 后的默认代理方式: 统一使用 CGLIB,无论目标类是否有接口。因为在某些情况下(如 Controller 未显式声明接口),用 JDK 代理会导致代理失败。CGLIB 更简单通用。
追问储备:
- AOP 的陷阱:同类内部方法调用(this调用)不会走代理。 因为调用发生在目标类内部,没有经过代理对象,所以 Aspect 不生效。解决办法:注入自己(@Autowired 注入自身的代理对象)再调用。
- 代理对象的创建发生在 Bean 生命周期的哪一步? Bean 初始化之后,BeanPostProcessor 的
postProcessAfterInitialization方法中,由AbstractAutoProxyCreator检查并生成代理对象,替换原始 Bean 放入容器。
Q128:MCP 协议解决什么问题?类比什么?
面试标准回答:
MCP,全称 Model Context Protocol(模型上下文协议),是由 Anthropic 在 2024 年底开源的一套标准协议。它解决的核心问题是:大模型与外部工具、数据源之间“集成爆炸”的难题。
在没有 MCP 之前,如果你想让大模型连接不同的外部系统(比如一个连 GitHub,一个连 Slack,一个连数据库),你需要为每一对“模型↔工具”单独编写适配代码,这就是 M×N 集成问题。MCP 的思路是:把所有工具都统一用一种标准协议暴露出来,大模型只需要支持这一种协议,就可以连接所有工具。
可以这样类比:MCP 就是 AI 世界的“USB-C 接口”。以前不同的设备需要不同的转接头(不同工具用不同 API),现在有了 MCP,只要大模型支持 MCP(就像手机有 USB-C 口),就能连接任何实现了 MCP 的工具(就像 USB-C 外设)。
MCP 的核心概念:
- MCP Server(服务端): 工具或数据源的提供方。比如一个天气 API 服务,它按照 MCP 标准暴露自己支持哪些功能(Tools)、能提供哪些数据(Resources)。
- MCP Client(客户端): 大模型应用程序。它通过 MCP 协议与 MCP Server 通信,发现可用的工具和资源,并调用它们。
MCP 的传输方式:
- stdio(标准输入输出): 适合本地工具,通过启动子进程的标准输入输出流通信,比如本地命令行工具。
- SSE(Server-Sent Events): 通过 HTTP 长连接通信,适合远程服务。
追问储备:
- MCP 和 Function Calling 的区别? Function Calling 是大模型调用工具的具体机制(模型输出函数名和参数,再由程序执行),而 MCP 是定义工具怎么暴露、怎么被发现的协议。两者可以协同:通过 MCP 发现工具,通过 Function Calling 调用工具。
- 在项目中怎么用 MCP 的? 在 AgentForge 中,我通过 MCP 协议管理各种外部工具(比如网页搜索、代码执行器、绘图工具),通过统一的 MCP 客户端连接不同的 MCP Server,将工具封装成统一的接口供 Agent 调用。
Q129:Function Calling 是什么?流程是怎样的?
面试标准回答:
Function Calling(函数调用)是大模型用于与外部工具交互的核心机制。大白话说就是:让大模型学会“点菜”——它不自己做饭,而是告诉厨房(外部程序)要做什么菜,厨房做好了端回来,大模型再根据结果继续回答用户。
完整流程:
- 定义工具(Tool Definition): 开发者为应用预定义一系列可调用的函数(工具),每个函数用 JSON Schema 描述它的名称、描述、参数名、参数类型和用法。比如一个“搜索网页”的函数,参数包含
query(搜索关键词,string 类型)。 - 用户输入 + 工具描述一同发送给模型: 用户的问题连同所有可用工具的 JSON Schema 一并发送给大模型。
- 模型决策是否调用工具: 大模型根据用户请求的内容,判断自己是否需要调用某个工具才能正确回答。如果需要,它不再直接生成回复文本,而是输出一个结构化的 工具调用指令(通常是一个 JSON 对象,指明要调用的工具名和参数值)。比如
{ "name": "search_web", "arguments": { "query": "2026年世界杯冠军" } }。 - 程序执行工具调用: 客户端代码收到模型的工具调用指令后,并不让模型执行,而是由真实的后端代码执行(比如真的去调用搜索引擎 API)。执行完后得到结果。
- 将工具结果返回给模型: 将上一步得到的真实结果,作为“工具执行结果”消息返回给大模型。
- 模型生成最终回复: 大模型拿到工具结果后,结合原始用户问题,生成一个基于真实数据的最终回复。
追问储备:
- Function Calling 和 MCP 的关系? MCP 是工具怎么暴露给模型的协议,Function Calling 是模型怎么选择和使用工具的机制。Function Calling 负责“点菜”,MCP 负责“让菜单统一”。
- 和 RAG 的对比? RAG 是先检索文档再让模型生成答案;Function Calling 更灵活,可以调用任何外部能力(搜索、计算器、发邮件等),而不限于检索。
Q130:Skills 是什么?和 MCP 工具的区别?
面试标准回答:
Skills 是给大模型提供的一种即插即用的“专业知识包”。你可以把它理解为“模型的外挂知识库”。当大模型遇到某个特定领域的任务时,它可以主动去加载对应的 Skill,从中获取这个领域的专业指导、最佳实践和代码示例,从而输出更高质量的答案。
Skills 的本质: Skills 通常表现为一个 Markdown 文档(SKILL.md),里面包含了完成某类任务所需的领域知识、操作步骤和规范。例如一个“绘制 UML 图”的 Skill,会教大模型如何用某种工具生成符合标准的 UML 图。
Skills 和 MCP 工具的核心区别:
- MCP 工具是“手和脚”:解决 “能做什么” 的问题。它让大模型可以通过外部程序执行具体操作(查询数据库、发送邮件、搜索网页)。它提供的是动作能力。
- Skills 是“大脑外挂”:解决 “怎么做更好” 的问题。它让大模型在需要时加载专业的指导手册,提升回答的规范性和质量。它提供的是认知能力。
形象比喻:
- MCP 工具就像给大模型装上了机械臂,它可以通过这个机械臂去拿东西、开门。
- Skills 就像在机械臂上贴了一张“标准操作流程”告示:第一步,对准位置;第二步,施力5牛;第三步,旋转30度。告诉大模型该怎么操作才能做得更好。
Skills 的优势:
- 即插即用,按需加载: 不占用长期上下文窗口。不像 system prompt 那样一直霸占 token 预算,Skills 只在需要时才被模型主动调用,然后加载相关内容,减少无关上下文对模型的干扰。
- 降低 Token 消耗: 不需要把海量的领域知识常驻在 system prompt 里,节省了大量 token 消耗和成本。
追问储备:
- Skills 是怎么被模型调用的? 类似 Function Calling,Skills 被定义为一种特殊的“工具”。当模型判断自己需要某方面的知识时,发出加载对应 Skill 的指令,系统将 Skill 的 Markdown 内容注入到 model 的上下文。
- 你自己是怎么用 Skills 的? 在 AgentForge 中,我也探索了 Skills 机制的概念:对特定类型的绘图任务(比如流程图、架构图),有对应的“绘图规范 Skill”,模型在执行时会自动调用对应的 Skill 来获取最佳实践和代码模板,提高生成图表的准确性和美观度。
Q131:Agent 的短期记忆和长期记忆怎么设计?
面试标准回答:
在 AI Agent 系统中,记忆机制是让 Agent 在多轮交互或跨会话中保持连贯、智能的核心。记忆分为两类:短期记忆(Short-term Memory)和长期记忆(Long-term Memory)。
1. 短期记忆(Short-term Memory):
- 指的是当前会话中产生的对话上下文。Agent 用它来理解用户当前的意图,并根据前几轮对话进行推理。
- 实现方式: 直接把最近的对话历史(包括用户的提问和模型的回答)存在 LLM 的上下文窗口(Context Window)中。每次模型进行下一轮推理时,整个对话历史都被打包成 Prompt 的一部分发送给模型。
- 优化技巧:
- 滑动窗口: 只保留最近 N 轮对话,丢弃更早的。因为模型的上下文窗口仍有上限。
- 摘要/压缩: 当对话历史太长,超出窗口容量时,用一个更小的模型或一个专门的摘要任务,把之前的对话总结成一段“摘要”,替换原有的详细历史,节省 token 空间。
- 关键信息提取: 只保留关键实体、决定、结果等结构化信息,丢弃过程描述。
2. 长期记忆(Long-term Memory):
- 指的是跨会话的、持久化的记忆。用于记住用户的偏好、重要事实和过往交互经验,让 Agent 在下一次对话中更“了解”用户。
- 实现方式:
- 向量数据库: 将历史对话或用户的陈述通过 Embedding 模型向量化,存入向量数据库(如 Milvus、Chroma)。下一次用户说类似的话时,Agent 检索最相关的历史记忆片段,像 RAG 一样把它注入到当前 Prompt 中。
- 结构化存储: 提取关键信息存入传统数据库(用户偏好、关键事实)。比如“用户名叫赵阳”、“他是Java后端开发”。
- 知识图谱: 记录实体及其之间的关系,适合处理复杂的关系查询。
追问储备:
- 短期和长期记忆如何协作? Agent 启动一轮新会话时,会先从长期记忆中检索与当前用户相关的信息(用户偏好、历史摘要),与本次会话的短期记忆(滑动窗口历史)一并注入 Prompt。
- 有没有开源的记忆框架? Mem0 提供开箱即用的长期记忆管理,Zep 提供会话记忆和事实提取。LangChain 也有对应的 Memory 模块。
Q132:多智能体系统有什么优势和复杂性?
面试标准回答:
多智能体系统(Multi-Agent System) 是指让多个专业化的 Agent 协同工作,共同完成一个复杂任务。每个 Agent 有自己独立的角色和职责,它们之间通过通信协作,类似于一个项目团队中的不同成员。
优势:
- 专业分工: 一个 Agent 只专注于一个领域(一个搜索、一个代码生成、一个分析),比一个超级 Agent 处理所有事情的准确性更高。因为每个 Agent 的 Prompt 和工具配置都是对该领域深度定制的。
- 并行执行: 相互独立的子任务可以由多个 Agent 同时进行,大幅加速整体完成时间。
- 更长的有效推理链: 单个 Agent 如果推理步数超过 10 步,容易累积错误。多 Agent 体系下,每个 Agent 只需要处理 3-5 步的任务,然后相互校对或传给下一棒,整个系统的推理稳定性更强。
- 弹性与容错: 某个 Agent 失败或卡死时,其他 Agent 可以接管或重新指派,系统总体更健壮。
引入的复杂性:
- 通信开销: Agent 之间的消息传递增加了延迟和 Token 消耗(每次交换信息都是额外的大模型调用)。
- 协调难题: 如何分解任务、分配给哪个 Agent、谁负责汇总结果、发生冲突如何处理,需要额外的编排逻辑。
- 一致性问题: 多个 Agent 可能给出矛盾的建议或数据,最终得出不一致的结论,需要一个仲裁或汇总机制。
- 调试困难: 链路变长,一个子任务失败导致全局失败时,很难定位是哪个 Agent、哪一步出了问题。
- 成本增加: 每个 Agent 都要调用大模型,多个 Agent 意味着多倍的大模型调用成本。
实践建议: 不要过度设计。一个任务能用 2-3 个 Agent 解决就够,超过 5 个通常引入的管理开销已超过分工带来的收益。只有在任务确实能清晰切分且有明确并行性时,才值得引入多智能体。
追问储备:
- 多智能体系统常用的编排框架? Autogen(微软,异步事件驱动版 v0.4)、CrewAI(角色扮演式)、LangGraph(状态机)。我的项目中基于 ADK 的编排能力实现多 Agent 的串行、并行和循环调度。
- 在你的项目中怎么用到多智能体的? 在 AgentForge 中,对于复杂的绘图任务,我会分解成“需求分析 Agent”→“绘图规划 Agent”→“执行 Agent”三个智能体串行协作,每个只负责自己的专业部分,通过上下文传递中间结果。
Q133:RAG 是什么?基本流程?
面试标准回答:
RAG(Retrieval-Augmented Generation,检索增强生成)是一种让大模型在回答问题前,先从外部知识库中检索相关文档,再结合这些文档生成答案的技术。它解决了大模型的三大固有缺陷:知识过时(训练数据截止日之后的事实不知道)、私有知识缺乏(没见过的公司内部文档无法回答)、幻觉生成(凭空编造事实)。
基本流程:
文档预处理(离线阶段):
- 将企业的私有文档(PDF、Word、网页等)解析为纯文本。
- 按设定的策略对文本进行切块(如按段落、按固定 token 数、或语义切分),形成一个个 文档块(Chunks)。
- 调用 Embedding 模型将每个 Chunk 转成向量,存入向量数据库(如 Elasticsearch、Milvus、Pinecone)。
用户查询(在线阶段):
- 用户提出一个问题,如:“公司的年假政策是什么?”
检索(Retrieval):
- 将用户的问题同样用 Embedding 模型转成向量。
- 用这个向量去向量数据库中通过语义相似度检索出最相关的 Top-K 个文档块。
- 通常还会结合关键词检索(如 BM25)进行混合检索,兼顾语义和精确关键词匹配。
增强(Augment):
- 将检索到的文档块和用户原始问题一起,构造成一个 增强版的 Prompt。例如:“请根据以下参考资料回答用户的问题:{参考文档内容} \n\n 用户问题:{问题}”
- 这个 Prompt 被发送给大模型。
生成(Generation):
- 大模型基于提供的参考资料进行阅读理解和推理,生成一个既准确又有依据的答案。最终展示给用户时,还可以附上出处(引用的文档块)。
追问储备:
- RAG 和微调的区别? RAG 给模型外挂一个实时可更新的知识库,不改变模型本身;微调是改变模型参数,让模型内化新知识。RAG 成本低、知识更新灵活,适合变化频繁的知识;微调成本高,但能让模型更深层地理解专业领域,适合术语极多的场景。
- 你的项目中 RAG 的核心组件有哪些? 智枢使用了 Apache Tika 文档解析、Elasticsearch 作为向量和倒排索引一体存储、通义千问 Embedding 模型,并采用 KNN + BM25 混合检索,最后通过增强 Prompt 将上下文传给 DeepSeek 生成答案。
Q134:RAG 和微调的区别?什么时候用哪个?
面试标准回答:
RAG 和微调都是让大模型在特定领域表现更好的技术,但它们的作用机制、成本和更新灵活性截然不同。
区别对比:
| 维度 | RAG(检索增强生成) | 微调(Fine-tuning) |
|---|---|---|
| 工作方式 | 不改变模型参数,只在外围外挂一个知识库,问答时实时检索相关文档注入 Prompt。 | 用标注好的领域数据重新训练模型,改变模型自身的参数,将新知识“内化”进模型。 |
| 知识动态性 | 知识库可以独立实时更新(增删文档),新知识即时生效,无需重新训练。 | 知识含在模型参数里,参数更新需要重新微调(重新训练和部署),较慢。 |
| 可解释性 | 可以明确给出答案的引用来源(原文块),方便核查。 | 答案没有引文,模型为什么得出这个答案难以追溯。 |
| 成本 | 只需要管理向量数据库和 Embedding API 开销,相对低。 | 微调训练需要 GPU 资源和高质量数据集,成本较高。 |
| 适用场景 | 企业内部知识问答、实时信息问询、操作手册——需要引用原文、知识频繁更新的场景。 | 医疗诊断、法律文书分析、金融风控——对专业术语的深度理解、推理和格式遵循要求极高的场景。 |
| 对模型的影响 | 不改变模型,不会影响模型的通用能力。 | 微调后可能“灾难性遗忘”其他通用知识,需要特别处理。 |
如何选择:
- 如果知识经常变化、需要引用出处做佐证、或者接入的是大量标准操作手册 → 选 RAG。
- 如果领域术语极深、推理模式复杂、要求输出严格格式,并且有高质量标注数据集 → 选 微调。
- 在实际复杂系统中,两者经常结合使用:先用微调让模型适应领域语境,再用 RAG 为其提供实时知识。
追问储备:
- RAG 有哪些常见的优化手段? 混合检索(KNN + BM25)、重排序(ReRank)、文档块重叠切分、上下文压缩、递归检索等。
- 你的项目为什么选 RAG 而不是微调? 智枢作为企业知识管理系统,企业内部文档更新频繁(新规章制度、新产品文档),如果每次更新都重新微调模型,成本极高。RAG 允许在知识库层面实时更新文档,实时影响问答结果,非常适合这种场景。
Q135:大模型幻觉怎么缓解?
面试标准回答:
大模型幻觉(Hallucination)是指模型生成的文本看似连贯,但实际上包含虚构或不准确的事实。这是当前大模型普遍存在的问题,根本原因在于模型学习的是统计规律而非真实世界知识。
缓解幻觉可以从以下几个层面入手:
1. 检索增强生成(RAG)——最有效的方法之一: 不让模型凭空”回忆“,而是把参考资料直接送到模型嘴边。要求模型严格基于引用的资料回答,在 Prompt 中明确指示:“请仅根据提供的资料回答。如果资料中没有相关信息,请明确回答‘不知道’。”
2. 约束模型温度和采样参数: 将生成温度(Temperature)调低(例如设为 0.1),让模型的输出更确定、更保守,减少随机性导致的不准确。
3. 优化 Prompt 工程: 在 System Prompt 或用户 Prompt 中嵌入反幻觉约束。例如:“如果问到你不确定的事情,请明确告诉我你无法确定,并且列出你需要哪些信息来得出更准确的结论。”
4. 事实核查与自省(Self-Reflection): 让模型先生成一个回答,再另起一个 Prompt 要求它”核查以上内容,指出事实错误“,或者通过连网搜索对比结果进行修正。
5. 工具调用(Function Calling): 让模型在回答前先查询可靠的数据库、搜索引擎或知识库,基于查到的准确数据来回复,而不是依赖记忆中的训练数据。
6. 引入外部知识约束: 结合知识图谱,对某些关键实体和关系进行约束,一旦模型输出违背图谱中的既定事实,自动修正或提示。
7. 微调与 RLHF(人类反馈强化学习): 在微调阶段,对于”我不知道“这类诚实回复给予奖励,惩罚盲目编造,逐渐内化模型的诚实倾向。
追问储备:
- 为什么即使资料充足,模型还可能幻觉? 因为模型缺乏真正的理解能力,它只是预测下一个 token。即使给了资料,模型的注意力可能飘走(Attention Drift),忽略了关键事实。这也是为什么需要将关键点以结构化方式呈现或强调。
- 有幻觉的自动检测方案吗? 有研究通过 NLI 模型(自然语言推理)判断生成的答案是否与引用资料有逻辑矛盾。还有一些基于置信度(Logprobs)的检测手段,当模型在某段输出中概率很低时,这段很可能是编造。
Q136:Docker 基本概念?镜像和容器的区别?
面试标准回答:
Docker 是一个开源的应用容器引擎,它可以将应用程序及其所有依赖环境(代码、运行时、系统工具、系统库等)打包成一个镜像,然后在任何安装了 Docker 的系统上以容器的形式运行,解决了"在我电脑上能跑,到你电脑上就不行"的环境不一致问题。
核心概念:镜像(Image)vs 容器(Container)
这是 Docker 最重要的两个概念,可以用面向对象编程来类比:
镜像(Image)——类(Class)的类比:
- 镜像是一个静态的、只读的模板。它包含了运行应用程序所需的一切:操作系统用户空间文件、应用代码、运行时、环境变量、配置文件等。
- 镜像是你通过编写名为 Dockerfile 的脚本来构建的。它包含一层一层文件系统叠加的层(Layers),每一层代表 Dockerfile 中的一条指令(FROM、RUN、COPY 等)。这些层可以被缓存和复用,加速构建。
- 镜像只要构建好了就不会变化。它存放在本地或远程的 Registry(如 Docker Hub)中。
容器(Container)——对象(Object)的类比:
- 容器是镜像的一个运行中的实例。启动容器时,Docker 会在镜像的只读层之上增加一个可写层(Container Layer),让容器内的应用可以读写数据。
- 容器是动态的、有生命周期的(创建、启动、停止、删除)。容器被删除后,它的可写层也随之消失。
- 一个镜像可以启动多个容器,彼此隔离,各自拥有独立的可写层,互不影响。
Docker 和虚拟机的核心区别:
| 维度 | Docker 容器 | 虚拟机(VM) |
|---|---|---|
| 系统内核 | 与宿主机共享同一个 OS 内核 | 每个虚拟机运行自己完整的独立 OS(包括内核) |
| 启动速度 | 秒级(直接启动进程) | 分钟级(需要引导完整操作系统) |
| 资源占用 | 极轻量(MB 级别) | 极重(GB 级别,每个 VM 都包含完整 OS) |
| 隔离级别 | 进程级隔离(Namespace + Cgroups) | 完全硬件虚拟化隔离,更安全但更重 |
| 性能损耗 | 几乎无(直接调用宿主机内核) | 有一定损耗(Hypervisor 层) |
Docker 容器之所以轻量,是因为它不像虚拟机那样虚拟整个操作系统内核,而是通过 Linux 内核的 Namespace(命名空间) 实现进程、网络、文件系统的隔离,通过 Cgroups(控制组) 限制 CPU、内存等资源。
追问储备:
- 什么是 Dockerfile? 它是构建 Docker 镜像的脚本文件。包含了一系列指令:
FROM(指定基础镜像)、RUN(在构建时执行命令)、COPY(将本地文件复制到镜像中)、ADD(类似 COPY,还能自动解压 tar 文件)、CMD(指定容器启动时默认执行的命令)、ENTRYPOINT(设置容器启动时的入口程序)、EXPOSE(声明容器监听的端口)。 - 什么是 docker-compose? 用于编排多个 Docker 容器的工具。通过一个
docker-compose.yml文件,定义服务(services)、网络(networks)和数据卷(volumes),一条命令即可启动整个应用栈(比如前端 + 后端 + MySQL + Redis)。
Q137:Git 常用命令?merge 和 rebase 的区别?
面试标准回答:
Git 是目前最流行的分布式版本控制系统。日常开发中高频使用的 Git 命令按功能分类:
基本工作流:
git clone <url>:克隆远程仓库到本地。git pull:从远程仓库拉取最新代码并合并到当前分支(相当于git fetch+git merge)。git add <file>:将工作区的修改添加到暂存区(Staging Area)。git commit -m "message":将暂存区的改动提交到本地仓库,生成一个新的提交记录(commit)。git push:将本地仓库的提交推送到远程仓库。git status:查看工作区和暂存区的状态(哪些文件被修改、哪些将要被提交)。git log:查看提交历史记录。
分支操作:
git branch:列出所有本地分支。git branch <name>创建新分支。git checkout <branch>:切换到指定分支。git checkout -b <new-branch>创建并切换到新分支。git merge <branch>:将指定分支的改动合并到当前所在分支。
撤销操作:
git reset --hard HEAD~1:撤销最近一次提交,--hard连工作区的改动也丢弃。git revert <commit-id>:创建一个新的提交来撤销指定的提交(安全,不改历史)。git stash:将当前工作区的修改暂存起来,可以切到其他分支处理紧急任务,事后再git stash pop恢复。
merge 和 rebase 的核心区别:
git merge 和 git rebase 都是将两条分支的改动整合到一起,但方式完全不同。
merge(合并):
- 原理:将两个分支的最新提交(snapshot)合并,自动生成一个新的合并提交(merge commit)。这个合并提交会有两个父提交(parent)。
- 提交历史效果:保留了完整的分支历史和分支拓扑图,能看到什么时候分岔、什么时候合并。
- 优点:绝对安全,不会改变已有的提交历史。适合公共分支(如 main、develop)的合并。
- 缺点:如果分支频繁合并,提交历史会变成一张复杂的"蜘蛛网",不易阅读。
rebase(变基):
- 原理:将当前分支的提交**“迁移"到目标分支的最新提交之上**。就像把当前分支从原来的分叉点"拔下来”,重新"插"到目标分支的顶端。操作会改写当前分支的提交历史(每个提交的 hash 会变,因为父提交变了)。
- 提交历史效果:产生一条干净、线性的提交历史,就像所有改动都是按顺序顺次开发的一样,没有分叉。
- 优点:历史非常清爽易读。
- 缺点:危险! 因为它改写了提交历史,如果这些提交已经被推送到远程且其他人在此之上开发,这次 rebase 会导致他们的代码库混乱,需要强制推送(
git push -f)。
黄金法则: 永远不要 rebase 已经推送到公共仓库的提交。 只在自己本地分支上 rebase。
追问储备:
- 解决 merge 冲突的流程?
git merge时如果发生冲突,Git 会在文件中标记冲突区域(<<<<<<<和>>>>>>>)。手动编辑文件,删除标记并保留需要的代码,然后git add标记为已解决,git commit完成合并。 - 什么是
git cherry-pick? 将指定的某几个提交"摘取"到当前分支,而不是合并整个分支。适用于紧急把某个修复补丁应用到其他分支。
Q138:Maven 生命周期?
面试标准回答:
Maven 是一个 Java 项目的依赖管理和构建自动化工具。它的核心竞争力在于定义了一套标准的构建生命周期,让整个研发团队都知道如何编译、测试、打包、部署项目。
Maven 有三套独立的构建生命周期,最核心和常用的是默认构建生命周期(Default Lifecycle),它由一系列阶段(phase) 组成:
validate(验证): 验证项目结构和配置文件是否正确、完备。
compile(编译): 将项目的主要源文件(
src/main/java)编译成 class 字节码,输出到target/classes目录。test(测试): 使用单元测试框架(如 JUnit)运行
src/test/java下的测试用例,生成测试报告。package(打包): 将编译后的 class 文件打包成可分发的格式,如 JAR、WAR。打包结果输出到
target/目录。verify(校验): 运行集成测试,确保打包后的工件质量合格。
install(安装): 将打包好的 JAR/WAR 安装到本地 Maven 仓库(通常位于
~/.m2/repository),这样本机的其他项目就可以依赖这个包。deploy(部署): 将最终的包分发到远程 Maven 仓库(如私服的 Nexus、Artifactory),供团队其他开发者或生产环境拉取。
注意: 这些阶段是顺序依赖的——执行后面的阶段会自动触发前面的所有阶段。例如执行 mvn install 会依次执行 validate、compile、test、package、verify、install 前六个阶段的所有任务。
另外两个生命周期:
- Clean 生命周期:
clean阶段——删除target/目录,清理之前构建的产物。 - Site 生命周期: 生成项目的文档站点和报告。
追问储备:
- Maven 依赖冲突怎么解决? Maven 通过"最短路径优先"和"先声明优先"规则仲裁。出现冲突时可排除传递依赖(
<exclusion>),或在父 POM 中通过<dependencyManagement>统一指定版本,或在子模块中直接显式声明需要的版本。 - Maven 和 Gradle 的区别? Maven 用 XML 配置更严格、标准;Gradle 用 Groovy/Kotlin DSL 编写,更灵活、构建速度更快(增量构建和缓存),尤其适合大型项目和 Android 开发。
Q139:MD5 和 CRC 的区别?为什么秒传用 MD5 不用 CRC?
面试标准回答:
MD5 和 CRC 都是生成数据"指纹"的算法,但它们的设计目标完全不同:CRC 是为了防随机错误(意外),MD5 是为了防人为篡改(安全)。
| 对比维度 | MD5(信息摘要算法) | CRC(循环冗余校验) |
|---|---|---|
| 全称 | Message Digest Algorithm 5 | Cyclic Redundancy Check |
| 算法类型 | 密码学哈希算法 | 数据校验算法 |
| 设计目标 | 检测人为恶意篡改,生成内容唯一标识 | 检测数据传输/存储过程中的随机错误(如网络误码、磁盘坏道) |
| 输出长度 | 固定 128 bit(16 字节) | 通常是 32 bit(4 字节,如 CRC32) |
| 安全性 | 较高(虽然理论上能被碰撞,但对普通文件碰撞概率极低) | 极低,完全不防人的恶意构造 |
| 碰撞概率 | 极低,两个不同内容文件碰撞概率约为 1/2^128 | 较高,攻击者可以故意构造出同 CRC 的文件 |
| 速度 | 较慢 | 非常快(支持硬件加速、线速计算) |
| 典型用途 | 文件完整性校验、文件唯一标识、数字签名 | 网络数据包校验、磁盘数据纠错 |
为什么秒传功能用 MD5 而绝不能用 CRC?
秒传的逻辑是:用户上传文件前,前端计算文件的"指纹"传给后端。后端检查这个指纹是否已经存在于数据库。如果存在,说明文件之前已经上传过了,直接返回上传成功,跳过整个上传流程。
场景对比:
- 用 MD5: 公司去年上传了《2025 年度财务报告.pdf》,后端记录了它的 MD5。今年,一个内部人员想悄无声息地上传一个病毒,但伪装成财务报告。由于 MD5 的抗碰撞性,他极难构造出一个与财务报告的 MD5 相同、但内容是病毒的文件。因此,他上传的病毒会得到一个不同的 MD5,秒传判定为新文件,不会链接到财务报告。
- 用 CRC32: CRC 的设计决定了它无法防止故意构造。攻击者可以根据财务报告的 CRC32 值,通过简单的算法构造出一个包含病毒的同等 CRC 文件。上传这个病毒后,后端的秒传检查发现 CRC 一样,直接返回成功并给了财务报告的下载链接。整个系统被无声息地攻破。
结论: CRC 是为通信协议设计的,场景是"数据可能在传输中被电磁干扰破坏"这种随机事件,它并不假设有人故意要骗过 CRC。但在互联网应用的文件校验场景中,我们面对的是智能的、恶意的攻击者,所以必须使用至少是密码学级别抗碰撞的哈希算法,MD5 虽然在密码学上被认为不完美(碰撞理论上可行),但在文件级别的去重和校验上,比 CRC 可靠几个数量级,成本也远低于更安全的 SHA-256。
追问储备:
- MD5 这么快为什么还要更安全的 SHA-256? 在实际业务中,如果对安全性要求极高(如金融、法律),且文件量不大,用 SHA-256 的抗碰撞能力更强。但对于大文件秒传和去重,MD5 的成本和抗碰撞性正好处于一个黄金平衡点。
- 为什么 CRC 计算快? CRC 的运算本质是多项式除法(二进制域上的多项式计算),通过硬件电路(移位寄存器和异或门)即可达到线速计算(每时钟周期处理多位数据),因此极快。而 MD5 是对数据作复杂的位运算和多轮混合,需要较多 CPU 时钟周期。
Q140:TCP 的拥塞控制算法?(慢启动、拥塞避免、快重传、快恢复)
面试标准回答:
TCP 的拥塞控制是保证网络不被过量的数据注入而崩溃的核心机制。它通过在发送端维护一个拥塞窗口(cwnd,Congestion Window),动态调节发送速率。拥塞控制算法包含四个核心部分:慢启动、拥塞避免、快重传、快恢复。
1. 慢启动(Slow Start):
- 背景: 当一个 TCP 连接刚建立,或网络发生丢包恢复后,发送方不知道当前网络能承受多大的发送速率。如果一上来就猛发,可能直接撑爆中间的路由器。
- 机制: 初始 cwnd 设置为一个很小的值(通常是 1 个 MSS,即最大报文段长度)。每收到一个 ACK 确认,cwnd 就翻倍(指数增长:1→2→4→8→16…)。这样初始窗口虽小,但增长极快,能迅速探测到网络容量边界。
- 结束条件: 当 cwnd 达到预先设定的慢启动阈值(ssthresh) 时,或发生丢包时,慢启动结束,进入下一阶段。
2. 拥塞避免(Congestion Avoidance):
- 目的: 慢启动指数增长到阈值后,不能再翻倍了,否则网络很快会被撑满。此时转为更温和的线性增长。
- 机制: 每经过一个完整的 RTT(往返时间),cwnd 就加 1 个 MSS(而不是翻倍)。这样窗口大小线性缓慢增长,在网络接近满负荷时小心探顶,尽量避免造成拥塞。
- 丢包后的处理: 如果在这个阶段检测到丢包(超时重传计时器超时,说明网络已严重拥塞),TCP 会将 ssthresh 设为当前 cwnd 的一半,将 cwnd 重置为 1,重新进入慢启动。
3. 快重传(Fast Retransmit):
- 背景: 等待超时重传太久。如果发送方连续收到 3 个重复的 ACK(Duplicate ACK),虽说明后面的包没到,但说明网络还在流通(部分包到了),很可能只是个别包丢失,不一定拥塞严重。
- 机制: 当收到第三个重复 ACK 时,发送方不等待重传计时器超时,立即重传缺失的那个报文段。这就是“快重传”。
4. 快恢复(Fast Recovery):
- 目的: 既然收到了重复 ACK,说明网络还能通,拥塞不算严重。如果按老规矩把 cwnd 打回 1,从慢启动开始,那损失了太多吞吐量。
- 机制(配合快重传执行):
- 当第三个重复 ACK 到达时,ssthresh 设为当前 cwnd 的一半。
- cwnd 不是退回 1,而是被减小到新的 ssthresh 加上 3 个 MSS。加上 3 个 MSS 是因为收到 3 个重复 ACK 意味着有 3 个报文段已经被接收方成功接收并离开了网络,因此可以在拥塞窗口中为这 3 个报文段腾出空间。
- 之后每收到一个重复 ACK,说明又有一个包离开网络(到了接收端),cwnd 临时加 1。
- 当最终收到新数据的 ACK(确认到丢失包的恢复),cwnd 重置为前面更新后的 ssthresh 值,然后进入拥塞避免阶段继续线性增长。
总结口诀:
- 连接刚建立 → 慢启动(指数增长,探测网络容量)。
- cwnd 到阈值 → 拥塞避免(线性增长,谨慎避碰)。
- 收到 3 个重复 ACK → 快重传(不等超时,立即重传丢失包)+ 快恢复(不退回 1,而是减半后继续,保护吞吐)。
追问储备:
- TCP 的拥塞控制和流量控制的区别? 拥塞控制是防止发送方把网络撑爆(全局考虑,通过 cwnd 控制),流量控制是防止发送方把接收方的接收缓冲区撑爆(端到端,通过接收方在 TCP 头中声明的 rwnd 窗口大小控制)。最终发送窗口 = min(cwnd, rwnd)。
- BBR 算法和传统算法的区别? 传统的基于丢包的算法(如 Reno、CUBIC)总把丢包当成拥塞信号,但现代网络(如 LTE、5G)丢包不一定等于拥塞(可能只是无线信号干扰)。BBR(Bottleneck Bandwidth and RTT,Google 提出)不基于丢包,而是通过实时探测带宽和最小 RTT 来估算链路容量,在高延迟、有一定丢包率的网络上吞吐更高。
补充:Java 里面有哪些集合?分别有什么特性?
面试标准回答:
Java 的集合框架主要分为两大接口族:Collection 和 Map。Collection 下面又分为三大子接口:List、Set、Queue。我按这个体系来讲。
一、Collection 接口族
1. List(列表)—— 有序、可重复
List 是有序集合,元素按插入顺序排列,可以通过索引访问,允许存重复元素。常用实现类有三个:
ArrayList: 底层是动态数组(Object[])。查询快(O(1) 随机访问),增删慢(中间插入/删除需要平移元素,O(n))。适合读多写少的场景。默认容量 10,扩容为 1.5 倍。
LinkedList: 底层是双向链表。查询慢(需要从头部或尾部遍历,O(n)),增删快(修改指针即可,O(1))。同时还实现了 Deque 接口,可以当作双端队列、栈来使用。
Vector(遗留类,已基本不用): 和 ArrayList 类似,但所有方法都用
synchronized修饰,线程安全但性能差。现代开发中如果需要线程安全的 List,用CopyOnWriteArrayList或Collections.synchronizedList()包装。
2. Set(集合)—— 无序、不可重复
Set 不允许存重复元素,判断重复的标准是 hashCode() 和 equals()。常用实现类有三个:
HashSet: 底层基于 HashMap(所有元素作为 HashMap 的 key,value 是一个固定的 Object 常量)。允许一个 null 元素。增删查都是 O(1)。不保证顺序。
LinkedHashSet: 继承 HashSet,底层是 LinkedHashMap,在 HashMap 的基础上加了双向链表维护元素的插入顺序。迭代遍历时按插入先后输出。
TreeSet: 底层是红黑树(TreeMap)。元素必须可比较(实现 Comparable 接口,或构造时传入 Comparator)。元素按自然顺序或自定义顺序自动排序。增删查 O(log n)。不支持 null 元素。
3. Queue(队列)—— 先进先出
Queue 用于在任务处理前持有元素。常用实现类有两个:
LinkedList: 同时也是队列的实现,因为实现了 Deque 接口。
PriorityQueue: 优先级队列,底层是二叉堆(Object[] 实现的小顶堆/大顶堆)。元素不是按先进先出,而是按优先级出队。默认自然顺序,也可传比较器。不允许 null 元素。入队出队 O(log n)。
ArrayDeque: 底层是循环数组,可作为双端队列或栈使用。性能比 LinkedList 好(没有节点对象的额外开销),比 Stack(遗留类)快。官方推荐用它替代 Stack。
二、Map 接口族 —— 键值对
Map 存储的是 key-value 键值对,key 不可重复,value 可以重复。每个 key 最多映射到一个 value。常用实现类有三个:
HashMap: 底层数组 + 链表 + 红黑树。key 的重复判定和 HashSet 一样(
hashCode()+equals())。允许一个 null key 和多个 null value。非线程安全。默认容量 16,负载因子 0.75。这是我们深入讲过的那个。LinkedHashMap: 继承 HashMap,内部用双向链表维护键值对的插入顺序(默认)或访问顺序(构造参数设为 true,适合做 LRU 缓存)。
TreeMap: 底层是红黑树。根据 key 的自然顺序或比较器进行排序。key 不允许为 null(因为需要比较)。增删查 O(log n)。
Hashtable(遗留类,基本不推荐): 和 HashMap 类似,但线程安全(方法全
synchronized),不允许 null key 和 null value。已被 ConcurrentHashMap 取代。
三、线程安全的集合(JUC 包)
Java 5 以后,java.util.concurrent 包提供了高性能的并发集合,替代那些老的同步集合:
ConcurrentHashMap: 替代 Hashtable。1.7 分段锁,1.8 CAS + synchronized 锁桶,读操作无锁。这是你简历上重点准备的那个。
CopyOnWriteArrayList: 写时复制,在修改时复制整个底层数组,写操作加 ReentrantLock。适合读多写极少的场景(如黑名单),读完全无锁。写操作成本极高。
CopyOnWriteArraySet: 底层基于 CopyOnWriteArrayList。
BlockingQueue(阻塞队列)及实现类:
- ArrayBlockingQueue: 有界,底层数组。
- LinkedBlockingQueue: 可选有界,底层链表。线程池中常用作任务队列。
- SynchronousQueue: 不存元素,生产者 put 必须等消费者 take,一对一直接交接。
- DelayQueue: 元素必须实现 Delayed 接口,只有在延迟期满后才能被取出。适合做定时任务调度。
四、快速对比总结
| 集合类型 | 有序性 | 重复性 | 查询性能 | 插入性能 | 线程安全 |
|---|---|---|---|---|---|
| ArrayList | 有序(插入序) | 可重复 | O(1) | O(n) | 否 |
| LinkedList | 有序(插入序) | 可重复 | O(n) | O(1) | 否 |
| HashSet | 无序 | 不重复 | O(1) | O(1) | 否 |
| LinkedHashSet | 有序(插入序) | 不重复 | O(1) | O(1) | 否 |
| TreeSet | 有序(排序) | 不重复 | O(log n) | O(log n) | 否 |
| HashMap | 无序 | key不重复 | O(1) | O(1) | 否 |
| LinkedHashMap | 有序(插入序) | key不重复 | O(1) | O(1) | 否 |
| TreeMap | 有序(key排序) | key不重复 | O(log n) | O(log n) | 否 |
| ConcurrentHashMap | 无序 | key不重复 | O(1) | O(1) | 是(高并发) |
追问储备:
- 什么时候用 ArrayList,什么时候用 LinkedList? 读多写少用 ArrayList,频繁在头部或中间插入删除用 LinkedList。但实际项目中,LinkedList 的内存开销(每个节点有前后指针)往往大于 ArrayList(连续内存),性能差异要实际压测,很多场景下 ArrayList 依然更快。
- HashMap 和 Hashtable 的区别? HashMap 允许 null,非线程安全;Hashtable 不允许 null,全方法 synchronized 线程安全但性能差。新代码统一用 HashMap(单线程)或 ConcurrentHashMap(并发)。
- Collections.synchronizedMap() 和 ConcurrentHashMap 的区别? 前者是对整个 Map 加一把大锁(synchronized 块),一次只能一个线程操作,并发度极低;后者是分段/分桶锁,允许多个线程同时操作不同的桶,并发性能远超前者。