腾讯后端开发岗面经:Java 并发、JVM 调优与 MySQL 索引
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
面经 01 · 后端开发岗(2025-09-28)
- 介绍一个做过的 Java 后端项目。追问:系统的核心架构是怎样的?高并发场景下你是怎么保证性能和稳定性的?数据库在高压力下是否出现过瓶颈?怎么优化的?你在项目中具体负责了哪些模块?有没有遇到特别难的问题?
- Java 内存模型(JMM):线程之间的可见性、有序性、原子性是怎么保证的?volatile 和 synchronized 的区别?底层实现原理?
- 集合框架:HashMap 的底层实现,JDK 8 中为什么引入红黑树?ConcurrentHashMap 的分段锁机制和 JDK 1.8 之后的 CAS + 红黑树实现?
- 多线程:线程池参数的含义?如何选择合适的线程池?submit() 和 execute() 的区别?线程池满了之后会发生什么?如何避免?
- 线上 JVM 调优的经历:如何分析 OOM、频繁 Full GC 的问题?
- MySQL 索引的底层数据结构(B+ 树),为什么不用 B 树?
- 索引失效的几种情况?
《参考解析》
JMM 与 volatile、synchronized 的底层实现。 JMM 是一层抽象:线程不能直接读写主内存,所有变量都要先拷到工作内存(CPU 缓存 + 寄存器的抽象),于是产生了可见性、有序性、原子性三个问题。volatile 解决可见性与有序性:写操作编译成带 lock 前缀的指令,把写缓冲刷回主存并使其他核的缓存行失效;读操作每次都从主存取;同时插入内存屏障禁止编译器与 CPU 把它和前后指令重排。但它不保证原子性——i++ 是「读-改-写」三步,多线程下照样丢更新,要原子性得上锁或 AtomicInteger。synchronized 三者都保证:字节码上是 monitorenter/monitorexit(方法上是 ACC_SYNCHRONIZED 标志),底层对应对象头 Mark Word 里的锁状态,JDK 6 之后有锁升级路径——无锁 → 偏向锁(同一线程重入只改 Mark Word)→ 轻量级锁(CAS 自旋抢锁,适合竞争时间极短的场景)→ 重量级锁(竞争激烈时挂起线程、陷入内核态,开销最大但不再空转 CPU)。要主动提一句:偏向锁在 JDK 15 之后已默认关闭并在新版废弃,因为它在高并发下撤销成本高、收益不明显;另外「锁的是对象不是代码」,静态方法锁的是 Class 对象。
HashMap 与 ConcurrentHashMap 的实现演进。 HashMap 是数组 + 链表 + 红黑树:哈希先做扰动 h ^ (h >>> 16) 让高位参与运算,桶下标用 (n-1) & hash(容量取 2 的幂,等价取模但更快);插入时链表尾插,链表长度到 8 且数组容量 ≥ 64 才树化,退化阈值是 6(留出缓冲避免反复转换)——引入红黑树是因为哈希冲突严重时链表查询退化成 O(n),树化后是最坏 O(log n),而按泊松分布链表长度到 8 的概率只有千万分之几,所以树化是防御性设计而不是常态。扩容 resize 时 JDK 8 用高低位拆分(hash & oldCap 决定留在原位还是移到 原位+oldCap),避免了 JDK 7 头插法在并发扩容下形成环导致 CPU 100% 的经典问题。ConcurrentHashMap 在 JDK 7 是分段锁:Segment 继承 ReentrantLock、默认 16 段,并发度被段数写死、内存开销也大;JDK 8 改成 CAS + synchronized 锁单个桶的头节点——空桶直接 CAS 插入,非空桶对头节点加 synchronized(锁粒度细到一个桶),扩容支持多线程协助迁移(ForwardingNode 标记),size 用 baseCount + CounterCell[] 分散计数避免热点,get 全程无锁靠 volatile 保证可见性。
线程池参数、选型与「满了怎么办」。 七个参数要能顺口报出来并说清协作关系:核心线程数、最大线程数、空闲回收时间、时间单位、工作队列、线程工厂、拒绝策略。任务提交后的顺序是「核心线程未满就新建线程 → 否则入队 → 队列满了才扩到最大线程数 → 再满就走拒绝策略」——这个顺序很多人在「先扩容还是先入队」上答错。选型没有万能公式:CPU 密集任务约 核数 + 1,IO 密集任务可按 核数 × (1 + 等待时间/计算时间) 估,最终一定靠压测和线上监控调;但队列必须有界,无界队列(不指定容量的 LinkedBlockingQueue)会让最大线程数形同虚设、任务无限堆积直到 OOM。submit() 与 execute() 的区别不只是返回值:submit 会把任务包成 FutureTask,异常被吞进 Future,不调用 get() 就永远不报错(线上表现为「任务悄悄没了」),execute 的异常会交给 UncaughtExceptionHandler。线程池满了之后的四种策略:AbortPolicy 抛 RejectedExecutionException(默认,靠调用方感知)、CallerRunsPolicy 让提交任务的线程自己跑(天然形成背压,慢但不会丢)、DiscardPolicy 静默丢弃(最危险)、DiscardOldestPolicy 丢最老的任务。避免打满的做法是分而治之:按业务隔离线程池避免互相拖死、上游限流 + 有界队列 + 合理的拒绝策略、把耗时任务异步化并设置超时,同时对队列深度、活跃线程数、拒绝次数做监控告警。
OOM 与频繁 Full GC 的线上排查思路。 先定性再动手:拿 GC 日志和监控曲线判断是「内存泄漏」还是「内存不足」——每次 Full GC 后老年代占用降不下去(锯齿的谷底越来越高)是泄漏;能降下去但很快又满、或大对象直接分配进老年代,才是容量或参数问题。工具链按顺序用:jstat -gcutil 看各区占用与 GC 频率、jmap -histo 看对象直方图定位可疑类、jmap -dump 抓堆转储丢进 MAT 看支配树和 GC Roots 引用链(这一步才能指认是谁在持有)、jstack 看线程卡在哪、arthas 做在线诊断(heapdump、trace、watch)。常见根因:静态集合或本地缓存没有容量上限、ThreadLocal 用完没 remove(线程池复用线程时尤其致命)、监听器/回调注册了没注销、一次性 select * 把大结果集读进内存、会话对象过大或并发数太高。Full GC 频繁还有几个容易漏的方向:元空间不足(大量动态生成类、热部署)、代码里显式调用 System.gc()、G1 下分配了大量 humongous 大对象引发并发周期、以及 CMS/G1 的并发失败退化成串行 Full GC。处置上先止损(限流、扩容、重启释放)再治本(修引用、换 G1/ZGC、控制大对象、给缓存加容量与过期),并把这次的特征固化成告警规则。
为什么是 B+ 树,以及索引失效的几种情况。 B+ 树相对 B 树有三个决定性优势:一是非叶节点只存键、不存数据,同样大小的页能容纳更多键,树高更低——三层左右就能撑住千万级数据,而每次下降就是一次随机磁盘 IO,树高直接决定 IO 次数;二是所有数据都在叶子节点且叶子用双向链表串联,范围查询和 order by 只需定位起点后顺序遍历,B 树则要在层间反复中序遍历回跳;三是查询路径长度稳定,任何键都要走到叶子,性能可预测。B 树真正的短板是「非叶节点也存数据和指针」,扇出小、树更高,且范围扫描效率差。索引失效的常见情形:不满足最左前缀(联合索引跳列或范围条件之后的列用不上);在索引列上做函数、表达式运算或隐式类型转换(字符串列传数字最典型);like '%xx' 前导模糊;or 连接的条件里有列没索引;!=、not in、is null 这类低选择性条件让优化器直接放弃索引;以及优化器基于统计信息算出「走索引再回表」比全表扫描更贵(典型是索引选择性差、或需要回表的数据占比很高)。答题时补上两个加分点:能覆盖查询就不回表(覆盖索引),以及用 EXPLAIN 的 type、key、rows、Extra(Using index、Using filesort、Using temporary)来验证结论,别只凭印象说「没走索引」。