HashMap底层

2026-01-08 09:21

HashMap Java 集合框架中用于存储键值对(key-value pairs)的核心数据结构,它基于哈希表实现,旨在提供高效的数据存取能力。‌

在使用HashMap的时候是否有想过以下问题:

1. HashMap 的数据结构是怎样的?

2. 如何解决哈希冲突?

3. 当存在哈希冲突时数据是怎么存取的?

下文中将依次解决以上疑问。

一、HashMap底层数据结构是怎样的

在JDK7及以前 HashMap 底层数据结构是:数组+链表,从JDK8开始 HashMap 底层数据结构是:数组+链表+红黑树,这种结构结合了数组的快速随机访问、链表的冲突处理和红黑树在极端情况下的性能保证。下面基于JDK8 分别解释每种结构的作用和为什么需要它们。

1. 整体架构图

HashMap 结构:
┌─────────────────────────────────────────────────────┐
│                 Node<K,V>[] table                    │
│  (数组,每个元素称为一个"桶"bucket)                   │
├──────────┬──────────┬──────────┬──────────┬──────────┤
│ index=0  │ index=1  │ index=2  │   ...    │ index=n-1│
├──────────┼──────────┼──────────┼──────────┼──────────┤
│   null   │ 链表/树  │  Node    │   ...    │   null   │
│          │ (冲突时) │          │          │          │
└──────────┴──────────┴──────────┴──────────┴──────────┘

 

2. 为什么使用数组

核心目的:快速查找,数组的查询速度(O(1)时间复杂度),数组优势如下:

2.1. 内存连续:CPU缓存友好,预读效率高

2.2.寻址快速:通过索引直接计算内存偏移量

2.3.空间局部性:相邻元素可能被连续访问

// 数组的快速随机访问特性
int index = (n - 1) & hash(key);  // O(1)操作
Node<K,V> node = table[index];    // 直接内存地址访问

相对于链表查询需要O(n)和红黑树查找需要O(log n)相比,数组的查询更快。

3. 为什么用到链表

核心目的:解决哈希冲突(碰撞)

// 当两个不同的key计算到同一个桶时
int hash1 = hash("key1");  // 假设结果为5
int hash2 = hash("key2");  // 假设结果也为5
// 它们都会进入table[5],形成链表:
// table[5] → Node("key1",v1) → Node("key2",v2)

链表的设计考量:

3.1.冲突概率低时的效率高:

统计学原理:Java默认负载因子0.75,当元素数量 < 容量*0.75时,冲突概率较低,链表节点少(通常0-3个),遍历成本可忽略

3.2.内存效率: 链表节点结构简单

class Node {
    int hash;      // 4字节
    K key;         // 引用8字节
    V value;       // 引用8字节
    Node next;     // 引用8字节
    // 总共约28字节+对象头
}

3.3.插入删除高效:链表头插法:O(1)时间复杂度

Node newNode = new Node(hash, key, value, first);
table[index] = newNode;  // 直接替换头节点

3.4.适合少量冲突性能更优:链表在元素≤8时,性能优于红黑树(常量因子更小)

4. 为什么用到红黑树

核心目的:防止极端情况下的性能退化,当大量key都哈希冲突到同一个桶时,链表会退化为O(n)查找,性能较低,而红黑树保证O(log n)查找速度。

红黑树和链表查询复杂度对比:

 链表:查找O(n),插入O(n),删除O(n)

红黑树:查找O(log n),插入O(log n),删除O(log n)

当n=100万时:链表需要100万次比较,红黑树只需20次

和链表相比,红黑树是使用空间换时间的设计,但保证了性能的下线。

// 树节点内存更大
class TreeNode extends Node {
    TreeNode parent;  // +8字节
    TreeNode left;    // +8字节
    TreeNode right;   // +8字节
    TreeNode prev;    // +8字节(用于退化成链表)
    boolean red;      // +1字节(对齐后+8字节)
    // 总共约60+字节,比链表节点大2倍
}

