Java-HashMap详解

本文参考:

hashset源码学习网站:
https://www.bilibili.com/video/BV1FE411t7M7?from=search&seid=4736935028171333861

1. HashMap集合介绍

  1. jdk8之前:HashMap是由数组+链表组成,数组是主体,链表是为了解决哈希冲突

    【哈希冲突】:两个对象调用的hashCode方法计算的哈希码值一致导致计算的数组索引相同

    jdk8之后:HashMap由:数组+链表+红黑树组成;链表和红黑树是为了解决哈希冲突的。

  2. 当链表长度大于阈值(或者红黑树边界值,默认为8)并且当前数组长度大于64时,此时此索引位置上的所有数据改为使用红黑树存储,具体可参考源码:treeifyBin方法

【补充】:在将链表转换成红黑树之前会判断,即使阈值大于8,但是数组长度小于64,此时并不会将链表变为红黑树,而是选择进行扩容。

【原因】:因为此时数组比较小,搜索时间相对要快些,这种情况下转换为红黑树反而降低效率,因为红黑树需要左旋,右旋变色这些操作来保持平衡。这样做是平衡之后为了提高性能和减少搜索时间选择的操作。
在这里插入图片描述
注意:数组比较小时,要尽量避开红黑树结构。

2. HashMap底层的数据结构

2.1 语言过程描述

  1. 当创建HashMap集合对象的时候,在jdk8之前,构造方法中创建一个长度是16的Entry[] table ,用来存放键值对数据的。在jdk8以后,不是在HashMap构造方法中创建数组(只初始化加载因子和初始容量大小),是在第一次调用put方法时创建的数组:**Node[] table;**用来存储键值对数据(两次实现的都是Map.Entry<>)
HashMap<String,Integer> hm = new HashMap<>();
hm.put("柳岩",18);
hm.put("杨幂",28);
hm.put("刘德华",40);
hm.put("柳岩",20);

上述代码存储过程:
(1)假设向哈希表中存储 柳岩=18,根据柳岩调用String类中重写之后的hashCode()方法计算出值,然后结合数组长度,采用某种算法计算出向Node数组中存储数据的空间对应的索引值。如果计算出的索引空间没有数据则直接将柳岩和对应的value18存储到数组中,假设计算出索引为3,杨幂索引为6.

面试题:哈希表底层采用哪种算法计算哈希值的?还有那些算法可以计算哈希值?

  • 底层采用的是key的HashCode方法的值,结合数组长度进行无符号右移(>>>),按位异或(^),按位与(&)。
  • 还可以采用平方取中法,取余数,伪随机数法。
  • 为什么不用其他算法:执行效率比较低,而位运算效率高。因为计算机底层都是二进制存储。

(2)向哈希表中存储数据刘德华-40,假设刘德华计算出的HashCode方法结合数组长度计算出的索引值也是3,那么此时数组空间不是null,此时底层会比较柳岩和刘德华的哈希值是否一致,如果不一致,则在此空间上划出一个节点来存储键值对数据:刘德华-40.

上述这种方式称为:拉链法

(3)假设向哈希表中存储数据:柳岩-20,那么根据柳岩调用hashCode方法结合数组长度计算出索引值,肯定为3.此时比较后存储的数据柳岩和已经存在的数据的哈希值是否相等,如果哈希值相等,此时发生哈希碰撞,那么底层会调用柳岩所属类String类中的equals方法比较两个内容是否相等(如:比较两个柳岩是否相等)。

​ 相等:则将后添加的数据的value覆盖之前的value

​ 不相等:那么继续向下和其他的数据的key进行比较:

​ 如果都不相等,则划出一个节点存储数据;

​ 如果相等,直接覆盖对应的value值。
数据存储结果如下图:
在这里插入图片描述

PS:HashCode值相等的两个key内容不一定相同,如”重地“和“通话”计算的哈希值相等

PS:如果节点长度大于阈值8且数组长度大于64,将链表变为红黑树

即:如果计算出的索引处,存在数据就比较哈希值,哈希值不相等直接存,如果相等就发生哈希碰撞,就要比较key的内容,如果key的内容相等就进行value值覆盖,如果不相等再继续和链表中剩余key值比较相等则继续覆盖value值,都不相等就再划出一块空间存储。

面试题:当两个对象HashCode相等时会怎样?

  • 产生哈希碰撞,若key值相同替换value值(调用equals比较key),不同就链到链表后面,链表长度大于8并且数组长度大于64就转换为红黑树。

面试题:何时发生哈希碰撞?如何解决?

  • 只要计算的key的哈希值相等就会发生哈希碰撞
  • jdk8之前采用链表解决,8之后采用链表+红黑树解决

