在开始之前,先过一遍本博客的重点
• HashMap寻值的速度快是因为HashMap的键会被映射成Hash值,从而避开用equal方法遍历来加快寻值速度。
• HashMap有初始容量和加载因子两个参数来控制性能。当条目大于容量*加载因子时,容量翻一倍。
• HashMap和Hashtable的区别在于线程安全性,同步,以及速度。
下面开始正文
1.HashMap的具体实现
Java为数据结构中的映射定义了一个接口java.util.Map,此接口主要有四个常用的实现类,分别是HashMap、Hashtable、LinkedHashMap和TreeMap,类继承关系如下图所示:
HashMap中我们最常用的就是put(K, V)和get(K)。我们都知道,HashMap的K值是唯一的,所以查询和修改数据的时候只要用equal遍历所有的key值就可以了,但我们知道直接遍历查询的时间复杂度为O(n),在数据量比较大时效率不高,所以在java中运用Hash算法来对所有的key进行运算加入数组中。查询的时候直接用数组下标来访问数据(时间复杂度变成了O(1)),以此绕开了用equal遍历带来的效率损失。
HashMap根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。映射中的key是不可变对象。不可变对象是该对象在创建后它的哈希值不会被改变。如果对象的哈希值发生变化,Map对象很可能就定位不到映射的位置了。
但我们知道,Hash算法具有的一个缺陷是即使在key值不同的情况下计算出来的hashCode仍有可能相同,即key1!=key2,但H(key1)=H(key2)。要寻找解决的办法那就要从HashMap的数据结构说起了。HashMap本身是一群键值对的集合,每一个键值对都被放入一个entry类中,当HashCode冲突时便在这个HashCode所对应的entry对象后面再链接上一个对象。相当于数组的每一项后面连上一个链表。这样就可以解决HashCode碰撞的问题。
但还有一点需要注意,链表查询某个数据用的方法还是遍历,所以当碰撞的数据量比较大时还是会出现效率上的损失。所以在jdk1.8中加入了当链表的长度大于8是自动转换为红黑树。我们知道有序二叉树的查询算法的时间复杂度为O(lg n),而红黑树在这个基础上再进行了优化,在二叉查找树的基础上增加了着色和相关的性质使得红黑树相对平衡,从而保证了红黑树的查找、插入、删除的时间复杂度最坏为O(log n),优于顺序查找的O(n)。
尽管我们可以用红黑树的方式减少碰撞带来的效率损失,但我们还是更加倾向于减少碰撞的数量。如果数组(哈希桶)很大,即使较差的Hash算法也会比较分散,如果哈希桶数组很小,即使好的Hash算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少Hash碰撞。那么通过什么方式来控制map使得Hash碰撞的概率又小,哈希桶数组占用空间又少呢?答案就是好的Hash算法和扩容机制。
这就要从HashMap的构造方法说起,HashMap一共有4个构造方法。
HashMap()
//构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。
HashMap(int initialCapacity)
//构造一个带指定初始容量和默认加载因子 (0.75) 的空HashMap。
HashMap(int initialCapacity,float loadFactor)
//构造一个带指定初始容量和加载因子的空 HashMap。
HashMap(Map<? extendsK,?extendsV> m)
//构造一个映射关系与指定 Map 相同的 HashMap。
第一个参数initialCapacity是HashMap的初始容量,第二个参数loadFactor是加载因子。默认初始容量是16,加载因子是0.75。容量是哈希表中桶(Entry数组)的数量,初始容量只是哈希表在创建时的容量。加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,通过调用 rehash 方法将容量翻倍。
2.HashMap的相关计算
为什么HashMap容量一定要为2的幂呢?HashMap中的数据结构是数组+单链表的组合,我们希望的是元素存放的更均匀,最理想的效果是,Entry数组中每个位置都只有一个元素,这样,查询的时候效率最高,不需要遍历单链表,也不需要通过equals去比较K,而且空间利用率最大。那如何计算才会分布最均匀呢?我们首先想到的就是%运算,哈希值%容量=bucketIndex。但其实取模运算对比比取余运算,数据能分布的更均匀,那么什么是取模呢?取模运算和取余运算两个概念有重叠的部分但又不完全一致。主要的区别在于对负整数进行除法运算时操作不同。借用百度百科里的说明:
对于整型数a,b来说,取模运算或者求余运算的方法都是:
1.求整数商: c = a/b;
2.计算模或者余数: r = a - c*b.
求模运算和求余运算在第一步不同:取余运算在取c的值时,向0方向舍入(fix()函数);而取模运算在计算c的值时,向负无穷方向舍入(floor()函数)。
例如:计算-7Mod 4
那么:a= -7;b = 4;
第一步:求整数商c,如进行求模运算c = -2(向负无穷方向舍入),求余c = -1(向0方向舍入);
第二步:计算模和余数的公式相同,但因c的值不同,求模时r = 1,求余时r = -3。
归纳:当a和b符号一致时,求模运算和求余运算所得的c的值一致,因此结果一致。
当符号不一致时,结果不一样。求模运算结果的符号和b一致,求余运算结果的符号和a一致。
而我们来看看HashMap中的源码:
static intindexFor(int h, int length) {
return h & (length-1);
}
这里返回的是Hash值对数组长度取模,因为Hash值本身是0和1组成的二进制数,当容量一定是2^n时,h & (length - 1) == h % length,它俩是等价不等效的,这也就是数组的长度一定要为2^n的原因。
那么Hash值又是怎么计算的呢?我们再来看看java的源代码:
static final inthash(Object key) {
int h;
// h = key.hashCode() 为第一步取hashCode值
// h ^ (h >>> 16) 为第二步高位参与运算
return (key == null) ? 0 : (h =key.hashCode()) ^ (h >>> 16);
}
hashCode的具体计算方法可以自行百度,这里不再赘述。这里的返回值是通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
3.HashMap的put方法和扩容
这里特意把put方法拉出来讲解一下主要是put方法会涉及扩容的内容。先用图来理解一下put方法的整个过程。
下面是源码:
public V put(K key, V value) {
// 对key的hashCode()做hash
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;
// 步骤①:tab为空则创建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 步骤②:计算index,并对null做处理
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 步骤③:节点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);
//链表长度大于8转换为红黑树进行处理
if (binCount >=TREEIFY_THRESHOLD - 1) // -1 for 1st
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;
// 步骤⑥:超过最大容量就扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
把所有步骤提取出来就是:
①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩容;
②.根据键值key计算hash值得到插入的数组索引i,如果table[i]==null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
③.判断table[i]的首个元素是否和key一样,如果相同直接覆盖value,否则转向④,这里的相同指的是hashCode以及equals;
④.判断table[i]是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。
然后再讲一下扩容的resize方法。因为java的数组时不会自动扩容的,所以我们HashMap的扩容其实就是新建一个容量翻倍的数组再把原来的的数组放进去,这个过程比较好理解就不细说可。最主要的是这里面会要求重新计算Hash值以保证数据分布均匀,也就是rehash的过程。但是在我们新的jdk1.8中不会直接再去计算Hash值。因为Hash值是一个二进制的数,直接用位运算把要移动的Hash值的最前面一位变成1就好了。比如数组长度为4扩充到8时,我们把第一位001变成101,原来的第一个元素就变成了第5个。那么怎么确定哪些数的最前面一位要变为1呢?答案是随机。因为原来的Hash值分布的就比较均匀,把原来的元素随机的选一半分到新增加的部分中整个数组还是均匀的。这样即不用重新计算Hash值又能使数据分布均匀,这个设计非常有意思。
4.HashMap和HashTable的区别
两者主要区别在于线程安全性,HashMap是线程共享的,所以在多线程使用的情况下游客能出现死锁。而HashTable是单个线程独享的,如果要在多线程的情况下使用HashMap推荐使用ConcurrentHashMap,这个不是线程共享的。
还有就是Hashtable是基于Dictionary类的,HashMap是Java 1.2引进的Map接口的一个实现。一般来说HashMap的效率较高。
来源:CSDN
作者:xjk091225
链接:https://blog.csdn.net/xjk091225/article/details/78516898