JDK 1.8 HashMap 源码解析

1. HashMap 类定义:

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

其中哈希表(Hash table)table 的定义:

static final int MAXIMUM_CAPACITY = 1 << 30;
由注释知 table的长度是2的N次方,每次扩容变为原来的两倍。所以这是 table.length 数组的最大长度。

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
table 默认大小为16,可通过构造函数改变

static final int TREEIFY_THRESHOLD = 8;
链表转换成红黑树的阈值

static final int UNTREEIFY_THRESHOLD = 6;
红黑树转换回链表的阈值

static final int MIN_TREEIFY_CAPACITY = 64;
当table的长度 < 64 时,就算某个桶中的链表超过8,也不转换为红黑树,而是进行 resize() 操作。

Node类的实例变量:

static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;  // 记录K的 Hash值
    final K key;     // K 不可变
    V value;         // V 可变
    Node<K,V> next;  // 采用拉链法处理冲突,指向下一个Node对象

可以看出使用拉链法处理Hash冲突。
在这里插入图片描述
图片来源网络,这里的Entry应该改为Node。

2. put 操作

在这里插入图片描述
其中的 hash() 函数当 key 为 null 时返回 0 ,即 key 为 null 的键值对直接存储在 table[0] 桶中。对于key不是null的键值对,取 ( h = key.hashCode()) ^ (h >>> 16) 为Hash值。 相当于前16位不变,后16位与前16位做异或,以此为hash值。

>>>无符号右移,忽略符号位,空位都以0补齐。
^ 是按位异或

在这里插入图片描述

关键的putVal()函数
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) // 是否已经初始化table
        n = (tab = resize()).length;  //  初始化table.
    if ((p = tab[i = (n - 1) & hash]) == null) // 计算hash值对应的table 桶
    // (n - 1) & hash  等价于  (hash % n)
        tab[i] = newNode(hash, key, value, null);  
        // 如果此桶还没有元素,则创建新Node节点并直接指向它。
    else {  // 如果此桶已有元素。
        Node<K,V> e; K k;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            // 如果此位置指向的第一个 Node 的 key 与 put 的 key 相同
            e = p;
        else if (p instanceof TreeNode)  // TreeNode 是 Node 的子类
        // 如果已经超过阈值,由链表转化成了红黑树。
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            // 向树中插入新节点
        else { // 还是链表,并且第一个Node的key和put的key不匹配
            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);
                        // 遍历链表到结尾,没有发现相同的key,要创建新节点时,
                        // 如果发现链表长度超过阈值,则转化为红黑树。
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    // 对比时,先对比hash值,如果两对象相同,hash值肯定相同,不相同则直接下一个。
                    // 如果hash相同,再对比key指针是否指向同一个对象,如果key不相同,则使用 equals()
                    break;
                p = e;
            }
        }
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null) 
            // 如果允许更新或原来的value为null,默认允许改变value
                e.value = value; // 更新value值,并非创建新节点
            afterNodeAccess(e); // 实现为空
            return oldValue;
        }
    }
    ++modCount;  // 记录结构修改次数,实现 fail-fast 迭代器。
    if (++size > threshold)  // 每次插入新节点就增加 size,超过 threshold 则 resize()
        resize();
    afterNodeInsertion(evict);  // 实现为空
    return null;
}

