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一样,照样会都添加进去。
posted @ 2021-11-11 11:14  初晨~  阅读(67)  评论(0编辑  收藏  举报