5个避坑点,手把手教你搞定哔哩哔哩招聘手写题
配置环境就卡半天,是不是你的常态?
别急着骂系统,大概率是你没搞懂底层逻辑。
很多B站后端开发面试题,表面看是算法,实则考的是最佳实践中的工程化思维。
我在掘金技术社区看到不少大牛复盘,发现80%的人挂在了“环境适配”和“边界条件”上。
今天这篇,不灌鸡汤,直接拆解哔哩哔哩招聘中最高频的手写实现考点。
我们聚焦三个核心:并发控制、状态机转换、数据结构优化。
这也是目前大厂面试中最能拉开差距的部分。
一、 为什么你写的代码跑不通?
很多人以为手写题考的是“背题”,错了。
它考的是你如何在受限环境下,写出可运行、可维护、无Bug的代码。
以B站常见的“视频播放进度上报”场景为例。
面试官不会让你直接调API,而是给你一个空的类,让你实现核心逻辑。
这时候,90%的人第一反应是:用个 HashMap 存状态。
这就踩坑了。
为什么?因为并发安全被忽略了。
在真实的高并发场景下,多个线程同时修改同一个视频的用户进度,HashMap 会直接报 ConcurrentModificationException。
更严重的是,数据不一致。
A用户看到进度100%,B用户看到50%,这在业务上是灾难。
原理简述:
我们需要的是一个线程安全的状态容器,且状态转换必须符合业务逻辑。
这就引入了**状态机(State Machine)**的概念。
状态机不是高深理论,它是处理“有限状态、明确转换规则”问题的最佳实践。
二、 状态机:视频进度的底层逻辑
把视频播放想象成一个地铁系统。
每个视频进度就是一个“站点”。
用户只能从“未开始”到“播放中”,再到“暂停”,或者“结束”。
你不能直接从“未开始”跳到“结束”,除非你是快进,但快进也有速度限制。
这就是状态约束。
在代码层面,我们需要定义:状态(State):当前视频处于什么阶段。
事件(Event):用户做了什么操作(如点击播放、暂停、拖动进度条)。
转换(Transition):在什么状态下,收到什么事件,会变成什么新状态。这种结构,天然避免了非法状态的出现。
比如,你不能在“暂停”状态下,再次收到“暂停”事件后,状态变成“播放中”。
逻辑必须闭环。
三、 源码拆解:手写一个线程安全的状态机
下面这段代码,是我在模拟B站面试环境时,反复打磨过的版本。
它解决了并发问题,也体现了最佳实践中的防御性编程思想。
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiFunction;public class VideoProgressStateMachine {// 定义状态枚举public enum State {INIT, // 初始状态PLAYING, // 播放中PAUSED, // 暂停FINISHED // 结束}// 定义事件枚举public enum Event {START, // 开始播放PAUSE, // 暂停RESUME, // 继续播放SEEK, // 拖动进度条FINISH // 播放结束}// 状态转换表:State + Event - NewState// 使用 ConcurrentHashMap 保证初始化时的线程安全private static final ConcurrentHashMapState, ConcurrentHashMapEvent, State TRANSITIONS = new ConcurrentHashMap();static {// 初始化状态转换规则// 注意:这里只定义合法转换,非法转换默认抛异常或忽略TRANSITIONS.put(State.INIT, new ConcurrentHashMap());TRANSITIONS.get(State.INIT).put(Event.START, State.PLAYING);TRANSITIONS.put(State.PLAYING, new ConcurrentHashMap());TRANSITIONS.get(State.PLAYING).put(Event.PAUSE, State.PAUSED);TRANSITIONS.get(State.PLAYING).put(Event.FINISH, State.FINISHED);TRANSITIONS.get(State.PLAYING).put(Event.SEEK, State.PLAYING); // 拖动后仍在播放TRANSITIONS.put(State.PAUSED, new ConcurrentHashMap());TRANSITIONS.get(State.PAUSED).put(Event.RESUME, State.PLAYING);TRANSITIONS.get(State.PAUSED).put(Event.SEEK, State.PAUSED); // 拖动后仍暂停TRANSITIONS.get(State.PAUSED).put(Event.FINISH, State.FINISHED);TRANSITIONS.put(State.FINISHED, new ConcurrentHashMap());// FINISHED 是终态,通常不允许再转换,除非重置}// 当前状态,使用 AtomicReference 保证原子性更新private final AtomicReferenceState currentState = new AtomicReference(State.INIT);// 回调函数,状态变化后执行private BiFunctionState, Event, Void onTransitionCallback;public void setOnTransitionCallback(BiFunctionState, Event, Void callback) {this.onTransitionCallback = callback;}/*** 核心方法:发送事件,触发状态转换* @param event 事件* @return 是否转换成功*/public boolean sendEvent(Event event) {State current = currentState.get();State nextState = getValidTransition(current, event);if (nextState == null) {// 非法状态转换,记录日志,返回false// 在实际项目中,这里应该接入监控系统System.err.println(Invalid transition: + current + - + event);return false;}// 原子性更新状态,只有当前状态确实是current时,才更新为nextState// 这防止了两个线程同时读取到INIT,都试图转换为PLAYINGboolean updated = currentState.compareAndSet(current, nextState);if (updated) {// 状态更新成功,触发回调if (onTransitionCallback != null) {onTransitionCallback.apply(nextState, event);}return true;}// 更新失败,说明状态已被其他线程修改,需要重试// 在实际高并发场景下,这里可以加一个重试机制return sendEvent(event);}private State getValidTransition(State current, Event event) {ConcurrentHashMapEvent, State eventsMap = TRANSITIONS.get(current);if (eventsMap == null) return null;return eventsMap.get(event);}public State getCurrentState() {return currentState.get();}// 测试代码public static void main(String[] args) {VideoProgressStateMachine sm = new VideoProgressStateMachine();sm.setOnTransitionCallback((newState, event) - {System.out.println(State changed to: + newState + via event: + event);return null;});System.out.println(Current State: + sm.getCurrentState()); // INITsm.sendEvent(Event.START); // 变为 PLAYINGsm.sendEvent(Event.PAUSE); // 变为 PAUSEDsm.sendEvent(Event.RESUME);// 变为 PLAYINGsm.sendEvent(Event.FINISH);// 变为 FINISHED// 尝试非法转换sm.sendEvent(Event.START); // 应该报错,因为FINISHED是终态}
}逐行讲解关键点AtomicReference vs synchronized:
很多新手喜欢用 synchronized 锁住整个 sendEvent 方法。
这没错,但性能差。
在高并发下,锁竞争严重。
AtomicReference 的 compareAndSet 是基于 CAS(Compare-And-Swap)操作,无锁,性能更高。
这是最佳实践中的典型优化。状态转换表(Transition Table):
我没有用一堆 if-else 判断状态。
而是用一个二维映射表。
这样做的好处是:逻辑与数据分离。
如果业务需求变了,比如“暂停”状态下允许直接“结束”,你只需要改配置表,不用改核心逻辑代码。
这就是开闭原则的体现。重试机制(Retry):
注意 sendEvent 里的递归调用。
如果 CAS 失败,说明状态变了,我们需要基于最新的状态再次尝试转换。
这在并发编程中非常关键。
如果不去重试,直接返回 false,可能会导致用户操作丢失。四、 流程描述:从用户点击到状态更新
让我们用文字描述一下这段代码在真实系统中的流转过程。用户操作:用户在B站APP点击“暂停”按钮。
前端请求:前端发送 HTTP 请求,携带视频ID和用户ID。
服务端接收:B站后端网关接收请求,路由到具体的视频服务实例。
实例获取:服务实例从缓存或内存中获取该用户对应的 VideoProgressStateMachine 实例。注:每个用户每个视频对应一个独立的状态机实例,避免互斥。状态检查:调用 sendEvent(Event.PAUSE)。
CAS 竞争:线程A读取当前状态为 PLAYING。
线程A尝试将状态从 PLAYING 更新为 PAUSED。
如果成功,继续下一步。
如果失败(说明其他线程刚改了状态),线程A重新读取状态,再次尝试。回调执行:状态更新成功后,触发回调函数。回调函数可能执行:更新数据库进度、推送WebSocket消息给前端、记录埋点数据。响应返回:服务端返回 200 OK,前端UI更新为“暂停”图标。这个流程中,状态机保证了核心逻辑的一致性,CAS 保证了并发的安全性,回调 实现了业务逻辑的解耦。
五、 实战验证与避坑指南
在掘金技术社区的很多讨论中,大家常问:“如果状态转换太频繁,CAS 一直失败怎么办?”
这就是我们要讲的进阶技巧。
1. 自适应自旋
如果 CAS 失败,不要立即重试。
可以加入一个短暂的 Thread.yield() 或者 LockSupport.park()。
让出 CPU 时间片,等待其他线程完成操作。
避免“忙等待”导致 CPU 空转。
2. 批量操作优化
如果用户快速拖动进度条,会产生大量 SEEK 事件。
如果每个事件都触发一次数据库更新,数据库会崩。
最佳实践:
在回调函数中,不要直接写库。
而是将事件放入一个内存队列或本地缓冲区。
通过定时任务(如每 500ms)或队列满时,批量更新数据库。
代码示例:
// 在回调函数中
private BiFunctionState, Event, Void onTransitionCallback = (newState, event) - {if (event == Event.SEEK || event == Event.PAUSE) {// 不直接写库,加入缓冲区progressBuffer.add(new ProgressRecord(newState, event, System.currentTimeMillis()));return null;}// 其他事件直接处理handleImmediateEvent(newState, event);return null;
};3. 内存泄漏风险
ConcurrentHashMap 如果一直往里放数据,不清理,会 OOM。
解决方案:使用 WeakHashMap 或 SoftHashMap 缓存状态机实例。
当用户长时间不活跃,GC 回收时,自动清理状态机。
或者设置 TTL(Time-To-Live),定时扫描清理过期实例。4. 为什么不用 ReentrantLock?
有些面试官会问:“既然 CAS 会失败,为什么不用 ReentrantLock 保证互斥?”
回答要点:粒度不同:ReentrantLock 是阻塞式,线程会挂起,上下文切换开销大。
场景不同:状态转换操作极短(纳秒级),CAS 的失败率虽然存在,但重试成本远低于锁的获取成本。
公平性:ReentrantLock 可以保证公平性,但状态转换不需要严格的公平性,只要最终一致即可。六、 总结与互动
这篇内容,我们拆解了哔哩哔哩招聘中典型的状态机手写题。
核心在于:理解业务本质:视频进度是有状态约束的,不是随意变动的。
选择合适工具:用 AtomicReference 处理并发,用 Map 管理转换规则。
考虑工程细节:重试机制、批量更新、内存清理,这些才是区分初级和高级开发的最佳实践。不要只盯着算法题的“解法”,要盯着业务场景的“痛点”。
配置环境卡半天,往往是因为你没看懂代码背后的设计意图。
希望这篇文章,能帮你理清思路,下次遇到类似的手写题,能从容应对。
你公司项目里,是怎么处理这种高频状态更新的?是用锁,还是用无锁结构?欢迎在评论区聊聊你的实战经验。
