Java之Set源码分析

本文主要围绕Java的Set接口展开,介绍了Set接口无序、无索引、不允许重复元素的特点及遍历方式。重点分析了HashSet和LinkedHashSet的底层机制与源码,包括HashSet基于HashMap的实现、扩容和树化机制,以及LinkedHashSet基于LinkedHashMap的实现和元素插入顺序的维护。

建议Debug模式调试

一、Set接口基本介绍

  1. 无序(添加和取出的顺序不一致),没有索引
  2. 不允许重复元素,所以最多包含一个null
  3. JDK API中Set接口的实现类有:EnumSet、HashSet、TreeSet、LinkedHashSet、CopyOrWriteArraySet等

二、Set的遍历方式

同Collection的遍历方式一样,因为Set接口是Collection接口的子接口

  1. 可以使用迭代器
  2. 增强for
  3. 不能使用索引的方式来获取

三、Set的演示

public static void main(String[] args) {
    //1、以Set接口的实现类HashSet来讲解Set接口的方法
    //2、Set接口的实现类,不能存放重复元素,可以添加一个null
    //3、Set接口对象存放数据是无序(即添的顺序和取出的顺序不一致)
    //4、注意:取出的顺序虽然不是添加的顺序,但是他是固定的。
    Set<Object> set = new HashSet<>();
    set.add("john");
    set.add("jack");
    set.add("john");//重复
    set.add("mark");
    set.add(null);
    set.add(null);//重复

    System.out.println("set=" + set);//set=[null, john, jack, mark]

    //遍历
    //方式1、使用迭代器
    System.out.println("========使用迭代器遍历Set集合============");
    Iterator<Object> iterator = set.iterator();
    while (iterator.hasNext()) {
      Object obj = iterator.next();
      System.out.println("obj:" + obj);
    }

    //方式2、使用增强for循环
    System.out.println("========使用增强for循环============");
    for (Object obj : set) {
      System.out.println("obj:" + obj);
    }
    //Set接口对象,不能通过索引来获取,因为没有get方法

  }

四、HashSet底层机制

模拟HaseSet(或HashMap)的底层

创建Node类,来储存数据,可以指向下一个结点,从而形成链表

class Node {

  Object item;//存放数据

  Node next;//指向下一个结点

  public Node(Object item, Node next) {
    this.item = item;
    this.next = next;
  }

  @Override
  public String toString() {
    return "Node{" +
        "item=" + item +
        ", next=" + next +
        '}';
  }
}

模拟HaseSet(或HashMap)的底层

public static void main(String[] args) {
    //1、创建一个数组,数组的类型是Node[]
    //2、有些人,直接把node[]数组称为一个表
    Node[] table = new Node[16];

    System.out.println("table:" + table);
    //3、创建结点
    Node john = new Node("john", null);
    table[2] = john;

    Node jack = new Node("jack", null);
    john.next = jack;//将jack结点挂载到john

    Node rose = new Node("Rose", null);
    jack.next = rose;//将rose结点挂载到jack

    Node luck = new Node("luck", null);
    table[3] = luck;//把luck放到table表索引为3的位置

    System.out.println("table:" + table);
  }

五、HashSet 源码分析(重点)

HashSet的介绍
  • 可以通过构造器看出HashSet底层还是采用HashMap实现的,HashMap底层是(数组+链表+红黑树)
    public HashSet() {
      map = new HashMap<>();
    }
  • 可以存放null值,但是只能有一个null 3、HashSet不保证元素是有序的,取决于hash后,再确定索引的结果,即不保证存放元素的顺序和取出顺序的一致 4、不能有重复的元素/对象
HashSet#add方法源码分析
  1. HashSet底层是HashMap
  2. 添加一个元素时,先得到hash值–>会转成–>索引值
  3. 找到存储数据表table,看这个索引位置是否已经存放的有元素
  4. 如果没有,直接加入
  5. 如果有,调用equals(具体对象调用具体对象的equals方法,比较的不一定就是内容)比较,如果相同,就放弃添加,如果不相同,则添加到最后
  6. 在Java1.8中,如果一个链表的元素个数超过TREEIFY_THRESHOLD(默认是8),并且table的大小>=MIN_TREEIFY_CAPACITY(默认64)就会进行树化(红黑树)
