HashSet有序無序問題-深入分析(JDK8)

HashSet 是否無序

(一) 問題起因:

《Core Java Volume I—Fundamentals》中對HashSet的描述是這樣的:

HashSet:一種沒有重複元素的無序集合

解釋:我們一般說HashSet是無序的,它既不能保證存儲和取出順序一致,更不能保證自然順序(a-z)

下面是《Thinking in Java》中的使用Integer對象的HashSet的示例

import java.util.*;

public class SetOfInteger {
public static void main(String[] args) {
Random rand = new Random(47);
Set<Integer> intset = new HashSet<Integer>();
for (int i = 0; i<10000; i++)
intset.add(rand.nextInt(30));
System.out.println(intset);
}
} /* Output:

[15, 8, 23, 16, 7, 22, 9, 21, 6, 1 , 29 , 14, 24, 4, 19, 26, 11, 18, 3, 12, 27, 17, 2, 13, 28, 20, 25, 10, 5, 0]

在0-29之間的10000個隨機數被添加到了Set中,大量的數據是重複的,但輸出結果卻每一個數只有一個實例出現在結果中,並且輸出的結果沒有任何規律可循。 這正與其不重複,且無序的特點相吻合。

看來兩本書的結果,以及我們之前所學的知識,看起來都是一致的,一切就是這麼美好。

隨手運行了一下這段書中的代碼,結果卻讓人大喫一驚

//JDK1.8下 Idea中運行
import java.util.*;

public class SetOfInteger {
    public static void main(String[] args) {
        Random rand = new Random(47);
        Set<Integer> intset = new HashSet<Integer>();
        for (int i = 0; i<10000; i++)
            intset.add(rand.nextInt(30));
        System.out.println(intset);
    }
}

//運行結果
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29]

嗯!不重複的特點依舊吻合,但是爲什麼遍歷輸出結果卻是有序的???

寫一個最簡單的程序再驗證一下:

import java.util.*;

public class HashSetDemo {
    public static void main(String[] args) {

        Set<Integer> hs = new HashSet<Integer>();

        hs.add(1);
        hs.add(2);
        hs.add(3);

        //增強for遍歷
        for (Integer s : hs) {
            System.out.print(s + " ");
        }
    }
}

//運行結果
1 2 3 

我還不死心,是不是元素數據不夠多,有序這只是一種巧合的存在,增加元素數量試試

import java.util.*;

public class HashSetDemo {
    public static void main(String[] args) {
        
        Set<Integer> hs = new HashSet<Integer>();

        for (int i = 0; i < 10000; i++) {
            hs.add(i);
        }

        //增強for遍歷
        for (Integer s : hs) {
            System.out.print(s + " ");
        }
    }
}

//運行結果
1 2 3 ... 9997 9998 9999 

可以看到,遍歷後輸出依舊是有序的

(二) 過程

通過一步一步分析源碼,我們來看一看,這究竟是怎麼一回事,首先我們先從程序的第一步——集合元素的存儲開始看起,先看一看HashSet的add方法源碼:

// HashSet 源碼節選-JKD8
public boolean add(E e) {
    return map.put(e, PRESENT)==null;
}

我們可以看到,HashSet直接調用HashMap的put方法,並且將元素e放到map的key位置(保證了唯一性 )

順着線索繼續查看HashMap的put方法源碼:

//HashMap 源碼節選-JDK8
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

而我們的值在返回前需要經過HashMap中的hash方法

接着定位到hash方法的源碼:

//HashMap 源碼節選-JDK8
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

hash方法的返回結果中是一句三目運算符,鍵 (key) 爲null即返回 0,存在則返回後一句的內容

(h = key.hashCode()) ^ (h >>> 16)

JDK8中 HashMap——hash 方法中的這段代碼叫做 “擾動函數

我們來分析一下:

hashCode是Object類中的一個方法,在子類中一般都會重寫,而根據我們之前自己給出的程序,暫以Integer類型爲例,我們來看一下Integer中hashCode方法的源碼:

/**
 * Returns a hash code for this {@code Integer}.
 *
 * @return  a hash code value for this object, equal to the
 *          primitive {@code int} value represented by this
 *          {@code Integer} object.
 */
