美团后端开发一面:Agent 项目深挖 + Java 并发八股
- 轮次
- 一面
- 时间
- 2026-09
- 来源
- 牛客网
《面试题目》
- 实习期间你具体做了什么?数据从哪里产生,主要分析什么?
- 你们部门是开发数据分析 agent 这个数据产品,还是数据部门自己去挖掘数据?
- 这个数据产品具体有哪些功能?它实际上是怎么做的?是不是做了能力适配,让业务团队直接使用大模型?
- 内置的 MCP 做了哪些工作,让数据分析 agent 表现更好?你在设计过程中参与了哪些具体事项?
- 在意图识别这块,你具体做了什么?
- 进入意图识别之后,针对深度分析等场景是怎么适配的?主要是装配 Skill 和 MCP 吗?
- 除了 Skill、MCP 和思考强度限制,还有哪些方式能让模型在特定场景下表现更好?
- 生产环境那个实例挂掉,是通过数据分析 agent 做的吗?你是怎么分析 dump 文件的?
- 这个数据产品是不是有页面,不同业务线用户可以上传文件、数据或文档做分析?底层是怎么实现的?
- 招聘助手按渠道适配、消息路由,最终产品形态是什么?是浏览器插件还是别的?
- 是本地起服务、本地起页面吗?不同渠道是否需要打通实际招聘平台?
- 你说的渠道具体指什么?
- 岗位这些数据从哪里来?
- 这个助手的作用是什么?是从招聘平台获取信息后,在本地页面按渠道分发到社交平台吗?
- 它怎么帮用户找到合适岗位?需要爬取吗?有爬虫吗?
- 具体涉及哪些外部招聘平台?是爬虫还是用户登录?登录和反爬怎么解决?
- 你平时用得比较多的 AI 编程工具或模型是什么?实际体验如何?
- 你怎么理解 Prompt Engineering 和 Context Engineering?
- 你了解现在主流的 Agent 架构吗?可以简单介绍一下。
- ReAct 为什么不适合大型复杂项目?
- 融合架构整体采用 Plan,如果初始 Plan 就错了,或者中间步骤确认后发现问题,怎么办?
- Java 中 List 里放自定义对象,怎么排序?自定义对象如何实现排序?
- Lambda 表达式背后的接口是什么?
- synchronized 修饰静态方法和实例方法有什么区别?锁对象分别是谁?类作为锁对象时有对应实例吗?
- 加锁之后锁标记放在哪里?怎么保证互斥?synchronized 修饰实例方法后,标记该实例已被当前线程持有,这个标记信息存在哪里?
- Java 多态中,父类引用指向子类实例,实际执行子类行为,动态绑定具体是怎么实现的?
- Java 线程池里 execute 提交任务和 submit 提交任务有什么区别?
- volatile 的实现原理,你知道哪些层次都可以说说;这些功能在 JVM 和操作系统层面分别怎么支持?
- 策略模式和模板方法模式有什么区别?
- 如果进程申请的内存远超过物理内存,操作系统怎么处理?
- 发生系统调用时,比如读文件,操作系统会经过什么流程?
- 内核处理系统调用时,进入和返回后,和应用程序怎么交互?
- 系统调用通常带参数,比如 read,内核怎么知道读哪个文件?返回后怎么回到应用程序下一行继续执行?
- 保存上下文的表具体在哪里?存在哪?
- TCP 第四次挥手时,第四个数据包丢失会怎么样?最后结论是什么?哪一方需要做什么?是重发第三次挥手吗?
- 如何用 UDP 实现 TCP 的可靠传输?
- MySQL/InnoDB 的日志主要有哪些?各自作用是什么?
- 一个事务提交时,redo log、undo log、binlog 的落盘顺序是什么?
- Redis 的淘汰策略你了解吗?
- LRU 和 LFU 各有什么不足?怎么优化 LFU?Redis 里 LFU 是怎么实现的?会不会增加开销?怎么保证 LFU 的计数准确?
- 手撕:盛最多水的容器。
《参考解析》
synchronized 与 volatile:锁标记放在哪,可见性和有序性又靠什么
先看 synchronized 的锁对象问题。实例方法上的 synchronized 锁的是 this,静态方法上的锁的是这个方法所属的 java.lang.Class 对象。这里有个常见误区:类被 JVM 加载后会在堆里生成一个 Class 实例,static synchronized 锁的就是它,所以「类作为锁对象时有没有对应实例」的答案是——有,就是那个 Class 对象。这两把锁互不相干:一个线程拿 this 锁跑实例方法时,另一个线程照样能进静态同步方法。
锁标记不在「锁」这个对象自身的数据字段里,而在对象头的 Mark Word(64 位 JVM 下 8 字节)里,按锁状态复用同一块位:
- 无锁:存
hashCode(31 位)+ 分代年龄(4 位)+ 偏向标志 + 锁标志位01; - 偏向锁:存偏向线程 ID + Epoch + 分代年龄,标志位仍是
01; - 轻量级锁:存指向当前线程栈帧中 Lock Record 的指针,标志位
00; - 重量级锁:存指向堆中 ObjectMonitor 的指针,标志位
10。
互斥是靠 CAS 抢 Mark Word 实现的:线程先把 Mark Word 拷到自己栈上的 Lock Record,再 CAS 写回对象头,抢到的执行,抢不到的自旋几次,还失败就把锁膨胀成重量级锁,把线程挂到 ObjectMonitor 的 _EntryList 上并 park();_owner 记录持有者,_recursions 记录重入次数,所以 synchronized 是可重入的。方法级的同步在字节码里靠方法访问标志 ACC_SYNCHRONIZED,同步块则是 monitorenter / monitorexit(编译器会保证异常路径也有 exit)。顺带一提,偏向锁自 JDK 15(JEP 374)起默认禁用并在后续版本移除,新版本面试里可以只说轻量级/重量级两条路径。
volatile 解决的则是另一个问题:可见性 + 禁止指令重排序,不保证原子性,volatile int i; i++ 依然线程不安全。
JVM 层面:字段带 ACC_VOLATILE 标志,JMM 规定在 volatile 写之前插 StoreStore 屏障、写之后插 StoreLoad 屏障,读之后插 LoadLoad/LoadStore 屏障(JSR-133)。这些屏障在解释执行时体现为内存语义,在 JIT 编译期体现为禁止把屏障两侧的访存指令重排——比如不能把 volatile 写之前的普通写挪到它后面,这才让「DCL 单例里 instance = new Singleton() 先发布引用后初始化对象」这种重排不会发生。
操作系统/硬件层面:x86 属于 TSO 强一致模型,读操作本身就是普通 mov,靠 MESI 缓存一致性协议保证——某个核改了自己缓存行里的数据后,其他核里该缓存行的副本被置为 Invalid,再读时必须重新拉取,所以「一个核的写能立刻被其他核看到」。写操作会被编译成一条带 lock 前缀的指令(HotSpot 里常见是 lock addl $0x0,(%rsp)),lock 前缀一方面锁住该缓存行(必要时升级为总线锁)保证读-改-写原子,另一方面充当内存屏障,把 store buffer 里的内容刷出去、阻止乱序执行。ARM 这类弱一致性架构没有这么便宜,需要显式插入 dmb ish 之类的屏障指令,代价更高——这也是「volatile 在不同架构上开销不同」的原因。顺带一个坑:多个 volatile 变量放在同一个缓存行里会互相失效,性能反而差,需要用 @Contended 或手动 padding。
一次 read 文件系统调用,从用户态到内核态再回来
用户态:read(fd, buf, count) 在 glibc 里被翻译成——把系统调用号写进 rax(x86-64 下 read 是 0),参数按 ABI 依次放进 rdi(fd)、rsi(buf)、rdx(count),然后执行 syscall 指令。
进入内核:syscall 让 CPU 从 ring3 切到 ring0,并从 MSR_LSTAR 寄存器取出内核预先注册的入口 entry_SYSCALL_64;入口汇编用 swapgs 换到内核的 GS 基址,把用户栈切换成当前线程专属的内核栈(每个线程在 task_struct 之外有一块 8KB/16KB 的内核栈),并把用户态的寄存器(rax/rcx/r11 以及通用寄存器)压成一个 pt_regs 结构保存在这块内核栈上——这就是面试官问的「保存上下文的表具体在哪里」的答案:不是一张全局表,而是每个线程自己内核栈上的 pt_regs;线程被抢占时,switch_to 还会把 CPU 寄存器上下文存进 task_struct->thread.cpu_context。
内核处理:SYSCALL_DEFINE3(read, ...) 从 pt_regs 里取参数。fd 是用户进程文件描述符表的下标:current->files->fd_array[fd] 得到 struct file,再顺着 f_op->read_iter / f_inode 找到 inode 和页缓存;命中页缓存就直接 copy_to_user 把数据拷回用户缓冲区,没命中就发起块设备 IO,把进程状态设为 TASK_UNINTERRUPTIBLE 让出 CPU,等 IO 完成中断唤醒后再继续。
返回:把返回值写进 rax,检查有没有 pending 信号或需要重新调度,然后 sysretq 把之前保存的 RIP(syscall 的下一条指令)、RFLAGS、RSP 恢复回去,程序就从 read 的下一行继续跑——这正是「内核怎么回到应用程序下一行」的机制。
TCP 第四次挥手丢包会怎样,以及怎么用 UDP 做可靠传输
四次挥手是 FIN → ACK → FIN → ACK。第三次 FIN 由被动关闭方发出,第四次 ACK 由主动关闭方发出。第四次 ACK 丢了:主动方发完 ACK 就进入 TIME_WAIT 并等 2MSL;被动方停在 LAST_ACK,它的定时器超时后会重传第三次 FIN(不是主动方去重发第三次挥手)。主动方收到这个重传 FIN 会把 TIME_WAIT 计时重置为 2MSL,并再补一个 ACK;只要重传能让 ACK 送达,双方最终都能正常关闭,最坏情况是被动方重传几次后放弃并复位连接。
2MSL 的作用是三点:给最后一个 ACK 留出「去 1MSL + 回来 1MSL」的容错时间;让本次连接产生的老报文在网络中自然消亡,避免新连接复用同一四元组时收到旧数据;配合 TIME_WAIT 状态防止把旧连接的包当新连接的数据。线上要处理 TIME_WAIT 堆积,能动的通常是 net.ipv4.tcp_tw_reuse=1(仅对客户端主动发起的连接生效)和 tcp_max_tw_buckets,靠缩短 tcp_fin_timeout 是不解决问题的。
用 UDP 实现可靠传输,本质是把 TCP 的机制搬到应用层重做一遍:给每个包编号(序号 + 确认号)实现去重与乱序重排;接收端回 ACK(累积确认 + 可选的 SACK 位图)让发送端滑动窗口前进;用 RTT 采样自适应算 RTO,超时重传,配合 Karn 算法避免重传样本污染 RTT 估计;加校验和防位翻转;加接收窗口做流量控制,加慢启动/拥塞避免/快速重传做拥塞控制;再加连接建立与关闭(或连接 ID)管理会话。QUIC 就是这个思路的工业实现,另外还可以用 FEC(前向纠错)牺牲一点带宽换更低的尾延迟。相对 TCP 的收益是:可以自定义重传与拥塞策略、避免 TCP 队头阻塞、连接迁移、0-RTT,代价是都要自己实现和调参。
事务提交时 redo log、undo log、binlog 的落盘顺序
三个日志的定位:redo log 是 InnoDB 的物理逻辑日志,记录「在某个数据页上做了什么修改」,用于崩溃恢复时前滚;undo log 是 InnoDB 的逻辑日志,记录反向操作,用于事务回滚和 MVCC 版本链;binlog 是 MySQL Server 层的逻辑日志,记录所有修改(statement/row/mixed 格式),用于主从复制和备份恢复。
落盘顺序就是两阶段提交:
- 事务执行期间,修改先写 redo log buffer 和 undo 表空间,binlog 还只在 Server 层的 binlog cache 里攒着;修改的数据页通常还在 buffer pool(写时已记 redo,脏页由后台刷)。
- 提交时 InnoDB 进入 prepare:把该事务的 redo 刷盘,并在 redo 里打上 prepare 标记。
- Server 层把 binlog cache 刷盘(一个事务的 binlog 是一个完整的、带 XID 的 event)。
- InnoDB 进入 commit:把 redo 的 prepare 标记改成 commit,这一步只是内存操作,不需要再 fsync。
崩溃恢复时的判断逻辑就是靠这个顺序:扫 redo,遇到 prepare 状态的事务,就拿 XID 去 binlog 里找——binlog 完整就提交,不完整就回滚,从而保证两个日志一致。
为什么不能简化:如果先写 redo 再写 binlog,写完 redo 就宕机,重启后 redo 前滚了数据,但 binlog 没写,从库和备份就少了这个事务;如果反过来先写 binlog 再写 redo,写完 binlog 宕机,主库恢复后没有这笔数据,从库却有,同样不一致。刷盘时机由 innodb_flush_log_at_trx_commit=1 + sync_binlog=1(双一)保证最安全,代价是每次提交两次 fsync;为了摊薄这个代价,MySQL 用组提交(binlog flush/sync 分组、redo fsync 跟着分组),并发提交越多,摊销越明显。另外注意:如果实例没开 binlog,就没有两阶段提交这回事,只靠 redo 保证持久性。
LRU 和 LFU 各有什么不足,Redis 的 LFU 是怎么实现的
LRU 只按「最近一次访问时间」排序,两个典型问题:一是缓存污染——一次性批量扫描(比如全表导出)会把大量只访问一次的 key 顶进缓存,把真正的热点挤出去;二是对「周期性强、但热度低」的访问没有抵抗力。LFU 按访问频次排序,问题也有两个:一是旧热点难以淘汰,一个曾经爆火的 key 攒了很高计数,即使早就没人访问也长期占位(历史权重过大);二是新 key 容易饿死,刚进来的 key 计数为 1,面对历史高计数永远排不上。另外纯 LFU 需要为每个 key 维护计数器和按计数排序的结构,内存与实现开销都更大。
Redis 的 LFU 是「近似 LRU + 概率计数」的组合:maxmemory-policy 设成 allkeys-lfu / volatile-lfu 后,每个对象 24 bit 的 lru 字段被拆成两部分——高 16 bit 存访问时间(分钟级精度的 lru_clock,用于衰减),低 8 bit 存对数计数器(logarithmic counter)。计数不是每次访问都 +1,而是按概率递增:p = 1 / (counter * lfu_log_factor + 1)(lfu_log_factor 默认 10),counter 越大越难再加,8 bit 就能表示很大的访问量级;同时用 lfu_decay_time(默认 1 分钟)按「距上次访问过了多少个衰减周期」给 counter 减一次,解决了旧热点退不出去的问题。所以不存在需要额外内存的独立计数器,开销就是每个 key 固有的那 24 bit,衰减和递增都是常数时间,几乎没有额外开销。
「计数准不准」要分两面看:它是概率计数,本来就不精确,目的是排序而不是记账;好处是不精确换来极小内存和 O(1),并且通过 lfu_log_factor 调陡峭度、lfu_decay_time 调衰减速度就能适配不同业务。真要更精确可以调大 lfu_log_factor 让计数增长更快在低区段更细,或者干脆用业务侧自建的热点探测(比如本地 Caffeine + 定期上报)。另外 Redis 的淘汰是在采样基础上做的近似(maxmemory-samples 默认 5),即使策略选对了,也应该监控 evicted_keys、命中率和内存水位,别把缓存命中率下降当偶然。
手撕:盛最多水的容器
题意:数组 height,下标差是宽度,两端较矮的那根是高度,求能盛的最大水量。暴力枚举 O(n²),正解是对撞双指针 O(n)。
public int maxArea(int[] height) {
int l = 0, r = height.length - 1, ans = 0;
while (l < r) {
int h = Math.min(height[l], height[r]);
ans = Math.max(ans, h * (r - l));
if (height[l] <= height[r]) l++;
else r--;
}
return ans;
}
为什么移动矮的一侧就能安全丢弃:假设当前 height[l] <= height[r],容量上限是 height[l] * (r - l)。如果下一步移动 r,宽度一定变小,而新的短板不超过 height[l],容量只会更小——也就是说,以当前 l 为一端的所有方案里,和 r 配已经是最好的一种,l 可以安全地扔掉。两边相等时移动任意一边都成立。
边界:n < 2 返回 0;题目约束下面积不会溢出 int,但用 long 更稳妥。注意别和「接雨水」混:那题要问每个柱子左右两侧最大高度的较小值,用前后缀最大值或单调栈,双指针在这题才成立。