JDK1.8之前的HashMap
JDK1.8之前的版本,HashMap的底层实现是数组和链表,结构如下图所示:
成员变量
HashMap的主要成员变量包括
//存储数据的核心成员变量
transient Entry<K,V>[] table;
//键值对数量
transient int size;
//加载因子,用于决定table的扩容量
final float loadFactor;
table是HashMap的核心成员变量,该数组用于记录HashMap的所有数据,它的每一个下标都对应一条链表。
所有哈希冲突的数据都会被存放到同一条链表中,Entry<K,V>是该链表的节点元素。
Entry<K,V>包含以下成员变量:
//存放键值对的关键字
final K key;
//存放键值对的值
V value;
//指向下一个结点的引用
Entry<K,V> next;
//key所对应的hashcode
int hash;
从以上源码看出,HashMap的核心实现是一个单向链表数组( Entry<K,V>[] table),由此可推测,HashMap的所有方法都是通过操作该数组来完成,HashMap规定了该数组的两个特性:
(1)会在特定时刻,根据需要来扩容
(2)长度始终保持为2的幂次方
在HashMap中,数据都是以键值对的形式存在的,键值对应的hashCode会作为其在数组的下标。例如字符串“1”的hashCode为51,那么在它被作为键值存入HashMap后,table[51]对应的Entry.key就是“1”。
如果另一个对象x的hashCode也是51该怎么办?
答案是它会被存入链表里,和之前的字符串同时存在,当需要查找指定对象的时候,会先找到hashCode对应的下标,然后遍历链表,调用对象的equals方法进行比对从而找到对应的对象。
由于数组的查找比链表要快,于是我们可以得出一个结论:尽可能使hashCode分散,这样可以提高HashMap的查找效率。
常量
//默认的初始化容量,必须为2的幂次方
static final int DEFAULT_INITIAL_CAPACITY=16;
//最大容量,在构造方法指定HashMap容量的时候,用于做比较
static final int MAXIMUM_CAPACITY=1<<30;
//默认的加载因子,如果没有构造方法指定,那么loadFactor会使用该常量
static final float DEFAULT_LOAD_FACTOR=0.75F;
put(K,V)方法
public V put(K key,V value){
if(key==null)
return putForNullKey(value);
int hash=hash(key);
int i=indexFor(hash,table.length);
for(Entry<K,V> e=table[i];e!=null;e=e.next){
Object k;
if(e.hash==hash&&((k=e.key)==key||key.equals(k))){
V oldValue=e.value;
e.value=value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash,key,value,i);
return null;
}
该方法执行流程如下:
indexFor
indexFor(int h,int len)方法的作用是根据hashCode和table的长度来计算下标,具体的实现是return h&(len-1)
,就是将hashcode值和table的长度-1进行按位与操作然后返回。
h是目标key的hashCode,如果不发生扩容,该hashCode值是不能超出lenghth-1的,由此需要把hashCode进行一定变换,保留不超出length的特征值,也即是hash表的冲突处理。计算后的index值不会超出table的长度范围。
相当于h小于length-1的时候取h,大于length-1的时候取余数。
hash
hash(Object k)方法,用于计算键值k的hashCode值
final int hash(Object k){
int h=0;
if(useAltHashing){
if(k instanceof String){
return sun.misc.Hashing.stringHash32((String)k);
}
h=hashSeed;
}
h^=k.hashCode();
h^=(h>>20)^(h>>>12);
return h^(h>>>7)^(h>>>4);
}
hashCode重复的现象称为哈希碰撞,当发生哈希碰撞时,碰撞的键值对都会被存入同一条链表,导致HashMap效率低下。松散哈希可以尽量减少哈希碰撞的发生。
useAltHashing
是一个标识量,当它为true时,将启用代替的哈希松散算法,有以下两个意义:
(1)当处理String类型数据时,直接调用stringHash32获取最终的哈希值
(2)当处理其他类型数据时,提供一个相对于HashMap实例唯一且不变的随机值hashSeed作为hashCode计算的初始量。
useAltHashing
为true要满足两个条件:
①虚拟机已经启动
②设定的容量超出了虚拟机设定的某个替代哈希散列算法阈值。
之后执行的一些异或操作和无符号右移操作,则是把高位的数据和低位的数据特性混合起来,使hashCode更加离散,可以使1和0的分布更加平衡。
存储数据
for(Entry<K,V> e=table[i];e!=null;e=e.next){
Object k;
if(e.hash==hash&&((k=e.key)==key||key.equals(k))){
V oldValue=e.value;
e.value=value;
e.recordAccess(this);
return oldValue;
}
}
这段代码的执行流程如图:
根据该流程图可以得出结论,新增Entry的情况有以下两种:
①table里不存在指定下标,也就是没有发生哈希碰撞
②table里存在指定下标,发生了哈希碰撞,但是该下标对应的链表上所有结点都和待添加的键值对的key不同,这种情况下也会向这个链表中添加Entry结点。
addEntry和createEntry
由于hashMap的核心数据结构是一个数组,所以一定会涉及到数组的扩容,是否需要扩容的依据为成员变量:int threshold
当添加键值对的时候,如果键值对将要占用的位置不是null,并且size>threshold,那么会启动HashMap的扩容方法resize(2*table.length),扩容之后会重新计算一次hash和下标。
不论HashMap是否扩容,都会执行创建键值对createEntry方法,该方法会增加size。
resize
用于给HashMap扩充容量
(1)根据新的容量,确定新的扩容阈值大小。如果当前的容量已经达到了最大容量(1<<30),那么把threshold的值设为Integer的最大值;反之,则用新计算出来的容量乘以加载因子,计算结构和最大容量+1比较大小,取较小值为新的扩容阈值。
加载因子对HashMap的影响很大,如果太小了会导致频繁扩容,太大了会导致空间浪费,0.75是Java提供的建议值。
(2)确定是否要哈希重构,判断依据是原有的useAltHashing(是否使用替代哈希算法标识)和新产生的这个值,如果不一致也需要哈希重构。
(3)使用新容量来构造新的Entry<K,V>数组,调用transfer(Entry[] newTable,boolean rehash)来重新计算当前所有结点转移到新table数组后的下标
transfer(Entry[] newTable,boolean rehash)
该方法会遍历所有的键值对,根据键值的哈希值和新的数组长度来确定新下标,如果需要哈希重构,那么还需先对所有键值执行哈希重构。
put总结
put方法的整个功能包括:
①计算key的hash值
②根据hash值和table长度确定下标
③存入数组
④根据key值和hash值来比对,确定是创建链表结点还是代替之前的链表值
⑤根据增加后的size来扩容,确定下一个扩容阈值,确定是否需要使用代替哈希算法。
get方法
get的实现要比put简单一些
每一次get都要比较对应链表所有结点key值,因为链表的遍历操作的时间复杂度为O(n),所以get方法的性能关键就在链表的长度上。
性能优化
HashMap在执行写操作时,比较消耗资源的是遍历链表,扩容数组操作
HashMap在执行读操作时,比较消耗资源的是遍历链表
影响遍历链表的因素是链表的长度,在HashMap中,链表的长度被哈希碰撞的频率决定
哈希碰撞的频率受数组长度决定,长度越长,碰撞概率越小,但长度越长,闲置的内存空间也就越多。所以扩容数组操作的结果也会影响哈希碰撞的概率,需要在时间和空间上取得一个平衡点。
哈希碰撞的频率又受到key值得hashCode方法影响,所计算得出得hashCode得独特性越高,哈希碰撞得概率越低。
链表的遍历中,需要调用key值得equals方法,不合理的equals会导致HashMap效率低下甚至异常。
因此要提高HashMap的使用效率,可以从以下方面入手:
(1)根据实际业务需求,测试出合理的loadFactor
(2)合理的重写hashCode和equals方法
JDK1.8开始的HashMap
之前的HashMap使用的是数组+链表来实现,这主要体现在Entry<K,V>这个成员变量。
新的HashMap里,虽然依旧使用的是table数组,但是数据类型发生了变化:
显而易见,代表链表结点额Entry换成了Node,Node本身具备链表结点的特性,同时他还有一个子类TreeNode,从名字可以看出这是一个树节点。
可以得出推论:JDK1.8中的HashMap使用的是数组+链表+树的结构。
通过源码分析来证明这点
put方法
主体流程只有2步:
①获取key值得hashCode
②调用putVal方法存值
hash
计算hashCode
JDK1.8也进行了哈希分散,但是过程简单了很多,这是个经验性质的改进,之前版本采用得多次位移异或计算方式与这种方式相比并不能避免太多的哈希碰撞,反倒增加了计算次数。
HashMap得效率问题主要还是出现在链表部分的遍历上,因此提高链表遍历的效率就能够提高HashMap的效率,下面通过源码的实现来讲解JDK1.8如何提高遍历效率。
putVal
hash代表key的hashCode,key代表key值,value代表value值,onlyIfAbsent代表是否取代已存在的值,evict在HashMap中没有特殊意义,是一个为继承预留的布尔值。
这个方法主要做了三件事:
(1)计算下标,将hash与table长度-1进行按位与,与历史版本一样
(2)当table为空,或者数据量超过扩容阈值的时候,增加一个树节点
(3)保存数据
①当下标位置没有结点的时候,直接增加一个链表结点
②当下标位置为树节点的时候,增加一个树节点
③当前面的情况都不满足时,说明当前下标位置有结点,且为链表结点,此时遍历链表,根据hash和key值判断是否重复,以决定是代替某个结点还是新增结点。
④添加链表结点后,如果链表深度达到或超过建树阈值(TREEIFY_THRESHOLD-1),那么调用treeifyBin方法将链表重构为树。注意TREEIFY_THRESHOLD是一个常量,固定为8,因此当链表长度达到7的时候,就会转为树结构。
该树是一棵红黑树,由于链表的查找是O(n),红黑树的查找是O(logn)的,数值太小的时候查找效率相差无几,JDK1.8认为7是一个合适的阈值,因此这个值被用来决定是否从链表转为树结构。
resize
用于重新规划table的长度和阈值,如果table长度发生了变化,那么部分数据结点也需要重新进行排列。
①重新规划table长度和阈值
当数据量(size)超出扩容阈值时,进行扩容:把table的容量增加到旧容量的两倍。
如果新的table容量小于默认的初始化容量16,那么将table容量重置为16,阈值重新设置为新容量和加载因子(默认0.75)之积。
如果新的table容量超出或大于等于最大容量(1<<30),那么将阈值调整为最大整形数,并且retuen,终止整个resize过程。注意由于size不可能超过最大整形值,所以之后不会再触发扩容。
②重新排列数据结点
该操作遍历table上每一个结点,对其分别处理。
如果不为null才处理。
如果没有next结点,那么重新计算hash值,存入新table。
如果为树结点,那么调用该树节点的split方法处理,对红黑树调整,如果红黑树太小退化为链表。
如果是链表结点,根据hashCode值算出的下标不会超过table容量,超出的位数会置0,而resize扩容后table容量发生了变化,同一个链表里有部分结点的下标也应当发生变化。因此需要把链表拆成两部分,分别为hashcode超出旧容量的链表和未超出容量的链表。
红黑树
红黑树是一种自平衡的二叉树,它的实现原理和平衡二叉树类似,但在统计上,它的性能要优于平衡二叉树,他有五个特性:
①结点是红色或者黑色
②根结点是黑色
③每个叶子结点为黑色
④每个红色结点的两个子结点都是黑色
⑤从任意节点到每个叶子的所有路径都包含相同数目的黑色结点
可以参考:https://www.jianshu.com/p/e136ec79235c