HashMap底层维护一个数组,数组中的每一项都是一个Entryhtml
transient Entry<K,V>[] table;
咱们向 HashMap 中所放置的对象其实是存储在该数组当中; java
而Map中的key,value则以Entry的形式存放在数组中算法
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; int hash;
而这个Entry应该放在数组的哪个位置上(这个位置一般称为位桶或者hash桶,即hash值相同的Entry会放在同一位置,用链表相连),是经过key的hashCode来计算的。shell
final int hash(Object k) { int h = 0; h ^= k.hashCode(); h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
经过hash计算出来的值将会使用indexFor方法找到它应该所在的table下标:数组
static int indexFor(int h, int length) { return h & (length-1); }
这个方法其实至关于对table.length取模。安全
当两个key经过hashCode计算相同时,则发生了hash冲突(碰撞),HashMap解决hash冲突的方式是用链表。数据结构
当发生hash冲突时,则将存放在数组中的Entry设置为新值的next(这里要注意的是,好比A和B都hash后都映射到下标i中,以前已经有A了,当map.put(B)时,将B放到下标i中,A则为B的next,因此新值存放在数组中,旧值在新值的链表上)多线程
示意图:并发
因此当hash冲突不少时,HashMap退化成链表。app
总结一下map.put后的过程:
当向 HashMap 中 put 一对键值时,它会根据 key的 hashCode 值计算出一个位置, 该位置就是此对象准备往数组中存放的位置。
若是该位置没有对象存在,就将此对象直接放进数组当中;若是该位置已经有对象存在了,则顺着此存在的对象的链开始寻找(为了判断是不是否值相同,map不容许<key,value>键值对重复), 若是此链上有对象的话,再去使用 equals方法进行比较,若是对此链上的每一个对象的 equals 方法比较都为 false,则将该对象放到数组当中,而后将数组中该位置之前存在的那个对象连接到此对象的后面。
值得注意的是,当key为null时,都放到table[0]中
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; }
当size大于threshold时,会发生扩容。 threshold等于capacity*load factor
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); }
jdk7中resize,只有当 size>=threshold而且 table中的那个槽中已经有Entry时,才会发生resize。即有可能虽然size>=threshold,可是必须等到每一个槽都至少有一个Entry时,才会扩容。还有注意每次resize都会扩大一倍容量
一直到JDK7为止,HashMap的结构都是这么简单,基于一个数组以及多个链表的实现,hash值冲突的时候,就将对应节点以链表的形式存储。
这样子的HashMap性能上就抱有必定疑问,若是说成百上千个节点在hash时发生碰撞,存储一个链表中,那么若是要查找其中一个节点,那就不可避免的花费O(N)的查找时间,这将是多么大的性能损失。这个问题终于在JDK8中获得了解决。再最坏的状况下,链表查找的时间复杂度为O(n),而红黑树一直是O(logn),这样会提升HashMap的效率。
JDK7中HashMap采用的是位桶+链表的方式,即咱们常说的散列链表的方式,而JDK8中采用的是位桶+链表/红黑树(有关红黑树请查看红黑树)的方式,也是非线程安全的。当某个位桶的链表的长度达到某个阀值的时候,这个链表就将转换成红黑树。
JDK8中,当同一个hash值的节点数不小于8时,将再也不以单链表的形式存储了,会被调整成一颗红黑树(上图中null节点没画)。这就是JDK7与JDK8中HashMap实现的最大区别。
接下来,咱们来看下JDK8中HashMap的源码实现。
JDK中Entry的名字变成了Node,缘由是和红黑树的实现TreeNode相关联。
transient Node<K,V>[] table;
当冲突节点数不小于8-1时,转换成红黑树。
static final int TREEIFY_THRESHOLD = 8;
以put方法在JDK8中有了很大的改变
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); } final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; //若是当前map中无数据,执行resize方法。而且返回n if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; //若是要插入的键值对要存放的这个位置恰好没有元素,那么把他封装成Node对象,放在这个位置上就完事了 if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); //不然的话,说明这上面有元素 else { Node<K,V> e; K k; //若是这个元素的key与要插入的同样,那么就替换一下,也完事。 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; //1.若是当前节点是TreeNode类型的数据,执行putTreeVal方法 else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); else { //仍是遍历这条链子上的数据,跟jdk7没什么区别 for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); //2.完成了操做后多作了一件事情,判断,而且可能执行treeifyBin方法 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) //true || -- e.value = value; //3. afterNodeAccess(e); return oldValue; } } ++modCount; //判断阈值,决定是否扩容 if (++size > threshold) resize(); //4. afterNodeInsertion(evict); return null; }
treeifyBin()就是将链表转换成红黑树。
以前的indefFor()方法消失 了,直接用(tab.length-1)&hash,因此看到这个,表明的就是数组的下角标。
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
为何说HashMap是线程不安全的呢?它在多线程环境下,会发生什么状况呢?
咱们都知道HashMap初始容量大小为16,通常来讲,当有数据要插入时,都会检查容量有没有超过设定的thredhold,若是超过,须要增大Hash表的尺寸,可是这样一来,整个Hash表里的元素都须要被重算一遍。这叫rehash,这个成本至关的大。
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, initHashSeedAsNeeded(newCapacity)); table = newTable; threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }
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; } } }
大概看下transfer:
通过这几步,咱们会发现转移的时候是逆序的。假如转移前链表顺序是1->2->3,那么转移后就会变成3->2->1。这时候就有点头绪了,死锁问题不就是由于1->2的同时2->1形成的吗?因此,HashMap 的死锁问题就出在这个transfer()
函数上。
单线程状况下,rehash 不会出现任何问题:
<key,value>
从新rehash到新 Hash 表的过程如图所示:
为了思路更清晰,咱们只将关键代码展现出来
while(null != e) { Entry<K,V> next = e.next; e.next = newTable[i]; newTable[i] = e; e = next; }
Entry<K,V> next = e.next;
——由于是单链表,若是要转移头指针,必定要保存下一个结点,否则转移后链表就丢了e.next = newTable[i];
——e 要插入到链表的头部,因此要先用 e.next 指向新的 Hash 表第一个元素(为何不加到新链表最后?由于复杂度是 O(N))newTable[i] = e;
——如今新 Hash 表的头指针仍然指向 e 没转移前的第一个元素,因此须要将新 Hash 表的头指针指向 ee = next
——转移 e 的下一个结点假设这里有两个线程同时执行了put()
操做,并进入了transfer()
环节
while(null != e) { Entry<K,V> next = e.next; //线程1执行到这里被调度挂起了 e.next = newTable[i]; newTable[i] = e; e = next; }
那么如今的状态为:
从上面的图咱们能够看到,由于线程1的 e 指向了 key(3),而 next 指向了 key(7),在线程2 rehash 后,就指向了线程2 rehash 后的链表。
而后线程1被唤醒了:
e.next = newTable[i]
,因而 key(3)的 next 指向了线程1的新 Hash 表,由于新 Hash 表为空,因此e.next = null
,newTable[i] = e
,因此线程1的新 Hash 表第一个元素指向了线程2新 Hash 表的 key(3)。好了,e 处理完毕。e = next
,将 e 指向 next,因此新的 e 是 key(7)而后该执行 key(3)的 next 节点 key(7)了:
Entry<K,V> next = e.next
,那么 next 就是 key(3)了e.next = newTable[i]
,因而key(7) 的 next 就成了 key(3)newTable[i] = e
,那么线程1的新 Hash 表第一个元素变成了 key(7)e = next
,将 e 指向 next,因此新的 e 是 key(3)这时候的状态图为:
而后又该执行 key(7)的 next 节点 key(3)了:
Entry<K,V> next = e.next
,那么 next 就是 nulle.next = newTable[i]
,因而key(3) 的 next 就成了 key(7)newTable[i] = e
,那么线程1的新 Hash 表第一个元素变成了 key(3)e = next
,将 e 指向 next,因此新的 e 是 key(7)这时候的状态如图所示:
很明显,环形链表出现了!!固然,如今尚未事情,由于下一个节点是 null,因此transfer()
就完成了,等put()
的其他过程搞定后,HashMap 的底层实现就是线程1的新 Hash 表了。
若是在使用迭代器的过程当中有其余线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast策略。
这个异常意在提醒开发者及早意识到线程安全问题,具体缘由请查看ConcurrentModificationException的缘由以及解决措施
顺便再记录一个HashMap的问题:
为何String, Interger这样的wrapper类适合做为键? String, Interger这样的wrapper类做为HashMap的键是再适合不过了,并且String最为经常使用。由于String是不可变的,也是final的,并且已经重写了equals()和hashCode()方法了。其余的wrapper类也有这个特色。不可变性是必要的,由于为了要计算hashCode(),就要防止键值改变,若是键值在放入时和获取时返回不一样的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其余的优势如线程安全。若是你能够仅仅经过将某个field声明成final就能保证hashCode是不变的,那么请这么作吧。由于获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是很是重要的。若是两个不相等的对象返回不一样的hashcode的话,那么碰撞的概率就会小些,这样就能提升HashMap的性能。
在淘宝内网里看到同事发了贴说了一个 CPU 被 100% 的线上故障,而且这个事发生了不少次,缘由是在 Java 语言在并发状况下使用 HashMap 形成 Race Condition,从而致使死循环。这个事情我四、5 年前也经历过,原本以为没什么好写的,由于 Java 的 HashMap 是非线程安全的,因此在并发下必然出现问题。可是,我发现近几年,不少人都经历过这个事(在网上查“HashMap Infinite Loop”能够看到不少人都在说这个事)因此,以为这个是个广泛问题,须要写篇疫苗文章说一下这个事,而且给你们看看一个完美的“Race Condition”是怎么造成的。
问题的症状
从前咱们的 Java 代码由于一些缘由使用了 HashMap 这个东西,可是当时的程序是单线程的,一切都没有问题。后来,咱们的程序性能有问题,因此须要变成多线程的,因而,变成多线程后到了线上,发现程序常常占 了 100% 的 CPU,查看堆栈,你会发现程序都 Hang 在了 HashMap.get ()这个方法上了,重启程序后问题消失。可是过段时间又会来。并且,这个问题在测试环境里可能很难重现。
咱们简单的看一下咱们本身的代码,咱们就知道 HashMap 被多个线程操做。而 Java 的文档说 HashMap 是非线程安全的,应该用 ConcurrentHashMap。
可是在这里咱们能够来研究一下缘由。
Hash 表数据结构
我须要简单地说一下 HashMap 这个经典的数据结构。
HashMap 一般会用一个指针数组(假设为 table[])来作分散全部的 key,当一个 key 被加入时,会经过 Hash 算法经过 key 算出这个数组的下标i,而后就把这个插到 table[i]中,若是有两个不一样的 key 被算在了同一个i,那么就叫冲突,又叫碰撞,这样会在 table[i]上造成一个链表。
咱们知道,若是 table[]的尺寸很小,好比只有 2 个,若是要放进 10 个 keys 的话,那么碰撞很是频繁,因而一个O(1) 的查找算法,就变成了链表遍历,性能变成了O(n),这是 Hash 表的缺陷(可参看《Hash Collision DoS 问题》)。
因此,Hash 表的尺寸和容量很是的重要。通常来讲,Hash 表这个容器当有数据要插入时,都会检查容量有没有超过设定的 thredhold,若是超过,须要增大 Hash 表的尺寸,可是这样一来,整个 Hash 表里的无素都须要被重算一遍。这叫 rehash,这个成本至关的大。
相信你们对这个基础知识已经很熟悉了。
HashMap 的 rehash 源代码
下面,咱们来看一下 Java 的 HashMap 的源代码。
Put 一个 Key,Value 对到 Hash 表中:
public V put (K key, V value) { ...... //算 Hash 值 int hash = hash (key.hashCode ()); int i = indexFor (hash, table.length); //若是该 key 已被插入,则替换掉旧的 value (连接操做) 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++; //该 key 不存在,须要增长一个结点 addEntry (hash, key, value, i); return null; }
检查容量是否超标
void addEntry (int hash, K key, V value, int bucketIndex) { Entry<K,V> e = table[bucketIndex]; table[bucketIndex] = new Entry<K,V>(hash, key, value, e); //查看当前的 size 是否超过了咱们设定的阈值 threshold,若是超过,须要 resize if (size++ >= threshold) resize (2 * table.length); }
新建一个更大尺寸的 hash 表,而后把数据从老的 Hash 表中迁移到新的 Hash 表中。
void resize (int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; ...... //建立一个新的 Hash Table Entry[] newTable = new Entry[newCapacity]; //将 Old Hash Table 上的数据迁移到 New Hash Table 上 transfer (newTable); table = newTable; threshold = (int)(newCapacity * loadFactor); }
迁移的源代码,注意高亮处:
void transfer (Entry[] newTable) { Entry[] src = table; int newCapacity = newTable.length; //下面这段代码的意思是: // 从 OldTable 里摘一个元素出来,而后放到 NewTable 中 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); } } }
好了,这个代码算是比较正常的。并且没有什么问题。
正常的 ReHash 的过程
画了个图作了个演示。
并发下的 Rehash
1)假设咱们有两个线程。我用红色和浅蓝色标注了一下。
咱们再回头看一下咱们的 transfer 代码中的这个细节:
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);
而咱们的线程二执行完成了。因而咱们有下面的这个样子。
注意,由于 Thread1 的 e 指向了 key (3),而 next 指向了 key (7),其在线程二 rehash 后,指向了线程二重组后的链表。咱们能够看到链表的顺序被反转后。
2)线程一被调度回来执行。
3)一切安好。
线程一接着工做。把 key (7) 摘下来,放到 newTable[i]的第一个,而后把e和 next 往下移。
4)环形连接出现。
e.next = newTable[i] 致使 key (3) .next 指向了 key (7)
注意:此时的 key (7) .next 已经指向了 key (3), 环形链表就这样出现了。
因而,当咱们的线程一调用到,HashTable.get (11) 时,悲剧就出现了——Infinite Loop。
其它
有人把这个问题报给了 Sun,不过 Sun 不认为这个是一个问题。由于 HashMap 原本就不支持并发。要并发就用 ConcurrentHashmap
http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=6423457
我在这里把这个事情记录下来,只是为了让你们了解并体会一下并发环境下的危险。
原文: