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

主题 01集合框架

把它放在第一站有个很实际的理由:集合是算法教程里出现频率最高、而八股问答里被追得最深的一处重合。 你写「两数之和」时随手敲的 new HashMap<>(),面试官能顺着它连问五个问题: 为什么是 O(1)、容量为什么是 2 的幂、负载因子为什么 0.75、扩容怎么做、并发下为什么不能用它。 这一章把这条线一次走完——每一节都是「怎么写」和「为什么这样写」同时给。

学习目标
能口述 put 全流程、能白纸写出简化 HashMap
前置要求
知道哈希表是什么、能读 Java 泛型
本章产出
4 张四槽速记卡 + 1 份可编译的手写实现
建议学时
3–4 小时

1.1为什么第一站是集合

先说清楚这一章要解决什么问题,不然容易背成一堆散点。

面试里的集合问题有个特点:它从来不是孤立的一道题,而是从你写的代码里长出来的追问。 你说「用 HashMap 存一下」,对方就会问「为什么不用数组」;你答「因为查得快」,对方就问「为什么快」; 你答「哈希」,对方就问「哈希冲突怎么办」;你说「链表」,对方就问「链表长了怎么办」。 这四个问题正好对应本章的四节,一路问下去就是一条完整的知识链。

而这条链的价值不止于面试。算法教程的「哈希表」专题里,几乎所有 O(n) 解法都建立在一句隐含假设上: 「哈希表的查找是常数时间」。这句话为什么成立、什么时候不成立、代价是什么—— 如果你没读过这一章,就只是在用别人的结论;读完之后,你是在用自己理解的工具。

本章读法:每读完一节,先合上页面,用「题 / 八股 / 一句话 / 追问」四个槽自己说一遍。 说不出来就说明只是「看懂了」,不是「记住了」——这两件事在面试现场差别很大。

1.2数组与链表的真实取舍

这是集合问题的地基。先把三种存储方式摆在一起看,因为面试里「为什么用 X 不用 Y」的答案, 本质都是在两种代价之间做选择。

操作数组(ArrayList)链表(LinkedList)哈希表(HashMap)
按下标访问O(1)O(n)不支持(无「第 n 个」概念)
按值查找O(n)O(n)平均 O(1),最坏 O(log n)
头部插入O(n)(整体后移)O(1)—
尾部插入均摊 O(1)O(1)均摊 O(1),扩容那一次 O(n)
内存布局连续每个节点分散 + 两个指针数组存桶头 + 桶内链表

光看这张表,结论好像是「链表适合频繁插入删除」。但真实工程里的结论恰好相反: ArrayList 几乎在所有场景都赢,LinkedList 几乎没人用。原因不在复杂度,在 CPU。

被复杂度表藏起来的那个常数

CPU 读内存不是一次读 1 个字节,而是一次搬一整条缓存行(cache line,通常 64 字节)。 数组的元素挨在一起,读 arr[0] 时 arr[1]…arr[15] 很可能已经一起进了缓存, 后面 15 次访问几乎不花时间。

链表的节点是 new 出来的,散在堆的各处,地址之间没有规律。 遍历链表时几乎每次访问都是一次缓存未命中,要等主存。所以: 即使两者的复杂度都写成 O(n),链表遍历的常数可能是数组的十倍以上。

面试时的正确说法:不要说「LinkedList 插入快所以用它」——这是教科书答案,会被追问。 应该说:「LinkedList 只在已经持有节点引用、且频繁做头尾插入时才有优势;按值查找仍是 O(n), 而且节点分散导致缓存命中率低,实际吞吐通常不如 ArrayList。」

还有一个常被忽略的点:ArrayList 的「扩容」并不是每次加元素都复制。 容量不足时按 1.5 倍增长(oldCap + (oldCap >> 1)), 所以连续 n 次尾部插入的均摊代价仍是 O(1)——这就是「均摊复杂度」这个词的来历。

四槽速记 · 集合 #1

题为什么工程里几乎不用 LinkedList?
八股数组与链表的内存布局差异及其对性能的影响
一句话复杂度一样,常数差一个数量级:数组连续能命中缓存行,链表节点分散几乎每次都未命中。
追问ArrayList 扩容为什么是 1.5 倍而不是 2 倍?→ 1.5 倍能更好复用之前释放的内存块,2 倍容易连续浪费;且 1.5 倍均摊仍为 O(1)。

1.3HashMap 的骨架:一次 put 的完整流程

