深入理解HashMap

1、HashSet如何检查重复?

HashSet(无序,唯一),基于HashMap实现,底层采用HashMap来保存元素。

在jdk1.8中,HashSet的add()方法只是简单的调用了HashMap的put方法,然后判断一下返回值以确保是否有重复元素

// Returns: true if this set did not already contain the specified element
// 返回值:当 set 中没有包含 add 的元素时返回真
public boolean add(E e) {
        return map.put(e, PRESENT)==null;
}

//在HashMap中的putVal()
// 返回值:如果插入位置没有元素返回null,否则返回上一个元素
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
...
}


也就是说,在jdk1.8中,实际上无论HashSet中是否已经存在某元素,HashSet都会直接插入,只是会add()方法的返回值告诉我们插入前是否存在相同元素。

2、HashMap的底层实现

在这里插入图片描述

当插入一个元素(键值对)时,map.put(k,v),首先,它的底层会扰动函数(hash),实际就是调用key的hashcode方法,得到hash值,然后通过**(n-1)&hash** 判断当前元素存放在数组中的位置,如果当前位置没有任何元素,则把元素加到这个位置;如果当前位置有元素存在(甚至如果已经形成了链表),就拿着k和链表上每个节点的key进行equal,如果返回的是true,则覆盖该节点的value值;如果返回的是false,则加到链表末端

冲突/碰撞 :当多个对象的hashcode值相同时----会找到在数组的同一个位置,这时就发生碰撞。解决碰撞的方法是,如果进行equals比较后返回的是false,以链表形式追加。

hashcode值相同,equals值不一定相同;

hashcode值不同,euqals肯定不同;

euqals值相同,hashcode一定相同

equals值不同,hashcode不一定不同

equals()方法只比较两个对象是否相等。因此在比较时不止要看对象还要看对象的属性时,我们就要重写equals方法

在jdk1.8之后,解决哈希冲突,当链表长度大于阈值(默认为8)时,将链表转化为红黑树,以减少搜索时间。在转化为红黑树之前,如果当前数组长度小于64,会先进行数组扩容而不是转化红黑树

3、HashMap的长度为什么是2的幂次方?

HashMap默认的初始化大小是16,之后每次扩充,容量变为原来的2倍,如果创建时给定了容量初值,HashMap会将其扩充为2的幂次方大小。。也就是说 HashMap 总是使用 2 的幂次方作为哈希表的大小。这是为什么呢?

为了能让HashMap存取高效,尽量减少碰撞,也就是尽量要把数据分配均匀。Hash值的范围值很大,前后加起来大概40亿长度的数组,内存是放不下的 ,所以不能直接拿来用,。用之前还要先做对数组的长度取模运算,得到的余数才能用来要存放的位置也就是对应的数组下标。而是经过 (n-1)& hash 。这样得到的就是2的幂次方

4、HashMap的扩容机制

负载因子=元素/容量,默认为0.75

当添加某个元素时,数组的总的添加元素 > 数组长度*0.75时,数组长度扩容为两倍。(调用resize(),完成数据迁移)。进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的,在编写程序中,要尽量避免resize,resize方法实际上是将table初始化和table扩容进行了整合,底层的行为都是给table赋值一个新的数组。

在没有红黑树的条件下,添加元素后数组中某个链表长度超过了8,此时数组长度小于64,则会先进行扩容,数组扩容为2倍。只有同时满足数组长度达到64和链表长度大于8时才会转化为红黑树

扩容机制在ArrayList中也有应用。数组扩容并不是直接修改数组的长度(数组长度一旦定义是不可变的),而是通过创建一个扩容后的新数组来覆盖原来的数组从而达到扩容的目的(需要将原来数组的元素复制到新数组中,调用系统的复制方法system.arraycopy())。

ArrayList的扩容机制

  public ArrayList() {
        this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
    }

以无参构造方法创建ArrayList时,实际上初始化赋值的是一个空数组,当真正对数组进行添加元素操作时,才真正分配容量,即向数组中添加第一个元素,数组容量扩为10。

int newCapacity = oldCapacity + (oldCapacity >> 1);

所以 ArrayList 每次扩容之后容量都会变为原来的 1.5 倍左右(oldCapacity 为偶数就是 1.5 倍,否则是 1.5 倍左右)!奇偶不同 ,比如:10+10/2 = 15, 33+33/2=49。如果是奇数的话会丢掉小数.

5、HashMap线程不安全

  • jdk1.7扩容引发的线程不安全

jdk1.7在扩容时,调用了transfer()函数,重新定义每个桶的下标,并采用头插法将元素迁移到新数组中。头插法会将链表的顺序翻转。当现在有两个线程同时对一个HashMap进行扩容操作,某个线程执行过程中,被挂起(还没完成链表的迁移),然后另一个线程执行,完成了链表的迁移,等轮到第一个线程的时候,重新执行之前的逻辑,此时数据已经发送改变(甚至链表的顺序都反转了),会造成死循环和数据丢失

  • 上面扩容造成的数据丢失、死循环已经在jdk1.8中得到了很好得到解决,jdk1.8中找不到transfer()了,而是在resize()中就完成了数据迁移。

但仍会出现数据覆盖的情况:

情况1:当两个线程1,2同时进行put操作,且hash函数计算出来的插入下标相同。

假设线程1执行完哈希冲突判断后,原本可以直接插入,但此时时间片轮转给线程2,线程2先完成了插入操作,随后线程1获得时间片,因为之前已经判断过哈希碰撞,所以仍然认为该下标不存在元素,所以依旧直接进行插入,这就导致线程2插入的数据被线程1覆盖

情况2:两个线程同时put操作导致size的值不正确,进而导致数据覆盖

