jdk1.7的HashMap底层原理
简单理解一遍jdk1.7的HashMap底层原理
数组中的每一个位置都有一个entry对象的引用地址
一个entry对应一个key对应一个index
????这个index是通过hashcode
为什么hashcode取余不可以当index?
如果 346546464%8 ------0~7的数
就会出现相同的index,都要存在这个数组的同一个位置
这就是哈希冲突!!
产生哈希冲突的解决办法?
数组加链表的方式,每一个数组位置存一个链表
基于插入的效率来讲,插入头部的效率最高!因为尾插和中插都需要先遍历找到目标结点再加到它的后面,如果插到头部,只要在找到头部结点,指向它就完事了!
如何通过get(key)找到value?
数组中的每一个位置都有一个entry对象的引用地址
一个entry对应一个key对应一个index
也就是先key----hashcode----(取余(某种方式))----index
但是这个entry1的key不是周瑜,然后不论怎么next都找不到周瑜。不会向上找!
怎么解决找不到元素????
插入时移动链表!
方式:每次插入头部时都要移动整个链表位置,将新插入的结点放到数组位置对应的第一个链表位置,就不会出现找不到元素的问题。
如果每次插入结点都遵循这个原则,每次get都会找到元素了!
怎么实现插入时移动链表???
移动头部结点,头结点保持在数组table[index]上的位置,所以只需要移动链表的头结点到数组位置上就完了
怎么将链表的头结点到数组位置上???
先了解一下一般的插入元素put的实现方式
先通过传进来的key进行哈希-----得到的哈希码进行数组长度取余(某种方式)-----取余后得到一个index-----然后将传进来的key、value放入一个Entry对象,null表示没有下一个元素的地址(指向下一个元素为空)----最后就放到index位置的数组上。
增强版put (jdk1.7头插法)
先通过传进来的key进行哈希-----得到的哈希码进行数组长度取余(某种方式)-----取余后得到一个index-----然后将传进来的key、value放入一个Entry对象,同时存放着旧头部元素位置(指向下一个元素为旧头部元素)----最后就放到index位置的数组上,也就是链表的头部就保持在数组的位置上。
哈希冲突
什么是哈希冲突?
哈希冲突就是当你的key的hashcode经过hash算法和indexFor方法处理后得出的一个数组位置已经有元素占了!有两种冲突,第一种就是不同的key产生的hashcode相同,哈希算法后就能得出相同的index位置。第二种就是不同的key的hashcode不同,但是经过hash算法和indexFor方法后得出相同的index位置。
怎么解决哈希冲突?
链地址法
jdk1.7里的HashMap使用的是链地址法。
这种方法的是将所有哈希地址相同的元素i构成一个单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在单链表中进行。链地址法适用于经常进行插入和删除的情况。
开放定址法
这种方法也称再散列法,其基本思想是:当关键字key的哈希地址p=H(key)出现冲突时,以p为基础,产生另一个哈希地址p1,如果p1仍然冲突,再以p为基础,产生另一个哈希地址p2,…,直到找出一个不冲突的哈希地址pi ,将相应元素存入其中。这种方法有一个通用的再散列函数形式:
Hi=(H(key)+di)%m i=1,2,…,n
其中H(key)为哈希函数,m 为表长,di称为增量序列。增量序列的取值方式不同,相应的再散列方式也不同。
再哈希法
这种方法是同时构造多个不同的哈希函数: Hi=RH1(key) i=1,2,…,k
当一个哈希函数地址还产生冲突时,在计算另一个哈希函数地址,直到不再发生冲突为止。
建立公共溢出区
这种方法就是将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表,比较粗暴。
源码解析
构造方法
HashMap无参构造方法调用HashMap的有参构造方法
有参构造方法
有两个参数
initialCapacity:初始化容量,也就是数组的大小
默认是16
loadFactor:扩容,叫做加载因子
默认是0.75,也就是3/4
初始化数组
设置一个2的n次方的容量大小作为Entry类型数组table
为什么设置一个2的n次方的容量大小的数组呢?
防止数组越界,在indexFor方法体现。
put方法
HashMap的key可以为null吗?
首先判断table数组是否为空,如果为空就执行putForNullKey方法,证明了HashMap的key可以为null。
indexFor()方法
indexFor()方法:将hash码传入indexFor方法,然后将hash码和容量-1进行 & 运算
为什么要将hash码传入indexFor方法进行 & 运算?
得到一个【0,容量-1】的index,防止数组越界
首先需要将数组容量设置为2n 的形式(2的指数倍的二进制都是只有一个1),再进行减一操作。得到的是左边全是0,右边全是1的二进制数,只有这样才能保证运算结果大小范围在【0,(2^n - 1)】区间内,这样子可以防止数组越界。就比如容量为16,结果就是0—15,不会让index超过15。
为什么要用&操作呢?而不用取余呢?
跟jdk历史有关。在jdk1.4中,取余操作比较慢的。
hash()方法
hash()方法:先得到一个hashcode,传入hash方法进行异或和右移操作得到hash码
为什么key的hashcode要通过hash方法进行异或和右移操作??
如果直接使用key的hashcode会造成大概率的哈希冲突,也就是index相同,造成链表非常长,导致get的效率非常低,提高散列性来提高查询效率。
假设容量为16,那么就要对二进制0000 1111(即15)进行按位与操作,那么hash值的二进制的高28位无论是多少,都没意义,因为都会被0&,变成0。所以哈希冲突容易变多。
进行h^=(h>>>20)^(h>>>12)运算,将h的二进制中高位右移变成低位。其次异或运算是利用了特性:同0异1原则,尽可能的使得h>>>20和h>>>12在将来做取余(按位与操作方式)时都参与到运算中去。综上,通过h^=(h>>>20)^(h>>>12);运算,可以使k.hashCode()方法获得的hash值的二进制中高位尽可能多地参与按位与操作,从而减少哈希冲突。
addEntry()方法
头插法,就是将新结点放在数组的位置上,也就是放在链表的头部。addEntry(hash, key, value, i) 将hash完的hash码和key value和索引值i传入addEntry方法,也就是增加一个数组位置。
首先找到数组位置上的头结点,其次将原本头结点作为新结点的下一个指向,然后再讲新结点作为头结点,放在数组的位置上。同时判断所有的键值对数量size是否大于阈值,大于就进行扩容,数组*2。先加数组
creatEntry()方法
与addEntry()方法类似,每增加一个结点就size++。
for循环
for (Entry<K,V> e = table[i]; e != null; e = e.next){} 遍历这个数组位置对应的链表
循环整个链表的所有结点,直到符合两大条件的,就进行覆盖操作,返回的是一个旧值。
如果不满足判断条件,那么就执行addEntry(hash, key, value, i)加入新结点操作。
这个两大判断条件是什么呢??
if (e.hash == hash &&
((k = e.key) == key || key.equals(k)) )
第一个e.hash == hash,判断的是两个hashcode不一样的,但是index的位置一样的key对象,也就是说一个数组位置的链表存在(1)不一样的key但是hashcode相同,也就是hash相同的key对象(2)同时还存在hashcode不相同,也就是hash不同,但是经过indexFor后算出的数组位置index相同的key对象。正是有这两种key对象组成的链表。因此e.hash == hash就是用来判断第(2)种的key对象,因为hashcode不同,key对象肯定不同。e.hash == hash不成立的话,就说明不是同一个对象,直接不进行后面的(k = e.key) == key || key.equals(k),短路了。跳出判断条件遍历下一个结点。
第二个条件(k = e.key) == key || key.equals(k))就是用来判断(1)的那种hashcode的相同,对象不同的那种,如果key是同一个,就代表是同一个对象。key.equals(k)的使用的前提是hash相同,进一步equals,如果是对象进行过重写,也可以通过这个判断。
基于理论的角度,e.hash == hash在前面就是因为hashcode不同就说明不是同一个对象。短路操作,不必执行后面的equals方法
基于效率的角度,equals方法先执行的话效率会低。
resize方法
扩容:扩到原来的2倍,阈值乘2
再通过transfer方法进行转移。
遍历每一个链表上的元素,经过新的&运算,得出新的位置。
key为空的处理方法
放在数组的第0个位置,永远只有一个,会覆盖
get方法
遍历----判断----取值
getForNullKey
去第0个数组位置找
jdk1.8的HashMap底层原理
为什么要用红黑树??
保证put和get效率,比较均衡,折中的考虑
新增的内容
转换阈值
链表变成红黑树的转换阈值:TREEIFY_THRESHOLD 默认为8
当链表的个数大于等于8,就认为你的查询效率就比较低了,就转换成红黑树,来均衡一下两者,折中一下。
反向阈值
红黑树变成链表的反向阈值:UNTREEIFY_THRESHOLD 默认为6
当红黑树上的结点只有6个的时候就转变成链表。认为查询压力小了,就提高插入效率。
为什么两个阈值一个是8,一个是6呢???为什么不是其它数字呢?
防止频繁的转变,间隔两个临界值,来降低频繁转换的操作。
假如两个值都是8的话,如果现在链表上的元素达到了8,那就转换成红黑树,来了第9个元素插入到树中,接下来我去除掉这个元素,那么就剩下8个元素,这个时候发生什么呢?
是不是又转换成链表了?又来一个元素呢?又转换成红黑树吗?一个设置为8,一个是6,是用来间隔开这两个临界值的,7作为缓冲的存在。泊松分布表明发生8次哈希冲突的概率是亿分之一。
源码解析
构造方法
构造方法:判断+赋值,与jdk1.7差别不大
put方法
put方法:调用了putVal方法
先对key进行hash算法处理
hash(key)
1.8稍微简化了key的hash值,因为1.7中是通过提高散列性来提高查询效率,现在引入了红黑树,就不用那么复杂了。右移和与运算都有。
putVal方法
putVal方法
1.7的Entry到1.8变成Node
属性还是原本那4个
找到数组index后并且有结点的情况下,想添加结点,必须先遍历一下原本的链表,到底有几个,要是8个以上,那就是插入红黑树,8个以下就插到链表的尾部,同时还需要判断是否有相同的key对象,有的话就直接覆盖,进入else。
判断新结点是否是数组上的那个结点,也就是链表或树的头结点。是的话就覆盖,否则就下一步。
这一步表示数组上的p结点是否属于树结点,是的话就按照红黑树的规则加入树结点。
若不是树结点,那就走链表那种方式,在尾部加新结点。
首先加入一个binCount,来记录结点的个数,一边遍历一边记录。
第一个判断就是已经遍历到链表的尾部了,直接新建一个结点放在链表的尾部。如果binCount达到8转为红黑树。以红黑树的方式加入结点。
第二个判断就是如果遍历的过程中发现相同的key对象,就进行覆盖操作。
treeifyBin方法
树化方法!链表长度达到8或者以上就会进入树化方法。
链表长度达到8就会转成红黑树吗?
不一定!因为里面有两个判断条件
如果数组为空就重新初始化一个数组(resize()方法有初始化与扩容的功能);或者是数组的长度小于默认的树化容量(默认是64)的话,就先进行resize扩容,这也是基于长远的角度来考虑的,因为扩容后进行重新indexFor,会使链表的长度变短,也一定程度上缓解了查询压力,同时也解决了选择扩容还是选择转换成红黑树的冲突!1.8的HashMap就是先扩容再树化。例如,原本的数组容量是16,扩容第一次就是32,扩容第二次就是64,这时候数组长度等于树化容量(最小树形化容量阈值)就可以进行树化了!
手写一个HashMap
数组+链表的实现方式
定义一个接口
实现类
public class myHashMap<K, V> implements Map<K, V> {
private Entry<K, V> table[] = null;
int size=0;
public myHashMap() {
this.table = new Entry[16];
}
/**
* 通过key hash 算法hash
* index下标数组当前下标对象
* 判断当前对象是否为空如空直接存储
* 如果不为空冲突next链表
* 返回当前的节点对象
*
* @param k
* @param v
* @return
*/
@Override
public V put(K k, V v) {
int index = hash(k);
//找到原本数组位置上的结点,存放在一个临时的entry里
Entry<K, V> entry = table[index];
//1、如果数组上没有结点,是空的直接存
if (null == entry) {
table[index] = new Entry<>(k, v, index, null);
size++;
} else {
//数组上有结点,把旧结点放在next的指针上
table[index] = new Entry<>(k, v, index, entry);
size++;
}
return table[index].getValue();
}
/**
* 自定义的简单哈希算法
* @param k
* @return
*/
private int hash(K k) {
int index = k.hashCode() % 16;
//index是负数的话就负负得正
return index >= 0 ? index : -index;
}
/**
*通过key 进行hash
* 找到index数组下标
* 判断当前对象是否为空
* 如果不为空 判断是否相等
* 如果不相等 判断next是否为空
* 如果不为空 再判断是否相等
* 直到相等为止或者为空
* @param k
* @return
*/
@Override
public V get(K k) {
if(size==0){
return null;
}
int index=hash(k);
Entry<K, V> entry = findValue(table[index],k);
if(null!=entry){
return entry.getValue();
}else{
return null;
}
}
/**
*
* @param entry 取出下标对应的结点
* @param k 查询 的key
* @return
*/
private Entry<K,V> findValue(Entry<K,V> entry, K k) {
//遍历链表上的结点,递归的方式
if(k.equals(entry.getKey())||k==entry.getKey()){
return entry;
}else {
if(entry.next!=null){
return findValue(entry.next,k);
}
}
return null;
}
@Override
public int size() {
return size;
}
class Entry<K, V> implements Map.Entry<K, V> {
K k;
V v;
int hash;
Entry<K, V> next;
public Entry(K k, V v, int hash, Entry<K, V> next) {
this.k = k;
this.v = v;
this.hash = hash;
this.next = next;
}
@Override
public K getKey() {
return k;
}
@Override
public V getValue() {
return v;
}
}
}
添加Entry数组实例化
实现put方法
实现get方法
测试
数组+链表的实现方式存在严重的哈希冲突问题
因为自定义的数组长度固定在16,所以由于哈希冲突产生了一条条很长的链表,严重影响查询的效率。这也是为什么要引入红黑树的原因所在。
想要查找最后或者某个结点,需要一个一个往下查找,时间复杂度很高。
引入红黑树可以让性能提升一倍,提升100%
解决了链表过长查询效率过低的问题。
红黑树简单记忆
左中右
小中大
小的值都在左边,大的值在右边
查询效率快,但是插入效率会低
为什么要加入阈值?
阈值默认是8,加入阈值就是为了均衡查询与插入的性能。
鱼与熊掌不可兼得,在结点数量比较小的时候,查询压力小,就优先插入效率。结点多时,查询压力大了,就牺牲插入效率,优先查询效率。
为什么是8呢?
CPU 100%解密