满帮面经-满帮集团后端开发岗面经-01
- 轮次
- 面试合集
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
(面经 01)
- HashMap 是线程安全的吗?多线程环境下可以用哪些 Map?
- ConcurrentHashMap 是怎么做到线程安全的?
- 线程池的核心参数有哪些?
- 线程池有哪些拒绝策略?CallerRunsPolicy 会有什么问题?
- Lock 和 synchronized 的区别?
- ReentrantLock 默认是公平锁还是非公平锁?怎么实现公平锁的?
- 死锁产生的条件是什么?
- @Transactional 能加在私有方法上吗?为什么?
- 动态代理为什么只能代理 public 方法?
- MySQL 查询慢怎么排查?怎么查看走了哪个索引?EXPLAIN 关注哪些字段?
- 走了索引但还是很慢,可能是什么原因?
- 深分页怎么优化?
- JWT 的无状态认证原理?
- 如何防止 JWT 被中间人攻击拦截?
- HTTPS 的原理是什么?
(面经 02) 16. 线上排查问题一般的排查方式、方案是什么? 17. 是什么问题引发这次链路排查? 18. 后面问题解决了吗? 19. Redis 锁的 key 是怎么构成? 20. 获取锁失败之后的流程? 21. 讲讲性能优化,怎么定位性能瓶颈,用什么方案解决? 22. 本地缓存刷新、过期机制是什么? 23. 配置变更放在存储,为什么不直接写代码里? 24. 本地缓存的命中率怎么样? 25. 并发处理主要解决什么问题? 26. 如果其中某一个查询失败了怎么办?如果现在你来做会怎么处理? 27. 系统高并发下有哪些性能瓶颈点?怎么解决? 28. 主要针对帖子详情做缓存吗? 29. 评论会不会存在性能瓶颈? 30. 为什么引入 ES 做全文检索? 31. MySQL 和 ES 如何保证数据一致? 32. 描述 ES 一次完整查询过程? 33. 什么是深度分页问题?你怎么解决? 34. MySQL 索引有哪些索引失效场景? 35. 使用函数、类型转换为什么会索引失效? 36. 联合索引 ABC,只查 BC 为什么不走索引?底层原因? 37. @Transactional 同类方法内部调用,事务为什么会失效? 38. 为什么要使用泛型? 39. 了解泛型擦除吗?
(面经 03) 40. 模型用的什么?知识库是公司用的吗?知识库有没有泄密风险?怎么解决设计的? 41. HashMap 是线程安全的吗?为什么? 42. 并发 HashMap 类名叫啥?怎么实现线程安全的?HashMap 有几个桶?他会增加吗?读要上锁吗?如果读的过程中有个来写,怎么保证不会读到别人写的? 43. Spring 的事务用过吗?@Transactional 可以用在静态方法上吗?为什么? 44. 怎么排查慢 SQL 的问题? 45. LangGraph 是个什么?子图会把输出结果传给下一个子图是 LangGraph 完成的吗?
(面经 04) 46. 线程池,ThreadLocal,线程通信(TransmittableThreadLocal,InheritableThreadLocal) 47. Spring Bean 的生命周期 48. 策略模式,模版方法模式 49. MCP 协议细节
《参考解析》
HashMap 与 ConcurrentHashMap
HashMap 线程不安全,多线程并发 put 在 1.7 扩容时头插法会形成环形链表导致 get 死循环,1.8 改成尾插不再成环,但仍会丢数据、读到脏值、size 计数不准,所以并发写场景绝不能用。替代方案按需求选:ConcurrentHashMap(读写都并发、推荐)、Collections.synchronizedMap(全表一把锁,性能差)、Hashtable(基本淘汰);读多写极少且元素少时用 CopyOnWriteArrayList 这类思路或不可变 Map。ConcurrentHashMap 的线程安全手段分版本:1.7 用分段锁(Segment 继承 ReentrantLock,锁粒度是段,默认 16 段并发度);1.8 改为 CAS + synchronized 只锁单个桶的头节点——put 时先算哈希定位桶,桶为空就用 CAS 写入,桶非空则 synchronized 锁住头节点再遍历插入,冲突严重时链表转红黑树(阈值 8 且容量到 64);扩容用多线程协同迁移(每个线程认领一段桶区间),迁移中通过 ForwardingNode 标记,读到标记的线程会去帮忙迁移;size 用 baseCount 加 CounterCell 分段计数减少 CAS 竞争。关于「读要不要上锁」:get 不加锁,靠 Node 的 val 与 next 用 volatile 修饰保证可见性,所以不会读到「写了一半」的结构性中间态;但复合操作仍然不原子,像「先判断再写入」必须用 putIfAbsent、computeIfAbsent、merge 这类原子接口,否则仍会丢更新。
线程池核心参数与拒绝策略
ThreadPoolExecutor 七个核心参数:corePoolSize、maximumPoolSize、keepAliveTime、unit、workQueue、threadFactory、handler。任务流转顺序要背准:核心线程未满就新建核心线程;核心满了先入队;队列满了才扩到最大线程数;再满才触发拒绝策略——很多人把它说成「先扩线程再入队」,这是错的。队列类型决定行为:用无界队列(LinkedBlockingQueue 默认容量 Integer.MAX_VALUE)会让 maximumPoolSize 失效、任务无限堆积直到 OOM;用 SynchronousQueue 则来一个任务就扩线程,配大 maximumPoolSize 才有弹性;有界队列(ArrayBlockingQueue)是生产推荐,容量与线程数一起估算。拒绝策略四种:AbortPolicy 抛异常(默认)、DiscardPolicy 静默丢弃(最危险,会丢业务)、DiscardOldestPolicy 丢队头(会丢已排队的任务,可能丢的是重要任务)、CallerRunsPolicy 由提交任务的线程自己执行。CallerRunsPolicy 的问题:它在调用线程里同步跑任务,会阻塞提交线程(如果是 Web 线程就会把整个请求链路拖慢、甚至让上游超时级联),而且此时线程池已满,说明下游本来就慢,让上游帮着执行等于把背压传给上游;不过它也有价值——能起到天然限流、不丢任务的作用。所以实践上常用自定义策略:先落盘或投递到消息队列持久化,再返回降级结果,并配套线程池监控(活跃线程数、队列长度、拒绝次数)与指标告警。
Lock 与 synchronized、ReentrantLock 的公平性
两者都能保证互斥与可见性,差别在实现层级与能力:synchronized 是 JVM 关键字,靠对象头 Mark Word 与 Monitor 实现,加解锁由编译器自动插入,锁升级路径是无锁 → 偏向锁 → 轻量级锁(CAS 自旋)→ 重量级锁(操作系统互斥量,涉及用户态到内核态切换);JDK 15 之后偏向锁默认关闭因为它带来的收益在多数场景已不划算。ReentrantLock 是 JUC 基于 AQS 实现的 API 级锁,必须手动 lock/unlock(一定放 try-finally),换来的是 synchronized 没有的能力:可中断获取(lockInterruptibly)、超时获取(tryLock(timeout))、公平锁模式、以及一个锁配多个 Condition 做分组唤醒(synchronized 只能靠 wait/notifyAll 全唤醒,效率低)。AQS 的结构是 volatile 的 state 加一个 CLH 双向等待队列,加锁失败就包成节点入队并 park,释放时唤醒后继。公平性:ReentrantLock 默认是非公平锁,构造时传 true 才是公平锁。公平锁在 tryAcquire 里会先判断队列中是否有前驱节点在等待,有就让位、自己排队,因此能严格按 FIFO 获得锁、避免饥饿;非公平锁则允许刚到的线程直接 CAS 抢 state,抢不到再排队,所以吞吐更高(减少线程唤醒与切换的次数),代价是可能出现线程饿死。生产上默认用非公平,只有对顺序有硬要求的场景(比如顺序处理同一资源的任务)才用公平锁。
死锁的四个必要条件与排查
四个条件同时成立才可能死锁:互斥(资源同一时刻只能被一个线程占用)、请求并持有(持有资源的同时申请新资源且不释放旧的)、不可剥夺(不能被强制抢走,只能主动释放)、循环等待(存在一个线程到资源的环形等待链)。破坏其中任意一个就能避免:破坏请求并持有靠一次性申请全部资源或申请不到就释放已有资源;破坏不可剥夺靠 tryLock 超时后回退并释放已持有的锁;破坏循环等待最实用——给所有锁定义全局顺序,所有线程按同一顺序加锁(比如按资源 ID 排序后依次加锁),这是工程里最常落地的做法。排查手段:jstack 打印线程栈会自动给出 “Found one Java-level deadlock” 及互相等待的线程与锁对象;JConsole/VisualVM 有死锁检测;线上还可以用 arthas 的 thread -b 直接找阻塞其他线程的元凶。除了锁,还要注意线程池死锁(父任务提交子任务后等待、而线程池被父任务占满)和数据库死锁(InnoDB 会自动检测并回滚代价小的一方,报 Deadlock found,应对是缩短事务、按固定顺序更新、给 WHERE 走索引减少锁范围)。
@Transactional 的失效边界与动态代理范围
能加在私有方法上吗?不能生效——Spring 的事务是通过代理对象在方法外层开启和提交的,而 CGLIB 生成的子类没法覆写 private 方法,JDK 动态代理也只能拦接口方法,所以私有方法的 @Transactional 会被完全忽略,且不报错。同理,protected、包可见方法在 CGLIB 下(Spring 4.3 起允许)行为不一致,static 方法一定不生效(静态方法属于类不属于实例,代理拦不到);final 方法在 CGLIB 下同样拦不到,因为不能覆写;普通 public 方法也要注意同类内部自调用——this 是原始对象不是代理对象,注解形同虚设,解法是注入自身代理、AopContext.currentProxy()(需开 exposeProxy)、或把方法拆到另一个 bean。抽象到「动态代理为什么只能代理 public 方法」:JDK 动态代理基于接口,接口方法天然 public,所以代理只对 public 生效;CGLIB 基于继承覆写,private 与 final 方法不可覆写、static 属于类也不在覆写范围内,因此只有可覆写的 public(及配置允许的 protected)方法能进拦截链。另有几个常见失效:异常被 catch 吞掉、抛出受检异常而默认只回滚 RuntimeException 与 Error(要配 rollbackFor=Exception.class)、类没被 Spring 管理、以及事务方法内开了新线程(事务上下文不跨线程传播)。补充一点:事务里做远程调用或长耗时操作会把数据库连接和行锁持有很久,这是比注解失效更常见的线上事故。
慢 SQL 排查与 EXPLAIN、走了索引还是慢
排查路径:先拿到具体 SQL(慢查询日志 slow_query_log + long_query_time、performance_schema 的 TOP SQL、监控里的耗时分布、或 APM 链路数据),再用 EXPLAIN 看执行计划。EXPLAIN 关注字段:type(访问类型,从好到坏大致是 system > const > eq_ref > ref > range > index > ALL)、key(实际使用的索引,为 NULL 说明没走)、key_len(用了联合索引的几列)、rows(预估扫描行数,是判断放大倍数的关键)、filtered、以及 Extra(Using filesort 排序未走索引、Using temporary 用了临时表、Using index 是覆盖索引的好信号、Using where 表示回表后再过滤)。走了索引还是慢,常见原因有六类:① 索引选择性差或回表次数太多——扫描行数大,每次回表都是随机 IO,改用覆盖索引或延迟关联;② 排序或分组没走索引导致 filesort 与临时表;③ 索引失效实际没走(函数包列、隐式类型转换、前导模糊 like ‘%x’、OR 混用、联合索引未遵守最左前缀);④ SQL 本身写法差(select *、大 IN、子查询、多表连接顺序错);⑤ 深分页(limit 100000, 20 要扫描并丢弃十万行);⑥ 数据量与并发:表太大、统计信息过期(analyze table)、锁等待或缓冲池命中率低。优化后再用 EXPLAIN ANALYZE 或对比实际耗时与扫描行数验证,并确认没有把别的查询拖慢。压测环境能复现再上线是最稳的收尾。
深分页与联合索引最左前缀
深分页慢的本质是 limit offset, size 需要扫描并丢弃前 offset 行,offset 越大扫描越多,且若走二级索引还要逐行回表。三种优化:① 游标法(seek method),把「第几页」换成「上一页最后一条的排序键」,写成 where id > last_id order by id limit size,直接走索引定位、不回退扫描,适合无限下拉、不允许跳页的场景;② 延迟关联,先用覆盖索引把主键捞出来再回表,例如 select t.* from t join (select id from t order by c limit 100000, 20) x on t.id = x.id,把回表从十万次降到二十次;③ 业务层限制翻页深度(只允许看前 N 页或改成筛选条件收敛结果集),搜索场景则交给 ES 的 search_after 或滚动查询。要能说清为什么延迟关联快:子查询只用二级索引就能拿到主键(索引覆盖),避免了大量随机回表,外层再按主键精确取行。联合索引 ABC 只查 BC 为什么不走索引:B+ 树先按 A 排序,A 相同的记录才按 B 有序、B 相同才按 C 有序,所以整体上 B、C 是「局部有序、全局无序」的;没有 A 的等值条件就无法在树上定位到某个连续区间,只能全树扫描,优化器会判定不如直接全表扫。补救办法是把 BC 建成新索引、或用 A 的等值条件补上(A 上加等值就能用到 AB 或 ABC)、或在 MySQL 8.0 用索引跳跃扫描(skip scan)但要求 A 的区分度很低且只在特定条件下生效,不能当常规手段。要顺带说清为什么范围条件放在联合索引最后:一旦某列走了范围,它后面的列就无法再用于精确定位,只能回表过滤。
JWT 无状态认证与 HTTPS
JWT 由 Header、Payload、Signature 三段 Base64URL 拼接而成,签名用服务端密钥(HMAC)或私钥(RSA/ECDSA)生成,所以内容可读但不可篡改——Payload 里绝不能放敏感信息。无状态的原理:服务端不存会话,登录后签发 token,客户端每次请求用 Authorization: Bearer 带上,服务端验签加校验 exp、iss、aud 即可确认身份与权限,因此天然适合多实例横向扩展、不需要粘性会话或集中存 session。代价是服务端无法主动让某个 token 失效,所以要靠短有效期加 refresh token、维护黑名单或版本号(用户改密码时递增 tokenVersion,验签时比对),以及把权限变更做成短 TTL 的可撤销缓存。防止被中间人拦截:全程 HTTPS/TLS 是前提(否则 token 在链路上明文可见可重放),传输时不要让 token 出现在 URL 与日志里;存储位置要权衡——localStorage 简单但易被 XSS 读取,HttpOnly + Secure + SameSite 的 Cookie 能防 XSS 读取但要防 CSRF(配 SameSite 或 CSRF token);再叠加短有效期、refresh token 轮换(一次性、用后即废并检测重复使用以发现窃取)、绑定指纹或 IP 做风控、以及在网关层做速率限制与异常检测。HTTPS 的原理:TCP 之后做 TLS 握手,客户端发 ClientHello(版本、密码套件、随机数),服务端回 ServerHello 与证书链;客户端用内置 CA 验签校验证书有效性与域名;主流的 ECDHE 密钥交换下服务端私钥只用于签名、具备前向安全;双方由共享秘密派生出对称会话密钥,之后的数据用对称加密(如 AES-GCM)加完整性校验传输——非对称只用来安全地协商出对称密钥,这是整个设计的核心。
Redis 分布式锁与本地缓存
Redis 锁的 key 构成要能讲设计而不是背答案:一般是「业务前缀 + 资源维度 + 资源 ID」,例如 order:lock:{orderId}、res:lock:{tenantId}:{bizId};粒度要按「需要串行化的最小资源」定,太粗(例如一把全局锁)会拖垮并发,太细会产生大量 key 且可能出现需要跨资源原子性的场景;前缀让不同业务互不干扰,也能按前缀做监控与清理。value 必须是持有者的唯一标识(UUID + 线程 ID),这样释放时才能用 Lua 脚本比对后再删,避免「A 超时释放时误删了 B 的锁」;更稳的方案是 Redisson 的可重入锁加看门狗续期。获取锁失败之后的流程:① 直接返回失败让上层快速拒绝(适合能容忍失败的场景,如防重复提交);② 自旋重试并带退避与抖动(次数与总超时有上限,否则线程堆积);③ 转异步排队,把请求投进队列由单线程消费者顺序处理(更稳,避免大量线程原地等待);④ 降级到数据库唯一键兜底或直接走同步业务。无论哪种,都必须讲幂等(重试与锁超时都可能让同一操作执行两次)。本地缓存的刷新与过期:容量与淘汰策略(Caffeine 用 W-TinyLFU 做淘汰、ConcurrentHashMap 要自己控容量)、过期分写入后过期(expireAfterWrite)与访问后过期(expireAfterAccess),刷新分被动失效(下次访问时同步或异步 reload,Caffeine 的 refreshAfterWrite 会在访问时返回旧值并异步加载,适合高并发)与主动推送(配置中心变更时广播失效,解决多实例不一致)。命中率要看监控:requests、hitRate、eviction 与 load 耗时,命中率低要么是 key 设计或容量问题,要么是 TTL 太短。
高并发性能瓶颈、ES 引入与一致性
定位瓶颈的顺序是从入口往里压:先用压测与监控确定是 CPU、内存、磁盘 IO、网络还是连接数先到顶,再把链路拆开逐段计时(网关、应用、缓存、数据库),找出耗时与 QPS 分布里的长板。分层优化清单:接入层限流(令牌桶、漏桶)与负载均衡;应用层线程池隔离、异步化与非核心逻辑降级、批量合并请求;缓存层多级缓存(本地 + Redis)+ 缓存预热 + 防穿透(布隆过滤器、空值缓存)防击穿(互斥重建或逻辑过期)防雪崩(TTL 加随机、多副本、限流兜底);数据库层索引与 SQL 优化、读写分离、连接池调优、热点行拆分、冷热分离,最后才是分库分表与异步削峰(MQ)。帖子详情这类读多写少的场景,天然适合缓存正文与计数、把渲染结果或 DTO 直接缓存;评论则同时是读热点与写热点,瓶颈常在写放大(计数、通知、审核)与分页排序,需要异步化与分片。为什么引入 ES:MySQL 的 like ‘%词%’ 无法走索引,全文检索、分词、相关性排序(TF-IDF/BM25)、高亮、聚合分析这些能力它都没有,数据量一大就退化成全表扫;ES 倒排索引按词查文档,天然匹配这类需求,代价是资源开销与一致性复杂度。MySQL 与 ES 的数据一致性:以 MySQL 为准,用 binlog 订阅(Canal/Debezium)或应用发消息异步同步,配幂等(按主键与版本号覆盖)、顺序保证(同一文档按路由键单分区)、失败重试与死信、以及定时全量对账补偿;接受秒级最终一致,业务上对「写完立刻要查到」的场景要读主库或做读写标记(写后一段时间内强制走 DB)。ES 一次查询的过程:客户端请求打到协调节点,协调节点根据路由算出目标分片并发查询(query 阶段,每个分片本地检索并返回 doc id 与排序值),协调节点归并全局排序取出 Top N 后再向相关分片发起 fetch 阶段取回完整 _source,最后返回;写侧则是先写内存 buffer 与 translog,refresh 生成新 segment 才可被搜索(默认 1 秒,这就是「写入后要等一秒才搜得到」的原因),flush 落盘并清 translog,merge 合并 segment。深度分页问题:from + size 需要每个分片都取 from+size 条再归并,分片越多内存与网络开销越大,且受 index.max_result_window(默认一万)限制;解法是 search_after(配合唯一排序键翻页)、scroll(快照式遍历,适合导出,占资源要限时)或 PIT + search_after,业务上也常用「限制翻页深度、改用筛选条件」。
泛型与泛型擦除
泛型的价值有三点:编译期类型检查(把 ClassCastException 从运行期提前到编译期)、消除强制类型转换让代码可读、以及通过泛型方法与通配符把「同一个算法适配多种类型」的能力表达出来,同时不改动运行期结构,兼容老代码。泛型擦除是指 Java 的泛型只在编译期存在,编译后类型参数被替换成其上界(无界时是 Object)并插入必要的强制转换与桥接方法,所以运行期 List? extends T、写入为主的用 ? super T,才能既安全又灵活。
Spring Bean 生命周期与线程上下文传递
Bean 生命周期主线:扫描与注册 BeanDefinition → 合并定义 → 实例化(推断构造器、反射创建)→ 属性填充(populateBean,注入依赖)→ Aware 回调(BeanNameAware、BeanFactoryAware、ApplicationContextAware)→ BeanPostProcessor 的 postProcessBeforeInitialization → @PostConstruct 与 InitializingBean.afterPropertiesSet → 自定义 init-method → postProcessAfterInitialization(AOP 代理在这一步生成,所以代理对象替换原始对象发生在初始化之后)→ 投入使用 → 容器关闭时 @PreDestroy 与 DisposableBean.destroy。要提的关键点:AOP 代理在 afterPropertiesSet 之后、初始化后处理器里生成,所以 @PostConstruct 里拿到的是原始对象;循环依赖靠三级缓存解决,但构造器注入的循环依赖无法解决。线程与线程池的上下文传递:ThreadLocal 只在当前线程有效,线程池复用线程会导致上下文丢失或串号;InheritableThreadLocal 只在「创建新线程」时从父线程拷贝一次,线程池里的线程是预先创建的,所以池化场景基本无效;TransmittableThreadLocal(TTL)的思路是在任务提交时做快照、任务执行前回放、执行后恢复,配合 TtlExecutors 包装线程池或 Java Agent 实现无侵入增强,也可以包装 Runnable/Callable。实践上仍要显式清理(finally 里 remove),并优先把上下文做成显式参数传递,避免隐式传播带来的串号事故。设计模式部分:策略模式把可互换的算法族抽象成接口、由调用方选择具体实现(典型是多种支付渠道、不同计费规则),优点是把 if-else 换成多态并可在运行期切换;模板方法模式把流程骨架放在父类的 final 方法里、把可变步骤留给子类实现,用来复用固定流程(如导入流程的校验、解析、入库、通知),两者常配合使用——模板方法定义主流程,其中某一步用策略注入。