HashMap是如何存放元素的呢

HashMap存储结构

元素存放过程分析

当我们向HashMap中存放数据时,首先会根据key的hashCode方法计算出值,然后结合数组长度和算法(如取余法、位运算等)来计算出向数组存放数据的位置索引值。如果索引位置不存在数据的话则将数据放到索引位中;如果索引位置已经存在数据,这时就发生了hash碰撞(两个不同的原始值在经过哈希运算后得到相同的结果),为了解决hash碰撞,JDK1.8前用的是数组+链表的形式,而JDK1.8后用的是数组+链表+红黑树,这里以链表为例。由于该位置的hash值和新元素的hash值相同,这时候要比较两个元素的内容如果内容相同则进行覆盖操作,如果内容不同则继续找链表中的下一个元素进行比较,以此类推如果都没重复的则在链表中新开辟一个空间存放元素。以上图为例,假设张三、王五、赵六、王五对应的数组数组下标是1,李四对应的数组下标是2。刚开始索引位置1为空,(张三,15)直接放入1位置,索引位置 2为空,(李四,22)直接放入2位置,(王五,18)发现索引位
置1已经有数据,这时候调用equals方法和张三进行内容比较,发现内容不同,链表开辟新空间存放数据;(赵六,25)发现1位置已经有元素,调用equals和张三比较,内容不同,继续向下和王五比较,内容不同,开辟新空间存放数据;(王五,28)发现1位置已经有元素,调用equals和张三进行内容比较,不同,继续向下和(王五,18)比较,发现内容也相同,这时候则进行覆盖操作将原来的(王五,18)改成(王五,28)

HashMap在JDK1.8和JDK1.7的区别

HashMap在1.7和1.8中最大的区别就是底层数据结构的变化,在1.7中HashMap采用的底层数据结构是数组+链表的形式,而在1.8中HashMap采用的是数组+链表+红黑树的数据结构(当链表长度大于8且数组长度大于等于64时链表会转成红黑树,当长度低于6时红黑树又会转成链表),红黑树是一种平衡二叉搜索树,它能通过左旋、右旋、变色保持树的平衡。之所以用红黑树是因为他能够大大提高查找效率,链表的时间复杂度是O(n)而红黑树的时间复杂度是O(logn),那么为啥要链表长度大于8且数组长度大于64才转成红黑树呢,简单来说就是节点太少的时候没必要转换数据结构,因为不仅转换数据结构需要浪费时间同时也要浪费空间。

分析HashSet的扩容和转成红黑树机制
  1. HashSet底层是HashMap,第一次添加时,table数组扩容到16,临界值(threshold)是16*加载因子(loadFactor)0.75 = 12
  2. 如果table数组使用到了临界值12,就会扩容到16 * 2 = 32,新的临界值就是32 * 0.75 = 24,依次类推。
  3. 在Java8中,如果一条链表得元素个数到达TREEIFY_THRESHOLD(默认是8),并且table的大小 >= MIN_TREEIFY_CAPACITY(默认是64),就会进行树化(红黑树),否则仍然采用数组的扩容机制。

注意:其加载因子的作用是放置大量线程进来填充数据的时候,防止没有容量做的提前扩容

那么为啥要链表长度大于8且数组长度大于64才转成红黑树呢?

简单来说就是节点太少的时候没必要转换数据结构,因为不仅转换数据结构需要浪费时间同时也要浪费空间。
不直接一直用红黑树呢?这是因为树的结构太浪费空间,只有节点足够多的情况下用树才能体现出它的优势,而如果在节点数量不够多的情况下用链表的效率更高,占用的空间更小

为什么不直接一直用红黑树呢?

这是因为树的结构太浪费空间,只有节点足够多的情况下用树才能体现出它的优势,而如果在节点数量不够多的情况下用链表的效率更高,占用的空间更小

附代码

