HashMap源码阅读

由于最近一直在准备面试(比较菜,准备的晚所以现在还在准备= =)系统的搞一下HashMap,毕竟这个东西还是经常被问到的,甚至会被怼到源码上,所以这两天花了点时间把HashMap的源码扒下来看了一下;

只捡了一些基础的地方(扒的不是很深),粘了一部分源码(大段代码预警),结合代码搞了一点自己的理解在里面,不一定100%精准,大神勿喷,欢迎指正,交流。

全手工码字,未经允许请勿转载。

一、HashMap简介

HashMap 是一个基于哈希表的 Map 接口的实现,以 键值对(Key-Value)的形式存在。

HashMap 可以看作一个 节点数组( Node [] table ),数组中的每一个位置可以看做是一个个的桶(bucket),桶中存放的是 哈希值相同的键值对 形成的 链表或者红黑树(JDK 1.8);

哈希值相同的键值对最初会以链表的形式被存在桶中,当 链表的长度达到一个阈值(TREEIFY_THRESHOLD = 8)的时候,链表会被转换为红黑树;

HashMap中比较重要的点大概有:

  • HashMap的内部实现
  • 容量(capacity)和负载因子(load factor)
  • 树化
  • 线程安全问题

二、HashMap的内部实现

1、HashMap支持 null 的键值

HashMap 支持键值为空(null);而另一种HashTable,(本身使用了synchronized关键字进行同步,线程安全),则不支持null的键值

HashMap本身支持 null的键的原因,是因为它在构造其 hash值的时候,如果发现键(key)是null,会默认将其设置为0

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

2、Node 数组

HashMap 的内部,是以一个 节点数组 Node [] table 的形式存在的,这个 Node 实际上是 HashMap的一个 静态内部类;HashMap中的键值对,实际上都会封装成这个Node对象存在;

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

    Node(int hash, K key, V value, Node next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }

    public final K getKey()        { return key; }
    public final V getValue()      { return value; }
    public final String toString() { return key + "=" + value; }

    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }

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

3、HashMap的构造方法

HashMap有四个构造方法,一个一个看

// HashMap最终被调用的构造函数
// 至于为什么是最终调用的,看下面的几个构造函数就知道了
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);
}

从这个构造函数中,我们会发现,在构造函数中并没有去创建任何的东西,只是对阈值和负载因子之类的一些数据进行了初始化


// 可以手动指定初始容量的HashMap构造函数

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

注意,HashMap的容量一定为2的倍数,如果参数中的初始容量不是,则会被tableSizeFor方法调整成大于这个参数,且最接近的2的幂(见第一个构造函数的第13行)

// tableSizeFor方法
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;
}


// 无参构造方法
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // 默认的负载因子,大小为0.75
}

无参构造方法会使用到默认的容量大小

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

其默认的大小为16


