Java容器框架——Set接口及其实现类HashSet

146 阅读9分钟

Set接口

仍然是经典口诀: 无序、不重复、无索引

  • Set接口的常用方法

    • 和List一样,Set接口也是Collection的子接口,因此,其常用方法和Collection接口是一样的
  • Set接口的遍历方式

    • 使用迭代器进行遍历
    • 增强for循环进行遍历
    • 不能使用索引的方式来获取

注: 实际上我们在使用Set集合来进行编写的时候,一定要有一个“上帝视角”,一旦放入Set,那么我们就一定能够通过全局来知晓 某个东西究竟在不在集合中。

HashSet

HashSet实际上就是HashMap,底层使用的是HashMap

public static void main(String[] args) {
        /**
         * 底层源码:
         *     public HashSet() {
         *         map = new HashMap<>();
         *     }
         */
        Set<String> set = new HashSet<>();
    }

HashSet底层机制说明

HashSet的底层就是HashMapHashMap的底层是(数组+链表+红黑树)三者组合起来一块儿构成的。

image.png

直接先说结论:

  1. HashSet底层是HashMap

    •   private transient HashMap<E,Object> map;
        ​
        private static final Object PRESENT = new Object();
      
    • HashSet中维护了一个HashMap对象map,HashSet的重要方法,比如addremoveiteratorclearsize等等都是围绕map来实现的。

      • 通过上面的源码我们注意到,HashSet中维护的map,被transient修饰,在ArrayList中就已经有提到,被transient修饰,就代表着无法进行序列化。和ArrayList相同,HashSet类中通过定义writeObject()readObject()方法确定了其序列化和反序列化的机制。
      • 同时,在HashSet中还维护了一个静态常量Object,这个实际上就是HashSet用来“偷鸡”的,因为HashSet中任何元素,在底层都是作为HashMapKey来进行存储,而每个Key所对应的Value,就用这个常来那个PRESENT来顶替了
  2. 添加一个元素时,先得到hash值,会被转换成->索引值

  3. 找到存储数据表table(实际上这个所谓的table就是数组),看这个索引位置是否已经存放的有元素

  4. 如果没有,直接加入

  5. 如果有,调用equals方法比较,如果相同,就放弃添加,如果不相同,就添加到最后

    • equals方法进行解读,实际上equalsObject类的方法,然后Object类是最顶层的类,其他的任何类都是其子类。Object类中的最原始equals方法,因为Java中是值传递,所以它只会比较值,如果是俩对象,比较的也只是二者的地址值。 像我们使用的很多的类String、Integer等,很多都已经重写了equals()方法,核心代码是比较二者的具体内容值。
    • 如果是自己自定义的类,实际上也要重写equals()方法和hashCode()方法
  6. 在Java8中,如果一条链表的元素个数达到TREEIFY_THRESHOLD(默认是8),并且table的大小>=MIN_TREEIFY_CAPACITY(默认64),就会进行树化(红黑树)

HashSet底层源码解读(重要重要重要)

自己简单写的代码:

  • 重点分析两块儿内容

    • HashSet 的构造方法
    • add方法的源码分析
public class SetDemo2 {
    public static void main(String[] args) {
        HashSet<String> set = new HashSet<>();
        set.add("java");
    }
​
}
HashSet构造方法

源码为:

不用再过多赘述,实际上HashSet底层就是使用的是HashMap,本质上就是给HashMap披上了一层皮而已

  public HashSet() {
        map = new HashMap<>();
    }
HashSet 的add()方法