先记住骨架,再记细节。HashMap 在 JDK 8 里的结构是三样东西拼起来的:

部件作用关键点
Node[] table桶数组,长度永远是 2 的幂懒初始化,第一次 put 才创建(默认 16)
桶内链表哈希冲突时,冲突的元素挂在同一个桶里JDK 8 是尾插,JDK 7 是头插
红黑树链表过长时转成树,把 O(n) 压到 O(log n)链表长度 ≥ 8 且容量 ≥ 64 才树化

一次 put 到底发生了几件事

  1. 表为空则先初始化。第一次 put 时 table 还是 null,先按默认容量 16 建表(这一步在 resize() 里做)。
  2. 算 hash。不是直接用 hashCode(),而是 (h = key.hashCode()) ^ (h >>> 16)——高 16 位异或到低 16 位,原因见 1.4。
  3. 定位桶。i = (n - 1) & hash,n 是数组长度。因为 n 是 2 的幂,这个式子等价于取模但快得多。
  4. 桶为空:直接放。新建节点放进 table[i],然后检查是否需要扩容。
  5. 桶不空且首节点 key 相同:覆盖。比较方式是 hash 相等 && (key == 同引用 || key.equals(...))。
  6. 首节点是树节点:走红黑树插入。
  7. 否则遍历链表:途中发现相同 key 就覆盖;走到尾部都没有,就尾插新节点。
  8. 收尾检查两件事:① 链表长度 ≥ 8 → 尝试树化(但容量 < 64 时改为先扩容);② size > threshold → 扩容。

get 就简单得多,只有三步:算 hash → 定位桶 → 在桶里比对 key。 注意它比对的是「hash 相等且 key 相等」,只比 hash 不够——不同对象完全可能撞 hash。

常量值含义 / 记忆点
DEFAULT_INITIAL_CAPACITY16默认桶数量,必须是 2 的幂
DEFAULT_LOAD_FACTOR0.75装到 3/4 就扩容,兼顾空间与冲突
TREEIFY_THRESHOLD8链表长度到 8 转红黑树
UNTREEIFY_THRESHOLD6树节点少到 6 退化成链表(留 2 的缓冲,防抖动)
MIN_TREEIFY_CAPACITY64容量不足 64 时不做树化,先扩容
「为什么是 8 和 6,不是 7?」两个原因要一起说才完整: ① 8 来自概率——源码注释给了泊松分布:在理想的哈希函数下,一个桶里节点数达到 8 的概率约 0.00000006, 也就是说正常情况下几乎不会树化,树化只是兜底。 ② 6 与 8 之间留 2 的空隙是为了防抖动——如果树化和退化用同一个阈值, 一个桶在 7、8 之间反复增删就会不停在「链表↔树」之间来回转换,开销比不转还大。

1.4寻址与扰动:容量为什么必须是 2 的幂

这一节是全章最值得较真的地方,因为它把「一句话结论」变成了「能推导的结论」。

先看那个公式:(n - 1) & hash

n 是桶数组长度。当 n 是 2 的幂时,n - 1 的二进制是一串全 1: n=16 → n-1=15 → 1111。任何数与全 1 做「与」运算,结果就是它的低若干位,正好落在 0 ~ n-1 范围内。 这等价于 hash % n,但位运算比取模快得多,而且取模对负数会得到负结果,位运算不会。

但如果 n 不是 2 的幂,n - 1 的二进制里就有 0,与运算会把那些位强制清掉—— 结果是某些桶永远为空,冲突概率大幅上升。下面这张表用 n=15(反例)说明:

hash二进制n=16:(15 & hash)n=15:(14 & hash)
211010154
221011066
231011176 ← 撞了
241100088

n=15 时 14 = 1110,最低位恒为 0,所以所有 hash 的桶下标都是偶数,奇数桶一个也用不上—— 实际容量直接砍半,冲突翻倍。这就是「容量必须是 2 的幂」的全部原因,不是规定,是数学要求。

再看待过的扰动:h ^ (h >>> 16)

公式里只用到 hash 的低位(因为 n 通常很小,n-1 只覆盖低 4~5 位)。 如果两个 key 的 hashCode() 高位不同、低位相同,它们就会挤在同一个桶里—— 而现实中这种情况很常见:很多对象的 hashCode 是「小整数连续变化」或「地址右移生成的」,低位区分度差。

所以 JDK 加了一步扰动:把自己的高 16 位异或到低 16 位上去。

