Java源码阅读——HashMap

本文详细探讨了HashMap和HashTable的区别,包括线程安全性、空值处理、容量与负载因子、链表与红黑树转换策略。HashMap在处理冲突时采用链地址法,当链表长度达到一定阈值时会转为红黑树以提高查找效率。扩容时,HashMap容量必须为2的幂,以优化计算元素位置和扩容操作。此外,文章还对比了HashMap在多线程环境下的优势以及与JDK7的差异。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

HashMap和HashTable的区别:

  1. 不是线程安全的。HashTable中每个方法加上了synchronized来保证线程安全
  2. key和value允许空值(null值),HashTable不允许

一、类变量

  • DEFAULT_INITIAL_CAPACITY = 16,初始容量,可以在构造方法中指定。容量指哈希表中bin的个数,而不是保存元素的个数
  • MAXIMUM_CAPACITY = 2 30 2^{30} 230,最大容量
  • DEFAULT_LOAD_FACTOR = 0.75,负载因子,可以在构造方法中指定。当有0.75 * capacity个bin中存有元素时,需要扩容
  • TREEIFY_THRESHOLD = 8。当一个bin中存的元素数量>=8时,将链表转化为红黑树。根据泊松分布,这种情况出现的概率为亿分之六。
  • UNTREEIFY_THRESHOLD = 6。当一个bin中存储元素 <= 6时,从红黑树转为链表
  • MIN_TREEIFY_CAPACITY = 64。当整个hashmap中存储的元素数量>=64时,才允许将链表转为红黑树,否则先使用hashmap扩容来减低hash冲突

二、链表和红黑树

HashMap处理hash冲突的方法是链地址法,每个bin存储的是一个链表的首节点。每个节点定义为

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;
		...
        public final boolean equals(Object o) {
            if (o == this)
                return true;
            if (o instanceof Map.Entry) {
                Map.Entry<?,?> e = (Map.Entry<?,?>)o;
                if (Objects.equals(key, e.getKey()) &&
                    Objects.equals(value, e.getValue()))
                    return true;
            }
            return false;
        }
    }

除链表节点外,还定义了TreeNode作为红黑树的节点,什么时候转红黑树,什么时候转回链表,都有红黑树的插入删除操作控制

 static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;
    TreeNode<K,V> right;
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;
    ...
}

与jdk7的区别: jdk7仅使用链表存储hash冲突的元素,jdk8中会在链表长度 >= 8且hash表容量 >= 64时将链表转为红黑树,保证查找的效率。
为什么要求hash表容量 >= 64?
hash表容量较小时有更大的可能进行扩容,扩容后又要重新移动数据,使用红黑树开销更大。

三、hash值、表容量与扩容

key的hashCode的高16位与低16位异或运算,得到hash值。
为什么不直接使用hashCode作为hash值: 因为hash表长度多数时都比较短,导致之后计算这一元素放在哪一个bin中时只会用到hash值的低几位。因此用hashCode高位进行扰动,减少hash冲突

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

bin的位置的计算在putVal()方法中:

if ((p = tab[i = (n - 1) & hash]) == null)

本质上是hash值对hash表长度取模,但由于规定了hash表长度为2的幂,因此可以使用hash值与(hash表长度 - 1)进行与运算。假设hash表长度 l = 00...01 ( 00...00 ) i − 1 l = 00...01(00...00)_{i-1} l=00...01(00...00)i1。一个数 h h h l l l取模,结果为 h h h的第 i i i位及其之前的位全为0,后面的位保持不变。这一结果等于 h h h 00...0 ( 11..1 ) i − 1 00...0(11..1)_{i-1} 00...0(11..1)i1进行与运算,而 00...0 ( 11..1 ) i − 1 = l − 1 00...0(11..1)_{i-1} = l-1 00...0(11..1)i1=l1

构造方法中可以指定初始容量,不论指定数值为多少,都会调用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;
    }

