一、HashMap概述:
HashMap是java中一种用来存储键值对key-value的集合,实现了Serializable(可被序列化),Cloneable(可被克隆),Map接口,继承了AbstractMap,其key和value都可以为null,但是key的位置只能存在一个null。其底层数据结构如下:
-
在1.7及之前采用数组+链表实现,遇到哈希冲突时通过拉链法解决。数组中的每一个元素,在1.7中称为Entry,在1.8中称为Node。
-
在1.8及之后采用数组+链表+红黑树实现。当链表长度大于8时,会先进行数组扩容(减短链表长度),当数组长度大于64时,会将链表转换为红黑树。
说明:本文的源码基于jdk1.8,在进行1.8和1.7对比时会加以说明。
二、HashMap的基本属性
//序列化版本号
private static final long serialVersionUID = 362498820763181265L;
//HashMap的初始化容量(必须是 2 的 n 次幂)默认的初始容量为16
// 1 << 4 相当于 1*2的4次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大的容量为2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认的装载因子
loadFactor 太大导致查找元素效率低,太小导致数组的利用率低,存放的数据会很分散。loadFactor 的默认值为 0.75f 是官方给出的一个比较好的临界值。
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//树化阈值,当一个桶中的元素个数大于等于8时进行树化
static final int TREEIFY_THRESHOLD = 8;
//树降级为链表的阈值,当一个桶中的元素个数小于等于6时把树转化为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 当桶的个数达到64的时候才进行树化
static final int MIN_TREEIFY_CAPACITY = 64;
// Node数组,又叫作桶(bucket)
transient Node<K,V>[] table;
// 作为entrySet()的缓存
transient Set<Map.Entry<K,V>> entrySet;
//元素的数量
transient int size;
//修改次数,用于在迭代的时候执行快速失败策略
transient int modCount;
//扩容阈值,threshold = capacity * loadFactor
int threshold;
//装载因子
final float loadFactor;
三、构造方法
1.HashMap()
构造一个空的HashMap,默认初始容量(16)和默认负载因子(0.75)。
public HashMap() {
// 将默认的负载因子0.75赋值给loadFactor,并没有创建数组
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
2.HashMap(int initialCapacity)
构造一个具有指定的初始容量和默认负载因子(0.75)HashMap 。
// 指定“容量大小”的构造函数
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
3.HashMap(int initialCapacity,float loadFactor)
构造一个具有指定的初始容量和负载因子的 HashMap。
/*
指定“容量大小”和“负载因子”的构造函数
initialCapacity:指定的容量
loadFactor:指定的负载因子
*/
public HashMap(int initialCapacity, float loadFactor) {
// 判断初始化容量initialCapacity是否小于0
if (initialCapacity < 0)
// 如果小于0,则抛出非法的参数异常
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
// 判断初始化容量initialCapacity是否大于集合的最大容量MAXIMUM_CAPACITY
if (initialCapacity > MAXIMUM_CAPACITY)
// 如果超过MAXIMUM_CAPACITY,会将MAXIMUM_CAPACITY赋值给initialCapacity
initialCapacity = MAXIMUM_CAPACITY;
// 判断负载因子loadFactor是否小于等于0或者是否是一个非数值
if (loadFactor <= 0 || Float.isNaN(loadFactor))
// 如果满足上述其中之一,则抛出非法的参数异常
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
// 将指定的负载因子赋值给HashMap成员变量的负载因子loadFactor
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
// 最后调用了tableSizeFor,返回比指定cap容量大的最小2的n次幂数
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;
}
说明:对于this.threshold = tableSizeFor(initialCapacity);
tableSizeFor(initialCapacity)
判断指定的初始化容量是否是2的n次幂,如果不是那么会变为比指定初始化容量大的最小的2的n次幂。
但是注意,在tableSizeFor方法体内部将计算后的数据返回给调用这里了,并且直接赋值给threshold边界值了。有些人会觉得这里是一个bug,应该这样书写:
this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;
这样才符合threshold的意思(当HashMap的size到达threshold这个阈值时会扩容)
但是在jdk8以后的构造方法中,并没有对table这个成员变量进行初始化,table的初始化被推迟到了put方法中,在put方法中会对threshold重新计算。
四、常用成员方法
1.put(K key, V value)方法
在1.8之前,创建HashMap对象的时候就会从构造方法生成一个默认大小为16的Entry[] table数组。而在1.8之后,当第一次调用put(putVal)方法时才会创建一个Node[] table数组。
执行步骤
1)计算key的hash值
2)进入putVal方法,判断数组是否为空,若为空则初始化数组。
3)计算出索引并判断该位置是否为空,若为空则创建一个新的Node节点加入桶中。
4)若不为空
a.判断两个key的值是否相等(==和equals),如果相等则直接覆盖value
b.若不相等,判断该节点是否为树节点。如果是,则通过putTreeVal方法加入元素。
c.若不是树节点,则该节点为链表节点。遍历链表,如果包含key相等的元素,覆盖。如果不包含,则在链表尾部加入该元素,加入后判断链表大小是否超过阈值8,超过则调用treeifyBin方法进行树化。
(注:在treeifyBin方法中会判断,如果数组大小超过64才会真正树化!)
5)覆盖过程:判断是否需要替换旧值,并直接返回旧值。
6)插入元素成功,数组大小+1,判断是否需要进行扩容。
注:两个对象的hashcode值相等不一定为同一对象(hash碰撞),但如果两个对象的hashcode值不相等则一定为不同对象!
put源码分析
public V put(K key, V value) {
// 调用hash(key)计算出key的hash值
return putVal(hash(key), key, value, false, true);
}
//hash函数,计算key的hashcode
static final int hash(Object key) {
int h;
// 如果key是null 则hash值为0,否则调用key的hashCode()方法,并让高16位参与整个hash异或
// 这样可以使计算出的结果更分散,不容易产生哈希冲突
// 寻址公式:(length - 1) & hash
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
解释一下为什么(h = key.hashCode()) ^ (h >>> 16)
进行hash运算:
将key原本的hashcode右移16位,然后与原hashcode进行异或,本质上还是为了减小碰撞。
虽然HashMap使用拉链法作为hash碰撞的解决方案,但是仍可以在hash函数的设置上去进行一定程度的优化,来减少碰撞的可能性。
-
如果直接使用key.hashCode()作为hash值的话,存在一些问题。 举例说明,HashMap的默认长度(length)为16,并且是通过(table.length - 1) & hash的方式得到key在table中的下标。
key1.hashCode()=1661580827(二进制为0110,0011,0000,10
0
1,1011,0110,0001,1011), key2.hashCode()=1661711899(二进制为0110,0011,0000,101
1,1011,0110,0001,1011)在与掩码(length-1)进行与的过程中,只有后4位起作用,导致得到的下标值均为11,导致高位完全失效,加大了冲突的可能性。
-
如果通过高位向低位异或传播的话,高位同样参与到key在table中下标的运算,减少了碰撞的可能性 。key1.hashCode() ^ (key1.hashCode() >>>16)=1661588754
(二进制为0110,0011,0000,1001,1101,0101,0001,0010)
key2.hashCode() ^ (key2.hashCode() >>>16)=1661719824
(二进制为0110,0011,0000,1011,1101,0101,0001,0000)
再于掩码进行与操作得到的下标分别为2和0,减少了冲突的可能性。
HashMap只提供了 put 用于添加元素,而put方法的逻辑实现实际在putval()方法中。
putVal源码分析
hash:key 的 hash 值
key:原始 key
value:要存放的值
onlyIfAbsent:如果 true 代表不更改现有的值
evict:如果为false表示 table 为创建状态
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
/*
1)(tab = table) == null 表示将table赋值给tab,然后判断tab是否等于null,第一次肯定是null。
2)(n = tab.length) == 0 表示将数组的长度赋值给n,然后判断n是否等于0,n等于0,进行数组初始化,并将初始化好的数组长度赋值给n。
3)执行完n = (tab = resize()).length,数组tab每个空间都是null。
*/
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/*
1)i = (n - 1) & hash 表示计算数组的索引赋值给i,即确定元素存放在哪个桶中。
2)p = tab[i = (n - 1) & hash]获取索引为i的数据赋值给p
3) (p = tab[i = (n - 1) & hash]) == null 判断结点位置是否等于null;
如果为null,则创建新的结点放入该位置的桶中。
*/
if ((p = tab[i = (n - 1) & hash]) == null)
// 创建一个新的结点存入到桶中
tab[i] = newNode(hash, key, value, null);
else {
// 执行else表示这个位置已经有值了
Node<K,V> e; K k;
/*
比较桶中第一个元素(数组中的结点)的hash值和key是否相等
1)p.hash表示原来存在数据的hash值;hash表示传进来key的hash值;判断二者是否相等
2)(k = p.key) == key :
p.key获取原来数据的key赋值给k;比较两个key的地址值是否相等。
3)key != null && key.equals(k):
如果两个key的地址值不相等,那么先判断后添加的key是否为null,不为null再调用equals方法判断两个key的内容是否相等。
*/
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
/*
两个元素哈希值相等,并且key的值也相等,
将旧的元素整体对象赋值给e,用e来记录
*/
e = p;
// hash值不相等或者key不相等,判断p是否为红黑树结点
else if (p instanceof TreeNode)
// 通过putTreeVal方法放入树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 说明是链表结点
else {
/*
1)如果是链表的话需要遍历到最后结点然后插入
2)采用循环遍历的方式,判断链表中是否有重复的key
*/
for (int binCount = 0; ; ++binCount) {
/*
1)e = p.next 获取p的下一个元素赋值给e。
2)(e = p.next) == null 判断p.next是否等于null,为null说明此时到达了链表的尾部,还没有找到重复的key,将该键值对插入链表中。
*/
if ((e = p.next) == null) {
/*
1)创建一个新的结点插入到尾部
p.next = newNode(hash, key, value, null);
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
return new Node<>(hash, key, value, next);
}
注意第四个参数next是null,因为当前元素插入到链表末尾了,那么下一个结点肯定是null。
2)这种添加方式也满足链表数据结构的特点,每次向后添加新的元素。
*/
p.next = newNode(hash, key, value, null);
//结点添加完成之后判断此时结点个数是否大于TREEIFY_THRESHOLD临界值8
//binCount表示除去桶中第一个头节点元素后,链表的元素,如果binCount>=7,此时除去桶中第一个元素的链表有8个元素,再加上第一个元素,此时共有9个元素,超过临界值8
//将链表转换为红黑树。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 转换为红黑树
treeifyBin(tab, hash);
// 跳出循环
break;
}
/*
执行到这里说明e = p.next 不是null,不是最后一个元素。继续判断链表中结点的key值与插入的元素的key值是否相等。
*/
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
/*
要添加的元素和链表中的存在的元素的key相等了,则跳出for循环。不用再继续比较了
直接执行下面的if语句去替换去 if (e != null)
*/
break;
/*
说明新添加的元素和当前结点不相等,继续查找下一个结点。
用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
*/
p = e;
}
}
/*
表示在桶中找到key值、hash值与插入元素相等的结点
也就是说通过上面的操作找到了重复的键,所以这里就是把该键的值变为新的值,并返回旧值
这里完成了put方法的修改功能
*/
if (e != null) {
// 记录e的value
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null
if (!onlyIfAbsent || oldValue == null)
// 用新值替换旧值
// e.value 表示旧值 value表示新值
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 修改记录次数
++modCount;
// 判断实际大小是否大于threshold阈值,如果超过则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
2.扩容方法 resize()
扩容概述
HashMap每次进行扩容,会伴随着一次重新 hash 分配,并且会遍历 hash 表中所有的元素,非常耗时。
HashMap 在进行扩容时,使用的 rehash 方式非常巧妙,因为每次扩容都是翻倍,与原来计算的 (n - 1) & hash 的结果相比,只是多了一个 bit 位,所以结点要么就在原来的位置,要么就被分配到 “原位置 + 旧容量” 这个位置。
由于hash位上的0.1是随机的,所以保证了随机性。
源码分析
/*
* 为了解决哈希冲突导致的链化影响查询效率问题,扩容会缓解该问题
*/
final Node<K,V>[] resize() {
// oldTab:表示扩容前的哈希表数组
Node<K,V>[] oldTab = table;
// oldCap:表示扩容之前table数组长度
// 如果当前哈希表数组等于null 长度返回0,否则返回当前哈希表数组的长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// oldThr:表示扩容之前的阀值(触发本次扩容的阈值) 默认是12(16*0.75)
int oldThr = threshold;
// newCap:扩容之后的table散列表数组长度
// newThr: 扩容之后,下次再出发扩容的条件(新的扩容阈值)
int newCap, newThr = 0;
// 如果老的哈希表数组长度oldCap > 0
// 如果该条件成立,说明hashMap 中的散列表数组已经初始化过了,是一次正常扩容
// 开始计算扩容后的大小
if (oldCap > 0) {
// 扩容之前的table数组大小已经达到 最大阈值后,则不再扩容
// 且设置扩容条件为:int的最大值
if (oldCap >= MAXIMUM_CAPACITY) {
// 修改阈值为int的最大值
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 扩容之前的table数组大小没超过最大值,则扩充为原来的2倍
// (newCap = oldCap << 1) < MAXIMUM_CAPACITY 扩大到2倍之后容量要小于最大容量
// oldCap >= DEFAULT_INITIAL_CAPACITY 原哈希表数组长度大于等于数组初始化长度16
// 如果oldCap 小于默认初始容量16,比如传入的默认容量为8,则不执行下面代码
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 新的扩容阈值扩大一倍
newThr = oldThr << 1; // double threshold
}
// 如果老的哈希表数组长度oldCap == 0
// 说明hashMap中的散列表还没有初始化,这时候是null
// 如果老阈值oldThr大于0 直接赋值
/*
以下三种情况会直接进入该判断:(即,这时候oldThr扩容阈值已存在)
1.new HashMap(initCap,loadFactor);
2.new HashMap(initCap);
3.new HashMap(Map);// 这个传入的map中已经有数据
*/
else if (oldThr > 0) // 老阈值赋值给新的数组长度
newCap = oldThr;
// 如果老的哈希表数组长度oldCap == 0
// 说明hashMap中的散列表还没有初始化,这时候是null
// 此时,老扩容阈值oldThr == 0
else { // 直接使用默认值
newCap = DEFAULT_INITIAL_CAPACITY;//16
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);//12
}
// 如果执行到这个位置新的扩容阈值newThr还没有得到赋值,则
// 需要计算新的resize最大上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 将新的阀值newThr赋值给threshold
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 创建新的散列表
// newCap是新的数组长度---> 32
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 说明:hashMap本次扩容之前,table不为null
if (oldTab != null) {
// 把每个bucket桶的数据都移动到新的散列表中
// 遍历旧的哈希表的每个桶,重新计算桶里元素的新位置
for (int j = 0; j < oldCap; ++j) {
// 当前node节点
Node<K,V> e;
// 说明:此时的当前桶位中有数据,但是数据具体是
// 1.单个数据 、 2.还是链表 、 3.还是红黑树 并不能确定
if ((e = oldTab[j]) != null) {
// 原来的数据赋值为null 便于GC回收
oldTab[j] = null;
// 第一种情况:判断数组是否有下一个引用(是否是单个数据)
if (e.next == null)
// 没有下一个引用,说明不是链表,
// 当前桶上只有单个数据的键值对,
// 可以将数据直接放入新的散列表中
// e.hash & (newCap - 1) 寻址公式得到的索引结果有两种:
// 1.和原来旧散列表中的索引位置相同,
// 2.原来旧散列表中的索引位置i + 旧容量oldCap
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;
// 高位链表:扩容之后数组的下标位置等于
// 当前数组下标位置 + 扩容之前数组的长度oldCap 时使用
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 通过上述讲解的原理来计算结点的新位置
do {
// 原索引
next = e.next;
// 这里来判断如果等于true
// e这个结点在resize之后不需要移动位置
// 举例:
// 假如hash1 -> ...... 0 1111
// 假如oldCap=16 -> ...... 1 0000
// e.hash & oldCap 结果为0,则
// 扩容之后数组的下标位置j,与当前数组的下标位置一致
// 使用低位链表
//(e.hash & oldCap) == 0,说明新增的一位hash值为0,此时(e.hash & newCap-1) 这一位也为0,所以在原下标。用旧长度代替新长度-1.
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 举例:
// 假如hash2 -> ...... 1 1111
// 假如oldCap=16 -> ...... 1 0000
// e.hash & oldCap 结果不为0,则
// 扩容之后数组的下标位置为:
// 当前数组下标位置j + 扩容之前数组的长度oldCap
// 使用高位链表
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 将低位链表放到bucket桶里
if (loTail != null) {
loTail.next = null;
// 索引位置=当前数组下标位置j
newTab[j] = loHead;
}
// 将高位链表放到bucket里
if (hiTail != null) {
hiTail.next = null;
// 索引位置=当前数组下标位置j + 扩容之前数组的长度oldCap
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 返回新散列表
return newTab;
}
步骤总结
1)如果使用是默认构造方法,则第一次插入元素时初始化为默认值,容量为16,扩容门槛为12;
2)如果使用的是非默认构造方法,则第一次插入元素时初始化容量等于扩容门槛,扩容门槛在构造方法里等于传入容量向上最近的2的n次方;
3)如果旧容量大于0,则新容量等于旧容量的2倍,但不超过最大容量2的30次方,新扩容门槛为旧扩容门槛的2倍;
4)创建一个新容量的桶;
5)搬移元素,原链表分化成两个链表,低位链表存储在原来桶的位置,高位链表搬移到原来桶的位置加旧容量的位置;
3.删除方法remove()
方法概述
删除方法就是首先先找到元素的位置,如果是链表就遍历链表找到元素之后删除。如果是用红黑树就遍历树然后找到之后做删除,树小于 6 的时候要转链表。
remove方法源码
// remove方法的具体实现在removeNode方法中,所以我们重点看下removeNode方法
// 根据key删除
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
// 根据key,value 删除
@Override
public boolean remove(Object key, Object value) {
return removeNode(hash(key), key, value, true, true) != null;
}
removeNode() 方法源码
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
// 参数:
// matchValue 当根据 key和value 删除的时候该参数为true
// movable 可以先不用考虑这个参数
// tab:引用当前haashMap中的散列表
// p:当前node元素
// n:当前散列表数组长度
// index:表示寻址结果
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 根据hash找到位置
// 如果当前key映射到的桶不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
// 进入这个if判断内部,说明桶位是有数据的,需要进行查询操作,并且执行删除
// node:通过查找得到的要删除的元素
// e:表示当前node的下一个元素
// k,v 键 值
Node<K,V> node = null, e; K k; V v;
// 第一种情况:当前桶位中的元素 即为我们要删除的元素
// 如果桶上的结点就是要找的key,则将node指向该结点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
// 如果桶位中的头一个元素不是我们要找的元素,且桶位中的e = p.next不为null
// 说明该桶位中的节点存在下一个节点
else if ((e = p.next) != null) {
// 说明:当前桶位,要么是 链表,要么是 红黑树
// 第二种情况:判断桶位中是否已经形成了红黑树
if (p instanceof TreeNode)
// 说明是以红黑树来处理的冲突,则获取红黑树要删除的结点
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
// 第三种情况:桶位中已经形成链表
else {
// 判断是否以链表方式处理hash冲突
// 是的话则通过遍历链表来寻找要删除的结点
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 比较找到的key的value和要删除的是否匹配
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)
// 链表删除
tab[index] = node.next;
// 如果桶位中
else
// 第三种情况:将当前元素p的下一个元素设置为 要删除元素的 下一个元素
p.next = node.next;
// 记录修改次数
++modCount;
// 变动的数量
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
五、HashMap常见问题总结
为什么初始化容量的值必须是 2 的 n 次幂?如果输入值不是 2 的幂比如 10 会怎么样?
为了获取下标,我们需要对数组进行取模运算,为了提高效率将hash % length 替换为了 hash & ( length - 1) ,而这个替换的前提就是length 是 2 的 n 次幂。另外,这样还可以使数据均匀分布,减少hash冲突。
如果将长度初始化为10:
HashMap<String, Integer> hashMap = new HashMap(10);
此时HashMap双参构造函数会通过tableSizeFor(initialCapacity)方法,得到一个最接近length且大于length的2的n次幂数(比如最接近10且大于10的2的n次幂数是16)。因为当在实例化 HashMap 实例时,如果给定了 initialCapacity,由于 HashMap 的 capacity 必须是 2 的幂,因此这个方法用于找到大于等于 initialCapacity 的最小的 2 的幂。
static final int tableSizeFor(int cap) {
int n = cap - 1;
//cap-1是为了防止 cap 已经是 2 的幂。如果 cap 已经是 2 的幂,又没有这个减 1 操作,则执行完后面的几条无符号操作之后,返回的 capacity 将是这个cap的2倍
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;
//为什么最后n+1?
//如果 n 这时为 0 了(经过了cap - 1后),则经过后面的几次无符号右移依然是 0,返回0是肯定不行的,所以最后返回n+1最终得到的 capacity 是1。
}
解决Hash冲突的方法有那些?HashMap用的哪种?
解决Hash冲突方法有:开放定址法
、再哈希法
、拉链法
。
- 开放地址法(再散列法):如果
p=H(key)
出现冲突时,则以p为基础,用同一种hash函数再次hash,p1=H(p)
,如果p1再次出现冲突,则以p1为基础,以此类推,直到找到一个不冲突的哈希地址pi。 - 再哈希法(多重散列):提供多个不同的hash函数,
R1=H1(key1)
发生冲突时,再计算R2=H2(key1)
,直到没有冲突为止。 - 拉链法:将哈希值相同的元素构成一个同义词的单链表,并将单链表的头指针存放在哈希表的第i个单元中,查找、插入和删除主要在同义词链表中进行。
为什么要先将数组扩容为64后,再转为红黑树?
如果在数组比较小时转为红黑树结构,反而会降低效率,因为红黑树需要进行左旋右旋,变色,这些操作来保持平衡。在数组长度小于64时,搜索时间相对要快些。
为什么桶中的元素大于8之后,转为红黑树?元素个数小于6时,红黑树转为链表?
在随机哈希代码下,桶中的节点频率遵循泊松分布,桶的长度超过8的概率非常非常小(千万分之一),所以选择8作为阈值。
红黑树的平均查找长度是logn,链表的平均查找长度为n/2。当长度为6时红黑树退化为链表是因为logn=log6约等于2.6,而n/2=6/2=3,两者相差不大,而红黑树节点占用更多的内存空间。
hash函数是如何实现的?为什么要这样实现?
将key原本的hashcode右移16位,然后与原hashcode进行异或,本质上还是为了减小碰撞。
虽然HashMap使用拉链法作为hash碰撞的解决方案,但是仍可以在hash函数的设置上去进行一定程度的优化,来减少碰撞的可能性。
六、HashMap的线程安全问题
在多线程下,HashMap有什么问题?我们从jdk1.7和jdk1.8对比来看。
jdk1.7中的扩容机制(头插法)
调用resize方法时,如果原有table长度已经达到了上限,就不再扩容了。如果还未达到上限,则创建一个新的table,并调用transfer方法。
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; //注释1
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity); //注释2
e.next = newTable[i]; //注释3
newTable[i] = e; //注释4
e = next; //注释5
}
}
}
transfer方法的作用是把原table的Node放到新的table中,使用的是头插法,也就是说,新table中链表的顺序和旧列表中是相反的,在HashMap线程不安全的情况下,这种头插法可能会导致环状节点。
假设原有table记录的某个链表,比如table[1]=3,链表为3–>5–>7,那么头插法处理流程如下:
1.注释1:记录e.next的值。开始时e是table[1],所以e3,e.next5,那么此时next==5。
2.注释2,计算e在newTable中的节点。为了展示头插法的倒序结果,这里假设e再次散列到了newTable[1]的链表中。
3.注释3,把newTable [1]赋值给e.next。因为newTable是新建的,所以newTable[1]null,所以此时3.nextnull。
4.注释4,e赋值给newTable[1]。此时newTable[1]=3。
5.注释5,next赋值给e。此时e==5。
此时newTable[1]中添加了第一个Node节点3,下面进入第二次循环,第二次循环开始时e==5。
1.注释1:记录e.next的值。5.next是7,所以next==7。
2.注释2,计算e在newTable中的节点。为了展示头插法的倒序结果,这里假设e再次散列到了newTable[1]的链表中。
3.注释3,把newTable [1]赋值给e.next。因为newTable[1]是3(参见上一次循环的注释4),e是5,所以5.next==3。
4.注释4,e赋值给newTable[1]。此时newTable[1]==5。
5.注释5,next赋值给e。此时e==7。
此时newTable[1]是5,链表顺序是5–>3。
总结一下:遍历数组,然后遍历数组链表,从链表头到尾,保留next = e.next;先计算出节点在新hashmp的数组位置i,然后用头插法将节点插入到新数组的头结点(e.next = new B)。e = next; do while(e != null)之后的一样。
单线程是没问题的,如果是多线程的话,那么可能 next = e.next;被干扰的话 next = B;造成死循环,影响扩容。
jdk1.8存在的问题
尽管在jdk1.8中,将扩容时迁移元素的方法由头插法改为了尾插法,但也并不是就说是线程安全的。当多个线程同时调用put方法添加元素时,如果产生哈希碰撞,导致两个线程得到同样的bucketIndex去存储,就可能会发生元素覆盖丢失的情况。
七、遍历HashMap的方式
1.分别遍历Key和Values
for (String key : map.keySet()) {
System.out.println(key);
}
for (Object vlaue : map.values() {
System.out.println(value);
}
2.使用迭代器
Iterator<Map.Entry<String, Object>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, Object> mapEntry = iterator.next();
System.out.println(mapEntry.getKey() + "---" + mapEntry.getValue());
}
3.for-each迭代Entries
Map<Integer, Integer> map = new HashMap<Integer, Integer>();
for(Map.Entry<Integer, Integer> entry : map.entrySet()){
System.out.println("key = " + entry.getKey() + ", value = " + entry.getValue())
}
4.通过get方式(二次迭代不建议使用)
Set<String> keySet = map.keySet();
for (String str : keySet) {
System.out.println(str + "---" + map.get(str));
}