static final int hash(Object key) {
    int h;
    // ① h >>> 16:把高 16 位无符号右移到低位
    // ② ^ 异或:把「高位信息」掺进「低位」,让低位也带上高位的区分度
    // ③ key == null 时返回 0,所以 HashMap 允许一个 null 键,放在 0 号桶
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

一句话概括:容量是 2 的幂保证了「下标算得对」,扰动函数保证了「下标分得散」。 这两句话能同时说出来,比背十遍源码有用。

四槽速记 · 集合 #2

题算法题里的 O(1) 查找靠什么实现?
八股HashMap 的寻址方式,容量为什么必须是 2 的幂
一句话用 (n-1) & hash 一次位运算算出桶下标;n 是 2 的幂时 n-1 全为 1,才不会浪费桶。
追问为什么要扰动?→ 数组长度小,只用得到 hash 低位;把高 16 位异或下来,低位才有区分度。
为什么不用 % ?→ 位运算更快,且取模对负数结果为负。

1.5扩容与树化

扩容:翻倍,但不重算 hash

当 size > capacity × 0.75 时扩容,容量翻倍。传统写法是「重新遍历所有元素、重新算下标」, JDK 8 用了一个巧妙的观察省掉了重算:

容量翻倍后 n - 1 只多了一个高位 1。所以一个元素的新下标只有两种可能: 要么仍在原位 j,要么挪到 j + oldCap。判断依据就一位:hash & oldCap 是 0 还是 1。

// 扩容的核心:把一条桶链拆成「低位链」和「高位链」,一次遍历搞定
Node<K,V> loHead = null, loTail = null;   // 留在原位的
Node<K,V> hiHead = null, hiTail = null;   // 挪到 j + oldCap 的

for (Node<K,V> p = oldTab[j]; p != null; p = p.next) {
    if ((p.hash & oldCap) == 0) {          // 这一位是 0 → 位置不变
        if (loTail == null) loHead = p; else loTail.next = p;
        loTail = p;
    } else {                                 // 这一位是 1 → 挪到高位
        if (hiTail == null) hiHead = p; else hiTail.next = p;
        hiTail = p;
    }
}
if (loTail != null) { loTail.next = null; newTab[j] = loHead; }
if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; }
必考追问:JDK 7 的 HashMap 为什么在并发扩容时会死循环?
JDK 7 用头插法迁移链表,会把链表的顺序反转。两个线程同时扩容时, A 线程刚读完 a.next = b 就被挂起;B 线程完成扩容后,顺序变成 b.next = a。 A 线程醒来按旧顺序继续操作,就可能把 a.next 重新指回 b,形成 a → b → a 的环。 之后任何一次 get 落到这个桶,就会在环里无限循环,表现为 CPU 100%、请求永不返回。
JDK 8 改成尾插 + 拆高低位链,顺序不再反转,成环问题消失。 但 JDK 8 仍会丢数据(两个线程同时 put 可能互相覆盖),所以并发场景必须用 ConcurrentHashMap,不是「升级到 8 就安全了」。

树化:兜底,不是常态

树化要同时满足两个条件:链表长度 ≥ 8,且桶数组长度 ≥ 64。 第二个条件常被漏掉——容量不足时,treeifyBin() 会先触发一次扩容而不是树化, 因为扩容后链表自然会变短,比维持一棵树更划算。

退化阈值是 6。这里的 2 个空隙就是前面说的防抖缓冲。

四槽速记 · 集合 #3

题负载因子为什么是 0.75?
八股HashMap 扩容机制与空间/时间的折中
一句话太小浪费空间、频繁扩容;太大冲突变多、链表变长;0.75 是二者交点,且能让容量始终为 2 的幂。
追问扩容后元素怎么迁移?→ 不用重算 hash,只看 hash & oldCap 一位,留在原位或挪到 +oldCap。

1.6ConcurrentHashMap 怎么做到并发安全

面试常把它和 HashMap 连着问:「HashMap 线程不安全,那用哪个?」答 ConcurrentHashMap 之后, 对方一定会追「它怎么保证线程安全」。答案分两代,能说清差异就够了,不必背源码。

版本机制并发度与代价
JDK 7 分段锁:整个表切成 16 个 Segment,每个 Segment 继承 ReentrantLock 并发度 = Segment 数(默认 16);不同段可并行,同一段串行
JDK 8 CAS + synchronized 锁桶头:桶为空时用 CAS 直接放;桶不空才锁住该桶的首节点 锁粒度细到单个桶,并发度接近桶数量;空桶插入无锁