五个或运算是为了将n的最高位后面全都变为1,即 00...011...1 00...011...1 00...011...1的形式,最后再+1得到2的整数幂,当指定的cap已经是2的整数幂时,这么做会将结果变为输入的2倍,因此第一步先将cap - 1。

resize()方法用于扩容,每次扩容会将hash表的容量扩大为原来的2倍,扩大为2倍的操作也使用位运算来完成。至此,可以看出hash表容量一定为2的整数幂的原因:

  1. 计算元素应放置的位置时可以使用位运算代替取模运算
  2. 扩容时可以使用位运算代替乘法运算

与jdk7的区别: jdk7中HashMap的容量保证为素数,目的是较少hash冲突

    final Node<K,V>[] resize() {
    	...
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        ...

在重新计算hash表容量并分配数组空间之后,需要对hash表中每个元素重新计算位置并插入。由于每次扩容都是原来容量的2倍,因此元素的在新表中的位置只有两种情况:

  1. 与原来的位置相同
  2. 变为原来位置+旧表长度

假设原来旧表长度 - 1为 00...0 ( 11...1 ) i 00...0(11...1)_{i} 00...0(11...1)i,则新表长度 - 1为 00...0 ( 11...1 ) i + 1 00...0(11...1)_{i+1} 00...0(11...1)i+1。若元素hash值的第 i i i位为0,则两次与运算的结果相同。若元素hash值的第 i i i位为1,则与运算后最高位多了一个1,相当于加上了旧表长度。

因此,在迁移元素时将旧表中一个bin的链表拆分为两个,分别加入到新表中的对应位置。

for (int j = 0; j < oldCap; ++j) {
    Node<K,V> e;
    if ((e = oldTab[j]) != null) {
        oldTab[j] = null;
        if (e.next == null)   // 这个位置只有1个元素
            newTab[e.hash & (newCap - 1)] = e;
        else if (e instanceof TreeNode)   // 这个位置的元素已经转化为红黑树
            ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
        else { // 拆分链表
            Node<K,V> loHead = null, loTail = null;   // 第一个链表的头尾
            Node<K,V> hiHead = null, hiTail = null;   // 第二个链表
            Node<K,V> next;
            do {
                next = e.next;
                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;
            }
        }
    }
}

与jdk7的区别: jdk7中扩容同样是扩大为原来的2倍,但迁移数据只是遍历数据、重新计算哈希值、头插法形成链表。这样的缺点是:1. 重新计算位置效率低。 2. 多线程环境下使用头插法可能形成环,之后元素再插入时找不到链表尾形成死循环

四、增删改查

数据插入实际上调用的是putVal()方法,先插入数据,再判断是否需要扩容。插入时分为三种情况处理:

  1. 这个位置现在为空,直接插入,作为链表头节点
  2. 已经有相同key的元素了,只需要更新
  3. 这个位置上的数据已经转为红黑树,按照红黑树的插入进行操作
  4. 这个位置是链表,找到链表尾节点,在之后插入新元素。在寻找尾节点的同时,计算链表长度,若当前长度 >= 7(插入新元素后将变为8),则转化为红黑树(按照前面说的,并不是必定转化,而是判断hash表长度是否 >= 64)。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
     Node<K,V>[] tab; Node<K,V> p; int n, i;
     if ((tab = table) == null || (n = tab.length) == 0)
         n = (tab = resize()).length;
     // 这个位置没有元素
     if ((p = tab[i = (n - 1) & hash]) == null)
         tab[i] = newNode(hash, key, value, null);
     else {
         Node<K,V> e; K k;
         // 情况2,直接修改
         if (p.hash == hash &&
             ((k = p.key) == key || (key != null && key.equals(k))))
             e = p;
         // 情况3,红黑树插入
         else if (p instanceof TreeNode)
             e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
         else {
         	 // binCount记录链表长度
             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;
             }
         }  
     }
     ...
 }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值