详解JDK7和8的HashMap的实现方式不同

144 阅读8分钟

HashMap 在不同的 JDK 版本下的实现是不同的,在 JDK 7 时,HashMap 底层是通过数组 + 链表实现的;而在 JDK 8 时,HashMap 底层是通过数组 + 链表或红黑树实现的。

JDK7

image.png

内部结构

HashMap 类内部有一个数组 table,它存储着所有的 Entry(JDK 7 中是 Node)对象,Node 中包含了键、值、哈希值、以及指向下一个节点的指针。哈希冲突会导致多个 Node 对象存在于同一个数组索引位置。

transient Node<K,V>[] table;

在 JDK 7 中,Node 类的定义如下:

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;
    }

}
  • hash: 是当前节点的哈希值,基于 key 计算得出。
  • key: 存储当前节点的键。
  • value: 存储当前节点的值。
  • next: 用于指向同一哈希桶中下一个节点,形成链表结构。

插入操作

插入元素时,HashMap 会根据键的哈希值计算出数组的索引位置,然后将元素插入到该位置。如果该位置已经有元素(即发生哈希冲突),则通过链表的形式将新元素追加到现有链表的末尾。

public V put(K key, V value) {
    // 计算键的哈希值
    int hash = hash(key);
    int i = indexFor(hash, table.length);

    // 遍历链表,如果键相同则更新值,否则添加新节点
    for (Node<K,V> e = table[i]; e != null; e = e.next) {
        if (e.hash == hash && (e.key == key || (key != null && key.equals(e.key)))) {
            V oldValue = e.value;
            e.value = value;
            return oldValue;
        }
    }
    
    // 如果链表没有该键,则插入新节点
    modCount++;
    addEntry(hash, key, value, i);
    return null;

}

put() 方法中,首先通过 hash() 计算键的哈希值,然后通过 indexFor(hash, table.length) 计算出数组索引 i。如果当前位置已经有元素(即链表),就遍历链表判断是否有相同的键。如果有相同的键,则更新值;如果没有,则在链表末尾添加新节点。

计算哈希值和数组索引

final int hash(Object key) {
    int h = key.hashCode();
    return h ^ (h >>> 16);
}

hash() 方法是用来计算键的哈希值的,首先通过 key.hashCode() 获取哈希值,然后进行异或运算 h ^ (h >>> 16),这是为了降低哈希冲突的概率。

然后通过 indexFor() 方法将哈希值映射到数组索引上:

static final int indexFor(int h, int length) {
    return h & (length - 1);
}

indexFor() 通过与数组长度减一(即 length - 1)做位与操作,得到数组的索引。

添加节点到数组

如果该位置没有元素,直接通过 addEntry() 方法添加新节点。

void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }
    createEntry(hash, key, value, bucketIndex);
}

void createEntry(int hash, K key, V value, int bucketIndex) {
    Node<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Node<>(hash, key, value, e);
    size++;
}

createEntry() 方法会创建一个新的 Node 节点,并将其插入到数组指定索引位置的链表头部。

查找操作

查找操作也会基于键的哈希值计算出数组索引,然后通过链表遍历来寻找指定的键。

public V get(Object key) {
    if (size == 0) {
        return null;
    }

    int hash = hash(key);
    int i = indexFor(hash, table.length);
    
    // 遍历链表,查找键值对
    for (Node<K,V> e = table[i]; e != null; e = e.next) {
        if (e.hash == hash && (e.key == key || (key != null && key.equals(e.key)))) {
            return e.value;
        }
    }
    
    return null;

}

get() 方法中,首先根据键的哈希值找到对应的数组索引,然后遍历链表,检查每个节点的键是否与传入的 key 相同。

扩容机制

当哈希表中的元素个数超过阈值(threshold)时,HashMap 会进行扩容,通常是将数组的大小扩大为原来的两倍,重新计算每个元素的数组索引并插入。

