Java集合框架深度解析:HashMap

一、核心定义与定位

HashMap 是 Java 集合框架中基于「哈希表」实现的 Map 接口实现类,核心用于存储键值对(Key-Value),支持以平均 O (1) 时间复杂度完成增、删、查操作,是日常开发中最常用的非线程安全键值对容器。

记忆口诀:哈希表核心,O (1) 效率,键值对存储,非线程安全。

HashMap与ArrayList / LinkedList比较:

比 ArrayList / LinkedList 难一档,因为:

  1. 数组 + 链表 + 红黑树 三种结构随时切换
  2. 哈希冲突、负载因子、位运算、树化阈值、退化阈值……一堆魔法数字
  3. resize 时既要重新散列又要拆分链表/树,并发场景还会把节点链成环(JDK7 经典死链)
  4. JDK8 引入红黑树后代码量直接翻倍,split、untreeify、treeify 全是细节

但抓住一条主线就不乱了:

HashMap = 桶数组 + 哈希码 → 桶下标 → 链表/红黑树 → 扩容再散列
所有复杂度都藏在“怎么快速定位桶”和“桶里元素太多怎么办”这两件事上。

二、底层数据结构

1. 核心结构:数组 + 链表 + 红黑树

Java 8以后,HashMap 的底层结构是「数组(桶)」为基础,「链表」解决哈希冲突,「红黑树」优化极端冲突场景的组合结构,JDK8 对该结构做了核心优化:

结构组件 作用说明 核心特性
数组(Node [] table) 又称「桶(Bucket)」,是核心存储载体,每个桶对应一个数组下标 随机访问效率 O (1),下标由 Key 的哈希值计算得出
链表(Node 节点) 解决「哈希冲突」(多个 Key 映射到同一下标),JDK8 为双向链表 插入 / 删除 O (1)(尾插法),查询 O (n),长度≥8 时触发转红黑树
红黑树(TreeNode) 优化链表过长导致的查询低效问题 查询 O (logn),节点数≤6 时转回链表,避免红黑树维护成本过高

核心节点类

// HashMap 基础链表节点(JDK8)
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;    // 节点哈希值(Key经扰动后的最终hash)
    final K key;       // 键(不可修改,保证哈希稳定性)
    V value;           // 值(可修改)
    Node<K,V> next;    // 下一个节点(双向链表还会有prev,此处简化)

    // 构造方法:初始化节点核心属性
    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }

    // 重写equals:保证键值对的相等判断(Key+Value都相等才是同一个Entry)
    public final boolean equals(Object o) {
        if (o == this)
            return true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            // Key和Value都相等,才判定为相等的Entry
            if (Objects.equals(key, e.getKey()) &&
                Objects.equals(value, e.getValue()))
                return true;
        }
        return false;
    }
}

// 红黑树节点(简化版,仅保留核心属性)
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent; // 父节点(红黑树核心属性)
    TreeNode<K,V> left;   // 左子节点
    TreeNode<K,V> right;  // 右子节点
    TreeNode<K,V> prev;   // 前驱节点(双向链表)
    boolean red;          // 红黑树颜色标记(红/黑)

    TreeNode(int hash, K key, V value, Node<K,V> next) {
        super(hash, key, value, next);
    }
}

核心字段

//核心字段(需背会)

Node<K,V>[] table;      // 桶数组,长度永远是 2 的幂
int threshold;          // 扩容阈值 = capacity * loadFactor
final float loadFactor; // 默认 0.75
static final int TREEIFY_THRESHOLD = 8;   // 链表→树
static final int UNTREEIFY_THRESHOLD = 6; // 树→链表
static final int MIN_TREEIFY_CAPACITY = 64; // 树化前最低容量

2. 结构演进(JDK7 vs JDK8)

对比维度 JDK7 JDK8 优化目的
基础结构 数组 + 单向链表 数组 + 双向链表 + 红黑树 优化极端冲突场景的查询性能
链表插入方式 头插法 尾插法 解决扩容时链表成环问题
扩容后节点定位 重新计算哈希值 仅判断 hash 的某一位 提升扩容效率
空值处理 支持 null Key/Value 支持 null Key/Value 保持兼容性

