HashMap源码分析

数据结构

HashMap数据结构
此图为JDK1.8以后,HashMap的数据结构图。JDK1.7以前(包含),HashMap里只有链表,在JDK1.8引入了红黑树,当数组长度大于等于64并且链表的长度大于等于8之后,会将链表转为红黑树。红黑树是一个自平衡的二叉查找树,查找效率会从链表的O(n)降为O(logn),效率有非常大的提高。

这里有一个疑问,为什么不一开始就存红黑树呢?

  • 红黑树的数据结构相较于链表来说比较复杂,需要维持红黑树的性质,在插入时需要左旋右旋等,整体效率来说,可能还比链表慢。
  • 当Map中的节点达到一定程度时,会执行resize扩容,在resize时,会对红黑树进行拆分重新哈希,并在新的位置上进行重组,这也是比链表耗时的一个过程,所以设置一定的阈值是有必要的。

源码分析

1. 类结构

HashMap类结构图

2. 类注释

  • 允许NULL值,NULL键
  • 不要轻易改变负载因子,负载因子过高会导致链表过长,查找键值对时间复杂度就会增高,负载因子过低会导致hash桶的数量过多,空间复杂度会增高
  • Hash表每次会扩容长度为以前的2倍
  • HashMap是多线程不安全的,在 JDK1.7 进行多线程put操作,可能会造成链表闭环,导致CPU飙到100%,会出现节点或value丢失的情况,而在 JDK1.8 中进行多线程操作只会出现节点和value值丢失,为什么 JDK1.8 多线程操作不会出现链表闭环呢?是因为 JDK1.8 的作者对resize方法进行了优化,不会产生链表闭环。
  • 尽量设置HashMap的初始容量,尤其在数据量大的时候,防止多次resize

3. 类常量

//默认hash桶初始长度16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 

//hash表最大容量2的30次幂
static final int MAXIMUM_CAPACITY = 1 << 30;

//默认负载因子 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//链表的数量大于等于8个并且桶的数量大于等于64时链表树化 
static final int TREEIFY_THRESHOLD = 8;

//hash表某个节点链表的数量小于等于6时树拆分
static final int UNTREEIFY_THRESHOLD = 6;

//树化时最小桶的数量
static final int MIN_TREEIFY_CAPACITY = 64;

4. 实例变量

//hash表,在第一次使用时才初始化,长度是2的整数幂
transient Node<K,V>[] table;                         

//键值对的数量
transient int size;

//HashMap结构修改的次数
transient int modCount;

//扩容的阈值,当键值对的数量超过这个阀值会产生扩容
int threshold;

//负载因子,默认为 DEFAULT_LOAD_FACTOR
final float loadFactor;

5.构造函数

/**
 * 使用该构造函数,会初始化两个变量:loadFactor、threshold
 * threshold会通过传入的initialCapacity参数在tableSizeFor方法中计算得出。
 * 而table在resize()方法中进行初始化,首次长度为threshold
 * tatble初始化后,会将该threshold变为table.length * loadFactor。
 * 这也是为什么这里threshold没有 * loadFactor的原因。具体逻辑可以看resize()方法分析
 */
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

hash表没有在构造函数中进行初始化,而是在第一次put操作时进行初始化和resize。

构造函数中有一个方法:tableSizeFor(initialCapacity),这个方法的作用是用你传入的initialCapacity进行计算,返回一个大于等于initialCapacity & 最小的2的整数幂值。所以这个操作保证了无论你传入的initialCapacity为多少,hash表的初始长度都是2的整数幂次方。比如initialCapacity = 6,则计算出来的hash表长度为8.

/**
 * Returns a power of two size for the given target capacity.
 */
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;
}

6. 插入方法——put()

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    // 当table为空时,初始化table(不是通过构造函数初始化,而是在插入时通过扩容初始化,有效防止了初始化HashMap没有数据插入造成空间浪费,可能造成内存泄露的情况)
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
        
    // 如果table的目标位置没有元素,则直接放入一个新建的node,否则进入else代码块
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
        Node<K,V> e; K k;
        // table的第一个节点的key = 传入的key,则将该节点复制给e(不需要进入红黑树和链表里找)
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 如果table的第一个节点是TreeNode,则进入红黑树的查找,没有则新增,有则返回旧节点并赋值给e
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 不是上面两种情况,则现在是链表,直接遍历链表,如果不存在则在队尾新增节点,如果存在则将该节点赋值给e
        else {
            for (int binCount = 0; ; ++binCount) {
                // 在队尾新增节点
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    
                    // 如果链表的长度 >= 8 && table数组的长度 >= 64(在treeifyBin方法中判断) 将链表树化
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 如果链表中存在则跳出循环,e的值已在上一个if中赋值了
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        
        // 如果在当前map中存在旧的key,则将该节点的值赋值为新值,并返回旧值
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);  // 开放接口,给LinkedHashMap使用
            return oldValue;
        }
    }
    
    // map的调整次数+1
    ++modCount;
    
    // 键值对的数量达到阈值时扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);  // 开放接口,给LinkedHashMap使用
    return null;
}

