zoukankan      html  css  js  c++  java
  • Java学习笔记 HashSet源码分析

    HashSet概述

    Hashset 实现 set 接口,底层是基于 HashMap 实现并且使用 HashMap 来保存所有元素,但与 HashMap 不同的是 HashMap 存储键值对,HashSet仅存储对象,也就是把将要存的对象放到key部分,而value部分直接给一个空Object。

    HashSet 使用存放的对象也是Key来计算 HashCode 值。

    构造函数:

    public HashSet() {
        map = new HashMap<>();
    }
    

    HashSet属性

    HashSet底层使用的HashMap,数据是存放在了一个 数组+单项链表 的数据结构上边了,如下:

    image-20211110170305453

    数组类型为节点Node,每一个位置存放一个节点,节点有数据域和next指针域,指向下一个节点,构成单向链表。

    属性如下:

    // 声明HashMap集合
    private transient HashMap<E,Object> map;
    
    // Dummy value to associate with an Object in the backing Map
    private static final Object PRESENT = new Object();
    

    PRESENT就是和key对应的value值,是一个虚拟的,没啥用处,因为HashSet存放只存放对象,而底层又用的HashMap,所以value就废了。

    HashMap的属性:

    // The default initial capacity - MUST be a power of two.
    // 默认初始化容量
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    
    // The load factor used when none specified in constructor.
    // 默认的加载因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    
    // 可以树形化容器的最小表容量
    static final int MIN_TREEIFY_CAPACITY = 64;
    
    // 阈值
    static final int TREEIFY_THRESHOLD = 8;
    
    // 存放Node节点的数组
    transient Node<K,V>[] table;
    
    // 获取HashMap中的key部分,返回值Set类型
    transient Set<Map.Entry<K,V>> entrySet;
    
    // 集合中节点数量
    transient int size;
    
    // 集合修改次数
    transient int modCount;
    
    // 容量乘以加载因子所得结果,如果key-value的数量达到该值,则调用resize方法,扩大容量,同时修改threshold的值。
    // 比如刚开始 DEFAULT_INITIAL_CAPACITY * 0.75 = 12
    int threshold;
    
    // 加载因子。
    final float loadFactor;
    

    如下分析:

    1. DEFAULT_INITIAL_CAPACITY为默认初始化容量,也就是第一次添加数据,数组扩容为16。

    2. DEFAULT_LOAD_FACTOR为默认加载因子,通过源码发现如果创建HashMap集合对象,loadFactor默认等于12,如下:

      public HashMap() {
          this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
      }
      
    3. table就是存放数据的数组,每个位置存放一个节点,也有可能挂着一个单项链表。

    4. MIN_TREEIFY_CAPACITY为可以树形化容器的最小table容量,默认为64,TREEIFY_THRESHOLD为阈值,默认为8,这两个属性联合使用,主要用在扩容机制,当数组中某一个位置的单向链表的节点数量到达TREEIFY_THRESHOLD后,就会将该单项链表进行树化,转换为红黑树结构,但是有个条件,那就是数组的容量大小必须达到MIN_TREEIFY_CAPACITY,也就是64,如果没达到,就会对数组扩容,然后继续判断,如果容量还没达到,继续扩容,当数组容量达到该值后,就会调用相关方法,对该链表进行树化。

    5. entrySet存放的是HashMap中的键,对应的就是存放在HashSet中的对象值。

    6. threshold也是阈值,以判断数组是否需要扩容,它是容量乘以加载因子所得结果,如首次添加数据数组扩容到了默认初始容量16,那么threshold = 16 * 0.75 = 12,当数组容量到达12这个阈值,数组大小将会扩容到16 * 2 = 32,此时threshold = 32 * 0.75 = 24,当数组容量到达24时就会继续扩容到 32 * 2 = 64,此时threshold = 64 * 0.75 = 48,以此类推。

    HashSet原理

    首次添加数据

    编写Java代码如下:

    Set<String> set = new HashSet<>();
    set.add("张三");
    

    首次实例化HashSet集合对象,底层实例化HashMap对象,然后调用add()方法,添加数据:

    public boolean add(E e) {
        return map.put(e, PRESENT)==null;
    }
    

    这里返回的结果Boolean类型,也就是说如果方法结束后返回null,说明添加成功。

    底层调用的就是是HashMap中的put()方法,并且value的位置传入的就是虚拟值PRESENT,继续跟进:

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
    

    这里调用了putVal()方法,进行存值,需要注意的是,在存值之前首先将key作为参数,调用了hash()方法:

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
    

    可以看到,内部调用key的hashCode()方法获取hash值,然后通过位运算返回一个int类型的值。

    拿到hash值进入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;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }
    

    如下分析:

    1. 第一行代码定义了一些辅助变量:

      Node<K,V>[] tab; Node<K,V> p; int n, i;
      
    2. 接着到达判断语句,并且将table赋值给了tab,将table.length赋值给了变量n:

      if ((tab = table) == null || (n = tab.length) == 0){
          n = (tab = resize()).length;
      }	
      

      这里非常关键,第一次添加数据,table为null,所以tab也为null,则n = tab.length = 0,所以该判断成立,调用resize()方法进行扩容,将扩容后的结果重新给tab赋值,并将扩容后的数组容量大小重新赋值给变量n。

    3. 进入到resize()方法,由于代码过多,只看主要代码即可:

      // 首先将table数组赋值给了变量oldTab
      Node<K,V>[] oldTab = table;
      // 判断是否为空,如果不为空,将长度赋值给oldCap,如果为空,则赋值0
      int oldCap = (oldTab == null) ? 0 : oldTab.length;
      // 将默认阈值赋值给oldThr
      int oldThr = threshold;
      // 定义两个新的变量
      int newCap, newThr = 0;
      

      由于是第一次添加数据,数组一定为空,所以oldCap = 0,oldThr = 0.75。

    4. 接着进行判断,前两个条件都不成立,到达最后的else:

      if (oldCap > 0) {
         // 略...
      }else if (oldThr > 0){
         // 略...
      }else {               // zero initial threshold signifies using defaults
          newCap = DEFAULT_INITIAL_CAPACITY;
          newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
      }
      

      可以看到,这里设置新容量newCap = DEFAULT_INITIAL_CAPACITY,也就是16,新阈值newThr = 16 * 0.75 = 12。

    5. 继续往下走,开始初始化赋值:

      // 将新的阈值赋值给threshold,第一次等于12,第二次等于24.....
      threshold = newThr;
      // 创建一个新数组,大小就是16
      Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
      // 赋值给table
      table = newTab;
      
    6. 最后返回新数组:

      return newTab;
      
    7. 回到putVal()方法,进行下一个判断:

      if ((p = tab[i = (n - 1) & hash]) == null)
          tab[i] = newNode(hash, key, value, null);
      

      这里边有一个算法,也就是(n - 1) & hash,它最终返回的结果就是数组的下标,并且赋值给了变量i,然后通过下标取出该位置的节点值赋值给变量p,最后判断是否为null,其实就是判断该位置有没有节点已存在,如果没有,直接创建节点,放到该位置。由于是第一次添加,数组中所有位置都为null,所以这里直接就将新节点放到这里了。

    8. 接着else就不会走了,直接来到最后return null,那么add()方法return map.put(e, PRESENT)==null返回的就是true,添加失功。

    所以得出结论:首次添加数据,调用key的hashCode()方法获取哈希值,然后判断数组是否为空,最后将数据扩容到16的大小,阈值初始化为12,通过算法获取将哈希值转换为数组下标,也就是找到对应的存放位置,然后放到该位置。

    再次添加数据

    set.add("李四");
    set.add("李四");
    

    再次进入到putVal()方法:

    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);
    

    第一个判断直接跳过,因为table里边已经有数据了,数组大小为16,其中有一个位置存放一个Node节点,数据域为张三

    第二个判断依旧是通过算法找到位置,并且取出该位置的节点赋值给节点p,判断是否为空,如果成立,直接创建节点放入,如果不为空,继续往下走:

    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);
        // 略...
        }
    

    1、第一个判断:成立的条件是p.hash == hash,也就是该位置已存在节点的hash值和将要添加的新节点的hash值要相等,并且下边两个条件必须满足一个:

    • (k = p.key) == key表示已存在节点的key和新节点的key相同,比较的是地址。
    • (key != null && key.equals(k))表示key不为空,并且equals相同,比较的是内容。

    如果成立,说明添加的重复数据,将已存在节点p赋值给e,直接就结束,如下:

    if (e != null) { // existing mapping for key
        // 首先取出已存在节点的value值,在这里就是一个空Object,如果使用的hashmap添加数据,value值就是我们添加的value值。
        V oldValue = e.value;
        // onlyIfAbsent这个参数的作用在于,如果我们传入的key已存在我们是否去替换,true:不替换,false:替换。
        if (!onlyIfAbsent || oldValue == null)
            e.value = value;
        afterNodeAccess(e);
        return oldValue;
    }
    

    内部判断左边的条件也成立,onlyIfAbsent默认为false,取反为true,里边就是e.value = value,从上边代码可以看出,如果存放的数据已存在,那就会覆盖value值,就算value值为null,并不会覆盖key值。

    最后返回已存在节点的value值,也就是方法最终返回的不是null,那么add()方法return map.put(e, PRESENT)==null返回的就是false,添加失败,所以HashSet集合数据不可重复。

    2、如果第一个条件不成立,就说明该位置已存在的节点和我们这次要添加的节点不同,接下来就是要判断该位置的单项链表的每一个节点,进行比对,注意:是从链表的第二个节点开始,第一个已经比对过了,不成立,并且赋值给了节点p。

    首先到达:else if (p instanceof TreeNode),这里判断该位置对应的是不是红黑树,还是链表,如果是树结构,则按照树结构的方式变量查询。

    3、如若不是,继续往下走,说明该位置有节点,但是不同,所以要判断链表上每一个节点,到达else里边:

    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;
            }
        }
    

    这里边就是循环,查询单链表每一个节点和将要添加的节点进行比对,如果某一个比对成功,直接break,如果一直到最后p.next为null,则说明该链表上每一个节点都和新节点不同,最后添加到链表的末尾p.next = newNode(hash, key, value, null)

    另外:接着的判断if (binCount >= TREEIFY_THRESHOLD - 1)就是判断是否到达了指定阈值,也就是链表的长度如果达到8,就转为红黑树结构。

    接着,下方的判断就不成立了:

    if (e != null) {
     // 略...
    }
    

    最后返回:

    ++modCount;
    // 判断是否需要扩容
    if (++size > threshold)
        resize();
    // 里边啥都没有,留给子类重写
    afterNodeInsertion(evict);
    return null;
    

    所以:

    1. HashSet底层使用的是HashMap,value值是一个空Object。
    2. HashSet存放数据是无序不可重复的,不一定放到那个位置了,或者挂在那个链表的末尾了,另外,如果链表节点的个数到达阈值,并且数组容量也达到64,就会扩容,并且更新阈值threshold。
    3. HashSet存放的对象必须重写equals()和hashCode()方法,不然每次添加都会调用对象的hashCode()返回的哈希值都不一样,而如果重写了equals()没有重写hashCode(),那么两个对象equals一样,照样会都添加进去。
  • 相关阅读:
    ASP.NET(C#)图片加文字、图片水印
    CMake构建Visual Studio中MFC项目的Unicode问题
    用Visual Studio 2008(VS)编译WebKit的r63513
    此时学习中
    ASP.NET进阶——初学者的提高(长期)
    继续努力
    程序员阿士顿的故事
    iOS 深拷贝和浅拷贝
    Javascript中this的取值
    Lisp的本质(The Nature of Lisp)
  • 原文地址:https://www.cnblogs.com/dcy521/p/15538989.html
Copyright © 2011-2022 走看看