Java集合之 HashMap

参考文档:

https://blog.csdn.net/majinggogogo/article/details/80260400

 

此说明文档基于Java 1.8+.

基本知识点

1.Hash基本知识

  Hash就是把任意长度的输入,通过散列算法,变换成固定长度的输出,该输出就是散列值。

  因为是将任意长度变换为固定长度,这种变换实际是一种压缩映射,可以理解为散列值的空间通常远小于输入值得空间,所以不同的输入可能会散列成相同的输出。

  简单来说,哈希就是一种将任意长度的消息压缩到某一固定长度的消息摘要函数。

  散列函数有如下几个特性:

    根据同一散列函数计算出的散列值如果不同,输入值肯定不同。

    根据同一散列函数计算出的散列值如果相同,输入值不一定相同。

  不同的输入值,根据同一散列函数计算出的散列值相同的现象叫做哈希碰撞。实际操作过程中应该尽量减少哈希碰撞的几率,以及提供好的解决哈希碰撞的方案。

2. X % 2^n == X&(2^n - 1)

  2^n 表示2的n次方,也就是说一个数对2^n取模 == 一个数和(2^n - 1)按位与运算。

  假设n为3,2^n = 8, 其二进制表示为 0000 1000.  2^n - 1 = 7, 即 0000 0111.

  此时X&(2^n-1)就相当于取X的2进制的后n位。而从二进制角度来看,X/(2^n),相当于X >> n, 即X二进制右移3位,此时得到X/8的商;而移掉的部分(3位),则是 X%8,也就是余数。所以上述公式成立。

 

 

简单示意下HashMap的结构

此图大致结构是 数组 + 链表 + 红黑树。

Java8中使用Node标识HashMap中的数据节点,Node包含4个属性,hash, key, value 和 next。 

 

 

下面我们具体分析下源码

HashMap的构造函数

HashMap构造函数有4个,代码如下:

 1 public HashMap(int initialCapacity, float loadFactor) {
 2     if (initialCapacity < 0)
 3         throw new IllegalArgumentException("Illegal initial capacity: " +
 4                                            initialCapacity);
 5     if (initialCapacity > MAXIMUM_CAPACITY)
 6         initialCapacity = MAXIMUM_CAPACITY;
 7     if (loadFactor <= 0 || Float.isNaN(loadFactor))
 8         throw new IllegalArgumentException("Illegal load factor: " +
 9                                            loadFactor);
10     this.loadFactor = loadFactor;
11     this.threshold = tableSizeFor(initialCapacity);
12 }
13 
14 public HashMap(int initialCapacity) {
15     this(initialCapacity, DEFAULT_LOAD_FACTOR);
16 }
17 
18 public HashMap() {
19     this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
20 }
21 
22 public HashMap(Map<? extends K, ? extends V> m) {
23     this.loadFactor = DEFAULT_LOAD_FACTOR;
24     putMapEntries(m, false);
25 }

其中,主要有2种形式:

copy别的HashMap的形式,暂不讨论。

定义初始化容量 和 负载因子,计算扩容阀值。

  capacity:当前数组容量,默认大小为16,始终保持2^n, 可以初始化,也可以扩容。

  loadFactor:负载因子,默认为 0.75

  threshold:扩容的阀值,等于 capacity * loadFactor; 指定参数构建时,初始值为 > 且接近初始容量的2的n次方。

 

刚才我们说到,指定参数构建时,初始值为 > 且接近初始容量的2的n次方。我们看它的算法如何实现,即上述第11行 tableSizeFor代码。

 1 /**
 2      * 返回接近初始容量且大于初始容量的2的N次幂
 3      */
 4     static final int tableSizeFor(int cap) {
 5         int n = cap - 1;
 6         n |= n >>> 1;
 7         n |= n >>> 2;
 8         n |= n >>> 4;
 9         n |= n >>> 8;
10         n |= n >>> 16;
11         return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
12     }

先来分析有关n位操作部分:先来假设n的二进制为01xxx...xxx。接着

  对n右移1位:001xx...xxx,再位或:011xx...xxx

  对n右移2为:00011...xxx,再位或:01111...xxx

  此时前面已经有四个1了,再右移4位且位或可得8个1

  同理,有8个1,右移8位肯定会让后八位也为1。

  我们知道int类型长度为4个字节,即32个bit位,所以经过1,2,4,8,16   五次移位后,该算法正好可以让最高位的1后面的位全变为1. 

  最后再让结果n+1,即得到了2的整数次幂的值了。

再看第一条语句   

int n = cap - 1;

  让cap-1再赋值给n的目的是另找到的目标值大于或等于原值。例如二进制1000,十进制数值为8。如果不对它减1而直接操作,将得到答案10000,即16。显然不是结果。减1后二进制为111,再进行操作则会得到原来的数值1000,即8。  

 

HashMap的默认初始容量为什么是16,容量一般都是2的N次幂?

在HashMap中,遵循均匀分布的原则,尽量避免碰撞因子的个数。