还有一个容易被追问的点:size() 怎么数得准? 并发下累加一个 size 变量本身就是竞争点。JDK 8 借鉴了 LongAdder 的思路: 用 baseCount 做基础计数,冲突时分散到 CounterCell[] 数组的不同槽位各自累加, 最后求和。这样把「一个热点变量」拆成「多个变量」,显著降低 CAS 失败率。

为什么 ConcurrentHashMap 不允许 null 键和 null 值? 因为无法区分「不存在」和「值是 null」。单线程的 HashMap 可以用 containsKey 区分, 但并发场景下「先查再做」这两步之间状态可能已变,二义性会导致难以排查的 bug。 所以直接禁止,让你在编译/运行早期就暴露问题。

1.7手写一个能跑的简化 HashMap

这一节对应「能手写」。面试让你手写 HashMap,考的从来不是默写 JDK 源码,而是你有没有真正理解 「数组 + 链表 + 扩容」这三件事的配合。所以下面这版刻意砍掉了红黑树,只保留骨架, 但它能跑、能存、能扩容,且每个关键决策都标了原因。

先明确这版实现了什么、没实现什么,避免你误以为手写题要求一致:

能力本实现说明
put / get / size✅核心三件套
自动扩容✅翻倍 + 拆高低位链(与 JDK 8 同思路)
哈希扰动✅与 JDK 8 完全一致
红黑树树化❌手写题里不需要,说清楚「知道 JDK 怎么做」即可
并发安全❌明确说明「这是单线程版本」是加分项
import java.util.Objects;

/**
 * 简化版 HashMap:数组 + 链表 + 扩容。
 * 明确不做的两件事:不树化、不保证并发安全。
 */
public class SimpleHashMap<K, V> {

    /** 桶数组,长度恒为 2 的幂 */
    private Node<K, V>[] table;
    /** 实际键值对数量 */
    private int size;
    /** 扩容阈值 = 容量 × 负载因子 */
    private int threshold;

    private static final float LOAD_FACTOR = 0.75f;
    private static final int INIT_CAP = 16;

    /** 链表节点:只存 hash 不存「算好的下标」,因为扩容后下标会变 */
    static class Node<K, V> {
        final int hash;
        final K key;
        V value;
        Node<K, V> next;

        Node(int hash, K key, V value, Node<K, V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }

    @SuppressWarnings("unchecked")
    public SimpleHashMap() {
        table = (Node<K, V>[]) new Node[INIT_CAP];
        threshold = (int) (INIT_CAP * LOAD_FACTOR);
    }

    /** 与 JDK 8 一致:高 16 位异或到低 16 位,让低位也带上高位信息 */
    static int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

    /** 容量是 2 的幂时,这个运算等价于取模,但快得多 */
    static int indexFor(int hash, int cap) {
        return hash & (cap - 1);
    }

    public V put(K key, V value) {
        int h = hash(key);
        int i = indexFor(h, table.length);

        // ① 桶为空:直接放,这是最快路径
        if (table[i] == null) {
            table[i] = new Node<>(h, key, value, null);
            if (++size > threshold) resize();
            return null;
        }

        // ② 桶不为空:沿链表比对,命中就覆盖
        Node<K, V> p = table[i];
        while (true) {
            // 必须同时比 hash 和 key:只比 hash 会把不同对象误判为同一个
            if (p.hash == h && Objects.equals(p.key, key)) {
                V old = p.value;
                p.value = value;
                return old;
            }
            if (p.next == null) break;
            p = p.next;
        }

        // ③ 走到尾都没找到:尾插新节点
        //    之所以尾插而不是头插,见 1.5 的死循环分析
        p.next = new Node<>(h, key, value, null);
        if (++size > threshold) resize();
        return null;
    }

    public V get(Object key) {
        int h = hash(key);
        for (Node<K, V> p = table[indexFor(h, table.length)]; p != null; p = p.next) {
            if (p.hash == h && Objects.equals(p.key, key)) return p.value;
        }
        return null;   // 做不到区分「不存在」与「值为 null」,这也是 ConcurrentHashMap 禁 null 的原因
    }

    public int size() {
        return size;
    }