记忆:JDK8 改头插为尾插,加红黑树,扩容更智能,解决成环和性能问题。

3. 初始容量的合理设置

现有未提 “自定义初始容量” 的坑点:

  • 若已知 HashMap 要存储 N 个元素,推荐初始容量设置为 (N / 0.75) + 1(避免扩容);

  • 注意:HashMap 会将自定义初始容量向上取整为最近的 2 的幂(如设置 10 → 实际 16),底层通过 tableSizeFor()方法实现

    // 核心逻辑:将传入容量转为≥它的最小2的幂
    static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
    

容量必须为 2 的幂的原因

  1. 效率优化(n-1) & hash 等价于 hash % n,位运算效率远高于取模;
  2. 扩容优化:JDK8 扩容时,节点下标仅需判断 hash 的某一位,无需重新计算哈希值;
  3. 哈希均匀:2 的幂次的容量,(n-1) 的二进制全为 1,能最大化利用 hash 的所有位,减少冲突。

记忆:容量 2 的幂,下标计算快,扩容更智能,哈希更均匀。

三、哈希计算与下标定位

1. 哈希值计算(扰动函数)

HashMap 对 Key 的 hashCode() 做「扰动处理」,核心目的是让 hashCode 的高位也参与下标计算,减少哈希冲突(尤其是数组容量较小时,高位无法通过取模参与计算)。

核心源码

/**
 * 计算Key的最终哈希值(扰动函数)
 * @param key 要计算的键
 * @return 扰动后的哈希值
 */
static final int hash(Object key) {
    int h;
    // 1. key为null时,hash固定为0(HashMap支持null Key的核心逻辑)
    // 2. 非null时:先获取hashCode,再将高16位与低16位异或(扰动)
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

h >>> 16无符号右移 16 位,把 32 位整数 h 整体向右平移 16 格,左边空出来的位一律补 0(不分正负)。

直观效果:

  • 原来高 16 位被搬到“低 16 位”的位置
  • 原来低 16 位被直接扔掉
  • 高位永远补 0,所以结果永远是非负数
h = 1111 1111 0000 0000 1111 0000 1111 0000
h >>> 16
  = 0000 0000 0000 0000 1111 1111 0000 0000
    ↑ 高位补 0      ↑ 原高 16 位现变成低 16 位

在 HashMap 里就是为了把高位的混乱特征“折”到低位,再跟原低位异或,减少哈希冲突。

扰动函数的作用解析

假设 Key 的 hashCode 是 0x0000FFFF(低 16 位全 1,高 16 位全 0):

  • 未扰动:高 16 位无意义,下标计算仅用低 16 位,冲突概率高;
  • 扰动后:h ^ (h >>> 16) = 0x0000FFFF ^ 0x00000000 = 0x0000FFFF(示例),让高 16 位的特征融入最终 hash 值。

记忆:扰动 = 高 16 位 ^ 低 16 位,让高位也 “干活”,减少冲突。

哈希扰动函数的演进(JDK7 vs JDK8)

// JDK7 扰动函数(4次位运算+5次异或,扰动更“激进”)
static int hash(int h) {
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}

// JDK8 扰动函数(仅1次异或,简化但足够)
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

优化原因:JDK8 减少扰动次数,兼顾性能与冲突率 —— 高 16 位异或低 16 位,既让高位参与计算,又避免过度运算。

2. 数组下标计算

通过位运算替代取模,提升效率(仅当数组容量为 2 的幂时等价):

/**
 * 计算Key在数组中的下标
 * @param hash 扰动后的哈希值
 * @param capacity 数组容量(2的幂)
 * @return 数组下标
 */
private static int getIndex(int hash, int capacity) {
    // (capacity - 1) & hash 等价于 hash % capacity,但位运算效率更高
    // 前提:capacity 必须是 2 的幂(如16、32、64)
    return (capacity - 1) & hash;
}

代码验证

public class HashMapHashDemo {
    public static void main(String[] args) {
        String key = "Java";
        // 1. 获取原始hashCode
        int hashCode = key.hashCode();
        System.out.println("原始hashCode:" + hashCode); // 示例:2301506
        
        // 2. 执行扰动函数
        int hash = hashCode ^ (hashCode >>> 16);
        System.out.println("扰动后的hash:" + hash); // 示例:2301507
        
        // 3. 计算下标(容量16)
        int capacity = 16;
        int index = (capacity - 1) & hash;
        System.out.println("数组下标:" + index); // 示例:3
    }
}

3. null Key 的特殊处理

现有内容提到支持 null Key/Value,但未细化底层逻辑:

  • null Key 的 hash 值固定为 0,因此永远落在桶数组下标 0 的位置;
  • 查找 null Key 时,无需计算哈希,直接遍历下标 0 的桶即可,是 HashMap 对 null 的专属优化;
  • 注意:ConcurrentHashMap 不支持 null Key/Value,这是与 HashMap 的关键区别之一。

4. 补充:>>>>>符号的作用

  1. >>> 是 Java 的无符号右移运算符unsigned right shift)。