解读源码的顺序,按照debug调用顺序一步步进入,相关的解读直接敲在源码的注解中:

  • 初步进入add()方法

    • public boolean add(E e) {
              //直接调用的是map的put方法。 
              //是将值传入到map的键中,PRESENT就是一个静态常量
              //private static final Object PRESENT = new Object();
              //说白了这个PRESENT不用理会,就是源码用来偷鸡的!!!
              return map.put(e, PRESENT)==null;
          }
      
  • 进入put()方法

    • public V put(K key, V value) {
              return putVal(hash(key), key, value, false, true);
          }
      
      • 先进入 hash()方法

        • 实际上就是为了计算当前传入的值,计算它的哈希值。看到下面的源码,hashCode()具体内容我们不去探究。但是有一个点必须非常明确:▲ 哈希值的计算,并不仅仅是依赖于hashCode()方法,然后与其通过hashCode()方法求得的哈希值向右偏移16位,然后求异或,
      • static final int hash(Object key) {
               int h;
               return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
           }
        
    • 进入putVal()方法 。先说结论,如果能够插入,那么putVal()方法返回的实际是null,返回null证明插入成功;插入不成功/无法插入的话,那么实际上putVal()我们可以认为是其执行的是查找功能,返回查找到的元素。

      • final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                          boolean evict) {
                //创建临时变量
               Node<K,V>[] tab; Node<K,V> p; int n, i;
                //检查当前哈希表table是否为空或者长度为0,如果是,则需要进行初始化
               if ((tab = table) == null || (n = tab.length) == 0)
                   //如果哈希表需要初始化,那么调用resize()方法来初始化哈希表,并且更新长度
                   n = (tab = resize()).length;
                //把键的哈希值通过(n-1)&hash的方式,将其转换为数组索引,检查该位置是否为空
               if ((p = tab[i = (n - 1) & hash]) == null)
                   //如果位置为空,那么直接插入即可
                   tab[i] = newNode(hash, key, value, null);
               //如果位置不为空,那么就说明有冲突
                //那么就看我这个要插入的这个东西究竟会不会插进来,就遍历链表,看我能不能放到链表里面
                else {
                   //创建局部变量
                   Node<K,V> e; K k;
                   //如果两者哈希值相同并且key值的地址也相同(那就是同一个东西)或者是通过equals()方法返回true
                   if (p.hash == hash &&
                       ((k = p.key) == key || (key != null && key.equals(k))))
                       e = p;
                   //否则再判断p是不是一棵红黑树
                   //如果是是一棵红黑树,那么就调用putTreeVal方法,再来进行添加。(这里面又会进行一些新的判断)
                   else if (p instanceof TreeNode)
                       e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
                    //如果table对应的索引位置,后面链了个链,就使用for循环来进行比较
                 else {
                     for (int binCount = 0; ; ++binCount) {
                         //这个if是完成如下功能:
                         //1、把当前node能够转到链表的下一个node
                         //2、判断是否已经指向null(即看是否是最终的节点)
                         if ((e = p.next) == null) {
                             //如果执行到最终的节点,那么就可以进行插入
                             p.next = newNode(hash, key, value, null);
                             //在把元素添加到链表之后,立刻进行判断,该链表是否已经达到8个结点
                             //如果是,就调用treeifyBin(),对当前链表进行树化
                             if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                                 treeifyBin(tab, hash);
                             break;
                         }
                         //这儿跟前面一样,判断哈希值是否一样并且是同一对象。 或者是说key不为空,并且通过equals方法判为true
                         if (e.hash == hash &&
                             ((k = e.key) == key || (key != null && key.equals(k))))
                             break;
                         p = e;
                     }
                 }
                 //如果最后没有执行插入操作(也就是e==null)
                 if (e != null) { // existing mapping for key
                     V oldValue = e.value;
                     if (!onlyIfAbsent || oldValue == null)
                         e.value = value;
                     afterNodeAccess(e);
                     //返回值。   (也就是前面说的查询操作)
                     return oldValue;
                 }
             }
             ++modCount;
             if (++size > threshold)
                 resize();
             afterNodeInsertion(evict);
            //这是最后插入成功,插入成功就返回null
             return null;
         }
          
        
HashSet的底层扩容机制
  1. HashSet底层是HashMap,第一次添加时,table数组扩容到16,临界值(threshold) 是16*加载因子(loadFactor)是0.75 = 12

  2. 如果table数组使用到了临界值12,就会扩容到16*2=32,新的临界值就是 32x0.75 = 24 。以此类推

    • 也就是说,每次的临界值都是当前容量的0.75倍,一旦达到了这个临界值,那么就会扩充容量为当前容量的2倍。
  3. 在Java8中,如果一条链条的元素个数到达TREEIFY_THRESHOLD(默认是8),并且table的大小>= MIN_TREEIFY_CAPACITY(默认是64),就会进行树化(红黑树),否则仍然采用数组扩容机制