当线程1执行 if(++size>threshold)判断时,假设获得的size的值为10,由于时间片耗尽挂起,此时轮到线程2,线程2也执行 if(++size>threshold)判断,获得的size值为10,并将元素插入到该桶位,更新size值为11,随后线程1获得时间片,因为之前获得的size值为10,所以不用重新获取,也将元素放入桶位,更新size值为11。这就导致,进行了两次put操作,但size值只增加了1,实际上只有一个元素被添加。

6、ConcurrentHashMap

HashMap是线程不安全的,如果你需要线程安全的,那就选择ConcurrentHashMap

ConcurrentHashMap线程安全的具体实现

  • 在jdk1.8之前,ConcurrentHashMap的数据结构是由Segment数组结构和HashEntry数组结构组成。

在这里插入图片描述

Segment的结构和HashMap相似,也是由数组+链表构成。一个Segment的元素就是一个HashEntry数组链表。首先将数据分为一段一段(这个段就是segment)存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段时,其他段的数据能被其他线程访问。Segment的个数一旦初始化就不能改变,默认大小为16,也就是说默认可以同时支持16个线程并发写。 当要对HashEntry数组的数据进行修改时,必须首先获得对应的Segment的锁。对同一Segment的并发写入会被阻塞,不同Segment的写入可以并发执行

  • jdk1.8之后

在这里插入图片描述

取消了Segment的分段锁,改为采用Node+CAS+synchronized 来保证并发安全。数据结构和HashMap类似,数组+链表/红黑树。Java8中,锁粒度更细。synchronized只锁定当前链表或红黑树的首节点,这样只要hash不冲突,就不会发生并发(对数组中其他节点的操作不影响),不会影响其他Node的读写,效率大幅提升

CAS

CAS机制中使用了三个基本操作数,内存地址V,旧的预期值A,要修改的新值B,更新一个变量时,只要当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B

eg:

  • 在内存地址V当中,存储着值为10的变量
  • 此时线程1想把变量的值增加1,对线程1来说,旧的预期值为10,要修改的新值为11
  • 在线程1要提交更新时,处理机给线程2占用,线程2抢先一步将内存地址V中的变量值率先更改成了11
  • 再轮到线程1时,提交更新时,会先判断此时内存地址V中的实际值与旧的预期值是否相等,11显然不等于10,所以提交失败。
  • 线程1重新获取内存地址V的当前值,并重新计算想要修改的值。

CAS属于乐观锁,乐观的认为程序中的并发情况不那么严重,所以让线程不断去重试更新

缺点:

  • CPU开销过大
  • 不能保证代码块的原子性
  • ABA问题— 因为比较的是旧的预期值和现在内存V中的实际值是否相等来判断有没有被修改,那两个值相等能一定说明变量没有被别的线程修改吗?显然是不能的,因为存在这个值被修改为别的值后又修改回来了原来的预期值,那么CAS机制就不会发现,仍然认为没有被修改。

7、为什么HashMap底层实现时,树化的阈值是8?

前面说过,jdk1.8之后,在HashMap底层数据结构链表长度大于=8时,且当前数组长度到达64,那么就会由链表转化为红黑树,为什么这个阈值是8?

树化条件

  • 链表的节点数量(包括新增节点)大于等于树化阈值(8)
  • HashMap的容量(数组长度)大于等于最小树化容量值(64)

条件1:

和hashcode碰撞次数的泊松分布有关,主要是为了寻找一种时间和空间的平衡

红黑树中的TreeNode是链表中Node所占空间的2倍,虽然红黑树的查找效率为O(logN),要由于链表的O(N),但当链表长度小时,两者的时间查找效率相差不大,而红黑树所占的空间却比链表要大的多,因此需要找到一个时间和空间的平衡。

Java的源码贡献者在进行了大量试验后,发现hash碰撞发送八次的概率已经降低到了0.00000006,几乎为不可能事件,如果真的发生了八次碰撞,说明由于元素本身和hash函数的原因,此时链表的性能非常差,操作的hash碰撞可能性非常大,在这种极端情况下才会把链表转换为红黑树

虽然转化为红黑树后,查找的效率会比链表高,但是转化红黑树这个过程是耗时的,而且在扩容时还要对红黑树重新的左旋右旋保持平衡,相对耗时,所以,阈值设置为8就是为了尽量减少hashmap中出现红黑树 。大部分情况下hashMap还是使用链表。

红黑树转链表的阈值为6,是因为如果也设置为8,那么当hash碰撞在8时,会发生链表和红黑树的不停相互激荡转换,白白浪费资源,中间有个差值7可以防止链表和树之间的频繁转换----超过7的转换为红黑树(前提满足数组容量到达64),低于7的转换为链表,等于7的不转换。

条件2:

至于为什么要满足数组长度大于等于64,这个源码解释的并不清楚。但这么设置一定有它的道理。原理就是,当数组长度小于64时,此时进行扩容来提高性能会比将链表转化为红黑树效率要高,因此这时候选择的是扩容;而当数组长度达到了64,转化为树效率更高,因此数组长度到达64也是树化的条件之一。

树转链表条件

  • 当删除红黑树的节点时,调用removeTreeNode方法,在removeTreeNode方法中,判断如果根节点root、root.right、root.left、root.left.left其中一个为空,则认为红黑树的节点太少,不必采用红黑树结果,调用untreeify方法将红黑树转化为链表
if(root == null || root.right == null || (rl = root.left)==null) || rl.left == null){
  tab[index] = first.untreeify(map);
}
  • 红黑树的节点数量小于等于非树化阈值(6)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Zero摄氏度

感谢鼓励!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值