功能:
“把整数的二进制位整体向右移动指定位数,左边空出来的位一律补 0(不管原来正负)。”

  1. >> 是 java的带符号右移运算符(signed right shift)。

功能:
把整数的二进制位整体向右移动指定格数,左边空出来的位用“原来的符号位”补齐(正数补 0,负数补 1)。
结果保持原符号(正数仍 ≥ 0,负数仍 < 0)。

对比记忆:

运算符 名称 左侧补位规则 结果正负
>> 带符号右移 原符号位(0 或 1) 保持原符号
>>> 无符号右移 永远补 0 永远得 非负数

示例(8 位演示):

原始          : 1111 0001   (-15 的补码)
-15 >>  2     : 1111 1100   带符号右移→仍是负数
-15 >>> 2     : 0011 1100   无符号右移→正数 60

在 HashMap 里:

h >>> 16

就是把 32 位哈希值的高 16 位“搬”到低 16 位,同时保证结果为正,再与原低 16 位异或,用来打散低位分布

记忆口诀:
>> 带符号,移后符号不变;>>> 无符号,移后永远非负。

四、扩容(Rehash)机制

1. 扩容触发条件

当 HashMap 满足以下任一条件时,触发扩容:

  • 元素总数(size)≥ 数组容量(capacity)× 负载因子(loadFactor);
  • 链表转红黑树时,数组容量 < 64(优先扩容而非转树)。

默认参数:

  • 初始容量:16(2^4);
  • 负载因子:0.75;
  • 初始阈值:16 × 0.75 = 12(触发扩容的临界值)。

2. 扩容核心逻辑

/**
 * 简化版扩容逻辑(保留核心步骤+详细注释)
 */
