HashMap梳理

转载:

原文链接: https://blog.csdn.net/qq_35190492/article/details/103467732.

我们都知道hashMap 是我们非常常用的数据结构。它是由数组和链表组合构成的数据结构
在这里插入图片描述
大概如图,数组每个地方都存着Key-Values 这样的实例,在java 7 里叫Entry(入口),在java 8 里叫Node(节点)
因为它本身所有的位置都为null,所有在put 插入时会根据key 的hash 去计算一个索引值。
就比如我put(”帅丙“,520),我插入了为”帅丙“的元素,这个时候我们会通过哈希函数计算出插入的位置,计算出来索引是2那结果如下。
在这里插入图片描述

  • 我们前面有提到了还有链表,为啥需要链表,链表又是怎么样子的呢?

我们都知道数组长度是有限的,在有限的长度里面我们使用哈希,哈希本身就存在概率性,就是”帅丙“和”丙帅“我们都去hash有一定的概率会一样,就像上面的情况我再次哈希”丙帅“极端情况也会hash到一个值上,那就形成了链表。
在这里插入图片描述

  • 每一个节点都会保存自身的hash、key、value、以及下个节点,我看看Node的源码。
    *

说到链表,你想知道新的Entry节点在插入链表的时候,是怎么插入的么?

java8之前是头插法,就是说新来的值会取代原有的值,原有的值就顺推到链表中去,就像上面的例子一样,因为写这个代码的作者认为后来的值被查找的可能性更大一点,提升查找的效率。

  • 但是,在java8之后,都是所用尾部插入了。

首先我们看下HashMap的扩容机制:
数组容量是有限的,数据多次插入的,到达一定的数量就会进行扩容,也就是resize(调整大小)。

那么什么时候开始扩容那:

有两个因素:

Capacity:HashMap当前长度。
LoadFactor:负载因子,默认值0.75f。
>

  • 怎么理解呢,就比如当前的容量大小为100,当你存进第76个的时候,判断发现需要进行resize了,那就进行扩容,但是HashMap的扩容也不是简单的扩大点容量这么简单的。

它的扩容分为两步:
1).扩容:创建一个新的Entry空数组,长度是原数组的2倍。
2).ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组。

扩容为什么要重新Hash呢,直接复制过去不香么?

是因为长度扩大以后,Hash的规则也随之改变。
Hash的公式—> index = HashCode(Key) & (Length - 1)

补充:任意一个数 & (位运算“与”)上一个正整数n,得到的结果一定是0-n的正整数切包括0-n。

原来长度(Length)是8你位运算出来的值是2 ,新的长度是16你位运算出来的值明显不一样了。
在这里插入图片描述

为啥之前用头插法,java8之后改成尾插了呢?

我先举个例子吧,我们现在往一个容量大小为2的HashMap put两个值,负载因子是0.75是不是我们在put第二个的时候就会进行调整大小?

2*0.75 = 1 所以插入第二个就要调整大小了
在这里插入图片描述

现在我们要在容量为2的容器里面用不同线程插入A,B,C,假如我们在调整大小之前打个短点,那意味着数据都插入了但是还没调整大小,那扩容前可能是这样的。
我们可以看到链表的指向A->B->C
在这里插入图片描述
因为resize的赋值方式,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。

  • 就可能出现下面的情况,

B的下一个指针指向了A
在这里插入图片描述
如果这个时候去取值,悲剧就出现了——死循环。

头插是JDK1.7的那1.8的尾插是怎么样的呢?

因为java8之后链表有红黑树的部分,大家可以看到代码已经多了很多if else的逻辑判断了,红黑树的引入巧妙的将原本O(n)的时间复杂度降低到了O(logn)。
红黑树图文详解->链接: https://mp.weixin.qq.com/s/-8JFh5iLr88XA4AJ9mMf6g.

使用头插会改变链表的上的顺序,但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题了。