@Override
public int hashCode() {
    return Integer.hashCode(value);
}

/**
 * Returns a hash code for a {@code int} value; compatible with
 * {@code Integer.hashCode()}.
 *
 * @param value the value to hash
 * @since 1.8
 *
 * @return a hash code value for a {@code int} value.
 */
public static int hashCode(int value) {
    return value;
}

Integer中hashCode方法的返回值就是這個數本身

注:整數的值因爲與整數本身一樣唯一,所以它是一個足夠好的散列

所以,下面的A、B兩個式子就是等價的

//注:key爲 hash(Object key)參數

A:(h = key.hashCode()) ^ (h >>> 16)

B:key ^ (key >>> 16)

分析到這一步,我們的式子只剩下位運算了,先不急着算什麼,我們先理清思路

HashSet因爲底層使用哈希表(鏈表結合數組)實現,存儲時key通過一些運算後得出自己在數組中所處的位置。

我們在hashCoe方法中返回到了一個等同於本身值的散列值,但是考慮到int類型數據的範圍:-2147483648~2147483647 ,着很顯然,這些散列值不能直接使用,因爲內存是沒有辦法放得下,一個40億長度的數組的。所以它使用了對數組長度進行取模運算,得餘後再作爲其數組下標,indexFor( ) ——JDK7中,就這樣出現了,在JDK8中 indexFor()就消失了,而全部使用下面的語句代替,原理是一樣的。

//JDK8中
(tab.length - 1) & hash;
//JDK7中
bucketIndex = indexFor(hash, table.length);

static int indexFor(int h, int length) {
    return h & (length - 1);
}

提一句,爲什麼取模運算時我們用 & 而不用 % 呢,因爲位運算直接對內存數據進行操作,不需要轉成十進制,因此處理速度非常快,這樣就導致位運算 & 效率要比取模運算 % 高很多。

看到這裏我們就知道了,存儲時key需要通過hash方法indexFor( )運算,來確定自己的對應下標

(取模運算,應以JDK8爲準,但爲了稱呼方便,還是按照JDK7的叫法來說,下面的例子均爲此,特此提前聲明)

但是先直接看與運算(&),好像又出現了一些問題,我們舉個例子:

HashMap中初始長度爲16,length - 1 = 15;其二進制表示爲 00000000 00000000 00000000 00001111

而與運算計算方式爲:遇0則0,我們隨便舉一個key值

        1111 1111 1010 0101 1111 0000 0011 1100
&       0000 0000 0000 0000 0000 0000 0000 1111
----------------------------------------------------
        0000 0000 0000 0000 0000 0000 0000 1100

我們將這32位從中分開,左邊16位稱作高位,右邊16位稱作低位,可以看到經過&運算後 結果就是高位全部歸0,剩下了低位的最後四位。但是問題就來了,我們按照當前初始長度爲默認的16,HashCode值爲下圖兩個,可以看到,在不經過擾動計算時,只進行與(&)運算後 Index值均爲 12 這也就導致了哈希衝突

17955338-c387f76ffe5cfd48

image

哈希衝突的簡單理解:計劃把一個對象插入到散列表(哈希表)中,但是發現這個位置已經被別的對象所佔據了

例子中,兩個不同的HashCode值卻經過運算後,得到了相同的值,也就代表,他們都需要被放在下標爲2的位置

一般來說,如果數據分佈比較廣泛,而且存儲數據的數組長度比較大,那麼哈希衝突就會比較少,否則很高。

但是,如果像上例中只取最後幾位的時候,這可不是什麼好事,即使我的數據分佈很散亂,但是哈希衝突仍然會很嚴重。

別忘了,我們的擾動函數還在前面擱着呢,這個時候它就要發揮強大的作用了,還是使用上面兩個發生了哈希衝突的數據,這一次我們加入擾動函數再進行與(&)運算

17955338-b4355fcd85670633

image

補充 :>>> 按位右移補零操作符,左操作數的值按右操作數指定的爲主右移,移動得到的空位以零填充

​ ^ 位異或運算,相同則0,不同則1

可以看到,本發生了哈希衝突的兩組數據,經過擾動函數處理後,數值變得不再一樣了,也就避免了衝突

