HashMap、HashTable、ConcurrentHashMap、HashSet底层原理

1、哈希表

哈希表(hash table)也叫散列表,是一种非常重要的数据结构,我们先来看一下其它数据结构的特点。

  • 数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)
  • 链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)

我们可以发现,数组和链表几乎是两个极端,一个查找效率高,一个插入删除效率高,那么将两者的优点进行融合,得到的是一个存放链表的数组,这种数组和链表的结合体,叫散列表或哈希表

  • 在哈希表中进行添加,删除,查找等操作,性能都非常高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1),那么是如何做到的呢?首先,哈希表的主干为数组,例如我们要增加或查找某个元素,我们可以将当前元素通过某个函数映射到数组中的某个位置,通过数组下标直接定位即可。这个函数被称为哈希函数。
  • 哈希函数的设计至关重要,好的哈希函数会尽可能地保证散列的地址分布均匀,但是再好的哈希函数也会出现冲突的情况,比如我们的两个元素通过哈希函数得到同一个存储地址,那么该如何解决呢?哈希冲突的解决方案有很多种,而HashMap采用了链地址法,就是数组+链表的方式,所有通过哈希函数得到同一地址的元素,将其加在对应的链表上,就构成了HashMap

2、HashMap

2.1 HashMap的特点

  • HashMap是一个散列桶(数组和链表),可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个。当get(key)方法返回null值时,即可以表示HashMap中没有该key,也可以表示该key为null,因此,在HashMap中不能由get()方法来判断HashMap中是否存在某个key,应该用containsKey()方法来判断

    Hashtable的key与value均不能为null

  • HashMap线程不安全,没synchronized,但HashMap快

  • 初始Table数组的长度为16(Table.length一定为2的n次幂),当HashMap中元素总数超过table数组的75%,触发扩容操作,扩容方式为 newTable.length = oldTable.length*2,扩容针对整个HashMap,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入

  • HashMap 通过 key 的 hashCode()得到 hash 值,然后通过 (Table.length - 1) & hash 判断当前元素存放的位置(这里的 Table.length指的是数组的长度)

    • 如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过拉链法解决冲突。
    • 使用length-1的意义在于,length是2的倍数,所以length-1在二进制来说每位都是1,可以最大程度地散列hash值,充分利用数组的空间,并减少 key 的碰撞,否则,当有一位是0时,不管hash值对应位是1还是0,按位与后的结果都是0,会造成散列结果的重复
  • 如果hash碰撞,后加入的值

    • 对于JDK1.7,在链表的表头(表头插入会导致死循环的问题)
    • 对于JDK1.8,在链表的表尾
  • HashMap 的 7 种遍历方式与性能分析

2.2 HashpMap实现原理

  • HashMap的主干是一个Node数组。Node是HashMap的基本组成单元,每一个Node包含一个key-value键值对:
//HashMap的主干数组,可以看到就是一个Node数组,初始值为空数组,主干数组的长度一定是2的次幂
transient Node<K,V>[] table;
  • Node的内部结构:
  static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算
        final K key;
        V value;
        Node<K,V> next;//存储指向下一个Entry的引用,单链表结构

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
    }    

在这里插入图片描述

2.3 HashMap扩容机制

在这里插入图片描述

当HashMap中元素个数超过Table数组大小*loadFactor时,就会进行数组扩容,(loadFactor的默认值为0.75),HashMap的Table数组扩容后,原数组中的数据必须重新计算其在新数组中的位置,并放进去

2.3.1 JDK 1.7
  • JDK1.8 之前 HashMap 底层是 数组和链表 结合在一起使用也就是 链表散列。如果在一个HashMap中,有很多Key发生了碰撞的时候,就会产生一个超级长的链表,那么在数据查询的时候就会花费O(n)的时间。
2.3.2 JDK 1.8
  • JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,红黑树的查询时间是O(log n),以减少搜索时间。

红黑树是一种近似平衡的二叉查找树,他并非绝对平衡,但是可以保证任何一个节点的左右子树的高度差不会超过二者中较低的那个的一倍。红黑树有这样的特点:

  • 每个节点要么是红色,要么是黑色。
  • 根节点必须是黑色。叶子节点必须是黑色NULL节点。
  • 红色节点不能连续(黑色节点可以连续)
  • 对于每个节点,从该点至叶子节点的任何路径,都含有相同个数的黑色节点
  • 能够以O(log2(N))的时间复杂度进行搜索、插入、删除操作。此外,任何不平衡都会在3次旋转之内解决

2.4 HashMap ,HashTable 区别

  • 线程是否安全
    • HashMap 是非线程安全的
    • HashTable 是线程安全的,因为 HashTable 内部的方法基本都经过synchronized 修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap 吧!);
  • 效率: 因为线程安全的问题,HashMap 要比 HashTable 效率高一点。另外,HashTable 基本被淘汰,不要在代码中使用它;
  • 对 Null key 和 Null value 的支持
    • HashMap 可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;
    • HashTable 不允许有 null 键和 null 值,否则会抛出 NullPointerException。
  • 初始容量大小和每次扩充容量大小的不同
    • ① 创建时如果不指定容量初始值,Hashtable 默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap 默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。
    • ② 创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而 HashMap 会将其扩充为 2 的幂次方大小(HashMap 中的tableSizeFor()方法保证)。也就是说 HashMap 总是使用 2 的幂作为哈希表的大小。
  • 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制

3、HashTable

3.1 HashTable的特点

  • 底层数组+链表实现,无论key还是value都不能为null

  • 在修改内部共享数据时,使用synchronized锁住整个HashTable,保证线程安全,但是效率低,ConcurrentHashMap做了相关优化

    当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。

  • 初始Table数组的长度为为11,当HashTable中元素总数超过Table数组的75%,扩容方式为newTable.length = oldTable.length*2+1,扩容针对整个HashTable,每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入

  • 计算索引的方法:index = (hash & 0x7FFFFFFF) % tab.length

  • 将新元素加到链表头部 在这里插入图片描述

