面灵AI→

美团后端开发一面:Agent 项目深挖 + Java 并发八股

轮次
一面
时间
2026-09
来源
牛客网

《面试题目》

  1. 实习期间你具体做了什么?数据从哪里产生,主要分析什么?
  2. 你们部门是开发数据分析 agent 这个数据产品,还是数据部门自己去挖掘数据?
  3. 这个数据产品具体有哪些功能?它实际上是怎么做的?是不是做了能力适配,让业务团队直接使用大模型?
  4. 内置的 MCP 做了哪些工作,让数据分析 agent 表现更好?你在设计过程中参与了哪些具体事项?
  5. 在意图识别这块,你具体做了什么?
  6. 进入意图识别之后,针对深度分析等场景是怎么适配的?主要是装配 Skill 和 MCP 吗?
  7. 除了 Skill、MCP 和思考强度限制,还有哪些方式能让模型在特定场景下表现更好?
  8. 生产环境那个实例挂掉,是通过数据分析 agent 做的吗?你是怎么分析 dump 文件的?
  9. 这个数据产品是不是有页面,不同业务线用户可以上传文件、数据或文档做分析?底层是怎么实现的?
  10. 招聘助手按渠道适配、消息路由,最终产品形态是什么?是浏览器插件还是别的?
  11. 是本地起服务、本地起页面吗?不同渠道是否需要打通实际招聘平台?
  12. 你说的渠道具体指什么?
  13. 岗位这些数据从哪里来?
  14. 这个助手的作用是什么?是从招聘平台获取信息后,在本地页面按渠道分发到社交平台吗?
  15. 它怎么帮用户找到合适岗位?需要爬取吗?有爬虫吗?
  16. 具体涉及哪些外部招聘平台?是爬虫还是用户登录?登录和反爬怎么解决?
  17. 你平时用得比较多的 AI 编程工具或模型是什么?实际体验如何?
  18. 你怎么理解 Prompt Engineering 和 Context Engineering?
  19. 你了解现在主流的 Agent 架构吗?可以简单介绍一下。
  20. ReAct 为什么不适合大型复杂项目?
  21. 融合架构整体采用 Plan,如果初始 Plan 就错了,或者中间步骤确认后发现问题,怎么办?
  22. Java 中 List 里放自定义对象,怎么排序?自定义对象如何实现排序?
  23. Lambda 表达式背后的接口是什么?
  24. synchronized 修饰静态方法和实例方法有什么区别?锁对象分别是谁?类作为锁对象时有对应实例吗?
  25. 加锁之后锁标记放在哪里?怎么保证互斥?synchronized 修饰实例方法后,标记该实例已被当前线程持有,这个标记信息存在哪里?
  26. Java 多态中,父类引用指向子类实例,实际执行子类行为,动态绑定具体是怎么实现的?
  27. Java 线程池里 execute 提交任务和 submit 提交任务有什么区别?
  28. volatile 的实现原理,你知道哪些层次都可以说说;这些功能在 JVM 和操作系统层面分别怎么支持?
  29. 策略模式和模板方法模式有什么区别?
  30. 如果进程申请的内存远超过物理内存,操作系统怎么处理?
  31. 发生系统调用时,比如读文件,操作系统会经过什么流程?
  32. 内核处理系统调用时,进入和返回后,和应用程序怎么交互?
  33. 系统调用通常带参数,比如 read,内核怎么知道读哪个文件?返回后怎么回到应用程序下一行继续执行?
  34. 保存上下文的表具体在哪里?存在哪?
  35. TCP 第四次挥手时,第四个数据包丢失会怎么样?最后结论是什么?哪一方需要做什么?是重发第三次挥手吗?
  36. 如何用 UDP 实现 TCP 的可靠传输?
  37. MySQL/InnoDB 的日志主要有哪些?各自作用是什么?
  38. 一个事务提交时,redo log、undo log、binlog 的落盘顺序是什么?
  39. Redis 的淘汰策略你了解吗?
  40. LRU 和 LFU 各有什么不足?怎么优化 LFU?Redis 里 LFU 是怎么实现的?会不会增加开销?怎么保证 LFU 的计数准确?
  41. 手撕:盛最多水的容器。

《参考解析》

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 格式),用于主从复制和备份恢复。

落盘顺序就是两阶段提交:

  1. 事务执行期间,修改先写 redo log buffer 和 undo 表空间,binlog 还只在 Server 层的 binlog cache 里攒着;修改的数据页通常还在 buffer pool(写时已记 redo,脏页由后台刷)。
  2. 提交时 InnoDB 进入 prepare:把该事务的 redo 刷盘,并在 redo 里打上 prepare 标记。
  3. Server 层把 binlog cache 刷盘(一个事务的 binlog 是一个完整的、带 XID 的 event)。
  4. 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 更稳妥。注意别和「接雨水」混:那题要问每个柱子左右两侧最大高度的较小值,用前后缀最大值或单调栈,双指针在这题才成立。