文章目录

思维导图

HashMap核心原理:JDK1.7 vs 1.8+ 系统性知识体系

一、整体概述

HashMap是Java集合框架中最常用的键值对存储结构,基于哈希表实现,允许null键和null值,线程不安全。JDK1.8对其进行了重大重构,解决了1.7中链表过长导致的性能退化问题,同时优化了哈希冲突处理和扩容机制。

特性 JDK1.7 JDK1.8+
核心数据结构 数组+单向链表 数组+单向链表+红黑树
哈希冲突解决 链地址法(头插法) 链地址法(尾插法)+红黑树
插入方式 头插法 尾插法
扩容时链表顺序 反转 保持原顺序
并发问题 死循环、数据丢失 数据覆盖、丢失
平均时间复杂度 O(1)~O(n) O(1)~O(logn)

二、核心数据结构对比

1. JDK1.7:数组+单向链表

  • 底层实现Entry[] table 数组,每个元素是单向链表的头节点
  • Entry节点结构
    static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;
        // ...
    }
    
  • 问题:当哈希冲突严重时,链表会变得很长,get操作时间复杂度退化为O(n)

2. JDK1.8+:数组+单向链表+红黑树

  • 底层实现Node[] table 数组,链表长度超过阈值时转换为红黑树
  • Node节点结构(链表节点):
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
        // ...
    }
    
  • TreeNode节点结构(红黑树节点):
    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;           // 颜色标记
        // ...
    }
    
  • 优势:红黑树的查找、插入、删除时间复杂度均为O(logn),解决了链表过长的性能问题

三、哈希函数对比

1. JDK1.7 哈希函数

final int hash(Object k) {
    int h = hashSeed;
    if (0 != h && k instanceof String) {
        return sun.misc.Hashing.stringHash32((String) k);
    }
    h ^= k.hashCode();
    // 多次扰动:4次位运算+5次异或
    h ^= (h >>> 20) ^ (h >>> 12);
    return h ^ (h >>> 7) ^ (h >>> 4);
}
  • 特点
    • 使用hashSeed随机种子(可通过JVM参数配置)
    • 对key的hashCode进行9次扰动,目的是让高位参与运算,减少哈希冲突
    • 对String类型有特殊优化

2. JDK1.8+ 哈希函数

