目录
2、HashMap为什么不直接使用hashCode()处理后的哈希值直接作为table的下标?
3、HashMap在JDK1.7和JDK1.8中有哪些不同?
4、为什么HashMap中String、Integer这样的包装类适合作为Key?我们能否使用任何类作为Map的key?如果能需要注意哪些问题
一、HashMap实现原理
1、底层数据结构
hashMap是map接口下一个重要的实现类,基于数组+链表+红黑树的数据结构(如下图),通过hashCode来定位bucket(桶)的位置,可以大大提升查询效率。
HashMap中几个重要的参数如下:
initialCapacity:初始容量只是创建哈希表时的容量,容量是哈希表中能够容纳的节点(node)数。
loadFactor:负载因子是在容量自动增加之前允许哈希表得到满足的度量。
threshold:容量阈值,负载因数和当前容量的乘积,当在散列表中的条目的数量超过了threshold,哈希表需要进行扩容(resize),其中的元素需要被被重新散列到新的数据结构中。
size:当前节点(node)数
通过hashMap的put(插入)和get(查询)两个方法可以了解到hashMap的实现原理。由于插入(put)时需要先根据key查找是否已经存在当前key的节点,需要对map进行查询,因此get的思路和put相似,不再赘述。
2、put方法:
当我们要插入一个节点,首先要计算key的hash值,根据这个hash值在数组中寻址,如果对应的bucket是空的(null),那么构造node节点并放入对应的bucket,如果对应的bucket已经存在节点元素,也就是发生了hash冲突,那么就需要逐个比较链表(或者红黑树)的节点,如果节点的key和查询的key相等,那么将value替换oldValue;当记录的数量(size)超过了阈值(负载因数和初始容量的乘积),哈希表需要进行扩容(resize)。
具体代码和注释如下:
//onlyIfAbsent如果为true,那么只有插入不存在的key-value对,如果key已经对应了value则插入失败
//evict如果为真,则table处于creation模式
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;//table在构造函数中是没有初始化的,而是在put中检测到table==null则使用resize进行初始化
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
//如果tab数组中hash值对应的bucket是空的(null),则构造key-value节点放入
else {//如果不为空,则进行以下操作
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//比较tab数组中hash值对应的bucket的key和查询的key是否相同,如果相同则定位到该节点
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//如果该节点是红黑树的节点node,那么使用树的方法来定位对应节点
else {//如果以上都不是,说明说链表的节点,或者不存在这样的key,因此下面循环的在链表中查询是否有对应的key
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);//如果没有带查询的key,那么将key-value构造成链表节点放入链表尾部
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);//如果查询技术binCount超过8,说明新增节点导致链表超过8,应该转换为红黑树结构
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;//如果在链表中找到查询key对应的节点,那么此时的e已经定位到该节点,跳出循环
p = e;
}
}
//以上代码已经完成节点的查找并定位到e(插入)节点,接下来就是用value替换oldValue
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);//该方法在HashMap中为空,是用于linkedHashMap的
return oldValue;
}
}
++modCount;//用于迭代器的fail-fast机制
if (++size > threshold)
resize();//检测添加节点后是否超过阈值,如果是则需要扩容
afterNodeInsertion(evict);
return null;
}
3、扩容resize()方法
每次扩容都是原数组长度的2倍
那么为什么要2倍扩容呢?从resize()的实现中可以看出,因为2倍扩容可以不再计算hash值对应的bucket位置,扩容后,原下标j处的链表被分为两个部分,一个部分仍在下标j的bucket中,另一部分在(j+oldCap)下标的bucket中,这样可以大大的提高效率
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;//首先用一个数组来保存原map
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {//当原table不为空
if (oldCap >= MAXIMUM_CAPACITY) {
//如果原table的长度超过MAXIMUM_CAPACITY,那么无法扩容,返回原table
threshold = Integer.MAX_VALUE;
return oldTab;
}
//否则,table数组长度*2,map容量阈值*2
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//如果原始table为空,说明map的table还未初始化,玉石对table进行初始化
else if (oldThr > 0)
// 如果threshold大于0,则将容量初始化为原threshold大小,threshold一定是2的
//幂次方(通过tableSizeFor(initialCapacity)方法实现)
newCap = oldThr;
else { // 如果threshold等于0,则使用默认值,并计算相应的threshold
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];//构造一个newCap大小的新数组
table = newTab;
if (oldTab != null) {
//如果原table不为空,那么逐个的将元素计算hash值,并放入新的table中
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;
//由于是2倍扩容,因此原table下得链表会被分成两部分,一部分仍在原bucket的位置j,
//另一部分在下标(j+oldCap)的bucket位置,因此可以先分别得到高低两个链表,
//然后将两个链表放入对应的bucket中
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);
//分别将两个链表放入对应的bucket中
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
4、删除remove()方法:
HashMap的remove方法很简单,只要根据key找到对应的节点,然后通过链表的操作就可以实现节点删除
//matchValue为true表示只有key和value都匹配才删除节点
//movable为false表示删除node节点时不移动其它节点
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;
//……一系列通过key来定位节点node的代码,同get方法
//下面是删除定位到的节点node的代码
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)//如果node就在tab数组中,也就是链表的开头,那么把该位置换上node.next即可
tab[index] = node.next;
else//如果node在链表或红黑树中,那么将p链接到node.next就完成节点删除
p.next = node.next;
++modCount;
--size;//节点数量减少
afterNodeRemoval(node);//早HashMap中是空方法,用于LinkedHashMap的双向链表的节点处理
return node;
}
}
return null;
}
二、关于HashMap的几个问题
1、为什么HashMap不是线程安全的
有两个操作时线程不安全的,分别是put和resize操作:
put操作:导致的多线程数据不一致
首先,从put方法不是同步方法,那么我们假设有两个线程A和B分别要插入d和e两个节点,并且其hash值都定位到了0下标的bucket中,那么他们获取到c节点并要将他们自己链接到c节点后。
线程A首先计算d节点所要落到的桶的索引坐标,然后获取到该桶里面的c节点,此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行,只不过线程B成功将d节点插到了桶里面,那么当线程B成功插入之后,线程A再次被调度运行时,它依然持有过期的c节点但是它对此一无所知,以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。
resize操作:get操作可能因为resize而引起死循环(cpu100%)
在JDK1.7中,resize()方法实现如下:可以看出JDK1.7中,由于扩容后的节点是从链表头插入的,因此会导致节点顺序颠倒
void resize(int newCapacity) { //传入新的容量
Entry[] oldTable = table; //引用扩容前的Entry数组
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //扩容前的数组大小如果已经达到最大(2^30)了
threshold = Integer.MAX_VALUE; //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了
return;
}
Entry[] newTable = new Entry[newCapacity]; //初始化一个新的Entry数组
transfer(newTable); //!!将数据转移到新的Entry数组里
table = newTable; //HashMap的table属性引用新的Entry数组
threshold = (int)(newCapacity * loadFactor);//修改阈值
}
void transfer(Entry[] newTable) {
Entry[] src = table; //src引用了旧的Entry数组
int newCapacity = newTable.length;
for (int j = 0; j < src.length; j++) { //遍历旧的Entry数组
Entry<K,V> e = src[j]; //取得旧Entry数组的每个元素
if (e != null) {
src[j] = null;//释放旧Entry数组的对象引用(for循环后,旧的Entry数组不再引用任何对象)
do {
Entry<K,V> next = e.next;
int i = indexFor(e.hash, newCapacity); //!!重新计算每个元素在数组中的位置
e.next = newTable[i]; //标记[1]
newTable[i] = e; //将元素放在数组上
e = next; //访问下一个Entry链上的元素
} while (e != null);
}
}
}
可以看出tranfer()方法是扩容的关键,实现了节点元素的移动,如果我们的map中有3个节点[3,A],[7,B],[5,C],并且这三个entry都落到了第二个bucket里面。
现在假设有两个线程同时执行resize()方法:
-
线程thread1执行到了transfer方法的Entry next = e.next这一句,然后时间片用完了,此时的e = [3,A], next = [7,B]。
-
线程thread2被调度执行并且顺利完成了resize操作,需要注意的是,此时的[7,B]的next为[3,A]。
-
此时线程thread1重新被调度运行,此时的thread1持有的引用是已经被thread2 resize之后的结果。线程thread1首先将[3,A]迁移到新的数组上,因为线程thread1中e=next的next是[7,B],也就是[7,B]被链接到了[3,A]的后面,而通过thread2的resize之后,[7,B]的next变为了[3,A],此时,[3,A]和[7,B]形成了环形链表,在get的时候,如果get的key的桶索引和[3,A]和[7,B]一样,那么就会陷入死循环。
那么在JDK1.7中,可以看出由于是采用链表头部插入的方式会导致循环链表,而JDK1.8中采用了尾部插入的方法,因此已经不存在resize()导致的线程不安全问题。
2、HashMap为什么不直接使用hashCode()处理后的哈希值直接作为table的下标?
hashCode()
方法返回的是int整数类型,其范围为-(2 ^ 31)~(2 ^ 31 - 1),约有40亿个映射空间,而HashMap的容量范围是在16(初始化默认值)~2 ^ 30,HashMap通常情况下是取不到最大值的,并且设备上也难以提供这么多的存储空间,从而导致通过hashCode()
计算出的哈希值可能不在数组大小范围内,进而无法匹配存储位置;
HashMap中是怎么解决的?
-
HashMap自己实现了自己的
hash()
方法,通过两次扰动使得它自己的哈希值高低位自行进行异或运算,降低哈希碰撞概率也使得数据分布更平均;
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
-
在保证数组长度为2的幂次方的时候,使用
hash()
运算之后的值与运算(&)(数组长度 - 1)来获取数组下标的方式进行存储,这样一来是比取余操作更加有效率,二来也是因为只有当数组长度为2的幂次方时,h&(length-1)才等价于h%length,三来解决了“哈希值与数组大小范围不匹配”的问题;
3、HashMap在JDK1.7和JDK1.8中有哪些不同?
- 数据结构
在JDK1.7中,HashMap还是基于数组+链表的实现。
在JDK1.8以后,HashMap的底层数据结构改成了数组+链表+红黑树的实现,在链表的节点大于8时,链表转为红黑树。
*由于红黑树是平衡多叉树,因此查找开销为O(log n),但是要求作为key的对象必须正确的实现了Compare接口,如果没有实现Compare接口,或者实现得不正确(比方说所有Compare方法都返回0),那JDK1.8的HashMap其实还是慢于JDK1.7的
- 节点类型
在JDK1.7中,节点是Entry类型。
在JDK1.8中,节点是Node类型。(便于红黑树的转换)
- 初始化方式
在JDK1.7中,单独使用函数inflateTable()进行table数组的初始化
。
在JDK1.8中,最开始并不对table进行初始化,而是直接集成到了扩容函数resize()
中,在put时如果table为null,调用resize()进行初始化。
- hash值计算方式
在JDK1.7中,扰动处理为9次扰动 ,其中包括4次位运算 + 5次异或运算。
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
在JDK1.8中,扰动处理为2次扰动 ,其中包括1次位运算 + 1次异或运算。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
扰动的目的: 保证最终获取的存储位置尽量分布均匀。
为什么JDK1.8中是2次扰动:两次就够了,已经达到了高位低位同时参与运算的目的;
- 插入数据方式
在JDK1.7中,头插法(先讲原位置的数据移到后1位,再插入数据到该位置)
在JDK1.8中,尾插法(直接插入到链表尾部/红黑树)
- 扩容方式
在JDK1.7中,resize的核心方法是transfer,其中使用的是头插法,会导致循环链表,在get方法中会导致死循环。并且在在JDK1.7中,元素都需要根据取模算法重新计算在数组中的下标。
在JDK1.8以后,由于是2倍扩容,可以得到新的index要么不变,要么是旧index+oldCapacity,不用重新计算下标,效率大幅提升。并且是从链表(红黑树)头部开始遍历,并将节点分别顺序放到高、低两个链表中,然后将链表头部链接到数组的相应bucket中,因此不再存在线程不安全的问题。
4、为什么HashMap中String、Integer这样的包装类适合作为Key?我们能否使用任何类作为Map的key?如果能需要注意哪些问题
String、Integer这样的包装类适合作为Key的原因:
- 都是final类型,即不可变性,保证key的不可更改性,不会存在获取hash值不同的情况
- 内部已重写了
equals()
、hashCode()
等方法,遵守了HashMap内部的规范(不清楚可以去上面看看putValue的过程),不容易出现Hash值计算错误的情况;
用自己定义的类做key需要注意什么?重写hashCode()
和equals()
方法
- 重写
hashCode()
是因为需要计算存储数据的存储位置,需要注意不要试图从散列码计算中排除掉一个对象的关键部分来提高性能,这样虽然能更快但可能会导致更多的Hash碰撞; - 重写
equals()
方法,需要遵守自反性、对称性、传递性、一致性以及对于任何非null的引用值x,x.equals(null)必须返回false的这几个特性,目的是为了保证key在哈希表中的唯一性;
5、在HashMap的查找(get)操作中,首先利用hash值定位bucket位置,然后在链表中遍历,比较key是否相等确定节点。那么为什么不两次都用hash值进行比较呢?(认为不同对象的hash值不同)
其实这里也可以用hashCode来进行第二次比较,只是看用户对相等的定义是什么,如果认为key的hashCode相等时认为key相等,那么就可以用hash值做第二次比较