继承自Node的TreeNode类型,充当红黑树的节点。
在这里插入图片描述
treeifyBin()是把链表转换为红黑树的函数,可以看出,这里只转换长度超出阈值的那个链表,而不是把table上的都转换成红黑树。
在这里插入图片描述

  • 其中的 resize() 函数用来 Initializes or doubles table size.,可见每次扩容长度变成原来的两倍
  • (n - 1) & hash 等价于 (hash % n) ,位运算效率更快。 其中 ntable 的长度,是2的x次方,所以此位运算成立。
  • key 为 null 的 hash 值规定为 0。
  • TreeNode 是 Node 的子类,当此链表长度超过 static final int TREEIFY_THRESHOLD 时,将Node类型链表转换为节点类型为TreeNode的红黑树。反之,如果在resize()时发现新哈希位置上的红黑树节点数量少于 static final int UNTREEIFY_THRESHOLD 时,将红黑树转换为链表。
  • 当table的长度 < MIN_TREEIFY_CAPACITY = 64 时,就算某个桶中的链表超过8,也不转换为红黑树,而是进行resize() 扩容操作。
  • JDK 1.7 中采用头插法并且不能转化为红黑树。JDK 1.8 中采用尾插法并且长度超过阈值时转换为红黑树。
  • HashMap 的迭代器是 fail-fast 迭代器。

3. 扩容

设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此平均查找次数的复杂度为 O(N/M)。

为了让查找的成本降低,应该尽可能使得 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证。

如果table.length已经为最大值MAXIMUM_CAPACITY = 1 << 30,则不能再扩容,则把 threshold 改为 Integer.MAX_VALUE,不再考虑装载因子,如果持续插入,当 size 超过 Integer.MAX_VALUE 时会变为负值。

和扩容相关的参数主要有:capacity、size、threshold 和 load_factor。

  • capacity() table 的长度,默认为 16,table != null 时等于 table.length。需要注意的是 capacity 必须保证为 2 的 n 次方。
  • transient int size map 中 key-value 对的个数。
  • int threshold size 的临界值,size 必须小于 threshold,如果大于等于,就必须进行扩容操作。
  • final float loadFactor 装载因子,table 能够使用的比例,threshold = capacity * loadFactorDEFAULT_LOAD_FACTOR 默认为 0.75f
  • 可以通过构造函数自定义 初始的table长度 和 loadFactor。
关键的 resize() 函数
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            //  如果table不能再变长,则改threshold为int最大整数,不再考虑装载因子。
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
            // table还能变大,则 capacity 和 threshold 都乘2
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    // 创建新的 table 引用数组
    table = newTab;
    if (oldTab != null) {
    //  转移原 table 上的 Node 到新的 table
        for (int j = 0; j < oldCap; ++j) {
            // 遍历原table数组
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                    //  如果此桶只有一个Node节点,直接重新插入到新table
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    // 如果已经是红黑树
                else { // preserve order
                // 非树类型,桶内节点不止一个,节点在新桶中会保持顺序
                    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) {
                        // 看旧capacity的位置是否为0
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                        // 如果旧capacity的位置为1
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                        // 还在新table原位置
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                        // 在新table [原结果 + oldCap] 这个位置
                    }
                }
            }
        }
    }
    return newTab;
}
在进行扩容时,需要把键值对重新放到对应的桶上。HashMap 使用了一个特殊的机制,可以降低重新计算桶下标的操作。假设原数组长度 capacity 为 16,扩容之后 new capacity 为 32,Key在新数组上的位置只有两种可能:
  • 它的哈希值如果在第 5 位上为 0 (即旧capacity的位置,网上有博客写是第六位,即新capacity的位置,这是不对的),那么取模得到的结果和之前一样,即还在原位置
  • 如果为 1,那么得到的结果为原来的结果 +16,即在 [原位置 + oldCap] 这个位置。
table 长度是2的N次方的好处
  • 当table扩容,计算Node新位置时,可以用位运算 e.hash & oldCap 加速,值为0则还在[原位置],值不为0则在[原位置 + oldCap]。
  • 计算hash值对应的桶时,可用位运算 (n - 1) & hash 加速 (hash % n)。(因为n是2的N次方才成立)
其他一些细节
  • 在链表中对比时,先对比hash值(如果两对象相同,hash值肯定相同),不相同则直接下一个。如果hash相同,再对比key指针是否指向同一个对象,如果key不相同,最后使用 equals() 判断。
  • JDK 1.8 时,插入key-values时如果发现链表长度超过阈值(8),则转化为红黑树。
  • 当执行 resize() 操作时,如果某桶中是红黑树会执行 split() 把红黑树分割成两个树,如果某个树的节点个数小于 UNTREEIFY_THRESHOLD,则会执行 untreeify() 把该树转换回链表。