其實在擾動函數中,將數據右位移16位,哈希碼的高位和低位混合了起來,這也正解決了前面所講 高位歸0,計算只依賴低位最後幾位的情況, 這使得高位的一些特徵也對低位產生了影響,使得低位的隨機性加強,能更好的避免衝突

到這裏,我們一步步研究到了這一些知識

HashSet add() → HashMap put() → HashMap hash() → HashMap (tab.length - 1) & hash;

有了這些知識的鋪墊,我對於剛開始自己舉的例子又產生了一些疑惑,我使用for循環添加一些整型元素進入集合,難道就沒有任何一個發生哈希衝突嗎,爲什麼遍歷結果是有序輸出的,經過簡單計算 2 和18這兩個值就都是2

(這個疑惑是有問題的,後面解釋了錯在了哪裏)

//key = 2,(length -1) = 15

h = key.hashCode()      0000 0000 0000 0000 0000 0000 0000 0010 
h >>> 16                0000 0000 0000 0000 0000 0000 0000 0000
hash = h^(h >>> 16)     0000 0000 0000 0000 0000 0000 0000 0010
(tab.length-1)&hash     0000 0000 0000 0000 0000 0000 0000 1111
                        0000 0000 0000 0000 0000 0000 0000 0010 
-------------------------------------------------------------
                        0000 0000 0000 0000 0000 0000 0000 0010

//2的十進制結果:2
//key = 18,(length -1) = 15

h = key.hashCode()      0000 0000 0000 0000 0000 0000 0001 0010 
h >>> 16                0000 0000 0000 0000 0000 0000 0000 0000
hash = h^(h >>> 16)     0000 0000 0000 0000 0000 0000 0001 0010
(tab.length-1)&hash     0000 0000 0000 0000 0000 0000 0000 1111
                        0000 0000 0000 0000 0000 0000 0000 0010 
-------------------------------------------------------------
                        0000 0000 0000 0000 0000 0000 0000 0010

//18的十進制結果:2

按照我們上面的知識,按理應該輸出 1 2 18 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 但卻仍有序輸出了

import java.util.*;

public class HashSetDemo {
    public static void main(String[] args) {

        Set<Integer> hs = new HashSet<Integer>();

        for (int i = 0; i < 19; i++) {
            hs.add(i);
        }

        //增強for遍歷
        for (Integer s : hs) {
            System.out.print(s + " ");
        }
    }
}

//運行結果:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 

再試一試

import java.util.*;

public class HashSetDemo {
    public static void main(String[] args) {

        Set<Integer> hs = new HashSet<Integer>();
        
        hs.add(0)
        hs.add(1);
        hs.add(18);
        hs.add(2);
        hs.add(3);
        hs.add(4);
        ......
        hs.add(17)

        //增強for遍歷
        for (Integer s : hs) {
            System.out.print(s + " ");
        }
    }
}

//運行結果:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18

真讓人頭大,不死心再試一試,由與偷懶,就只添加了幾個,就是這個偷懶,讓我發現了新大陸!

import java.util.*;

public class HashSetDemo {
    public static void main(String[] args) {

        Set<Integer> hs = new HashSet<Integer>();

        hs.add(1);
        hs.add(18);
        hs.add(2);
        hs.add(3);
        hs.add(4);

        //增強for遍歷
        for (Integer s : hs) {
            System.out.print(s + " ");
        }
    }
}

//運行結果:
1 18 2 3 4

這一段程序按照我們認爲應該出現的順序出現了!!!

突然恍然大悟,我忽略了最重要的一個問題,也就是數組長度問題

//HashMap 源碼節選-JDK8