final Node<K,V>[] resize() {
    // 1. 保存原数组和原容量/阈值
    Node<K,V>[] oldTab = table; // 原数组
    int oldCap = (oldTab == null) ? 0 : oldTab.length; // 原容量
    int oldThr = threshold; // 原阈值
    int newCap, newThr = 0;

    // 2. 计算新容量和新阈值
    if (oldCap > 0) {
        // 2.1 原容量超过最大值(2^30),不再扩容,阈值设为int最大值
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 2.2 容量翻倍(左移1位 = ×2),阈值也翻倍(原容量≥16时)
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) {
            newThr = oldThr << 1;
        }
    }
    // 2.3 首次初始化(new HashMap(int initialCapacity) 场景)
    else if (oldThr > 0) {
        newCap = oldThr;
    }
    // 2.4 无参构造首次初始化(默认容量16,阈值12)
    else {
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }

    // 3. 兜底计算新阈值(如自定义容量时)
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY) ? (int)ft : Integer.MAX_VALUE;
    }
    threshold = newThr; // 更新全局阈值

    // 4. 创建新数组(容量翻倍)
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;

    // 5. 迁移原数组节点到新数组(JDK8核心优化)
    if (oldTab != null) {
        // 遍历原数组的每个桶
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e = oldTab[j]; // 当前桶的头节点
            if (e != null) {
                oldTab[j] = null; // 释放原数组引用(GC)
                
                // 5.1 桶中只有单个节点:直接计算新下标放入
                if (e.next == null) {
                    newTab[e.hash & (newCap - 1)] = e;
                }
                // 5.2 红黑树节点:拆分红黑树(简化)
                else if (e instanceof TreeNode) {
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                }
                // 5.3 链表节点:JDK8优化核心(无需重新计算hash)
                else {
                    Node<K,V> loHead = null, loTail = null; // 留在原下标
                    Node<K,V> hiHead = null, hiTail = null; // 移到 原下标+oldCap
                    
                    do {
                        Node<K,V> next = e.next;
                        // 判断hash的oldCap位:0→留原下标,1→移新下标
                        if ((e.hash & oldCap) == 0) {
                            // 尾插法构建原下标链表
                            if (loTail == null) loHead = e;
                            else loTail.next = e;
                            loTail = e;
                        } else {
                            // 尾插法构建新下标链表
                            if (hiTail == null) hiHead = e;
                            else hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    
                    // 将链表放入新数组对应位置
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

3. 负载因子 0.75 的设计原因

负载因子是「时间效率」与「空间效率」的最优平衡:

负载因子 = 0.75 不是算出来的,是**“测” + “估”** 后留下的经验折中值,官方源码注释只给了一句话解释:

“As a general rule, the default load factor (.75) offers a good trade-off between time and space costs.”

把它拆开就是三条量化依据 + 一条工程兜底:

  1. 冲突概率 vs 空间浪费的实测曲线

    哈希表的期望冲突次数服从泊松分布:
    λ = 0.5 时,桶里出现 ≥8 个节点的概率已经 <10⁻⁶;
    当负载因子 α 从 0.6 升到 0.9 时,平均查找长度(ASL)近似 1/(1-α) 增长:

    • α=0.6 → ASL≈2.5

    • α=0.75 → ASL≈4

    • α=0.9 → ASL≈10

      0.75 正好处于“ASL 还能接受”与“数组不至于太稀疏”的拐点。

  2. 扩容代价的摊销
    0.75 意味着每插入 3 个元素平均才触发一次 2× 扩容,rehash 的 CPU 开销多占 25 % 内存之间取得平衡;再高一点,节省的内存远抵不过频繁扩容的拷贝成本。

  3. 与“树化阈值 8”配套
    当 α=0.75 时,泊松分布给出:
    P(桶长度≥8) ≈ 0.0000001,几乎不可能因自然冲突就出现超长链表;
    一旦真出现 ≥8 的桶,大概率是哈希攻击极劣质 key,此时转成红黑树把查找复杂度从 O(n) 降到 O(log n) 才划算。
    换句话说,0.75 让‘树化’成为真正的异常分支,而不是日常路径。

  4. 工程兜底 —— 经验值好记
    3/4 是简单分数,0.75 = 3/4 便于口算与文档描述;同时与 0.5、0.9 拉开明显间隔,减少误调。

一句话总结
0.75 是实测后给出的时间-空间-冲突概率-树化概率四重折中点:
再小 → 浪费数组;再大 → 冲突暴涨、扩容频繁;
它和“链表长度≥8 才树化”一起,把正常场景攻击/劣质哈希清晰分开。

五、核心方法解析(核心逻辑 + 简化源码)

1. put (K key, V value):添加 / 修改元素

核心流程(图解)

此图片为put (K key, V value):添加 / 修改元素的核心流程

简化源码

/**
 * 添加/修改键值对
 * @param key 键
 * @param value 值
 * @return 旧值(无则返回null)
 */
public V put(K key, V value) {
    // 调用核心方法,hash参数为扰动后的hash值
    return putVal(hash(key), key, value, false, true);
}

/**
 * put的核心实现
 * @param hash Key的最终hash值
 * @param key 键
 * @param value 值
 * @param onlyIfAbsent 仅当Key不存在时才插入(false=覆盖已有值)
 * @param evict 用于LinkedHashMap的回调标记
 * @return 旧值(无则返回null)
 */
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;

    // 1. 数组未初始化 → 执行扩容(首次put时)
    if ((tab = table) == null || (n = tab.length) == 0) {
        n = (tab = resize()).length;
    }

    // 2. 计算下标,桶为空 → 直接创建节点放入
    if ((p = tab[i = (n - 1) & hash]) == null) {
        tab[i] = newNode(hash, key, value, null);
    } else {
        Node<K,V> e; K k;
        // 3. 桶首节点匹配(hash相等且Key相等)→ 记录节点
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) {
            e = p;
        }
        // 4. 首节点是红黑树 → 树中插入/替换
        else if (p instanceof TreeNode) {
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        }
        // 5. 首节点是链表 → 遍历链表
        else {
            // binCount:记录链表长度(用于判断是否转红黑树)
            for (int binCount = 0; ; ++binCount) {
                // 5.1 遍历到链表尾部 → 尾插法插入新节点
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 链表长度≥8 → 触发转红黑树(还会检查数组容量≥64)
                    if (binCount >= TREEIFY_THRESHOLD - 1) {
                        treeifyBin(tab, hash);
                    }
                    break;
                }
                // 5.2 找到匹配节点 → 跳出循环
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
                    break;
                }
                p = e; // 移动到下一个节点
            }
        }

        // 6. 节点已存在 → 替换Value
        if (e != null) {
            V oldValue = e.value;
            // onlyIfAbsent=false 或旧值为null → 覆盖
            if (!onlyIfAbsent || oldValue == null) {
                e.value = value;
            }
            afterNodeAccess(e); // LinkedHashMap回调(此处无实际逻辑)
            return oldValue; // 返回旧值
        }
    }

    // 7. 结构修改次数+1(用于Fail-Fast检测)
    ++modCount;
    // 8. 元素总数+1,超过阈值 → 扩容
    if (++size > threshold) {
        resize();
    }
    afterNodeInsertion(evict); // LinkedHashMap回调
    return null; // 无旧值返回null
}

2. get (Object key):获取元素

核心流程

  1. 计算 Key 的 hash 值;
  2. 定位数组下标,桶为空则返回 null;
  3. 桶首节点匹配则返回 Value;
  4. 首节点是红黑树则树中查找,是链表则遍历查找;
  5. 未找到则返回 null。

简化源码

/**
 * 根据Key获取Value
 * @param key 键
 * @return Value(无则返回null)
 */
public V get(Object key) {
    Node<K,V> e;
    // 计算hash → 调用核心方法 → 无节点返回null,有则返回Value
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

/**
 * get的核心实现
 * @param hash Key的最终hash值
 * @param key 键
 * @return 匹配的节点(无则返回null)
 */
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;

    // 1. 数组非空 + 桶非空 → 继续查找
    if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
        // 2. 首节点匹配 → 直接返回
        if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k)))) {
            return first;
        }

        // 3. 遍历后续节点
        if ((e = first.next) != null) {
            // 3.1 红黑树 → 树中查找
            if (first instanceof TreeNode) {
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            }
            // 3.2 链表 → 遍历匹配
            do {
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) {
                    return e;
                }
            } while ((e = e.next) != null);
        }
    }
    // 4. 未找到匹配节点 → 返回null
    return null;
}