我们假设table的长度是10,table.length-1 = 9, 9的二进制表示为 1001,我们假设hash的二进制为  ...1 1001 1001,  那  (table.length -1)& hash = 1001, 

再看一个例子,假设table的长度不变,hash的二进制为 ...1 1001 1111, 那(table.length - 1)& hash = 1001, 同一个table, 不同的hash, table数组的索引一样,出现重复的概率增大了。反观 16 或其它 2的N次幂,length -1 后的长度二进制基本都是1,key不同,hash基本也不同,那table索引基本也不相同。符合hash均匀分布的原则。

 

put 过程分析

 1 public V put(K key, V value) {
 2     return putVal(hash(key), key, value, false, true);
 3 }
 4 
 5 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
 6                boolean evict) {
 7     Node<K,V>[] tab; Node<K,V> p; int n, i;
 8     if ((tab = table) == null || (n = tab.length) == 0)
 9         n = (tab = resize()).length;
10     if ((p = tab[i = (n - 1) & hash]) == null)
11         tab[i] = newNode(hash, key, value, null);
12     else {
13         Node<K,V> e; K k;
14         if (p.hash == hash &&
15             ((k = p.key) == key || (key != null && key.equals(k))))
16             e = p;
17         else if (p instanceof TreeNode)
18             e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
19         else {
20             for (int binCount = 0; ; ++binCount) {
21                 if ((e = p.next) == null) {
22                     p.next = newNode(hash, key, value, null);
23                     if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
24                         treeifyBin(tab, hash);
25                     break;
26                 }
27                 if (e.hash == hash &&
28                     ((k = e.key) == key || (key != null && key.equals(k))))
29                     break;
30                 p = e;
31             }
32         }
33         if (e != null) { // existing mapping for key
34             V oldValue = e.value;
35             if (!onlyIfAbsent || oldValue == null)
36                 e.value = value;
37             afterNodeAccess(e);
38             return oldValue;
39         }
40     }
41     ++modCount;
42     if (++size > threshold)
43         resize();
44     afterNodeInsertion(evict);
45     return null;
46 }

put 操作:

代码第10~40行表示put的真正操作。

  代码第10,11行,找到数组的具体下标,如果此位置没有值,直接初始化一个Node节点,并放置在这里。

    注意,代码第10,11行,在putVal方法中,插入的hash是个32位的int数据,但数据所在table数组的索引是 (table.length-1)& hash .

  代码第14~16行,33~36行,表示数组此位置有值,当hash和key完全相同时,新value覆盖旧value。

  代码第17,18行,表示属于红黑树结构。具体结构稍后补充。

  代码第19~32行,表示链表操作。链表尾指针为空,就添加;如果hash和key相同,新value覆盖旧value。在链表操作过程中,如果链表长度超过8,转为红黑树操作。

 

总结:

1. HashMap底层是用 数组 + 双向链表 + 红黑树 实现的。

2. 插入元素的时候,首先通过一个hash方法计算得到key的哈希值,进而计算出元素待插入的位置(利用hash % length = hash & (length - 1),length为 2的N次幂)。

3. 如果该位置为空,则直接插入(包装为Node).

4. 如果该位置有值,则依次遍历。遍历的规则是,hash值相同,key值相等的元素视为相同,则用新值替换旧值并返回旧值;如果hash值相同,key值不相等的元素则插到链表的末尾(包装为Node)。

5. 如果该位置的元素是红黑树结构,则同理,查找,找到则替换,没找到就插入。

 

 

resize()扩容

 1 final Node<K,V>[] resize() {
 2         Node<K,V>[] oldTab = table;
 3         int oldCap = (oldTab == null) ? 0 : oldTab.length;
 4         int oldThr = threshold;
 5         int newCap, newThr = 0;
 6         if (oldCap > 0) {
 7             if (oldCap >= MAXIMUM_CAPACITY) {
 8                 threshold = Integer.MAX_VALUE;
 9                 return oldTab;
10             }
11             else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
12                      oldCap >= DEFAULT_INITIAL_CAPACITY)
13                 newThr = oldThr << 1; // double threshold
14         }
15         else if (oldThr > 0) // initial capacity was placed in threshold
16             newCap = oldThr;
17         else {               // zero initial threshold signifies using defaults
18             newCap = DEFAULT_INITIAL_CAPACITY;
19             newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
20         }
21         if (newThr == 0) {
22             float ft = (float)newCap * loadFactor;
23             newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
24                       (int)ft : Integer.MAX_VALUE);
25         }
26         threshold = newThr;
27         @SuppressWarnings({"rawtypes","unchecked"})
28             Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
29         table = newTab;
30         if (oldTab != null) {
31             for (int j = 0; j < oldCap; ++j) {
32                 Node<K,V> e;
33                 if ((e = oldTab[j]) != null) {
34                     oldTab[j] = null;
35                     if (e.next == null)
36                         newTab[e.hash & (newCap - 1)] = e;
37                     else if (e instanceof TreeNode)
38                         ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
39                     else { // preserve order
40                         Node<K,V> loHead = null, loTail = null;
41                         Node<K,V> hiHead = null, hiTail = null;
42                         Node<K,V> next;
43                         do {
44                             next = e.next;
45                             if ((e.hash & oldCap) == 0) {
46                                 if (loTail == null)
47                                     loHead = e;
48                                 else
49                                     loTail.next = e;
50                                 loTail = e;
51                             }
52                             else {
53                                 if (hiTail == null)
54                                     hiHead = e;
55                                 else
56                                     hiTail.next = e;
57                                 hiTail = e;
58                             }
59                         } while ((e = next) != null);
60                         if (loTail != null) {
61                             loTail.next = null;
62                             newTab[j] = loHead;
63                         }
64                         if (hiTail != null) {
65                             hiTail.next = null;
66                             newTab[j + oldCap] = hiHead;
67                         }
68                     }
69                 }
70             }
71         }
72         return newTab;
73     }

