深刻分析——HashSet是否真的無序?(JDK8)

HashSet 是否無序

(一) 問題原由:

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

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

解釋:咱們通常說HashSet是無序的,它既不能保證存儲和取出順序一致,更不能保證天然順序(a-z)算法

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

import java.util.*;dom

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

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]this

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

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

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

//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 這也就致使了哈希衝突

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

例子中,兩個不一樣的HashCode值卻通過運算後,獲得了相同的值,也就表明,他們都須要被放在下標爲2的位置

通常來講,若是數據分佈比較普遍,並且存儲數據的數組長度比較大,那麼哈希衝突就會比較少,不然很高。

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

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

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

​ ^ 位異或運算,相同則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技術的公衆號:理想二旬不止

相關文章
相關標籤/搜索