static final int hash(Object key) {
    int h;
    // 1次位运算+1次异或
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • 特点
    • 简化为2次扰动:将hashCode的高16位与低16位进行异或
    • 去掉了hashSeed随机种子
    • null键的哈希值固定为0,因此null键总是存放在数组第0个位置
  • 设计思想
    • 数组长度总是2的幂,计算数组下标时使用(n-1) & hash
    • 高16位参与运算可以让高位信息也影响下标计算,减少哈希冲突
    • 1.8的扰动函数虽然简单,但配合红黑树已经足够应对大部分场景

四、数组下标计算

两个版本的数组下标计算方式相同:

// n是数组长度,总是2的幂
int index = hash & (n - 1);
  • 为什么用&而不是%:位运算效率远高于取模运算
  • 为什么数组长度必须是2的幂:保证n-1的二进制表示全是1,这样hash & (n-1)的结果才能均匀分布在[0, n-1]范围内

五、扩容机制对比

1. 核心参数

参数 含义 默认值
initialCapacity 初始容量 16
loadFactor 负载因子 0.75f
threshold 扩容阈值 capacity * loadFactor

2. JDK1.7 扩容机制

  • 触发条件:当size >= threshold且当前插入位置已有元素时
  • 扩容大小:原容量的2倍(newCapacity = oldCapacity * 2
  • 扩容过程
    1. 创建新数组,长度为原数组的2倍
    2. 遍历原数组的每个链表
    3. 对每个Entry重新计算哈希值和数组下标
    4. 使用头插法将Entry插入到新数组的对应位置
  • 致命问题
    • 头插法会导致链表顺序反转
    • 多线程并发扩容时可能形成环形链表,导致get操作时死循环

3. JDK1.8+ 扩容机制

  • 触发条件:当size >= threshold时(无论当前插入位置是否有元素)
  • 扩容大小:原容量的2倍
  • 优化点
    • 不需要重新计算每个节点的哈希值
    • 利用hash & oldCap的结果判断节点在新数组中的位置:
      • 结果为0:新下标 = 原下标
      • 结果为1:新下标 = 原下标 + 原容量
    • 使用尾插法插入节点,保持链表原顺序
  • 红黑树处理
    • 扩容时会对红黑树进行拆分
    • 如果拆分后树的节点数小于等于6,则退化为链表
  • 并发问题
    • 尾插法避免了环形链表和死循环
    • 但仍然存在数据覆盖、丢失等问题,因此HashMap仍然线程不安全

六、put方法全流程对比

1. JDK1.7 put流程

  1. 判断table数组是否为空,若为空则初始化
  2. 计算key的哈希值和数组下标
  3. 遍历该下标处的链表:
    • 若key已存在(hash相等且equals返回true),则更新value并返回旧value
    • 若key不存在,则继续
  4. 判断是否需要扩容(size >= threshold且当前位置已有元素)
  5. 若需要扩容,则创建新数组并将原数组元素转移到新数组
  6. 使用头插法将新Entry插入到链表头部
  7. size加1,返回null

2. JDK1.8+ put流程

1. 判断table数组是否为空,若为空则调用resize()初始化
2. 计算key的哈希值和数组下标i
3. 若table[i] == null,则直接创建新Node插入,转步骤8
4. 若table[i] != null:
   a. 若第一个节点的key与待插入key相同(hash相等且equals返回true),则直接覆盖value
   b. 若第一个节点是TreeNode,则调用putTreeVal()插入红黑树
   c. 若第一个节点是普通Node,则遍历链表:
      i. 若找到相同key的节点,则覆盖value
      ii. 若遍历到链表尾部仍未找到,则创建新Node插入尾部
      iii. 插入后判断链表长度是否 >= 8,若是则调用treeifyBin()尝试转换为红黑树
5. 若步骤4中找到了相同key的节点,则返回旧value
6. 若步骤4中未找到相同key的节点,则size加1
7. 判断size是否 >= threshold,若是则调用resize()扩容
8. 返回null

七、get方法全流程对比

1. JDK1.7 get流程

  1. 若key为null,则遍历table[0]处的链表查找
  2. 否则计算key的哈希值和数组下标
  3. 遍历该下标处的链表,找到hash相等且key.equals()返回true的节点
  4. 返回节点的value,若未找到则返回null

2. JDK1.8+ get流程

  1. 计算key的哈希值和数组下标i
  2. 若table[i] == null,则返回null
  3. 若table[i]的key与待查找key相同,则直接返回该节点的value
  4. 若table[i]是TreeNode,则调用getTreeNode()在红黑树中查找
  5. 若table[i]是普通Node,则遍历链表查找
  6. 找到则返回value,否则返回null

八、红黑树转换与退化阈值

1. 链表转红黑树阈值

  • 链表长度阈值TREEIFY_THRESHOLD = 8
  • 数组容量阈值MIN_TREEIFY_CAPACITY = 64
  • 转换条件:当链表长度 >= 8 数组容量 >= 64时,才会将链表转换为红黑树
  • 为什么是8
    • 根据泊松分布,在负载因子0.75的情况下,链表长度达到8的概率约为0.00000006
    • 红黑树的节点占用空间比普通Node大,因此只有在链表足够长时才转换

2. 红黑树退化为链表阈值

  • 阈值UNTREEIFY_THRESHOLD = 6
  • 触发时机:扩容时红黑树拆分后,若节点数 <= 6,则退化为链表
  • 为什么是6
    • 避免频繁在链表和红黑树之间转换
    • 当节点数为6时,链表的平均查找长度(3.5)已经优于红黑树的平均查找长度(log2(6)≈2.6),但考虑到红黑树的额外开销,此时退化为链表更划算

九、其他重要区别

1. 插入方式

  • JDK1.7:头插法
    • 优点:实现简单,不需要遍历链表到尾部
    • 缺点:扩容时链表反转,并发时可能形成环形链表
  • JDK1.8+:尾插法
    • 优点:保持链表原顺序,避免并发扩容时的环形链表问题
    • 缺点:需要遍历链表到尾部才能插入

2. 遍历方式

  • JDK1.7:使用Iterator遍历,基于EntrySet
  • JDK1.8+:新增forEach方法,支持Lambda表达式

3. 性能

  • JDK1.7:最坏情况下O(n)
  • JDK1.8+:最坏情况下O(logn),在哈希冲突严重时性能提升明显

4. 并发问题

  • JDK1.7:死循环、数据丢失、数据覆盖
  • JDK1.8+:数据丢失、数据覆盖(解决了死循环问题)

十、常见面试考点

  1. HashMap的底层数据结构:1.7是数组+链表,1.8是数组+链表+红黑树
  2. HashMap的工作原理:通过key的hashCode计算哈希值,再通过哈希值计算数组下标,然后在对应位置的链表或红黑树中查找
  3. 为什么数组长度必须是2的幂:保证n-1的二进制全是1,使哈希结果均匀分布
  4. JDK1.8对HashMap做了哪些优化:引入红黑树、简化哈希函数、尾插法、扩容优化
  5. HashMap的扩容机制:触发条件、扩容大小、1.7和1.8的区别
  6. 红黑树的转换阈值:链表转红黑树是8,红黑树退化为链表是6
  7. HashMap为什么线程不安全:1.7的死循环,1.8的数据覆盖
  8. HashMap和HashTable的区别:线程安全、null键值、性能、父类
  9. HashMap和ConcurrentHashMap的区别:线程安全实现方式、性能、锁粒度

HashMap面试版:必背核心要点+高频题标准答案

一、面试必背核心要点(3分钟速记版)

1. 核心数据结构

  • JDK1.7:数组+单向链表(Entry数组)
  • JDK1.8+:数组+单向链表+红黑树(Node数组)
  • 核心解决:1.7链表过长导致O(n)性能退化,1.8红黑树将最坏时间复杂度降至O(logn)

2. 哈希函数

  • 1.7:9次扰动(4次位运算+5次异或)+ hashSeed随机种子,String特殊优化
  • 1.8:简化为2次扰动(h = key.hashCode() ^ (h >>> 16)),去掉hashSeed
  • 共同:null键哈希值固定为0,存于数组第0位;下标计算hash & (n-1)(n为数组长度)

3. 扩容机制(核心考点)

维度 JDK1.7 JDK1.8+
触发条件 size≥threshold 当前插入位置有元素 size≥threshold(无条件)
扩容大小 原容量×2(始终保持2的幂) 原容量×2
节点迁移 重新计算hash+头插法→链表反转 无需重算hash,hash & oldCap判断新位置+尾插法→保持原顺序
并发问题 头插法导致环形链表死循环 解决死循环,仍存在数据覆盖/丢失

4. 红黑树转换阈值(必考)

  • 链表转红黑树:链表长度≥8 数组容量≥64(缺一不可)
  • 红黑树退链表:扩容拆分后节点数≤6
  • 设计原因:泊松分布下链表长度达8的概率≈0.000006;6-8的缓冲区间避免频繁转换

5. put流程核心差异

  • 1.7:先判断扩容→再头插(扩容后插入新数组)
  • 1.8:先插入→再判断扩容(插入原数组后再扩容);插入后检查链表长度,≥8则尝试树化

6. 插入方式

  • 1.7:头插法(实现简单,无需遍历尾部)
  • 1.8:尾插法(解决扩容死循环,需遍历到尾部)

二、高频面试题标准答案(直接背诵)

1. HashMap的底层实现原理?

标准回答
HashMap基于哈希表实现,采用链地址法解决哈希冲突。

  • JDK1.7:底层是Entry数组+单向链表,通过key的hashCode计算数组下标,冲突时用链表存储。
  • JDK1.8+:底层是Node数组+单向链表+红黑树,当链表长度≥8且数组容量≥64时,链表转换为红黑树,将最坏查找时间复杂度从O(n)优化到O(logn)。

2. JDK1.8对HashMap做了哪些重大优化?

标准回答

  1. 数据结构优化:引入红黑树,解决链表过长的性能退化问题
  2. 哈希函数优化:简化为高16位异或低16位,减少计算开销
  3. 插入方式优化:头插法改尾插法,解决并发扩容时的环形链表死循环
  4. 扩容机制优化:无需重新计算每个节点的哈希值,通过hash & oldCap直接判断新位置(原下标或原下标+原容量)
  5. put流程优化:先插入后扩容,避免不必要的扩容操作

3. 为什么HashMap的数组长度必须是2的幂?

标准回答

  1. 提高计算效率:数组下标计算使用hash & (n-1),位运算远快于取模运算hash % n
  2. 保证哈希均匀分布:当n是2的幂时,n-1的二进制表示全为1,此时hash & (n-1)的结果能均匀分布在[0, n-1]范围内,减少哈希冲突
  3. 简化扩容计算:1.8中扩容时可通过hash & oldCap直接判断节点新位置,无需重新计算哈希值

4. HashMap的扩容过程是怎样的?1.7和1.8有什么区别?

标准回答

  • 共同:当元素个数size达到扩容阈值(容量×负载因子0.75)时,扩容为原容量的2倍
  • 1.7扩容
    1. 创建新数组,长度为原数组2倍
    2. 遍历原数组每个链表,对每个Entry重新计算哈希值和下标
    3. 用头插法将Entry插入新数组,导致链表顺序反转
    4. 多线程并发时易形成环形链表,引发死循环
  • 1.8扩容
    1. 创建新数组,长度为原数组2倍
    2. 遍历原数组每个节点,无需重新计算哈希值
    3. 通过hash & oldCap判断:结果为0则留在原下标,结果为1则移到原下标+原容量
    4. 用尾插法插入,保持链表原顺序,解决了死循环问题
    5. 红黑树拆分后节点数≤6时,退化为链表

5. 为什么链表转红黑树的阈值是8,退化为链表是6?

标准回答

  1. 阈值8的原因:根据泊松分布,在负载因子0.75的情况下,链表长度达到8的概率约为0.00000006,几乎不可能发生。因此只有在极端哈希冲突情况下才会转换为红黑树,平衡了时间和空间开销(红黑树节点占用空间比普通Node大)
  2. 阈值6的原因:设置6-8的缓冲区间,避免频繁在链表和红黑树之间转换。当节点数为6时,链表的平均查找长度(3.5)与红黑树的平均查找长度(log₂6≈2.6)差距不大,但红黑树有额外的空间和维护开销,此时退化为链表更划算

6. HashMap为什么线程不安全?1.7和1.8的并发问题有什么不同?

标准回答
HashMap没有任何同步机制,多线程并发操作时会出现数据不一致问题:

  • JDK1.7
    1. 并发扩容时,头插法会导致链表反转,形成环形链表,get操作时陷入死循环
    2. 并发插入时可能导致数据丢失
  • JDK1.8+
    1. 解决了环形链表死循环问题(尾插法)
    2. 并发插入时,若两个线程同时计算到同一个数组下标,后插入的线程会覆盖先插入的线程的数据
    3. 并发扩容时仍可能出现数据丢失

7. HashMap和HashTable的区别?

标准回答

维度 HashMap HashTable
线程安全 不安全 安全(方法加synchronized)
性能 低(全表锁,并发效率差)
null键值 允许1个null键,多个null值 不允许任何null键或null值
父类 AbstractMap Dictionary
迭代器 快速失败(fail-fast) 快速失败
初始容量 16 11
扩容方式 ×2 ×2+1

8. HashMap和ConcurrentHashMap的区别?

标准回答

  1. 线程安全:HashMap不安全;ConcurrentHashMap线程安全
  2. 锁机制
    • JDK1.7 ConcurrentHashMap:分段锁(Segment),锁粒度是段
    • JDK1.8+ ConcurrentHashMap:CAS+synchronized,锁粒度是数组头节点,性能更高
  3. 性能:HashMap单线程性能最高;ConcurrentHashMap并发性能远高于HashTable
  4. null键值:HashMap允许null键和null值;ConcurrentHashMap不允许任何null键或null值
  5. 数据结构:两者1.8后都是数组+链表+红黑树,但ConcurrentHashMap多了辅助扩容机制

9. HashMap的负载因子为什么是0.75?

标准回答
负载因子是容量和时间开销的平衡:

  • 负载因子过高(如1.0):数组填满才扩容,哈希冲突概率大幅增加,链表变长,查找性能下降
  • 负载因子过低(如0.5):数组只用一半就扩容,空间利用率低,频繁扩容影响性能
  • 0.75是经过大量测试得出的最优值,在空间利用率和查找性能之间取得了最佳平衡。同时0.75×16=12是整数,计算方便。

10. HashMap中key的hashCode()和equals()方法有什么作用?

标准回答

  1. hashCode():计算key的哈希值,用于确定元素在数组中的下标。如果两个key的hashCode不同,一定是不同的key
  2. equals():解决哈希冲突问题。如果两个key的hashCode相同(哈希冲突),需要通过equals()判断是否是同一个key
  3. 约定:如果两个对象通过equals()返回true,那么它们的hashCode()必须相同;但hashCode()相同,equals()不一定返回true
  4. 注意:如果重写了equals()方法,必须重写hashCode()方法,否则会导致HashMap无法正确工作

更多推荐