/**
* 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;

<< :按位左移運算符,做操作數按位左移右錯作數指定的位數,即左邊最高位丟棄,右邊補齊0,計算的簡便方法就是:把 << 左面的數據乘以2的移動次冪

爲什麼初始長度爲16:1 << 4 即 1 * 2 ^4 =16;

我們還觀察到一個叫做加載因子的東西,他默認值爲0.75f,這是什麼意思呢,我們來補充一點它的知識:

加載因子就是表示哈希表中元素填滿的程度,當表中元素過多,超過加載因子的值時,哈希表會自動擴容,一般是一倍,這種行爲可以稱作rehashing(再哈希)。

加載因子的值設置的越大,添加的元素就會越多,確實空間利用率的到了很大的提升,但是毫無疑問,就面臨着哈希衝突的可能性增大,反之,空間利用率造成了浪費,但哈希衝突也減少了,所以我們希望在空間利用率與哈希衝突之間找到一種我們所能接受的平衡,經過一些試驗,定在了0.75f

現在可以解決我們上面的疑惑了

數組初始的實際長度 = 16 * 0.75 = 12

這代表當我們元素數量增加到12以上時就會發生擴容,當我們上例中for循環添加0-18, 這19個元素時,先保存到前12個到第十三個元素時,超過加載因子,導致數組發生了一次擴容,而擴容以後對應與(&)運算的(tab.length-1)就發生了變化,從16-1 變成了 32-1 即31

我們來算一下

//key = 2,(length -1) = 31

h = key.hashCode()      0000 0000 0000 0000 0000 0000 0001 0010 
h >>> 16                0000 0000 0000 0000 0000 0000 0000 0000
hash = h^(h >>> 16)     0000 0000 0000 0000 0000 0000 0001 0010
(tab.length-1)&hash     0000 0000 0000 0000 0000 0000 0011 1111 
                        0000 0000 0000 0000 0000 0000 0000 0010     
-------------------------------------------------------------
                        0000 0000 0000 0000 0000 0000 0000 0010

//十進制結果:2
//key = 18,(length -1) = 31

h = key.hashCode()      0000 0000 0000 0000 0000 0000 0001 0010 
h >>> 16                0000 0000 0000 0000 0000 0000 0000 0000
hash = h^(h >>> 16)     0000 0000 0000 0000 0000 0000 0001 0010
(tab.length-1)&hash     0000 0000 0000 0000 0000 0000 0011 1111 
                        0000 0000 0000 0000 0000 0000 0000 0010     
-------------------------------------------------------------
                        0000 0000 0000 0000 0000 0000 0001 0010

//十進制結果:18

當length - 1 的值發生改變的時候,18的值也變成了本身。

到這裏,才意識到自己之前用2和18計算時 均使用了 length -1 的值爲 15是錯誤的,當時並不清楚加載因子及它的擴容機制,這纔是導致提出有問題疑惑的根本原因。

(三) 總結

JDK7到JDK8,其內部發生了一些變化,導致在不同版本JDK下運行結果不同,根據上面的分析,我們從HashSet追溯到HashMap的hash算法、加載因子和默認長度。

由於我們所創建的HashSet是Integer類型的,這也是最巧的一點,Integer類型hashCode()的返回值就是其int值本身,而存儲的時候元素通過一些運算後會得出自己在數組中所處的位置。由於在這一步,其本身即下標(只考慮這一步),其實已經實現了排序功能,由於int類型範圍太廣,內存放不下,所以對其進行取模運算,爲了減少哈希衝突,又在取模前進行了,擾動函數的計算,得到的數作爲元素下標,按照JDK8下的hash算法,以及load factor及擴容機制,這就導致數據在經過 HashMap.hash()運算後仍然是自己本身的值,且沒有發生哈希衝突。

補充:對於有序無序的理解

集合所說的序,是指元素存入集合的順序,當元素存儲順序和取出順序一致時就是有序,否則就是無序。

並不是說存儲數據的時候無序,沒有規則,當我們不論使用for循環隨機數添加元素的時候,還是for循環有序添加元素的時候,最後遍歷輸出的結果均爲按照值的大小排序輸出,隨機添加元素,但結果仍有序輸出,這就對照着上面那句,存儲順序和取出順序是不一致的,所以我們說HashSet是無序的,雖然我們按照123的順序添加元素,結果雖然仍爲123,但這只是一種巧合而已。

所以HashSet只是不保證有序,並不是保證無序

結尾:

如果內容中有什麼不足,或者錯誤的地方,歡迎大家給我留言提出意見, 蟹蟹大家 !_

如果能幫到你的話,那就來關注我吧!(系列文章均會在公衆號第一時間更新)

在這裏的我們素不相識,卻都在爲了自己的夢而努力 ❤

一個堅持推送原創Java技術的公衆號:理想二旬不止

17955338-e9f6ce65ad9cc5f0

image

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章