快手电商一面 — 19 道面试题总结

✅ 回答要点

1. Java 泛型的类型擦除

Java泛型-类型擦除

2. JVM 对象从 new 到 GC 回收的全过程

对象创建流程:

  1. 类加载检查 → 2. 分配内存 → 3. 初始化零值 → 4. 设置对象头 → 5. 执行 <init> 方法

内存分配:

  • TLAB(Thread Local Allocation Buffer):每个线程在 Eden 区预分配一块私有的内存缓冲区,避免多线程竞争。对象优先在 TLAB 中分配,TLAB 空间不足时在 Eden 区加锁分配。
  • 大对象直接进入老年代:通过 -XX:PretenureSizeThreshold 参数控制,超过该大小的对象直接在老年代分配,避免在 Eden 和 Survivor 之间频繁复制。

对象生命周期:

  • 新生代(Eden → S0 / S1)→ 年龄增长 → 进入老年代 → 老年代满 → Full GC/Major GC → 不可达对象被回收

GC 回收判定:

  • 可达性分析(GC Roots Tracing)→ 不可达对象标记 → 第一次标记(finalize)→ 第二次标记 → 回收
    • 可达性分析:从一组称为 GC-Roots详解 的根对象出发,沿着引用链(Reference Chain)向下遍历,遍历经过的对象标记为 “可达”(reachable)没有被遍历到的对象就是”不可达”(unreachable),也就是”垃圾候选人”
    • 不可达对象标记:第一次不可达只是标记,不会立即回收
    • 第一次标记-finalize机会:JVM 会检查这个不可达对象 是否覆盖了 finalize() 方法(且未被 JVM 调用过)
      • 如果没覆盖 finalize() → 直接跳过此步,进入第 5 步回收
      • 如果覆盖了 finalize() → 将该对象放入 F-Queue(一个低优先级队列)
        • JVM 启动一个 Finalizer 线程(优先级很低),逐个执行 F-Queue 中对象的 finalize() 方法。在 finalize() 方法中,对象可能重新与 GC Roots 链上的对象建立引用关系。但是实际生产中几乎不用。
    • 第二次标记finalize() 执行完成后,JVM 对 F-Queue 中的对象进行 第二次标记
      • 如果对象在 finalize() 中 自救成功(重新可达)→ 移出回收队列,对象存活
      • 如果对象仍然 不可达 → 正式判定死亡,进入下一步回收
       开始
         │
         ▼
  ┌──────────────┐
  │ GC Roots Tracing │ ← 从根出发遍历对象图
  └──────┬───────┘
         │
         ▼
  不可达对象标记 ──────→ 没覆盖 finalize() ────→ 直接回收
         │
         ▼
  放入 F-Queue
  执行 finalize()   ← 对象最后自救机会
         │
         ▼
  第二次标记 ──────→ 自救成功(重新可达)──→ 存活(移出队列)
         │                          ↑
         ▼                          │
  正式判定死亡 ──────→ 自救失败 ─────┘
         │
         ▼
       回收

JVM-对象创建与内存分配

3. GC Roots 有哪些?

GC Roots 包括(以 HotSpot 为例):

  1. 虚拟机栈(栈帧中的局部变量表)引用的对象:当前正在执行的方法中的局部变量
  2. 方法区中静态属性引用的对象:类的静态成员变量
  3. 方法区中常量引用的对象:字符串常量池中的引用、final 常量
  4. 本地方法栈中 JNI 引用的对象:Native 方法引用的对象
  5. Java 虚拟机内部的引用:系统类加载器、基本类型 Class 对象、常驻异常对象等
  6. 所有被 synchronized 持有的对象
  7. JVM 内部的 JMXBean、JVMTI 回调等

局部变量为什么能作为 GC Root?

  • 局部变量存储在虚拟机栈的栈帧中,栈帧是当前活跃线程的方法调用帧。只要方法正在执行,局部变量就引用了堆中的对象,这个对象必须存活,不能被回收。GC 从栈帧中的局部变量开始遍历对象图,所有可达对象都需要保留。

