HashMap 一、JDK 1.7 的HashMap 1. 数据结构 1 2 3 public class HashMap <K,V> extends AbstractMap <K,V> implements Map <K,V>, Cloneable, Serializable
HashMap 继承了 AbstractMap 抽象类,实现了 Cloneable、Serializable接口。HashMap的拷贝是一种浅拷贝,即拷贝对象的改变会影响到原对象;HashMap是线程不安全的,不保证插入的键值对有序,但是允许存储键为null的值;HashTable是线程安全的,但是不能存储键为 null的值。
jdk 1.7 中的HashMap 是基于 数组+链表 的模式构建的。虽然 HashMap定义了hash函数来避免冲突,但是还是会存在两个 key在计算后桶的位置一样,因此考虑使用 链表的方式解决冲突;
结构: Entry节点(包括键、值、next、hash)
1 2 3 4 5 6 7 8 9 10 static class Entry implements Map .Entry { final K key; V value; Entry next; int hash; }
由于是数组组成,因此 O(1)的平均查找、插入、删除时间;
结构
描述
备注
数组(主)
(1)底层的核心是数组(table[]) ,数组中存储的是结点Entry。 (2)数组的下标是经过处理的键Key的hash值。 (3)数组元素 = 1个Entry结点 = hash值+key+value+next指针 = 1个链表头结点 (4)数组大小 = HashMap的容量
单链表(辅)
(1)每个链表 = 哈希表的桶(bucket) (2)链表的结点值 = 1个键值对 (3)链表长度 = 桶的大小
链表的主要作用是用来解决Hash冲突的。发生冲突时,新元素插入到链表头中;新元素总是添加到数组中,旧元素移动到单链表中。
HashMap的键值对数量 = 数组中的键值对数量 + 链表中的键值对数量
2. 重要参数 重要参数包括三个: 容量、负载因子、扩容阈值
容量: 容量(Capicity)是指 HashMap中的数组长度;而 Size 指的是HashMap中存储的键值对的数量;
容量的范围必须是 2的整数次幂;最大容量是 2^30;初始容量是 16;
负载因子: 负载因子是用来描述 HashMap在扩容前能够存储达到多满的一种程度;
负载因子越大,说明能够填满的元素越多,空间利用率就越高,但是相对应的Hash冲突的概率就会越大;
负载因子越小,说明能填满的元素越少,空间利用率越低,对应Hash越不容易冲突,查找效率更高。
扩容阈值: 当容量 > 扩容阈值时需要进行扩容操作。
扩容是将 当前容量扩大为原容量的2倍,从而使哈希表具有2倍的桶数;
扩容阈值 = 容量大小 × 负载因子 ,默认为 12
3. 构造方法分析 构造方法包括四种,默认构造方法、根据容量进行初始化、根据容量和负载因子进行初始化、根据子Map进行初始化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 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; threshold = initialCapacity; init(); } public HashMap () { this (DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); } public HashMap (int initialCapacity) { this (initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap (Map<? extends K, ? extends V> m) { this (Math.max((int ) (m.size() / DEFAULT_LOAD_FACTOR) + 1 , DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR); inflateTable(threshold); putAllForCreate(m); }
重点: 构造方法初始化只是设置了初始容量大小(capicity)、负载因子(Load factor),并没有真正的初始化哈希表。哈希表中数组的初始化,必须等到第一次 put()
操作时进行。
4. 插入 put() 方法分析
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 public V put (K key, V value) (分析1 ) if (table == EMPTY_TABLE) { inflateTable(threshold); } (分析2 ) if (key == null ) return putForNullKey(value); (分析3 ) int hash = hash(key); int i = indexFor(hash, table.length); for (Entry<K,V> e = table[i]; e != null ; e = e.next) { Object k; (分析4 ) if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this ); return oldValue; } } modCount++; (分析5 ) addEntry(hash, key, value, i); return null ; }
分析一、哈希表为空时进行初始化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 private void inflateTable (int toSize) { int capacity = roundUpToPowerOf2(toSize);->>分析1 threshold = (int ) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1 ); table = new Entry [capacity]; initHashSeedAsNeeded(capacity); } private static int roundUpToPowerOf2 (int number) { return number >= MAXIMUM_CAPACITY ? MAXIMUM_CAPACITY : (number > 1 ) ? Integer.highestOneBit((number - 1 ) << 1 ) : 1 ; }
分析二、当key为null时,将key-value插入到数组的第一个位置 table[0]
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 private V putForNullKey (V value) { for (Entry<K,V> e = table[0 ]; e != null ; e = e.next) { if (e.key == null ) { V oldValue = e.value; e.value = value; e.recordAccess(this ); return oldValue; } } modCount++; addEntry(0 , null , value, 0 ); return null ; }
分析三、计算键值对实际在哈希表中应该存储的位置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 int hash = hash(key); int i = indexFor(hash, table.length); static final int hash (int h) { h ^= k.hashCode(); h ^= (h >>> 20 ) ^ (h >>> 12 ); return h ^ (h >>> 7 ) ^ (h >>> 4 ); } static final int hash (Object key) { int h; return (key == null ) ? 0 : (h = key.hashCode()) ^ (h >>> 16 ); } static int indexFor (int h, int length) { return h & (length-1 ); }
关键点: 计算哈希码,通过扰动函数去二次处理哈希码,最后用 h&(length-1)
来计算最终的存储位置。
问题(1): 为什么不采用经过扰动处理的 hashcode 作为最终的存储位置?
因为经过扰动处理的哈希码的范围可能与数组长度不匹配,这样存储就会出现错误。因此要想办法将哈希码的范围约束在数组长度范围内。
一般想到的解决方案就是将 hash值对 数组范围取模(求余运算)。但是 JDK7中并没有这么做,而是采用了与运算(&):h & (length-1)
来得到最终的存储下标。
问题(2): 为什么采用哈希码和数组长度-1 与运算来计算数组的下标?
我认为关键有两点:
1. 提高运算效率:
与运算的实质是采用取模运算,即用 Hash值对数组长度取模 h % length
,但是由于求余运算的效率很低,因此采用与运算来提高效率。而且由于数组的长度为2的整数次幂,那么 h & (length-1)
实质上是用 哈希码与length-1 个1进行与运算,结果就是哈希码对应长度为 length-1 低位的部分 。
2. 保证哈希码的均匀性:
因为下标的求解方法是:将hash值和 数组长度-1 进行与运算。当长度为2^n时,数组长度减1后得到的所有位都为1,那么与运算的结果就是 hash值,一定程度上减小了碰撞的几率;
而如果不是 2^n,那么数组长度-1 后一定会产生0位,0 和任何数求与都为0,那么无论hash值为何值,对应该位为1时的那个位置的桶会一直空着。
问题(3): 为什么在计算数组下标前,需要对哈希码进行二次处理(扰动处理)?
扰动处理的目的就是为了让哈希码分布的更加均匀,使得存储在hashmap中的数组位置更加均匀,从而避免出现 hash冲突。
问题(4): 为什么数组的长度一定为 2^n ?
因为下标的求解方法是:将hash值和 数组长度-1 进行与运算。当长度为2^n时,数组长度减1后得到的所有位都为1,那么与运算的结果就是 hash值,一定程度上减小了碰撞的几率;
而如果不是 2^n,那么数组长度-1 后一定会产生0位,0 和任何数求与都为0,那么无论hash值为何值,对应该位为1时的那个位置的桶会一直空着。
分析四、若对应的key已经存在,就使用新value去替换旧value
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 for (Entry<K,V> e = table[i]; e != null ; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this ); return oldValue; } } modCount++; addEntry(hash, key, value, i); return null ;
方法很简单,就是顺序查找链表,找到对应值进行覆盖即可。
分析五、若对应的key不存在,就将该key-value添加到数组对应位置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 addEntry(hash, key, value, i); void addEntry (int hash, K key, V value, int bucketIndex) { if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length); hash = (null != key) ? hash(key) : 0 ; bucketIndex = indexFor(hash, table.length); } createEntry(hash, key, value, bucketIndex); } void resize (int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return ; } Entry[] newTable = new Entry [newCapacity]; transfer(newTable); table = newTable; threshold = (int )(newCapacity * loadFactor); } void transfer (Entry[] newTable) { Entry[] src = table; int newCapacity = newTable.length; for (int j = 0 ; j < src.length; j++) { Entry<K,V> e = src[j]; if (e != null ) { src[j] = null ; do { Entry<K,V> next = e.next; int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } while (e != null ); } } } void createEntry (int hash, K key, V value, int bucketIndex) { Entry<K,V> e = table[bucketIndex]; table[bucketIndex] = new Entry <>(hash, key, value, e); size++; }
创建新结点时会检查是否需要进行扩容(阈值12);如果需要就将容量扩大为原来的2倍,然后将旧数组中的值转移到新数组(会重新计算hash和下标点进行插入),数据转移过程使用的是头插法(头插法在多线程环境下会造成死锁问题)。
问题(1): transfer 为什么采用头插而不是尾插?为什么不用二维数组解决冲突?
因为后插入的数据被使用的频次更高,而单链表无法随机访问只能从头开始遍历查询,所以采用头插.突然又想为什么不采用二维数组的形式利用线性探查法来处理冲突,数组末尾插入也是O(1),可数组其最大缺陷就是在于若不是末尾插入删除效率很低,其次若添加的数据分布均匀那么每个桶上的数组都需要预留内存.
问题(2): 头插法为什么造成死锁问题?
1 2 3 4 5 6 7 8 do { Entry<K,V> next = e.next; int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } while (e != null );
单线程下:当HashMap 被扩容后,key值为 7和3的节点分别插入到了下标为3的位置。
多线程下:如果线程1执行到第一句被挂起了;此时线程2将整个插入操作执行完了(key:7 放在了 key:3的前面);此时如果线程1又被调度,执行e.next= newTable[i]
又会将7连接在3的尾部,这样就形成了环。那么如果对环进行查找时(找一个不存在的量)就会造成死锁。
5. 扩容操作 resize()分析
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 void resize (int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return ; } Entry[] newTable = new Entry [newCapacity]; transfer(newTable); table = newTable; threshold = (int )(newCapacity * loadFactor); }
扩容操作首先会复制出一个大小为原数组大小二倍的新数组。然后需要遍历旧数组中的每个值,采用和put()
方法相同的方式,重新计算每个数据在新数组中的存储位置h & (length-1)
,然后将旧数组的值转移到新数组中。对于旧数组的链表结点,在新数组中采用头插法进行插入。最后重新计算扩容阈值,重新计算需要插入数据对应的hash值,然后进行插入。
JDK7 中为了防止 hash 碰撞引起的拒绝服务攻击,在计算 hash 的过程中引入了随机种子。以增强 hash 的随机性,使得键值对均匀分布在桶数组中。在扩容过程中,相关方法会根据容量判断是否需要生成新的随机种子,并重新计算所有节点的hash。
6. 获取数据 get() 方法分析
get()
方法原理和put()
方法类似,都是根据 key计算hash值,然后扰乱处理,结合数组长度计算最终的存放位置。然后到该存放位置去查找,如果找到了就返回,否则就没有找到。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 private V getForNullKey () { if (size == 0 ) { return null ; } for (Entry<K,V> e = table[0 ]; e != null ; e = e.next) { if (e.key == null ) return e.value; } return null ; } final Entry<K,V> getEntry (Object key) { if (size == 0 ) { return null ; } int hash = (key == null ) ? 0 : hash(key); for (Entry<K,V> e = table[indexFor(hash, table.length)]; e != null ; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } return null ; }
7. 总结 HashMap的关键点主要包括: 数据结构+重要参数+重点方法
二、JDK 1.8 的HashMap JDK8 中的 hashMap相较于 JDK7做出了很大的改变,主要包括 数据结构、插入查找方法、扩容操作这三个方面。
1. 数据结构 JDK8 采用的是 数组+链表+红黑树 来实现的。
为什么要引入红黑树?
因为如果像 JDK7 那样,采用链地址法解决哈希冲突,那么当链表长度过长时查找是非常耗时的。因此引入红黑树的结构来加快查找过程,并且根据链表的长度来控制链表和红黑树之间的转化,这样一方面提高了查找的效率(时间复杂度从 O(n) 减少为了 O(logn));另一方面又不至于浪费很多资源空间。
如何转化?
红黑树的特点?
红黑树本质上是一棵二叉搜索树,因此查找的时间复杂度为 O(logn),效率很高。
红黑树每个结点不是红色就是黑色,而且父子结点必须是不同的颜色。根节点和叶子节点都是黑色。
从1个结点到该子孙结点的所有路径上包含相同数量的黑色结点,因此红黑树是相对接近平横二叉树。
2. 重要结构 主要看一下 Node结点和 TreeNode结点
Node结点
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 static class Node <K,V> implements Map .Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; 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 ; } }
Node结点和 JDK7 中的结点结构完全类似。
TreeNode 结点:
1 2 3 4 5 6 7 8 9 10 static final class TreeNode <K,V> extends LinkedHashMap .Entry<K,V> { TreeNode<K,V> parent; 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); }
3. 重要变量 由于引入了红黑树,因此也引入了一些与红黑树相关的临界变量。
(1)桶的树化阈值,即链表转化为红黑树的阈值,当链表长度大于该值时,将链表转化为红黑树
static final int TREEIFY_THRESHOLD = 8;
(2)桶的还原阈值,当原有红黑树中的结点数小于6时,转换回链表
static final int UNTREEIFY_THRESHOLD = 6;
(3)最小树化容量阈值,当哈希表中的容量大于该值时,才允许进行树化。为树化阈值的4倍
static final int MIN_TREEIFY_CAPACITY = 64;
4. 插入 put() 方法分析 1 2 3 public V put (K key, V value) { return putVal(hash(key), key, value, false , true ); }
4.1 哈希的计算方式 1 2 3 4 5 6 7 8 9 10 11 12 static final int hash (Object key) { int h; return (key == null ) ? 0 : (h = key.hashCode()) ^ (h >>> 16 ); } static final int hash (int h) { h ^= k.hashCode(); h ^= (h >>> 20 ) ^ (h >>> 12 ); return h ^ (h >>> 7 ) ^ (h >>> 4 ); }
JDK7 中总共采用了9次扰动处理,包括4次位运算、5次异或运算;
JDK8 中总共采用了2次扰动处理,包括1次位运算、1次异或运算;
问题(1):这里计算 hash值为什么采用 key.hashcode() ^ h>>>16
这种方式?
因为如果直接使用 hashcode 去进行 index = key.hashcode() & (n-1)
运算来计算最终的存储位置,那么相当于 hashcode 的高位都没有用到 (因为n为2的整数次幂,减一后低位都1,高位都为0,0和任何数与运算都为0);简单的来说,直接使用 hashcode 去计算最终位置会使得取余的计算结果只对低位有效,当hash 值高位变化时低位不变,取余结果仍然一样,容易发生哈希冲突。
因为哈希值为32位,那么如果我将整个hash 值右移16位,得到的结果就是哈希值的高16位;再将高16位和低16位进行异或运算,那就相当于用到了整个 hash部分。无论此时是 hash的高位还是低位发生改变,最终的 hash求余结果一定会发生改变,巧妙地避免了哈希冲突。
4.2 putVal() 方法分析 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 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 ) n = (tab = resize()).length; if ((p = tab[i = (n - 1 ) & hash]) == null ) tab[i] = newNode(hash, key, value, null ); else { Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; else if (p instanceof TreeNode) e = ((TreeNode<K,V>)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 ) treeifyBin(tab, hash); break ; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break ; p = e; } } if (e != null ) { V oldValue = e.value; if (!onlyIfAbsent || oldValue == null ) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null ; }
JDK8 与 JDK7 的插入操作有了很大的不同:
第一步都是判断数组是否被初始化,没有初始化就进行初始化(容量大小为大于输入数值的最小的2的整数次幂);
第二步就是根据 key 计算应该存放的位置,这里的计算方法和JDK7 也不相同,主要体现在扰动函数上,只进行了2次扰动处理;
如果该位置没有值就生成一个新的结点插入;否则有值但是 key可能相同,这样的话进行覆盖就可以了。
但是如果 key不相同却有值,说明存在hash冲突(不同的key经过计算得到相同的存储位置);就需要根据此时的存储数据结构进行判断,如果是红黑树就采用红黑树的方式插入然后调整;如果是链表就查找链表中是否已经存在,存在就覆盖,不存在就在链表尾插入一个新的。
但是注意链表超过一定长度是需要转化为红黑树进行存储的,因此遍历链表的过程中还需要记录链表的位置,源码中使用binCount
进行记录。记录完毕后判断是否需要变换结构。
最后判断是否需要进行扩容处理,如果当前键值对的数量大于扩容阈值,就采用 resize()
方法进行扩容。扩容操作接下来分析。
5. 扩容操作 resize() 分析
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 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; return oldTab; } else if ((newCap = oldCap << 1 ) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1 ; } else if (oldThr > 0 ) newCap = oldThr; else { 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 = newTab; 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); else { 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; } if (hiTail != null ) { hiTail.next = null ; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
重点在重新计算数组中元素在新数组中的位置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 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; }
根据代码,这里只存在两种情况,要么仍然是原位置,要么是原位置+原数组的长度作为新位置。
因为扩容后容量为原来的2倍,相当于把容量的2进制值往左移动1位。在计算存储位置 i = (n-1) & hash
时,n-1
的二进制比原来的二进制高位多了一个1,那么原hash 值与这个1相与结果就可能是1或者0了。如果是0,则新计算的存储位置和原存储位置相同;如果是1,新存储位置 = 原长度+原来的存储位置。
6. 获取数据 get() 方法分析 获取数据类似于插入操作,包括计算存储位置,判断存储的数据结构然后去对应位置查找。
7. 总结
三、JDK7 和 JDK8 HashMap的区别 3.1 数据结构不同 JDK7 的数据结构为 数组+链表,当发生哈希冲突时采用链地址法解决冲突;缺点是当冲突过多导致链表过长时,对链表中数据的查询效率非常低;
JDK8 的数据结构为 数组+链表+红黑树,当发生哈希冲突时优先采用链表解决冲突,但是如果链表的长度大于8,就要将结构转换为红黑树,红黑树的查找时间复杂度为 O(logn) 比 链表的 O(n) 在结点很多的情况下要好很多。如果链表长度低于6时,就没必要使用红黑树了,此时将红黑树转换回链表结构。
3.2 插入数据、获取数据 JDK7 的插入数据首先会检查数组是否初始化,初始化完毕后判断key是否为空,为空直接插在数组第一个位置;然后根据 key对应的哈希值进行扰动处理,这里的扰动处理包括4次位运算和5次异或运算;紧接着根据 index = h & (length-1)
计算出最终的插入位置;如果该位置没有冲突,直接插入;否则需要插入到单链表中,这就涉及到了扩容操作,在扩容之后又重新计算需要插入的数据的位置进行插入。是先扩容后计算插入位置进行插入
JDK8 的插入数据首先也会检查数据是否初始化,初始化完毕后紧接着计算哈希值,哈希值的计算与JDK7不同,是通过采用 hashcode & (hashcode>>>16)
计算得到hash值,然后根据 index=h&(length-1)
得出最终插入位置。没有冲突直接插入,如果有冲突,判断当前数据结构的类型进行冲突解决,对于链表而言需要特别注意当结点数大于8时需要转化为红黑树的结构。最后插入完毕后判断是否需要进行扩容操作。是先插入后判断是否需要扩容
3.3 扩容机制 JDK7 中扩容是生成一个大小为原容量2倍的数组,然后重新计算原数组中各个头结点的存储位置,接着插入到新数组中。对于原数组中的链表结构,采用头插法依次插入到新数组中,这种头插法在多线程情况下可能会出现环形链表,因此会出现死循环问题。
JDK8 中扩容也是生成一个大小为原容量2倍的新数组,然后按照扩容规律计算新位置,不同的是它的位置只有两种情况,一种是原位置、一种是原位置+旧容量;接着采用尾插法插入结点,尾插法保证了即使在多线程情况下也不会出现问题。
四、一些面试问题 4.1 哈希表如何解决哈希冲突?
4.2 为什么 HashMap中 String、Integer这样的包装类适合作为键?
4.3 HashMap中的key若为Object类型,需要实现哪些方法?
4.4 HashMap的链表大小超过8个时会自动转化为红黑树,当小于6个时会转换为链表,为什么呢? 因为根据泊松分布的原理,单个桶中结点个数大于8的概率小于百万分之一,所以将7作为一个分水岭,大于等8时转换为红黑树进行存储;小于等于6时转换回链表的结构。
参考文章:
Java集合——HashMap(jdk1.7)
《疫苗:Java HashMap的死循环》的相关评论
[彻底拿下HashMap面试问题!!!]
HashMap常见面试题汇总(一)