// 将现有的Map映射到新的 HashMap中
public HashMap(Map m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

putMapEntries()方法用于将 Map 插入到新的HashMap中,简而言之就是调用了HashMap的putVal()方法;

其中第二个参数,如果是false,表示这是一次初始化,如果是True,则和afterNodeInsertion()方法有关(这个方法是用于HashMap的子类 LinkedHashMap中供其继承的,用来回调移除最早放入Map中的元素)

4、HashMap的put和get方法

4.1 get方法

HashMap的get方法比较简单,就是通过一个Key,通过匿名内部类Node的getNode()方法来查找这个Node是否存在,存在就返回它的Value值

public V get(Object key) {
    Node e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

4.2 put方法

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

找到这个 put() 方法的定义可以发现,它的put方法实际上调用了另一个 putVal()方法

其中,调用putVal()方法的第一个参数,它其实并不完全是 Key 的 hashCode,而是 Key 的 hashCode 和 它右移16位取 异或 得到的值;

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

这里进行右移的操作,实际上是为了减少hash冲突的几率;int类型是4个字节的,右移16位异或可以同时保留 高于16位 和 低于16位 的特征;

通常,hashCode的差异主要存在于高位,因此需要保留高位的特征,这样才能减少哈希碰撞的几率;

使用 异或 的原因是,异或操作能够保留 高16位 和 低16位 的全部特征(高低位相同则为0,不同则为1);

如果使用 & 与 运算,则结果向全0靠拢,而使用 | 或 运算,则结果向全1靠拢,不能同时反映高位和低位的特征

言归正传,来看一下putVal()方法的代码,这个东西太长了,很多地方就直接以注释的形式写了理解;

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node[] tab; Node p; int n, i;
    
    // table 是 transient Node[] table; HashMap这个类的一个成员
    if ((tab = table) == null || (n = tab.length) == 0)
        // table为空,表示初始创建
        n = (tab = resize()).length;
    
    //如果索引为i的桶为空,表示当前桶中没有任何元素(链表为空),则创建第一个节点
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    
    else {  
        Node e; K k;
        
        // 如果桶的第一个元素恰好和插入的元素的hash和key都相同,即插入的元素恰好是桶的第一个元素,无需执行其他操作,直接到后面覆盖
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        
        // 如果p节点是TreeNode,表示已经发生过红黑树的转换,那么调用插入 树节点的方法
        else if (p instanceof TreeNode)
            e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
        
        // 否则是常规插入
        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;
                }
                
                // 如果在遍历的中途发现有 key 和 hash重复的情况,记录这个节点,后续对value进行覆盖操作
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                
                p = e;
            }
        }
        if (e != null) { 
            // existing mapping for key,存在相同key,则覆盖原有的value
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount; // 记录HashMap修改的次数
    // 判断是否需要扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

总结一下这个putVal()方法的一些特点:

  • 如果没有创建过HashMap,或者只是创建的HashMap什么都没有,则会调用扩容的resize()方法,这个方法既可以创建,也可以扩容

    if ((tab = table) == null || (n = tab.length) == 0)
        // table为空,表示初始创建
        n = (tab = resize()).length;
    
    if (++size > threshold)
        resize();
    
  • HashMap的桶(Node数组),数组的 i 的决定方式,不是简单的使用 hashCode

    i = (n - 1) & hash
    

    此处的 hash,在上面提到过,是通过 hash()这个方法,将 key 的 hashCode 和 它右移16位 进行异或运算得到的一个值(进行异或是为了减少哈希碰撞)

    至于为什么要和 容量 - 1 进行 与& 运算,是因为这个 hash的值很有可能大于它的容量n,因此执行&运算,将忽略超过容量的若干二进制位,保证最终计算得到的索引不会越界;

    为什么是 i = (n - 1) & hash

    事实上这个公式是HashMap必须保证容量为2的幂的原因之一,我们暂且不讨论为什么n是2的幂,考虑一下,当 n - 1 是奇数 或者 是偶数的时候会有什么情况:

    • 如果 n - 1 是个偶数,则会出现这样一种情况,计算得到的 i 一定为偶数(由于使用的位运算,而偶数的二进制末位一定为0,这个0 & 任何数 的结果都是0,因此结果必然不会出现奇数);这样的坏处很明显,索引为奇数的桶中将不会有任何元素,因此会产生空间的浪费和大量的哈希碰撞
    • 而如果 n - 1是奇数,那么计算得到的 i 既可能是奇数,也可能是偶数

    关于这个n为什么是2的幂,后面会再说到

4.3 resize()方法

resize()方法用于HashMap的扩容(同时也具有创建HashMap的能力,在这里也提一下);同样的,这个代码很长,也以注释的形式写一下理解;

final Node[] resize() {
    Node[] 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;
            return oldTab;
        }
        // 进行扩容,将容量左移1位,相当于x2
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               
        // zero initial threshold signifies using defaults
        // 最开始创建,oldCap为0的时候执行
        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(触发扩容的阈值) 设置为2倍
    threshold = newThr;
    
    @SuppressWarnings({"rawtypes","unchecked"})
    
    // 创建一个新的 大小为原先的2被的 Node 数组
    Node[] newTab = (Node[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node e;
            if ((e = oldTab[j]) != null) {  // e为原先每一个桶中的链表头
                // 把原先的桶置空
                oldTab[j] = null;
                
                // 如果原先的桶中只有一个元素,则直接将这个链表头放到新的位置上
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                
                // 如果获取到的e是树节点,就按照红黑树的方式处理
                else if (e instanceof TreeNode)
                    ((TreeNode)e).split(this, newTab, j, oldCap);
                
                
                else { // preserve order
                    // 链表的拆分
                    Node loHead = null, loTail = null;
                    Node hiHead = null, hiTail = null;
                    Node 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;
                    }
                }
            }
        }
    }
    return newTab;
}

