主题 15设计模式与工程手写题
手写题考的不是「能不能写出来」,是边界考虑得全不全。同一道 LRU, 有人 3 行写完,有人 60 行还漏了「get 也要刷新顺序」;同一道限流, 四种常见实现里只有一种会在窗口边界放过去 2 倍的流量。
这一章把限流器做成可复现的实验:用虚拟时间精确构造「t=999 打 100 个, t=1001 再打 100 个」,四种实现的差别就变成一行数字——固定窗口放过 200 个, 滑动窗口只放过 100 个。
15.1手写题到底在考什么
面试官给你一张纸写 LRU,他心里有四条评分线,写完能跑只占第一条:
| # | 评分线 | 不合格的表现 | 满分的表现 |
|---|---|---|---|
| 1 | 数据结构选对 | 用 ArrayList 存,get 是 O(n) | HashMap + 双向链表,get/put 都 O(1) |
| 2 | 边界覆盖 | 只测「put 满了淘汰」,不测「get 命中也要刷新顺序」 | 主动说「get 也算一次使用」 |
| 3 | 并发意识 | 写完就说「这是线程安全的」 | 主动说「没加锁,要多线程就包一层 synchronized 或换 ConcurrentHashMap + 分段」 |
| 4 | 能不能复用现成的 | 不知道 LinkedHashMap 能三行实现 | 先说现成的,再说手写版原理——顺序反了会被认为只会背书 |
LinkedHashMap 三行版」证明你知道有现成的,
再补「底层其实是 HashMap + 双向链表,我手写一遍是这样的」。
反过来(先手写再提现成的)会显得你不熟悉 JDK。
15.2LRU:三行 vs 六十行
先看现成的。构造 LinkedHashMap 时把 accessOrder 设成 true,
再重写一个方法:
LinkedHashMap<Integer, String> cache = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, String> eldest) {
return size() > 3; // 超过容量就淘汰最久未用的
}
};
实测行为(容量 3):
put 1,2,3 之后: [1, 2, 3]
get(1) 之后: [2, 3, 1] <-- 1 移到末尾
put(4,d) 之后: [3, 1, 4] <-- 2 被淘汰
关键在于第二行:get(1) 之后,1 从队首跑到了队尾。
这就是 accessOrder = true 的作用——访问也算一次「使用」。
很多人手写时会漏掉这一点,只把 put 当作使用。
| accessOrder | 链表顺序 | 适用 |
|---|---|---|
false(默认) | 插入顺序 | 普通的「按放入顺序遍历」 |
true | 访问顺序,get/put 都会把节点移到队尾 | LRU 缓存 |
那为什么还要手写?因为面试官想看你知道它内部是怎么做到的。
核心是三个方法:afterNodeAccess(访问后移到队尾)、
afterNodeInsertion(插入后判断要不要淘汰)、afterNodeRemoval(删除时维护链表)。
手写版就是自己实现这三件事:
class LRU<K, V> {
class Node { K k; V v; Node prev, next; }
Map<K, Node> map = new HashMap<>();
Node head, tail; // 哨兵:head 最旧、tail.next 最新
V get(K k) {
Node n = map.get(k);
if (n == null) return null;
moveToHead(n); // ★ 访问也要刷新(最容易漏的一行)
return n.v;
}
void put(K k, V v) {
Node n = map.get(k);
if (n != null) { n.v = v; moveToHead(n); return; }
if (map.size() == cap) {
Node last = tail.prev; // 淘汰最久未用的
map.remove(last.k);
unlink(last);
}
n = new Node(k, v);
map.put(k, n);
addHead(n);
}
}
性能上两者几乎一样(100 万次 get + put,容量 1 万):
LinkedHashMap: 45 ms
手写双向链表 : 54 ms
手写版是 0.83 倍速
手写版反而慢 20%。因为 LinkedHashMap 的三个钩子方法在 JIT 之后会被内联,
而我的版本多了一层 map.get + 方法调用。这不是重点——
重点是「JDK 已经优化过的轮子,自己造不会更快」,这句话面试时说出来很加分。
15.3四种限流器,边界处见真章
限流是工程手写题里最容易写错的:四种实现里三种在日常流量下表现完全一样,
只有跨窗口边界时才露馅。为了结果可复现,这一节用虚拟时间
(把 now 当参数传进去,不读真实时钟)。
① 同样的瞬时洪峰,四个都合格
=== case1 四种限流器,限制 100/秒 ===
场景 A:t=0 这一刻打 500 个请求
固定窗口 通过 100 / 500
滑动窗口 通过 100 / 500
令牌桶 通过 100 / 500
漏桶 通过 100 / 500
这一组没有区分度。面试里如果只测这一组,会以为自己写对了。
② 跨窗口边界:固定窗口放过了 2 倍
=== case2 跨窗口边界:t=999 打 100,t=1001 再打 100 ===
固定窗口 第一批 100 / 100,第二批 100 / 100,共 200
滑动窗口 第一批 100 / 100,第二批 0 / 100,共 100
令牌桶 第一批 100 / 100,第二批 0 / 100,共 100
漏桶 第一批 100 / 100,第二批 1 / 100,共 101
固定窗口在 2 毫秒内放过了 200 个请求,是限制值的 2 倍。原因只有一句话:
t=999 -> 属于第 0 秒窗口,计数 0/100 -> 全部放行
t=1000 -> 窗口重置,计数归零
t=1001 -> 属于第 1 秒窗口,计数 0/100 -> 又全部放行
真实流量里,攻击者只要卡着秒的边界打,就能让固定窗口的限流形同虚设。 这就是为什么生产上不推荐固定窗口。
③ 稳态流量:四个又都一样
=== case3 稳态:每 10ms 一个请求,持续 2 秒(共 200 个)===
固定窗口 通过 200 / 200
滑动窗口 通过 200 / 200
令牌桶 通过 200 / 200
漏桶 通过 200 / 200
三个场景连起来看,才是完整结论:限流器的差别只在「突发」和「边界」, 平稳流量下没有区别。所以测限流器,一定要造突发。
| 算法 | 边界突发 | 能否应对突发 | 实现复杂度 | 典型用途 |
|---|---|---|---|---|
| 固定窗口 | 放过 2 倍 | 不能控 | 最低(一个计数器) | 只用于粗粒度限制 |
| 滑动窗口 | 正确(100) | 不能 | 中(要存时间戳) | 接口级 QPS 限制 |
| 令牌桶 | 正确(100) | 能(桶里攒着就能一次取走) | 中 | 网关限流(Sentinel、Guava) |
| 漏桶 | 正确(101) | 不能(匀速流出) | 中(要排队) | 保护下游(如消息队列) |
令牌桶允许突发(桶里攒了 100 个令牌,可以一次性全用掉), 漏桶强制匀速(不管来多少,出口速率恒定)。
所以「限制调用第三方接口」用令牌桶(允许偶尔爆发), 「保护下游数据库」用漏桶(必须平滑)。
15.4单例:为什么现在推荐枚举
单例的写法演进本身就是一部「如何防住反射和反序列化」的历史:
| 写法 | 线程安全 | 防反射 | 防反序列化 | 问题 |
|---|---|---|---|---|
| 饿汉式 | 是(类初始化保证) | 否 | 否 | 不能延迟加载 |
懒汉 + synchronized | 是 | 否 | 否 | 每次都加锁,慢 |
| DCL | 是(必须加 volatile) | 否 | 否 | 写法复杂,见 06 章 |
| 静态内部类 | 是 | 否 | 否 | 最推荐的「普通」写法 |
| 枚举 | 是 | 是 | 是 | 不能继承(但单例本来也不该继承) |
枚举那两列「是」不是巧合:Constructor.newInstance 里有一句硬检查——
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
throw new IllegalArgumentException("Cannot reflectively create enum objects");
反序列化则是因为枚举的 readObject 被特殊处理成「按名字查找已有实例」。
所以《Effective Java》说「单元素的枚举类型是实现单例的最佳方法」——
它能挡住反射和反序列化这两个漏洞,而其他写法都挡不住。
15.5手写题的通用四步
不管题目是 LRU、限流、还是生产者消费者,套路都一样:
| 步 | 做什么 | 为什么 | 面试官看到什么 |
|---|---|---|---|
| 1 | 先说数据结构 | 「HashMap + 双向链表」一句话定调 | 你有设计能力,不是边写边想 |
| 2 | 画清楚边界 | 队首/队尾是谁,容量满了删哪个 | 你知道最容易错的地方在哪 |
| 3 | 写核心路径 | 先写 hit 和 miss 两条路,再补淘汰 | 你会分优先级 |
| 4 | 主动说并发 | 「这个版本非线程安全,要多线程得加锁或换 ConcurrentHashMap」 | 这一句往往是加分项 |
15.6速记卡与自查
| # | 卡 | 内容(带数字) |
|---|---|---|
| 1 | LRU 三行版 | new LinkedHashMap<>(16, 0.75f, true) + 重写 removeEldestEntry。
第三个参数 true 才是 LRU(访问顺序)。实测 get 会把节点移到队尾。 |
| 2 | 固定窗口的 2 倍漏洞 | t=999 打 100、t=1001 再打 100 → 固定窗口放过 200,滑动窗口只放 100。 平稳流量下四种算法完全一样,必须造突发才测得出差别。 |
| 3 | 令牌桶 vs 漏桶 | 令牌桶允许突发(攒着能用),漏桶强制匀速。 限第三方用令牌桶,保下游用漏桶。 |
动手清单与自查表
| # | 动手项 | 可观察的判据 |
|---|---|---|
| 1 | 跑 EngDemo lru |
get(1) 之后顺序从 [1,2,3] 变 [2,3,1] |
| 2 | 把自己手写的 LRU 里 get 的 moveToHead 注释掉 |
顺序不再变化,退化成 FIFO——这是最常被扣分的一处 |
| 3 | 跑 EngDemo burst |
固定窗口那行显示共 200,其余是 100/101 |
| 4 | 把 LIMIT 改成 10 再跑一遍 |
比例不变,结论不变——漏洞和阈值无关 |
| 5 | 用枚举写一个单例,再试着反射创建 | 抛 IllegalArgumentException: Cannot reflectively create enum objects |
burst 里两批请求的时间从
999 / 1001 改成 500 / 1500,再跑一遍——固定窗口也变成 100 了。
同一份代码,只因为请求时刻不同,一次放过 200、一次放过 100。
「测试通过」在限流这种场景里同样是伪证,必须按时间轴推演。
| 卡住了怎么办 | 去哪查 |
|---|---|
| 不确定 LRU 该淘汰谁 | 画三个节点,标「最久未用」和「最近使用」,淘汰和刷新都对着图写 |
| 不确定限流器对不对 | 用虚拟时间按 0 / 999 / 1001 / 1999 四个时刻各打一批,看总数 |
| 不确定要不要加锁 | 问「这个对象会被几个线程同时改」,答案是 1 就不用 |
| 不知道现成轮子叫什么 | LRU 是 LinkedHashMap;限流是 Guava RateLimiter / Sentinel;缓存是 Caffeine |