3. 常用方法速查

方法名 核心功能 时间复杂度 注意事项
size() 获取元素总数 O(1) 直接返回 size 变量
isEmpty() 判断是否为空 O(1) 等价于 size () == 0
containsKey(Object) 判断是否包含指定 Key O(1) 基于哈希查找,效率高
containsValue(Object) 判断是否包含指定 Value O(n) 需遍历所有元素,效率低
remove(Object) 删除指定 Key 的元素 O(1) 找到节点后直接移除,链表 / 红黑树分别处理
clear() 清空所有元素 O(n) 遍历所有桶,置 null 并重置 size/modCount
keySet() 获取所有 Key 的集合(视图) O(1) 视图与原 Map 关联,修改会同步
values() 获取所有 Value 的集合(视图) O(1) 同上
entrySet() 获取所有键值对的集合(视图) O(1) 遍历 HashMap 的最优方式(减少哈希计算)

简单演示

import java.util.*;

public class Main {
    public static void main(String[] args) {
        // 1. 初始化一个 HashMap
        HashMap<String, Integer> map = new HashMap<>();
        map.put("A", 1);
        map.put("B", 2);
        map.put("C", 3);

        //1. size()
        System.out.println("size() = " + map.size());          // 3

        //2. isEmpty()
        System.out.println("isEmpty() = " + map.isEmpty());    // false

        //3. containsKey()
        System.out.println("containsKey(\"B\") = " + map.containsKey("B")); // true

        //4. containsValue()
        System.out.println("containsValue(3) = " + map.containsValue(3)); // true

        //5. remove()
        Integer removed = map.remove("B");
        System.out.println("remove(\"B\") 返回值 = " + removed); // 2
        System.out.println("删除后 map = " + map);              // {A=1, C=3}

        //6. clear()
        map.clear();
        System.out.println("clear() 后 isEmpty = " + map.isEmpty()); // true

        // 重新填点数据,方便演示三种视图
        map.put("K1", 10);
        map.put("K2", 20);

        //7. keySet()
        Set<String> keys = map.keySet();
        System.out.println("keySet() = " + keys);              // [K1, K2]

        // 8. values()
        Collection<Integer> values = map.values();
        System.out.println("values() = " + values);            // [10, 20]

        //9. entrySet()
        Set<Map.Entry<String, Integer>> entries = map.entrySet();
        System.out.println("entrySet() 遍历:");
        for (Map.Entry<String, Integer> e : entries) {
            System.out.printf("  %s -> %s%n", e.getKey(), e.getValue());
        }

        /* -------------- 视图联动演示 -------------- */
        System.out.println("修改原 map 后,视图自动同步:");
        map.put("K3", 30);
        System.out.println("  keySet 现在 = " + keys);
        System.out.println("  values 现在 = " + values);
        System.out.println("  entrySet 现在 = " + entries);
    }
}

