HashMap 源码分析(JDK1.8)

最近在学习并发容器 ConcurrentHashMap,所以就先从 HashMap 开始了解。

前言

普及一下后面需要用到的一些知识:

  1. HashMap底层是由 数组+链表/红黑树 实现的;
  2. 这些数组就相当于哈希表;
  3. 哈希表简单理解:
    由对象的 hashCode 通过 hash 函数处理得到 hash 值,再处理 hash值 得到数组下标直接存储(时间复杂度为 O(1));
  4. HashMap hash函数的处理方式:
    用对象的 hashCode 高16位 和 低16位 作异或混合运算;
  5. 处理 hash值得到数组下标的方式:
    用 hash值对数组容量取模,得到数组下标;
  6. HashMap 大体数据结构示意图
    HashMap 源码分析(JDK1.8)_第1张图片

1.重要属性

/**
     * The default initial capacity - MUST be a power of two.
     * 默认数组容量为 16
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    /**
     * The maximum capacity, used if a higher value is implicitly specified
     * by either of the constructors with arguments.
     * MUST be a power of two <= 1<<30.
     * 最大数组容量
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;

    /**
     * The load factor used when none specified in constructor.
     * 默认负载因子 0.75
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /**
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     * 某个桶的链表结点个数大于等于 8 时,链表转为红黑树
     */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     * The bin count threshold for untreeifying a (split) bin during a
     * resize operation. Should be less than TREEIFY_THRESHOLD, and at
     * most 6 to mesh with shrinkage detection under removal.
     * 某个桶的红黑树结点个数小于等于 6时,红黑树转为链表
     */
    static final int UNTREEIFY_THRESHOLD = 6;

    /**
     * The smallest table capacity for which bins may be treeified.
     * (Otherwise the table is resized if too many nodes in a bin.)
     * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
     * between resizing and treeification thresholds.
     * 在链表转红黑树之前,需要满足数组结点个数至少为 64,为了避免进行扩容、树形化选择的冲突
     */
    static final int MIN_TREEIFY_CAPACITY = 64;

    /**
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     * (We also tolerate length zero in some operations to allow
     * bootstrapping mechanics that are currently not needed.)
     * 存放结点数组,数组大小必须为 2的幂
     */
    transient Node<K,V>[] table;

    /**
     * The next size value at which to resize (capacity * load factor).
     * 如果数组结点个数 size > threshold,数组就需要扩容
     */
    int threshold;

    /**
     * 根据泊松分布得到
     * 用于与数组容量相乘计算的数组阈值
     */
    final float loadFactor;

2.重要内部类

2.1 Node

Node是最核心的内部类,它封装了 key-value 键值对,所有插入 HashMap 的数据都封装在这个对象里。

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

        Node(int hash, K key, V value, Node<K,V> 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;
        }
    }

2.2 TreeNode

红黑树结点,当链表长度过长的时候,会将 Node 转换为 TreeNode。这个类大概写了500多行代码比较复杂,这里就不着重分析,简单说下类的成员变量。

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;    // 将原单链表变为双向链表的前置指针
        boolean red; 		   // 判断结点颜色 红/黑
        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }
}

想深入学习 HashMap 红黑树的可以参考:史上最详细的HashMap红黑树解析

这里先贴一个 TreeNode 的继承类图:
HashMap 源码分析(JDK1.8)_第2张图片

大家考虑一个问题:为什么 HashMap 的内部类 TreeNode 不继承它的内部类 Node,却继承自 Node 的子类 LinkedHashMap.Entry?

LinkedHashMap.Entry 新增了两个引用 before 、after,用于维护双向链表。TreeNode 继承自 LinkedHashMap.Entry 同样也具有了组成链表的能力(连接 TreeNode 的插入顺序)。但使用 HashMap 并不需要这种链表能力,这样不就浪费了 2 个引用的空间。

因为我觉得这样做是多余的,所以这里转自别人的解释:
HashMap 源码分析(JDK1.8)_第3张图片

仔细想想,也许是 Doug Lea 给开发者留下了 TreeNode 可以组成链表功能的想法,
开发者也可以继承 HashMap 实现一个 TreeNode 具有 链表功能的集合框架。

