HashSet是一個包含非重復元素的集合,如何實現的,要從底層實現代碼看起。
背景
首先非重復元素如何定義,看Set的描述:
More formally, sets contain no pair of elements e1 and e2 such that e1.equals(e2), and at most one null element.
Set不會找到兩個元素,并且兩個元素滿足e1.equals(e2)為true;并且最多只有一個null元素。
如果沒有重寫equals方法,查看Object類中equal方法的實現,==比較的其實是兩個對象在內存中的地址。
public boolean equals(Object obj) {
return (this == obj);
}
說起equals方法,就不得不說hashCode方法了。Java中對于hashCode有個常規協定
The general contract of hashCode is:
- Whenever it is invoked on the same object more than once during an execution of a Java application, the hashCode method must consistently return the same integer, provided no information used in equals comparisons on the object is modified. This integer need not remain consistent from one execution of an application to another execution of the same application.
- If two objects are equal according to the equals(Object) method, then calling the hashCode method on each of the two objects must produce the same integer result.
- It is not required that if two objects are unequal according to the equals(java.lang.Object) method, then calling the hashCode method on each of the two objects must produce distinct integer results. However, the programmer should be aware that producing distinct integer results for unequal objects may improve the performance of hash tables.
- 程序執行期間,在同一個對象上執行多次hashCode方法,都返回相同的整數,前提是equals比較中所使用的字段沒有被修改。跨應用中的hashCode方法調用返回的整數不要求相同。
- 如果兩個對象根據equals方法比較相同,那hashCode返回的整數也必須相同。
- 如果兩個對象equals方法比較不相同,調用hashCode返回的整數不需要不同。但是程序員應該知道為不相等的對象生成不同的整數可以提高哈希表的性能。
HashSet的底層實現
HashSet的底層是通過HashMap實現的,將元素作為map的key以達到去重的目的,value使用的是同一個虛擬的Object實例。
private transient HashMap<E,Object> map;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
HashMap的底層實現
到最后我們要看HashMap的實現了,簡單說就是一個數組+鏈表的結合。
- 默認初始容量16
- 默認負荷系數0.75
- Entry數組
- 大小
- 閾值:初始值等于初始容量
- 負荷系數
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final Entry<?,?>[] EMPTY_TABLE = {};
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
transient int size;
int threshold;
final float loadFactor;
Entry元素
Entry是鏈表的結果,key為Map中的key,value為Map中的value,hash為key的hash結果,next為下一個元素。
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
}
添加元素
- 如果數組為空(即map初始化后第一次添加元素)擴充table
- 如果key為null,則調用putForNullKey方法,null位于table的下標0處
- 算出key的hash值
- 通過hash值算出元素在table中的下標值
- 如果該位置元素不為空,然后需要比較元素的hash值和上面算出的hash值是否相等,同時元素的key對象和要出入的key是否為同一對象(相同的地址 ==比較為true)或者equals方法是否為true。如果滿足條件,則更新該entry的value值;若不滿足則遍歷整個鏈表。
- 如果為空直接添加新的entry。
public V put(K key, V value) {
if (table == EMPTY_TABLE) {
inflateTable(threshold);
}
if (key == null)
return putForNullKey(value);
int hash = hash(key);
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(hash, key, value, i);
return null;
}
擴充table
對toSzie算出最小的2的冪值,用了Integer.highestOneBit((toSize -1) << 1)。減一之后左移一位,然后取最高位值,其余為補0。
為什么數組長度必須為2的冪值,請繼續看。
/**
* 擴充table
**/
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
int capacity = roundUpToPowerOf2(toSize);
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
計算hash值
hashSeed值為0,將key的hashCode值做多次位移和異或運算
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
h ^= k.hashCode();
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
計算元素位置
這里的邏輯很簡單:將hash值跟數組長度-1做了按位與。
在進行查找的時候是通過key的hash值,如果我們將元素的位置分布得盡量均勻一些,盡量做到每個位置上只有一個元素,達到O(1)的查找。這種查找通過取余就可以做到,在Java中如何做到比較快的取余呢,答案是位與運算。
上面擴充數組的時候我們保證長度為2的冪值,那減一之后就是每位都是1。做位與運算就能保證低位不同的hash值會落在不同的位置上,降低沖突(碰撞),最大程度做到均勻分布,減少鏈表的出現(查找變成O(n))。
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
return h & (length-1);
}
添加entry
添加新的元素時要檢查元素個數是否達到閾值,否則要做擴容處理,新table的容量為當前table長度的兩倍。
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
resize
新table的容量為當前table長度的兩倍(table.length >= size),將舊數據中的數據遷移到新的數組中,遷移的過程中要重新計算元素在新數組中的位置。網上很多地方提到這個操作rehash,但我覺得reindex反而更恰當一些。JDK中對rehash有額外的定義,就是initHashSeedAsNeeded。當新的容量>=jdk.map.althashing.threshold的配置時,會重新計算key的hash值,即hash(e.key)。
void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
Entry[] newTable = new Entry[newCapacity];
transfer(newTable, initHashSeedAsNeeded(newCapacity));
table = newTable;
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
reindex
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}