HashMap源码学习笔记

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中,取余操作比较慢的。

使用&操作,效率更高,直接基于bit位操作,快就完了

 

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%解密

 

 

 

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值