面试题:如果两个hashCode相同如何存储键值对?

  • hashCode相同,通过equals比较内容(key值),若内容相同直接后添加数据value覆盖,如果不相同,继续存储就可以了。

(5)在不断添加的过程中,会发现有扩容情况,什么时候扩容呢?

检测链表大于阈值,先检查长度是否小于64,小于就扩容,扩容为原来2倍。

jdk8之后在哈希表中引入红黑树只是为了查找效率更高。

传统hashMap的缺点?1.8为什么引入红黑树这样结构不是更复杂了吗?为何阈值大于8换成红黑树?

  • 1.8之前HashMap的实现是数组+链表,即使哈子函数取得再好,也很难达到百分之百均匀分布。通过计算,当存储的长度超过 加载因子*默认长度就会进行扩容(如,默认长16,加载因子为0.75,当超过12时就会扩容)。

    当HashMap中有大量元素都存放同一个桶中时,此时HashMap就相当于一个单链表,加入有n个元素,遍历的时间复读就是O(n),转换为红黑树就会变成O(logn),小于O(n),所以转换为红黑树之后效率变高了。

  • 为什么阈值为8,需要看源码。

哈希流程图:

在这里插入图片描述

3. HashMap继承关系

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
   
//...
}
  1. 由源码可以看出HashMap,继承了AbstractMap<K,V>抽象类,并且实现了 Map<K,V>, Cloneable, Serializable三个接口:

    AbstractMap<K,V>抽象类:实现Map<K,V>接口,以最大限度减少此接口所需的工作

    Cloneable空接口:表示可以克隆,创建并返回一个HashMap副本。(是一个标记性接口表示HashMap可以克隆的,即可以完成一个对象副本)

    Serializable序列化接口:属于标记性接口,HashMap对象可以完成序列化和反序列化

    Map<K,V>:提供Map集合的方法,更加方便

  2. 为什么父类AbstractMap<K,V>抽象类实现Map<K,V>接口后HashMap还要再实现该接口呢?

  • 首先,所有集合都是这样做的
  • 根据集合创始人描述,这样写是个小失误,最开始写集合时认为有某种价值,但最后发现是个错误,oracle公司认为不值当修改就留着了。

4. HashMap集合的成员变量

4.1序列化版本号:

private static final long serialVersionUID = 362498820763181265L;

4.2 集合的默认初始化容量(必须是2的n次幂)

//默认初始化容量16---1<<4等价于2^4=16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

为什么必须是2的n次幂?

  • 首先HashMap在创建元素时是调用hashCode方法计算出哈希码值再结合数组长度,计算出其数组索引,HashMap为了存取高效,要尽量减少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就是把数据存到哪个链表中的算法。这个算法实际就是取模,hash%length,上面说过取模运算的效率没有位运算效率高,所以底层使用了位运算:hash&(length-1),前提就是length是2的n次幂。

(如果数组长度过大就有可能导致有的数组空间一直没有数据,这样的空间比较多的话就会造成空间浪费,如果链表空间很长的话,也会使得查询效率低,即使转化为红黑树效率也会低,所以就要让数组空间分配均匀一些)

为什么这样hash&(length-1)就能减少哈希碰撞呢?让数据在数组空间平均分配呢?2的n次幂

  • hash&(length-1)是按照二进制处理的,如下:
  • 按位与运算:参与运算的两个数,相同位上都为1时结果为1,否则为0
length-1:
 10000
-    1
---------
  1111
按位与运算:参与运算的两个数,相同位上都为1时结果为1,否则为0,等价于低位(数组长度范围位)取得是自己,高位全部被取0,就实现了取余运算。
- hash&(length-1)这种算法如何减少哈希碰撞?
假设数组长度为8,hash:15,那么15&(8-1),对应的二进制运算即为:
     1111-111115&0000-01118-1-----------
     0000-01117=15%8)
     
假设数组长度为8,hash:14,那么14&(8-1),对应的二进制运算即为:
     1111-111014&0000-01118-1-----------
     0000-01106=14%8)
两次计算出的索引并不相同

如果数组长度不是2的n次幂

1:hash=15,数组长度为91001)
  hash&(length-1)运算对应15&9-11111-111115&0000-10009-1----------
    0000-10008)
    
 例2:hash=14,数组长度为91001)
  hash&(length-1)运算对应14&9-11111-111014&0000-10009-1----------
    0000-10008)
上次计算出的索引相同
//从上例可以看出如果数组长度不是2的n次幂,计算出的索引特别容易相同,及其容易发生碰撞,导致其余数组空间很大程度上并没有存储数据,链表或者红黑树过程,效率降低。
    //如果不考虑效率问题,就可以直接用取余操作,不用再用位运算实现,这样链表长度就可以不限定必须为2的n次幂了。

