美团 3
类型:Java 后端面试问题整理
已排除项目介绍、MySQL 索引、秒杀系统三题。
面试问题
Java 并发
2. 说下你对 Java 锁的理解?synchronized 和 ReentrantLock 的区别是什么?AQS 的原理是什么?
答:
锁用于保证多线程访问共享资源时的互斥性和可见性。常见分类包括悲观锁和乐观锁、公平锁和非公平锁、可重入锁、独占锁和共享锁。
synchronized 是 JVM 提供的关键字,底层通过对象监视器 Monitor 实现。修饰实例方法时锁当前对象,修饰静态方法时锁 Class 对象,修饰代码块时锁指定对象。它是可重入锁,退出同步块时由 JVM 自动释放。
ReentrantLock 是 JUC 提供的显式锁,底层基于 AQS。它同样可重入,额外支持:
tryLock()尝试获取锁;tryLock(timeout)超时等待;lockInterruptibly()等锁时响应中断;- 公平锁和非公平锁;
- 多个
Condition条件队列。
ReentrantLock lock = new ReentrantLock();
lock.lock();try { // 临界区} finally { lock.unlock();}日常简单同步优先使用 synchronized,代码短且不易遗漏释放锁;需要超时、中断、公平性或多个条件队列时使用 ReentrantLock。
AQS 原理:AQS 是 JUC 中锁和同步器的基础框架。它维护一个 volatile int state 和一个 FIFO 等待队列。线程先通过 CAS 尝试修改 state 获取同步状态;失败后封装为节点进入等待队列,并使用 LockSupport.park() 挂起。持锁线程释放时更新 state,再 unpark() 唤醒后继节点继续竞争。
以 ReentrantLock 为例,state = 0 表示未加锁;首次获得锁后为 1;同一线程重入时递增。释放时递减到 0 才真正释放,这就是可重入的基础。
JVM 与垃圾回收
3. 说下你对 JVM 的理解?JDK 7 到 JDK 8 有哪些重要变化?为什么?
答:
JVM 是 Java 字节码的运行环境,主要包括类加载子系统、运行时数据区、执行引擎、垃圾收集器和本地方法接口。Java 代码编译为 .class 后,由类加载器加载,再由解释器和 JIT 编译器执行。
运行时数据区主要包括:
- 堆:保存对象实例,是 GC 的主要区域;
- 虚拟机栈:每个线程独有,保存栈帧、局部变量表和操作数栈;
- 本地方法栈:为 Native 方法服务;
- 程序计数器:记录当前线程执行的字节码位置;
- 方法区:存放类元数据、常量池、方法信息等概念性数据。
JDK 7 到 JDK 8 的内存变化,面试中重点回答永久代到元空间的演进。
- JDK 8:永久代被元空间替代
JDK 7 JDK 8
Heap Heap├── Young ├── Young├── Old └── Old└── PermGen
Native Memory └── Metaspace元空间主要保存类元数据,使用本地内存,默认可以按类加载情况增长。生产环境仍可通过 -XX:MaxMetaspaceSize 设置上限,防止无边界占用本地内存。
-
为什么要删除永久代
永久代容量难以预估。动态代理、反射、CGLIB、热部署或频繁类加载场景中,永久代容易出现
OutOfMemoryError: PermGen space。元空间把类元数据迁到本地内存,减少了固定永久代大小带来的配置困难。元空间也需要监控。应用发生 ClassLoader 泄漏时,已加载类无法卸载,最终仍可能出现
OutOfMemoryError: Metaspace。
补充:G1 在 JDK 7 已出现,JDK 8 的默认收集器仍取决于具体发行版和配置;JDK 9 才将 G1 设为 HotSpot 默认收集器。
4. 垃圾回收器有哪些?各有什么特点?G1 的工作原理是什么?怎么调整老年代阈值?
答:
常见收集器可以这样回答:
- Serial:单线程收集,停顿时会 Stop The World,适合小堆、客户端或资源受限场景。
- Parallel Scavenge / Parallel Old:强调吞吐量,适合批处理、离线计算。
- CMS:老年代并发标记清除,目标是缩短停顿;会产生内存碎片和浮动垃圾,已经在较新 JDK 中移除。
- G1:面向服务端大堆和可预测停顿,按 Region 管理堆,优先回收垃圾收益高的 Region。
G1 将堆划分为一批大小相等的 Region。Region 的角色可以动态为 Eden、Survivor、Old 或 Humongous,不要求年轻代和老年代在物理上连续。
[E][E][S][O][O][E][H][O][E][S]G1 的并发标记周期大致是:
Initial Mark(STW) -> Root Region Scan -> Concurrent Mark -> Remark(STW) -> Cleanup -> Mixed GCMixed GC 会回收年轻代 Region,并按预估回收收益选择一部分老年代 Region。-XX:MaxGCPauseMillis 是停顿时间目标,G1 会据此控制每次选择回收的 Region 数量,它是目标值而非硬性保证。
“老年代阈值”需要先确认具体含义:
- 如果指对象经过多少次 Young GC 晋升,关注
-XX:MaxTenuringThreshold;Survivor 空间不足时也可能提前晋升。 - 如果指什么时候启动 G1 并发标记周期,关注
-XX:InitiatingHeapOccupancyPercent,它按堆占用情况触发并发标记。
Java 集合
5. 说下你对 Java 集合的理解?HashMap、HashSet、LinkedHashMap 的底层实现?ConcurrentHashMap 从 JDK 7 到 JDK 8 有哪些改进?
答:
Java 集合可分为 Collection 和 Map 两大体系。Collection 下有 List、Set、Queue;Map 保存 Key-Value。
HashMap 在 JDK 8 中是“数组 + 链表 + 红黑树”。通过 Key 的哈希值计算桶下标:
(n - 1) & hash哈希冲突的元素位于同一个桶。链表长度达到 8 且数组容量至少为 64 时,链表会树化为红黑树,降低极端冲突场景的查询复杂度。默认负载因子是 0.75,元素数量超过 capacity * loadFactor 时扩容,容量通常翻倍。
HashSet 底层就是 HashMap。集合元素作为 Key,Value 使用固定占位对象,因此它利用 Key 的唯一性实现元素去重。
LinkedHashMap 在 HashMap 的基础上维护双向链表,可保持插入顺序;设置 accessOrder = true 时,会维护访问顺序,可用于实现简单的 LRU 缓存。
ConcurrentHashMap 的演进:
-
JDK 7:
Segment[] + HashEntry[],锁粒度是 SegmentJDK 7 的
ConcurrentHashMap由多个Segment组成。每个Segment内部维护一张自己的HashEntry[]哈希表,并且Segment继承ReentrantLock。HashEntry才是真正保存一条 Key-Value 的节点;同一个桶发生哈希冲突时,多个HashEntry会形成链表。一次
put可以理解为两次定位:Key-> hash 定位 Segment-> 在该 Segment 的 HashEntry[] 中定位 bucket-> 将 HashEntry 写入 bucket 的链表写入前需要先锁住整个 Segment。假设两个 Key 都进入
Segment[3],但分别在bucket[2]和bucket[8]:user:1001 -> Segment[3] -> bucket[2]product:9 -> Segment[3] -> bucket[8]两个线程写入时仍需竞争
Segment[3]这一把锁,只能串行执行。两个 Key 位于不同 Segment 时,才可以并发写入。Segment数量通常远小于实际数据节点数,例如 16 个 Segment 内可以保存数百万个HashEntry。 -
JDK 8:
Node[] + 链表/红黑树,锁粒度缩小到桶JDK 8 删除了
Segment,直接使用全局Node[]桶数组。写入时根据 hash 直接定位一个 bucket:Key -> hash -> table[index]空桶插入通过 CAS 完成,无需加锁;桶中已有节点时,才对该桶的头节点加
synchronized,在链表或红黑树中完成插入、更新。user:1001 -> table[52]product:9 -> table[93]两个线程写不同桶时通常可并发进行。只有两个 Key 落入同一个桶、同时修改该桶时,才会竞争同一把桶锁;这既可能来自哈希冲突,也可能是同一 Key 的并发更新。链表过长时还会树化为红黑树,降低冲突严重时的操作复杂度。
JDK 8 在扩容时还允许多个线程协作迁移不同桶的数据,减少单线程扩容带来的停顿。
总结:JDK 7 将锁划分到 Segment;JDK 8 将日常写竞争进一步缩小到单个 bucket,因此不同桶之间的并发写入能力更强。
ConcurrentHashMap 不允许 null Key 和 null Value,因为并发场景中 null 难以区分“键不存在”和“值为 null”。
MySQL
6. MySQL 有哪些日志?各有什么用途?undo log 如何保证事务原子性?
答:
面试中最重要的三类日志是:
undo log -> 原子性 + MVCCredo log -> 持久性 + 崩溃恢复binlog -> 主从复制 + 数据恢复redo log 属于 InnoDB 存储引擎层,采用 WAL。修改数据页时,先记录 redo,再由后台慢慢刷脏页。数据库故障恢复时,可根据 redo 重做已经提交但尚未落盘的数据修改,保证持久性。
undo log 保存修改前的逻辑版本,用于事务回滚和 MVCC。例如余额从 1000 更新为 500,对应 undo 信息保存旧值或反向操作。事务失败时,InnoDB 按 undo log 逆序撤销已完成的修改,使事务整体回到执行前状态,从而保证原子性。undo 中的历史版本也会组成 MVCC 的版本链。
binlog 属于 MySQL Server 层,记录逻辑变更,用于主从复制和基于时间点的数据恢复。InnoDB 会通过两阶段提交让 redo log 和 binlog 保持一致,避免主从复制与崩溃恢复产生不一致。
Redis
8. Redis 有哪些数据类型?底层实现原理是什么?为什么 ZSet 用跳表?
答:
Redis 的五种基础数据类型是 String、List、Hash、Set、ZSet,另外常用扩展结构还有 Bitmap、HyperLogLog、Geo 和 Stream。
-
String
核心字符串结构是 SDS。SDS 记录字符串长度和可用空间,获取长度为
O(1),支持二进制安全,也会通过预分配空间减少频繁扩容。值会按内容和大小选择int、embstr、raw等编码。 -
List
现代 Redis 主要使用 quicklist,可理解为由多个紧凑 listpack 组成的双向链表,兼顾内存利用率与两端插入、删除效率。
-
Hash
小 Hash 使用紧凑的 listpack 节省内存;字段或值较大、数量较多后转为哈希表。适合对象属性的局部读写。
-
Set
元素全为整数且数量较少时使用
intset;其他场景使用哈希表。适合去重和交、并、差集运算。 -
ZSet
ZSet 保存
member + score。小数据量使用 listpack;规模增大后使用“哈希表 + 跳表”。哈希表支持按 member 快速查 score,跳表按 score 排序并支持范围查询、排名查询。
普通有序链表查找是 O(n),跳表通过多级索引让平均查询、插入和删除达到 O(log n)。它还天然保持有序链表结构,范围查询和顺序遍历实现直接,因此适合 ZSet。
Spring
9. Spring Bean 的生命周期是怎样的?Spring 怎么解决循环依赖?@Lazy 能解决循环依赖吗?
答:
Bean 生命周期可概括为:
实例化 -> 属性注入 -> Aware 回调 -> BeanPostProcessor Before -> 初始化回调 -> BeanPostProcessor After -> 对外可用 -> 销毁回调Spring Bean 的生命周期整体可以分成实例化、属性赋值、初始化和销毁四个阶段。首先 Spring 根据 BeanDefinition 创建 Bean 实例,然后进行属性填充和依赖注入。接下来会执行 BeanNameAware、BeanFactoryAware、ApplicationContextAware 等 Aware 回调,再执行 BeanPostProcessor 的初始化前置处理,然后执行 @PostConstruct、InitializingBean.afterPropertiesSet() 以及自定义 init-method 等初始化逻辑,之后执行 BeanPostProcessor 的后置处理,AOP 代理通常也是在这个阶段生成。至此 Bean 可以正常使用。对于默认的 singleton Bean,它通常会一直存在到 Spring 容器关闭,关闭时再执行 @PreDestroy、DisposableBean.destroy() 或自定义 destroy-method。
Spring 解决循环依赖的核心思想是 提前暴露 Bean 的引用。它处理的是单例 Bean 的属性注入循环依赖。
假设有两个 Bean:
A -> B^ ||____|Bean 的创建不是一步完成,而是:
实例化 -> 属性注入 -> 初始化 -> 完整 BeanSpring 可以利用“对象已经实例化,但属性尚未注入”的中间阶段提前暴露 A 的引用。三级缓存是:
singletonObjects 完整 BeanearlySingletonObjects 提前暴露的 BeansingletonFactories 生成早期引用的 ObjectFactory完整过程如下:
-
Spring 先创建 A,完成实例化。此时 A 对象已存在,但其中的 B 属性还没有注入,所以 A 还不能作为完整 Bean 放入一级缓存。
-
Spring 将“获取 A 早期引用”的
ObjectFactory放入三级缓存。它表达的是:后续有 Bean 提前需要 A 时,可以通过这个工厂取得 A 的早期引用。 -
Spring 开始为 A 注入属性,发现 A 依赖 B,于是暂停 A 的创建,转而创建 B。
-
B 完成实例化后也开始属性注入,发现 B 又依赖 A。此时 Spring 不会重新创建 A,否则会形成 A 创建 B、B 创建 A 的无限递归。
-
Spring 从三级缓存取得 A 的 early reference,并将这个已经确定的引用放入二级缓存,同时移除三级缓存中的工厂。若 A 没有 AOP,这个 early A 通常就是先前实例化出的原始对象;若 A 需要 AOP,这一步可以提前返回 A 的代理引用。
-
Spring 将 early A 注入 B,B 的依赖满足,B 完成初始化后进入一级缓存:
singletonObjectsB -> 完整 BearlySingletonObjectsA -> early A -
Spring 回到 A 的创建流程,将已经完成的 B 注入 A。A 继续初始化,最终也进入一级缓存;A 的二级缓存早期引用随之清理。
singletonObjectsA -> 完整 AB -> 完整 B
三级缓存的关键作用是延迟生成早期引用。它保证同一个 A 在创建完成前,对其他 Bean 暴露的是同一份 early reference;同时给 AOP 留出机会,使依赖方拿到的可以是代理引用而不是后续再替换的原始对象。
构造器循环依赖无法按这个机制解决,因为对象尚未实例化完成,无法提前暴露引用。原型 Bean 循环依赖也无法使用单例三级缓存解决。
@Lazy 可以在一侧注入延迟代理,从创建时依赖变为首次使用时再解析,因此可打断部分构造器循环依赖:
public A(@Lazy B b) { this.b = b;}但循环依赖通常反映职责耦合,优先通过拆分职责或引入中间协调服务解决,@Lazy 更适合作为有限场景的补救手段。
计算机网络
10. OSI 七层模型是什么?每一层的作用是什么?
答:
OSI 七层从上到下是:
应用层表示层会话层传输层网络层数据链路层物理层| 层级 | 主要作用 | 常见协议或概念 |
|---|---|---|
| 应用层 | 直接向应用程序提供网络服务 | HTTP、HTTPS、DNS、FTP、SMTP、WebSocket、SSH |
| 表示层 | 数据格式转换、编码、加密解密、压缩 | TLS/SSL、JSON、XML、UTF-8 |
| 会话层 | 建立、管理和终止会话 | RPC 会话、NetBIOS Session |
| 传输层 | 端到端通信,使用端口区分应用 | TCP、UDP、QUIC |
| 网络层 | 跨网络寻址和路由 | IP、ICMP;设备是路由器 |
| 数据链路层 | 同一链路或局域网内按帧传输 | Ethernet、Wi-Fi、MAC 地址;设备是交换机 |
| 物理层 | 传输比特流 | 网线、光纤、无线电信号 |
面试中最常问的协议对应关系是:
HTTP / HTTPS -> 应用层TLS / SSL -> 常按表示层理解TCP / UDP -> 传输层IP -> 网络层MAC 地址 -> 数据链路层例如浏览器访问 HTTPS 网站时,请求大致会逐层封装:
HTTP 请求 -> TLS 加密 -> TCP 分段,加入源端口和目标端口 -> IP 加入源 IP 和目标 IP,供路由器转发 -> Ethernet / Wi-Fi 加入源 MAC 和目标 MAC -> 通过网线、光纤或无线信号传输接收方按相反方向逐层拆包,最终将 HTTP 请求交给 Web 服务器。TCP 三次握手属于传输层;HTTPS 可以理解为 HTTP 加 TLS,HTTP 位于应用层,TLS 通常按表示层理解。
实际工程更多使用 TCP/IP 四层模型:
OSI 应用层 + 表示层 + 会话层 -> TCP/IP 应用层OSI 传输层 -> TCP/IP 传输层OSI 网络层 -> TCP/IP 网络层OSI 数据链路层 + 物理层 -> TCP/IP 网络接口层线上排障
11. 线上接口出现超时,你会如何排查?
答:
先确认影响范围:是全部请求还是部分请求,是单个实例还是整个集群,是持续发生还是突发发生,是否与刚发布的版本、流量峰值或下游故障相关。
然后根据 TraceId 沿调用链定位耗时位置:
Client -> Nginx / Gateway -> Service A -> Service B -> Redis / MySQL / MQ排查顺序如下:
- 监控指标:查看 QPS、错误率、RT/P99、CPU、Load、内存、GC、线程数、网络指标,先判断是资源饱和、突发流量还是依赖异常。
- 本服务线程:CPU 高时排查热点方法、死循环和频繁 GC;CPU 不高但请求阻塞时,查看 Web 线程池、业务线程池、队列和连接池。可用
jstack <pid>、Arthasthread查看线程阻塞位置。 - 数据库:查看 SQL 耗时、执行计划、慢 SQL、锁等待、连接数和连接池等待。大量线程卡在
HikariPool.getConnection()时,重点检查连接池耗尽和慢事务。 - Redis、MQ 与下游服务:检查 Redis RT、慢命令、大 Key、Hot Key、连接池;检查 MQ 堆积和消费延迟;当前服务耗时正常而下游调用慢时,继续沿下游链路排查。
- 网络与发布:排查 DNS、连接建立、丢包、跨机房网络;对比发布前后指标,确认是否只发生在新版本实例,必要时快速回滚或摘流。
总结:先确定范围,再看监控和调用链,接着定位到具体服务,最后按 CPU/GC/线程池、MySQL/Redis/MQ、下游依赖、网络和近期发布逐层收敛。