Java集合之Set接口及HashSet
1、Set接口
Set继承于Collection接口,是一个不允许出现重复元素,并且无序的集合,主要有HashSet和TreeSet两大实现类。在判断重复元素的时候,Set集合会调用hashCode()和equal()方法来实现。
HashSet是哈希表结构,主要利用HashMap的key来存储元素,计算插入元素的hashCode来获取元素在集合中的位置;
TreeSet是红黑树结构,每一个元素都是树中的一个节点,插入的元素都会进行排序
Set集合框架结构:
1.1 Set常用方法
与List接口一样,Set接口也提供了集合操作的基本方法。但与List不同的是,Set还提供了equals(Object o)和hashCode(),供其子类重写,以实现对集合中插入重复元素的处理;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | public interface Set<E> extends Collection<E> { A:添加功能 boolean add(E e); boolean addAll(Collection<? extends E> c); B:删除功能 boolean remove(Object o); boolean removeAll(Collection<?> c); void clear(); C:长度功能 int size(); D:判断功能 boolean isEmpty(); boolean contains(Object o); boolean containsAll(Collection<?> c); boolean retainAll(Collection<?> c); E:获取Set集合的迭代器: Iterator<E> iterator(); F:把集合转换成数组 Object[] toArray(); <T> T[] toArray(T[] a); //判断元素是否重复,为子类提高重写方法 boolean equals(Object o); int hashCode(); } |
2、HashSet
HashSet实现Set接口,底层由HashMap来实现,为哈希表结构,新增元素相当于HashMap的key,value默认为一个固定的Object。HashSet可以看作是一个阉割版的HashMap。当有元素插入的时候,会计算元素的hashCode值,将元素插入到哈希表对应的位置中来;它继承于AbstractSet,实现了Set, Cloneable, Serializable接口。
(1)HashSet继承AbstractSet类,获得了Set接口大部分的实现,减少了实现此接口所需的工作,实际上是又继承了AbstractCollection类;
(2)HashSet实现了Set接口,获取Set接口的方法,可以自定义具体实现,也可以继承AbstractSet类中的实现;
(3)HashSet实现Cloneable,得到了clone()方法,可以实现克隆功能;
(4)HashSet实现Serializable,表示可以被序列化,通过序列化去传输,典型的应用就是hessian协议。
HashSet具有如下特点:
1、不允许出现重复因素;
2、允许插入Null值;
3、元素无序(添加顺序和遍历顺序不一致);
4、线程不安全,若2个线程同时操作HashSet,必须通过代码实现同步;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | //HashSet是对HashMap的简单包装 public class HashSet<E> { ...... private transient HashMap<E,Object> map; //HashSet里面有一个HashMap // Dummy value to associate with an Object in the backing Map private static final Object PRESENT = new Object(); public HashSet() { map = new HashMap<>(); } ...... public boolean add(E e) { //简单的方法转换 return map.put(e, PRESENT)== null ; } ...... } |
2.1 HashSet基本操作
HashSet底层由HashMap实现,插入的元素被当做是HashMap的key,根据hashCode值来确定集合中的位置,由于Set集合中并没有角标的概念,所以并没有像List一样提供get()方法。当获取HashSet中某个元素时,只能通过遍历集合的方式进行equals()比较来实现;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | public class TestHashSet1 { public static void main(String[] agrs){ //创建HashSet集合: Set<String> hashSet = new HashSet<String>(); System.out.println( "HashSet初始容量大小:" +hashSet.size()); //元素添加: hashSet.add( "hello" ); hashSet.add( "new" ); hashSet.add( "world" ); hashSet.add( "!" ); System.out.println( "HashSet容量大小:" +hashSet.size()); //迭代器遍历: Iterator<String> iterator = hashSet.iterator(); while (iterator.hasNext()){ String str = iterator.next(); System.out.print(str + " " ); } System.out.println( " " ); // //增强for循环 // for(String str:hashSet){ // if("hello".equals(str)){ // System.out.println("get:"+str); // } // System.out.println(str); // } //元素删除: hashSet.remove( "world" ); System.out.println( "HashSet元素大小:" + hashSet.size()); hashSet.clear(); System.out.println( "HashSet元素大小:" + hashSet.size()); //集合判断: boolean isEmpty = hashSet.isEmpty(); System.out.println( "HashSet是否为空:" + isEmpty); boolean isContains = hashSet.contains( "hello" ); System.out.println( "HashSet是否含有hello:" + isContains); } } 输出: HashSet初始容量大小: 0 HashSet容量大小: 4 new ! world hello HashSet元素大小: 3 HashSet元素大小: 0 HashSet是否为空: true HashSet是否含有hello: false |
2.2 HashSet元素添加分析
Set集合不允许添加重复元素,那么到底是个怎么情况呢?
来看一个简单的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | class App { String name; public App() { } public void setName(String name) { this .name = name; } @Override public String toString() { return "App{" + "name='" + name + '\ '' + '}' ; } } public class TestHashSet { public static void main(String[] agrs){ //hashCode() 和 equals()测试: hashCodeAndEquals(); } public static void hashCodeAndEquals(){ //第一个 Set集合: Set<String> set1 = new HashSet<String>(); String str1 = new String( "baidu" ); String str2 = new String( "baidu" ); set1.add(str1); set1.add(str2); System.out.println( "长度:" +set1.size()+ ",内容为:" +set1); //第二个 Set集合: Set<App> set2 = new HashSet<App>(); App app1 = new App(); app1.setName( "zhihu" ); App app2 = new App(); app2.setName( "zhihu" ); set2.add(app1); set2.add(app2); System.out.println( "长度:" +set2.size()+ ",内容为:" +set2); //第三个 Set集合: Set<App> set3 = new HashSet<App>(); App app3 = new App(); app3.setName( "taobao" ); set3.add(app3); set3.add(app3); System.out.println( "长度:" +set3.size()+ ",内容为:" +set3); } } 输出: 长度: 1 ,内容为:[baidu] 长度: 2 ,内容为:[App{name= 'zhihu' }, App{name= 'zhihu' }] 长度: 1 ,内容为:[App{name= 'taobao' }] |
可以看到,第一个Set集合中最终只有一个元素;第二个Set集合保留了2个元素;第三个集合也只有1个元素;什么原因呢?
来看看HashSet的add(E e)方法:
1 2 3 | public boolean add(E e) { return map.put(e, PRESENT)== null ; } |
在底层HashSet调用了HashMap的put(K key, V value)方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | public V put(K key, V value) { if (table == EMPTY_TABLE) { inflateTable(threshold); } if (key == null ) return putForNullKey(value); int hash = hash(key); int i = indexFor(hash, table.length); for (Entry<K,V> e = table[i]; e != null ; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess( this ); return oldValue; } } modCount++; addEntry(hash, key, value, i); return null ; } |
通过查看以上的源码,我们可以了解到:实际的逻辑都是在HashMap的put()方法中。
int hash = hash(key) 对传入的key计算hash值;
int i = indexFor(hash, table.length) 对hash值进行转换,转换成数组的index(HashMap中底层存储使用了Entry<K,V>[]数组);
for (Entry<K,V> e = table[i]; e != null; e = e.next) 判断对应index下是否存在元素;
如果存在,则if(e.hash == hash && ((k = e.key) == key || key.equals(k)))判断;
如果不存在,则addEntry(hash, key, value, i)直接添加;
简单概括如下:在向HashMap中添加元素时,先判断key的hashCode值是否相同,如果相同,则调用equals()、==进行判断,若相同则覆盖原有元素;如果不同,则直接向Map中添加元素;
反过来,回过头看看上面的例子:
反过来,我们在看下上面的例子,分析如下:
在第一个Set集合中,我们new了两个String对象,赋了相同的值。当传入到HashMap中时,key均为“baidu”,所以hash和i的值都相同。进行if (e.hash == hash && ((k = e.key) == key || key.equals(k)))判断,由于String对象重写了equals()方法,所以在((k = e.key) == key || key.equals(k))判断时,返回了true,所以第二次的插入并不会增加Set集合的长度;
第二个Set集合中,也是new了两个类的对象,但没有重写equals()方法(底层调用的Object的equals(),也就是==判断),所以会增加2个元素;
第三个Set集合中,只new了一个对象,调用的两次add方法都添加的这个新new的对象,所以也只是保留了1个元素;
2.3 HashSet源码分析(JDK1.7)
HashSet基于HashMap,底层方法是通过调用HashMap的API来实现,因此HashSet源码结构比较简单,代码较少。
成员变量:
在HashSet中,有两个成员变量比较重要:map、PRESENT;其中,map就是存储元素的地方,实际是一个HashMap。当有元素插入到HashSet中时,会被当做HashMap的key保存到map属性中去。对于HashMap来说,光有key还不够,在HashSet的实现中,每个key对应的value都默认为PRESENT属性,也就是new了一个Object对象而已;
1 2 3 4 5 6 7 | public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable{ static final long serialVersionUID = -5024744406713321676L; //HashSet通过HashMap保存集合元素的: private transient HashMap<E,Object> map; //HashSet底层由HashMap实现,新增的元素为map的key,而value则默认为PRESENT。 private static final Object PRESENT = new Object(); } |
构造方法:
HashSet的构造方法很简单,主要是在方法内部初始化map属性,new了一个HashMap对象;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable { //无参构造方法: public HashSet() { //默认new一个HashMap map = new HashMap<>(); } // 带集合的构造函数 public HashSet(Collection<? extends E> c) { // 进行初始化HashMap容量判断, map = new HashMap<>(Math.max(( int ) (c.size()/.75f) + 1 , 16 )); addAll(c); } // 指定HashSet初始容量和加载因子的构造函数:主要用于Map内部的扩容机制 public HashSet( int initialCapacity, float loadFactor) { map = new HashMap<>(initialCapacity, loadFactor); } // 指定HashSet初始容量的构造函数 public HashSet( int initialCapacity) { map = new HashMap<>(initialCapacity); } //与前4个不同,此构造最终new了一个LinkedHashMap对象: HashSet( int initialCapacity, float loadFactor, boolean dummy) { map = new LinkedHashMap<>(initialCapacity, loadFactor); } } |
由上面源码可以看出,HashSet 的实现其实非常简单,它只是封装了一个 HashMap 对象来存储所有的集合元素,所有放入 HashSet 中的集合元素实际上由 HashMap 的 key 来保存,而 HashMap 的 value 则存储了一个 PRESENT,它是一个静态的 Object 对象。
往hashset中插入对象其实只不过是内部做了
1 2 3 | public boolean add(Object o) { return map.put(o, PRESENT)== null ; } |
HashSet 的绝大部分方法都是通过调用 HashMap 的方法来实现的,因此 HashSet 和 HashMap 两个集合在实现本质上是相同的。
添加元素add():
HashSet的add(E e)方法,主要是调用底层HashMap的put(K key, V value)方法。
1 2 3 4 | //调用HashMap中的put()方法: public boolean add(E e) { return map.put(e, PRESENT)== null ; } |
比较关键的就是这个add()方法。 可以看出它是将存放的对象当做了 HashMap的健,value都是相同的PRESENT 。由于HashMap的key是不能重复的,所以每当有重复的值写入到 HashSet时,value会被覆盖,但key不会受到影响,这样就保证了HashSet 中只能存放不重复的元素。
删除元素remove():
与add(E e)方法类似,HashSet的remove(Object o)也是调用了底层HashMap的(Object key)方法;主要是计算出要删除元素的hash值,在HashMap找到对应的对象,然后从Entry<K,V>[]数组中删除;
1 2 3 4 | //调用HashMap中的remove方法: public boolean remove(Object o) { return map.remove(o)==PRESENT; } |
3、HashSet VS HashMap
对于接口Set,是一种不包含重复的元素的Collection,即任意的两个元素e1和e2都有e1.equals(e2)=false,Set最多有一个null元素。Set的构造函数有一个约束条件,传入的Collection参数不能包含重复的元素。请注意:必须小心操作可变对象(Mutable Object)。如果一个Set中的可变元素改变了自身状态导致Object.equals(Object)=true将导致一些问题。
hashMap具体实现是采用数组结合链表实现,链表是为了解决在hash过程中因hash值一样导致的碰撞问题。所以在使用自定义对象做key的时候,一定要去要同时复写equals方法和hashCode方法。,不然HashMap就成了纯粹的链表,查找性能非常的慢,添加节点元素也非常的慢。
按照散列函数的定义,如果两个对象相同,即obj1.equals(obj2)=true,则它们的hashCode必须相同,但如果两个对象不同,则它们的hashCode不一定不同。如果两个不同对象的hashCode相同,这种现象称为冲突,冲突会导致操作哈希表的时间开销增大,所以尽量定义好的hashCode()方法,能加快哈希表的操作。
HashMap保存数据的过程为:首先判断key是否为null,若为null,则直接调用putForNullKey方法。若不为空则先计算key的hash值,然后根据hash值搜索在table数组中的索引位置,如果table数组在该位置处有元素,则通过比较是否存在相同的key,若存在则覆盖原来key的value,否则将该元素保存在链头(最先保存的元素放在链尾)。若table在该处没有元素,则直接保存。
HashSet的原理比较简单,几乎全部借助于HashMap实现的。所以HashMap会出现的问题HashSet依然不能避免。
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
· 字符编码:从基础到乱码解决
· 提示词工程——AI应用必不可少的技术