流程

(1)如果table没有初始化,则初始化table。
(2)是新的键值对
    - 如果table中相应的位置没有节点,则该键值对作为头结点;
    - 如果是链表则放在队尾,如果达到一定条件,将链表转为红黑树;
    - 如果是红黑树,则进行相应的插入操作。
(3)如果已经有相应的键存在,则更新键值对的值,并返回旧值。

然后我们再看看hash方法,它是如何找寻table中相对应的位置

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

从方法得知,HashMap通过将key的hashCode无符号右移16位后再与hashCode异或得到这个key在HashMap中的hash值,然后再在putVal()方法中通过 (n - 1) & hash 计算,得到table的索引位置。这里延伸出两个问题。

(1)为什么要两次hash,而不直接使用hashCode?

首先,通常HashMap的table的长度一般都很小,即n一般都很小,而hashCode的值是int类型,有32位,需要当n的值大于2的16次方+1时,才会使用到hashCode的高16位。通常来说,HashMap的n的值大于2的16次方的情况是很少的,即那么就会出现下面这种情况。

我们假设n当前的大小为:2^16
当hashCode = 2^16 - 1时,(n - 1) & hashCode = 2^16 - 1
当hashCode = 2^17 - 1时,(n - 1) & hashCode = 2^16 - 1
当hashCode = 2^18 - 1时,(n - 1) & hashCode = 2^16 - 1
当hashCode = 2^19 - 1时,(n - 1) & hashCode = 2^16 - 1
如果不知道怎么算出来的,自己换成二进制计算哈

通过上面的例子可以看出,当hashCode的低16位相同时,不管hashCode的高16位是多少,都会使这些键值对指向同一个table的索引,照常hash冲突,从而导致链表的长度变长或者红黑树的深度变深。而通过二次hash之后,将hashCode的高16位参与后面的与运算,尽可能的让key均匀的分布在table的数组上,避免造成Hash堆积

(2)为什么不用 hash % n?

其实 hash % nhash & (n - 1) 的结果是等价的,但是&位运算的效率比%高,因为&运算是使用二进制直接运算,而计算机天生就认识二进制。所以使用hash & (n - 1)效率更高。

7. 扩容机制——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;
    
    // 1. 这里计算出新的table的长度与threshold的值
    // 第一种情况,已经跑了一段时间后,需要扩容,即oldCap > 0,oldThr > 0
    if (oldCap > 0) {
        // 如果table的长度已经达到最大值,直接返回
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 否则newCap = 2 * oldCap,当oldCap >= 16时
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 第二种情况,使用有initialCapacity参数的构造函数 & 第一次put数据,此时oldCap = 0,oldThr > 0
    else if (oldThr > 0)
        // 将旧的threshold赋值给newCap,这种情况newThr = 0,newThr在后面进行赋值
        newCap = oldThr;
    // 第三种情况,使用空参构造方法 & 第一次put数据,此时时oldCap = 0,oldThr = 0
    else {
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    
    // 在上面第二种情况的时候,newThr = 0,这时候需要去给newThr赋值
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    
    // 更新threshold的值为newThr
    threshold = newThr;
    
    // 新建一个table
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    
    // 如果就的table不为空,将旧的table上的键值对重新映射到新table上
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                // 如果该桶上只有一个元素,则直接将节点放入目标桶中
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                    
                // 该桶是一个红黑树,需要进行树拆分然后映射
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                
                // 该桶是链表,将原链表拆分为两个链表,两个链表的索引位置,一个为原索引,一个为原索引加上旧Hash桶长度的偏移量,下面会进行说明(这也是JDK1.8的优化,不会造成闭环)
                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) {
                            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;
                    }
                    // 如果高位链表不为空,赋值到“原索引+旧table长度”的索引上
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

扩容的时间点

  • 第一次put操作
  • HashMap的size大于threshold,threshold = size * loadFactor

流程

(1)计算新table的容量与阈值,初始化新table
(2)如果旧table上有数据,则将旧table上的键值对映射到新table上

链表在映射时的巧妙之处

table的长度总会是2的整数幂,而每次扩容,新table的长度总为旧table长度的2倍,根据这种现象,作者实现了一种非常巧妙的算法,他将新的table分为2半,高位与低位,然后计算 hash & oldCap,如果为0则放在低位,为1则放在高位,具体算法可以看上面的逻辑,那他为什么可以这么做呢?我们来看下面这张图
在这里插入图片描述

而这种做法,大大提高了链表在迁移过程中的效率,因为不需要再一个节点一个节点进行rehash。

8. JDK1.7造成链表闭环原因

为什么JDK1.7的HashMap在多线程环境下,会出现闭环,导致CPU跑满,使用率飙到100%呢?我们先来看看JDK1.7中HashMap的resize代码。

void resize(int newCapacity) {  
    Entry[] oldTable = table;
    // 获取旧table的长度——capacity
    int oldCapacity = oldTable.length;
    // 如果旧table的capacity已经达到了最大,则修改阈值,直接返回,不进行扩容  
    if (oldCapacity == MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;  
        return;  
    }  
    // 用新的capacity创建一个新的table数组
    Entry[] newTable = new Entry[newCapacity];  
    // 将旧table中的键值对迁移到新table中,initHashSeedAsNeeded方法用于判断是否需要进行rehash
    transfer(newTable, initHashSeedAsNeeded(newCapacity));
    // 将信的table数组赋值给HashMap对象实例变量中,供外界使用
    table = newTable;
    // 修改下一次扩容所需的阈值
    threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}

// 将旧table中的键值对迁移到新table中
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;
            // 如果需要rehash,则重新计算当前节点的hash值
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            
            // 定位当前节点在新table中的索引
            int i = indexFor(e.hash, newCapacity);
            // 将当前元素的next指针,指向新表中目标索引的链表的头结点,然后将当前节点放到目标索引位置,相当于头插法
            e.next = newTable[i];
            newTable[i] = e;
            
            // 继续下一个元素
            e = next;
        }  
    }  
}