小结:

  1. 由上面可以看出,当我们根据key的hash确定其在数组的位置时,如果n为2的幂次方,可以保证数据的均匀插入,如果n不是2的幂次方,可能数组的一些位置永远不会插入数据,浪费数组空间,加大hash冲突
  2. 另一方面,一般我们可能想通过区域来确定位置,这样也可以,只不过性能不如位运算(&),而且当n是2幂次方时,hash&(length-1) == hash % length;
  3. 因此,HashMap容量为2次幂的原因:为了数据的均匀分布,减少hash冲突,毕竟hash冲突越大,代表数组汇总一个链的长度就越大,这样的话就会降低HashMap的性能。

如果用有参构造给的初始值不是2的n次幂会怎么样呢?

  • 如果创建HashMap对象时,输入的数组长度是10.不是2的次幂,HashMap通过位运算(或运算和右移运算)得到的肯定是2的幂次数,并且是里那个数最近的数字。如,长度为10会创建长为16的数组,底层通过移位运算自主完成。
  • 底层源码分析:
 HashMap<String, Integer> hm = new HashMap<>(10); 

public HashMap(int initialCapacity) {
   
        this(initialCapacity, DEFAULT_LOAD_FACTOR);//用this调用有参构造方法
    }

public HashMap(int initialCapacity, float loadFactor) {
   //有参构造方法
        if (initialCapacity < 0)//10<0-false
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)//MAXIMUM_CAPACITY最大容量,false
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))//false || false
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;//加载因子初始化
        this.threshold = tableSizeFor(initialCapacity);//从这进入初始化数组容量
    }

static final int tableSizeFor(int cap) {
   //初始化数组容量,cap=10
        int n = cap - 1;//减1:防止创建集合的时候恰巧给的长度时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;
    }

说明:

从上面源码可以看出,如果指定的数组长度不是2的幂次方,底层会自己右移,或运算将数组长度转换为与所给长度差距最小的2的幂次方大小。

【分析算法:】

1)int n = cap - 1;

  • 减1:防止创建集合的时候恰巧给的长度时2的次幂的情况。

    如果给的长度恰好是2的幂次方又没有减1,那么经过下面几条无符号右移操作之后,返回的capacity就是这个cap的2倍。

2)如果给的长度是0:经过一 波无符号右移之后得到的还是0,那么在返回时就会执行n+1操作返回一个1给边界值this.threshold.[ this.threshold = tableSizeFor(initialCapacity);]。

3)或运算"|":

运算规则:参与运算的两个数的相同数位上,都是0的时候结果为0,否则为1.

HashMap<String, Integer> hm = new HashMap<>(10); 
cap = 10;
int n = cap - 1;int型数据占32位)
n |= n >>> 1;//第一次操作
    0000-0000 0000-0000 0000-0000 0000-1001  9 (n = cap - 1)
  | 0000-0000 0000-0000 0000-0000 0000-0100  4 (n >>> 1)无符号右移
  -----------------------------------------------
	0000-0000 0000-0000 0000-0000 0000-1101   13 (n |= n >>> 1)

n |= n >>> 2;//第二次操作
	0000-0000 0000-0000 0000-0000 0000-1101   13
  | 0000-0000 0000-0000 0000-0000 0000-0011   3 (n >>> 2)
	-----------------------------------------------
	0000-0000 0000-0000 0000-0000 0000-1111   15 (n |= n >>> 2)

n |= n >>> 4;//第三次操作
	0000-0000 0000-0000 0000-0000 0000-1111   13
  | 0000-0000 0000-0000 0000-0000 0000-0000   3 (n >>> 4)
	-----------------------------------------------
	0000-0000 0000-0000 0000-0000 0000-1111   15 (n |= n >>> 4)
        
n |= n >>> 8;//得到结果还是15,对结果没什么作用了
n |= n >>> 16;//得到结果还是15,对结果没什么作用了
//注意:容量最大也就是32bit的正数,因此最后到n |= n >>> 16为止;最多也就32个1

但是这已经是负数了,所以在执行tableSizrFor之前,对initialCapacity做了判断:
	在HashMap(int initialCapacity, float loadFactor)方法中调用:this.threshold = tableSizeFor(initialCapacity);之前,判断initialCapacity做了判断是否大于MAXIMUM_CAPACITY
,如果大于MAXIMUM_CAPACITY[2^30],则取MAXIMUM_CAPACITY
在tableSizeFor(initialCapacity)方法里面,如果等于MAXIMUM_CAPACITY就先减1得到2^30-1(即291)再执行移位操作,但是此时的每一步移位操作对这个结果都不再起作用,在return语句中执行n+1,,得到最大值2^30.
 即:对最大值MAXIMUM_CAPACITY不是由移位得到的,而是经过返回值语句时加1得到的。
    就一般情况而言也是如此,通过移位,或运算只能达到2^n-1,经过加1之后才是真正的2^n,如上例中移位,或之后得到15,经过加1之后才是16,并将其返回.