    /** 扩容:容量翻倍,旧元素只可能落在「原位」或「原位 + oldCap」 */
    @SuppressWarnings("unchecked")
    private void resize() {
        int oldCap = table.length;
        Node<K, V>[] oldTab = table;
        Node<K, V>[] newTab = (Node<K, V>[]) new Node[oldCap << 1];
        table = newTab;
        threshold = (int) (newTab.length * LOAD_FACTOR);

        for (int j = 0; j < oldCap; j++) {
            Node<K, V> p = oldTab[j];
            if (p == null) continue;

            // 一条桶链拆成两条:hash 在 oldCap 这一位为 0 的留下,为 1 的挪走
            Node<K, V> loHead = null, loTail = null;
            Node<K, V> hiHead = null, hiTail = null;
            while (p != null) {
                Node<K, V> next = p.next;
                p.next = null;                       // 断开旧链,避免残留指针
                if ((p.hash & oldCap) == 0) {
                    if (loTail == null) loHead = p; else loTail.next = p;
                    loTail = p;
                } else {
                    if (hiTail == null) hiHead = p; else hiTail.next = p;
                    hiTail = p;
                }
                p = next;
            }
            if (loTail != null) newTab[j] = loHead;
            if (hiTail != null) newTab[j + oldCap] = hiHead;
        }
    }

    /** 自测:塞 100 个键,验证 get 全中、size 正确、确实扩容了 */
    public static void main(String[] args) {
        SimpleHashMap<Integer, String> map = new SimpleHashMap<>();
        for (int i = 0; i < 100; i++) map.put(i, "v" + i);

        for (int i = 0; i < 100; i++) {
            if (!("v" + i).equals(map.get(i))) throw new AssertionError("取值失败: " + i);
        }
        map.put(42, "覆盖");                       // 覆盖已有 key
        if (!"覆盖".equals(map.get(42))) throw new AssertionError("覆盖失败");
        if (map.size() != 100) throw new AssertionError("size 错误: " + map.size());

        System.out.println("通过:size=" + map.size()
                + ", 桶数=" + map.table.length + ", 阈值=" + map.threshold);
    }
}

看到什么算通过:编译运行后输出 通过:size=100, 桶数=256, 阈值=192。 桶数 256 是因为从 16 开始,装到 12 扩一次、24 扩一次……一直到 192 之前正好到 256——这个数字能自己推出来,说明你真的懂了扩容。

手写题的表达技巧:写完主动说一句「这版省掉了树化,因为 8 个节点以上才会用到, 实际面试里更重要的是把扩容和覆盖逻辑写对;如果要加树化,我会把 Node 换成 TreeNode 并加一个 链表长度计数」——主动划边界,比硬写一堆半对的红黑树得分高得多。

1.8本章速记卡与自测

四槽格式的意义在这一节体现:四个槽位是固定的,所以你默写时能自查「我漏了哪一格」, 而不是感觉「好像记得又好像忘了」。

#八股问题一句话答案最可能的追问
1HashMap 的 get 为什么平均 O(1)? 一次位运算算出桶下标,直接寻址;桶内链表/红黑树兜底冲突。 最坏为什么是 O(log n)?→ 树化之后桶内是红黑树。
2容量为什么必须是 2 的幂? 这样 n-1 二进制全 1,与运算才不浪费桶。 那为什么还要扰动?→ 数组小,只用得到低位。
3负载因子 0.75 怎么来的? 空间与冲突的交点;再大冲突陡增,再小频繁扩容。 扩容时元素怎么迁移?→ 看 hash&oldCap,原位或 +oldCap。
4JDK 7 扩容为什么死循环? 头插法反转链表,并发迁移形成环。 JDK 8 就安全了?→ 不成环但仍会丢数据,并发要用 CHM。
5ConcurrentHashMap 怎么保证线程安全? JDK 8:空桶 CAS,非空桶 synchronized 锁桶头。 它为什么不允许 null?→ 并发下无法区分「不存在」和「值为 null」。

自测:能答上来再离开本章

  1. 不看书,用 30 秒说清一次 put 从入参到落库的全过程(8 步里至少说出 6 步)。
  2. 把 n 改成 15,解释为什么 `(n-1) & hash` 会浪费一半桶。
  3. 白纸写出「扩容时拆高低位链」那 10 行代码,说出为什么不用重算 hash。
  4. 说出树化阈值的两个条件,以及为什么容量不到 64 时先扩容。
  5. 如果面试官问「你的实现和 JDK 差在哪」,你能列出至少 3 条并说明为什么这样取舍。
一句话记忆(主题 01):集合的所有设计都是在「空间 / 时间 / 冲突」三角里找平衡点—— 数组换连续性、链表换插入、哈希换查找;而 HashMap 的全部技巧,都是为了在扩容那一刻让这三笔账同时不崩。