这个问题可以参考:LinkedHashMap 源码详细分析(JDK1.8)

3.方法分析

接下来我们就从构造方法开始一步一步分析HashMap的扩容、添加元素等操作。

3.1 构造方法

HashMap 的构造方法有4个:

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

默认容量:16
默认负载因子:0.75
其他所有字段都是默认值

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

调用了 3 的构造方法,初始容量:initialCapacity
默认负载因子:0.75

3. HashMap(int initialCapacity, float loadFactor)
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);
   }

三个判断规定了initialCapacity的范围,
threshold通过调用tableSizeFor(initialCapacity)来设定(threshold:标志数组扩容的阈值)
tableSizeFor(initialCapacity):返回一个比initialCapacity大且最接近的2幂次方的整数。(比如给定10,返回 2^4=16)

分析tableSizeFor(int cap)方法:

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

注意:HashMap 的容量必须为 2的幂次方(后面会有解释)
int n = cap - 1;:为了防止 cap 已经是2的幂次方,那么下面操作之后会变成2倍(比如 n=4已经符合要求,但经过下面5步之后 n 就会变为 8)
5步“n右移x再取或”保证最高位后面的位数全为1
图解 tableSizeFor(int cap) 方法:
HashMap 源码分析(JDK1.8)_第4张图片
这里将得到的 capacity 直接赋值给了threshold,并不符合threshold的定义(capacity*loadFactor),在构造方法中并未初始化 table,table 的初始化被推迟到 put 方法中,put 方法在调用 resize 方法会重新计算threshold(后面会分析)。

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

构造一个和指定Map具有相同 mappings 的 HashMap,默认负载因子:0.75,里面调用了putMapEntries(m, false);:将指定Map放入HashMap中。

分析putMapEntries(Map m, boolean evict)方法:

/**
  * 将 m 的所有键值对存入到 HashMap实例中
  */
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
        // m 中元素个数
        int s = m.size();
        // 如果 m 中存在元素
        if (s > 0) {
        	// table 还未初始化,先保存一些需要的变量
            if (table == null) { // pre-size
            	// s相当于阈值,括号中会计算得到一个容量 +1是为了向上取整
                float ft = ((float)s / loadFactor) + 1.0F;
                // 容量小于最大容量那么就截断
                int t = ((ft < (float)MAXIMUM_CAPACITY) ?
                         (int)ft : MAXIMUM_CAPACITY);
                // 如果上面运算得到的容量 t 大于暂存容量 
                // threshold(table还未初始化,所以threshold存的是数组容量),
                // 那么就重新计算 threshold 暂存容量
                if (t > threshold)
                    threshold = tableSizeFor(t);
            }
            // table 被初始化,s 元素个数超过阈值 threshold,那么就需要扩容
            else if (s > threshold)
                resize();
            // 将 m 中的元素迭代插入 table 中
            for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
                K key = e.getKey();
                V value = e.getValue();
                putVal(hash(key), key, value, false, evict);
            }
        }
    }
看了上面代码可能有人疑惑:为什么要根据 s 来计算得到一个容量?
可以这样分析,阈值是比容量小的,分析 s 不当做阈值来计算而是直接当做容量的情况:
假设暂存容量 threshold = 8、s = 7;那么在迭代插入table,第一次调用 putVal 方法中
的 resize 方法会初始化 table(table.length = 8、threshold = 6(8*0.75)),可以发
现 7 大于阈值,那么在插入第7个元素时,会再次调用 resize 方法。这样就会多调用一次 
resize 方法,增加了内存消耗。而通过 7 作为阈值算得的容量,则不会出现这种问题。

3.2 hash(Object key)

hash函数:用来计算 key 的 hash值。

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

从代码可以看出 key 的 hash值计算方法:
key的hash值高16位不变,低16位与高16位异或作为key的最终hash值。
HashMap 源码分析(JDK1.8)_第5张图片
那为什么要这样做?
这与HashMap中table下标的计算有关:

