1、概述
HashSet是Java集合的Set的实现类,实际上底层它是HashMap,Set是无序的、不可重的.其中无序的含义是:添加到集合中和取出顺序不一致.可以存放null,但是只可以存放一个null.
需要深入了解它,便要从成员变量、构造方法、主要方法深入
2、成员变量
static final long serialVersionUID = -5024744406713321676L; private transient HashMap<E,Object> map; // Dummy value to associate with an Object in the backing Map private static final Object PRESENT = new Object();
transient HashMap<E,Object> map.再次说明HashSet实际上是HashMap的,并且该属性不会被序列化的.
3、构造方法
HashSet提供了五个构造函数:
方式一: /** * Constructs a new, empty set; the backing {@code HashMap} instance has * default initial capacity (16) and load factor (0.75). */ public HashSet() { map = new HashMap<>(); } public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted 0.75 } 方式二: /** * Constructs a new, empty set; the backing {@code HashMap} instance has * the specified initial capacity and the specified load factor. * * @param initialCapacity the initial capacity of the hash map * @param loadFactor the load factor of the hash map * @throws IllegalArgumentException if the initial capacity is less * than zero, or if the load factor is nonpositive */ public HashSet(int initialCapacity, float loadFactor) { map = new HashMap<>(initialCapacity, loadFactor); } 方式三: /** * Constructs a new, empty set; the backing {@code HashMap} instance has * the specified initial capacity and default load factor (0.75). * * @param initialCapacity the initial capacity of the hash table * @throws IllegalArgumentException if the initial capacity is less * than zero */ public HashSet(int initialCapacity) { map = new HashMap<>(initialCapacity); }
其中方式一作用:创建一个空的Set,其中属性map默认初始化容量为16的数组table,加载因子为0.75.怎么突然冒出来了数组???这得需要了解HashMap的结构代码
HashMap的实现是使用(数组+链表+红黑树)类似如图:
HashSet无参构造方法初始化map默认容量为16的数组.加载因子是0.75(计算扩容使用的临界值),加载因子是确定下次数组table扩容的阈值,不是一定要添加元素时候加到16才开始扩容,而是在添加到16*0.75=12时就开始扩容。
这里也没用到红黑树呀,怎么说它使用了(数组+链表+红黑树)呢???其实是链表的数据数达到某值,就会把链表转为红黑树。
无参构造方法可以看出实例化一个容量为16的HashMap,其中加载因子loadFactor是默认0.75,threshold = 加载因子 * 容量大小.当存储到threshold临界值,容器自动扩容reSize()方法,扩容为2倍.
其实在创建HashSet对象时(使用无参构造)并未创建链表数组的容量为16,只是在第一次添加元素时,才会调用 resize()就创建一个链表数组长度为16.加载因子是会在创建被确定.resize()方法如下:
final Node<K,V>[] resize() { // 辅助变量 Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; else { // zero initial threshold signifies using defaults // 第一次add元素时 扩容为16 newCap = DEFAULT_INITIAL_CAPACITY; // 临界值 阈值为 16 * 0.75 = 12 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { Node<K,V> e; if ((e = oldTab[j]) != null) { oldTab[j] = null; if (e.next == null) newTab[e.hash & (newCap - 1)] = e; else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // preserve order Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { next = e.next; if ((e.hash & oldCap) == 0) { if (loTail == null) loHead = e; else loTail.next = e; loTail = e; } else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null); if (loTail != null) { loTail.next = null; newTab[j] = loHead; } if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
注意到容量的大小始终是2的幂,在有参构造方法中传入一个initalicapity是3时最后,数组最终长度是4呢???
通过跟着源代码发现:会把传入不是2的幂的转为2的幂的数,在tableSizeFor转为2的次幂的数.
public HashSet(int initialCapacity) { map = new HashMap<>(initialCapacity); } public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); this.loadFactor = loadFactor; this.threshold = tableSizeFor(initialCapacity); } static final int tableSizeFor(int cap) { int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1); return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
为什么链表数组(哈希表)长度必须得2的n次幂??
其实就是保证 位与运算的下标计算结果跟取余运算一致。
3、HashSet的扩容机制(重点)
使用无参构造方法创建HashSet对象为例分析该些过程,主要还是通过deburg方式来分析
public class TestSet { public static void main(String[] args) { Set set = new HashSet(); for(int i = 0;i < 100;i++){ set.add(i); } set.add(1); } }
第一次调用add()方法过程
public boolean add(E e) { return map.put(e, PRESENT)==null; }
其中add()方法还是调用map的put()方法,key作为可变的,其中value (PRESENT )部分是固定的. private static final Object PRESENT = new Object(); 每一个key的value都是一样的.
再看看put()方法内部又是如何操作呢?
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
首先在该方法中有两个操作需要执行,hash(key) 和 putVal(),源码的方法名也是见名知意,hash(key):就是根据key值获取hash值,通过key的 hashCode() ^ (key.hashCode() >>> 16).putVal()把key是否添加到链表中去
具体还是看看源码:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
接下来的putVal()方法可是重头戏(核心代码),需要仔仔细细look:
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 是HashMap的一个属性 链表数组 // 开始 tab 为null if ((tab = table) == null || (n = tab.length) == 0) // 链表数组 扩容为 16大小的长度 n = (tab = resize()).length; // 根据key得到hash 去计算该key应该存放在table 表的哪个索引位置 i // 并把这个位置对象 赋值给 p // 如果p 为null,表示还没有存放元素,就创建一个Node (key=key,value=PRESENT) // 就存放在该位置 tab[i] = newNode(hash, key, value, null); if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { // 如果当前索引位置对应的链表的第一个元素 和 正准备添加的key的hash的值一样 // 并且满足 下面两个条件之一: // (1)准备加入的key 和 p指向的Node 结点 的key 是同一个对象 == // (2) p 指向的Node 结点的key 的equals() 和准备加入的key比较后相同 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; }
代码行数过于多,其中开始扩容时代码:
if ((tab = table) == null || (n = tab.length) == 0) // 链表数组 扩容为 16大小的长度 n = (tab = resize()).length;
下次再次扩容代码:
++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict);
当链表数组长度 size 大于 阈值(threshold = 数组容量 * 加载因子)就再次扩容为之前的2倍. 如果table数组使用到12,就会扩容到 16 * 2 =32,新的阈值就会变为 32 * 0.75 = 24,依次类推.
4、HashSet的添加元素底层分析(重点)
那么数组是怎么存放到链表中去???以及为什么相同的数据不可以再次存入??现在就分析该过程:存入链表中.
抽出关键性代码:
if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null);
- 根据key得到hash 去计算该key应该存放在table 表的哪个索引位置 i; 并把这个位置对象赋值给p,如果p 为null,表示还没有存放元素,就创建一个Node (key=key,value=PRESENT)就存放在该位置 tab[i] = newNode(hash, key, value, null);
- 如果在次计算的table索引位置有元素(不为空),那就得和这个索引的链表中的元素key值进行判断。
代码如下:
Node<K,V> e; K k; if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p;
如果当前索引位置对应的链表的第一个元素 和 正准备添加的key的hash的值一样
并且满足 下面两个条件之一:
(1)准备加入的key 和 p指向的Node 结点 的key 是同一个对象 ==
(2) p 指向的Node 结点的key 的equals() 和准备加入的key比较后相同
后面需要在对该链表后面的结点Node在进行判断,如果一直判断到最后结点也不相同,就把该key添加进去(死循环判断)
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; } }
在该过程中还会判断是否会树化,在比较的过程中,并且还是会再次判断,指针p(p=e)会往后移动;依次和该链表的每一个元素比较后,都不相同,则加入到该链表的最后.
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break;
只有当返回值是null,才是是添加成功,不然就没有加入.
public boolean add(E e) { return map.put(e, PRESENT)==null; }
上面是为哈希表索引存储的是链表情况,如果是红黑树,就会调用putTreeVal来进行添加:
else if (p instanceof TreeNode) e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
该内容过于复杂,再次就不分析了
5、HashSet转红黑树机制
对于HashSet转红黑树的代码测试:
public class TestSet { public static void main(String[] args) { Set set = new HashSet(); for(int i = 0;i < 100;i++){ set.add(new Dog(i)); } } } class Dog { private int age; private String name; public Dog(int age) { this.age = age; } public int getAge() { return age; } public void setAge(int age) { this.age = age; } public String getName() { return name; } public void setName(String name) { this.name = name; } @Override public int hashCode() { return 100; }
跟踪代码发现,在第一次add是判断在哈希表的索引4为null,直接存入链表中,(每一次Dog对象的hashCode,通过计算发现,每次都定位到索引为4处)第二次add方法时,还是定位到索引为4处,这时需要对链表每一个结点的key进行对比判断是否相同,相同就返回oldValue,
直到add执行8次后,发现在执行第九次add时,提取部分源代码分析:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { ... 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; } } }
其中当某个数组的索引处的链表结点数超过8时,就执行treeifyBin()方法,以为这样就开始转为红黑树了??查看treeifyBin() 方法发现
final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); else if ((e = tab[index = (n - 1) & hash]) != null) { TreeNode<K,V> hd = null, tl = null; do { TreeNode<K,V> p = replacementTreeNode(e, null); if (tl == null) hd = p; else { p.prev = tl; tl.next = p; } tl = p; } while ((e = e.next) != null); if ((tab[index] = hd) != null) hd.treeify(tab); } }
table.length的大小必须大于等于64才会转为红黑树的,小于64(MIN_TREEIFY_CAPACITY)table继续扩容为之前的2倍.直到table的长度大于等于64(MIN_TREEIFY_CAPACITY)才会转红黑树.
所以转红黑树条件:链表中的(结点)个数大于等于8,且数组table长度大于等于64,此时链表才会转为红黑树.注意:只要添加到到HashSet表中size++,直接添加到table中也会size++,在数组table的某个索引处的链表中也会size++.
6、HashSet源码分析结论总结
- HashSet底层是Hashmap,key是变化的,value是固定的
- 添加元素时,先通过hashCode计算得到hash值,转为对应的索引值
- 找到存储数据表table(数组),看这个表索引位置是否已经存放元素,如果没有,直接添加,如果有元素,内部会先判断链表的元素 和 要添加的key的hash是否一样,接下来只要满足 要加入的key 和 要判断的结点的key是否同一对象 == 或者 要判断的结点的key的equals 和 准备加入的key比较是否相同,如果不相同就添加到最后(equals一般都是需要重写,hashCode方法也是)
- 如果一个链表的元素个数超过 8 并且 table的长度大于等于 64 就转为红黑树,如果不满足就是仍然采用数组扩容机制
- 扩容机制第一次添加时,table数组扩容到16,临界值(threshold)= 16 * 加载因子 (默认是0.75) =12,当添加元素到12时,就开始自动扩容到32,这时的临界值(threshold)= 32 * 0.75 = 24依次类推
- HashMap底层哈希表的长度必须是2的n次幂,不是2的n次幂也会转为2的n次幂.
- 为什么链表中的个数大于等于8的时候才会转为链表?? 经过空间的权衡,根据泊松分布推算出8最合适.