resize()方法中,需要特别说明一下这个链表拆分方法(第一次读源码的时候属实没搞懂它这个链表拆分是什么意思…)

else { // preserve order
    // 链表的拆分
    Node loHead = null, loTail = null;
    Node hiHead = null, hiTail = null;
    Node next;
    do {
        next = e.next;
        // 检索 原先的节点是否换了新的桶
        // 如果(e.hash & oldCap) == 0) ,表示扩容之后不会进入新桶,这样的节点,形成一条链表
        if ((e.hash & oldCap) == 0) {
            if (loTail == null)
                loHead = e;
            else
                loTail.next = e;
            loTail = e;
        }
        
        // 如果 (e.hash & oldCap) != 0),表明经过扩容之后,再计算索引会落入一个新的桶,这样的节点,形成另一条链表
        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;
    }
}

当发生扩容的时候,假设容量为8的HashMap中有两个节点,这个两个节点的 hash 分别为 01010 和 00010,那么根据 桶索引的计算 i = (n - 1) & hash:

0111 & 01010 = 0010 = 2,即,在扩容之前,这个节点会落到 i = 2 这个桶中

0111 & 00010 = 0010 = 2,显然,这个节点在扩容之前也会落在 i = 2 这个桶中

此时发生了扩容,此时 n = 16, oldCap = 8,我们再来看代码中(e.hash & oldCap) == 0这一行的执行结果:

1000 & 01010 = 1000 = 8

1000 & 00010 = 0000 = 0

这个现象表明了,第一个节点之所以最终落到了 i = 2 这个桶中,是因为一开始的容量不够,将其高位第二位的1给忽略了,再来看一下,此时发生扩容以后的索引的计算

0 1111 & 0 1010 = 0 1010 = 10

0 1111 & 0 0010 = 0 0010 = 2

可以发现,第一个节点确实会落到其他的桶中,这就说明 :

  • (e.hash & oldCap) == 0这个是否等于 0 的判断,足以说明节点是否会落在原来的桶中
  • 并且,由于一次扩容就是二进制左移1位,在这个情况下,至多会影响像第一个节点这样,第4位是1的节点,换言之,如果一个节点可能落到其他桶中,那么这个节点一定是第4位是1,它能落到的新桶的索引,必然是 旧索引+8
  • 如果是从 8 扩容到 16,则 落到新桶的节点的新索引,一定是 旧索引+16,以此类推

这样一来,再看上面的代码就好理解了,扩容之后,它要么落到原来的桶中,要么落到一个新桶中,只要使用这样的判断将 原来的链表拆分成两个链表,然后将这两个链表的头放到对应的桶中,当我们遍历完所有的桶的时候,扩容操作也就完成了。

三、HashMap的 容量 和 负载因子

容量 和 负载因子 是 HashMap中重要的部分,它们共同决定了可用的桶的数量,如果空桶太多,则会浪费空间,如果桶的利用率过高则会严重影响操作性能;

1、负载因子的选择