4、ConcurrentHashMap

在这里插入图片描述

4.1 ConcurrentHashMap的特点

  • 底层数据结构:

    • JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现
    • JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树
  • 实现线程安全的方式(重要):

    • 在 JDK1.7 的时候,ConcurrentHashMap(分段锁)使用了锁分段技术来保证线程安全,通过把整个Map分为N个Segment(默认将整个表分为16segment),诸如get、put、remove等常用操作只锁住当前需要用到的segment,这样,原来只能一个线程进入,现在却能同时有16个写线程执行,提高并发访问率。(读操作不加锁,由于HashEntry的value变量是 volatile的,也能保证读取到最新的值)

      锁分段技术:首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

    • 到了 JDK1.8 的时候已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作(JDK1.6 以后 对 synchronized 锁做了很多优化)。 整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本

      synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就可以产生并发,效率又提升 N 倍。

  • Hashtable的synchronized是针对整张Hash表的,即每次锁住整张表让线程独占,ConcurrentHashMap允许多个修改操作并发进行,其关键在于使用了锁分离技术

  • 有些方法需要跨段,比如size()和containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁

  • 扩容:段内扩容(段内元素超过该段对应Entry数组长度的75%触发扩容,不会对整个Map进行扩容),插入前检测需不需要扩容,有效避免无效扩容

在这里插入图片描述

5、HashSet

5.1 HashSet的特点

  • HashSet内部基于HashMap来实现的,底层采用HashMap来保存元素,HashSet中的元素都存放在HashMap的key上面,而value中的值都是统一的一个private static final Object PRESENT = new Object();
  • HashSet跟HashMap一样,都是一个存放链表的数组,这种数组和链表的结合体,也叫散列表、哈希表。
  • HashSet集合里的元素无序且不可重复

5.2 HashMap与HashSet的区别

在这里插入图片描述

一、HahMap存储对象的过程如下
1、对HahMap的Key调用hashCode()方法,返回int值,即对应的hashCode;
2、把此hashCode作为哈希表的索引,查找哈希表的相应位置,若当前位置内容为NULL,则把hashMap的Key、Value包装成Entry数组,放入当前位置;
3、若当前位置内容不为空,则继续查找当前索引处存放的链表,利用equals方法,找到Key相同的Entry数组,则用当前Value去替换旧的Value;
4、若未找到与当前Key值相同的对象,则把当前位置的链表后移(Entry数组持有一个指向下一个元素的引用),把新的Entry数组放到链表表头;


二、HashSet存储对象的过程
往HashSet添加元素的时候,HashSet会先调用元素的hashCode方法得到元素的哈希值 ,然后通过元素 的哈希值经过移位等运算,就可以算出该元素在哈希表中 的存储位置。

  • 情况1: 如果算出元素存储的位置目前没有任何元素存储,那么该元素可以直接存储到该位置上。
  • 情况2: 如果算出该元素的存储位置目前已经存在有其他的元素了,那么会调用该元素的equals方法与该位置的元素再比较一次,如果equals返回的是true,那么该元素与这个位置上的元素就视为重复元素,不允许添加,如果equals方法返回的是false,那么允许添加该元素。

6、LinkedHashMap

  • HashMap是无序的,也就是说,迭代HashMap所得到的元素顺序并不是它们最初放置到HashMap的顺序。HashMap的这一缺点往往会造成诸多不便,因为在有些场景中,我们需要用到一个可以保持插入顺序的Map。庆幸的是,JDK为我们解决了这个问题,它为HashMap提供了一个子类 —— LinkedHashMap。虽然LinkedHashMap增加了时间和空间上的开销,但是它通过维护一个额外的双向链表保证了迭代顺序。特别地,该迭代顺序可以是插入顺序,也可以是访问顺序。因此,根据链表中元素的顺序可以将LinkedHashMap分为:保持插入顺序的LinkedHashMap和保持访问顺序的LinkedHashMap,其中LinkedHashMap的默认实现是按插入顺序排序的
    在这里插入图片描述

6.1 LinkedHashMap 的数据结构

  • LinkedHashMap = HashMap + 双向链表,也就是说,HashMap和双向链表合二为一即是LinkedHashMap,也可以这样理解,LinkedHashMap 在不对HashMap做任何改变的基础上,给HashMap的任意两个节点间加了两条连线(before指针和after指针),使这些节点形成一个双向链表
    在这里插入图片描述
  • 由于LinkedHashMap是HashMap的子类,所以LinkedHashMap自然会拥有HashMap的所有特性。比如,LinkedHashMap也最多只允许一条Entry的键为Null(多条会覆盖),但允许多条Entry的值为Null

6.2 HashMap与LinkedHashMap的Entry结构示意图

LinkedHashMap采用的hash算法和HashMap相同,但是它重新定义了Entry。LinkedHashMap中的Entry增加了两个指针 before 和 after,它们分别用于维护双向链接列表。特别需要注意的是,next用于维护LinkedHashMap各个桶中Entry的连接顺序,before、after用于维护Entry插入的先后顺序在这里插入图片描述

参考:https://blog.csdn.net/c99463904/article/details/77619826
参考:https://mp.weixin.qq.com/s/DTr0vHtSk9n_QF7vfXZ7qQ
参考:https://blog.csdn.net/chen213wb/article/details/84647179

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

还能坚持

你的鼓励将是我创作的最大动力

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

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

打赏作者

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

抵扣说明:

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

余额充值