六、核心坑点与避坑指南

1. 线程不安全问题

问题表现

  • JDK7:多线程扩容时,链表头插法导致链表成环,引发死循环;
  • JDK8:多线程 put 可能覆盖数据,遍历可能丢失数据。

解决方案

// 方案1:ConcurrentHashMap(推荐,并发性能优)
Map<String, Integer> chm = new ConcurrentHashMap<>();

// 方案2:Collections.synchronizedMap(全局锁,性能差,适合低并发)
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());

// 方案3:手动加锁(灵活,适合自定义场景)
Lock lock = new ReentrantLock();
Map<String, Integer> map = new HashMap<>();

// 加锁put示例
lock.lock();
try {
    map.put("key", 1);
} finally {
    lock.unlock();
}

2. Key 需重写 hashCode/equals

问题根源

HashMap 判定 Key 相等的规则:hash相等 && equals为true,若未重写:

  • 不同对象(如两个 new User (“张三”))的 hashCode 不同,会被判定为不同 Key;
  • 即使 hashCode 相同,equals 为 false,也会被判定为不同 Key。

正确示例

class User {
    private String id;
    private String name;

    // 构造方法省略

    // 重写equals:基于业务唯一标识(如id)判断相等
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        User user = (User) o;
        return Objects.equals(id, user.id); // 仅id相等即判定为同一用户
    }

    // 重写hashCode:基于equals的字段计算
    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

3. Fail-Fast 机制

之前在Java集合框架深入解析:ArrayList & LinkedList一文中有过详细解释Fail-Fast 机制。

触发条件

遍历 HashMap 时(如 foreach、迭代器),修改集合结构(put/remove),会抛出 ConcurrentModificationException(通过 modCount 检测)。

安全遍历方式

// 方式1:迭代器的remove方法(推荐)
Iterator<Map.Entry<String, Integer>> it = map.entrySet().iterator();
while (it.hasNext()) {
    Map.Entry<String, Integer> entry = it.next();
    if ("test".equals(entry.getKey())) {
        it.remove(); // 安全,会同步更新modCount
    }
}

// 方式2:遍历前拷贝集合(适合小数据量)
for (String key : new ArrayList<>(map.keySet())) {
    if ("test".equals(key)) {
        map.remove(key);
    }
}

// 方式3:使用ConcurrentHashMap(无Fail-Fast)
Map<String, Integer> chm = new ConcurrentHashMap<>();
chm.put("test", 1);
for (String key : chm.keySet()) {
    chm.remove(key); // 不会抛异常
}