静态变量什么时候被移除?

  • 静态变量本身属于类元数据,存储在方法区(元空间),它们引用堆中的对象。当类被卸载时,静态变量引用的对象就不再是 GC Root。类卸载发生在 Metaspace 空间不足需要回收时,条件:
    • 该类所有的实例都已被回收
    • 该类的 ClassLoader 已被回收
    • 该类对应的 java.lang.Class 对象没有任何地方被引用

GC-Roots详解

4. ReadWriteLock 使用场景与锁降级

详见已有知识卡片:Java读写锁-ReadWriteLock

使用场景: 读多写少的场景(如缓存、配置中心)。 默认锁: 非公平锁(性能优于公平锁)。 锁降级: 指写锁降级为读锁 — 持有写锁时获取读锁,然后释放写锁,最终只持有读锁。

5. synchronized 锁对象分析

详见已有知识卡片:synchronized机制详解

  • 修饰静态方法:锁的是当前类的 Class 对象
  • 修饰实例方法:锁的是当前实例对象 this
  • 两者能同时执行吗? 不能。因为静态方法锁 Class 对象,实例方法锁实例对象,锁的对象不同,可以同时执行(互不阻塞)

6. wait/notify 机制

wait() 后线程什么时候被唤醒?

  • notify() 随机唤醒一个等待线程
  • notifyAll() 唤醒所有等待线程
  • interrupt() 中断并抛出 InterruptedException
  • 虚假唤醒(Spurious Wakeup):即使没有 notify,线程也可能被唤醒,所以 wait 必须在循环中调用

notify() vs notifyAll()

  • notify():随机唤醒一个等待线程,可能导致”信号丢失”(唤醒的线程不满足条件又重新 wait)
  • notifyAll():唤醒所有等待线程,更安全但可能有性能开销
  • 一般建议用 notifyAll() 除非你非常确定唤醒一个就够

唤醒后需要重新获取 monitor 吗?

  • 需要。线程被唤醒后会从 Waiting 状态进入 Blocked 状态,等待获取 monitor 锁。获取到锁后才能从 wait() 返回继续执行。

7. TCP 三次握手与可靠性

SYN-ACK 丢了客户端会怎样?

  • 客户端一直收不到 SYN-ACK,会重传 SYN。重传超时时间(RTO)指数退避,默认重试次数由 tcp_syn_retries 控制(默认 6 次,约 127 秒)。

SYN Flood 防御:

  1. SYN Cookie:不分配连接资源,直接计算一个 Cookie 作为初始序列号发给客户端
  2. SYN Cache:使用有限散列表缓存半连接,超过就丢弃
  3. 缩短 SYN Timeout:减少半连接等待时间
  4. tcp_max_syn_backlog:限制半连接队列大小
  5. 反向探测(如 RST 验证)

TIME_WAIT 等待 2MSL 的原因:

  1. 保证最后一个 ACK 能到达服务端:如果 ACK 丢了,服务端重传 FIN,客户端需要等待并重新发送 ACK
  2. 防止旧连接数据包影响新连接:2MSL 时间足够网络中所有数据包消失
  • MSL(Maximum Segment Lifetime):报文最大生存时间,Linux 默认 30s/60s

8. MySQL 两阶段提交

详见已有知识卡片:Redo日志详解MySQL Binlog 日志配置

两阶段提交流程:

  1. Prepare 阶段:事务写入 redo log,状态设为 prepare
  2. Commit 阶段:写入 binlog,然后提交事务,将 redo log 状态改为 commit

prepare 后崩溃:

  • 若 prepare 后、binlog 写入前崩溃 → 回滚事务
  • 若 binlog 已写入、但 redo log 未标记 commit 时崩溃 → 恢复时检查 binlog 和 redo log 的一致性,若 binlog 完整则重新提交
  • 关键: 以 binlog 是否写成功作为事务是否提交的依据

9. MySQL 深度分页优化

详见已有知识卡片:MySQL索引创建原则MySQL联合索引

LIMIT 1000000,10 为什么慢?

  • 需要扫描前 1000010 行数据然后丢弃前 1000000 行
  • InnoDB 按聚簇索引顺序扫描,即使有覆盖索引也要回表查

