前言
投递了字节财经支付团队的日常实习,本以为会有一场“八股”大战,结果没想到面试官几乎全程在深挖项目,八股(基础知识)问得很少。这种面试风格非常考验对项目细节的理解和技术方案的思考深度。虽然过程很“痛苦”(哭),但复盘下来收获真的很大。
📝 面试感受
整体感觉:项目为主,算法为辅,场景为王。
面试官非常关注你“为什么”这么做,以及你做的方案有什么“隐患”或“B计划”。如果只是简单地“用”一个技术,而没有思考背后的原理和取舍(Trade-off),就很容易被问倒。
💻 手撕算法:无重复字符的最长子串
- 题目: LeetCode Hot 100 – “无重复字符的最长子串”(LC.3)
- 过程:
- 这是一个典型的滑动窗口问题,使用一个哈希表(
HashMap)来存储字符及其最新的索引。 - 思路是:用右指针
j不断向右移动,把字符char和它的索引j放入map。 - 如果
map中已包含char,说明遇到了重复字符。此时,需要移动左指针i。 - 关键点: 左指针
i应该跳到map.get(char) + 1和i当前位置中较大的那一个,以防止窗口回缩(例如 “abba”)。 ans = Math.max(ans, j - i + 1)
- 这是一个典型的滑动窗口问题,使用一个哈希表(
- 复盘:
- 我的整体思路是正确的,但是在更新
map中重复字符的索引时忘记了(map.put(char, j)这一步)。 - 由于面试平台不能本地调试,这个小 bug 一直没找出来,导致最后没能 AC。
- 反思: 核心代码的熟练度还是不够,边界条件和更新逻辑要刻在脑子里。
- 我的整体思路是正确的,但是在更新
🚀 印象深刻的几个问题(深度复盘)
这几个问题问得非常有水平,直指项目中的痛点和技术选型的核心。
1. 项目中使用布隆过滤器的真正作用?
- 问题: 面试官问我项目里用布隆过滤器(Bloom Filter)是做什么的。
- 我的回答: 我一开始想当然地回答是为了“防止缓存穿透”。
- 追问与反思:
- 面试官追问:“你的业务代码里,如果布隆过滤器误判了(说‘有’,但实际数据库‘没有’),你是怎么处理的?”
- 我回去看代码发现,业务逻辑是:先查布隆,如果布隆说“有”,就再去查数据库。
- 正确结论: 在我这个特定的业务场景下,它并没有起到防止缓存穿透的作用(因为缓存穿透的定义是:查询一个数据库必定不存在的数据)。
- 它的真正作用是:快速过滤(Quick Fail)。它充当了一个“白名单”,对于那些必定不存在的请求(布隆说“没有”),就直接返回错误,连数据库都不用查。这极大地降低了数据库的压力,是一种“快速检验”功能。
2. 如何终止线程池中一个无限循环的任务?
- 问题: 如果
threadPool.submit()提交了一个while(true)的无限循环任务,如何从外部优雅地终止它? - 我的思考:
- (错误答案)使用
Thread.stop()。这个方法已经被废弃,因为它太暴力,会立即释放所有锁,导致数据不一致,非常不安全。 - (正确方向)提交任务时就设计成“可响应中断”的。
- (错误答案)使用
- ⭐ 详细的标准答案: 核心是利用 Java 的中断(Interrupt)机制。第一步:任务代码必须“可响应中断” 你提交的
Runnable或Callable任务,它的run()方法必须正确处理中断信号。如何优雅地终止 Java 线程池中的任务
使用
Future.cancel(true)和中断信号在 Java 中,我们经常使用线程池来管理异步任务。但一个常见的问题是:如果一个任务是无限循环的(例如,一个后台监控服务),我们该如何从外部安全地停止它呢?
答案是使用 Java 的中断机制 (Interrupt)。这是一种协作式的方式,允许一个线程“请求”另一个线程停止。
Future.cancel(true)正是利用了这一机制。第一步:让你的任务响应中断
首先,你的
Runnable或Callable任务必须被编写为可以响应中断信号。这通常意味着在循环中检查中断状态。Runnable infiniteTask = () -> { // 循环条件检查当前线程的中断标志位 while (!Thread.currentThread().isInterrupted()) { try { // 1. 你的业务逻辑 System.out.println("任务正在运行..."); // 2. 如果任务中有可中断的阻塞方法(如 sleep, wait, take) // 当它被中断时,会抛出 InterruptedException Thread.sleep(1000); } catch (InterruptedException e) { // 3. 捕获到 InterruptedException // 这表示有人想让我们停止。 // 我们应该立即清理资源,并跳出循环。 System.out.println("收到中断信号,正在停止..."); // (重要) 捕获 InterruptedException 后,中断标志位会被清除。 // 如果想让循环外的代码也能感知到中断,需要重新设置中断标志。 Thread.currentThread().interrupt(); // 跳出 while 循环 break; } catch (Exception e) { // 处理其他业务异常 } } System.out.println("任务已终止。"); };- 关键点: 循环的判断条件是
!Thread.currentThread().isInterrupted()。 - 当调用
Thread.sleep()这样的阻塞方法时,如果线程被中断,它会抛出InterruptedException。 - 捕获
InterruptedException后,必须重新设置中断标志Thread.currentThread().interrupt(),并break循环。
第二步:从外部发起中断
- 当你使用
threadPool.submit()提交任务时,你会得到一个Future<?>对象。 - 调用
future.cancel(true)。
// 假设我们有一个 ExecutorService // ExecutorService threadPool = Executors.newSingleThreadExecutor(); // 1. 提交任务,并保留 Future 对象 // Future<?> future = threadPool.submit(infiniteTask); // ... 让任务运行一会儿 // Thread.sleep(5000); // 2. 想要停止它时,调用 cancel(true) System.out.println("正在从外部请求终止任务..."); // 参数 true 至关重要,它表示 "mayInterruptIfRunning" // 这会向正在执行该任务的线程发送一个 interrupt() 信号 // future.cancel(true); // threadPool.shutdown();为什么是
cancel(true)?Future.cancel()方法接受一个布尔参数mayInterruptIfRunning。
如果为false,它只会尝试取消尚未开始的任务。
如果为true,它会向正在运行该任务的线程发送一个interrupt()信号,这正是触发我们catch (InterruptedException e)块的关键。总结: 优雅地终止任务是一个“双向奔赴”。
- 外部调用者: 必须调用
future.cancel(true)来“打招呼”(发送中断信号)。 - 任务执行者: 必须在循环中检查
isInterrupted()状态,或者在catch (InterruptedException)中正确地处理这个“招呼”,然后主动退出。
如果任务代码写得很烂(例如
while(true)里面只有计算,没有sleep也不检查中断标志),那cancel(true)也拿它没办法。 - 关键点: 循环的判断条件是
3. 手写策略模式
- 问题: 现场手写策略模式。
- 复盘:
- 虽然磕磕绊绊写出来了,但很不规范。
- 标准结构:
Strategy接口: 定义一个抽象方法(例如doOperation(int a, int b))。ConcreteStrategy类: 多个实现类(例如AddStrategy,SubtractStrategy),分别实现该接口。Context类: 一个上下文类,它持有一个Strategy接口的引用。它提供一个setStrategy()方法来切换策略,并提供一个executeStrategy()方法来调用策略的抽象方法。
- 反思: 以为不会考这种手写设计模式的。基础设计模式的标准写法还是要背熟,尤其是策略模式、单例模式、工厂模式。
4. Lua 脚本与数据一致性
- 问题 1: 在 Redis 的 Lua 脚本里写过多逻辑会导致什么问题?
- 回答:
- 阻塞 Redis: Redis 是单线程的。Lua 脚本的执行是原子性的,在脚本执行期间,Redis 无法处理任何其他命令。如果脚本逻辑过重、执行时间过长,会严重阻塞 Redis,导致吞吐量急剧下降。
- 难以调试和维护: 复杂的逻辑写在 Lua 脚本(本质是字符串)里,非常不利于调试、测试和版本控制。
- 事务回滚困难: Lua 脚本是原子的,但它没有“回滚”机制。一旦执行到一半出错了,已经执行过的命令无法撤销(除非在脚本里手动写反向操作,但这更复杂了)。
- 问题 2: 扣减库存场景:如果 Redis(Lua 脚本)扣减成功,但后续数据库扣减失败了怎么办?(数据一致性问题)
- 我的回答:
- 分布式事务(TCC): 使用 TCC(Try-Confirm-Cancel)模式。
- Try:预扣减 Redis 和 数据库(例如冻结库存)。
- Confirm:异步 Kafka 创建订单成功后,执行 Confirm,真正扣减库存。
- Cancel:创建订单失败,执行 Cancel,回滚(解冻)Redis 和数据库的库存。
- 定时任务兜底: 启动一个定时任务,定期扫描“Redis 已扣减但数据库未扣减”的异常订单,进行补偿或回滚。
- 分布式事务(TCC): 使用 TCC(Try-Confirm-Cancel)模式。
- 补充方案(面试后思考):
- 可靠消息最终一致性(MQ): 这是业界更常用的方案。
- (上游)扣减 Redis 成功后,向 RocketMQ 发送一个“事务消息”(或“半消息”)。
- (上游)发送“半消息”成功后,立即执行数据库扣减(本地事务)。
- (上游)如果数据库执行成功,则
Commit这个半消息,MQ 将其投递给下游(例如订单系统)。 - (上游)如果数据库执行失败,则
Rollback这个半消息,MQ 将其丢弃。 - (下游)订单系统消费到消息,创建订单。
- 这种方案保证了“Redis 扣减”和“数据库扣减”这两个操作的最终一致性。
- 可靠消息最终一致性(MQ): 这是业界更常用的方案。
总结
字节的面试非常注重实战,会逼着你去思考方案的B面(缺点和风险)。这次面试虽然挂了,但也暴露了我在项目深度、算法熟练度和设计模式上的短板。继续刷题,继续深入项目,下次再战!