负载因子主要用于HashMap的扩容操作,扩容阈值 = 容量 * 负载因子;当HashMap的节点数组大小超过扩容阈值的时候,HashMap就会发生扩容;

显然,由此看来,负载因子的选择是比较重要的,频繁的扩容 或是 不进行扩容都会影响性能;

关于这个桶的利用率过高(负载因子设置得比较接近于1)导致影响操作性能这个问题,我看了一些博客,说法大致上来说就是,如果负载因子设置的太大,会使得查找效率降低,比较含糊。

后面看到了一篇,大概的意思是,如果设置负载因子为1,就会增大哈希碰撞的概率,一旦发生了很严重的哈希碰撞,那么它的底层将会形成结构复杂的红黑树,红黑树虽然查询效率会高,但是如果节点过多,结果过于复杂,这个复杂的查找操作成本 会高于 我们将HashMap扩容,从而将产生哈希碰撞的节点分散到新的桶中的操作消耗;

另一篇博客用了比较数学的方法,通过泊松分布来分析,得到的结论是,大概在0.7左右的时候,时间和空间的消耗将比较平衡,是一种比较均衡的选择;

而如果将负载因子设置的过小(比如0.5),那么哈希碰撞的概率会被降低,但是会造成比较严重的空间浪费(才用了一半就要扩容,并且还是翻倍)

因此,对于负载因子:

  • 如果没有特殊的需求,不要轻易的对负载因子进行修改,JDK默认的负载因子具有很好的通用性,适合大量的场景;
  • 如果需要调整,尽量不要超过0.75,这样会显著增加冲突,降低性能
  • 如果负载因子需要调小,则需要实现设定好初始容量,避免频繁进行扩容

2、HashMap的容量问题

HashMap经典问题之——为什么HashMap的容量一定是2的幂?

上面提到了,HashMap的节点进入桶的索引的计算方式:i = (n - 1) & hash

那么为什么是 n - 1? n又为什么一定是2的幂呢?

首先,在我们计算这个索引 i 的时候,我们实际上可以这样做:i = hash % n,但是这样的方法效率并不高;而相对的,&运算属于位运算,至少它比 % 取模要快;并且我们发现

(n - 1) & hash = hash % n

并且它使用效率更高的位运算,这就是使用 n - 1 的原因;

其次为什么 n 必须是2的幂,这个是由于,2的幂的二进制的形式,必然是 1 后面有若干个全0,而我们需要的 n - 1 就恰好是 0 后面有若干个全1;

这种形式的好处就在于,最终计算得到的索引值是均匀的,可以减少哈希碰撞

在前面提到了一个 hash的计算,是这样的

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

(h = key.hashCode()) ^ (h >>> 16)之所以要将 hashCode右移16位取异或运算,原因在上面讲过了,由于hashCode的差异通常在高位,因此这个操作可以将高低16位的特征都保留下来,放在了它的低位上,从而减少哈希冲突;

而在计算索引的时候,n - 1 使得计算出的索引只取决于 hash它的x位低位值(x是几取决于容量需要用几个二进制位来表示)

简而言之,我们保证它的容量是2的幂,就是为了要这若干个1,有了这若干个1,才可以让索引计算的结果是均匀的,最终才能避免哈希碰撞,举一个例子:

hash (n-1)& hash 结果
0 1111 & 0 0
1 1111 & 1 1
2 1111 & 10 2
3 1111 & 11 3
4 1111 & 100 4
5 1111 & 101 5
…… …… ……
16 1111 & 10000 0
17 1111 & 10001 1
18 1111 & 10010 2

我的理解是,当hash是均匀的时候,最终计算得到的索引也恰好是均匀的,如果(n - 1)不全是1,那么即使 hash是均匀的,得到的结果也是不均匀的;

四、HashMap的树化

HashMap的树化有一点需要特别注意,虽然进行树化的阈值被设置为了8,但是还有另外一个条件:

  • 如果容量小于 MIN_TREEIFY_CAPACITY,只会进行简单的扩容。
  • 如果容量大于 MIN_TREEIFY_CAPACITY,这个时候,达到树化阈值才会进行树的改造

