这大概是最枯燥的一篇HashMap小总结了

HashMap的原理

HashMap是基于哈希表的、实现了Map接口的类,可以存储一个null键。JDK1.7底层使用了数组加链表,JDK1.8改为数组加链表/红黑树。通过哈希值来定位存储位置,并且通过各种优化算法有效降低哈希冲突和操作效率。

HashMap底层数据结构

JDK1.7

底层使用了Entry类型的数组和链表,并且为了提高插入效率采用头插法,这种方法造成的问题是在多线程情况下进行put操作引发扩容操作时会产生环,导致死循环的出现。

头插法:

Entry<K,V> e = table[i];//先获取当前哈希桶的头节点
table[i] = new Entry<>(hash,key,value,e);//新建一个节点,后继节点为原来的头节点,然后放回哈希桶代替原来的头节点
size++//使用一个指针来记录元素的个数

在添加元素的时候,如果元素个数超过阈值,并且计算出来的下标里面有元素(不为null),那么才会进行扩容;如果下标对应的哈希桶没有元素,那么即便元素个数超过阈值也不会扩容。

扩容函数是resize,而里面涉及到了一个转移元素的函数transfer,死循环的关键在于transfer。HashMap会遍历哈希桶和链表,将元素全部重定位然后采用头插法去把元素插在新的哈希桶中,那么元素的顺序就会倒序,多线程情况下如果一个线程刚获取到了某个哈希桶里面的元素,另一个线程已经把它通过头插法插进新的哈希桶了,因为是倒序的,所以就会出现那个元素的后继节点的后继节点是那个元素,即出现环的情况。而出现环了之后HashMap继续遍历该链表就会出现死循环。

JDK1.8

底层使用了基于Entry的Node类型数组和链表,同时加入红黑树,并且采用尾插法。尾插法解决了环问题,但是依旧线程不安全,因为它的插入操作不是原子性的,会造成数据覆盖丢失。引入红黑树解决了链表过长造成查询效率低下的问题,当链表长度超过阈值(默认为8,根据泊松分布,长度为7的概率是百万分之一,就以7为界限)就会转化为红黑树;当红黑树长度降为6时退化回链表。红黑树的插入操作比平衡树效率高,因为它保持平衡的条件没有平衡树那么苛刻,就减少了很多移动节点的开销。

HashMap的hash函数和定位

HashMap的hash函数定义如下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

为什么要这样定义呢?
我们一般用String来作为key,String重写了hashCode方法,返回的是int类型的,范围为-2 ^ 31 - 2 ^31-1,单论正数就有大约20亿个,而HashMap初始容量为16,所以直接使用hashCode作为下标很容易会造成越界错误。

假如我们想要不越界,那么自然而然就想到了%数组长度来保证不越界。这当然没问题,但是%的效率并不高,而且只有低位会参与运算,很容易造成计算结果相同导致链表过长或红黑树过大(下面我们在如何解决哈希冲突中再详细讨论)。

HashMap的hash算法,它将hash值右移16位,获取hash值的高位,再与原hash值做异或运算,这样高位就能参与运算,使数据分布更加均匀,有效降低哈希冲突的概率。

以上hash函数通过两次扰动(一次位运算和一次异或运算,在JDK1.7中有九次扰动:4次位运算,5次异或运算(9次扰动))即解决了哈希冲突和数据分布不均匀的问题,堪称经典。

定位
HashMap并不是直接使用hash值来作为数组下标,而是使用(n-1)&hash来定位,n是指数组的当前容量。%数组长度的方法效率不高,而假如数组长度是2的幂次方,那么hash%length就等价于hash&(length-1),而使用&效率极高。这也是为什么HashMap的数组长度总是设定为2的幂次方。即便自己设定初始容量,HashMap也会计算得到一个最近的二次方数作为容量(JDK1.7和JDK1.8都是这样)。

HashMap如何解决哈希冲突