这里直接通过debug来进行调试

代码:

public static void main(String[] args) {
        HashSet<Integer> set = new HashSet<>();
        for(int i=1;i<100;i++){
            set.add(i);
        }
    }

Snipaste_2024-11-07_15-01-57.png

Snipaste_2024-11-07_15-02-15.png

当容量size超过阈值也就是16*0.75 = 12的时候,这时容量就会倍扩充为原来的2倍

Snipaste_2024-11-07_15-04-23.png 特别注意: 什么时候数组会被扩容? 难道是数组的长度占用到了阈值? 并不是!!

这里回归到前面提到的add()方法putVal()方法的源码的最后几行。

注意到下面的源码,++size 什么概念?就是只要我加一个元素进来,不管这个元素是加在数组上,还是链在链表上,还是挂在红黑树上。都算是所谓的容量增加。所以只要使用容量到了阈值,那么就会执行扩容。

if (++size > threshold)
            resize();

韩顺平老师留下的小练习:

定义一个Employee类,该类包括: private成员属性name,sal,birthday(MyData类型),其中birthday为MyDate类型(属性包括:year,month,day) ,要求:

  1. 创建三个Employee放入到HashSet中
  2. 当name和birthday的值相同时,认为是相同员工,不能添加到HashSet集合中

代码如下:

package com.nylonmin.setDemo;
​
import java.util.Arrays;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
​
public class SetDemo3 {
    public static void main(String[] args) {
        Set<Employee> set= new HashSet<>();
        Employee e1 = new Employee("zhangsan",20,"2010/10/10");
        Employee e2 = new Employee("zhangasdff",20,"2000/1/1");
        Employee e3 = new Employee("lisi",20,"2001/10/10");
        Employee e4 = new Employee("zhangsan",2045,"2010/10/10");
        Employee e5 = new Employee("wangwu",20,"2010/10/10");
        set.addAll(Arrays.asList(e1,e2,e3,e4,e5));
        for(Employee e : set){
            System.out.println(e);
        }
    }
}
class Employee{
    private String name;
    private double sal;
    private MyData birthday;
​
    private static class MyData{
        private int year;
        private int month;
        private int day;
        MyData(int year,int month,int day){
            this.year = year;
            this.month=month;
            this.day=day;
        }
​
        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()) {
                return false;
            }
            MyData myData = (MyData) o;
            return year == myData.year && month == myData.month && day == myData.day;
        }
​
        @Override
        public int hashCode() {
            return Objects.hash(year, month, day);
        }
​
        @Override
        public String toString() {
            return "MyData{" +
                    "year=" + year +
                    ", month=" + month +
                    ", day=" + day +
                    '}';
        }
    }
    //构造函数
    public Employee(String name,double sal,String data){
        this.name= name;
        this.sal=sal;
        String[] split = data.split("/");
        int year = Integer.parseInt(split[0]);
        int month = Integer.parseInt(split[1]);
        int day = Integer.parseInt(split[2]);
        this.birthday= new MyData(year,month,day);
    }
​
    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        Employee employee = (Employee) o;
        return  Objects.equals(name, employee.name) && Objects.equals(birthday, employee.birthday);
    }
​
    @Override
    public int hashCode() {
        return Objects.hash(name, birthday);
    }
​
    @Override
    public String toString() {
        return "Employee{" +
                "name='" + name + ''' +
                ", sal=" + sal +
                ", birthday=" + birthday +
                '}';
    }
}

控制台输出如下:

Employee{name='zhangsan', sal=20.0, birthday=MyData{year=2010, month=10, day=10}} Employee{name='zhangasdff', sal=20.0, birthday=MyData{year=2000, month=1, day=1}} Employee{name='wangwu', sal=20.0, birthday=MyData{year=2010, month=10, day=10}} Employee{name='lisi', sal=20.0, birthday=MyData{year=2001, month=10, da