就是说原本是A->B,在扩容后那个链表还是A->B

  • Java7在多线程操作HashMap时可能引起死循环,原因是扩容转移后前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系。
  • Java8在同样的前提下并不会引起死循环,原因是扩容转移后前后链表顺序不变,保持之前节点的引用关系。

是如果发生hash冲突的概率比较高,就会导致同一个桶中的链表长度过长,遍历效率降低,所以在JDK1.8中如果链表长度到达阀值(默认是8),就会将链表转换成红黑二叉树。

HashMap的put方法实现

思路如下:
1.table[]是否为空
2.判断table[i]处是否插入过值
3.判断链表长度是否大于8,如果大于就转换为红黑二叉树,并插入树中
4.判断key是否和原有key相同,如果相同就覆盖原有key的value,并返回原有value
5.如果key不相同,就插入一个key,记录结构变化一次

 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
 2                    boolean evict) {
 3 //判断table是否为空,如果是空的就创建一个table,并获取他的长度
 4         Node<K,V>[] tab; Node<K,V> p; int n, i;
 5         if ((tab = table) == null || (n = tab.length) == 0)
 6             n = (tab = resize()).length;
 7 //如果计算出来的索引位置之前没有放过数据,就直接放入
 8         if ((p = tab[i = (n - 1) & hash]) == null)
 9             tab[i] = newNode(hash, key, value, null);
10         else {
11 //进入这里说明索引位置已经放入过数据了
12             Node<K,V> e; K k;
13 //判断put的数据和之前的数据是否重复
14             if (p.hash == hash &&
15                 ((k = p.key) == key || (key != null && key.equals(k))))   //key的地址或key的equals()只要有一个相等就认为key重复了,就直接覆盖原来key的value
16                 e = p;
17 //判断是否是红黑树,如果是红黑树就直接插入树中
18             else if (p instanceof TreeNode)
19                 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
20             else {
21 //如果不是红黑树,就遍历每个节点,判断链表长度是否大于8,如果大于就转换为红黑树
22                 for (int binCount = 0; ; ++binCount) {
23                     if ((e = p.next) == null) {
24                         p.next = newNode(hash, key, value, null);
25                         if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
26                             treeifyBin(tab, hash);
27                         break;
28                     }
29 //判断索引每个元素的key是否可要插入的key相同,如果相同就直接覆盖
30                     if (e.hash == hash &&
31                         ((k = e.key) == key || (key != null && key.equals(k))))
32                         break;
33                     p = e;
34                 }
35             }
36 //如果e不是null,说明没有迭代到最后就跳出了循环,说明链表中有相同的key,因此只需要将value覆盖,并将oldValue返回即可
37             if (e != null) { // existing mapping for key
38                 V oldValue = e.value;
39                 if (!onlyIfAbsent || oldValue == null)
40                     e.value = value;
41                 afterNodeAccess(e);
42                 return oldValue;
43             }
44         }
45 //说明没有key相同,因此要插入一个key-value,并记录内部结构变化次数
46         ++modCount;
47         if (++size > threshold)
48             resize();
49         afterNodeInsertion(evict);
50         return null;
51     }

HashMap的get方法实现

实现思路:
1.判断表或key是否是null,如果是直接返回null
2.判断索引处第一个key与传入key是否相等,如果相等直接返回
3.如果不相等,判断链表是否是红黑二叉树,如果是,直接从树中取值
4.如果不是树,就遍历链表查找

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//如果表不是空的,并且要查找索引处有值,就判断位于第一个的key是否是要查找的key
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
//如果是,就直接返回
                return first;
//如果不是就判断链表是否是红黑二叉树,如果是,就从树中取值
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//如果不是树,就遍历链表
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

java 8 可以将HashMap 用在多线程中吗?

我认为即使不会出现死循环,但是通过源码看到put/get方法都没有加同步锁,多线程情况最容易出现的就是:无法保证上一秒put的值,下一秒get的时候还是原值,所以线程安全还是无法保证。