链表和红黑树转换的阈值设计:

链表→树:阈值8(泊松分布计算得出),JDK 8+的进一步优化:链表长度≥8 且数组长度≥64才树化

static final int TREEIFY_THRESHOLD = 8;

树→链表:阈值6(避免频繁转换的"抖动")

static final int UNTREEIFY_THRESHOLD = 6;

4. 为什么不只使用一种数据结构或使用其他数据结构

数据结构为什么不采用HashMap的选择优势
纯数组冲突无法解决数组+链表/树解决冲突
纯链表查找O(n),易被攻击(DOS)数组提供O(1)定位
纯红黑树内存开销大,简单场景浪费链表处理常见情况,树处理极端情况
跳表SkipList内存开销更大(多级指针)红黑树更平衡,JDK内部已有实现
AVL树旋转操作更频繁,插入删除稍慢红黑树插入删除更快,仍保持近似平衡

DOS攻击:一般指拒绝服务攻击,拒绝服务攻击即是攻击者想办法让目标机器停止提供服务。

二、哈希冲突解决基本原理及数据存储

1. 哈希冲突解决基本原理

HashMap通过链表(JDK 8之前)或链表+红黑树(JDK 8及以后)来解决哈希冲突。当多个key的哈希值映射到同一个数组索引(桶)时,这些key-value对会以链表或树的形式存储在该位置。

2. 查找值的过程

步骤一:计算哈希值定位桶

// 1. 计算key的哈希值(经过扰动函数处理)
int hash = hash(key);

// 2. 定位到数组中的索引位置
int index = (n - 1) & hash;  // n是数组长度

步骤二:在桶内查找

// 伪代码表示查找逻辑
public V get(Object key) {
    // 1. 计算哈希值
    int hash = hash(key);
    
    // 2. 找到对应的桶(数组元素)
    Node<K,V> first = table[index];
    
    // 3. 遍历链表/树查找
    if (first != null) {
        // 检查第一个节点
        if (first.hash == hash && 
            (first.key == key || (key != null && key.equals(first.key)))) {
            return first.value;
        }
        
        // 继续遍历后续节点
        Node<K,V> e = first.next;
        while (e != null) {
            if (e.hash == hash && 
                (e.key == key || (key != null && key.equals(e.key)))) {
                return e.value;
            }
            e = e.next;
        }
    }
    return null;
}

步骤三:根据key进行比较获取值

HashMap通过两个条件来确定是否找到正确的key:

(1)哈希值相等:比较计算出的哈希值是否相同

(2)key相等:通过以下条件判断:

  • 内存地址相同(==)
  • 或者通过equals()方法比较内容相等
// 实际的比较逻辑
if (node.hash == hash && 
    ((k = node.key) == key || (key != null && key.equals(k)))) {
    // 找到匹配的key
}

三、JDK HashMap 源码解析:哈希冲突处理

1. HashMap 的核心数据结构

// HashMap 类中的关键字段
public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
    
    // 存储节点的数组(桶数组)
    transient Node<K,V>[] table;
    
    // 默认初始容量
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
    
    // 链表转换为红黑树的阈值
    static final int TREEIFY_THRESHOLD = 8;
    
    // 红黑树退化为链表的阈值
    static final int UNTREEIFY_THRESHOLD = 6;
    
    // 树化的最小容量
    static final int MIN_TREEIFY_CAPACITY = 64;
    
    // 节点类(链表节点)
    static class Node<K,V> implements Map.Entry<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;
        }
    }
    
    // 树节点类(红黑树节点)
    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;           // 红色标志
    }
}

2. hash() 方法:扰动函数