public static void main(String[] args) {
    //说明
    //1、在执行add方法后,会返回一个Boolean的
    //2、如果添加成功,返回true,否则返回false
    //3、可以通过remove指定删除那个对象
    HashSet hashSet = new HashSet();
    System.out.println(hashSet.add("jack"));//T
    System.out.println(hashSet.add("lucy"));//T
    System.out.println(hashSet.add("jack"));//F
    
    //TODO HashSet#add方法 源码分析(重点)
    /**
     * 1.TODO 执行
     *  HashSet() public HashSet() {
     *    map = new HashMap<>();
     *   }
     * 2.TODO 执行第一次 hashSet.add("jack")
     *  public boolean add(E e) {//e = "jack"
     *    return map.put(e, PRESENT)==null;// (static)static final Object PRESENT = new Object()仅仅用于占位;
     *  }
     * 3、TODO 执行put方法
     *  public V put(K key, V value) { key = "jack"  value = PRESENT(共享)
     *     return putVal(hash(key), key, value, false, true);
     *  }
     * 4、TODO 调用hash(),计算key对应的hash值:(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16),由此可看,key的hash值不等于key的hashCode
     *
     * 5、TODO 执行putVal方法逻辑分析
     * final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
     *                    boolean evict) {
     *         //TODO 定义了辅助变量
     *         Node<K,V>[] tab; Node<K,V> p; int n, i;
     *         //TODO table是HashMap的一个属性,是一个Node[]数组
     *         //TODO if语句表示当前table是null,或者大小-0,就是第一次扩容,到16个空间
     *         if ((tab = table) == null || (n = tab.length) == 0)
     *             n = (tab = resize()).length;
     *         //TODO (1) 根据key,得到hash ,去计算该key应该存放到table表的那个索引位置,并把这个位置的对象,赋值给p
     *         //TODO (2) 再判断p是否为null,
     *         //TODO (2.1) 如果p为null,表示没有存放过元素,就创建一个Node(key ="jack",value =PRESENT )
     *         //TODO (2.2) 就放在该位置 tab[i] = newNode(hash,key value,null)
     *         if ((p = tab[i = (n - 1) & hash]) == null)
     *             tab[i] = newNode(hash, key, value, null);
     *         else {
     *         //如果当前索引位置上有结点数据了,就会进行以下复杂的判断
     *             Node<K,V> e; K k;//开发技巧提示:在需要的地方创建辅助(局部)变量
     *             //p.hash == hash:如果当前索引位置对应的链表的第一个元素和准备添加的key的hash值一样
     *             //并且满足下面两个条件之一,
     *             //1、准备加入的key和p指向的Node结点的key是同一个对象
     *             //2、或者p指向的Node结点的key的equals()和准备加入的key比较后相同,(针对对象)
     *             //就不能往里面添加/
     *             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);
     *             else {//处理table当前索引下是链表数据,就使用for循环比较
     *             //(1)、依次和该链表的每一个元素比较后,都比相同,则加入到该链表的最后
     *                 for (int binCount = 0; ; ++binCount) {//死循环
     *                      //判断链表下一个元素是不是null,如果为null,说明后面没有元素了
     *                     if ((e = p.next) == null) {
     *                         p.next = newNode(hash, key, value, null);
     *                         //TODO 把元素添加到链表后,立刻判断该链表是否已经达到8个结点,如果达到,就调用treeifyBin对当前这个链表进行树化(红黑树)
     *                         //TODO 注意:在转成红黑树时,要进行判断,其条件是:if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
     *                         //TODO 如果上面条件成立,会对table扩容,调用resize()
     *                         //TODO 只有当上面条件成立时,才进行转成红黑树
     *                         if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
     *                             treeifyBin(tab, hash);
     *                         break;
     *                     }
     *                     //如果链表下一个元素不是null,则比较以下两个条件(跟上面的条件一样):如果满足条件,就break
     *                    //1、准备加入的key和p指向的Node结点的key是同一个对象
     *                    //2、或者p指向的Node结点的key的equals()和准备加入的key比较后相同,(针对对象)
     *                     if (e.hash == hash &&
     *                         ((k = e.key) == key || (key != null && key.equals(k))))
     *                         //break这一刻,相同的就是e这个元素
     *                         break;
     *                     //如果上面条件都不满足,将e指向p。相当于指向下一个结点,继续循环判断
     *                     p = e;
     *                 }
     *             }
     *             if (e != null) { // existing mapping for key
     *                 V oldValue = e.value;
     *                 if (!onlyIfAbsent || oldValue == null)
     *                     e.value = value;
     *                 afterNodeAccess(e);
     *                 return oldValue;
     *             }
     *         }
     *         ++modCount;
     *         //TODO 判断是否需要扩容
     *         if (++size > threshold)
     *             resize();
     *         //TODO afterNodeInsertion方法是HashMap留给他的子类(比如:LinkedHashMap)使用的,在此处是空实现
     *         afterNodeInsertion(evict);
     *         //TODO 返回null,说明插入成功,于add方法:return map.put(e, PRESENT)==null对应
     *         return null;
     *     }
     * 6、TODO 执行第二次 hashSet.add("lucy")
     *  同上2
     * 7、TODO 执行put方法
     *  同上3
     * 8、TODO 调用hash()
     *  同上4
     * 9、TODO 执行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;
     *       if ((tab = table) == null || (n = tab.length) == 0)
     *           n = (tab = resize()).length;
     *       if ((p = tab[i = (n - 1) & hash]) == null)
     *           tab[i] = newNode(hash, key, value, null);
     *       else {
     *           Node<K,V> e; K k;
     *           if (p.hash == hash &&
     *               ((k = p.key) == key || (key != null && key.equals(k))))
     *               e = p;
     *           else if (p instanceof TreeNode)
     *               e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
     *           else {
     *               for (int binCount = 0; ; ++binCount) {
     *                   if ((e = p.next) == null) {
     *                       p.next = newNode(hash, key, value, null);
     *                       if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
     *                           treeifyBin(tab, hash);
     *                       break;
     *                   }
     *                   if (e.hash == hash &&
     *                       ((k = e.key) == key || (key != null && key.equals(k))))
     *                       break;
     *                   p = e;
     *               }
     *           }
     *           if (e != null) { // existing mapping for key
     *               V oldValue = e.value;
     *               if (!onlyIfAbsent || oldValue == null)
     *                   e.value = value;
     *               afterNodeAccess(e);
     *               return oldValue;
     *           }
     *       }
     *       ++modCount;
     *       //TODO size就是每加入一个结点Node(k,v,next),size++;
     *       if (++size > threshold)
     *           resize();
     *       afterNodeInsertion(evict);
     *       return null;
     *   }
     *
     *
     *
     */
}
HashSet添加元素,调用对象的hashCode方法,计算对应的hash值,来决定
    HashSet hashSet = new HashSet();
    hashSet.add("lucy");//添加成功
    hashSet.add("lucy");//添加不成功
    hashSet.add(new Dog("tom"));//OK
    hashSet.add(new Dog("tom"));//OK
    System.out.println("hashSet=" + hashSet);//打印出来是三个内容