4.3 默认的负载因子,默认值是0.75

static final float DEFAULT_LOAD_FACTOR = 0.75f;

【注意】:

数组长度并不是满了才会扩容,而是当数组中存放的数据达到:加载因子*数组长度大小时就会扩容。如数组长16,加载因子0.75,那么当数组中元素有16 * 0.75 = 12个时就会扩容。

4.4 集合最大容量

static final int MAXIMUM_CAPACITY = 1 << 30;上限2^30

4.5 当链表的值超过8则会转换为红黑树(1.8新增)

//当桶(bucket)上的节点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;

【面试】:为什么Map中结点个数大于8才会转为红黑树呢?

8这个阈值在HashMap中,针对成员变量,在源码的主食中是说明了8是bin(即bucket,桶)藏链表转成输的阈值,但是没有说明是为什么、

在HashMap中有一段注释说明:

Because TreeNodes are about twice the size of regular nodes, we
         use them only when bins contain enough nodes to warrant use
         (see TREEIFY_THRESHOLD). And when they become too small (due to
         removal or resizing) they are converted back to plain bins.  In
         usages with well-distributed user hashCodes, tree bins are
         rarely used.  Ideally, under random hashCodes, the frequency of
         nodes in bins follows a Poisson distribution
         (http://en.wikipedia.org/wiki/Poisson_distribution) with a
         parameter of about 0.5 on average for the default resizing
         threshold of 0.75, although with a large variance because of
         resizing granularity. Ignoring variance, the expected
         occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
         factorial(k)). The first values are:
 
 因为(在内存中)树节点(红黑树节点)大小约是普通节点(链表节点)的两倍,所以我们只在箱子包含足够节点的时候才使用树节点(参见——TREEIFY_THRESHOLD)。当它们变得太小(由于删除或调整大小时),就会被换成普通的桶,在使用分布良好的用户hashcode时,很少使用树箱。理想情况下,在随机哈希码下,箱子中节点的频率服从泊松分布。(http://en.wikipedia.org/wiki/Poisson_distribution),默认调整阈值为0.75,平均参数约为0.5,尽管由于调整粒度的差异很大,忽略方差,列表大小k的预期出现次数是(exp(-0.5) * pow(0.5, k) / factorial(k))
第一个值是:
	  0:    0.60653066
      1:    0.30326533
      2:    0.07581633
      3:    0.01263606
      4:    0.00157952
      5:    0.00015795
      6:    0.00001316
      7:    0.00000094
     *8:    0.00000006
     
//红黑树的节点比链表节点在内存中占的空间大两倍,所以当达到边界值才变为红黑树,当小于边界值时不建议转换为红黑树,当节点数小于边界值时也会由红黑树变为链表,这样从空间时间角度查询效率都会比较高。根据统计学泊松分布,统计每个小标下存储元素的概率,计算出当达到8指向的空间时存储的概率非常小

上述注释就说明了为什么不是一开始就将其转换为TreeNodes,而是需要一定节点数才转为TreeNodes,说白了就是权衡空间和时间。

这段内容还说到,当hashCode离散型很好的时候,树型bin用到的概率非常小,因为数据均匀分布在每个bin中几乎不会有bin中链表长度回答道阈值,但是在随机hashCode下,离散型可能会变差,然而JDK又不能组织用户实现这种不好的hash算法,因此就有可能法制不均匀的数据分布,不过理想情况下随机hashCode算法下所有bin中结点的分布概率会遵循泊松分布,我们可以看到,一个bin中链表长度达到8个元素的概率为0.00000006,几乎是不可能事件,所以之所以选8,不是随便决定的,而是根据概率统计决定的,由此可见,发展将近30年的Java每一项改动和优化都是非常严谨和科学的。

也即:选择8是因为8是符合泊松分布的,超过8的时候概率已经非常小了,所以我们选择8这个数字

2)下面是老师在研究这个问题时遇到的另外一个种解释

hong红黑树的查找长度是log(n),如果长度为8,平均查找长度为log(8)=3,链表的平均查找长度为n/2,当长度为8时,平均查找长度为8/2=4,这才有转换为红黑树的必要;如果链表常熟小于等于6,6/2=3log(6)=2.6,虽然速度也很快,但是转化为树结构和生成树的时间并不会太短。
    但是维持红黑树结构的话还需要左旋,右旋,耗费时间

4.6 当链表值小于6时会从红黑树转回链表

//当桶上的结点数小于这个值时书转为链表
static final int UNTREEIFY_THRESHOLD = 6;

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值