优化方案:

  1. 子查询优化(延迟关联):利用覆盖索引先找到 id,再关联原表
  2. 范围查询代替分页WHERE id > 1000000 LIMIT 10(不支持跳页)
  3. 基于游标的分页:传上次的最后 ID (WHERE id > last_id LIMIT 10)
  4. 强制跳页优化:预计算偏移量映射表(定时任务更新偏移量到主键 ID 的映射)

10. Redis BigKey

BigKey 定义: 单个 key 的 value 过大(String 类型 > 10KB,集合类型元素 > 5000 个/Len > 10000)

导致的问题:

  1. 操作阻塞:Redis 单线程模型下,BigKey 的读写操作耗时过长
  2. 网络延迟:传输大对象占用带宽
  3. 内存倾斜:集群模式下某个分片内存使用远高于其他分片
  4. 慢查询DELGET 等操作成为慢查询
  5. 数据迁移慢:集群扩容/缩容时需要迁移大量数据

删除时为什么会阻塞?

  • 对于集合类型(List/Hash/Set/SortedSet),DEL 命令需要遍历所有元素释放内存,时间复杂度 O(N)。如果元素数量巨大,释放过程会长时间占用主线程。
  • 优化方案:使用 UNLINK 命令(异步删除)或者分批删除(SSCAN/HSCAN + SREM/HDEL

11. OOM 处理

详见已有知识卡片:JVM-OOM排查指南

Heap Space vs Metaspace:

  • Heap Space:存放 Java 对象实例,OOM 时通常是因为对象泄漏或堆太小
  • Metaspace(JDK8+):存放类元数据(Class 信息、方法数据等),默认无上限(受本地内存限制),类加载过多会导致 Metaspace OOM

MAT 分析 heap dump 步骤:

  1. Dominator Tree:找出最大的对象/对象集合
  2. Leak Suspects:MAT 自动分析泄漏嫌疑
  3. GC Root Path:找到 GC Root 到泄漏对象的引用链,分析为什么未被回收
  4. Accumulated Objects:按 Retained Size 排序,找出内存大户

12. 线程死锁定位与避免

快速定位(jstack):

  1. jstack <pid> 打印线程堆栈
  2. 搜索 Found one Java-level deadlock 直接定位死锁
  3. 查看死锁线程的堆栈,看哪些锁在等待
  4. 也可以使用 jconsoleVisualVM 的图形界面检测(自动检测死锁)

线上恢复:

  1. kill -3 触发线程 dump(不停止进程)
  2. 分析 dump 文件找出死锁
  3. 重启服务(最直接但暴力),如果无法重启则尝试 kill 死锁线程(危险,可能导致数据不一致)

代码层面避免:

  1. 固定锁顺序:所有线程按统一顺序获取锁
  2. 使用显式锁的 tryLock 超时机制lock.tryLock(timeout, TimeUnit.SECONDS)
  3. 减少锁粒度:使用 ConcurrentHashMap、读写分离等
  4. 使用并发工具类:如 CountDownLatchCyclicBarrier

13. SQL 突然变慢排查

详见已有知识卡片:接口性能排查指南

可能原因:

  1. 执行计划改变:旧索引被删除、统计信息过期导致优化器选择不同索引
  2. 锁等待:行锁/表锁升级,被其他事务阻塞
  3. Buffer Pool 淘汰:热点数据被刷出,需要大量磁盘 IO 重新加载
  4. 资源竞争:其他查询占用了 IO/CPU
  5. 数据分布变化:虽然数据量没变,但数据分布发生变化导致索引选择性下降

排查流程:

  1. EXPLAIN ANALYZE 查看实际执行计划
  2. SHOW PROCESSLIST 查看是否有阻塞
  3. 检查慢查询日志(Slow Query Log)
  4. 分析 Buffer Pool 命中率
  5. 检查索引统计信息是否更新

14. Spring @Transactional 失效场景

  1. 同一个类中方法自调用this.method() 不走代理,AOP 拦截失效
  2. 私有/静态方法:Spring AOP 默认基于 JDK Proxy/CGLIB,只能拦截 public 方法
  3. 异常类型不匹配:默认只回滚 RuntimeExceptionErrorchecked Exception 不回滚
  4. 异常被捕获:try-catch 内部吞掉异常不抛出
  5. 数据库引擎不支持事务:如 MyISAM
  6. 传播行为配置不当:如 REQUIRES_NEW 内层异常不会导致外层回滚
  7. 多数据源事务:默认的 @Transactional 只支持单数据源,跨数据源需要分布式事务
  8. final 方法被重写:CGLIB 无法重写 final 方法

15. 大数据量导出 Excel 异步方案

异步导出方案设计:

  1. 分层架构:

    • 请求层:接收导出请求,生成任务 ID,立即返回
    • 任务管理层:使用线程池/消息队列异步处理
    • 数据处理层:分批查询数据库,流式处理
    • 文件生成层:使用 SXSSFWorkbook(Apache POI)流式写入,控制内存
    • 结果持久化层:生成文件后上传 OSS/本地存储,提供下载链接
  2. 分批读取(大批量数据):

    • 游标查询(Cursor):MyBatis Cursor / JDBC ResultSet 流式读取,不一次性加载到内存
    • 分页查询:基于主键 ID 分段(WHERE id > ? LIMIT 10000
    • MySql Streaming ResultSetstatement.setFetchSize(Integer.MIN_VALUE)
    • 数据量监控:控制每批次大小(通常 5000-10000 条)
  3. 进度通知:

    • 前端轮询/WebSocket 通知任务进度
    • 导出完成后发通知(邮件/钉钉/短信)

16. 接口幂等方案

详见已有知识卡片:接口幂等方案设计

17. AI Coding

AI 幻觉举例: 虚构不存在的 API、方法名、参数,依赖版本与实际不一致等。

直播电商中 AI 场景:

  • 商品审核(图片/文本违规检测)
  • 实时弹幕审核(敏感词+语义)
  • 智能推荐(商品/直播卡片)
  • 虚拟主播/智能客服

低延迟策略:

  • 本地部署轻量模型:小模型(蒸馏/量化)在端侧推理
  • 多级模型级联:简单规则 + 轻量模型过滤大部分请求,仅复杂场景走大模型
  • 异步回查:低风险判断走快速通道走,后续异步回查修正
  • 缓存/预判:高频内容提前缓存,相似请求复用结果

18. 【算法】无重复字符的最长子串

滑动窗口(双指针)解法:

public int lengthOfLongestSubstring(String s) {
    Map<Character, Integer> map = new HashMap<>();
    int maxLen = 0, left = 0;
    for (int right = 0; right < s.length(); right++) {
        char c = s.charAt(right);
        if (map.containsKey(c)) {
            left = Math.max(left, map.get(c) + 1);
        }
        map.put(c, right);
        maxLen = Math.max(maxLen, right - left + 1);
    }
    return maxLen;
}

复杂度: O(n) 时间,O(min(m,n)) 空间(m 为字符集大小) 核心思想: 维护一个不重复的滑动窗口,right 指针扩展窗口,left 指针收缩窗口,用 HashMap 记录每个字符最近出现的位置。

19. 【场景题】直播弹幕实时审核系统

分层架构设计:

客户端 → 接入层(Nginx/LB) → 缓存层(Redis) → 业务层 → 审核管道 → 存储层

管道分层(审核管道,分阶段并行):

  1. L0 — 前置过滤层:短时间相同弹幕去重(本地布隆过滤器/分布式计数)
  2. L1 — 规则引擎层:敏感词匹配(AC 自动机/Trie 树)、正则规则、黑白名单。要求 < 5ms
  3. L2 — 轻量模型层:部署蒸馏后的 NLP 模型,语义分类。要求 < 50ms
  4. L3 — 大模型层(异步):复杂语义、上下文理解。走 MQ 异步处理,结果延迟回调
  5. L4 — 人工审核层:高风险弹幕进入人工审核工单

降级策略:

  • L3 降级:大模型超时 → 降级到 L2 规则模型结果
  • L2 降级:模型推理失败 → 降级到 L1 规则引擎
  • L1 降级:规则引擎过载 → 放行(允许少量漏过,回查追责)
  • 缓存降级:Redis 不可用 → 本地缓存 + 限流

机器刷屏识别:

  • 时间维度:同一用户/同一 IP 在单位时间内发送频率
  • 内容维度:弹幕内容相似度(编辑距离/SimHash)
  • 行为维度:多账号在相同毫秒级时间点发送相同内容

🔗 相关知识