Dog对象

class Dog {

  private String name;

  public Dog(String name) {
    this.name = name;
  }

  @Override
  public String toString() {
    return "Dog{" +
        "name='" + name + '\'' +
        '}';
  }
}

上面两次hashSet.add(new Dog("tom"))之所以都成功,是因为每次new都是一个新的对象,其hash值都不一样。加入对Dog对象重写hash值,
让Dog对象的hash值返回固定值,这样就能促使其进行equals方法判断

经典面试题,有关字符串对象往HashSet中存储
    //再加深一下,非常经典的面试题‘
    //看源码
    HashSet hashSet = new HashSet();
    hashSet.add(new String("hsp"));//OK
    hashSet.add(new String("hsp"));//加入不了,涉及到字符串常量池问题
    System.out.println("hashSet=" + hashSet);
HashSet的扩容机制1

HashSet底层是HashMap,第一次添加时,table数组扩容到16,临界值(threshold)是16*加载因子(loadFactor)0.75 = 12 如果table数组使用到了临界值12,就会扩容到16 * 2 = 32,新的临界值就是32 * 0.75 = 24,依次类推。

  private static void testLoadFactor() {
    HashSet<Object> hashSet = new HashSet<>();
    for (int i = 0; i < 1000; i++) {
      //观察当添加到第13个、第24个元素的时候发生的变化即可发现扩容机制的奥秘
      hashSet.add(i);
    }
  }
HashSet的扩容机制2

创建内部类A、B,并重写hashCode方法

class A {

  private int n;

  public A(int n) {
    this.n = n;
  }