MIN_TREEIFY_CAPACITY 的值为 64,因此不是当链表长度大于8就一定会发生树化,数组容量必须先达到64,否则只是会进行扩容,而非树化!

在数组容量条件已经满足的情况下,继续讨论链表长度的问题:

当链表的长度过长之后,存取性能都会急剧下降,并且,如果有人通过构造会产生哈希碰撞的恶意代码对系统进行攻击,链表会导致CPU的大量占用,这会造成哈希碰撞拒绝服务攻击,因此在链表过长的时候需要对其进行树化,将链表改造成红黑树;

1、为什么节点数达到8的时候需要进行树化

这一点在HashMap的源码中有一段注释,将的是这个问题

/*
     * Because TreeNodes are about twice the size of regular nodes, we
     * use them only when bins contain enough nodes to warrant use
     * (see TREEIFY_THRESHOLD). And when they become too small (due to
     * removal or resizing) they are converted back to plain bins.  In
     * usages with well-distributed user hashCodes, tree bins are
     * rarely used.  Ideally, under random hashCodes, the frequency of
     * nodes in bins follows a Poisson distribution
     * (http://en.wikipedia.org/wiki/Poisson_distribution) with a
     * parameter of about 0.5 on average for the default resizing
     * threshold of 0.75, although with a large variance because of
     * resizing granularity. Ignoring variance, the expected
     * occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
     * factorial(k)). The first values are:
     *
     * 0:    0.60653066
     * 1:    0.30326533
     * 2:    0.07581633
     * 3:    0.01263606
     * 4:    0.00157952
     * 5:    0.00015795
     * 6:    0.00001316
     * 7:    0.00000094
     * 8:    0.00000006
     * more: less than 1 in ten million

全英文不好看,强行翻译一下:

由于树形节点的大小大约是常规节点的两倍,所以我们只有当容器包含足够多的节点时才使用它们(见TREEIFY_THRESHOLD)。当它们变得太小(由于移除或调整大小)它们被转换回普通的bin。在使用分布良好的用户哈希码,树bin是很少使用。理想情况下,在随机哈希码下,bin中的节点频率遵循泊松分布

参数为默认大小调整的平均值约为0.5

阈值为0.75,虽然有较大的方差,因为调整粒度。忽略方差,期望列表大小k的出现次数为(exp(-0.5) pow(0.5, k) /阶乘(k))。第一个值是:

0: 0.60653066

1: 0.30326533

2: 0.07581633

3: 0.01263606

4: 0.00157952

5: 0.00015795

6: 0.00001316

7: 0.00000094

8: 0.00000006

more: less than 1 in ten million

大概意思是说,在随机的hash码的情况下,出现哈希碰撞从而产生节点数超过8的链表的可能性非常的低,比8更多的情况几乎小于千万分之1;

并且,红黑树的平均查找长度为 log(n),当 n = 8 的时候,使用红黑树的平均查找长度为 3;而链表的平均查找长度为 n / 2,当 n = 8 的时候,链表的平均查找长度为 4,因此在 n = 8 的时候,使用红黑树的效率更高,这个时候转换成红黑树需要的性能消耗将显得没有那么重要。

有关红黑树转换回链表:

在HashMap的源码中,我们可以找到

static final int UNTREEIFY_THRESHOLD = 6;

如果我们由于删除操作,将一个桶中的红黑树节点删除到了6个的时候,就会把红黑树改造成链表。

至于为什么不是7,可以把7理解成一个缓冲量,如果在7的时候就进行树的链表改造,那么万一刚刚改造成链表,就又插入了一个元素,就会造成刚改回来的链表又执行转换为红黑树的操作,有可能在 链表 < -- > 红黑树 之间进行反复横跳,会造成性能的浪费;

你可能感兴趣的:(HashMap源码阅读)