哈希冲突是值不同的key计算出来的哈希值相同,那么就会覆盖掉原来的元素。HashMap采用了数组加链表(JDK1.8加入了红黑树做优化),那么假如一个key的hash值计算出来,发现已经存在了,那么就会再进行判断,看存在的这个元素的key和要存储的key是否相同,假如相同就做更新操作,用新值覆盖旧值,并返回旧值;假如不相同,说明就出现了哈希冲突,那么就存储在这个数组的链表中。这就是为什么上面说如果hash函数计算结果出现太多相同的会导致链表过长。

JDK1.8中加入了红黑树作为优化,假如链表的长度大于阈值就会转化成红黑树,使得原本O(N)的查找时间复杂度变为O(logN)。

HashMap的扩容机制

HashMap是在初始化或者数组大小元素个数(添加元素操作完成后)超过阈值(负载因子0.75*哈希桶当前容量)的时候进行扩容的。

在JDK1.7的时候,扩容需要重新计算hash值,然后把元素分派到对应位置,效率比较低。而JDK1.8中,只在同一个桶中计算hash值,计算方法是将hash值与原数组容量的大小进行与运算,假如为0,就放在原位置,假如不为0则放在原位置+新增容量的位置上。

判断是否换位:

(e.hash & oldCap) == 0

赋值代码:

		//loTail是不需要换位置的链表
		if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;//放在原地
                    }
		//hiTail是需要换位置的链表
                if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;//放在原位置+新增容量的位置上
                    }

HashMap的key

一般我们习惯用String类来作为key,为什么呢?
因为String类底层使用char[]数组实现,它定义为fianl,即不可变。不可变的对象作为key的好处是它的hashCode可以被缓存且未来不会被修改,减少开销和降低哈希冲突的概率。

那么我们可以用自定义的类对象来作为key吗?
答案是可以的,但是假如自定义类重写了equals方法,那么必须重写hashCode方法,而且它的equals和hashCode必须符合相关规范:

  • hashCode相同的对象,equals不一定相同
  • equals相同的对象,hashCode必定相等
  • 如果两个对象相同,它们互相调用equals得到的结果都是true

为什么要重写hashCode方法和equlas方法?
当自定义类的对象作为key时,HashMap使用该对象的hashCode作为插入下标的重要参考依据,那么我们就必须保证具有相同属性的对象它们的hashCode相同,所以要重写hashCode方法(Object的hashCode方法是内存地址,所以两个不同的对象hashCode必定不同;我们重写hashCode方法可以根据对象的属性经过某种运算来确定hash值,这样只要拥有相同属性的对象它们的hashCode都相同);而重写equls方法是为了在get的时候能获取到正确的元素,因为get时用hashCode定位到了下标,但是还要用equals方法去比较两者是否相同,假如只重写了hashCode方法但是没有重写equals方法,那么即便能根据hashCode正确地定位元素,因为equals比对不成功,同样无法取出元素(因为Object的equals方法是直接拿==来比较的,即比较内存地址,不同对象必定equals返回false)。

举个例子:
我们用User类对象作为key,用户的订单情况作为value存在HashMap中。我们根据登录信息(用户名和密码)来创建了一个新的User对象user来封装登录信息,用这个user去HashMap里面查找它的订单情况。这时候这个新的用户虽然有着跟HashMap中一样的属性(同样的用户名和密码),但是它们是不同的对象,如果User类没有重写hashCode和equals方法,就无法根据用户的登录信息去获取对应的订单数据。

最佳实践是将类定义为fianl
类被定义为fianl后,其所有的成员变量及方法都默认是fianl,那么它的hashCode也就不会改变,这样作为key就不用担心因为对象属性被修改而获取不到数据了。

HashMap作为HashSet的底层实现

HashSet的底层是使用HashMap来实现的。它的key是要存储的元素,value是一个final Object对象 PRESENT,因为HashMap的key是唯一的,这样就实现了HashSet的去重效果。

HashMap和Hashtable的区别

两者实现基本相同,但是HashMap是线程不安全的,而Hashtable它使用了synchornized去同步方法,所以线程安全,但是效率低,一般不使用了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值