Java Hashmap,经典数据结构。主要理解他的组成结构和哈希原理,哈希冲突如何处理。基于jdk8。
Example 1 2 3 4 5 Map<String, Object> map = new HashMap<>(); map.put("name" , "Gloria" ); map.put("age" , 3 ); map.put("wage" , 539.8 ); System.out.println(map);
通过一个简单的put
操作,来看看究竟发生了什么。
构造 初始化时,构造一个HashMap对象,这里有有参和无参(当然,阿里开发规约建议知道容量的情况下要指定大小)的构造方法,有参中你可以指定装载因子、容量、甚至是把map放进去:
1 Map<String, Object> map1 = new HashMap<>(map);
来看看初始化容量时的装载因子:
1 2 3 4 5 6 final float loadFactor;
HashMap的容量是我们存入的数值乘以0.75
,例如:
1 Map<String, Object> map = new HashMap<>(16 );
那么,这个map的实际容量 就是12,超过这个数量的话就会进行扩容操作。一般我们都使用默认的0.75
,自定义初始容量 。
put初识 1 2 3 4 5 6 7 8 9 10 11 12 13 14 final V putVal (int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { return putVal(hash(key), key, value, false , true ); }key
put有几个参数,其中之一是hash值,所以我们先来看看他的hash,上面是他的入参,可以看到,只对传入的key进行hash处理了。那么hash是如何做的呢?
1 2 3 4 static final int hash (Object key) { int h; return (key == null ) ? 0 : (h = key.hashCode()) ^ (h >>> 16 ); }
惊不惊喜,意不意外?(好吧,很easy了)首先,他是一个三目元算,判断key是否为null,如果是返回0(说明hashmap允许存入key为null),否则“一顿操作”。再来细看key不为null的情况,用到了两个位运算 :
^ 异或(两个相同的数做异或运算结果为0)
>>> 无符号右移,左边空出来的补0
首先是给h赋值为key的hashCode,key的hashcode是通过Object的native方法
,所以跟不下去了!所以重点放在位运算上,将h进行右移16位再与之自身进行异或。
关于异或先放在这里,我们来看看后续是如何存储的,往下一跟便能发现一个变量table
,他的类型是Noe<K,V> []
,即Node类型数组,接下来我们来看看他的结构,包括存储时防止冲突的解决方法。
Node 它是HahsMap的静态内部类,存储的核心,构造也不复杂,可以看看。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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; } }
妥妥的链表有么有!!! Node<K, V> next
!!!
上面提到的table
就是一个链表类型的数组,里面用来装Node链表。所以他的基本储存就明了了:数组+链表。
实现了Map.Entry<K, V>
接口,所以,他的本质(或者说表现)就是一个k-v键值对。
与算法题通常定义的简易链表不同,除了next
外这里存储了三个值:hash
, key
, value
注意其中的key,后面要考~
冲突解决 首先想想:什么是哈希表。
哈希表=数组+链表。通过Node<K, V>
以及Node<K, V> []
我们知道hashmap是使用哈希表存储的。通过课本我们也了解到对需哈希冲突,解决的方式通常有两种:开放寻址、链地址。毫无疑问,既然用了链表,那就连地址呗,实现方式同样是数组+链表
。
哈希冲突是有条件的,或者说是限制。在hashmap中,冲突取决于桶 (即之前提到的table
数组)和哈希算法 ,前者代表了空间成本,后者则是时间成本,空间与时间的权衡是要自己考虑的了(一般默认)。它默认的做法是初始化一个大小,容不下时会进行扩容,如此一来,数组占的空间又小还能使得发生碰撞的概率减小。我们来看看初始化涉及到的一些参数:
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 transient int size;transient int modCount;int threshold;final float loadFactor;
threshold
临界值,所能容纳k-v的极限。如果不指定任何值,初始化时Node<K,V>[] table的length是16, loadFactor是0.75,,那么
threshold = loadFactor * length
也就是说,table数组中所能容纳的Node个数由threshold
指定,初始为12.往里面装的Node个数超过12,会进行扩容, 扩容为之前的两倍。这里的负载因子可以看作是对空间的限制,毕竟长度16,由于负载因子变成了12,所以如果内存紧张,对时间要求也不高,可以加大因子,允许超过1.
size就是表示目前存储的Node的数量。
在HashMap中,哈希桶数组的table长度必须为2的n次方,这是一种非常规 设计,为什么呢?一个冷知识:
2的n次方的数为合数
,实际上质数
导致哈希冲突的概率要小于合数。
参考HashTable的初始化,initialCapacity
就是11. 进行这种非常规的设计必然是有道理的,这道理猜都能猜的到吧,当然是为了减少冲突,直接哈希是肯定不可能,不如素数来得快,所以必然是做了一些操作。什么操作呢?后续且看代码。另外,即时哈希算法和桶做得再合理也免不了出现链表过长的情况(数组中一个坑里好长的链表)。链表过长会影响性能,数组形同虚设,所以,在jdk8中引入了红黑树 ,当链表长度过长(默认为8)时会将链表转换为红黑数,利用它快速增删改查的特点提高hashmap的性能。
解决完冲突后如何查找? 比如,在冲突发生后转换成链表或者红黑树后,此时意味着同一个key的hash相同,如何寻找对应的value呢,在每个Node中,除了hash和value之外,还存储着key,根据key找到对应的value。
确定索引 我们知道,数组的查询效率很高而链表很慢,hahsmap的查询效率与数组无异,我们存储的个k-v都以Node链表的形式放入table数组中,并且尽可能地使每个它分布均匀,每个位置上的元素只有一个,对于平时操作的增删改查都是以key的hash来进行查找,可以理解为table的index 了,当找到后最好是不用再遍历链表(最好是这样),所以非常的u迅速,那么,如何确定索引?根据上方的hash代码,总结起来三步:
取值(key的hashCode),高位运算,取模运算
我们知道,对于存入的任意key,只要它返回的hashCode相同,那么生成的hash码也一定相同。想让Node在数组中能均匀分布,我们首先想到的应该是对数组取模,这样一来一定是均匀分散在数组中 了,但是对于底层运算来说,取模的操作消耗还是比较大的,我们来看看hashmap是如何找到高效的替代方法的:
1 2 if ((p = tab[i = (n - 1 ) & hash]) == null ) tab[i] = newNode(hash, key, value, null );
在代码中有这么一行,n为数组长度,hash为key的哈希码,由于n总是2的n次方,所以(n-1)&hash
等价于对length取模,比使用%
具有更高的效率。
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 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 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 ; }
扩容 我们知道HashMap的存储基础是桶,也就是数组,一般涉及数组的扩容,都是重新定一个大的数组,然后将小的数组拷贝过去(参考ArrayList),那么这里的HashMap是如何做的呢?怎么处理内部的Node以及红黑数呢?
note: 要注意上方提到的一些实例变量,都是与HashMap相关的属性。
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 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; }
todo 扩容后的如何快速将原来的数据放置新的位置?