4. 性能调优建议

结合实际开发的调优方向:

  • 低内存 + 高并发:减小负载因子(如 0.6),降低冲突概率,代价是更频繁扩容;
  • 高内存 + 低并发:增大负载因子(如 0.85),减少数组占用空间,代价是冲突略增;
  • 避免使用「哈希值分布差」的 Key(如连续整数),可自定义哈希函数优化分布;
  • 大批量插入前先 initialCapacity 初始化,避免多次扩容。

七、关联

1. HashMap 与 ConcurrentHashMap 的关联

一句话区分:
HashMap = 单机快车,线程不安全
ConcurrentHashMap = 高并发专车,读无锁、写桶锁,安全且快。

维度 HashMap ConcurrentHashMap
线程安全 ❌ 完全不行 ✅ 高并发设计
读写锁 无锁,也不保证可见性 读全程无锁,写只锁单个桶
Null 支持 key/value 都可 null key/value 都不能 null(防歧义)
结构 数组+链表+红黑树 同左,但节点全 volatile,迁移用 ForwardingNode
扩容 单线程内部完成 多线程协同 transfer,CPU 越多越快
计数 普通 int size() 分段 LongAdder,size() 无锁也精确
迭代器 fail-fast,并发修改抛异常 fail-safe,遍历快照,允许并发更新
典型用途 普通业务缓存 全局缓存、并发计算、高吞吐计数器

记忆口诀:
普通地图用 HashMap,并发战场换 ConcurrentHashMap。

2. HashMap 与 LinkedHashMap 的关联

补充 LinkedHashMap 基于 HashMap 的扩展逻辑:

  • LinkedHashMap 继承 HashMap,重写 newNode() 方法,在节点中增加「前驱 / 后继」指针,维护插入 / 访问顺序;

  • 可通过 LinkedHashMap 实现 “LRU 缓存”(覆写 removeEldestEntry()):

    // 简单LRU缓存:容量超过5时,移除最久未访问的元素
    Map<String, Integer> lruMap = new LinkedHashMap<String, Integer>(16, 0.75f, true) {
        @Override
        protected boolean removeEldestEntry(Map.Entry<String, Integer> eldest) {
            return size() > 5;
        }
    };
    

八、面试高频考点总结

1. 底层原理核心考点

  • 问题一:HashMap JDK7 和 JDK8 的核心区别?

    答:

    ① 结构:JDK7 数组 + 单向链表,JDK8 数组 + 双向链表 + 红黑树;

    ② 插入:JDK7 头插法(成环风险),JDK8 尾插法;

    ③ 扩容:JDK8 节点定位更高效;

    ④ 性能:JDK8 极端冲突场景更优。

  • 问题二:红黑树转换条件为什么是链表长度≥8 且数组容量≥64?

    答:

    ① 长度≥8:泊松分布下,链表长度超过 8 的概率极低,避免过度优化;

    ② 容量≥64:数组容量小时,优先扩容减少冲突,而非转树(红黑树维护成本高)。

  • 问题三:负载因子为什么是 0.75?

    答:时间与空间的最优平衡,基于泊松分布,该值下哈希冲突概率最低。(见 四.3

2. 实战避坑核心考点

  • 问题一:HashMap 为什么线程不安全?

    答:

    ① JDK7 扩容头插法导致链表成环;

    ② JDK8 多线程 put 可能覆盖数据;

    ③ 无同步机制,modCount 非原子更新。

  • 问题二:Key 为什么要重写 hashCode/equals?

    答:保证 Key 的相等判断与哈希计算一致,避免 “存得进,找不出” 的问题。

  • 问题三:如何解决 HashMap 的 Fail-Fast 问题?

    答:

    ① 使用迭代器的 remove 方法;

    ② 遍历前拷贝集合;

    ③ 使用 ConcurrentHashMap。

3. 终极记忆口诀

HashMap 三大核心:

  1. 哈希计算(扰动 + 位运算,少冲突);
  2. 扩容机制(翻倍 + 智能定位,平衡时空);
  3. 结构优化(链表转红黑树,提性能)。
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