HashMap的默认初始化长度是多少?为什么?

初始化大小是16

这样是为了位运算的方便,位与运算比算数计算的效率高了很多,之所以选择16,是为了服务将Key映射到索引的算法。

所有的key我们都会拿到他的hash,但是我们怎么尽可能的得到一个均匀分布的hash呢?
是的我们通过Key的HashCode值去做位运算。
我打个比方,key为”帅丙“的十进制为766132那二进制就是 10111011000010110100
我们再看下index的计算公式:index = HashCode(Key) & (Length- 1)
15的的二进制是1111,那10111011000010110100 &1111 十进制就是4
之所以用位与运算效果与取模一样,性能也提高了不少!
那为啥用16不用别的呢?
因为在使用不是2的幂的数字的时候,Length-1的值是所有二进制位全为1,这种情况下,index的结果等同于HashCode后几位的值。
只要输入的HashCode本身分布均匀,Hash算法的结果就是均匀的。
这是为了实现均匀分布。

为啥我们重写equals方法的时候需要重写hashCode方法呢?

因为在java中,所有的对象都是继承于Object类。Ojbect类中有两个方法equals、hashCode,这两个方法都是用来比较两个对象是否相等的。

在未重写equals方法我们是继承了object的equals方法,那里的 equals是比较两个对象的内存地址,显然我们new了2个对象内存地址肯定不一样

  • 对于值对象,==比较的是两个对象的值
  • 对于引用对象,比较的是两个对象的地址
    前面说的HashMap是通过key的hashCode去寻找索引的,那索引一样就形成链表了,也就是说”帅丙“和”丙帅“的索引都可能是2,在一个链表上的。

我们去get的时候,他就是根据key去hash然后计算出索引,找到了2,那我怎么找到具体的”帅丙“还是”丙帅“呢?

equals!是的,所以如果我们对equals方法进行了重写,建议一定要对hashCode方法重写,以保证相同的对象返回相同的hash值,不同的对象返回不同的hash值。

不然一个链表的对象,你哪里知道你要找的是哪个,到时候发现hashCode都一样,不就蒙了嘛!

是怎么处理HashMap在线程安全的场景

在这样的场景,我们一般都会使用HashTable或者ConcurrentHashMap,但是因为前者的并发度的原因基本上没啥使用场景了,所以存在线程不安全的场景我们都使用的是ConcurrentHashMap。

  • HashTable我看过他的源码,很简单粗暴,直接在方法上锁,并发度很低,最多同时允许一个线程访问,ConcurrentHashMap就好很多了!
    *

ConcurrentHashMap

链接: https://mp.weixin.qq.com/s/AixdbEiXf3KfE724kg2YIw.

HashMap是怎么处理hash碰撞的?

HashMap是一个数组,数组中的每个元素是链表。put元素进去的时候,会通过计算key的hash值来获取到一个index,根据index找到数组中的位置,进行元素插入。当新来的元素映射到冲突的数组位置时,只需要插入到对应链表位置即可,新来的元素是插入到链表的头部。java 8 改为了尾插法不再是头部

Java中HashMap是利用“拉链法”处理HashCode的碰撞问题。在调用HashMap的put方法或get方法时,都会首先调用hashcode方法,去查找相关的key,当有冲突时,再调用equals方法。hashMap基于hasing原理,我们通过put和get方法存取对象。当我们将键值对传递给put方法时,他调用键对象的hashCode()方法来计算hashCode,然后找到bucket(哈希桶)位置来存储对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。HashMap使用链表来解决碰撞问题,当碰撞发生了,对象将会存储在链表的下一个节点中。hashMap在每个链表节点存储键值对对象。当两个不同的键却有相同的hashCode时,他们会存储在同一个bucket(哈希桶)位置的链表中。键对象的equals()来找到键值对。

hash的计算规则?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值