Java Map类集合,与Collections类集合存在很大不一样。它是与Collection 类平级的一个接口。java
在集合框架中,经过部分视图方法这一根 微弱的线联系起来。node
(在以后的分享中,咱们会讨论到Collections 框架的内容)面试
Map类集合中的存储单位是K-V键值对,就是 使用必定的哈希算法造成一组比较均匀的哈希值做为Key,Value值挂在Key上。算法
Map类 的特色:bootstrap
Map集合类 | Key | Value | Super | JDK | 说明 |
---|---|---|---|---|---|
Hashtable | 不容许为 null | 不容许为 null | Dictionary | 1.0 | (过期)线程安全类 |
ConcurrentHashMap | 不容许为 null | 不容许为 null | AbstractMap | 1.5 | 锁分段技术或CAS(JDK8 及以上) |
TreeMap | 不容许为 null | 容许为 null | AbstractMap | 1.2 | 线程不安全(有序) |
HashMap | 容许为 null | 容许为 null | AbstractMap | 1.2 | 线程不安全(resize 死链问题) |
从jdk1.0-1.5,这几个重点KV集合类,见证了Java语言成为工业级语言的成长历程。
知识点:数组
keySet()
、values()
、entrySet()
,其中values()
方法返回的视图的集合实现类是Values extends AbstractCollection<V>
,没有实现add操做,实现了remove/clear等相关操做,调用add方法时会抛出异常。哈希算法 哈希值
在Object 类中,hashCode()方法是一个被native修饰的类,JavaDoc中描述的是返回该对象的哈希值。安全
那么哈希值这个返回值是有什么做用呢?数据结构
主要是保证基于散列的集合,如HashSet、HashMap以及HashTable等,在插入元素时保证元素不可重复,同时为了提升元素的插入删除便利效率而设计;主要是为了查找的便捷性而存在。
拿Set进行举例,app
众所周知,Set集合是不能重复,若是每次添加数据都拿新元素去和集合内部元素进行逐一地equal()比较,那么插入十万条数据的效率能够说是很是低的。框架
因此在添加数据的时候就出现了哈希表的应用,哈希算法也称之为散列算法,当添加一个值的时候,先去计算出它的哈希值,根据算出的哈希值将数据插入指定位置。这样的话就避免了一直去使用equal()比较的效率问题。
具体表如今:
上述第二种状况中,若是两个元素不相同,可是hashCode()相同,那就是发生了咱们所谓的哈希碰撞。
哈希碰撞的几率取决于hashCode()计算方式和空间容量的大小。
这种状况下,会在相同的位置,建立一个链表,把key值相同的元素存放到链表中。
在HashMap中就是使用拉链法来解决hashCode冲突。
hashCode是一个对象的标识,Java中对象的hashCode是一个int类型值。经过hashCode来指定数组的索引能够快速定位到要找的对象在数组中的位置,以后再遍历链表找到对应值,理想状况下时间复杂度为O(1),而且不一样对象能够拥有相同的hashCode。
在 JDK1.8 中,HashMap 是由 数组+链表+红黑树构成,新增了红黑树做为底层数据结构。
经过哈希来确认到数组的位置,若是发生哈希碰撞就以链表的形式存储 ,可是这样若是链表过长来的话,HashMap会把这个链表转换成红黑树来存储,阈值为8。
下面是HashMap的结构图:
/** * The table, initialized on first use, and resized as * necessary. When allocated, length is always a power of two. * (We also tolerate length zero in some operations to allow * bootstrapping mechanics that are currently not needed.) */ transient Node<K,V>[] table;
在JDK1.8中咱们了解到HashMap是由数组加链表加红黑树来组成的结构其中table就是HashMap中的数组。
/** * The number of key-value mappings contained in this map. */ transient int size;
HashMap中 键值对存储数量。
/** * The load factor for the hash table. * * @serial */ final float loadFactor;
负载因子。负载因子是权衡资源利用率与分配空间的系数。当元素总量 > 数组长度 * 负载因子
时会进行扩容操做。
/** * The next size value at which to resize (capacity * load factor). * * @serial */ // (The javadoc description is true upon serialization. // Additionally, if the table array has not been allocated, this // field holds the initial array capacity, or zero signifying // DEFAULT_INITIAL_CAPACITY.) int threshold;
扩容阈值。threshold = 数组长度 * 负载因子
。超事后执行扩容操做。
/** * The bin count threshold for using a tree rather than list for a * bin. Bins are converted to trees when adding an element to a * bin with at least this many nodes. The value must be greater * than 2 and should be at least 8 to mesh with assumptions in * tree removal about conversion back to plain bins upon * shrinkage. */ static final int TREEIFY_THRESHOLD = 8; /** * The bin count threshold for untreeifying a (split) bin during a * resize operation. Should be less than TREEIFY_THRESHOLD, and at * most 6 to mesh with shrinkage detection under removal. */ static final int UNTREEIFY_THRESHOLD = 6;
树形化阈值。当一个哈希桶存储的链表长度大于8 会将链表转换成红黑树,小于6时则从红黑树转换成链表。
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
能够看到实际执行添加元素的是putVal()操做,在执行putVal()以前,先是对key执行了hash()方法,让咱们看下里面作了什么
static final int hash(Object key) { int h; // key.hashCode():返回散列值也就是hashcode // ^ :按位异或 // >>>:无符号右移,忽略符号位,空位都以0补齐 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
key==null
说明,HashMap中是支持key为null的状况的。
一样的方法在Hashstable中是直接用key来获取hashCode,没有key==null
的判断,因此Hashstable是不支持key为null的。
再回来讲这个hash()方法。这个方法用专业术语来称呼就叫作扰动函数。
使用hash()也就是扰动函数,是为了防止一些实现比较差的hashCode()方法。换句话来讲,就是为了减小哈希碰撞。
JDK 1.8 的 hash方法 相比于 JDK 1.7 hash 方法更加简化,可是原理不变。咱们再看下JDK1.7中是怎么作的。
// code in JDK1.7 static int hash(int h) { // This function ensures that hashCodes that differ only by // constant multiples at each bit position have a bounded // number of collisions (approximately 8 at default load factor). h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
相比于 JDK1.8 的 hash 方法 ,JDK 1.7 的 hash 方法的性能会稍差一点点,由于毕竟扰动了 4 次。
知识延伸外链: JDK 源码中 HashMap 的 hash 方法原理是什么? - 胖君的回答 - 知乎
https://www.zhihu.com/questio...
再来看真正执行增长元素操做的putVal()方法,
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; // 当数组为空或长度为0,初始化数组容量(resize() 方法是初始化或者扩容用的) if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 计算数组下标 i = (n-1) & hash // 若是这个位置没有元素,则直接建立Node并存值 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)))) // hash值、key值相等,用e变量获取到当前位置这个元素的引用,后面用于替换已有的值 e = p; else if (p instanceof TreeNode) // 当前是以红黑树方式存储,执行其特有的putVal方法 -- putTreeVal 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) // -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) // onlyIfAbsent 若是为true - 不覆盖已存在的值 // 把新值赋值进去 e.value = value; afterNodeAccess(e); return oldValue; } } // 记录修改次数 ++modCount; // 判断元素数量是否超过阈值 超过则扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
这是一个常见的面试题。这个问题描述的设计,实际上为了服务于从Key映射到数组下标index的Hash算法。
前面提到了,咱们为了让HashMap存储高效,应该尽可能减小哈希碰撞,也就是说,应该让元素分配得尽量均匀。
Hash 值的范围值-2147483648
到2147483647
,先后加起来大概40亿的映射空间,只要哈希函数映射得比较均匀松散,通常应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。因此这个散列值是不能直接拿来用的。
因此才须要一个映射的算法。这个计算方式就是3.2中有出现的(n - 1) & hash
。
咱们来进一步演示一下这个算法:
key="book"
book
的hashCode值,结果为十进制的3029737,二进制的101110001110101110 1001。经过这种与运算的方式,可以和取模运算同样的效果hashCode % length
,在上述例子中就是3029737 % 16=9
。
而且经过位运算的方式大大提升了性能。
可能到这里,你仍是不知道为何长度必须是2的幂次方,也是由于这种位运算的方法。
长度16或者其余2的幂,Length-1的值是全部二进制位全为1,这种状况下,index的结果等同于HashCode后几位的值。只要输入的HashCode自己分布均匀,Hash算法的结果就是均匀的。若是HashMap的长度不是2的幂次方,会出现某些index永远不会出现的状况,这个显然不符合均匀分布的原则和指望。因此在源码里面一直都在强调power-of-two expansion
和size must be power of two
。
另外,HashMap 构造函数容许用户传入的容量不是 2 的 n 次方,由于它能够自动地将传入的容量转换为 2 的 n 次方。
/** * Returns a power of two size for the given target capacity. */ static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
接下来咱们来说讲HashMap扩容相关的知识。
HashMap的初始长度是16,假设HashMap中的键值对一直在增长,可是table数组容量一直不变,那么就会发生哈希碰撞,查找的效率确定会愈来愈低。因此当键值对数量超过某个阈值的时候,HashMap就会执行扩容操做。
那么扩容的阈值是怎么计算的呢?
阈值 = 数组长度 * 负载因子threshold = capacity * loadFactor
每次扩容后,threshold 加倍
上述计算就出如今resize()方法中。下面会详细解析这个方法。咱们先继续往下讲。
loadFactor这个参数,咱们以前提到过,负载因子是权衡资源利用率与分配空间的系数。至于为何是0.75呢?这个实际上就是一个做者认为比较好的权衡,固然你也能够经过构造方法手动设置负载因子 。public HashMap(int initialCapacity, float loadFactor) {...)
。
接下去再来到这里的主角resize()方法
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; } // newCap 变成原来的 两倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) // 执行扩容操做,新阈值 = 旧阈值 * 2 newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold // 初始阈值被手动设置过 // 数组容量 = 初始阈值 newCap = oldThr; else { // zero initial threshold signifies using defaults // 初始化操做 // 数组容量 = 默认初始容量 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引用的数组 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 { // preserve order .... } } } } return newTab; }
// 链表的处理 这个链表处理实际上很是的巧妙 // 定义了两条链 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; }
上述代码红黑树和链表的处理不知道你们看懂了没有,我反正在第一次看的时候有点晕乎。可是理解了以后有感受很是的巧妙。
拿链表处理打比方,它干的就是把在遍历旧的table数组的时候,把该位置的链表分红high链表和low链表。具体是什么意思呢?看下下面的举例。
A->B->C->D->E->F
这样一个链表。Hash & oldCapacity
的操做,Hash值为5的A/B/C计算以后为0,被分到了low链表,Hash为21的D/E/F被分到了high链表。红黑树相关的操做虽然代码不一样,可是实际上要干的事情是同样的。就是把相同位置的不一样Hash大小的链表元素在新table数组中进行分离。但愿讲到这里你能听懂。
Java7的HashMap会存在死循环的问题,主要缘由就在于,Java7中,HashMap扩容转移后,先后链表顺序倒置,在转移过程当中其余线程修改了原来链表中节点的引用关系,致使在某Hash桶位置造成了环形链表,此时get(key),若是key不存在于这个HashMap且key的Hash结果等于那个造成了循环链表的Hash位置,那么程序就会进入死循环;
Java8在一样的前提下并不会引发死循环,缘由是Java8扩容转移后先后链表顺序不变,保持以前节点的引用关系。
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]; // JDK8 移出了hashSeed计算,由于计算时会调用Random.nextInt(),存在性能问题 // 很重要的transfer() transfer(newTable, initHashSeedAsNeeded(newCapacity)); // 在此步骤完成以前,旧表上依然能够进行元素的增长操做,这就是对象丢失缘由之一 table = newTable; threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }
// 寥寥几行,却极为重要 void transfer(Entry[] newTable, boolean rehash) { // newCapacity 是旧表的两倍,这个扩容大小 int newCapacity = newTable.length; // 使用foreach 方式遍历整个数组下标 for (Entry<K,V> e : table) { // 若是在这个slot上面存在元素,则开始遍历上面的链表,知道e==null,退出循环 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); // 当前元素老是直接放在数组下标的slot上,而不是放在链表的最后 // 倒序插入新表 // 这里是造成死链的关键步骤 e.next = newTable[i]; newTable[i] = e; e = next; } } }
延伸阅读。
不少人可能都会答上一句,为了提升查找性能,但更确切地来讲的话,采用红黑树的方法是为了提升在极端哈希冲突的状况下提升HashMap的性能。
极端哈希冲突的状况下,去测量Java7和Java8版本的HashMap的查询性能差距。
Java 7的结果是能够预期的。 HashMap.get()的性能损耗与HashMap自己的大小成比例增加。 因为全部键值对都在一个巨大的链表中的同一个桶中,查找一个条目须要平均遍历一半这样的列表(大小为n)。 所以O(n)复杂性在图上可视化。
与此相对的是Java8,性能提升了不少,发生灾难性哈希冲突的状况下,在JDK 8上执行的相同基准测试会产生O(logn)最差状况下的性能。
关于此处的算法优化实际上在 JEP-180中有描述到,
另外若是Key对象若是不是Comparable的话,那么发生重大哈希冲突时,插入和删除元素的效率会变不好。(由于底层实现时红黑树,须要经过compare方法去肯定顺序)
当HashMap想要为一个键找到对应的位置时,它会首先检查新键和当前检索到的键之间是否能够比较(也就是实现了Comparable接口)。若是不能比较,它就会经过调用tieBreakOrder(Objecta,Object b) 方法来对它们进行比较。这个方法首先会比较两个键对象的类名,若是相等再调用System.identityHashCode 方法进行比较。这整个过程对于咱们要插入的500000个元素来讲是很耗时的。另外一种状况是,若是键对象是可比较的,整个流程就会简化不少。由于键对象自身定义了如何与其它键对象进行比较,就没有必要再调用其余的方法,因此整个插入或查找的过程就会快不少。值得一提的是,在两个可比的键相等时(compareTo 方法返回 0)的状况下,仍然会调用tieBreakOrder 方法。
又可能会有人说了,哪有这么极端的哈希冲突?
这个其实是一个安全性的考虑,虽然在正常状况下不多有可能发生不少冲突。可是想象一下,若是Key来自不受信任的来源(例如从客户端收到的HTTP头名称),那么就有可能收到伪造key值,而且这种作法不难,由于哈希算法是你们都知道的,假设有人有心去伪造相同的哈希值的key值,那么你的HashMap中就会出现上述这种极端哈希冲突的状况。 如今,若是你去对这个HashMap执行屡次的查询请求,就会发现程序执行查询的效率会变得很慢,cpu占用率很高,程序甚至会拒绝对外提供服务。
延伸外链: https://www.yuque.com/docs/sh...