void resize(int newCapacity) {
    Node<K,V>[] oldTable = table;
    int oldCapacity = oldTable.length;

    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }
    
    Node<K,V>[] newTable = new Node[newCapacity];
    transfer(newTable);
    table = newTable;
    threshold = (int) (newCapacity * LOAD_FACTOR);

}

resize() 方法会将当前 table 数组的大小扩大为 newCapacity,并将所有现有的节点重新分配到新的数组位置。

总结

  • 数组 + 链表HashMap 使用一个数组来存储 Node,每个 Node 保存一个键值对。当多个键映射到相同的数组位置时,这些键值对通过链表存储。
  • 哈希值计算:通过 key.hashCode() 来计算哈希值,并使用 indexFor 方法将其映射到数组索引上。
  • 冲突处理:哈希冲突通过链表来解决,新的节点被插入到链表头部。
  • 扩容机制:当元素数量超过阈值时,HashMap 会扩容并重新分配元素。

JDK8

image.png

在 JDK 1.8 中,HashMap 底层的实现机制在 JDK 7 的基础上做了优化,特别是在处理哈希冲突时引入了 红黑树,以提高链表长度过长时的性能。具体来说,当一个哈希桶中链表的长度超过一定阈值时,HashMap 会将链表转换为红黑树,从而避免性能退化为 O(n)。

基本结构

在 JDK 1.8 中,HashMap 内部使用一个数组 table 来存储哈希桶,每个桶可能包含一个链表或一个红黑树。每个桶存储的是一个 Node(JDK 7 中是 Entry,JDK 8 中是 Node)对象。

transient Node<K,V>[] table;

在 JDK8 中,Node 类的定义如下:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;

    // 红黑树特有的字段
    TreeNode<K,V> parent; // 红黑树的父节点
    int height;           // 红黑树的高度(用来判断树的深度)
    
    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }

}
  • hash: 当前节点的哈希值。
  • keyvalue: 当前节点存储的键值对。
  • next: 链表中下一个节点的指针。
  • parent: 红黑树节点的父节点(如果是红黑树节点)。
  • height: 红黑树节点的高度。

插入操作

put() 方法负责将新的键值对插入到 HashMap 中。首先根据键的哈希值计算出索引,如果该索引处有其他元素,则判断当前桶是链表还是红黑树,并根据不同的情况进行插入。

public V put(K key, V value) {
    if (key == null) {
        return putForNullKey(value);
    }

    int hash = hash(key);
    int i = indexFor(hash, table.length);
    Node<K,V> e = table[i];
    
    if (e == null) {
        table[i] = new Node<>(hash, key, value, null);
        size++;
        return null;
    }
    
    if (e.hash == hash && (e.key == key || key.equals(e.key))) {
        V oldValue = e.value;
        e.value = value;
        return oldValue;
    }
    
    if (e instanceof TreeNode) {
        TreeNode<K,V> p = (TreeNode<K,V>)e;
        if (p.putTreeVal(hash, key, value)) {
            return null;
        }
    }
    
    // 插入链表
    for (Node<K,V> q = e; q != null; q = q.next) {
        if (q.hash == hash && (q.key == key || key.equals(q.key))) {
            V oldValue = q.value;
            q.value = value;
            return oldValue;
        }
    }
    
    // 如果链表过长,转化为红黑树
    table[i] = newNode(hash, key, value, e);
    size++;
    if (size > threshold) {
        resize();
    }
    return null;

}

put() 方法中,首先根据 hash()indexFor() 计算出桶的索引,然后判断桶中是否存在元素。

  • 如果桶为空,则直接插入新节点。
  • 如果桶中已经存在元素,检查键是否相同,若相同则更新值并返回。
  • 如果桶中的元素是 TreeNode,则将其作为红黑树节点处理,调用 putTreeVal() 方法将新节点插入红黑树中。
  • 如果桶中的元素是链表,则遍历链表插入新节点。

putTreeVal() 方法

当链表长度超过阈值时,putTreeVal() 方法会将链表转换为红黑树,优化查找和插入性能。

final boolean putTreeVal(int hash, K key, V value) {
    if (parent != null) {
        throw new IllegalStateException("Should not put value in treeNode");
    }
    

    TreeNode<K,V> root = (TreeNode<K,V>)this;
    if (root == null) {
        this.parent = null;
        this.red = false;
    }
    
    // 红黑树插入算法
    TreeNode<K,V> p = root.putTreeNode(hash, key, value);
    if (p != null) {
        return true;
    }
    
    return false;

}

查找操作

get() 方法用于查找一个键对应的值。在 JDK 1.8 中,首先根据哈希值找到对应的桶,然后判断该桶的元素是链表还是红黑树。如果是链表,则遍历链表;如果是红黑树,则在树中查找。

public V get(Object key) {
    if (key == null) {
        return getForNullKey();
    }

    int hash = hash(key);
    int i = indexFor(hash, table.length);
    Node<K,V> e = table[i];
    
    if (e == null) {
        return null;
    }
    
    if (e.hash == hash && (e.key == key || key.equals(e.key))) {
        return e.value;
    }
    
    // 如果是红黑树
    if (e instanceof TreeNode) {
        return ((TreeNode<K,V>)e).getTreeNode(hash, key);
    }
    
    // 否则,遍历链表
    for (Node<K,V> p = e; p != null; p = p.next) {
        if (p.hash == hash && (p.key == key || key.equals(p.key))) {
            return p.value;
        }
    }
    return null;

}

如果桶中是 TreeNode,则调用红黑树的查找方法 getTreeNode()

如果桶中是链表,则遍历链表查找对应的键。

红黑树的转换和调整

当链表长度超过一定阈值时(默认为 8),HashMap 会将链表转换为红黑树,避免查找性能退化为 O(n)。

void treeifyBin(Node<K,V>[] tab, int index) {
    Node<K,V> b = table[index];
    if (b instanceof TreeNode) {
        return;
    }

    TreeNode<K,V> hd = null, tl = null;
    for (Node<K,V> e = b; e != null; e = e.next) {
        TreeNode<K,V> p = new TreeNode<>(e.hash, e.key, e.value, null, null);
        if (tl == null) {
            hd = p;
        } else {
            tl.next = p;
        }
        tl = p;
    }
    
    table[index] = hd;

}

treeifyBin() 方法会将链表转换为红黑树,它首先创建一个链表上的每个节点的红黑树节点(TreeNode),然后将它们组成红黑树。

扩容机制

HashMap 中的元素数量超过阈值时,数组会扩容。扩容时,所有元素会重新计算哈希值并放入新的数组中。

void resize() {
    Node<K,V>[] oldTable = table;
    int oldCapacity = oldTable.length;
    if (oldCapacity >= MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return;
    }

    int newCapacity = oldCapacity << 1;
    Node<K,V>[] newTable = new Node[newCapacity];
    transfer(newTable);
    table = newTable;
    threshold = (int)(newCapacity * loadFactor);

}

在扩容时,resize() 会将哈希表的容量加倍,并将所有元素重新分配到新的表中。

总结

在 JDK 1.8 中,HashMap 的底层实现使用 数组 + 链表 + 红黑树 结构,关键点如下:

  1. 链表:当桶中元素较少时,使用链表来解决哈希冲突。
  2. 红黑树:当链表长度超过阈值时,链表会转化为红黑树,以提高查找、插入和删除操作的效率。
  3. 扩容机制:当元素数量超过阈值时,哈希表会扩容,并将所有元素重新分配到新的哈希桶中。

小结

HashMap 在 JDK 1.7 时,是通过数组 + 链表实现的,而在 JDK 1.8 时,HashMap 是通过数组 + 链表或红黑树实现的。在 JDK 1.8 之后,如果链表的数量大于阈值(默认为 8),并且数组长度大于 64 时,为了查询效率会将链表升级为红黑树,但当红黑树的节点小于等于 6 时,为了节省内存空间会将红黑树退化为链表。

欢迎关注公众号:“全栈开发指南针”

这里是技术潮流的风向标,也是你代码旅程的导航仪!🚀

Let’s code and have fun! 🎉