// 计算 key 的哈希值(扰动函数)
static final int hash(Object key) {
    int h;
    // 1. 获取 key 的 hashCode()
    // 2. 将高16位与低16位异或,增加低位的随机性
    //    目的:减少哈希冲突
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

// 示例:
// key.hashCode() = 1111 1111 1111 1111 0000 0000 0000 0001
// h >>> 16       = 0000 0000 0000 0000 1111 1111 1111 1111
// hash = h ^ (h >>> 16) = 1111 1111 1111 1111 1111 1111 1111 1110

3. get() 方法入口

// 公开的 get 方法
public V get(Object key) {
    Node<K,V> e;
    // 调用 getNode 方法
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

// 核心查找方法
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab;          // 引用数组
    Node<K,V> first, e;       // first: 桶的第一个节点
    int n;                    // 数组长度
    K k;                      // 临时存储 key
    
    // 步骤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))))     // equals 相等
            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);
        }
    }
    return null;  // 未找到
}

4. 红黑树查找方法

// TreeNode 的 getTreeNode 方法
final TreeNode<K,V> getTreeNode(int h, Object k) {
    // 从根节点开始查找
    return ((parent != null) ? root() : this).find(h, k, null);
}

// 在红黑树中查找节点
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
    TreeNode<K,V> p = this;  // 从当前节点开始
    
    do {
        int ph, dir;
        K pk;
        TreeNode<K,V> pl = p.left, pr = p.right, q;
        
        // 步骤1: 比较哈希值
        if ((ph = p.hash) > h)
            p = pl;  // 向左子树查找
        else if (ph < h)
            p = pr;  // 向右子树查找
        else if ((pk = p.key) == k || (k != null && k.equals(pk)))
            return p;  // 找到匹配的节点
        
        // 步骤2: 哈希值相等但key不相等,检查子树
        else if (pl == null)
            p = pr;  // 左子树为空,查找右子树
        else if (pr == null)
            p = pl;  // 右子树为空,查找左子树
        
        // 步骤3: 比较 key 的类型和哈希值
        else if ((kc != null ||
                  (kc = comparableClassFor(k)) != null) &&
                 (dir = compareComparables(kc, k, pk)) != 0)
            p = (dir < 0) ? pl : pr;  // 使用比较器决定方向
        
        // 步骤4: 递归在右子树中查找
        else if ((q = pr.find(h, k, kc)) != null)
            return q;
        else
            p = pl;  // 在左子树中查找
    } while (p != null);
    
    return null;  // 未找到
}

5. put() 方法中的冲突处理

// put 方法的实现(简化版)
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: 如果数组为空,初始化
    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 {
        // 步骤3: 桶不为空,发生哈希冲突
        Node<K,V> e;  // 临时节点
        K k;
        
        // 情况3.1: 第一个节点就匹配
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        
        // 情况3.2: 节点是树节点
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        
        // 情况3.3: 链表遍历
        else {
            for (int binCount = 0; ; ++binCount) {
                // 到达链表尾部
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    
                    // 检查是否需要树化
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                
                // 找到匹配的节点
                if (e.hash == hash &&
                    ((k = e.key) == key || 
                     (key != null && key.equals(k))))
                    break;
                
                p = e;  // 继续下一个节点
            }
        }
        
        // 步骤4: 如果找到已存在的key,更新值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    
    // 步骤5: 修改计数和扩容检查
    ++modCount;
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

6. 索引计算详解

// 如何计算桶索引
int hash = hash(key);  // 经过扰动函数的哈希值
int index = (n - 1) & hash;  // n是数组长度,必须是2的幂

// 示例:
// hash = 1100 1100 1100 1100 0011 0011 0011 0011
// n = 16 (0000 0000 0000 0000 0000 0000 0001 0000)
// n-1 = 15 (0000 0000 0000 0000 0000 0000 0000 1111)
// index = (n-1) & hash = 0000 0000 0000 0000 0000 0000 0000 0011 = 3

为什么 n 必须是2的幂? 因为当 n 是2的幂时,(n-1) 的二进制是全部为1的形式。例如:n=16, n-1=15 (1111),与运算相当于取模运算 hash % n,但位运算(&)比取模运算(%)快得多。

 

 

  • 上一条:没有了
  • 下一条:没有了
相关新闻
精选内容