n = table.length;
index = (n-1& hash;

在前言中我提到过,处理 hash值得到数组下标的方式:用 hash值对数组容量取模,得到数组下标。

这里我需要将将两点:

  1. 与运算对取模的优化
    使用 % 取模有两个缺点:不能处理负数(负数取模还是负数)、比较慢。
    那如果我们使用与运算就会提高获取数组下标的效率
    HashMap 源码分析(JDK1.8)_第6张图片
    一个hash值能大的数使用%需要计算较长时间,而通过与运算就可以一步计算出来。只有n为2的幂次方时,减一才会使后面位数都为1,而不影响与运算的结果。这也是为什么容量大小必须是2的幂次方的原因。
  2. 高低位异或,减少hash碰撞
    HashMap 源码分析(JDK1.8)_第7张图片
    由上图可以知道只有后四位参与了运算,如果只是将对象的hashCode取后四位很容易产生碰撞,设计者考虑到现在的hashCode分布的已经很不错了,而且当发生较大碰撞时也用树形存储降低了冲突。仅仅异或一下,既减少了系统的开销,也不会造成的因为高位没有参与下标的计算(table长度比较小时),从而引起的碰撞。

3.3 resize()

用于数组扩容的函数,其中也包括初始化数组。

final Node<K,V>[] resize() {
		// 保存当前 table
        Node<K,V>[] oldTab = table;
        // 保存当前容量
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        // 保存当前阈值
        int oldThr = threshold;
        // 设置新的阈值为0
        int newCap, newThr = 0;
        /**
          * 1.在 size>threshold 时被调用
          * 	oldCap>0 表示原table非空
          * 	oldThr(threshold) = oldCap × load_factor
          */
        if (oldCap > 0) {
        	// 若之前table容量超过最大容量,阈值设为最大整型,之后不会扩容
            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
        }
        /**
          * 2.table 为空被调用,oldCap<=0 且 oldThr>0 表示:
          * 	用户使用下面构造方法创建了 HashMap:
          * 		HashMap(int initialCapacity, float loadFactor)
          * 		HashMap(int initialCapacity)
          * 		HashMap(Map m)
          * 	oldTab = null,oldCap = 0,oldThr为用户指定容量或m在putMapEntries
          * 	算得的容量
          */
        else if (oldThr > 0) // initial capacity was placed in threshold
            // 将暂存容量设为新的容量
            newCap = oldThr;
        /**
          * 3.table 为空被调用,oldCap<=0 且 oldThr<=0 表示调用了默认构造
          * 	HashMap(),oldCap=0、oldThr=0
          */
        else {               // zero initial threshold signifies using defaults
            // 容量设为默认容量(16)
            newCap = DEFAULT_INITIAL_CAPACITY;
            // 阈值设为默认阈值计算(16*0.75)
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        // 新阈值为0
        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"})
      	// 初始化新的 table
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
        	// 把 oldTab 中的结点 reHash 到 newTab 中去
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                	// 置为null 利于GC回收
                    oldTab[j] = null;
                    // 如果为单个结点,直接在 newTab 中直到下标存储
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    // 如果为 TreeNode 结点,就进行红黑树的 rehash
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    // 若是链表,进行链表的 rehash
                    else { // preserve order
                    	// 用于连接索引位置不变的结点
                        Node<K,V> loHead = null, loTail = null;
                        // 用于连接索引位置改变的结点
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        /**
                          * 将同一桶的结点根据 (e.hash & oldCap) 是否为0进行分割
                          * 	为0:继续存在当前索引的桶中
                          * 	不为0:存放在(索引+oldCap)的桶中
                          * 不理解的下面有图解
                          */
                        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;
                        }
                        // 将索引改变的头指针赋给新的数组(索引+oldCap)处
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

在扩容中使用了 2次幂的扩展(长度翻倍),所以元素的索引位置要么是在原位置,要么是在原位置再移动2次幂的位置。

下图所示,扩容前 n=2^4=16,扩容后 n=2^5=32,因为计算数组下标要减一所以图中直接使用了n-1的位数来表示。
图(a)表示扩容前 key1和key2 所确定的数组下标,图(b)表示扩容后 key1和key2 所确定的数组下标:
HashMap 源码分析(JDK1.8)_第8张图片
因为n扩容翻倍,所以 n-1 的掩码范围增加 1bit,因此 key2的index就会发生变化:
HashMap 源码分析(JDK1.8)_第9张图片
因此,我们在扩容HashMap的时候,只需要看原来hash值新增的一位bit是0还是1,是0的话索引不变,是1索引变为“原索引+oldCap”,可以看下 n从16扩充32 的示意图:
HashMap 源码分析(JDK1.8)_第10张图片
什么时候扩容:通过HashMap源码可以看到是在put操作时,有两处调用 resize 方法。一处是在刚进去判断 table 是否为空,为空则扩容;另一处是在 size>threshold 来进行扩容。
扩容(resize):其实就是重新计算容量;而这个扩容是计算出所需容器的大小之后重新定义一个新的容器,将原来容器中的元素放入其中。

3.4 putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)

向 HashMap 中添加元素,同时初始化 table。

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 为空或者 大小为0,调用 resize 方法初始化 table
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 1.当前索引的桶没有结点(并未hash碰撞),直接添加    
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        /**
          * 2.发生hash碰撞,存在两种情况:
          * 	1.key值相同,需要判断 onlyIfAbsent 来替换value值
          * 	2.key值不同:
          * 		1.存在桶的链表中
          * 		2.存在红黑树中
          */
        else {
            Node<K,V> e; K k;
            // 第一个结点的hash值与将要添加元素的hash值相同且key也相同
            // 那么这个添加元素的key已经存在,用 e 暂存这个结点引用
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 2.2    
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            // 2.1
            else {
            	// 不是 TreeNode 就是链表,遍历链表
                for (int binCount = 0; ; ++binCount) {
                    // 遍历完链表也没有找到相同的hash和key,那么就新建一个Node
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // 因为第一个结点已经访问过,所以这里 TREEIFY_THRESHOLD - 1
                        // 这里判断当前桶下链表的个数是否达到转换为红黑树的条件
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            // 将链表转为红黑树
                            treeifyBin(tab, hash);
                        break;
                    }
                    // 在链表遍历中,遇到hash和key相同的结点,e 记录该结点引用
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            // 如果 e 记录的结点引用不为空,那么就存在相同hash和key的元素
            if (e != null) { // existing mapping for key
            	// 记录之前的value
                V oldValue = e.value;
                // 如果 onlyIfAbsent 为false 或 之前的value为空,就将新的value赋值
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                // 返回之前的value
                return oldValue;
            }
        }
        // 数组修改次数+1
        ++modCount;
        // 数组大小+1 并判断是否需要扩容
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
注:hash 冲突发生的几种情况:
1.两节点key 值相同(hash值一定相同),导致冲突;
2.两节点key 值不同,由于 hash 函数的局限性导致hash 值相同,冲突;
3.两节点key 值不同,hash 值不同,但 hash 值对数组长度取模后相同,冲突;

get方法较为简单,这里就不在赘述。

4.JDK1.7和JDK1.8的比较

(1)JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法,那么为什么要这样做呢?因为JDK1.7是用单链表进行的纵向延伸,当采用头插法就是能够提高插入的效率,但是也会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。

(2)扩容后数据存储位置的计算方式也不一样:

  1. 在JDK1.7的时候是直接用hash值和需要扩容的二进制数进行&(这里就是为什么扩容的时候为啥一定必须是2的多少次幂的原因所在,因为如果只有2的n次幂的情况时减一之后,最后几位二进制数才一定是1,这样能最大程度减少hash碰撞)(hash值 & length-1) 。
  2. 在JDK1.8的时候直接用了JDK1.7的时候计算的规律,也就是扩容前的原始位置+扩容的大小值=JDK1.8的计算方式,而不再是JDK1.7的那种异或的方法。但是这种方式就相当于只需要判断Hash值的新增参与运算的位是0还是1就直接迅速计算出了扩容后的储存方式。

(3)JDK1.7的时候使用的是数组+ 单链表的数据结构。但是在JDK1.8及之后时,使用的是数组+链表+红黑树的数据结构(当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(N)变成O(logN)提高了效率)。

你可能感兴趣的:(数据结构,Java)