  //重写A对象的hashCode方法,固定返回100.这样就会导致追加到HashSet上的数据都会追加到一个链表上
  @Override
  public int hashCode() {
    return 100;
  }
}

class B{

  private int n;

  public B(int n) {
    this.n = n;
  }

  /**
   * 重写B对象的hashCode方法,固定返回200.这样就会导致追加到HashSet上的数据都会追加到一个链表上
   * @return
   */
  @Override
  public int hashCode() {
    return 200;
  }
}

触发扩容机制的,是元素的总个数。跟在不在一个链表无关

private static void testHashSetIncrement() {
    HashSet<Object> hashSet = new HashSet<>();
    for (int i = 0; i <= 7; i++) {//在hashSet的某一条链表上添加了7个A对象
      hashSet.add(new A(i));
    }

    for (int i = 0; i <= 7; i++) {//在hashSet的另外一条链表上添加了7个B对象
      hashSet.add(new B(i));//当hashSet增加到第13个元素的时候,就会触发HashSet数组的扩容机制,调用resize()方法
    }
  }

HashSet中链表如何转换成树型结构(红黑树)

在Java1.8中,如果一个链表的元素个数超过TREEIFY_THRESHOLD(默认是8),并且table的大小>=MIN_TREEIFY_CAPACITY(默认64)就会进行树化(红黑树)

  private static void testTreeifyThreshold() {
    HashSet<Object> hashSet = new HashSet<>();
    for (int i = 0; i < 12; i++) {
      hashSet.add(new A(i));
    }
    System.out.println("hashSet=" + hashSet);
  }

六、LinkedHashSet 源码分析(重点)

LinkedHashSet的全面说明
  1. LinkedHashSet是HashSet的子类
  2. LinkedHashSet底层是一个LinkedHashMap,底层维护了一个数组+双向链表
  3. LinkedHashSet根据元素的hashCode值来决定元素的存储位置,同时使用链表维护元素的次序(图),这使得元素看起来是以插入顺序保存得
  4. LinkedHashSet不允许添加重复元素
LinkedHashSet底层机制

1、LinkedHashSet加入顺序和取出元素的顺序是一致的
2、LinkedHashSet底层维护的是LinkedHashMap(是HashMap的子类)
3、LinkedHashSet底层结构维护的是(数组+双向链表)
4、添加第一次时,直接将数组table扩容到16,存放的结点类型是LinkedHashMapEntry(跟HashSet底层不一样,HashSet底层采用的Node),由此可猜出Entry实现或继承了Node(多态)5、table数组是HashMapEntry(跟HashSet底层不一样,HashSet底层采用的Node),由此可猜出Entry实现或继承了Node(多态) 5、table数组是HashMapEntry(跟HashSet底层不一样,HashSet底层采用的Node),由此可猜出Entry实现或继承了Node(多态)5table数组是HashMapNode[],其存放的数据/元素是LinkedHashMap$Entry类型
6、LinkedHashMap中维护了一个静态内部类Entry,继承了HashMap.Node类(继承关系是在内部类中发生的),Node在HashMap属于静态内部类,因为它是通过类调用的

  static class Entry<K,V> extends HashMap.Node<K,V> {
     //TODO 此处就是实现双向链表的关键
     Entry<K,V> before, after;
     Entry(int hash, K key, V value, Node<K,V> next) {
         super(hash, key, value, next);
     }
 }

附代码

内部类Customer创建

 class Customer {

  private String name;

  private Integer number;

  public Customer(String name, Integer number) {
    this.name = name;
    this.number = number;
  }

  @Override
  public String toString() {
    return "Customer{" +
        "name='" + name + '\'' +
        ", number=" + number +
        '}';
  }
}

代码测试LinkedHashSet添加(建议Debug模式调试)

  public static void underlyingMechanism() {
    Set<Object> set = new LinkedHashSet<>();
    set.add(new String("AA"));
    set.add(456);
    set.add(456);
    set.add(new Customer("刘", 1001));
    set.add(123);
    set.add("HSP");

    System.out.println("set=" + set);
  }

到此为止,Set相关实现类源码分析到此为止

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

双木林L

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

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

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

打赏作者

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

抵扣说明:

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

余额充值