HashSet是Set接口的一个实现类,它是一个容器类,里面的每个元素都是一个对象,且具有无序性和唯一性,
无序性:容器中的元素是无序的,不能通过类似于数组索引的方式获得。
唯一性:容器中不存在两个或多个元素具有完全一致的属性。
而equals()和hashCode()方法是保证这两个性质的前提,接下来通过分析源码来进行探究如何保证HashSet的这两个性质。
无序性
首先,从HashSet.java源文件中可以知道,HashSet底层实际上是通过HashMap存储数据,默认情况下这个哈希表的容量为16,负载因子为0.75,
HashSet中通过将每个元素作为key值,以一个公共的Object对象作为value值,构成一个键值对存储在HashMap中,也就是说,此时HashMap中只有key值才有真正意义,
在HashMap中每一个键值对通过一个Node<K,V>对象进行存储,
Node对象中的key、value属性分别表示键值对的key和value,hash属性表示key值通过一个hash(Object)方法得到的hash值,为了将多个键值对的Node对象进行有效存储以及管理,HashMap中将每个Node对象的hash属性进行一个变换,然后将变换后的值作为数组的索引存储在一个Node数组中(JDK8中这个数组的默认长度也就是刚刚所说的哈希表容量16,并且采用单例设计模式中懒汉式的思想,即首次使用时才进行初始化),
在HashMap中将Node对象的hash属性与数组长度减一进行&位运算((table.length - 1) & hash)作为数组的索引,由于通过这种方式会存在多个Node对象映射到同一个索引上的情况,因此通过Node的next属性将这些具有相同索引值的Node对象串联起来。
因此在HashMap中每个键值对以下图类似的结构进行存储(下图为一个简化的例子,实际上在JDK8中当一个链表的长度超过8并且数组长度大于64时,会将此链表转化为一棵红黑树进行存储),
虽然使用了数组结构,但是并不能通过一个索引值得到具体的一个键值对,而这个数组的索引也是为了便于通过key值快速查找对应的value值,因此这个结构中键值对之间逻辑上也不存在先后顺序,因此也就保证了HashSet中元素的无序性。
这时可能会问,既然HashSet中元素具有无序性,那么使用System.out.println(HashSet)或迭代器输出一个HashSet时,又是以怎样的顺序输出的呢?
实际上,在输出时是按照HashMap中数组的索引顺序,对于相同索引的对象按照单链表顺序依次输出(后文有代码验证)。
唯一性
为了保证HashSet中元素的唯一性,那么每加入一个元素,就需要与已有的元素进行比较,在数据量较大的情况下,明显这种方法是低效的,为了解决这个问题,就要涉及到上文所说的Node对象的hash属性了。
上文说到HashSet将每个元素作为key值保存在HashMap中,因此只要保证HashMap中key值的唯一性(而根据HashMap的实际意义也必须要求key值具有唯一性),也就保证了HashSet中元素的唯一性。那么具体又是如何实现的呢?下面通过两种情况进行探讨。
情况一:在HashMap存在一个key值与待插入的key属于同一个类的同一个对象,或者属于同一个类的不同对象,但是它们之间具有完全一致的属性值(存在equals(Object)相等);
情况二:其他(不存在equals(Object)相等)。
情况一:存在equals(Object)相等
由于Node对象的hash属性通过HashMap中的hash(Object)方法得到,而这个方法默认通过使用对象的hashCode()然后进行位运算得到,
下图是Object类中关于hashCode()方法的要求,
该方法的描述中明确要求,对于自定义的类重写该方法,如果两个对象的属性值之间完全相同(基于对象的equals(Object)方法),那么该方法返回相同的结果,否则返回不同的结果。
因此在情况一的条件下,待插入的key值就与HashMap中某一个key具有相同的hash值,因此也就会映射到数组的相同索引上,这时再通过key对象的equals(Object)方法与数组该索引上的链表中的所有key进行比较,此时比较一旦发现相同的key,表示插入重复的元素,就会插入失败。
情况二:不存在equals(Object)相等
这种情况下,按照hashCode()的约定,HashMap中找不到一个已有的key与待插入的key具有相同的hash值,此时只需通过将待插入key的hash值计算得到索引,然后将该键值对插入数组的该索引位置即可,即使再经过将hash值转为数组索引后,可能数组中该索引对应的值非null,再使用key对象的equals(Object)方法该索引上的所有key依次进行比较即可,如果存在相等的key(当然根据我这里对情况二的划分,这个比较过程中不会存在相等的key),则插入失败,否则在链表的尾部插入新的键值对对象。
上面两种情况中,首先计算待插入键值对的key的hash值,通过hash值转换得到数组的索引,将比较的范围缩小到具有相同的索引范围中,再使用key对象的equals(Object)与这个索引上的链表中的每个key进行比较,遇到相同的插入失败,否则在尾部插入。整个过程与HashSet中每个元素比较判断是否存在重复相比,可以极大提高时间效率。但是这个过程的关键是按照约定,重写hashCode()和equals()方法。
最后通过模仿HashMap中索引的计算方式,通过一段代码验证HashSet中元素的输出顺序是以hash值的索引顺序进行输出。
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
public class Test {
static class MyClass{
String str;
int num;
public MyClass(String str, int num){
this.str = str;
this.num = num;
}
@Override
public String toString() {
return "MyClass{" +
"str='" + str + '\'' +
", num=" + num +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
MyClass myClass = (MyClass) o;
if (num != myClass.num) return false;
return str != null ? str.equals(myClass.str) : myClass.str == null;
}
@Override
public int hashCode() {
int result = str != null ? str.hashCode() : 0;
result = 31 * result + num;
return result;
}
}
public static void main(String[] args) {
Set set = new HashSet();
set.add(123);
set.add("abc");
set.add(new MyClass("hello", 18));
set.add(567);
set.add(true);
set.add(90);
set.add(new MyClass("hello", 18));
Iterator iterator = set.iterator();
while(iterator.hasNext()){
Object object = iterator.next();
int hashCode;
int hash = (hashCode = object.hashCode()) ^ (hashCode >>> 16);
int index = (16 - 1) & hash;//HashMap中数组的默认长度是16
System.out.println(index + "\t" + object);
}
}
}
输出结果如下:
3 abc
7 567
10 MyClass{str='hello', num=18}
10 90
11 123
15 true