快手 AI 全栈一面:Agent 工作流、JVM 治理与并发八股
- 轮次
- 一面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
- 介绍下数据分析 Agent 项目,主要做什么?你具体负责哪些部分?
- 工作流的节点编排怎么做?
- skills 怎么组织、识别和召回?相似提示词怎么保证准确率?是不是统一放 skills 目录,交给 AI 调度?
- 工程做过哪些优化?算 AI 工程吗?
- GC / JVM 治理是怎么回事?
- CPU 飙高怎么排查?怎么定位到线程?
- 线程池核心参数及作用?
- volatile 的作用和场景?能保证原子性吗?为什么?
- ThreadLocal 的原理、场景?key/value 是什么?存在哪?
- 还熟悉哪些锁?ReentrantLock 怎么实现?AQS 的状态和链表?可重入、读写锁、获取锁过程?
- HashMap 底层结构、put、扩容和迁移?线程安全吗?为什么头插法会成环?
- 线程安全的 Map 有哪些?ConcurrentHashMap 怎么实现?
- Spring IoC 和 AOP 是什么?循环依赖怎么解决?
- B+ 树结构?为什么选它?哪些字段建索引?单字段索引和联合索引的区别?索引失效场景?
- SQL:给定 userid、subject、scores,查询得到以具体科目为字段名、按照 userid 分行的分数记录,缺失的科目分数默认为 0。
- 手撕:删掉链表的倒数第 n 个节点。
《参考解析》
数据分析 Agent:编排、Skills 与「提示词的准确率」
这类项目要讲成一条可复现的流水线,而不是「用了大模型」。节点编排可以按「意图识别 → 任务拆解 → 工具/代码执行 → 结果校验 → 汇总成文」来讲,每个节点说清输入输出与失败处理。编排形态上要能区分固定 DAG 与动态规划:固定流程稳定、可观测,适合取数这种步骤明确的场景;动态编排灵活但容易跑偏,所以要给约束(可用工具白名单、最大步数、预算上限)和回退路径。skills 的组织方式一般是「一个 skill = 一段可复用的能力说明 + 触发条件 + 输入输出契约 + 示例」,靠描述文本做语义召回,或者先按关键词/规则粗筛再用向量精排;要主动讲清为什么不能把所有提示词堆在一个目录里让模型自己挑——数量一多召回就会漂,正确做法是分层(分类目录先路由、再在小集合里精排)、给每个 skill 写明「什么时候用、什么时候不要用」、并维护一份回归用例集来度量召回准确率。相似提示词的区分靠三件事:把差异写成显式条件、给反例、以及用测试集持续评估;发现混淆就让两个 skill 的适用边界互补而不是重叠。工程优化可以落在:工具调用并行化、结果缓存(同一问题重用中间结果)、token 预算控制(摘要与截断策略)、失败重试与降级、以及全链路 trace 让每次回答都能回溯到用了哪些数据。
「算 AI 工程吗」这类问题不要纠缠定义,回答的落点是「有多少是模型能力、有多少是工程约束」:模型负责语义理解与生成,工程负责让它在可控、可观测、可复现的边界内运行,两者缺一不可。
JVM 治理与 CPU 飙高的排查路径
先分清「GC 治理」要解决的到底是频率、单次停顿还是内存泄漏。排查套路是看 GC 日志(-Xlog:gc*)确定是 Young GC 太频繁还是 Full GC 频繁:Young 频繁说明分配速率高或新生代太小,Full 频繁且回收后水位不降则指向泄漏或大对象,停顿长则要看收集器选择与堆大小(G1/ZGC 的取舍)。CPU 飙高的定位顺序是:top 找到进程,top -Hp <pid> 找到线程,把线程 ID 转十六进制后到 jstack <pid> 里比对 nid,读出它在做什么;如果栈显示在 GC 线程,就是内存问题导致 CPU 被 GC 吃掉;反复出现的业务方法就是热点,需要看是不是死循环、锁自旋、正则回溯、频繁序列化。要有能力区分「CPU 高的原因在应用内」还是「在外部依赖等待重试」。
线程池参数按七个来讲:核心线程数、最大线程数、空闲存活时间与单位、任务队列、线程工厂(命名很关键)、拒绝策略;执行顺序是核心满 → 入队 → 队满扩到最大 → 拒绝。生产上要自己 new ThreadPoolExecutor 而不用 Executors 的快捷方法(固定池用无界队列会 OOM、缓存池线程数不受控),并给队列设边界、给线程起可识别的名字、把活跃数与队列长度纳入监控。
volatile、ThreadLocal、ReentrantLock 与 AQS
volatile 保证可见性与禁止指令重排,但不保证原子性——因为像 i++ 是「读-改-写」三步,多个线程交错时会丢失更新;要原子性得用 AtomicInteger(CAS)或加锁。典型场景是状态标志位、单例的双重检查锁(防止其他线程读到未初始化完成的对象)。ThreadLocal 的实现是每个 Thread 内部的 ThreadLocalMap,key 是 ThreadLocal 的弱引用、value 是强引用,数据存在线程对象上,所以线程池里必须 remove;用途是请求上下文(用户、traceId)与线程不安全的对象副本;要顺口提 InheritableThreadLocal 与线程池复用的冲突。
ReentrantLock 基于 AQS:AQS 内部是一个 volatile int state 加一个双向的 CLH 等待队列(Node 里存线程与等待状态),获取锁就是用 CAS 把 state 从 0 改成 1,失败就入队并 park;释放时 state 减到 0 再唤醒后继。可重入靠 state 累加与持有者线程判断。公平锁在入队前多一步「队列里有没有前驱」的判断,因此吞吐略低但避免饥饿。读写锁把 state 的高 16 位当读计数、低 16 位当写计数,读读共享、读写与写写互斥;StampedLock 则用乐观读进一步减少读的开销。
HashMap 成环、ConcurrentHashMap 与 Spring 循环依赖
HashMap 线程不安全,JDK 1.7 的头插法在并发扩容时会把链表指针改成环——两个线程同时 transfer,一个线程把 A 指向 B 之后另一个线程又按旧顺序把头插回去,就可能形成 A↔B 的循环,之后 get 一个不存在的 key 就会死循环。JDK 1.8 改成尾插并引入红黑树,成环问题消失,但仍然不是线程安全的(并发 put 会丢数据、size 不准)。线程安全的替代有 Hashtable(全表锁,已淘汰)、Collections.synchronizedMap(同样是全表锁)、ConcurrentHashMap。1.7 的 CHM 用 Segment 分段锁;1.8 改成 CAS + synchronized 锁单个桶头节点,结构是数组+链表+红黑树,扩容支持多线程协同迁移(ForwardingNode 标记),size 用 baseCount 加 CounterCell 分散计数。它不允许 null 键值,原因是并发下无法区分「键不存在」和「值为 null」。
Spring 的 IoC 是容器负责对象的创建、装配与生命周期管理,好处是解耦与可测试;AOP 是在不修改业务代码的前提下织入横切逻辑(事务、日志、权限),实现有 JDK 动态代理(基于接口)与 CGLIB(基于继承生成子类),Spring Boot 默认都用 CGLIB。循环依赖的解决靠三级缓存:一级放成品单例、二级放提前暴露的早期引用(已实例化未注入属性)、三级放生成早期引用的工厂(用于 AOP 代理场景,保证拿到的代理与最终 bean 是同一个)。流程是 A 实例化后把工厂放进三级缓存,注入 B 时 B 又需要 A,于是从三级缓存拿到工厂生成 A 的早期引用并升到二级缓存。这套机制只能解决单例、且是 setter/字段注入的循环依赖;构造器注入的循环依赖无法解决(会直接抛 BeanCurrentlyInCreationException),原型作用域也不行——这也是推荐构造器注入时要避免循环依赖的原因。
B+ 树、索引与那道行转列 SQL
B+ 树的特点:非叶子节点只存键与指针(扇出大、树矮)、数据全在叶子、叶子用双向链表连接(范围查询与排序友好)、所有查询都要走到叶子(路径长度一致,性能稳定)。相比 B 树,它把数据只放在叶子,同样大小的节点能容纳更多键,磁盘 IO 次数更少且范围扫描不需要回溯。建索引的字段选择:高频出现在 where、join、order by/group by 的列,区分度高(唯一键、订单号、手机号),以及能构成覆盖索引的组合。单字段索引是联合索引的特例,联合索引遵循最左前缀——(a,b,c) 能支撑 a、a+b、a+b+c,但跳过 a 直接用 b 不行(除非有索引跳跃扫描)。索引失效的典型:对列做函数或运算、隐式类型转换(字符串列传数字)、like '%x' 前导通配、or 连接非索引列、区分度太低导致优化器选全表扫、联合索引不满足最左前缀、以及 !=/not in 在大比例数据上。
行转列的 SQL 用条件聚合最通用:SELECT userid, SUM(CASE WHEN subject='语文' THEN score ELSE 0 END) AS yuwen, SUM(CASE WHEN subject='数学' THEN score ELSE 0 END) AS shuxue FROM t GROUP BY userid;,缺失科目因为默认走 ELSE 0 自动补零;如果科目不固定就用动态拼接(先查出 distinct subject 再生成 SQL),或者用 GROUP_CONCAT 做展示层的拼接。答题时说明「列不固定时静态 SQL 写不出来」这一点,比单纯给出写法更有价值。
手撕:删除链表倒数第 N 个节点
标准解法是快慢指针:快指针先走 n 步,然后两指针同步前进,快指针到末尾时慢指针停在待删节点的前驱;用哨兵(dummy)节点指头可以统一「删头节点」的边界,一次遍历完成。要主动覆盖的用例:n 等于链表长度(删头)、n 大于长度(返回原链表或按约定报错)、只有一个节点。写完说一句复杂度 O(n) 时间 O(1) 空间,并说明为什么不能用两次遍历(虽然也能过,但面试想看你一次遍历)。