// 根据Hash值和table的大小定位索引  
static int indexFor(int h, int length) {  
    return h & (length-1);  
}  

这个扩容的过程这里不细说,在单线程模式下,扩容前与扩容后的结果是这样的。
在这里插入图片描述

而造成闭环的原因也是因为扩容,发生在transfer方法在转移节点的时候,在下面这段代码中。

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

我们来看看是怎么造成闭环的。

在这里插入图片描述

这张图可以很好的说明是怎么产生闭环的,具体过程,大家可以跟着代码进行模拟。

在JDK1.8中为什么不会产生闭环呢?
首先,改进后的resize方法在迁移过程中,使用了高位与地位两个链表对原链表进行拆分,而且使用的是尾插法,这种方式,始终不会造成在转移前后链表倒置,更不会因为多线程扩容而导致闭环。虽然HashMap解决了闭环问题,但是HashMap还是多线程环境下不安全的,数据丢失与覆盖的问题,仍然存在。

9. 清除方法——clear()

public void clear() {
    Node<K,V>[] tab;
    modCount++;
    if ((tab = table) != null && size > 0) {
        size = 0;
        for (int i = 0; i < tab.length; ++i)
            tab[i] = null;
    }
}

HashMap其实这段代码特别简单,为什么贴出来呢?到底是clear好还是新建一 个HashMap好?我认为clear()比新建一个HashMap好。下面从空间复杂度和时间复杂度来解释一下。

从时间角度来看,这个循环是非常简单无复杂逻辑,并不十分耗资源。而新建一个HashMap,首先他在在堆内存中年轻代中查看是否有足够空间能够存储,如果能够存储,那么创建顺利完成,但如果HashMap非常大,年轻代很难有足够的空间存储,如果老年代中有足够空间存储这个HashMap,那么jvm会将HashMap直接存储在老年代中,如果老年代中空间不够,这时候会触发一次minor gc,会产生小规模的gc停顿,如果发生minor gc之后仍不能存储HashMap,那么会发生整个堆的gc,也就是full gc,这个gc停顿是很恐怖的。实际上的gc顺序就是这样的,并且可能发生多次minor gc和full gc,如果发现年轻代和老年代均不能存储HashMap,那么就会触发OOM,而clear()是肯定不会触发OOM的,所以数据里特别大的情况下,千万不要创建一个新的HashMap代替clear()方法。

从空间角度看,原HashMap虽然不用,如果数据未被清空,是不可能被jvm回收的,因为HashMap是强引用类型的,从而造成内存泄漏。所以综上所述我是不建议新建一个HashMap代替clear()的,并且很多源码中clear()方法很常用,这就是最好的证明。


版权声明:本文为blacksnow_原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。