Java 后端八股精讲一问一答,背得下也答得出 速查 追问链路 ⌂ 全部教程

主题 15设计模式与工程手写题

手写题考的不是「能不能写出来」,是边界考虑得全不全。同一道 LRU, 有人 3 行写完,有人 60 行还漏了「get 也要刷新顺序」;同一道限流, 四种常见实现里只有一种会在窗口边界放过去 2 倍的流量。

这一章把限流器做成可复现的实验:用虚拟时间精确构造「t=999 打 100 个, t=1001 再打 100 个」,四种实现的差别就变成一行数字——固定窗口放过 200 个, 滑动窗口只放过 100 个。

学习目标
能手写 LRU 和令牌桶,并说清各自在边界上的表现
前置要求
熟练 HashMap;知道双向链表;读过第 13 章(Redis 的 LRU)
本章产出
4 组实测 + 3 段可直接背的代码 + 2 张速记卡
建议学时
2 小时(限流那三个场景务必自己跑)

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」这一句往往是加分项
一个反直觉的建议:手写题不要一上来就写代码。 先说「我打算用 HashMap + 双向链表,头插尾删,get 也要 moveToHead」, 等面试官点头再动手。说错方向被打断,比写错代码损失小得多—— 而且这一分钟的表述本身就是评分项。

15.6速记卡与自查

#卡内容(带数字)
1LRU 三行版 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