当构造HashMap时,传入的 initialCapacity 不是2的N次方怎么处理

HashMap 的构造函数:

public HashMap(int initialCapacity, float loadFactor) {
    ... ... 
    this.threshold = tableSizeFor(initialCapacity); 
}

其中 tableSizeFor()函数返回大于initialCapacity的最大二进制数。
比如 cap 为 0010 0100,则为 n 为 0011 1111,最后返回 n + 1,即为 0100 0000

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;
}
使用 modCount 实现 fail-fast 迭代器

和其他容器类一样,使用modCount 记录结构被修改的次数,以此实现 fail-fast (快速失败)迭代器。

参考 -> Java 集合类(容器类)概览

4. 序列化

这里不序列化 table 表、modCount 等参数。

    private void writeObject(java.io.ObjectOutputStream s)
        throws IOException {
        int buckets = capacity();
        // Write out the threshold, loadfactor, and any hidden stuff
        s.defaultWriteObject();  // ignore transient variable
        s.writeInt(buckets);  // the number of buckets
        s.writeInt(size);  // the number of key-value mappings 
        internalWriteEntries(s); // all key-value mappings
    }

    void internalWriteEntries(java.io.ObjectOutputStream s) throws IOException {
        Node<K,V>[] tab;
        if (size > 0 && (tab = table) != null) {
            for (int i = 0; i < tab.length; ++i) {
                for (Node<K,V> e = tab[i]; e != null; e = e.next) {
                    s.writeObject(e.key);
                    s.writeObject(e.value);
                }
            }
        }
    }

5. 与 JDK 1.7 的比较

  • 1.7 头插法可能命中率会更高,类似LRU。但线程不安全,在多个线程执行resize()扩容时,容易形成环。1.8 采用尾插法,则不会。
  • JDK 1.7 中采用头插法并且不能转化为红黑树。JDK 1.8 中采用尾插法并且长度超过阈值时转换为红黑树。
  • rehash 时 1.7 直接用位运算 (n - 1) & hash 加速 (hash % n) 并且头插,在新桶中不保持原有顺序。 1.8 看 (e.hash & oldCap) 是否为 0,尾插,在新桶中会保持顺序
  • hash() 函数的实现不同,1.7有随机生成的 hashSeed ,1.8 的hash() 函数更简单,没有 hashSeed。
为什么HashMap线程不安全
  • 因为所有函数没有加同步, size++ 操作就不安全。
  • 1.7 时在多个线程执行resize() -> transfer()扩容时,容易形成环,如下所述。
    // JDK 1.7 !!
    /**
     * Transfers all entries from current table to newTable.
     */
    void transfer(Entry[] newTable, boolean rehash) {
    // 两个线程同时执行此函数,可能会形成环
        int newCapacity = newTable.length; 
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];  // 可以看出是头插法
                newTable[i] = e;
                e = next;
            }
        }
    }

table[i] 是不变的,所以线程二执行时,e == A
在这里插入图片描述

6. 与 HashTable 的比较

  • HashTable 使用 synchronized 来进行同步(线程安全)。
  • JDK 5提供了ConcurrentHashMap,它是HashTable替代,使用分段锁实现线程安全,比HashTable的性能更好。
  • HashMap 可以插入key为 null 的 Node,HashTableConcurrentHashMap 插入 key 为 null 时会抛空指针异常
  • HashMapHashTable 的迭代器均为 fail-fast 迭代器,ConcurrentHashMap 没有 fail-fast 迭代器。
  • HashMap 不能保证随着时间的推移 Map 中的元素次序是不变的。
  • Hashtablekey.hashCode() & 0x7FFFFFFF 为Hash方式,HashMap( h = key.hashCode()) ^ (h >>> 16) 为Hash方式。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值