HashSet底层竟然是HashMap实现问题
作者:blacktal
HashSet底层竟然是HashMap实现
HashSet虽然实现的是Set - Collection接口,但其源码是通过new HashMap()实现的,底层数据结构是哈希表,存取都比较快,线程不安全。
特点
1. 无序集合,不保证存储元素的顺序,没有索引
2. 不能存储重复元素
3. 可以存储Null
存取基本操作
Iterator的获取跟ArrayList一样:
HashSet<String> hashSet = new HashSet<>(); hashSet.add("asf"); hashSet.add("bnk"); hashSet.add("lsk"); hashSet.add("owie"); System.out.println(hashSet); Iterator<String> it = hashSet.iterator(); while (it.hasNext()) { String ele = (String) it.next(); System.out.println(ele); } System.out.println("---------------------------"); for (String ele : hashSet) { System.out.println(ele); }
查看HashSet源码,其操作底层都是通过调用HashMap方法实现的,HashSet会将元素存储在HashMap的key集合中,然后为value赋一个static空对象PRESENT。
HashSet不能存储重复元素,因为HashMap的key集合不可以重复,本质也是通过equals()和hashCode()来判定两个对象是否重复,因此将对象存储在HashSet之前,必须先重写对象的equals()和hashCode()方法。
hashCode原则:
两个对象的hashCode()相等,equals()未必返回true
两个对象equals()返回true,它们的hashCode()一定相等
HashMap中,只有两个对象hashCode()相等,且equals()相同,才判定为重复元素,重写对象的equals()方法一定要同时重写hashCode()以符合原则。
HashSet底层实现解析
1.实现了Set接口
2.底层实际上是HashMap()
底层数据结构为:Node[]数组 + 单链表 + 红黑树
Set set = new HashSet();使用无参构造方法创建hashset对象,实际上创建了hashmap,初始大小默认为16,加载因子默认为 0.75.
当调用add方法添加元素时,其实调用的是map.put()方法
这里的key就是要添加的元素,value是一个object类型的共享常量,占了一个坑位,并没有实际意义。
当返回值为null的时候,说明添加成功
解析:首先,调用map.put();方法,跟踪发现实际上调用putVal();方法
参数说明:hash(key):hash()函数的返回值,返回的并不是key的hashCode值,而是经过处理hashCode得到的值(目的就是尽可能减少hash冲突),(若待加入元素 key 为 基础数据类型 或 封装数据类型(Java中已经重写),不需要重写 equals 和 hashCode方法;若key为自定义数据类型,往往需要重写 equals 和 hashCode 方法。
例(不重写方法):
输出:
说明是两个不同的对象,但是理论上应该是相同对象。
具体要不要重写方法以及如何重写,都要根据实际需求决定
key:待添加的元素。
value:共享常量PRESENT,没有实际意义(因为创建的是hashset对象,并没有value,但是当顶层创建的是hashmap对象时候,调用的直接是map.put()方法,那value对应的就是实际当中的value,就没有PRESENT了)。
onlyIfAbsent*:若为true,则不改变现存的value,默认为false。
evict*:若为false,表示当前表处于创建模式。
putVal();方法的具体实现
(1)首次添加元素 key
首先,创建两个临时变量 Node类型的数组 Node[] tab,Node<k,v>类型的 p,判断当前hash表是否为空,若满足条件,调用resize()方法,扩容。
resize()方法:
由于用无参构造器创建对象,因此hashtable为空,oldCap为0,oldThr为0,if条件不满足跳过,进入else
新数组容量 newCap 为默认大小 16,新的加载因子为默认 0.75,newThr为扩容阈值(即当数组的数据量达到阈值以后,就会扩容,目的就是当并发量过大以后,能够有缓冲空间)。
newThr = 加载因子*数组大小 ,在大小都默认的情况下,阈值为12
最后 return 扩容后的数组,并返回 putVal(); 方法中继续:
在 if ((p = tab[i = (n - 1) & hash]) == null) 中,首先用算法 i = (n - 1) & hash获得当前 待添加元素 key 应该添加的数组下标 i ,同时获取到当前位置上的所存储的链表的头节点,并将节点赋值给变量 p ,若该节点为空(即当前位置无数据),就把待添加元素 key 封装成一个节点Node,放到当前位置当作该链表的头节点。
最后操作计数+1,数据量size+1,并且若size大于扩容阈值,则进行扩容。返回 null 表示添加成功。(到此往数组中首次添加元素操作结束)
(2)添加重复元素流程分析
在链表上找是否有重复元素,只能从头节点遍历该链表。当 if 条件判断当前下标 i 位置上不为空的时候(即链表头节点),就要开始遍历。
定义需要的变量 Node<k,v> e (一个指针) ,以及 K k首先判断头节点是否与待添加的元素重复:
1)hash值是否相等,这是前提(重复元素hashCode值必相等,则hash值也必相等)
2)待添加元素 key 与 当前节点的 key 是否是同一对象,或者带添加的 key 与当前节点的 key 内容 是否相等,两者若有一个相同则说明是重复元素
若头节点就重复,则把头节点 p 赋给 e;(否则,判断当前头节点 p 是否是红黑树类型)
若是树类型,则按照树节点进行操作。若不是树类型,则继续遍历当前链表:
这里的 for 是一个死循环,退出的条件就是找到重复元素或遍历到链表结尾(肯定可以退出)。
判断是否重复,与上面的条件一样。
①若遍历到结尾没有发现重复元素,则将待添加的元素 key 封装成Node对象,添加在链表结尾,同时,检查当前链表节点数量是否达到树化(将链表转成红黑树)的阈值默认是 8,若达到阈值,则首先检查当前数组的大小是否达到64,若达到,则将该链表转成红黑树;若没达到64,则先对数组进行扩容;否则退出循环,返回 e 为空。static final int TREEIFY_THRESHOLD = 8;
②若遍历发现有重复元素,退出循环,此时的变量 e 就是重复元素。若顶层创建的对象是HashSet();则下面的语句没有意义,因为value值是共享的PRESENT;若是顶层是 HashMap(k,v) 对象,下面语句表达的是:当在map数组中发现已经存入key了,就把待添加键值对的value 替换掉旧的value,key不变。
不管创建哪种对象,返回值都不为空,说明添加失败。(到此添加重复元素流程结束)
补充总结:
①hashSet()底层实现的其实是hashMap(),数据结构相同都是数组+单链表,元素数据类型是HashMap的内部类Node类型–Node<k,v>
②利用无参构造方法创建HashSet,默认大小16,加载因子 0.75,扩容为原大小的 2 倍,扩容阈值 = 数组大小 * 加载因子。(也可用有参构造方法创建对象,设定大小或加载因子)
③HashSet中的add();方法其实就是map.put()方法,只是由于添加的都是单一元素(参数key),并不是<k,v>键值对,所以统一用共享value(常量PRESENT)。
④HashSet不允许存储元素重复,就和HashMap不允许重复的key一样,只是HahsSet没有用到value。
⑤添加元素的时候,要先得到key的hashCode值,经过hash函数后得到hash值,再经过一次计算得到待添加的key应该存放的数组下标。若当前下标对应位置不为空,则遍历该链表,查找是否有重复元素(若重复,则不添加;反之,加在链表尾部);若当前下标位置上为空,则之间添加到当前位置并作为头节点。(这也就是为什么,实际存储顺序和实际输出顺序不一致)
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。