刚才我们说到,首次put时扩容。代码第17~20,26~29行,表示首次扩容默认初始容量为16,扩容阀值 threshold =默认负载因子*默认初始容量 = 12。

代码第6~14行,表示再次扩容时,数组容量和阀值都扩大2倍。

代码第30~71是扩容后对数据的移动操作:

  第35,36行表示当前节点是普通的数组节点,这时候重新计算此结点在新数据的下标(利用hash % length = hash & (length - 1),length为 2的N次幂),然后存放。

  第37,38行表示当前节点是红黑树结构;

  第39~68行表示当前节点是一个链表,顺序遍历链表上的每一个结点(Node),根据结点的hash计算所在新数组的下标,并按照顺序形成新的链表,然后存放。

 

关于扩容时,链表数据移动逻辑(39 ~68 行的 do...while 部分),着重分析下: 

  我们假设数组的长度为2^n(n = 4),即数组长度为16,二进制表示为 0001 0000,  2^n - 1 二进制表示为 0000 1111.

  我们假设有一组数据,5,21,37,53,其hash二进制表示为:

    5:  0000  0101

    21:   0001  0101

    37:     0010   0101

              53:      0011   0101

  根据文章开篇基础知识里讲到的,hash & (2^n - 1),其实就是取的二进制的后 n 位,我们看到 5,21,37,53 的二进制 hash & (2^n - 1)后都是 0101,也就是说他们数组的同一个下标下,所以他们在一个链表上,

  链表数据按顺序为  5 --> 21 --> 37 --> 53 --> null

  此时进入 do...while循环,对链表节点遍历,判断是留下还是去新的链表:

  lo就是扩容后仍然在原地的元素链表

  hi就是扩容后新的元素链表

  扩容后n = 5,2^5 - 1 二进制表示为 0001  1111,元素所在数组下标为 hash & ( 2^n -1) 【取后5位】,我们分析,链表元素的hash不变,扩容前,所在数据下标取hash 的后4位,扩容后所在数据下标取hash的后 5位,变化的是第5位(从右往左数)。

  如果hash第5位为1,& 操作后数组下标值是扩容后的新值,如果hash第5位为0,&操作后数组下标值跟扩容前值一样。所以扩容后的数组下标完全取决于hash从右数第n位是 1还是0。如果是1,移动到新链表,如果是0,留原地不动。

  如何判断hash第n位是1还是0,这里用了一个巧妙的设计。hash & 2^n,可以判断hash的二进制数从右数第n位是1还是0. 

  所以我们看到上述代码第 45行,e.hash & oldCap 【扩容前数组的长度,即 2^n】来判断是保留还是移动。

  同时,利用尾指针Tail,完成了尾部插入,不会造成逆序,所以不会产生并发死锁问题。

 

总结:

扩容时机:

  new完HashMap对象后,首次put时,扩容;

  在put过程中,size > 阈值时扩容。

扩容移动数据:

  移动数据时不需要重新计算hash.

  移动数据后链表元素的相对顺序没有改变。

  并发扩容过程中不会发生死锁。

 

HashMap的线程安全性分析

上边我们讲了HashMap的put过程,我们看多线程情况下,put过程有什么问题:

  我们假设线程A和B同时put 一个 key-value对。我们假设HashMap的数组项为一个桶,桶里装的是链表结构的Node。

  线程Aput时,先寻找桶的索引,找到后记下链表头结点,此时线程A的时间片用完了,而此时线程B被调度执行。

  线程Bput的过程跟线程A一样,只不过线程B成功将记录插入到桶里面。

  假设线程A和线程B插入记录时,找到的桶的索引坐标一样。当线程B成功插入后,线程A重新被调度执行时,它依然持有过期的链表头却全然不知,以至于线程A还按照原来的执行顺序继续往下执行,那么它就会把线程B插入的数据覆盖了。

  如此一来,在多线程情况下,线程B插入的数据凭空消失了,这就是HashMap  put过程的线程不安全性。

 

posted on 2018-03-27 10:49  落地实验室  阅读(143)  评论(0)    收藏  举报

导航