hashMap 是我们日常中最常用的一种集合类型,他继承了AbstractMap类 实现了Map<K,V> Cloneable,Serializable 接口
继承关系图如下
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable
HashMap是根据hashCode值进行存储的,大多数情况可以直接定位到值,所以具有很快的访问速度。但是是无顺序的。是非线程安全的,如果需要满足线程安全,可以使用Collection的SynchronizedMap 或者使用ConcurrentHashMap。
HashTable: 是线程安全的 但是并发不如ConcurrentHashMap,功能和HashMap 类似。
TreeMap: 因为TreeMap 实现了 Sortedmap接口,所以他存储时候有序的,默认是按照键的升序排列。
HashMap 是数组+链表+红黑树(jdk1.8增加红黑树部分),当链表长度大于8时转换为红黑树。
那么HashMap 到底底层数据具体储存的是什么?优点是什么呢?
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;
//key和key对比 value 和value 对比 if (Objects.equals(key, e.getKey()) && Objects.equals(value, e.getValue())) return true; } return false; } }
这个Node是HashMap的一个内部类,实现了Map.Entry<K,V>接口,所以本质就是键值对(映射),其中在jdk1.8中使用node替换了原来的entry
transient Entry[] table;
基于链地址法的原理使用put<K,V> 存储对象达到hashMap中,使用get(key)从中获取,我们在put的时候,先对key调用hashCode()方法来计算其哈希值 从而得到放的链表的储存位置。那么get方法就是通过对key计算哈希值来获取存放的位置,进而确定value。
如何确定哈希桶数组的索引位置?
不管是增删查改,定位到哈希桶数组的位置都是很重要的,通过对key的 hashcode值进行高位运算和取模运算。
方法一: static final int hash(Object key) { //jdk1.8 & jdk1.7 int h; // h = key.hashCode() 为第一步 取hashCode值 // h ^ (h >>> 16) 为第二步 高位参与运算 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } 方法二: static int indexFor(int h, int length) { //jdk1.7的源码,jdk1.8没有这个方法,但是实现原理一样的 return h & (length-1); //第三步 取模运算 }
如果key的hashCode返回值相同,那么方法一计算出来的值也是相同的,把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的,在HashMap中是这样做的:调用方法二来计算该对象应该保存在table数组的哪个索引处。
这个方法非常巧妙,它通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。且在jdk1.8中优化了高位运算的算法。
通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
接下里看hashmap如何put key的
public V put(K key, V value) {
// 对key的hashCode()做hash
return putVal(hash(key), key, value, false, true); }
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
//hash值 key value Node<K,V>[] tab; Node<K,V> p; int n, i;
//1.如果tab 为null 或者 length=0 运行resize 扩展 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
//i 是索引,如果索引为空 那么插入node if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else {
Node<K,V> e; K k;
//判断如何hash值相等,且key不为空且相等,那么替换value 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) // -1 for 1st TREEIFY_THRESHOLD =8 判断创建红黑树 treeifyBin(tab, hash); break; }
//如果key存在 直接覆盖value 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) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; // 增加fail-fast数值
//超过阈值就扩容 if (++size > threshold) resize(); afterNodeInsertion(evict); return null; }
扩容resize()
字面意思,就是扩充容量,在不停的想hashmap里面添加元素的时候,内部的数组无法装载更多的内容,就会扩大数组的长度,以便能装更多的数据。java数组是无法自动扩容的。那么久需要一个新的大的数组去代替原数组。
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; //之前的Node数组 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; }
//没有超过最大值,扩容为原来的2倍 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) 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 = 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 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; }
jdk1.8扩充的时候不需要想1.7一样重新计算hash 只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”,可以看看下图为16扩充为32的resize示意图:
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。
如果hashmap 通过链表将产生碰撞的元素组织起来。在jdk1.8中 ,如果在一个位置中,碰撞的元素超过一个限制,默认是8,则使用红黑树来代替链表,来提高速率,而如果两个不同的key计算出的哈希值相同,定位到同一个存储位置,那么我们称之为hash冲突/hash碰撞。
如果hash算法计算的哈希值越分散均匀,发生冲突的几率就越小,map懂得存储效率就越会高。当时table[]的大小也会决定发生碰撞的几率。如果table越大,即使算法较差也会冲突很小,反之,table越小,即使算法很好,也会发生很多碰撞。所以需要在空间成本和时间成本做权衡。
所以我们需要合适的table大小和好的hash算法。
transient Node<K,V>[] table; //哈希桶
既然说到哈希桶的大小,那么我们就要说一下扩容。从hashmap的构造方法中得知
int threshold; //是hashmap的阈值,用来判断hashmap储存的极限容量?? threshold= 容量* 负载因子 当hashmap存储的数量达到了阈值,hashmap就要增加容量 final float loadFactor; // 负载因子 int modCount; int size;
首先,Node[]table的初始长度length(默认值是16),Load factor为负载因子(默认值是0.75) threshold是HashMap所能容纳的最大数据量的Node(键值对)个数。threshold = length * Load factor。也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 16
static final float DEFAULT_LOAD_FACTOR = 0.75f;
结合公式得知,threshold 就是允许的最大存储值。超过这个数值,就要调用resize()方法扩容, 扩容后容量是之前的两倍。
size 就是hashmap中键值对的数量。而modCount 是几率hashmap内部结构发生变化的次数 ,内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。
有一个问题就是即使敷在引资和hash算法设计在合理,也免不了出现拉链过长的情况,一旦拉链过长就会严重影响hashmap的性能,于是在1.8中对数据结构做了优化,引入红黑树,当链表长途太长,默认是8,链表就会转为红黑树。利用红黑树快速增删查改的特性提高hashmap的性能。如下图
hash冲突的解决办法:
在hashmap中解决冲突的办法是采用链地址法。(常用的方法有:开放地址法,再哈希法,链地址法,建立公共溢出法 后续讨论 TODO)
线程不安全
在多线程使用场景中,应该避免使用hashmap,转向使用线程安全的ConcurrentHashMap
public class HashMapInfiniteLoop {
private static HashMap<Integer,String> map = new HashMap<Integer,String>(2,0.75f);
public static void main(String[] args) {
map.put(5, "C");
new Thread("Thread1") {
public void run() {
map.put(7, "B");
System.out.println(map);
};
}.start();
new Thread("Thread2") {
public void run() {
map.put(3, "A);
System.out.println(map);
};
}.start();
}
}
其中,map初始化为一个长度为2的数组,loadFactor=0.75,threshold=2*0.75=1,也就是说当put第二个key的时候,map就需要进行resize。
通过设置断点让线程1和线程2同时debug到transfer方法(3.3小节代码块)的首行。注意此时两个线程已经成功添加数据。放开thread1的断点至transfer方法的“Entry next = e.next;” 这一行;然后放开线程2的的断点,让线程2进行resize。结果如下图。
注意,Thread1的 e 指向了key(3),而next指向了key(7),其在线程二rehash后,指向了线程二重组后的链表。
线程一被调度回来执行,先是执行 newTalbe[i] = e, 然后是e = next,导致了e指向了key(7),而下一次循环的next = e.next导致了next指向了key(3)。
e.next = newTable[i] 导致 key(3).next 指向了 key(7)。注意:此时的key(7).next 已经指向了key(3), 环形链表就这样出现了。
于是,当我们用线程一调用map.get(11)时,悲剧就出现了——Infinite Loop。
JDK1.8与JDK1.7的性能对比
HashMap中,如果key经过hash算法得出的数组索引位置全部不相同,即Hash算法非常好,那样的话,getKey方法的时间复杂度就是O(1),如果Hash算法技术的结果碰撞非常多,假如Hash算极其差,所有的Hash算法结果得出的索引位置一样,那样所有的键值对都集中到一个桶中,或者在一个链表中,或者在一个红黑树中,时间复杂度分别为O(n)和O(lgn)。 鉴于JDK1.8做了多方面的优化,总体性能优于JDK1.7,下面我们从两个方面用例子证明这一点。
Hash较均匀的情况
为了便于测试,我们先写一个类Key,如下:
class Key implements Comparable<Key> {
private final int value;
Key(int value) {
this.value = value;
}
@Override
public int compareTo(Key o) {
return Integer.compare(this.value, o.value);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass())
return false;
Key key = (Key) o;
return value == key.value;
}
@Override
public int hashCode() {
return value;
}
}
这个类复写了equals方法,并且提供了相当好的hashCode函数,任何一个值的hashCode都不会相同,因为直接使用value当做hashcode。为了避免频繁的GC,我将不变的Key实例缓存了起来,而不是一遍一遍的创建它们。代码如下:
public class Keys {
public static final int MAX_KEY = 10_000_000;
private static final Key[] KEYS_CACHE = new Key[MAX_KEY];
static {
for (int i = 0; i < MAX_KEY; ++i) {
KEYS_CACHE[i] = new Key(i);
}
}
public static Key of(int value) {
return KEYS_CACHE[value];
}
}
现在开始我们的试验,测试需要做的仅仅是,创建不同size的HashMap(1、10、100、......10000000),屏蔽了扩容的情况,代码如下:
static void test(int mapSize) {
HashMap<Key, Integer> map = new HashMap<Key,Integer>(mapSize);
for (int i = 0; i < mapSize; ++i) {
map.put(Keys.of(i), i);
}
long beginTime = System.nanoTime(); //获取纳秒
for (int i = 0; i < mapSize; i++) {
map.get(Keys.of(i));
}
long endTime = System.nanoTime();
System.out.println(endTime - beginTime);
}
public static void main(String[] args) {
for(int i=10;i<= 1000 0000;i*= 10){
test(i);
}
}
在测试中会查找不同的值,然后度量花费的时间,为了计算getKey的平均时间,我们遍历所有的get方法,计算总的时间,除以key的数量,计算一个平均值,主要用来比较,绝对值可能会受很多环境因素的影响。结果如下:
通过观测测试结果可知,JDK1.8的性能要高于JDK1.7 15%以上,在某些size的区域上,甚至高于100%。由于Hash算法较均匀,JDK1.8引入的红黑树效果不明显,下面我们看看Hash不均匀的的情况。
Hash极不均匀的情况
假设我们又一个非常差的Key,它们所有的实例都返回相同的hashCode值。这是使用HashMap最坏的情况。代码修改如下:
class Key implements Comparable<Key> {
//...
@Override
public int hashCode() {
return 1;
}
}
仍然执行main方法,得出的结果如下表所示:
从表中结果中可知,随着size的变大,JDK1.7的花费时间是增长的趋势,而JDK1.8是明显的降低趋势,并且呈现对数增长稳定。当一个链表太长的时候,HashMap会动态的将它替换成一个红黑树,这话的话会将时间复杂度从O(n)降为O(logn)。hash算法均匀和不均匀所花费的时间明显也不相同,这两种情况的相对比较,可以说明一个好的hash算法的重要性。
小结
(1) 扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
(2) 负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。
(3) HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使用ConcurrentHashMap。
(4) JDK1.8引入红黑树大程度优化了HashMap的性能。
参考
Java 8系列之重新认识HashMap https://zhuanlan.zhihu.com/p/21673805
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
1.HashMap原理,内部数据结构?
底层使用哈希表(数组加链表)来存储,链表过长会将链表转成红黑树,以实现在O(logn)时间复杂度内查找
2.讲一下HashMap中的put方法过程?
对key求哈希值然后计算下标
如果没有哈希碰撞则直接放入槽中
如果碰撞了以链表的形式链接到后面
如果链表长度超过阈值(默认阈值是8),就把链表转成红黑树
如果节点已存在就替换旧值
如果槽满了(容量*加载因子),就需要resize
3.HashMap中哈希函数是怎么实现的?还有哪些hash实现方式?
高16bit不变,低16bit和高16bit做异或
(n-1)&hash获得下标
还有哪些哈希实现方式?(查资料和博客)
4.HashMap如何解决冲突,讲一下扩容过程。如果一个值在原数组中,扩容后移动到了新数组,位置肯定改变了,如何定位到这个值在新数组中的位置?
将节点加到链表后
容量扩充为原来的两倍,然后对每个节点重新计算哈希值
这个值只可能在两个地方:一种是在原下标位置,另一种是在下标为<原下标+原容量>的位置
5.抛开HashMap,哈希冲突有哪些解决方法?
开放地址法,链地址法
6.针对HashMap中某个Entry链太长,查找的时间复杂度可能达到O(n),如何优化?
将链表转为红黑树,JDK1.8已经实现
(1)为什么在JDK1.7的时候是先进行扩容后进行插入,而在JDK1.8的时候则是先插入后进行扩容的呢?
(2)为什么在JDK1.8中进行对HashMap优化的时候,把链表转化为红黑树的阈值是8,而不是7或者不是20呢(面试蘑菇街问过)?
哈希表如何解决Hash冲突?