0x00 背景知识
首先HashMap
是基于hash表的,hash表的相关知识及其存储原理,可以参考我之前写的一篇文章:查找算法总结 ,要着重看此文章关于冲突处理所讲述的拉链法。
我们仅在这稍微明确一点概念:首先对于拉链式的hash表,其是由数组和链表共同组成的,当然Java中还引入了红黑树,当链表长度大于8时,将链表转为红黑树以提高查找效率。我们先来看上面的这个图,保存这个hash表的数组称之为桶(bucket),而每个桶中保存了一系列的数据入口(entries)。
同理,我们在这明确一个概念“等同 ”:我们假定如果两个对象其hashCode
相同,且任一一个调用equals
方法与另一个返回true
,即认为两个对象等同。
同时,在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 transient int size;int threshold;final float loadFactor;
HashMap
和HashTable
的区别
首先,HashMap
是非线程安全的,而HashTable
则是线程安全的,也因此,HashMap
的效率较高。除此之外,在HashMap
中null
可以用来作为键,而HashTable
则不可以,否则抛出NullPointerException
。
HashMap
和HashSet
的区别
HashSet
底层基于HashMap
实现,其实现了Set
接口,仅用来存储对象而不是键值对。而且HashSet
较HashMap
效率低。
HashMap
的长度为什么是2的幂次方
因为经计算出来的hash值需要对数组长度进行取余运算,然后用余数定位其在hash表中的位置,为了优化取余运算的效率,人们发现取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作,即:hash%length等价于hash&(length-1),前提是length是2的n次方,采用二进制位操作&,相对于%能够提高运算效率。
0x01 put方法
先来看一张流程图:
图片非原创,来自文章:HashMap原理深入理解
正好我们就着源代码来说说这张图,首先我们来看put
方法的源代码:
1 2 3 public V put (K key, V value) { return putVal(hash(key), key, value, false , true ); }
其直接将工作转交至了putVal
方法中,我们直接来看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 65 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 ; }
在上述两个代码中,我们可以看到两个方法afterNodeAccess
和afterNodeInsertion
这两个方法在HashMap
里面的实现是空的,在此处什么都不做,其注释中写道:Callbacks to allow LinkedHashMap
post-actions,即适用于LinkedHashMap
实现一些后置功能的回调函数。
JDK7 put
方法为什么线程不安全
以下内容仅适用于JDK7之前的版本,即对链表的插入方法为头插法,JDK8以后,链表的插入方法改为了尾插法,从此之后就不存在这个问题了 。
众所周知,HashMap
不是线程安全的,多线程的情况下操作HashMap
的put
操作有可能导致死循环,原因在于HashMap
扩容使用的resize
方法,由于扩容是新建一个数组,然后复制原数据到数组,由于数组下标挂有链表,所以需要复制链表,但多线程操作有可能导致环形链表。
我们先来看JDK7 HashMap
实现中的transfer
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 void transfer (Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { while (null != e) { Entry<K,V> next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); e.next = newTable[i]; newTable[i] = e; e = next; } } }
我们假设没有执行resize
之前Entry
数组的结构如下:
则其在单线程的情况下,正确执行扩容操作后的样子如下:
好了,现在我们来看多线程的情况,假设线程A执行扩容操作,在上述代码片中标记部分线程A被挂起,则目前线程A内的状态如下:
这个时候线程B介入了,线程B也想进行扩容操作,而且恰好线程B的扩容操作还操作完了,则线程B内的状态如下:
这是对于值为3, 7, 5的这三个对象来说,其状态为:
1 2 tab[1 ]=5 , 5. next=null tab[3 ]=7 , 7. next=3 , 3. next=null
然后CPU又切换时间片到线程A,线程A继续运行,在Java中代码中的临时变量e
和next
实际上都为引用类型变量。线程A继续操作,线程A的newTable
变为了如下状态:
继续循环:
1 2 3 4 5 e=7 next=e.next ----> next=3 【从主存中取值】 e.next=newTable[3 ] ----> e.next=3 (7. next=3 )【从主存中取值】 newTable[3 ]=e ----> newTable[3 ]=7 e=next ----> e=3
此时状态如下:
e=3
于是又再一次进入循环:
1 2 3 4 5 e=3 next=e.next ----> next=null e.next=newTable[3 ] ----> e.next=7 (3. next=7 ) newTable[3 ]=e ----> newTable[3 ]=3 e=next ----> e=null
此时线程状态如下,并结束循环:
此时newTable[3]
中所保存的链表即成了循环链表,当查找一个元素,假设说8,经计算indexOf(8) = 3
,那么将会在table[3]
所存储链表中查找,此时get(8)
操作即为一个死循环。
JDK8之后的put
方法为什么线程不安全?
JDK8中链表的插入方法由JDK7中的头插入改为了尾插入,所以上面会导致死循环的那个问题在JDK8中已经不复存在了。但HashMap
依旧是线程不安全的,因为写入丢失。
现在考虑一种情况,2个线程同时要向一个HashMap
中插入元素,而这两个元素恰好又有相同的hash值,假设这个hash值所对应的数组下标所在位置原先是没有元素的,那线程1直接进行赋值,然后CPU切换到了线程2,线程2又进行了一次直接复制,线程2赋的值覆盖了线程1的值,导致了写入丢失的问题。
其次,如果线程1和2向HashMap
插入元素时,需要进行扩容操作的话,线程1和线程2都会各自生成各自的newTab
,然后最后tab = resize()
,到最后table
里面只保存了最后一个线程的newTab
,其余线程的均会丢失。
0x02 get方法
先来看一下get
方法:
1 2 3 4 public V get (Object key) { Node<K,V> e; return (e = getNode(hash(key), key)) == null ? null : e.value; }
其主要工作在getNode
方法中,用于通过键值hash
和键值对象获取Node
,如果其得到的值为null
那就返回null
,否则返回Node
对象的value
,我们着重来看getNode
方法:
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 final Node<K,V> getNode (int hash, Object key) { Node<K,V>[] tab; Node<K,V> first, e; int n; K k; if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1 ) & hash]) != null ) { if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k)))) return first; if ((e = first.next) != null ) { if (first instanceof TreeNode) return ((TreeNode<K,V>)first).getTreeNode(hash, key); do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null ); } } return null ; }
0x03 remove方法
remove
方法用于从HashMap
中移除一个元素,代码如下:
1 2 3 4 public V remove (Object key) { Node<K,V> e; return (e = removeNode(hash(key), key, null , false , true )) == null ? null : e.value; }
其主要工作在removeNode
中,我们来看其源代码:
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 final Node<K,V> removeNode (int hash, Object key, Object value, boolean matchValue, boolean movable) { Node<K,V>[] tab; Node<K,V> p; int n, index; if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1 ) & hash]) != null ) { Node<K,V> node = null , e; K k; V v;. if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) node = p; else if ((e = p.next) != null ) { if (p instanceof TreeNode) node = ((TreeNode<K,V>)p).getTreeNode(hash, key); else { do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { node = e; break ; } p = e; } while ((e = e.next) != null ); } } if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) { if (node instanceof TreeNode) ((TreeNode<K,V>)node).removeTreeNode(this , tab, movable); else if (node == p) tab[index] = node.next; else p.next = node.next; ++modCount; --size; afterNodeRemoval(node); return node; } } return null ; }
0x04 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 95 96 97 98 99 100 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; }
对判断条件(e.hash & oldCap) == 0
的理解
扩容后重新计算元素在新表中的位置的时候,用到了一个非常巧妙的算法,首先我们每次重新计算,都是将原数组大小n乘以2。我们现在假设原数组大小为16,则新数组大小即为32,我们计算某hash值在数组中的位置时,使用的方法为e.hash & (n - 1)
,当n翻一番的时候,这个产生的结果只可能出现2种情况,一种是不变,如下:
1 2 3 4 5 6 7 8 扩容前n为16时: n-1 0000 0000 0000 0000 0000 0000 0000 1111 hash 1111 1111 1111 1111 0000 1111 0000 0101 hash & (n-1) 0000 0000 0000 0000 0000 0000 0000 0101 扩容后n为32时: n-1 0000 0000 0000 0000 0000 0000 0001 1111 hash 1111 1111 1111 1111 0000 1111 0000 0101 hash & (n-1) 0000 0000 0000 0000 0000 0000 0000 0101
首先一个对象的hash值只与这个对象有关,与其他因素无关,所以扩容前后hash值是不变的,变的只有n-1
的值,在上例中,扩容前后的这个元素在数组中的位置依旧保持不变
另一种是结果也跟着移动2次幂的位置,如下:
1 2 3 4 5 6 7 8 扩容前n为16时: n-1 0000 0000 0000 0000 0000 0000 0000 1111 hash 1111 1111 1111 1111 0000 1111 0001 0101 hash & (n-1) 0000 0000 0000 0000 0000 0000 0000 0101 扩容后n为32时: n-1 0000 0000 0000 0000 0000 0000 0001 1111 hash 1111 1111 1111 1111 0000 1111 0001 0101 hash & (n-1) 0000 0000 0000 0000 0000 0000 0001 0101
在这种情况下,我们只需要看新增的那个bit位是0还是1就好了,是0的话索引没变,是1的话索引变成原索引加oldCap
。
我们发现新增的那个bit位的位置实际上就是oldCap
(n)的位置,如下:
1 2 3 4 5 n 0000 0000 0000 0000 0000 0000 0001 0000 扩容后数组位置不变的情况: hash 1111 1111 1111 1111 0000 1111 0000 0101 扩容后数组位置发生变化的情况: hash 1111 1111 1111 1111 0000 1111 0001 0101
我们发现只要判断hash & n
是否等于1就可以判断出扩容之后的位置是否发生变化来,由此判断条件(e.hash & oldCap) == 0
的意思即为判断扩容后的位置是否发生变化。
0x05 hash方法
下面再来看一下其采用的Hash函数:
1 2 3 4 static final int hash (Object key) { int h; return (key == null ) ? 0 : (h = key.hashCode()) ^ (h >>> 16 ); }
显然,通过其hash函数所得的hash码值与这个对象的hashCode
有着直接的关联,这就产生了一个问题,首先hashCode
的返回值是依据当前对象在JVM中的内存地址算出来的,当我们用一个自定义类的对象来做key
的话,同样内容的两个对象,get
操作就会出现问题,看代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public class Main { public static void main (String[] args) { Person boy = new Person ("Qun" , 22 ); Person girl = new Person ("Hui" , 23 ); HashMap<Person, Person> couples = new HashMap <Person, Person>(); couples.put(boy, girl); Person someone = new Person ("Qun" , 22 ); System.out.println(couples.get(someone)); } } public class Person { private String name; private int age; public Person (String name, int age) { this .name = name; this .age = age; } }
get
函数会返回null
,someone
和boy
虽说看上去内容是一样的,但是实际上是两个对象,二者的hashCode
会返回不一样的值,所得到的用于查找的hash码值也会不一样,所以get
失败。好,我们来重写一下Person
类的hashCode
让他由属性name
和age
来决定,代码如下:
1 2 3 4 @Override public int hashCode () { return name.hashCode() + age; }
TL;DR
String
类型的对象所产生的hashCode
值是由其所保存的字符串决定的,字符串相同,hashCode
值也相同,字符串不同,hashCode
值也不同。我们可以在其文档中,找到相关hashCode
值产生算法的描述:
1 s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
这样就可以保证两个内容相同的Person
对象产生的hashCode
也相同了,让我们重新运行代码,依旧输出null
,经检查发现get
函数不仅会判断key
的hash值相同,同时也会使用equals
函数来判断两个对象是否等同,重写equals
函数如下:
1 2 3 4 5 6 @Override public boolean equals (Object obj) { return obj instanceof Person && this .name.equals(((Person) obj).name) && this .age == ((Person) obj).age; }
这样程序就返回了正确的结果。由此可以得到一个结论,查找HashMap
时,get
操作会先通过hashCode
计算其相关的hash
码值,当这个hash
码值相同时,会调用equals
方法来检验当前对象与数组中hash
码值相同的对象是否等同,如果等同即会返回其关联的value
。
这样的hash计算方法产生的hash碰撞的概率在其源码里有详细的介绍,摘抄如下:
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
大意是在理想状况下,链表内元素的个数是服从泊松分布(Poisson distribution)的,其还给出了链表内元素个数所出现的概率,例如链表内有1个元素的概率是0.3032,超过8个元素的概率就在千万分之一了。也就说真正需要转化成红黑树的概率其实是很小的。